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.
446 lines
30 KiB
Svelte
446 lines
30 KiB
Svelte
<!-- #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 -->
|