feat: git sync improvements (#6182)

* init checkpoint

* ui second pass...

* round 1 backend + saving settings + detecting changes...

* checkpoint

* fix openapi

* saving + correct wmill.yaml diff

* cli refactor

* cli and tests refactor done

* cli multi workspace support

* cli support skip core types to align with ui

* new test framework

* sqlx

* openapi spec

* frontend

* sync + settings changes

* some fixes

* some fixes

* security: Remove hardcoded EE license key, use environment variable only

- Remove hardcoded license key from containerized test backend
- Environment variable EE_LICENSE_KEY now required for EE features
- License key no longer stored in database during tests

* sqlx

* tests

* fixing tests

* fix tests

* checkpoint

* checkpoint

* cli build

* frontend - cli exchange

* settings match

* ee repo ref

* npm check

* openapi

* tests

* checkpoint

* cli + tests

* reset to preview on changes

* merge issue ee

* cleanup

* hubscript

* simplifications

* ee repo ref

* cli fixes

* fix sync and add tests

* extra test

* git sync settings / key change aware

* ee-repo ref

* ee-repo ref

* ee repo ref

* ee ref

* review 1

* ee ref

* Update frontend/src/lib/components/PullGitRepoPopover.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* ee ref

* remove extra includes from ui

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-07-15 16:25:31 +00:00
committed by GitHub
co-authored by ellipsis-dev[bot]
parent 574f14b349
commit 27bf4e34d8
41 changed files with 7892 additions and 924 deletions
@@ -0,0 +1,65 @@
<script lang="ts">
type DiffResult = {
added: string[]
deleted: string[]
modified: string[]
repoWmillYaml?: string
yamlModified?: boolean
}
let { previewResult } = $props<{
previewResult: DiffResult | undefined
}>()
</script>
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified}
<div class="mt-2 text-tertiary">No changes found! The workspace is up to date.</div>
{:else}
{#if previewResult?.yamlModified}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<ul class="list-disc list-inside">
<li>wmill.yaml (Git sync settings)</li>
</ul>
</div>
{/if}
{#if previewResult?.added?.length}
<div class="mt-2">
<div class="text-green-600">Added:</div>
<ul class="list-disc list-inside">
{#each previewResult.added as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{#if previewResult?.deleted?.length}
<div class="mt-2">
<div class="text-red-600">Deleted:</div>
<ul class="list-disc list-inside">
{#each previewResult.deleted as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{#if previewResult?.modified?.length}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<ul class="list-disc list-inside">
{#each previewResult.modified as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,454 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Alert } from '$lib/components/common'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
UploadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
let { gitRepoResourcePath, branchName, uiState } = $props<{
gitRepoResourcePath: string
branchName?: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
}>()
let _branchName = $state(branchName ?? '')
let previewResult = $state<
| {
added: string[]
deleted: string[]
modified: string[]
}
| undefined
>(undefined)
let isPreviewLoading = $state(false)
let isInitializing = $state(false)
let initResult = $state<{ success: boolean; message: string | undefined } | null>(null)
let initGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isCliInfoExpanded = $state(false)
async function previewChanges() {
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
previewJobId = null
previewJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) {
previewResult = undefined
isPreviewLoading = false
return
}
// Pass UI state directly as JSON to CLI
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
dry_run: true,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
// Wait for job completion (polling)
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Preview job timed out after 15s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 15000
})
if (jobSuccess) {
const result = await JobService.getCompletedJobResult({
workspace,
id: jobId
})
console.log('Preview result:', result)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
result &&
(result as GitSyncResult).changes &&
Array.isArray((result as GitSyncResult).changes)
) {
for (const change of (result as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
previewResult = { added, deleted, modified }
previewJobStatus = 'success'
} else {
previewResult = undefined
previewJobStatus = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
previewJobStatus = 'failure'
} finally {
isPreviewLoading = false
}
}
async function initializeRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Initializing repo:', gitRepoResourcePath, 'in workspace:', workspace)
isInitializing = true
initResult = null
pushJobId = null
pushJobStatus = undefined
try {
// Pass UI state directly as JSON to CLI
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Push job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
initResult = {
success: jobSuccess,
message: jobSuccess ? undefined : 'Failed to initialize repository.'
}
// Reset popover state after successful push
if (jobSuccess) {
setTimeout(() => {
previewResult = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
initGitRepoPopover?.close()
}, 1500) // Small delay to show success state
}
} catch (error) {
console.error('Failed to initialize repo:', error)
pushJobStatus = 'failure'
initResult = { success: false, message: 'Failed to initialize repository.' }
} finally {
isInitializing = false
}
}
</script>
<Popover
bind:this={initGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={initGitRepoPopover?.open}
startIcon={{ icon: UploadCloud }}
>
Push workspace to Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Push workspace to Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
This action will push all workspace objects that match your current filter settings to the
selected branch in your Git repository. <span
class="text-orange-600 flex items-center gap-1"
><AlertTriangle size={14} /> Any existing content in the branch will be replaced with the
filtered workspace content.</span
>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to pull from Windmill and push to git
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
wmill init
# adjust wmill.yaml file configuraton as needed
wmill sync pull
git add -A
git commit -m 'Initial commit'
git push</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="branch-name" class="text-sm font-medium">Push to new branch (optional)</label>
<div class="prose max-w-none text-2xs text-tertiary">
Enter a new branch name to push to (e.g so you can merge back into main with a pull
request). If left blank, the default branch from the git repository resource will be used.
</div>
<div class="flex flex-col w-1/4">
<input
id="branch-name"
type="text"
bind:value={_branchName}
class="border rounded px-2 py-1"
/>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}
>
{pushJobId}
</a>
</div>
{/if}
<!-- Action row: Cancel on left, Preview/Confirm on right -->
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
close()
}}
disabled={isPreviewLoading || isInitializing}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={initializeRepo}
disabled={isPreviewLoading || isInitializing}
startIcon={{ icon: Save }}
title="Initialize Git Repo"
>
Push
</Button>
{/if}
{/if}
</div>
</div>
{#if initResult?.message}
<div class="mt-2">
<Alert
type={initResult.success ? 'success' : 'error'}
title={initResult.success ? 'Success' : 'Error'}
size="xs"
>
{initResult.message}
</Alert>
</div>
{/if}
</div>
</svelte:fragment>
</Popover>
@@ -0,0 +1,418 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
DownloadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
let { gitRepoResourcePath, uiState, onFilterUpdate } = $props<{
gitRepoResourcePath: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
onFilterUpdate: (filters: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}) => void
}>()
type PreviewResult = {
added: string[]
deleted: string[]
modified: string[]
}
let previewResult = $state<PreviewResult | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPulling = $state(false)
let pullGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let jobStatus = $state<{
id: string | null
status: 'running' | 'success' | 'failure' | undefined
error?: string
type: 'preview' | 'pull'
}>({
id: null,
status: undefined,
type: 'preview'
})
let isCliInfoExpanded = $state(false)
async function handleJobCompletion(jobId: string, workspace: string): Promise<boolean> {
let success = false
await tryEvery({
tryCode: async () => {
const result = await JobService.getCompletedJob({
workspace,
id: jobId
})
success = !!result.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
return success
}
async function previewChanges() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
jobStatus = { id: null, status: undefined, type: 'preview' }
try {
// Always use the simplified JSON approach
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: true,
pull: true,
only_wmill_yaml: false,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'preview' }
const success = await handleJobCompletion(jobId, workspace)
if (success) {
const rawResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
console.log('Preview result:', rawResult)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
rawResult &&
(rawResult as GitSyncResult).changes &&
Array.isArray((rawResult as GitSyncResult).changes)
) {
for (const change of (rawResult as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
// For full sync mode, just use the CLI results directly
// The CLI already handles wmill.yaml changes with --include-wmill-yaml flag
previewResult = { added, deleted, modified }
jobStatus.status = 'success'
} else {
previewResult = undefined
jobStatus.status = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPreviewLoading = false
}
}
async function pullFromRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Pulling from repo:', gitRepoResourcePath)
isPulling = true
jobStatus = { id: null, status: undefined, type: 'pull' }
try {
// Use init git repo script with dry_run: false (actual pull operation)
// The script will read wmill.yaml directly from the cloned repo, no need to pass settings
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: false,
branch_to_push: '',
only_wmill_yaml: false,
pull: true,
settings_json: undefined // Let script use wmill.yaml from repo
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'pull' }
const success = await handleJobCompletion(jobId, workspace)
jobStatus.status = success ? 'success' : 'failure'
if (success) {
// Get the result which should contain the local git repo settings as JSON
const result = (await JobService.getCompletedJobResult({ workspace, id: jobId })) as any
console.log('Pull result:', result)
// Apply the settings from the sync operation result to the UI
if (result?.settings_json) {
// Directly update the UI state with the JSON result - no YAML conversion needed!
const settingsJson = result.settings_json as {
include_path: string[]
exclude_path?: string[]
extra_include_path?: string[]
include_type: string[]
}
onFilterUpdate({
include_path: settingsJson.include_path || ['f/**'],
exclude_path: settingsJson.exclude_path || [],
extra_include_path: settingsJson.extra_include_path || [],
include_type: settingsJson.include_type || ['script', 'flow', 'app', 'folder']
})
sendUserToast('Successfully pulled workspace content from repository')
// Reset popover state after successful pull
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
pullGitRepoPopover?.close()
} else {
console.warn('No settings_json returned from pull operation')
sendUserToast('Pull completed but could not update filter settings', true)
}
}
} catch (error) {
console.error('Failed to pull from repo:', error)
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPulling = false
}
}
</script>
<Popover
bind:this={pullGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={pullGitRepoPopover?.open}
startIcon={{ icon: DownloadCloud }}
>
Pull workspace from Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Pull workspace from Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
This action will pull all workspace objects from your Git repository according to the
filters set in the Git repository wmill.yaml file and apply those filter settings to the
workspace.
<span class="text-orange-600 flex items-center gap-1">
<AlertTriangle size={14} /> This will overwrite your current workspace content and Git sync
filter settings with the content from the Git repository.
</span>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to push local files to Windmill
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>npm install -g windmill-cli
# Clone your git repository
git clone $REPO_URL
cd $REPO_NAME
# Configure Windmill CLI
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
# Push the content to Windmill
wmill sync push --yes
# Optional: add --skip-secrets --skip-variables --skip-resources flags as needed</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if jobStatus.id}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if jobStatus.status === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if jobStatus.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if jobStatus.status === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
{jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job:
<a
target="_blank"
class="underline"
href={`/run/${jobStatus.id}?workspace=${$workspaceStore}`}
>
{jobStatus.id}
</a>
</div>
{#if jobStatus.error}
<div class="text-xs text-red-600">{jobStatus.error}</div>
{/if}
{/if}
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
close()
}}
disabled={isPreviewLoading || isPulling}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={pullFromRepo}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPulling ? Loader2 : Save,
classes: isPulling ? 'animate-spin' : ''
}}
>
{isPulling ? 'Pulling...' : 'Pull'}
</Button>
{/if}
{/if}
</div>
</div>
</div>
</svelte:fragment>
</Popover>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Plus, X } from 'lucide-svelte'
let {
title = '',
tooltip = undefined,
items = $bindable([] as string[]),
placeholder = 'Add filter (e.g. f/**)'
} = $props()
let newItem = $state('')
let inputRef: HTMLInputElement | null = $state(null)
function addItem() {
const value = newItem.trim()
if (value && !items.includes(value)) {
items = [...items, value]
newItem = ''
inputRef?.focus()
}
}
function removeItem(idx: number) {
items = items.filter((_, i) => i !== idx)
}
</script>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="font-semibold text-sm">{title}</h4>
{#if tooltip}
{@render tooltip?.()}
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center mb-1">
{#each items as item, idx (item)}
<span class="flex items-center bg-gray-100 rounded-full px-3 py-1 text-xs text-gray-700">
{item}
<button
class="ml-2 text-gray-400 hover:text-red-500 focus:outline-none"
onclick={() => removeItem(idx)}
aria-label="Remove filter"
>
<X size={14} />
</button>
</span>
{/each}
<input
bind:this={inputRef}
class="border border-gray-300 rounded-full px-3 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-primary"
{placeholder}
value={newItem}
oninput={(e) => (newItem = e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())}
/>
<button
class="ml-1 text-primary hover:bg-primary/10 rounded-full p-1"
onclick={addItem}
aria-label="Add filter"
>
<Plus size={14} />
</button>
</div>
</div>
@@ -0,0 +1,877 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { Filter, Save, Eye, Loader2, CheckCircle2, XCircle, Check } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import yaml from 'js-yaml'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import FilterList from './FilterList.svelte'
import { Tabs, Tab } from '$lib/components/common'
type ObjectType =
| 'script'
| 'flow'
| 'app'
| 'folder'
| 'resource'
| 'variable'
| 'secret'
| 'resourcetype'
| 'schedule'
| 'user'
| 'group'
| 'trigger'
| 'settings'
| 'key'
type GitSyncTypeMap = {
scripts: boolean
flows: boolean
apps: boolean
folders: boolean
resourceTypes: boolean
resources: boolean
variables: boolean
secrets: boolean
schedules: boolean
users: boolean
groups: boolean
triggers: boolean
settings: boolean
key: boolean
}
type PreviewResult = {
diff?: { [key: string]: { from: any; to: any } }
hasChanges?: boolean
isInitialSetup?: boolean
message?: string
local?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
backend?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
}
let {
git_repo_resource_path = $bindable(''),
include_path = $bindable(['f/**']),
include_type = $bindable(['script', 'flow', 'app', 'folder'] as ObjectType[]),
exclude_types_override = $bindable([] as ObjectType[]),
isLegacyRepo = false,
yamlText = $bindable(''),
onSettingsChange = (settings: { yaml: string }) => {},
excludes = $bindable([] as string[]),
extraIncludes = $bindable([] as string[])
} = $props()
// Component state
let collapsed = $state(false)
let editAsYaml = $state(false)
let yamlError = $state('')
let isPullMode = $state(false)
// Preview/Push state
let previewResult = $state<PreviewResult | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPushing = $state(false)
let previewError = $state('')
let previewSettingsSnapshot = $state<string | null>(null)
// Compute effective include types (include_type minus exclude_types_override for legacy repos only)
const effectiveIncludeTypes = $derived(
isLegacyRepo
? include_type.filter((type) => !exclude_types_override.includes(type))
: include_type
)
// Compute type toggles from effective include types
const typeToggles = $derived({
scripts: effectiveIncludeTypes.includes('script'),
flows: effectiveIncludeTypes.includes('flow'),
apps: effectiveIncludeTypes.includes('app'),
folders: effectiveIncludeTypes.includes('folder'),
resourceTypes: effectiveIncludeTypes.includes('resourcetype'),
resources: effectiveIncludeTypes.includes('resource'),
variables: effectiveIncludeTypes.includes('variable'),
secrets: effectiveIncludeTypes.includes('secret'),
schedules: effectiveIncludeTypes.includes('schedule'),
users: effectiveIncludeTypes.includes('user'),
groups: effectiveIncludeTypes.includes('group'),
triggers: effectiveIncludeTypes.includes('trigger'),
settings: effectiveIncludeTypes.includes('settings'),
key: effectiveIncludeTypes.includes('key')
})
// Tab selection for filter kinds
let filtersTab = $state<'includes' | 'excludes'>('includes')
function updateIncludeType(key: keyof GitSyncTypeMap, value: boolean) {
const newTypes = new Set(include_type)
const typeMap: Record<keyof GitSyncTypeMap, ObjectType> = {
scripts: 'script',
flows: 'flow',
apps: 'app',
folders: 'folder',
resourceTypes: 'resourcetype',
resources: 'resource',
variables: 'variable',
secrets: 'secret',
schedules: 'schedule',
users: 'user',
groups: 'group',
triggers: 'trigger',
settings: 'settings',
key: 'key'
}
if (value) {
newTypes.add(typeMap[key])
} else {
newTypes.delete(typeMap[key])
if (key === 'variables') {
newTypes.delete('secret')
}
}
include_type = Array.from(newTypes)
}
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// Simple JSON-based UI state helper
function getUIState() {
return {
include_path,
exclude_path: excludes,
extra_include_path: extraIncludes,
include_type
}
}
// Apply settings from backend format (used by both local git repo and backend settings)
function fromBackendFormat(settings: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}) {
include_path = settings.include_path || []
excludes = settings.exclude_path || []
extraIncludes = settings.extra_include_path || []
include_type = settings.include_type || []
}
// Simplified YAML parsing for manual editing
function fromYaml(yamlStr: string) {
yamlError = ''
try {
const parsed = yaml.load(yamlStr)
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid YAML structure')
}
const obj: any = parsed
yamlText = yamlStr
// Extract includes - reset to default if not present
if (obj.includes && Array.isArray(obj.includes)) {
include_path = obj.includes.map((p: any) => {
if (typeof p !== 'string') {
throw new Error('includes must contain only strings')
}
// Handle quoted strings
if (/^['"].*['"]$/.test(p)) {
return p.slice(1, -1).replace(/''/g, "'")
}
return p
})
} else {
// Reset to default if includes is not present
include_path = ['f/**']
}
// Build the type set based on the YAML flags
const newTypes = new Set<ObjectType>()
// Always include core types (these are fundamental and not controlled by flags)
newTypes.add('script')
newTypes.add('flow')
newTypes.add('app')
newTypes.add('folder')
// Handle skip flags (if skipX is false or undefined, include the type)
if (obj.skipResourceTypes !== true) newTypes.add('resourcetype')
if (obj.skipResources !== true) newTypes.add('resource')
if (obj.skipVariables !== true) newTypes.add('variable')
if (obj.skipSecrets !== true) newTypes.add('secret')
// Handle include flags (if includeX is true, include the type)
if (obj.includeSchedules === true) newTypes.add('schedule')
if (obj.includeTriggers === true) newTypes.add('trigger')
if (obj.includeUsers === true) newTypes.add('user')
if (obj.includeGroups === true) newTypes.add('group')
if (obj.includeSettings === true) newTypes.add('settings')
if (obj.includeKey === true) newTypes.add('key')
// Apply business rule: secrets can only be included if variables are included
// This matches the UI behavior where turning off variables also turns off secrets
if (!newTypes.has('variable')) {
newTypes.delete('secret')
}
include_type = Array.from(newTypes)
} catch (e) {
yamlError = e.message || 'Invalid YAML'
console.error('Error parsing YAML:', e)
}
}
// Simple YAML generation for manual editing mode
function generateYamlFromUI() {
try {
const validIncludePath = include_path
const validExcludePath = excludes
const validExtraInclude = extraIncludes
// Basic YAML structure - let the CLI handle the proper normalization
let config: any = {
includes: validIncludePath,
excludes: validExcludePath,
extraIncludes: validExtraInclude,
codebases: []
}
// Let the CLI handle the optimization of skip/include flags
// Just convert the UI state directly
if (!include_type.includes('variable')) config.skipVariables = true
if (!include_type.includes('resource')) config.skipResources = true
if (!include_type.includes('secret')) config.skipSecrets = true
if (!include_type.includes('resourcetype')) config.skipResourceTypes = true
if (include_type.includes('schedule')) config.includeSchedules = true
if (include_type.includes('trigger')) config.includeTriggers = true
if (include_type.includes('user')) config.includeUsers = true
if (include_type.includes('group')) config.includeGroups = true
if (include_type.includes('settings')) config.includeSettings = true
if (include_type.includes('key')) config.includeKey = true
return yaml.dump(config, {
indent: 2,
lineWidth: -1,
quotingType: '"',
forceQuotes: false,
noRefs: true
})
} catch (e) {
console.warn('Failed to generate YAML:', e)
yamlError = e.message || 'Failed to generate YAML'
return `includes:
- f/**
excludes: []
extraIncludes: []
codebases: []`
}
}
function switchToYaml() {
yamlText = generateYamlFromUI()
yamlError = ''
editAsYaml = true
}
function switchToUI() {
fromYaml(yamlText)
if (!yamlError) {
editAsYaml = false
}
}
// Simplified preview function - always uses JSON approach
async function previewFiltersToGitRepo() {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
// Take a snapshot of current settings
previewSettingsSnapshot = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
try {
const workspace = $workspaceStore
if (!workspace) return
// Always pass UI state as JSON - the backend now handles this uniformly
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
only_wmill_yaml: true,
dry_run: true,
pull: isPullMode,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
let jobSuccess = false
let result: PreviewResult = {}
await (
await import('$lib/utils')
).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 as PreviewResult
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Preview job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} catch (e) {
previewJobStatus = 'failure'
previewError = e?.message || 'Preview failed'
previewResult = null
} finally {
isPreviewLoading = false
}
}
// Simplified push function - always uses JSON approach
async function pushFiltersToGitRepo() {
if (isPullMode) {
// In pull mode, apply the local settings (from git repo) to UI
if (previewResult?.local) {
try {
fromBackendFormat(previewResult.local)
yamlText = generateYamlFromUI()
onSettingsChange({ yaml: yamlText })
sendUserToast('Changes applied - remember to save repository settings to persist changes')
// Clear the preview state after applying settings
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
} catch (e) {
previewError = 'Failed to apply pulled settings: ' + e.message
}
}
return
}
// Push mode - send current UI state as JSON
isPushing = true
pushJobId = null
pushJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) return
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
dry_run: false,
pull: isPullMode,
only_wmill_yaml: true,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await (
await import('$lib/utils')
).tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Push job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
// Reset preview state after successful push
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
}
} catch (e) {
pushJobStatus = 'failure'
} finally {
isPushing = false
}
}
// Simplified export function for backward compatibility
export function toYaml() {
return generateYamlFromUI()
}
export function setSettings(settings: { yaml: string }) {
yamlText = settings.yaml
fromYaml(settings.yaml)
}
$effect(() => {
// Reset preview state when switching modes
if (isPullMode !== undefined) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
isPreviewLoading = false
isPushing = false
previewError = ''
}
})
// Reset preview state when settings change (making preview stale)
$effect(() => {
// Track all the settings that affect the preview
const currentSettings = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
// If we have an existing preview result and settings have changed from snapshot, clear it
if (
previewResult !== null &&
previewSettingsSnapshot !== null &&
currentSettings !== previewSettingsSnapshot
) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
previewSettingsSnapshot = null
}
})
</script>
<div class="rounded-lg shadow-sm border p-0 w-full">
<!-- 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">
<Filter size={18} class="text-primary" />
<span class="font-semibold text-sm">Git Sync filter settings</span>
</div>
<div class="flex items-center gap-2">
{#if !collapsed}
<button
class="text-xs px-2 py-1 rounded border border-gray-300 bg-surface-primary hover:bg-surface-secondary"
onclick={editAsYaml ? switchToUI : switchToYaml}
>
{editAsYaml ? 'Edit in UI' : 'Edit as YAML'}
</button>
{/if}
<button
class="text-gray-500 hover:text-primary focus:outline-none"
onclick={() => (collapsed = !collapsed)}
aria-label="Toggle collapse"
>
{#if 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>
</div>
</div>
{#if !collapsed}
{#if editAsYaml}
<div class="px-4 py-4">
<textarea
class="w-full h-64 font-mono text-xs border rounded p-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
spellcheck="false"
bind:value={yamlText}
></textarea>
{#if yamlError}
<div class="text-xs text-red-600 mt-2">{yamlError}</div>
{/if}
</div>
{:else}
<div class="px-4 py-2">
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-32">
<div class="flex flex-col gap-2">
<Tabs bind:selected={filtersTab}>
<Tab value="includes">Includes</Tab>
<Tab value="excludes">Excludes</Tab>
</Tabs>
{#if filtersTab === 'includes'}
<FilterList
title="Include path filters"
bind:items={include_path}
placeholder="Add filter (e.g. f/**)"
>
{#snippet tooltip()}
<Tooltip>
Only scripts, flows and apps with their path matching one of those filters will
be synced to the Git repositories below. The filters allow '*' and '**'
characters, with '*' matching any character allowed in paths until the next
slash (/) and '**' matching anything including slashes. By default everything in
folders will be synced.
</Tooltip>
{/snippet}
</FilterList>
{:else if filtersTab === 'excludes'}
<FilterList
title="Exclude path filters"
bind:items={excludes}
placeholder="Add filter (e.g. f/**)"
>
{#snippet tooltip()}
<Tooltip>
After the include / extra include checks, if a file matches any of these
patterns it will be skipped.
</Tooltip>
{/snippet}
</FilterList>
{/if}
</div>
<!-- Type Filters Section (Right) -->
<div>
<div class="flex items-center gap-2 mb-3">
<h4 class="font-semibold text-sm">Type filters</h4>
<Tooltip>
On top of the filter path above, you can include only certain type of object to be
synced with the Git repository. By default everything is synced.
</Tooltip>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.scripts}
on:change={(e) => updateIncludeType('scripts', e.detail)}
options={{ right: capitalize('scripts') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.flows}
on:change={(e) => updateIncludeType('flows', e.detail)}
options={{ right: capitalize('flows') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.apps}
on:change={(e) => updateIncludeType('apps', e.detail)}
options={{ right: capitalize('apps') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.folders}
on:change={(e) => updateIncludeType('folders', e.detail)}
options={{ right: capitalize('folders') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.resourceTypes}
on:change={(e) => updateIncludeType('resourceTypes', e.detail)}
options={{ right: capitalize('resourceTypes') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.resources}
on:change={(e) => updateIncludeType('resources', e.detail)}
options={{ right: capitalize('resources') }}
/>
</div>
<div class="col-span-2 flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.variables}
on:change={(e) => updateIncludeType('variables', e.detail)}
options={{ right: 'Variables' }}
/>
<span class="text-gray-400">-</span>
<Toggle
size="xs"
disabled={!typeToggles.variables}
checked={typeToggles.secrets}
on:change={(e) => updateIncludeType('secrets', e.detail)}
options={{ left: 'Include secrets' }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.schedules}
on:change={(e) => updateIncludeType('schedules', e.detail)}
options={{ right: capitalize('schedules') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.users}
on:change={(e) => updateIncludeType('users', e.detail)}
options={{ right: capitalize('users') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.groups}
on:change={(e) => updateIncludeType('groups', e.detail)}
options={{ right: capitalize('groups') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.triggers}
on:change={(e) => updateIncludeType('triggers', e.detail)}
options={{ right: capitalize('triggers') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.settings}
on:change={(e) => updateIncludeType('settings', e.detail)}
options={{ right: 'Workspace settings' }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.key}
on:change={(e) => updateIncludeType('key', e.detail)}
options={{ right: 'Encryption key' }}
/>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 flex flex-col gap-2 p-2">
<div class="flex flex-col gap-2 mb-2">
<Toggle
size="sm"
bind:checked={isPullMode}
options={{
left: 'Push',
right: 'Pull'
}}
/>
<span class="text-xs text-tertiary">
{isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'}
</span>
</div>
<div class="flex gap-2 items-center">
<Button
size="sm"
on:click={previewFiltersToGitRepo}
disabled={isPreviewLoading || isPushing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview'}
</Button>
{#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))}
<Button
size="sm"
on:click={pushFiltersToGitRepo}
disabled={isPushing || isPreviewLoading}
color={isPullMode ? 'dark' : 'red'}
startIcon={{
icon: isPushing ? Loader2 : isPullMode ? Check : Save,
classes: isPushing ? 'animate-spin' : ''
}}
>
{isPushing
? isPullMode
? 'Applying...'
: 'Pushing...'
: isPullMode
? 'Apply'
: 'Push Settings to Git'}
</Button>
{/if}
</div>
{#if previewError}
<div class="text-xs text-red-600 mt-2">{previewError}</div>
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}>{previewJobId}</a
>
</div>
{/if}
{#if previewResult}
<div
class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary mt-2"
>
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if previewResult.isInitialSetup}
<div class="mt-2 text-green-600">
{previewResult.message || 'wmill.yaml will be created with repository settings'}
</div>
{:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0}
<div class="mt-2 space-y-1">
{#each Object.entries(previewResult.diff) as [field, change]}
<div class="flex items-start gap-2 text-2xs">
<span class="font-mono text-tertiary min-w-0 flex-shrink-0">{field}:</span>
<div class="min-w-0 flex-1">
{#if Array.isArray(change.from) || Array.isArray(change.to)}
<div class="space-y-0.5">
<div class="text-red-600">- {JSON.stringify(change.from)}</div>
<div class="text-green-600">+ {JSON.stringify(change.to)}</div>
</div>
{:else}
<span class="text-red-600">{JSON.stringify(change.from)}</span>
<span class="text-tertiary"> → </span>
<span class="text-green-600">{JSON.stringify(change.to)}</span>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="mt-2 text-tertiary">No changes found! The file is up to date.</div>
{/if}
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}>{pushJobId}</a
>
</div>
{/if}
</div>
{/if}
{/if}
</div>
+1
View File
@@ -83,6 +83,7 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL {
type HubPaths = {
gitSync: string
gitSyncTest: string
gitInitRepo: string
slackErrorHandler: string
slackRecoveryHandler: string
slackSuccessHandler: string
+1
View File
@@ -13,6 +13,7 @@
"gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill",
"gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill",
"gitSyncTest": "hub/11669/git-repo-test-read-write-windmill",
"gitInitRepo": "hub/19740/git-sync%3A-init-repository-windmill",
"slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack",
File diff suppressed because it is too large Load Diff