feat: simplify sync vs promotion mode ui in git sync settings (#6615)

* feat: simplify sync vs promotion mode ui in git sync settings

* improvements
This commit is contained in:
Alexander Petric
2025-09-16 16:28:38 -04:00
committed by GitHub
parent 3bde06f1fd
commit 7707bb8fec
8 changed files with 829 additions and 397 deletions

View File

@@ -6,13 +6,41 @@
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
let { idx } = $props<{ idx: number }>()
let { idx, mode } = $props<{ idx: number, mode?: 'sync' | 'promotion' }>()
const gitSyncContext = getGitSyncContext()
const repo = $derived(gitSyncContext.getRepository(idx))
let targetBranch = $state('main')
// Update target branch when repository changes
$effect(() => {
const abortController = new AbortController()
if (repo?.git_repo_resource_path) {
gitSyncContext.getTargetBranch(repo).then(branch => {
if (!abortController.signal.aborted) {
targetBranch = branch
}
}).catch(error => {
if (!abortController.signal.aborted) {
console.warn('Failed to get target branch:', error)
}
})
}
return () => {
abortController.abort()
}
})
async function handleDetect() {
if (!repo) {
sendUserToast('Repository not found', true)
return
}
try {
await gitSyncContext.detectRepository(idx)
} catch (error: any) {
@@ -46,24 +74,13 @@
}
</script>
{#if repo}
<div class="space-y-4">
{#if !repo.detectionState || repo.detectionState === 'idle'}
<!-- Step 1: Show toggles first, then check button -->
<div class="space-y-3">
<Toggle
disabled={!repo.git_repo_resource_path}
bind:checked={repo.use_individual_branch}
options={{
left: 'Sync mode',
leftTooltip: 'Changes will be committed directly to the branch',
right: 'Promotion mode',
rightTooltip:
"Changes will be made to a new branch per deployed object (prefixed with 'wm_deploy/')"
}}
/>
{#if repo.use_individual_branch}
<!-- Folder grouping option for promotion mode -->
{#if mode === 'promotion'}
<div class="space-y-3">
<Toggle
disabled={!repo.git_repo_resource_path}
bind:checked={repo.group_by_folder}
@@ -73,8 +90,8 @@
'Instead of creating a branch per object, Windmill will create a branch per folder containing objects being deployed.'
}}
/>
{/if}
</div>
</div>
{/if}
<!-- Check repo settings button -->
<div class="flex justify-start">
@@ -113,19 +130,7 @@
useIndividualBranch={repo.use_individual_branch}
/>
<!-- Display mode settings as prominent text -->
<div class="text-base">
{#if repo.use_individual_branch}
<div
><span class="font-bold">Promotion:</span> Creating branches whose promotion target is main</div
>
{#if repo.group_by_folder}
<div class="text-sm text-tertiary mt-1">Grouped by folder</div>
{/if}
{:else}
<div>Sync: <span class="font-bold">Syncing back to branch main</span></div>
{/if}
</div>
<GitSyncModeDisplay {mode} {targetBranch} repository={repo} />
<!-- Initialize button -->
<div class="flex justify-start">
@@ -152,19 +157,7 @@
useIndividualBranch={repo.use_individual_branch}
/>
<!-- Display mode settings as prominent text -->
<div class="text-base">
{#if repo.use_individual_branch}
<div
><span class="font-bold">Promotion:</span> Creating branches whose promotion target is main</div
>
{#if repo.group_by_folder}
<div class="text-sm text-tertiary mt-1">Grouped by folder</div>
{/if}
{:else}
<div>Sync: <span class="font-bold">Syncing back to branch main</span></div>
{/if}
</div>
<GitSyncModeDisplay {mode} {targetBranch} repository={repo} />
<!-- Save connection button -->
<div class="flex justify-start">

View File

@@ -1,5 +1,5 @@
import { getContext, setContext } from 'svelte'
import { JobService, WorkspaceService } from '$lib/gen'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type { GitRepositorySettings as BackendGitRepositorySettings, GitSyncObjectType } from '$lib/gen'
import { jobManager } from '$lib/services/JobManager'
import hubPaths from '$lib/hubPaths.json'
@@ -20,6 +20,10 @@ export type GitSyncRepository = BackendGitRepositorySettings & {
detectionJobStatus?: 'running' | 'success' | 'failure'
// Internal tracking for resource path changes
_trackedPath?: string
// Cached target branch from git resource
_targetBranch?: string
// Detection timestamp to avoid race conditions
_detectionTimestamp?: number
}
export type GitSyncTestJob = {
@@ -68,8 +72,15 @@ export function createGitSyncContext(workspace: string) {
if (repo.isUnsavedConnection) {
const currentPath = repo.git_repo_resource_path
if (repo._trackedPath && repo._trackedPath !== currentPath && repo.detectionState && repo.detectionState !== 'idle') {
_resetRepoDetectionState(repo)
if (repo._trackedPath && repo._trackedPath !== currentPath) {
// Clear cached branch when resource path changes
repo._targetBranch = undefined
// Reset detection state for any non-idle state (including errors)
if (repo.detectionState && repo.detectionState !== 'idle') {
// Cancel any running detection job by resetting immediately
_resetRepoDetectionState(repo)
}
}
repo._trackedPath = currentPath
@@ -273,6 +284,9 @@ export function createGitSyncContext(workspace: string) {
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
// Track the detection timestamp to avoid race conditions from old jobs
const detectionTimestamp = Date.now()
try {
const jobId = await JobService.runScriptByPath({
workspace,
@@ -291,15 +305,21 @@ export function createGitSyncContext(workspace: string) {
repo.detectionJobId = jobId
repo.detectionJobStatus = 'running'
repo._detectionTimestamp = detectionTimestamp
// Use JobManager for polling - result will be the actual job response
await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 30000,
timeoutMessage: 'Detection job timed out after 30s',
timeout: 60000,
timeoutMessage: 'Detection job timed out after 60s',
onProgress: (status) => {
// Only update state if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
repo.detectionJobStatus = status.status
// Process successful detection result
@@ -313,7 +333,7 @@ export function createGitSyncContext(workspace: string) {
if (response.local) {
repo.extractedSettings = response.local
// Auto-apply the extracted settings
repo.settings = {
repo.settings = {
...response.local,
exclude_path: response.local.exclude_path || [],
extra_include_path: response.local.extra_include_path || []
@@ -328,6 +348,11 @@ export function createGitSyncContext(workspace: string) {
}
)
} catch (error: any) {
// Only set error if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
repo.detectionState = 'error'
repo.detectionError = error?.message || error?.toString() || 'Failed to detect repository'
repo.detectionJobStatus = 'failure'
@@ -533,6 +558,128 @@ export function createGitSyncContext(workspace: string) {
}
}
function getPrimarySyncRepository(): { repo: GitSyncRepository, idx: number } | null {
const idx = repositories.findIndex(r => !r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getPrimaryPromotionRepository(): { repo: GitSyncRepository, idx: number } | null {
const idx = repositories.findIndex(r => r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getSecondarySyncRepositories(): { repo: GitSyncRepository, idx: number }[] {
const result: { repo: GitSyncRepository, idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (!repo.use_individual_branch) {
if (foundFirst) {
result.push({ repo, idx })
} else {
foundFirst = true
}
}
})
return result
}
function getLegacyPromotionRepositories(): { repo: GitSyncRepository, idx: number }[] {
const result: { repo: GitSyncRepository, idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (repo.use_individual_branch) {
if (foundFirst) {
result.push({ repo, idx })
} else {
foundFirst = true
}
}
})
return result
}
async function removeRepositoryByPath(resourcePath: string) {
const idx = repositories.findIndex(r => r.git_repo_resource_path === resourcePath)
if (idx !== -1) {
await removeRepository(idx)
}
}
function addSyncRepository() {
repositories.push({
git_repo_resource_path: '',
script_path: hubPaths.gitSync,
use_individual_branch: false,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: ['script', 'flow', 'app', 'folder']
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
function addPromotionRepository() {
repositories.push({
git_repo_resource_path: '',
script_path: hubPaths.gitSync,
use_individual_branch: true,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: ['script', 'flow', 'app', 'folder']
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
// Helper to get target branch from git resource
async function getTargetBranch(repo: GitSyncRepository): Promise<string> {
if (!repo.git_repo_resource_path) {
return 'main'
}
if (repo._targetBranch) {
return repo._targetBranch
}
try {
const resource = await ResourceService.getResource({
workspace,
path: repo.git_repo_resource_path
})
// Extract branch from git resource value
const resourceValue = resource.value as any
const targetBranch = resourceValue?.branch || 'main'
// Cache the result
repo._targetBranch = targetBranch
return targetBranch
} catch (error) {
console.warn('Failed to fetch git resource for branch info:', error)
return 'main'
}
}
// Return context object
return {
// State (read-only access)
@@ -553,7 +700,10 @@ export function createGitSyncContext(workspace: string) {
// Methods
addRepository,
addSyncRepository,
addPromotionRepository,
removeRepository,
removeRepositoryByPath,
getRepository,
getValidation,
revertRepository,
@@ -569,6 +719,15 @@ export function createGitSyncContext(workspace: string) {
closeSuccessModal,
loadSettings,
saveRepository,
// Repository categorization methods
getPrimarySyncRepository,
getPrimaryPromotionRepository,
getSecondarySyncRepositories,
getLegacyPromotionRepositories,
// Helper methods
getTargetBranch,
}
}

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { GitSyncRepository } from './GitSyncContext.svelte'
let {
mode,
targetBranch,
repository
} = $props<{
mode?: 'sync' | 'promotion' | null
targetBranch: string
repository?: GitSyncRepository | null
}>()
</script>
<div class="text-base">
{#if mode === 'promotion'}
<div><span class="font-bold">Promotion:</span> Creating branches whose promotion target is {targetBranch}</div>
{#if repository?.group_by_folder}
<div class="text-sm text-tertiary mt-1">Grouped by folder</div>
{/if}
{:else}
<div><span class="font-bold">Sync:</span> Syncing back to branch {targetBranch}</div>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Save, Trash, XCircle, CheckCircle2, RotateCw, RotateCcw, Download, Upload } from 'lucide-svelte'
import { Save, Trash, XCircle, CheckCircle2, RotateCw, RotateCcw, Download, Upload, Plus } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import { getGitSyncContext } from './GitSyncContext.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
@@ -9,14 +9,58 @@
import { fade } from 'svelte/transition'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import type { GitSyncRepository } from './GitSyncContext.svelte'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
let { idx } = $props<{ idx: number }>()
let {
idx = null,
isSecondary = false,
isLegacy = false,
variant = 'standard',
mode = null,
repository = null,
onAdd = null,
isCollapsible = true,
showEmptyState = false
} = $props<{
idx?: number | null,
isSecondary?: boolean,
isLegacy?: boolean,
variant?: 'primary-sync' | 'primary-promotion' | 'secondary' | 'legacy' | 'standard',
mode?: 'sync' | 'promotion' | null,
repository?: GitSyncRepository | null,
onAdd?: (() => void) | null,
isCollapsible?: boolean,
showEmptyState?: boolean
}>()
const gitSyncContext = getGitSyncContext()
const repo = $derived(gitSyncContext.getRepository(idx))
const validation = $derived(gitSyncContext.getValidation(idx))
const gitSyncTestJob = $derived(gitSyncContext.gitSyncTestJobs?.[idx])
const repo = $derived(repository || (idx !== null ? gitSyncContext.getRepository(idx) : null))
const validation = $derived(idx !== null ? gitSyncContext.getValidation(idx) : null)
const gitSyncTestJob = $derived(idx !== null ? gitSyncContext.gitSyncTestJobs?.[idx] : null)
let confirmingDelete = $state(false)
let targetBranch = $state('main') // Default to main, will be updated when resource is available
// Update target branch when repository changes
$effect(() => {
const abortController = new AbortController()
if (repo?.git_repo_resource_path) {
gitSyncContext.getTargetBranch(repo).then(branch => {
if (!abortController.signal.aborted) {
targetBranch = branch
}
}).catch(error => {
if (!abortController.signal.aborted) {
console.warn('Failed to get target branch:', error)
}
})
}
return () => {
abortController.abort()
}
})
// Compute already-used repository paths to exclude from picker
const usedRepositoryPaths = $derived(
@@ -25,8 +69,28 @@
.filter((path): path is string => Boolean(path?.trim()))
)
// Determine display title based on variant and legacy status
const displayTitle = $derived(
variant === 'primary-sync' ? (mode === 'sync' ? 'Sync mode' : 'Promotion mode') :
variant === 'primary-promotion' ? 'Promotion mode' :
isLegacy ? 'Legacy promotion repository' :
isSecondary ? 'Secondary sync repository' :
`Repository #${(idx ?? 0) + 1}`
)
// Determine display description based on variant and mode
const displayDescription = $derived(
(variant === 'primary-sync' || variant === 'primary-promotion') ?
(mode === 'sync' ? `Changes will be committed directly to the ${targetBranch} branch` :
mode === 'promotion' ? `Changes will be made to new branches whose promotion target is ${targetBranch}` :
null) :
null
)
const shouldShowEmptyState = $derived(showEmptyState || (!repo && (variant === 'primary-sync' || variant === 'primary-promotion')))
async function handleSave() {
if (!repo) return
if (!repo || idx === null) return
try {
await gitSyncContext.saveRepository(idx)
@@ -38,7 +102,7 @@
}
function handleRevert() {
if (!repo) return
if (!repo || idx === null) return
try {
gitSyncContext.revertRepository?.(idx)
sendUserToast('Reverted repository settings')
@@ -53,6 +117,7 @@
}
async function confirmDelete() {
if (idx === null) return
try {
await gitSyncContext.removeRepository(idx)
sendUserToast('Repository connection removed successfully')
@@ -69,7 +134,7 @@
}
function runGitSyncTestJob() {
if (gitSyncContext.runTestJob) {
if (idx !== null && gitSyncContext.runTestJob) {
gitSyncContext.runTestJob(idx)
}
}
@@ -79,271 +144,346 @@
}
function handlePullSettings() {
gitSyncContext.showPullModal(idx, true) // true for settingsOnly
if (idx !== null) {
gitSyncContext.showPullModal(idx, true)
}
}
</script>
{#if repo}
<div class="rounded-lg shadow-sm border p-0 w-full mb-4">
<!-- Card Header -->
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="font-semibold">Repository #{idx + 1}</span>
{#if repo.legacyImported}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 border border-orange-200">
Legacy Configuration
</span>
{/if}
<span class="text-xs text-tertiary pt-1 pl-8">
{repo.git_repo_resource_path}
</span>
</div>
<div class="flex items-center gap-2">
{#if validation.hasChanges && validation.isValid && !repo.isUnsavedConnection}
<Button
size="xs"
onclick={handleSave}
startIcon={{ icon: Save }}
>
{repo.legacyImported ? 'Migrate and save' : 'Save changes'}
</Button>
{#if gitSyncContext.initialRepositories[idx] && !repo.legacyImported}
<Button
color="light"
size="xs"
onclick={handleRevert}
startIcon={{ icon: RotateCcw }}
>
Revert
</Button>
{/if}
{/if}
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (repo.collapsed = !repo.collapsed)}
aria-label="Toggle collapse"
<!-- Shared snippets for reusable content -->
{#snippet headerActions()}
{#if !isLegacy}
{#if validation?.hasChanges && validation?.isValid && !repo.isUnsavedConnection}
<Button
size="xs"
onclick={handleSave}
startIcon={{ icon: Save }}
>
{repo.legacyImported ? 'Migrate and save' : 'Save changes'}
</Button>
{#if idx !== null && gitSyncContext.initialRepositories[idx] && !repo.legacyImported}
<Button
color="light"
size="xs"
onclick={handleRevert}
startIcon={{ icon: RotateCcw }}
>
{#if repo.collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
Revert
</Button>
{/if}
{/if}
{/if}
{#if isCollapsible}
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (repo.collapsed = !repo.collapsed)}
aria-label="Toggle collapse"
>
{#if repo.collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
{/if}
</button>
{/if}
{#if !confirmingDelete}
<div transition:fade|local={{ duration: 100 }}>
<Button
size="xs"
color="light"
variant="border"
onclick={initiateDelete}
startIcon={{ icon: Trash }}
>
Delete
</Button>
</div>
{:else}
<div class="flex gap-1">
<button
transition:fade|local={{ duration: 100 }}
class="px-3 py-1 text-xs bg-red-500 text-white rounded duration-200 hover:bg-red-600"
onclick={confirmDelete}
>
Confirm delete
</button>
<button
transition:fade|local={{ duration: 100 }}
class="px-2 py-1 text-xs bg-surface-secondary rounded duration-200 hover:bg-surface-hover"
onclick={cancelDelete}
>
<XCircle size={12} />
</button>
</div>
{/if}
{/snippet}
{#snippet repositoryContent()}
<div class="space-y-4">
<!-- Resource Picker -->
<div class="flex gap-2 items-center">
<div class="font-medium">Resource:</div>
<div class="flex-1">
<ResourcePicker
bind:value={repo.git_repo_resource_path}
resourceType={'git_repository'}
disabled={!repo.isUnsavedConnection}
excludedValues={usedRepositoryPaths}
/>
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.script_path)}
color="dark"
onclick={runGitSyncTestJob}
size="xs"
>
Test connection
</Button>
{/if}
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<!-- Validation and Test Status -->
{#if validation?.isDuplicate}
<div class="text-red-600 text-sm">
This resource is already used by another repository.
</div>
{/if}
{#if gitSyncTestJob && gitSyncTestJob.status !== undefined}
<div class="flex text-sm gap-1 items-center">
{#if gitSyncTestJob.status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJob.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
<XCircle size={14} class="text-red-700" />
{/if}
</button>
{#if !confirmingDelete}
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-2 bg-surface-secondary duration-200 hover:bg-surface-hover"
aria-label="Remove repository"
onclick={initiateDelete}
Git sync resource checked via Windmill job
<a
target="_blank"
href={`/run/${gitSyncTestJob.jobId}?workspace=${$workspaceStore}`}
class="text-blue-500 hover:underline"
>
<Trash size={14} />
</button>
{:else}
<div class="flex gap-1">
<button
transition:fade|local={{ duration: 100 }}
class="px-3 py-1 text-xs bg-red-500 text-white rounded duration-200 hover:bg-red-600"
onclick={confirmDelete}
{gitSyncTestJob.jobId}
</a>
<span class="text-secondary">WARNING: Only read permissions are verified.</span>
</div>
{/if}
<!-- Warnings -->
{#if repo.legacyImported}
<Alert type="warning" title="Legacy git sync settings imported">
This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press <b>Save</b> to migrate.
</Alert>
{/if}
{#if repo.script_path != hubPaths.gitSync}
<Alert type="warning" title="Script version mismatch">
The git sync version for this repository is not latest. Current: <a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{repo.script_path}</a>, latest:
<a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{hubPaths.gitSync}</a>
<div class="flex mt-2">
<Button
size="xs"
color="dark"
onclick={() => {
if (repo) {
repo.script_path = hubPaths.gitSync
}
}}
>
Confirm delete
</button>
<button
transition:fade|local={{ duration: 100 }}
class="px-2 py-1 text-xs bg-surface-secondary rounded duration-200 hover:bg-surface-hover"
onclick={cancelDelete}
>
<XCircle size={12} />
</button>
Update git sync script (require save git settings to be applied)
</Button>
</div>
</Alert>
{/if}
<!-- Configuration -->
{#if repo.isUnsavedConnection && !emptyString(repo.git_repo_resource_path) && idx !== null}
<DetectionFlow {idx} mode={variant === 'primary-promotion' || variant === 'legacy' ? 'promotion' : 'sync'} />
{:else}
<GitSyncFilterSettings
bind:git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={repo.legacyImported}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={false}
requiresMigration={repo.legacyImported}
useIndividualBranch={repo.use_individual_branch}
>
{#snippet actions()}
<Button
size="md"
onclick={handlePullSettings}
startIcon={{ icon: Download }}
>
Pull settings
</Button>
{/snippet}
</GitSyncFilterSettings>
{#if !repo.isUnsavedConnection}
<div class="flex justify-between items-start">
<!-- Display mode settings as prominent text -->
<div class="flex-1 mr-4">
<GitSyncModeDisplay
mode={variant === 'primary-promotion' || variant === 'legacy' ? 'promotion' : 'sync'}
{targetBranch}
repository={repo}
/>
</div>
<!-- Manual sync section for existing repos -->
{#if !emptyString(repo.git_repo_resource_path) && !repo.legacyImported}
<div class="flex flex-col">
<div class="text-sm text-secondary mb-2">Manual workspace content sync</div>
<div class="flex gap-2">
<Button
size="xs"
color="dark"
variant="border"
onclick={() => idx !== null && gitSyncContext.showPullModal(idx)}
startIcon={{ icon: Download }}
>
Pull from repo
</Button>
<Button
size="xs"
color="dark"
variant="border"
onclick={() => idx !== null && gitSyncContext.showPushModal(idx)}
startIcon={{ icon: Upload }}
>
Push to repo
</Button>
</div>
</div>
{/if}
</div>
{/if}
{/if}
{:else}
<div class="text-xs text-tertiary">Please select a Git repository resource.</div>
{/if}
</div>
{/snippet}
<!-- Main component rendering -->
{#if shouldShowEmptyState}
<!-- Empty State for Primary Variants -->
<div class="rounded-lg border bg-surface p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xl font-semibold">{displayTitle}</h3>
{#if displayDescription}
<p class="text-sm text-secondary">{displayDescription}</p>
{/if}
</div>
</div>
{#if !repo.collapsed}
<div class="px-4 py-2">
<div class="flex mt-5 mb-1 gap-1">
{#key repo}
<div class="pt-1 font-semibold">Resource: </div>
<ResourcePicker
bind:value={repo.git_repo_resource_path}
resourceType={'git_repository'}
disabled={!repo.isUnsavedConnection}
excludedValues={usedRepositoryPaths}
/>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.script_path)}
color="dark"
onclick={runGitSyncTestJob}
size="xs">Test connection</Button
>
{/if}
{/key}
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<div class="flex text-normal text-2xs gap-1">
{#if validation.isDuplicate}
<span class="text-red-600">This resource is already used by another repository.</span>
{/if}
{#if gitSyncTestJob && gitSyncTestJob.status !== undefined}
{#if gitSyncTestJob.status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJob.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Git sync resource checked via Windmill job
<a
target="_blank"
href={`/run/${gitSyncTestJob.jobId}?workspace=${$workspaceStore}`}
>
{gitSyncTestJob.jobId}
</a>WARNING: Only read permissions are verified.
{/if}
</div>
{#if repo.legacyImported}
<Alert type="warning" title="Legacy git sync settings imported">
This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press <b>Save</b> to migrate.
</Alert>
<div class="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed rounded-md">
<div class="text-center mb-4">
<p class="text-secondary mb-2">
{#if mode === 'sync'}
No sync repository configured. Add one to enable direct synchronization.
{:else if mode === 'promotion'}
No promotion repository configured. Add one to enable branch-based workflows.
{:else}
No repository configured.
{/if}
<div class="flex flex-col mb-1 gap-4">
{#if repo}
{#if repo.script_path != hubPaths.gitSync}
<Alert type="warning" title="Script version mismatch">
The git sync version for this repository is not latest. Current: <a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{repo.script_path}</a
>, latest:
<a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{hubPaths.gitSync}</a
>
<div class="flex mt-2">
<Button
size="xs"
color="dark"
onclick={() => {
if (repo) {
repo.script_path = hubPaths.gitSync
}
}}
>Update git sync script (require save git settings to be applied)</Button
>
</div>
</Alert>
{/if}
{#if repo.isUnsavedConnection && !emptyString(repo.git_repo_resource_path)}
<!-- Use DetectionFlow component -->
<div class="mt-4">
<DetectionFlow {idx} />
</div>
{:else}
<!-- Existing saved connection flow -->
<GitSyncFilterSettings
bind:git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={repo.legacyImported}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={false}
requiresMigration={repo.legacyImported}
useIndividualBranch={repo.use_individual_branch}
>
{#snippet actions()}
<Button
size="md"
onclick={handlePullSettings}
startIcon={{ icon: Download }}
>
Pull settings
</Button>
{/snippet}
</GitSyncFilterSettings>
{/if}
{#if !repo.isUnsavedConnection}
<div class="flex justify-between items-start">
<!-- Display mode settings as prominent text -->
<div class="text-base flex-1 mr-4">
{#if repo.use_individual_branch}
<div><span class="font-bold">Promotion:</span> Creating branches whose promotion target is main</div>
{#if repo.group_by_folder}
<div class="text-sm text-tertiary mt-1">Grouped by folder</div>
{/if}
{:else}
<div>Sync: <span class="font-bold">Syncing back to branch main</span></div>
{/if}
</div>
<!-- Manual sync section for existing repos -->
{#if !emptyString(repo.git_repo_resource_path) && !repo.legacyImported}
<div class="flex flex-col">
<div class="text-sm text-secondary mb-2">Manual workspace content sync</div>
<div class="flex gap-2">
<Button
size="xs"
color="dark"
variant="border"
onclick={() => gitSyncContext.showPullModal(idx)}
startIcon={{ icon: Download }}
>
Pull from repo
</Button>
<Button
size="xs"
color="dark"
variant="border"
onclick={() => gitSyncContext.showPushModal(idx)}
startIcon={{ icon: Upload }}
>
Push to repo
</Button>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
{:else}
<div class="text-xs text-tertiary pt-1 pl-8">Please select a Git repository resource.</div>
{/if}
</p>
</div>
{/if}
{#if onAdd}
<Button
size="md"
color="dark"
variant="border"
startIcon={{ icon: Plus }}
onclick={onAdd}
>
Add {mode || 'repository'} repository
</Button>
{/if}
</div>
</div>
{/if}
{:else if repo}
{#if variant === 'primary-sync' || variant === 'primary-promotion'}
<!-- Primary Repository Layout -->
<div class="rounded-lg border bg-surface p-4 mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex flex-col">
<h3 class="text-xl font-semibold">{displayTitle}</h3>
{#if displayDescription}
<p class="text-sm text-secondary">{displayDescription}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{@render headerActions()}
</div>
</div>
{@render repositoryContent()}
</div>
{:else}
<!-- Standard Repository Card Layout -->
<div class="rounded-lg shadow-sm border p-0 w-full mb-4">
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="text-lg font-semibold">{displayTitle}</span>
{#if repo.legacyImported}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 border border-orange-200">
Legacy Configuration
</span>
{/if}
<span class="text-xs text-tertiary pt-1 pl-8">
{repo.git_repo_resource_path}
</span>
</div>
<div class="flex items-center gap-2">
{@render headerActions()}
</div>
</div>
{#if !repo.collapsed}
<div class="px-4 py-2">
{@render repositoryContent()}
</div>
{/if}
</div>
{/if}
{/if}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { Plus, ExternalLink } from 'lucide-svelte'
import { ExternalLink, ChevronDown, ChevronRight, Plus } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import { setGitSyncContext } from './GitSyncContext.svelte'
import GitSyncRepositoryList from './GitSyncRepositoryList.svelte'
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -27,6 +27,19 @@
}
})
// Derived state for repository categorization
const primarySync = $derived(gitSyncContext?.getPrimarySyncRepository() || null)
const primaryPromotion = $derived(gitSyncContext?.getPrimaryPromotionRepository() || null)
const secondarySync = $derived(gitSyncContext?.getSecondarySyncRepositories() || [])
const legacyPromotion = $derived(gitSyncContext?.getLegacyPromotionRepositories() || [])
// State for collapsible sections
let secondarySyncExpanded = $state(false)
let legacyPromotionExpanded = $state(false)
// Check if any secondary repositories are unsaved
const hasUnsavedSecondary = $derived(secondarySync.some(s => s.repo.isUnsavedConnection))
</script>
{#if !gitSyncContext}
@@ -72,19 +85,129 @@
</div>
<div class="pt-2"></div>
<!-- Repository list -->
<GitSyncRepositoryList />
<!-- Primary Sync Repository -->
<div class="space-y-4">
<GitSyncRepositoryCard
variant="primary-sync"
mode="sync"
idx={primarySync?.idx ?? null}
repository={primarySync?.repo ?? null}
onAdd={() => gitSyncContext.addSyncRepository()}
isCollapsible={false}
showEmptyState={primarySync?.repo === null}
/>
<!-- Add repository button -->
<div class="flex mt-5 mb-5">
<Button
startIcon={{ icon: Plus }}
color="dark"
variant="border"
onclick={() => gitSyncContext.addRepository()}
>
Add connection
</Button>
<!-- Secondary Sync Repositories -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary sync repositories configured
</div>
{:else}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard
variant="secondary"
{idx}
isSecondary={true}
/>
</div>
{/each}
{/if}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary sync
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-tertiary hover:text-secondary transition-colors"
onclick={() => {
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
>
+ Add secondary sync repository
</button>
</div>
{/if}
{/if}
{/if}
<!-- Primary Promotion Repository -->
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
</div>
<!-- Legacy promotion repositories (backwards compatibility) -->
{#if legacyPromotion.length > 0}
<Alert type="warning" title="Multiple promotion repositories detected">
Multiple promotion repositories are no longer supported. Please reduce to a single promotion repository.
Only deletion is allowed for the additional repositories below.
</Alert>
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (legacyPromotionExpanded = !legacyPromotionExpanded)}
>
{#if legacyPromotionExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Legacy promotion repositories ({legacyPromotion.length})
</button>
{#if legacyPromotionExpanded}
<div class="space-y-3 mt-3">
{#each legacyPromotion as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard {idx} variant="legacy" isLegacy={true} />
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<!-- Modals -->

View File

@@ -7,7 +7,7 @@
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import hubPaths from '$lib/hubPaths.json'
import { tryEvery } from '$lib/utils'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync'
interface Props {
@@ -163,50 +163,46 @@
applyJobStatus = 'running'
}
let jobSuccess = false
let result: any = {}
// Use JobManager instead of tryEvery
const result = await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 60000,
timeoutMessage: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s`,
onProgress: (status) => {
if (isPreview) {
previewJobStatus = status.status
} else {
applyJobStatus = status.status
}
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult
// Handle failure status
if (status.status === 'failure') {
if (isPreview) {
previewError = status.error || 'Preview failed'
} else {
applyError = status.error || 'Pull failed'
}
}
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s` }
})
} catch (err) {}
},
interval: 500,
timeout: 60000
})
}
)
// Handle successful result
if (isPreview) {
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
if (previewJobStatus === 'success') {
previewResult = result as SyncResponse | SettingsResponse
}
} else {
applyJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
const settingsData = result?.local
if (applyJobStatus === 'success') {
const settingsData = (result as any)?.local
const hasSettingsChanges = settingsData && onFilterUpdate
if (hasSettingsChanges) {
onFilterUpdate(settingsData)
await saveUpdatedSettings()
}
onSuccess?.()
} else {
applyError = 'Pull failed'
}
}
} catch (e) {

View File

@@ -6,7 +6,7 @@
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { tryEvery } from '$lib/utils'
import { jobManager } from '$lib/services/JobManager'
import type { SyncResponse, SettingsObject } from '$lib/git-sync'
interface Props {
@@ -105,44 +105,40 @@
applyJobStatus = 'running'
}
let jobSuccess = false
let result: any = {}
// Use JobManager instead of tryEvery
const result = await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 60000,
timeoutMessage: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s`,
onProgress: (status) => {
if (isPreview) {
previewJobStatus = status.status
} else {
applyJobStatus = status.status
}
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult
// Handle failure status
if (status.status === 'failure') {
if (isPreview) {
previewError = status.error || 'Preview failed'
} else {
applyError = status.error || 'Push failed'
}
}
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s` }
})
} catch (err) {}
},
interval: 500,
timeout: 60000
})
}
)
// Handle successful result
if (isPreview) {
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
if (previewJobStatus === 'success') {
previewResult = result as SyncResponse
}
} else {
applyJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
if (applyJobStatus === 'success') {
onSuccess?.()
} else {
applyError = 'Push failed'
}
}
} catch (e) {

View File

@@ -84,10 +84,15 @@ export class JobManager {
throw new Error('Job was cancelled')
}
const jobResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
let jobResult
try {
jobResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
} catch (error) {
throw error
}
const success = !!jobResult.success
const status: JobStatus = {
@@ -98,10 +103,6 @@ export class JobManager {
onProgress?.(status)
if (!success) {
throw new Error(status.error)
}
return jobResult.result as T
},
timeoutCode: async () => {