Files
ss-tools/frontend/src/lib/components/git/GitManager.svelte
busya 0cb1f80cd6 feat(git): implement deployment tracking and enhance lifecycle UX
Introduce a deployment recording system to track dashboard versions
across environments and improve the Git management user experience.

- Add `Deployment` model and Alembic migration to persist deployment
  history.
- Implement `GitDeploymentRecorder` and `GitFingerprint` plugins to
  automate deployment logging and content hashing.
- Add `get_deployment_status` API endpoint to retrieve real-time
  environment states.
- Refactor `GitLifecycleHeader` to prioritize Call-to-Action (CTA)
  buttons and improve visual hierarchy.
- Update `GitWorkspacePanel` to emphasize version saving and
  streamline commit workflows.
- Enhance `GitEnvironmentTimeline` with deployment status integration,
  collapsible UI, and improved theme consistency.
- Add auto-navigation logic in `GitManagerModel` to guide users to
  relevant tabs based on recommended actions.
- Clean up obsolete documentation and update i18n strings for
  git visualization features.
2026-07-12 14:57:03 +03:00

446 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- #region GitManager [C:4] [TYPE Component] [SEMANTICS git, manager, workflow, version-control, promote] -->
<!-- @ingroup Components -->
<!-- @BRIEF Central Git management UI — thin shell delegating to sub-panels and composable handlers. -->
<!-- @LAYER UI -->
<!-- @RELATION USES -> [GitInitPanel] -->
<!-- @RELATION USES -> [GitWorkspacePanel] -->
<!-- @RELATION USES -> [GitReleasePanel] -->
<!-- @RELATION USES -> [GitOperationsPanel] -->
<!-- @RELATION USES -> [GitLifecycleHeader] -->
<!-- @RELATION USES -> [GitMergeDialog] -->
<!-- @RELATION USES -> [EXT:frontend:BranchSelector] -->
<!-- @RELATION USES -> [EXT:frontend:DeploymentModal] -->
<!-- @RELATION USES -> [EXT:frontend:ConflictResolver] -->
<!-- @RELATION BINDS_TO -> [GitManagerModel] -->
<!-- @INVARIANT This component contains NO business logic — only renders model state and calls model.action(). -->
<!-- @RATIONALE Decomposed from 1220→~370 lines by extracting sub-panels, utils, and composable handlers per INV_7. -->
<!-- @REJECTED Keeping all logic inline was rejected because it exceeded 150-line contract limit. -->
<!-- @UX_STATE CheckingStatus -> Spinner. -->
<!-- @UX_STATE Uninitialized -> GitInitPanel. -->
<!-- @UX_STATE Initialized -> Tabbed workspace/release/operations. -->
<!-- @UX_STATE MergeDialog -> Recovery overlay. -->
<!-- @UX_STATE Error -> Red banner with error details and dismiss action. -->
<!-- @UX_FEEDBACK Error banner shown in modal body, persistent until dismissed or next operation. -->
<!-- @UX_FEEDBACK Toast is still shown for quick notification alongside the persistent banner. -->
<!-- @UX_RECOVERY Dismiss error, Retry operation after fixing the issue. -->
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { Button, HelpTooltip, Icon } from '$lib/ui';
import { addToast } from '$lib/toasts.svelte.js';
import { resolveDefaultConfig } from '../../../services/git-utils.js';
import { GitManagerModel } from '$lib/models/GitManagerModel.svelte.ts';
import { ROUTES } from '$lib/routes';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
import GitInitPanel from './GitInitPanel.svelte';
import GitWorkspacePanel from './GitWorkspacePanel.svelte';
import GitReleasePanel from './GitReleasePanel.svelte';
import GitOperationsPanel from './GitOperationsPanel.svelte';
import GitMergeDialog from './GitMergeDialog.svelte';
import GitLifecycleHeader from './GitLifecycleHeader.svelte';
import CreateBranchDialog from './CreateBranchDialog.svelte';
import MergeDialog from './MergeDialog.svelte';
import GitEnvironmentTimeline from './GitEnvironmentTimeline.svelte';
import BranchSelector from './BranchSelector.svelte';
let { dashboardId, envId = null, dashboardTitle = '', show = $bindable(false) } = $props();
let deployConfirmInput = $state('');
// Focus-trap: keep Tab cycling inside the modal while it is open.
let modalEl = $state<HTMLElement | null>(null);
let lastFocused: HTMLElement | null = null;
function trapFocus(e: KeyboardEvent): void {
if (e.key !== 'Tab' || !modalEl) return;
const focusable = modalEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const model = new GitManagerModel({ dashboardId, envId, dashboardTitle });
$effect(() => {
model.envId = envId;
});
$effect(() => {
model.repositoryConfigUrl = resolveDefaultConfig(model.configs, model.selectedConfigId)?.url || '';
});
function closeModal() { show = false; model.clearGitError(); }
/** Called from BranchSelector when user clicks "Merge into dev".
* Opens the MergeDialog with the given source branch targeting dev. */
function handleOpenMergeDialog(sourceBranch: string) {
model.mergeSourceBranch = sourceBranch;
model.mergeTargetBranch = 'dev';
model.showMergeDialog = true;
}
function handleRecommendedAction() {
if (model.recommendedAction === 'sync') {
model.autoNavigateTab = 'workspace';
model.activeTab = 'workspace';
void model.handleSync();
return;
}
if (model.recommendedAction === 'commit') {
model.autoNavigateTab = 'workspace';
model.activeTab = 'workspace';
return;
}
if (model.recommendedAction === 'promote') {
model.autoNavigateTab = 'release';
model.activeTab = 'release';
return;
}
model.autoNavigateTab = 'operations';
model.activeTab = 'operations';
}
$effect(() => {
if (model.showDeployConfirm) deployConfirmInput = '';
});
// ── Auto-navigate tab pulse indicator ──
let pulseTab: string | null = $state(null);
$effect(() => {
if (model.autoNavigateTab) {
pulseTab = model.autoNavigateTab;
model.autoNavigateTab = null;
const timer = setTimeout(() => { pulseTab = null; }, 2000);
return () => clearTimeout(timer);
}
});
function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); }
onMount(() => {
model.initialize();
lastFocused = document.activeElement as HTMLElement | null;
// Move focus into the modal once it renders.
queueMicrotask(() => {
if (modalEl) {
const first = modalEl.querySelector<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])');
first?.focus();
}
});
});
function handleModalClose(): void {
closeModal();
lastFocused?.focus();
}
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-surface-overlay pt-6 backdrop-blur-sm" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') handleModalClose(); if (e.key === 'Tab') trapFocus(e); }} role="dialog" aria-modal="true" tabindex="-1" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<div bind:this={modalEl} class="relative w-[95vw] max-w-[1600px] overflow-hidden rounded-xl bg-surface-card shadow-2xl" role="document" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border bg-surface-page px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary text-white shadow-sm">
<Icon name="code" size={20} strokeWidth={2} />
</div>
<div>
<h2 class="text-lg font-bold text-text">{$t.git?.management || 'Управление Git'}</h2>
<p class="text-sm text-text-muted">{dashboardTitle} <span class="text-text-subtle">·</span> slug: {dashboardId}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => model.refreshStatus()}
disabled={model.checkingStatus || model.workspaceLoading}
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-text-muted transition-colors hover:bg-surface-muted hover:text-text disabled:opacity-50"
aria-label={$t.common?.refresh || 'Refresh'}
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="h-3.5 w-3.5 {model.checkingStatus ? 'animate-spin' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M16.023 9.348H20M14.985 19.644H20M2.985 14.652V20" /></svg>
{$t.common?.refresh || 'Refresh'}
</button>
<button type="button" onclick={closeModal} class="flex h-9 w-9 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface-muted hover:text-text" aria-label={$t.common?.close || 'Close'}>
<Icon name="close" size={20} strokeWidth={2} />
</button>
</div>
</div>
<!-- Git Error Banner -->
{#if model.gitError}
<div
class="mx-6 mt-4 flex items-start gap-3 rounded-lg border p-4 text-sm shadow-sm {model.gitErrorType === 'warning' ? 'border-warning bg-warning-light text-warning' : 'border-destructive-ring bg-destructive-light text-destructive'}"
role="alert"
aria-live="polite"
>
<div class="mt-0.5 flex-shrink-0">
{#if model.gitErrorType === 'warning'}
<Icon name="warning" size={20} class="text-warning" strokeWidth={2} />
{:else}
<Icon name="error" size={20} class="text-destructive" strokeWidth={2} />
{/if}
</div>
<div class="flex-1 space-y-2">
<p class="font-medium">{model.gitError.message || model.gitError}</p>
{#if model.gitError.files?.length}
<details class="text-xs text-destructive/80">
<summary class="cursor-pointer font-medium">{($t.git?.error_files_overwritten || 'Файлы, которые будут перезаписаны ({count})').replace('{count}', String(model.gitError.files.length))}</summary>
<ul class="mt-1 list-disc space-y-0.5 pl-5">
{#each model.gitError.files as file}
<li><code class="rounded bg-destructive-light/50 px-1">{file}</code></li>
{/each}
</ul>
</details>
{/if}
{#if model.gitError.next_steps?.length}
<div class="text-xs">
<span class="font-medium">{$t.git?.error_recommendations || 'Рекомендации:'}</span>
<ol class="mt-1 list-decimal space-y-0.5 pl-5">
{#each model.gitError.next_steps as step}
<li>{step}</li>
{/each}
</ol>
</div>
{/if}
</div>
<button
type="button"
onclick={() => model.clearGitError()}
class="flex-shrink-0 rounded p-1 transition-colors {model.gitErrorType === 'warning' ? 'hover:bg-warning-light' : 'hover:bg-destructive-light'}"
aria-label={$t.git?.close_aria || $t.common?.close || 'Закрыть'}
>
<Icon name="close" size={16} strokeWidth={2} />
</button>
</div>
{/if}
<!-- Body: height fills space, scroll is inside GitWorkspacePanel diff panel -->
<div class="flex flex-col p-6" style="height: calc(92vh - 80px);">
{#if model.checkingStatus}
<div class="flex justify-center py-12"><div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-ring"></div></div>
{:else if !model.initialized}
<GitInitPanel configs={model.configs} bind:selectedConfigId={model.selectedConfigId} bind:remoteUrl={model.remoteUrl} creatingRemoteRepo={model.creatingRemoteRepo} loading={model.loading} onCreateRemoteRepo={() => model.handleCreateRemoteRepo()} onInit={() => model.handleInit()} />
{:else}
<div class="flex min-h-0 flex-1 flex-col gap-4">
{#if model.hasOriginConfigMismatch}
<div class="rounded-lg border border-warning bg-warning-light p-3 text-sm text-warning">
<div class="flex items-start justify-between gap-2">
<div>
<div class="font-semibold">{$t.git?.git_server_mismatch_title || 'Git server mismatch detected'}</div>
<div class="mt-1">{($t.git?.git_server_mismatch_desc || 'Configured: {configured}, origin: {origin}.').replace('{configured}', model.configHost).replace('{origin}', model.originHost)}</div>
<div class="mt-1.5 text-xs text-warning/80">{$t.git?.git_server_mismatch_hint || 'Push/Pull будут отправлять на настроенный сервер, а не на origin. Обновите конфигурацию в Настройках Git, если это не intended.'}</div>
</div>
<a href={ROUTES.settings.git()} class="shrink-0 rounded-md border border-warning bg-surface-card px-3 py-1.5 text-xs font-medium text-warning transition-colors hover:bg-warning-light">{$t.git?.git_server_mismatch_fix || 'Настроить'}</a>
</div>
</div>
{/if}
{#if model.commitCompleted}
<div class="rounded-lg border border-success bg-success-light p-3 text-sm text-success">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div class="flex-1">
<span class="font-medium">{$t.git?.commit_success_banner || '✅ Изменения закоммичены.'}</span>
<span class="ml-1">{$t.git?.commit_next_step || 'Следующий шаг: вкладка «Релиз» для продвижения между ветками или «Серверные операции» для Pull/Push/Deploy.'}</span>
</div>
<button type="button" onclick={() => { model.commitCompleted = false; }} class="shrink-0 rounded p-0.5 text-success hover:bg-success-light" aria-label={$t.common?.close || 'Close'}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
{/if}
<GitLifecycleHeader
currentEnvStage={model.currentWorkflowStage}
currentBranch={model.currentBranch}
changedFilesCount={model.changedFilesCount}
recommendedAction={model.recommendedAction}
preferredDeployTargetStage={model.preferredDeployTargetStage}
onRecommendedAction={handleRecommendedAction}
/>
<!-- Branch selector moved here per UX review: separate from version map to avoid confusion -->
<div class="flex items-center gap-3 mb-2">
<div class="text-xs font-medium text-text-muted uppercase tracking-wide">{$t.git?.working_branch || 'Working branch (current changes):'}</div>
<div class="flex-1 max-w-[280px]">
<BranchSelector
{dashboardId}
envId={model.resolvedEnvId}
bind:currentBranch={model.currentBranch}
onchange={(event) => model.handleBranchChanged(event)}
onmerge={handleOpenMergeDialog}
/>
</div>
</div>
<!-- BI dashboard version timeline: focused on status map. Operations below in tabs. -->
<GitEnvironmentTimeline
branches={model.branches}
environmentHistories={model.environmentHistories}
historiesLoading={model.environmentHistoriesLoading}
deploymentStatus={model.deploymentStatus}
{dashboardId}
envId={model.resolvedEnvId}
bind:currentBranch={model.currentBranch}
onBranchChange={(event) => model.handleBranchChanged(event)}
onMerge={handleOpenMergeDialog}
selectedA={model.selectedVersionA}
selectedB={model.selectedVersionB}
onSelectVersion={(hash, secondary) => model.selectVersion(hash, secondary)}
onClearSelection={() => model.clearVersionSelection()}
onRequestDiff={async () => {
const res = await model.getSelectedVersionsDiff();
if (res?.diff) {
model.workspaceDiff = res.diff;
model.activeTab = 'workspace';
addToast($t.git?.viz_diff_ready || 'Dashboard changes loaded for comparison', 'success');
}
}}
/>
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0" role="tablist" aria-label={$t.git?.management || 'Управление Git'}>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${model.activeTab === 'workspace' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'} ${pulseTab === 'workspace' ? 'animate-pulse ring-2 ring-primary-ring' : ''}`} onclick={() => (model.activeTab = 'workspace')} role="tab" aria-selected={model.activeTab === 'workspace'} aria-controls="git-tab-panel" id="git-tab-workspace" tabindex={model.activeTab === 'workspace' ? 0 : -1}>
<Icon name="edit" size={16} strokeWidth={2} />
{$t.git?.tab_workspace || 'Изменения'}
<HelpTooltip text={$t.git?.hint_workspace || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${model.activeTab === 'release' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'} ${pulseTab === 'release' ? 'animate-pulse ring-2 ring-primary-ring' : ''}`} onclick={() => (model.activeTab = 'release')} role="tab" aria-selected={model.activeTab === 'release'} aria-controls="git-tab-panel" id="git-tab-release" tabindex={model.activeTab === 'release' ? 0 : -1}>
<Icon name="lightning" size={16} strokeWidth={2} />
{$t.git?.tab_release || 'Релиз'}
<HelpTooltip text={$t.git?.hint_release || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
<button class={`relative -mb-px inline-flex items-center gap-2 rounded-t-lg px-4 py-2.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${model.activeTab === 'operations' ? 'border border-b-white bg-surface-card text-primary shadow-sm' : 'border border-transparent text-text-muted hover:bg-surface-muted hover:text-text'} ${pulseTab === 'operations' ? 'animate-pulse ring-2 ring-primary-ring' : ''}`} onclick={() => (model.activeTab = 'operations')} role="tab" aria-selected={model.activeTab === 'operations'} aria-controls="git-tab-panel" id="git-tab-operations" tabindex={model.activeTab === 'operations' ? 0 : -1}>
<Icon name="settings" size={16} strokeWidth={2} />
{$t.git?.tab_operations || 'Серверные операции'}
<HelpTooltip text={$t.git?.hint_operations || ''} ariaLabel={$t.git?.help_aria || 'Справка'} />
</button>
</div>
<div id="git-tab-panel" role="tabpanel" aria-labelledby="git-tab-workspace" class="flex min-h-0 flex-1 flex-col">
{#if model.activeTab === 'workspace'}
<GitWorkspacePanel {dashboardId} envId={model.resolvedEnvId} commitHistoryKey={model.commitHistoryKey} hasWorkspaceChanges={model.hasWorkspaceChanges} changedFilesCount={model.changedFilesCount} workspaceStatus={model.workspaceStatus} workspaceLoading={model.workspaceLoading} workspaceDiff={model.workspaceDiff} committing={model.committing} generatingMessage={model.generatingMessage} bind:commitMessage={model.commitMessage} bind:autoPushAfterCommit={model.autoPushAfterCommit} loading={model.loading} pushProviderLabel={model.pushProviderLabel} onSync={() => model.handleSync()} onGenerateMessage={() => model.handleGenerateMessage()} onCommit={() => model.handleCommit()} />
{:else if model.activeTab === 'release'}
<GitReleasePanel currentEnvStage={model.currentEnvStage} bind:promoteFromBranch={model.promoteFromBranch} bind:promoteToBranch={model.promoteToBranch} bind:promoteMode={model.promoteMode} bind:promoteReason={model.promoteReason} preferredDeployTargetStage={model.preferredDeployTargetStage} bind:showAdvancedPromote={model.showAdvancedPromote} promoting={model.promoting} onPromote={() => model.handlePromote()} onDeploy={() => model.openDeployModal()} onOpenHistory={() => (model.activeTab = 'workspace')} />
{:else}
<GitOperationsPanel isPulling={model.isPulling} isPushing={model.isPushing} workspaceStatus={model.workspaceStatus} onPull={() => model.handlePull()} onPush={() => model.handlePush()} />
{/if}
</div>
</div>
{/if}
</div>
<GitMergeDialog bind:show={model.showUnfinishedMergeDialog} unfinishedMergeContext={model.unfinishedMergeContext} mergeRecoveryLoading={model.mergeRecoveryLoading} mergeResolveInProgress={model.mergeResolveInProgress} mergeAbortInProgress={model.mergeAbortInProgress} mergeContinueInProgress={model.mergeContinueInProgress} copyingUnfinishedMergeCommands={model.copyingUnfinishedMergeCommands} onRefresh={() => model.loadMergeRecoveryState()} onCopyCommands={() => model.handleCopyUnfinishedMergeCommands()} onOpenConflictResolver={() => model.handleOpenConflictResolver()} onAbortMerge={() => model.handleAbortUnfinishedMerge()} onContinueMerge={() => model.handleContinueUnfinishedMerge()} onClose={() => model.closeUnfinishedMergeDialog()} />
</div>
</div>
{/if}
<ConflictResolver conflicts={model.mergeConflicts} bind:show={model.showConflictResolver} onresolve={(e) => model.handleResolveConflicts(e)} />
<DeploymentModal {dashboardId} envId={model.resolvedEnvId} preferredTargetStage={model.preferredDeployTargetStage} bind:show={model.showDeployModal} />
{#if model.showDeployConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay" onclick={() => { model.showDeployConfirm = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showDeployConfirm = false; }}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="alertdialog" aria-modal="true" aria-label={$t.git?.deploy || 'Deploy to Environment'} onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.deploy || 'Deploy to Environment'}</h3>
<p class="text-sm text-text-muted mb-4">{$t.git?.deploy_confirm_intro || 'Подтвердите деплой. Введите slug дашборда:'} <strong>{model.deployConfirmSlug}</strong></p>
<dl class="mb-4 grid grid-cols-1 gap-2 rounded-lg border border-border bg-surface-page p-3 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_dashboard || 'Dashboard'}</dt>
<dd class="truncate font-medium text-text">{dashboardTitle || dashboardId}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_branch || 'Source branch'}</dt>
<dd class="font-mono font-medium text-text">{model.currentBranch}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_stage || 'Lifecycle stage'}</dt>
<dd class="font-medium text-text">{model.currentWorkflowStage}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_target || 'Target'}</dt>
<dd class="font-medium text-text">{model.preferredDeployTargetStage || model.currentEnvStage || 'PROD'}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_changes || 'Changed files'}</dt>
<dd class="font-medium text-text">{model.changedFilesCount}</dd>
</div>
{#if model.workspaceStatus?.last_commit_hash}
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_last_commit || 'Last commit'}</dt>
<dd class="font-mono font-medium text-text">{model.workspaceStatus.last_commit_hash.slice(0, 8)}</dd>
</div>
{/if}
{#if model.workspaceStatus?.last_commit_author}
<div class="flex justify-between gap-3">
<dt class="text-text-muted">{$t.git?.deploy_confirm_author || 'Author'}</dt>
<dd class="truncate font-medium text-text">{model.workspaceStatus.last_commit_author}</dd>
</div>
{/if}
{#if model.workspaceStatus?.last_commit_message}
<div class="grid gap-1">
<dt class="text-text-muted">{$t.git?.deploy_confirm_message || 'Commit message'}</dt>
<dd class="line-clamp-2 text-text">{model.workspaceStatus.last_commit_message}</dd>
</div>
{/if}
</dl>
<!-- svelte-ignore a11y-autofocus -->
<input
bind:value={deployConfirmInput}
class="w-full rounded-lg border border-border-strong p-2.5 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
placeholder={model.deployConfirmSlug}
onkeydown={(e) => { if (e.key === 'Enter' && deployConfirmInput.trim()) model.confirmDeploy(deployConfirmInput); }}
autofocus
/>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { model.showDeployConfirm = false; deployConfirmInput = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button variant="destructive" onclick={() => model.confirmDeploy(deployConfirmInput)} disabled={!deployConfirmInput.trim()}>{$t.common?.confirm || 'Confirm'}</Button>
</div>
</div>
</div>
{/if}
<!-- Create Branch Dialog -->
<CreateBranchDialog
bind:show={model.showCreateBranchDialog}
{dashboardId}
{envId}
branches={model.branches}
oncreated={() => model.refreshBranches()}
/>
<!-- Feature/Hotfix Merge Dialog -->
<MergeDialog
bind:show={model.showMergeDialog}
{dashboardId}
{envId}
sourceBranch={model.mergeSourceBranch}
targetBranch={model.mergeTargetBranch || 'dev'}
onmerged={() => model.refreshBranches()}
/>
<!-- Create Remote Repo Dialog (replaces native prompt()) -->
{#if model.showCreateRepoDialog}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) model.showCreateRepoDialog = false; }} onkeydown={(e) => { if (e.key === 'Escape') model.showCreateRepoDialog = false; }} role="dialog" aria-modal="true" aria-label={$t.git?.create_repo_dialog?.title || 'Create repository'}>
<div class="bg-surface-card rounded-xl shadow-xl p-6 max-w-md w-full mx-4 border border-border" role="document">
<h3 class="text-lg font-semibold text-text mb-2">{$t.git?.create_repo_dialog?.title || 'Create repository'}</h3>
<p class="text-sm text-text-muted mb-4">{($t.git?.create_repo_dialog?.name_prompt || 'Repository name for {provider}:').replace('{provider}', model.createRepoProviderLabel)}</p>
<input
bind:value={model.pendingRepoName}
class="w-full rounded-lg border border-border-strong p-2.5 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
placeholder={$t.git?.create_repo_dialog?.name_label || 'Repository name'}
onkeydown={(e) => { if (e.key === 'Enter' && model.pendingRepoName.trim()) model.confirmCreateRemoteRepo(); }}
/>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { model.showCreateRepoDialog = false; model.pendingRepoName = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={() => model.confirmCreateRemoteRepo()} disabled={!model.pendingRepoName.trim()}>{$t.git?.create_repo_dialog?.confirm || 'Create'}</Button>
</div>
</div>
</div>
{/if}
<!-- #endregion GitManager -->