Files
windmill/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte
Alexander Petric dcc20c4fc1 git sync UI improvements (#6303)
* ui improvements round 1

* modal cleanup

* init

* UI refactor

* UI cleanup + refactor

* legacy cleanup

* success model -> github actions, non-ee warnings

* sqlx

* npm check

* ee warning everywhere

* last comments

* formatting

* no hardcoded theme

* claude review improvemenets
2025-07-30 21:36:32 +00:00

345 lines
9.5 KiB
Svelte

<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Button, Alert } from '$lib/components/common'
import { Loader2, CheckCircle2, XCircle, Terminal, ChevronDown, ChevronUp } from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { tryEvery } from '$lib/utils'
import type { SyncResponse, SettingsObject } from '$lib/git-sync'
interface Props {
open: boolean
gitRepoResourcePath: string
uiState: SettingsObject
onSuccess?: () => void
isNewConnection?: boolean
onSaveWithoutInit?: () => void
}
let {
open = $bindable(false),
gitRepoResourcePath,
uiState,
onSuccess,
isNewConnection = false,
onSaveWithoutInit
}: Props = $props()
// Job state
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
// UI state
let showCliInstructions = $state(false)
let previewResult = $state<SyncResponse | null>(null)
// Note: Escape key is handled by the Modal component itself
// Reset state when modal opens/closes
$effect(() => {
if (!open) {
previewJobId = null
previewJobStatus = undefined
isPreviewLoading = false
previewError = ''
applyJobId = null
applyJobStatus = undefined
isApplying = false
applyError = ''
showCliInstructions = false
previewResult = null
}
})
// Execute job with dry run or actual execution
async function executeJob(isDryRun: boolean) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
} else {
isApplying = true
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: false,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
let jobSuccess = false
let result: any = {}
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
}
},
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
})
if (isPreview) {
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} else {
applyJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
onSuccess?.()
} else {
applyError = 'Push failed'
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
}
}
}
</script>
<Modal bind:open title="Push Workspace to Git Repository" class="sm:max-w-4xl">
<div class="flex flex-col gap-4">
<!-- Description -->
<p class="text-sm text-secondary">Push your current workspace content to the connected Git repository based on the configured filters.</p>
<p class="text-sm text-tertiary">Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from the repository as it is the source of truth.</p>
<!-- Settings display for new connections -->
{#if isNewConnection}
<div class="bg-surface-secondary border border-border rounded-lg p-3">
<h4 class="text-sm font-medium text-primary mb-2">Settings that will be pushed to repository</h4>
<div class="text-xs text-secondary space-y-1">
<div><strong>Include paths:</strong> {uiState.include_path?.join(', ') || 'None'}</div>
<div><strong>Exclude paths:</strong> {uiState.exclude_path?.join(', ') || 'None'}</div>
{#if uiState.extra_include_path?.length > 0}
<div><strong>Extra include paths:</strong> {uiState.extra_include_path.join(', ')}</div>
{/if}
<div><strong>Include types:</strong> {uiState.include_type?.join(', ') || 'None'}</div>
</div>
<p class="text-xs text-tertiary mt-2">To modify these settings, cancel and configure them in the workspace settings.</p>
</div>
{/if}
<!-- Preview section -->
{#if !previewResult}
<div class="flex justify-start pt-4">
<Button
size="md"
color="dark"
onclick={() => executeJob(true)}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview changes'}
</Button>
</div>
{/if}
<!-- Job status for preview -->
{#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}
<!-- Preview error -->
{#if previewError}
<Alert type="error" title="Preview failed">
{previewError}
</Alert>
{/if}
<!-- Preview results -->
{#if previewResult && !previewError}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-primary">Changes to Push</h4>
{#if previewResult.changes?.length > 0}
<GitDiffPreview previewResult={previewResult} />
{:else}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-tertiary">No changes to push to the repository.</div>
</div>
{/if}
</div>
{/if}
<!-- Apply section (shown after successful preview) -->
{#if previewResult && !previewError}
{@const hasChanges = previewResult.changes?.length > 0}
{#if hasChanges}
<div class="border-t pt-4 mt-4">
<div class="flex justify-start gap-2">
<Button
size="xs"
onclick={() => executeJob(false)}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : undefined,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ?
(isNewConnection ? 'Initializing...' : 'Pushing...') :
(isNewConnection ? 'Initialize repo and save connection' : 'Push to repository')
}
</Button>
{#if isNewConnection && onSaveWithoutInit}
<Button
size="xs"
color="light"
onclick={onSaveWithoutInit}
disabled={isApplying}
>
Save without initializing repo
</Button>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Job status for apply -->
{#if applyJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if applyJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if applyJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if applyJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
>
{applyJobId}
</a>
</div>
{/if}
<!-- Apply error -->
{#if applyError}
<Alert type="error" title="Push failed">
{applyError}
</Alert>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => showCliInstructions = !showCliInstructions}
>
<Terminal size={16} />
<span>CLI Instructions</span>
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Setup (only needed if local folder not initialized yet)
npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
# Pull workspace content to git repository
wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}</pre>
</div>
{/if}
</div>
</div>
</Modal>