Files
ss-tools/frontend/src/lib/components/git/GitManager.svelte
busya db998ce085 feat(semantic): curator-driven protocol hardening — decision memory + relation repair
- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models
- Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs)
- Add 13 @ingroup tags for DSA/HCA attention grouping
- Repair 29 stale graph edges via index rebuild
- Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance
- Git integration: merge routes, branch lifecycle, remote providers, UX components
- 0 broken anchor pairs, index rebuilt with 0 parse warnings
2026-07-02 08:53:19 +03:00

337 lines
24 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 { resolveDefaultConfig } from '../../../services/git-utils.js';
import { GitManagerModel } from '$lib/models/GitManagerModel.svelte.ts';
import { ROUTES } from '$lib/routes';
import BranchSelector from './BranchSelector.svelte';
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';
let { dashboardId, envId = null, dashboardTitle = '', show = $bindable(false) } = $props();
let deployConfirmInput = $state('');
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') {
void model.handleSync();
model.activeTab = 'workspace';
return;
}
if (model.recommendedAction === 'commit') {
model.activeTab = 'workspace';
return;
}
if (model.recommendedAction === 'promote') {
model.activeTab = 'release';
return;
}
model.activeTab = 'operations';
}
$effect(() => {
if (model.showDeployConfirm) deployConfirmInput = '';
});
function handleBackdropClick(e) { if (e.target === e.currentTarget) closeModal(); }
onMount(() => {
model.initialize();
});
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 pt-6 backdrop-blur-sm" onclick={handleBackdropClick} onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }} role="dialog" tabindex="-1" aria-label={`${$t.git?.management || 'Управление Git'}: ${dashboardTitle}`}>
<div 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-gradient-to-r from-slate-50 to-white 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" 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-amber-900' : '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.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}
/>
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-page px-4 py-3">
<div class="text-sm font-medium text-text">{$t.git?.branch_label || 'Ветка:'}</div>
<div class="flex w-full items-center gap-1 sm:w-72"><BranchSelector {dashboardId} envId={model.resolvedEnvId} bind:currentBranch={model.currentBranch} onchange={(event) => model.handleBranchChanged(event)} onmerge={handleOpenMergeDialog} /><HelpTooltip text={$t.git?.hint_branch_selector || ''} /></div>
</div>
<div class="flex flex-wrap items-center gap-1 border-b border-border pb-0">
<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 ${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'}`} onclick={() => (model.activeTab = 'workspace')}>
<Icon name="edit" size={16} strokeWidth={2} />
{$t.git?.tab_workspace || 'Фиксация изменений'}
<HelpTooltip text={$t.git?.hint_workspace || ''} />
</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 ${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'}`} onclick={() => (model.activeTab = 'release')}>
<Icon name="lightning" size={16} strokeWidth={2} />
{$t.git?.tab_release || 'Релиз'}
<HelpTooltip text={$t.git?.hint_release || ''} />
</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 ${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'}`} onclick={() => (model.activeTab = 'operations')}>
<Icon name="settings" size={16} strokeWidth={2} />
{$t.git?.tab_operations || 'Серверные операции'}
<HelpTooltip text={$t.git?.hint_operations || ''} />
</button>
</div>
{#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>
{/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-black/50" 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()}
/>
<!-- #endregion GitManager -->