feat: Git manager UI — панель управления Git + HelpTooltip + ReviewToggle

- GitManager: переработан в GitWorkspacePanel с вкладками
- Добавлен GitLifecycleHeader с быстрыми действиями
- RepositoryDashboardGrid: поддержка ReviewToggle, badges, env filter
- HelpTooltip: универсальный компонент подсказок с тестами
- GitManagerModel: доработаны экшены, добавлен isReady, loadDefaultBranch
- Локализация en/ru для Git UI
- tailwind: добавлен animation-delay-200
- ConfirmDialog: a11y-атрибуты для кнопок
This commit is contained in:
2026-07-04 15:01:45 +03:00
parent 84bf0fc22b
commit 6f53599758
23 changed files with 1041 additions and 151 deletions

View File

@@ -46,6 +46,27 @@
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(() => {
@@ -90,14 +111,27 @@
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-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}`}>
<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-gradient-to-r from-slate-50 to-white px-6 py-4">
<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} />
@@ -127,7 +161,7 @@
<!-- 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'}"
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"
>
@@ -165,7 +199,7 @@
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 || 'Закрыть'}
aria-label={$t.git?.close_aria || $t.common?.close || 'Закрыть'}
>
<Icon name="close" size={16} strokeWidth={2} />
</button>
@@ -217,25 +251,26 @@
/>
<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 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 || ''} ariaLabel={$t.git?.help_aria || 'Справка'} /></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')}>
<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 ${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')} 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 || ''} />
<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 ${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')}>
<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')} 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 || ''} />
<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 ${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')}>
<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')} 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 || ''} />
<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'}
@@ -243,6 +278,7 @@
{:else}
<GitOperationsPanel isPulling={model.isPulling} isPushing={model.isPushing} workspaceStatus={model.workspaceStatus} onPull={() => model.handlePull()} onPush={() => model.handlePush()} />
{/if}
</div>
</div>
{/if}
</div>
@@ -255,7 +291,7 @@
<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="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>
@@ -333,4 +369,24 @@
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 -->