mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-04 00:03:02 +00:00
644 lines
20 KiB
Svelte
644 lines
20 KiB
Svelte
<script lang="ts">
|
|
import Modal from '$lib/components/common/modal/Modal.svelte'
|
|
import { Button, Alert, Badge } from '$lib/components/common'
|
|
import {
|
|
Loader2,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Terminal,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Save,
|
|
Edit3
|
|
} from 'lucide-svelte'
|
|
import GitDiffPreview from '../GitDiffPreview.svelte'
|
|
import { JobService } from '$lib/gen'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import hubPaths from '$lib/hubPaths.json'
|
|
import { jobManager } from '$lib/services/JobManager'
|
|
import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
gitRepoResourcePath: string
|
|
uiState: SettingsObject
|
|
repoIndex?: number
|
|
currentGitSyncSettings?: any
|
|
onFilterUpdate?: (filters: SettingsObject) => void
|
|
onSettingsSaved?: () => void
|
|
onSuccess?: () => void
|
|
settingsOnly?: boolean
|
|
}
|
|
|
|
let {
|
|
open = $bindable(false),
|
|
gitRepoResourcePath,
|
|
uiState,
|
|
repoIndex,
|
|
currentGitSyncSettings,
|
|
onFilterUpdate,
|
|
onSettingsSaved,
|
|
onSuccess,
|
|
settingsOnly = false
|
|
}: 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 | SettingsResponse | null>(null)
|
|
let settingsApplied = $state(false)
|
|
let previewAttempted = $state(false)
|
|
|
|
// Helper functions to reduce type casting repetition
|
|
const getSettingsChanges = (result: SyncResponse | SettingsResponse | null) => {
|
|
if (!result) return { hasChanges: false, data: null, diff: null }
|
|
|
|
if (settingsOnly) {
|
|
const settingsResponse = result as SettingsResponse
|
|
return {
|
|
hasChanges: settingsResponse.hasChanges ?? false,
|
|
data: settingsResponse.local,
|
|
diff: settingsResponse
|
|
}
|
|
} else {
|
|
const syncResponse = result as SyncResponse
|
|
return {
|
|
hasChanges: syncResponse.settingsDiffResult?.hasChanges ?? false,
|
|
data: syncResponse.settingsDiffResult?.local,
|
|
diff: syncResponse.settingsDiffResult
|
|
}
|
|
}
|
|
}
|
|
|
|
const getWorkspaceChanges = (result: SyncResponse | SettingsResponse | null) => {
|
|
if (!result || settingsOnly) return { hasChanges: false, changes: [] }
|
|
const syncResponse = result as SyncResponse
|
|
return {
|
|
hasChanges: (syncResponse.changes?.length ?? 0) > 0,
|
|
changes: syncResponse.changes ?? []
|
|
}
|
|
}
|
|
|
|
// Auto-save settings after successful pull with settings updates
|
|
async function saveUpdatedSettings() {
|
|
if (!currentGitSyncSettings || repoIndex === undefined) return
|
|
|
|
try {
|
|
// Save only the specific repository that was updated
|
|
await currentGitSyncSettings.saveRepository(repoIndex)
|
|
onSettingsSaved?.()
|
|
} catch (error) {
|
|
console.error('Failed to save settings:', error)
|
|
sendUserToast('Failed to save updated settings', true)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
settingsApplied = false
|
|
previewAttempted = false
|
|
} else if (settingsOnly && !previewResult && !isPreviewLoading && !previewAttempted) {
|
|
// Auto-trigger settings preview when modal opens in settings-only mode (only once)
|
|
executeJob(true, true)
|
|
}
|
|
})
|
|
|
|
// Execute job with dry run or actual execution
|
|
async function executeJob(isDryRun: boolean, settingsOnly: boolean = false) {
|
|
const isPreview = isDryRun
|
|
|
|
if (isPreview) {
|
|
isPreviewLoading = true
|
|
previewError = ''
|
|
previewResult = null
|
|
previewJobId = null
|
|
previewJobStatus = undefined
|
|
previewAttempted = true
|
|
} 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: true,
|
|
only_wmill_yaml: settingsOnly,
|
|
settings_json: JSON.stringify(uiState),
|
|
use_promotion_overrides:
|
|
currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch === true
|
|
}
|
|
|
|
const jobId = await JobService.runScriptByPath({
|
|
workspace,
|
|
path: hubPaths.gitInitRepo,
|
|
requestBody: payload,
|
|
skipPreprocessor: true
|
|
})
|
|
|
|
if (isPreview) {
|
|
previewJobId = jobId
|
|
previewJobStatus = 'running'
|
|
} else {
|
|
applyJobId = jobId
|
|
applyJobStatus = 'running'
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Handle failure status
|
|
if (status.status === 'failure') {
|
|
if (isPreview) {
|
|
previewError = status.error || 'Preview failed'
|
|
} else {
|
|
applyError = status.error || 'Pull failed'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle successful result
|
|
if (isPreview) {
|
|
if (previewJobStatus === 'success') {
|
|
previewResult = result as SyncResponse | SettingsResponse
|
|
}
|
|
} else {
|
|
if (applyJobStatus === 'success') {
|
|
const settingsData = (result as any)?.local
|
|
const hasSettingsChanges = settingsData && onFilterUpdate
|
|
if (hasSettingsChanges) {
|
|
onFilterUpdate(settingsData)
|
|
await saveUpdatedSettings()
|
|
}
|
|
onSuccess?.()
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply settings only (no job needed - we have the data from preview)
|
|
async function applySettingsOnly() {
|
|
isApplying = true
|
|
|
|
try {
|
|
const settingsChanges = getSettingsChanges(previewResult)
|
|
|
|
if (!settingsChanges.hasChanges) {
|
|
sendUserToast('No settings changes to apply', true)
|
|
return
|
|
}
|
|
|
|
if (!settingsChanges.data) {
|
|
sendUserToast('Settings data not available', true)
|
|
return
|
|
}
|
|
|
|
// Update the UI state with the new settings
|
|
if (onFilterUpdate) {
|
|
onFilterUpdate(settingsChanges.data)
|
|
}
|
|
|
|
// Save the updated settings
|
|
await saveUpdatedSettings()
|
|
|
|
if (settingsOnly) {
|
|
// Settings-only mode - we're done, onSuccess will handle the toast
|
|
onSuccess?.()
|
|
} else {
|
|
// Two-step flow - transition to step 2
|
|
settingsApplied = true
|
|
sendUserToast('Settings applied successfully. You can now review workspace changes.')
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to apply settings:', error)
|
|
sendUserToast('Failed to apply settings: ' + error.message, true)
|
|
} finally {
|
|
isApplying = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Modal
|
|
bind:open
|
|
title={settingsOnly ? 'Pull Settings from Git Repository' : 'Pull Workspace from Git Repository'}
|
|
class="sm:max-w-4xl"
|
|
cancelText={settingsOnly && !getSettingsChanges(previewResult).hasChanges ? 'Close' : 'Cancel'}
|
|
>
|
|
<div class="flex flex-col gap-4">
|
|
<!-- Description -->
|
|
<p class="text-sm text-secondary">
|
|
{#if settingsOnly}
|
|
Pull and apply settings changes from the Git repository to your workspace. This will update
|
|
your sync filter settings only.
|
|
{:else}
|
|
Pull and apply changes from the Git repository to your workspace. If settings changes are
|
|
detected, you can choose to pull just the settings or everything.
|
|
{/if}
|
|
</p>
|
|
|
|
<!-- Warning about overwrites - only show for full pulls, not settings-only -->
|
|
{#if !settingsOnly}
|
|
<Alert type="warning" title="This will overwrite local changes">
|
|
Pulling from the repository will overwrite any local changes to files that exist in the
|
|
repository. Make sure to preview the changes before applying.
|
|
</Alert>
|
|
{/if}
|
|
|
|
<!-- Preview section -->
|
|
{#if !previewResult}
|
|
<div class="flex justify-start pt-4">
|
|
<Button
|
|
size="md"
|
|
variant="accent"
|
|
onclick={() => {
|
|
previewAttempted = false // Allow manual retry
|
|
executeJob(true, settingsOnly)
|
|
}}
|
|
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-primary">
|
|
{#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}
|
|
{@const settingsChanges = getSettingsChanges(previewResult)}
|
|
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
|
|
<div class="space-y-4">
|
|
<!-- Settings changes (always show first if present) -->
|
|
{#if settingsChanges.hasChanges && !settingsApplied}
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-primary mb-2">
|
|
Filter Settings from Repository
|
|
<Badge color="blue" size="xs" class="ml-2">wmill.yaml</Badge>
|
|
</h4>
|
|
|
|
<div class="bg-surface-secondary rounded-lg p-4 space-y-1">
|
|
{#if settingsChanges.diff?.diff}
|
|
{#each Object.entries(settingsChanges.diff.diff) as [field, change]}
|
|
{@const fieldName = field
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/^./, (str) => str.toUpperCase())}
|
|
{@const typedChange = change as { from: any; to: any }}
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="text-primary min-w-0 flex-shrink-0">{fieldName}:</span>
|
|
{#if Array.isArray(typedChange.from) && Array.isArray(typedChange.to)}
|
|
<span class="text-red-600"
|
|
>{typedChange.from.length === 0
|
|
? 'None'
|
|
: typedChange.from.join(', ')}</span
|
|
>
|
|
<span class="text-primary">→</span>
|
|
<span class="text-green-600"
|
|
>{typedChange.to.length === 0 ? 'None' : typedChange.to.join(', ')}</span
|
|
>
|
|
{:else}
|
|
<span class="text-red-600">{typedChange.from}</span>
|
|
<span class="text-primary">→</span>
|
|
<span class="text-green-600">{typedChange.to}</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
<div class="text-xs text-primary">
|
|
Settings changes detected but no detailed diff available.
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- No settings changes detected (settings-only mode) -->
|
|
{#if settingsOnly && !settingsChanges.hasChanges}
|
|
<div class="bg-surface-secondary rounded-lg p-3">
|
|
<div class="text-sm text-primary">
|
|
No settings changes detected. Your local sync filter settings are already up to date
|
|
with the repository. See the CLI instructions below on how to edit wmill.yaml
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-start">
|
|
<Button
|
|
size="sm"
|
|
variant="accent"
|
|
onclick={() => {
|
|
previewAttempted = false
|
|
previewResult = null
|
|
executeJob(true, settingsOnly)
|
|
}}
|
|
disabled={isPreviewLoading}
|
|
startIcon={{
|
|
icon: isPreviewLoading ? Loader2 : undefined,
|
|
classes: isPreviewLoading ? 'animate-spin' : ''
|
|
}}
|
|
>
|
|
{isPreviewLoading ? 'Previewing...' : 'Preview again'}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Workspace changes (show when no settings changes and there are workspace changes) -->
|
|
{#if !settingsOnly && !settingsChanges.hasChanges && workspaceChanges.hasChanges}
|
|
<div class={settingsChanges.hasChanges && settingsApplied ? 'border-t pt-4' : ''}>
|
|
<h4 class="text-sm font-semibold text-primary mb-2">Workspace changes to pull</h4>
|
|
|
|
{#if workspaceChanges.hasChanges}
|
|
<GitDiffPreview previewResult={previewResult as SyncResponse} />
|
|
{:else}
|
|
<div class="bg-surface-secondary rounded-lg p-3">
|
|
<div class="text-sm text-primary">No changes to pull from the repository.</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Apply section (shown after successful preview) -->
|
|
{#if previewResult && !previewError}
|
|
{@const settingsChanges = getSettingsChanges(previewResult)}
|
|
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
|
|
|
|
{#if settingsChanges.hasChanges || workspaceChanges.hasChanges}
|
|
<div class="border-t pt-4 mt-4">
|
|
{#if settingsChanges.hasChanges && workspaceChanges.hasChanges && !settingsApplied}
|
|
<!-- Step 1: Settings changes first when both are present -->
|
|
<div class="flex flex-col gap-3">
|
|
<div class="text-sm font-medium text-primary">Step 1 of 2: Apply settings changes</div
|
|
>
|
|
<div class="text-xs text-primary"
|
|
>Settings changes detected. Apply these first to ensure workspace content is pulled
|
|
with the correct configuration.</div
|
|
>
|
|
<div class="flex gap-2">
|
|
<Button
|
|
size="md"
|
|
onclick={applySettingsOnly}
|
|
disabled={isApplying}
|
|
startIcon={{
|
|
icon: isApplying ? Loader2 : Save,
|
|
classes: isApplying ? 'animate-spin' : ''
|
|
}}
|
|
>
|
|
{isApplying ? 'Applying...' : 'Apply settings'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else if settingsChanges.hasChanges && !workspaceChanges.hasChanges && !settingsApplied}
|
|
<!-- Only settings changes -->
|
|
<div class="flex gap-2">
|
|
<Button
|
|
size="md"
|
|
onclick={applySettingsOnly}
|
|
disabled={isApplying}
|
|
startIcon={{
|
|
icon: isApplying ? Loader2 : Save,
|
|
classes: isApplying ? 'animate-spin' : ''
|
|
}}
|
|
>
|
|
{isApplying ? 'Applying...' : 'Apply settings'}
|
|
</Button>
|
|
</div>
|
|
{:else if workspaceChanges.hasChanges && (!settingsChanges.hasChanges || settingsApplied)}
|
|
<!-- Step 2: Workspace changes (either no settings changes, or settings already applied) -->
|
|
<div class="flex flex-col gap-3">
|
|
{#if settingsApplied}
|
|
<div class="text-sm font-medium text-primary"
|
|
>Step 2 of 2: Pull Workspace Changes</div
|
|
>
|
|
<div class="text-xs text-green-600"
|
|
>✓ Settings applied successfully. Now you can pull the workspace changes.</div
|
|
>
|
|
{/if}
|
|
<div class="flex gap-2">
|
|
<Button
|
|
size="md"
|
|
onclick={() => executeJob(false, false)}
|
|
disabled={isApplying}
|
|
startIcon={{
|
|
icon: isApplying ? Loader2 : Save,
|
|
classes: isApplying ? 'animate-spin' : ''
|
|
}}
|
|
>
|
|
{isApplying ? 'Pulling...' : 'Pull from repository'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- No changes to pull -->
|
|
<div class="bg-surface-secondary rounded-lg p-3">
|
|
<div class="text-sm text-primary mb-3">No changes to pull from the repository.</div>
|
|
<Button
|
|
size="sm"
|
|
variant="accent"
|
|
onclick={() => {
|
|
previewAttempted = false
|
|
previewResult = null
|
|
executeJob(true, settingsOnly)
|
|
}}
|
|
disabled={isPreviewLoading}
|
|
startIcon={{
|
|
icon: isPreviewLoading ? Loader2 : undefined,
|
|
classes: isPreviewLoading ? 'animate-spin' : ''
|
|
}}
|
|
>
|
|
{isPreviewLoading ? 'Previewing...' : 'Preview again'}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Job status for apply -->
|
|
{#if applyJobId}
|
|
<div class="flex items-center gap-2 text-xs text-primary">
|
|
{#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}
|
|
Pull job:
|
|
<a
|
|
target="_blank"
|
|
class="underline"
|
|
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
|
|
>
|
|
{applyJobId}
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Apply error -->
|
|
{#if applyError}
|
|
<Alert type="error" title="Pull 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} />
|
|
{#if settingsOnly}
|
|
<span>Update settings with CLI</span>
|
|
{:else}
|
|
<span>Update workspace with CLI</span>
|
|
{/if}
|
|
<Edit3 size={14} class="text-primary" />
|
|
{#if showCliInstructions}
|
|
<ChevronUp size={16} />
|
|
{:else}
|
|
<ChevronDown size={16} />
|
|
{/if}
|
|
</button>
|
|
|
|
{#if showCliInstructions}
|
|
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
|
|
{#if settingsOnly}
|
|
<div class="text-xs text-primary mb-2">
|
|
Filter settings are sourced from the <code class="bg-surface px-1 py-0.5 rounded"
|
|
>wmill.yaml</code
|
|
>
|
|
file in your git repository. To modify them, edit the file in your repository, commit the
|
|
changes, and sync using the commands below. Learn more about
|
|
<a
|
|
href="https://www.windmill.dev/docs/advanced/cli/sync#wmillyaml"
|
|
target="_blank"
|
|
rel="noopener noreferrer">the wmill.yaml format</a
|
|
>.
|
|
</div>
|
|
{/if}
|
|
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
|
|
# Make sure your repo is up to date
|
|
git pull
|
|
|
|
{#if !settingsOnly}# Push from git repository to workspace
|
|
wmill sync push --workspace {$workspaceStore} --repository {gitRepoResourcePath}
|
|
{:else}# Edit wmill.yaml file
|
|
vim wmill.yaml
|
|
git add wmill.yaml
|
|
|
|
{/if}# Commit changes
|
|
git commit
|
|
git push
|
|
{#if settingsOnly}
|
|
# Push settings only from git repository or click the pull settings button above{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch}
|
|
wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath} --promotion main{:else}
|
|
wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath}{/if}{/if}</pre
|
|
>
|
|
{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch && settingsOnly}
|
|
<div class="text-xs text-primary mt-3">
|
|
<div class="font-medium mb-1">Promotion Mode Configuration:</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
>You can add promotion-specific overrides in your <code
|
|
class="bg-surface px-1 py-0.5 rounded">wmill.yaml</code
|
|
> file:</span
|
|
>
|
|
</div>
|
|
<pre class="text-xs bg-surface p-2 rounded mt-2 overflow-x-auto"
|
|
>gitBranches:
|
|
main:
|
|
promotionOverrides:
|
|
# Add your promotion-specific settings here
|
|
</pre>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Modal>
|