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

@@ -30,6 +30,8 @@
statusMode = "dashboard" as "dashboard" | "repository",
envId = null as string | null,
repositoriesOnly = false,
/** Called when bulk-delete removes dashboards from the list — parent owns the data. */
onDashboardsChanged = (_next: DashboardMetadata[]) => {},
} = $props();
// [/SECTION]
@@ -43,8 +45,14 @@
let gitDashboardTitle = $state("");
let repositoryStatusByDashboardId = $state<Record<number, string>>({});
let repositoryStatusRequestId = $state(0);
let repositoryStatusFetching = $state(false);
let bulkActionRunning = $state(false);
let showBulkDeleteConfirm = $state(false);
// Bulk-commit dialog (replaces native prompt())
let showBulkCommitDialog = $state(false);
let bulkCommitMessage = $state("");
// Bulk-action confirmation dialogs (sync/pull/push)
let bulkConfirmAction = $state<null | "sync" | "pull" | "push">(null);
// [/SECTION]
// ── Column definitions ─────────────────────────────────────────
@@ -108,9 +116,16 @@
repositoryStatusByDashboardId = {};
return;
}
const requestId = ++repositoryStatusRequestId;
// Load statuses for all pre-filtered data (not just current page — DashboardDataGrid owns pagination)
// P2 #7: skip if a fetch is already in progress, or all IDs already have resolved statuses.
if (repositoryStatusFetching) return;
const allIds = dashboards.map(d => d.id);
const allResolved = allIds.every(id => {
const tkn = repositoryStatusByDashboardId[id];
return tkn !== undefined && tkn !== "loading";
});
if (allResolved) return;
const requestId = ++repositoryStatusRequestId;
repositoryStatusFetching = true;
const missingIds = allIds.filter(id => {
const tkn = repositoryStatusByDashboardId[id];
return tkn === undefined || tkn === "loading";
@@ -130,11 +145,12 @@
} catch {
entries = missingIds.map((id) => [id, "error"] as const);
}
if (requestId !== repositoryStatusRequestId) return;
if (requestId !== repositoryStatusRequestId) { repositoryStatusFetching = false; return; }
repositoryStatusByDashboardId = {
...repositoryStatusByDashboardId,
...Object.fromEntries(entries),
};
repositoryStatusFetching = false;
}
// #endregion loadRepositoryStatuses:Function
@@ -166,18 +182,31 @@
// #endregion runBulkGitAction:Function
// #region handleBulkAction:Function [TYPE Function]
async function handleBulkSync() { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
async function handleBulkCommit() {
const msg = prompt($t.git?.commit_message);
if (!msg?.trim()) return;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg.trim(), [], envId));
async function handleBulkSync() { bulkConfirmAction = "sync"; }
async function handleBulkPull() { bulkConfirmAction = "pull"; }
async function handleBulkPush() { bulkConfirmAction = "push"; }
async function executeBulkConfirmed() {
const action = bulkConfirmAction;
bulkConfirmAction = null;
if (action === "sync") { await runBulkGitAction("sync", (id) => gitService.sync(id, null, envId)); }
else if (action === "pull") { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
else if (action === "push") { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
}
function openBulkCommitDialog() {
bulkCommitMessage = "";
showBulkCommitDialog = true;
}
async function confirmBulkCommit() {
const msg = bulkCommitMessage.trim();
if (!msg) return;
showBulkCommitDialog = false;
await runBulkGitAction("commit", (id) => gitService.commit(id, msg, [], envId));
}
async function handleBulkPull() { await runBulkGitAction("pull", (id) => gitService.pull(id, envId)); }
async function handleBulkPush() { await runBulkGitAction("push", (id) => gitService.push(id, envId)); }
async function onConfirmBulkDelete() {
const idsToDelete = [...selectedIds];
await runBulkGitAction("delete", (id) => gitService.deleteRepository(id, envId));
dashboards = dashboards.filter((d) => !idsToDelete.includes(d.id));
// P2 #9: do NOT mutate the prop directly — delegate to parent via callback.
onDashboardsChanged(dashboards.filter((d) => !idsToDelete.includes(d.id)));
selectedIds = [];
}
// #endregion handleBulkAction:Function
@@ -302,7 +331,7 @@
{$t.git?.bulk_sync}
</Button>
{#if !repositoriesOnly}
<Button size="sm" variant="secondary" onclick={handleBulkCommit} disabled={bulkActionRunning}>
<Button size="sm" variant="secondary" onclick={openBulkCommitDialog} disabled={bulkActionRunning}>
{$t.git?.bulk_commit}
</Button>
{/if}
@@ -337,13 +366,47 @@
<ConfirmDialog
bind:show={showBulkDeleteConfirm}
title="Delete repositories?"
title={$t.git?.delete_repo || "Delete repositories?"}
message={$t.git?.confirm_delete_repo || "Delete selected repositories?"}
variant="destructive"
confirmLabel="Delete"
cancelLabel="Cancel"
confirmLabel={$t.git?.delete_repo || "Delete"}
cancelLabel={$t.common?.cancel || "Cancel"}
onConfirm={onConfirmBulkDelete}
onCancel={() => {}}
/>
<!-- Bulk-commit dialog (replaces native prompt()) -->
{#if showBulkCommitDialog}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) showBulkCommitDialog = false; }} onkeydown={(e) => { if (e.key === 'Escape') showBulkCommitDialog = false; }} role="dialog" aria-modal="true" aria-label={$t.git?.bulk_commit_dialog?.title || 'Bulk commit'}>
<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?.bulk_commit_dialog?.title || 'Bulk commit'}</h3>
<p class="text-sm text-text-muted mb-4">{$t.git?.bulk_commit_dialog?.message || 'Enter a commit message for the selected dashboards:'}</p>
<textarea
bind:value={bulkCommitMessage}
class="h-28 w-full resize-none rounded-lg border border-border p-3 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring mb-4"
placeholder={$t.git?.bulk_commit_dialog?.placeholder || 'Commit message...'}
onkeydown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && bulkCommitMessage.trim()) confirmBulkCommit(); }}
></textarea>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { showBulkCommitDialog = false; bulkCommitMessage = ''; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={confirmBulkCommit} disabled={!bulkCommitMessage.trim()}>{$t.git?.bulk_commit_dialog?.confirm || 'Commit'}</Button>
</div>
</div>
</div>
{/if}
<!-- Bulk-action confirmation (sync/pull/push) -->
{#if bulkConfirmAction}
<div class="fixed inset-0 z-[60] flex items-center justify-center bg-surface-overlay" onclick={(e) => { if (e.target === e.currentTarget) bulkConfirmAction = null; }} onkeydown={(e) => { if (e.key === 'Escape') bulkConfirmAction = null; }} role="dialog" aria-modal="true" aria-label={$t.git?.bulk_confirm?.[`${bulkConfirmAction}_title`] || bulkConfirmAction}>
<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?.bulk_confirm?.[`${bulkConfirmAction}_title`] || bulkConfirmAction}</h3>
<p class="text-sm text-text-muted mb-4">{($t.git?.bulk_confirm?.[`${bulkConfirmAction}_message`] || '').replace('{count}', String(selectedIds.length))}</p>
<div class="flex justify-end gap-3">
<Button variant="secondary" onclick={() => { bulkConfirmAction = null; }}>{$t.common?.cancel || 'Cancel'}</Button>
<Button onclick={executeBulkConfirmed}>{$t.git?.bulk_confirm?.confirm || 'Confirm'}</Button>
</div>
</div>
</div>
{/if}
<!-- #endregion Dashboard.RepositoryGrid -->

View File

@@ -234,7 +234,7 @@
</ul>
{/if}
</div>
<button type="button" onclick={() => model.branchError = null} class="flex-shrink-0 rounded p-0.5 hover:bg-destructive-light" aria-label="Закрыть">
<button type="button" onclick={() => model.branchError = null} class="flex-shrink-0 rounded p-0.5 hover:bg-destructive-light" aria-label={$t.git?.close_aria || $t.common?.close || 'Закрыть'}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" 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>

View File

@@ -171,7 +171,7 @@
<!-- Rollback confirmation modal -->
{#if showRollbackConfirm}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={cancelRollback}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay" onclick={cancelRollback}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-surface-card p-5 shadow-2xl" onclick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" aria-label={($t.git?.rollback || 'Rollback') + ' ' + (rollbackConfirmHash?.substring(0, 7) || '')}>
<h3 class="text-lg font-semibold text-text mb-2">

View File

@@ -36,7 +36,7 @@
{#if show}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50 p-4"
>
<div
class="bg-surface-card p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"

View File

@@ -80,7 +80,7 @@
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50 p-4"
>
<div
class="bg-surface-card p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"

View File

@@ -113,7 +113,7 @@
{#if show}
<div
class="absolute inset-0 z-30 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-30 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={dialogT().title || 'Create new branch'}

View File

@@ -43,7 +43,7 @@
</script>
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="fixed inset-0 bg-surface-overlay flex items-center justify-center z-50">
<div class="bg-surface-card p-6 rounded-lg shadow-xl w-96">
<h2 class="text-xl font-bold mb-4">{$t.git?.deploy}</h2>

View File

@@ -29,7 +29,7 @@
if (recommendedAction === 'commit') return release.action_commit || 'Save version';
if (recommendedAction === 'promote') {
const label = release.action_promote || 'Promote to {stage}';
return String(label).replace('{stage}', preferredDeployTargetStage || promoteToBranch);
return String(label).replace('{stage}', preferredDeployTargetStage || 'next stage');
}
if (recommendedAction === 'deploy') return release.action_deploy || 'Deploy';
if (recommendedAction === 'review') return release.action_review || 'Review state';

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 -->

View File

@@ -29,13 +29,13 @@
{#if show && unfinishedMergeContext}
<div
class="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-20 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
>
<div class="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-warning bg-surface-card p-5 shadow-2xl">
<div class="mb-3 text-lg font-semibold text-amber-900">
<div class="mb-3 text-lg font-semibold text-warning">
{$t.git?.unfinished_merge?.title || 'Repository has an unfinished merge'}
</div>
<p class="mb-4 text-sm text-text">

View File

@@ -52,21 +52,7 @@
onCommit,
} = $props();
let commitTemplates = $derived([
{
label: $t.git?.commit_template_layout || 'Layout',
message: $t.git?.commit_template_layout_message || 'Update dashboard layout and visual arrangement',
},
{
label: $t.git?.commit_template_charts || 'Charts',
message: $t.git?.commit_template_charts_message || 'Update dashboard charts and metrics',
},
{
label: $t.git?.commit_template_filters || 'Filters',
message: $t.git?.commit_template_filters_message || 'Update dashboard filters and metadata',
},
]);
const changeCategoryRules = [
{ key: 'charts', test: (path: string) => path.includes('/charts/') || path.startsWith('charts/') },
{ key: 'datasets', test: (path: string) => path.includes('/datasets/') || path.startsWith('datasets/') },
@@ -167,6 +153,9 @@
return () => observer.disconnect();
});
// Diff view mode: line-by-line is default (lighter DOM, better a11y); user can toggle to side-by-side.
let diffViewMode = $state<'line-by-line' | 'side-by-side'>('line-by-line');
// Render only visible chunks via Diff2Html individually
let renderedHtml = $derived.by(() => {
const count = renderedChunkCount;
@@ -174,7 +163,7 @@
const visible = diffChunks.slice(0, count).join('\n');
try {
return Diff2Html.html(visible, {
outputFormat: 'side-by-side',
outputFormat: diffViewMode,
drawFileList: true,
matching: 'lines',
highlight: true,
@@ -193,6 +182,29 @@
if (!query) return lines.slice(0, 600);
return lines.filter((line) => line.toLowerCase().includes(query)).slice(0, 600);
});
// ── Scroll to raw diff (skip-link replacement for a11y) ────────
let rawDiffDetailsEl = $state<HTMLDetailsElement | null>(null);
let diffContainerEl = $state<HTMLDivElement | null>(null);
function scrollToRawDiff(): void {
if (!rawDiffDetailsEl) return;
rawDiffDetailsEl.open = true;
// After opening, scroll the diff container to the raw diff element.
requestAnimationFrame(() => {
if (diffContainerEl && rawDiffDetailsEl) {
const containerRect = diffContainerEl.getBoundingClientRect();
const targetRect = rawDiffDetailsEl.getBoundingClientRect();
diffContainerEl.scrollBy({
top: targetRect.top - containerRect.top - 16,
behavior: 'smooth',
});
// Focus the search input inside the raw diff.
const input = rawDiffDetailsEl.querySelector<HTMLInputElement>('input');
requestAnimationFrame(() => input?.focus());
}
});
}
</script>
<div class="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
@@ -230,20 +242,6 @@
class={`h-32 w-full resize-none rounded-lg border border-border p-3 text-sm outline-none transition-colors focus:border-primary-ring focus:ring-2 focus:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-surface-page' : 'bg-surface-card'}`}
placeholder={$t.git?.describe_changes || 'Опишите изменения...'}
></textarea>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-text-muted">{$t.git?.commit_templates || 'Templates'}:</span>
{#each commitTemplates as template}
<Button
variant="ghost"
size="sm"
onclick={() => (commitMessage = template.message)}
disabled={generatingMessage || workspaceLoading}
class="border border-border bg-surface-page"
>
{template.label}
</Button>
{/each}
</div>
</div>
<!-- Commit action -->
@@ -286,7 +284,37 @@
</span>
{/if}
</div>
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container">
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container" bind:this={diffContainerEl}>
{#if hasWorkspaceChanges && (workspaceDiff || changeSummary.length > 0)}
<div class="mb-3 flex items-center justify-between gap-2">
<button
type="button"
class="text-xs font-medium text-primary hover:text-primary-hover cursor-pointer"
onclick={scrollToRawDiff}
>
{$t.git?.skip_to_raw_diff || 'Skip to raw diff'}
</button>
<div class="flex items-center gap-1 rounded-md border border-border bg-surface-page p-0.5 text-xs">
<span class="px-2 text-text-muted">{$t.git?.diff_view_mode || 'Diff mode'}:</span>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${diffViewMode === 'line-by-line' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
onclick={() => (diffViewMode = 'line-by-line')}
aria-pressed={diffViewMode === 'line-by-line'}
>
{$t.git?.diff_view_line_by_line || 'Line-by-line'}
</button>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${diffViewMode === 'side-by-side' ? 'bg-surface-card text-text shadow-sm' : 'text-text-muted hover:text-text'}`}
onclick={() => (diffViewMode = 'side-by-side')}
aria-pressed={diffViewMode === 'side-by-side'}
>
{$t.git?.diff_view_side_by_side || 'Side-by-side'}
</button>
</div>
</div>
{/if}
{#if hasWorkspaceChanges && changeSummary.length > 0}
<div class="mb-4 rounded-lg border border-border bg-surface-page p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-text">
@@ -313,7 +341,7 @@
</div>
{/if}
{#if hasWorkspaceChanges && workspaceDiff}
<details class="mb-4 rounded-lg border border-border bg-surface-page">
<details id="git-raw-diff" bind:this={rawDiffDetailsEl} class="mb-4 rounded-lg border border-border bg-surface-page">
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-semibold text-text">
<Icon name="code" size={16} class="text-text-muted" strokeWidth={2} />
{$t.git?.advanced_raw_diff_title || $t.git?.raw_diff_title || 'Advanced: raw YAML diff'}
@@ -343,9 +371,16 @@
</div>
</div>
{:else if hasWorkspaceChanges && renderedHtml}
<div class="diff-view">
<div
class="diff-view"
role="region"
aria-label={$t.git?.diff_region_label || 'Changes preview'}
aria-hidden="true"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedHtml}</div>
{@html renderedHtml}
</div>
<p class="sr-only">{$t.git?.diff_rendered_hidden_hint || 'Visual diff is hidden from screen reader — use the raw diff below.'}</p>
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
{#if hasMoreChunks}
<div
@@ -385,26 +420,37 @@
</div>
{/if}
</div>
<!-- Commit History inside right panel — no z-index overlap with commit button -->
{#if dashboardId}
<details class="mt-4 rounded-lg border border-border bg-surface-card overflow-hidden">
<summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium text-text hover:bg-surface-muted transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{$t.git?.history || 'Commit History'}
</summary>
<div class="px-4 pb-4 bg-surface-card">
{#key commitHistoryKey}
<CommitHistory {dashboardId} {envId} />
{/key}
</div>
</details>
{/if}
</div>
</div>
<!-- Commit History collapsible section -->
{#if dashboardId}
<details class="mt-4 rounded-lg border border-border bg-surface-card overflow-hidden">
<summary class="flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-medium text-text hover:bg-surface-muted transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{$t.git?.history || 'Commit History'}
</summary>
<div class="px-4 pb-4 bg-surface-card">
{#key commitHistoryKey}
<CommitHistory {dashboardId} {envId} />
{/key}
</div>
</details>
{/if}
<!-- Custom styles for diff2html -->
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
:global(.diff-view .d2h-wrapper) {
font-size: 12px;
line-height: 1.6;

View File

@@ -98,7 +98,7 @@
{#if show}
<div
class="absolute inset-0 z-30 flex items-center justify-center bg-black bg-opacity-40 p-4"
class="absolute inset-0 z-30 flex items-center justify-center bg-surface-overlay p-4"
role="dialog"
aria-modal="true"
aria-label={(mergeT().title || 'Merge {source} into {target}').replace('{source}', sourceBranch).replace('{target}', targetBranch)}

View File

@@ -317,5 +317,43 @@
"bugfix": "Bugfix",
"legacy": "Legacy",
"other": "Other"
}
},
"diff_view_mode": "Diff mode",
"diff_view_side_by_side": "Side-by-side",
"diff_view_line_by_line": "Line-by-line",
"diff_region_label": "Changes preview",
"skip_to_raw_diff": "Skip to raw diff",
"diff_rendered_hidden_hint": "Visual diff is hidden from screen reader — use the raw diff below.",
"bulk_commit_dialog": {
"title": "Bulk commit",
"message": "Enter a commit message for the selected dashboards:",
"placeholder": "Commit message...",
"confirm": "Commit"
},
"create_repo_dialog": {
"title": "Create repository",
"name_label": "Repository name",
"name_prompt": "Repository name for {provider}:",
"confirm": "Create"
},
"bulk_confirm": {
"sync_title": "Bulk sync",
"sync_message": "Sync {count} dashboards from Superset to Git?",
"pull_title": "Bulk pull",
"pull_message": "Pull changes from remote for {count} dashboards?",
"push_title": "Bulk push",
"push_message": "Push local commits to remote for {count} dashboards?",
"confirm": "Confirm"
},
"numeric_id_forbidden": "GitManager requires dashboard slug. Numeric ID is forbidden.",
"promote_branches_must_differ": "Select different source and target branches",
"direct_reason_required": "Direct merge requires an audit reason",
"direct_promote_done": "Direct promote completed. Policy violation logged.",
"mr_created": "Merge Request created on Git server",
"deploy_confirm_failed": "PROD confirmation failed. Deploy cancelled.",
"repo_url_empty": "Remote repository created, but URL is empty",
"env_required_for_init": "Environment must be selected to initialize Git for this dashboard.",
"init_fields_required": "Please fill in all fields",
"close_aria": "Close",
"help_aria": "Help"
}

View File

@@ -317,5 +317,43 @@
"bugfix": "Bugfix",
"legacy": "Legacy",
"other": "Другое"
}
},
"diff_view_mode": "Режим diff",
"diff_view_side_by_side": "Две колонки",
"diff_view_line_by_line": "Одна колонка",
"diff_region_label": "Просмотр изменений",
"skip_to_raw_diff": "Перейти к текстовому diff",
"diff_rendered_hidden_hint": "Визуальный diff скрыт от скринридера — используйте текстовый diff ниже.",
"bulk_commit_dialog": {
"title": "Массовый commit",
"message": "Введите сообщение коммита для выбранных дашбордов:",
"placeholder": "Сообщение коммита...",
"confirm": "Зафиксировать"
},
"create_repo_dialog": {
"title": "Создать репозиторий",
"name_label": "Имя репозитория",
"name_prompt": "Имя репозитория для {provider}:",
"confirm": "Создать"
},
"bulk_confirm": {
"sync_title": "Массовая синхронизация",
"sync_message": "Синхронизировать {count} дашбордов из Superset в Git?",
"pull_title": "Массовый pull",
"pull_message": "Получить изменения из remote для {count} дашбордов?",
"push_title": "Массовый push",
"push_message": "Отправить локальные коммиты в remote для {count} дашбордов?",
"confirm": "Подтвердить"
},
"numeric_id_forbidden": "GitManager требует slug дашборда. Числовой ID запрещён.",
"promote_branches_must_differ": "Выберите разные исходную и целевую ветки",
"direct_reason_required": "Для небезопасного прямого переноса укажите причину",
"direct_promote_done": "Прямой перенос выполнен. Нарушение политики записано в логи.",
"mr_created": "Merge Request создан на Git сервере",
"deploy_confirm_failed": "Подтверждение PROD не пройдено. Деплой отменён.",
"repo_url_empty": "Удалённый репозиторий создан, но URL пуст",
"env_required_for_init": "Для инициализации Git выберите окружение.",
"init_fields_required": "Заполните все поля",
"close_aria": "Закрыть",
"help_aria": "Справка"
}

View File

@@ -289,6 +289,14 @@ export class GitManagerModel {
/** Target branch name for merge dialog (default: dev). */
mergeTargetBranch: string = $state('dev');
// ── Create Remote Repo Dialog ────────────────────────────────
/** True when the create-remote-repo modal is open (replaces native prompt()). */
showCreateRepoDialog: boolean = $state(false);
/** Pending repo name being typed in the create-repo dialog. */
pendingRepoName: string = $state('');
/** Provider label for the create-repo dialog prompt. */
createRepoProviderLabel: string = $state('');
// ── Error Banner ────────────────────────────────────────────
gitError: GitErrorPayload | null = $state(null);
gitErrorType: string = $state('error');
@@ -477,7 +485,7 @@ export class GitManagerModel {
if (isNumericDashboardRef(this.dashboardId)) {
this.checkingStatus = false;
this.initialized = false;
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
return;
}
this.checkingStatus = true;
@@ -542,7 +550,7 @@ export class GitManagerModel {
*/
async handleSync(): Promise<void> {
if (isNumericDashboardRef(this.dashboardId)) {
addToast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.numeric_id_forbidden as string || 'GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
return;
}
this.clearGitError();
@@ -622,11 +630,11 @@ export class GitManagerModel {
*/
async handlePromote(): Promise<void> {
if (!this.promoteFromBranch || !this.promoteToBranch || this.promoteFromBranch === this.promoteToBranch) {
addToast('Выберите разные исходную и целевую ветки', 'error');
addToast((this._t?.git as Record<string, unknown>)?.promote_branches_must_differ as string || 'Выберите разные исходную и целевую ветки', 'error');
return;
}
if (this.promoteMode === 'direct' && !String(this.promoteReason || '').trim()) {
addToast('Для небезопасного прямого переноса укажите причину', 'error');
addToast((this._t?.git as Record<string, unknown>)?.direct_reason_required as string || 'Для небезопасного прямого переноса укажите причину', 'error');
return;
}
this.clearGitError();
@@ -645,10 +653,10 @@ export class GitManagerModel {
this.resolvedEnvId,
);
if (this.promoteMode === 'direct') {
addToast('Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning');
addToast((this._t?.git as Record<string, unknown>)?.direct_promote_done as string || 'Прямой перенос выполнен. Нарушение политики записано в логи.', 'warning');
} else {
if (response?.url) window.open(response.url, '_blank', 'noopener,noreferrer');
addToast('Merge Request создан на Git сервере', 'success');
addToast((this._t?.git as Record<string, unknown>)?.mr_created as string || 'Merge Request создан на Git сервере', 'success');
}
} catch (e: unknown) {
this._setGitError(e);
@@ -724,7 +732,7 @@ export class GitManagerModel {
*/
confirmDeploy(slug: string): void {
if (slug.trim() !== this.deployConfirmSlug) {
addToast('Подтверждение PROD не пройдено. Деплой отменен.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.deploy_confirm_failed as string || 'Подтверждение PROD не пройдено. Деплой отменен.', 'error');
this.showDeployConfirm = false;
return;
}
@@ -745,10 +753,19 @@ export class GitManagerModel {
return;
}
if (!this.selectedConfigId && config.id) this.selectedConfigId = String(config.id);
const suggestedName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId);
const inputName = prompt(`Repository name for ${config.provider}:`, suggestedName);
const repoName = String(inputName || '').trim();
// Open modal dialog instead of native prompt() — non-blocking, i18n, a11y.
this.pendingRepoName = buildSuggestedRepoName(this.dashboardTitle, this.dashboardId);
this.createRepoProviderLabel = String(config.provider || '');
this.showCreateRepoDialog = true;
}
/** Confirm create-remote-repo from the modal dialog (replaces prompt() return value). */
async confirmCreateRemoteRepo(): Promise<void> {
const repoName = String(this.pendingRepoName || '').trim();
if (!repoName) return;
const config = resolveDefaultConfig(this.configs, this.selectedConfigId);
if (!config) return;
this.showCreateRepoDialog = false;
this.clearGitError();
this.creatingRemoteRepo = true;
try {
@@ -760,7 +777,7 @@ export class GitManagerModel {
default_branch: 'prod',
} satisfies CreateRepoPayload);
const url = repo?.clone_url || repo?.html_url || '';
if (!url) throw new Error('Remote repository created, but URL is empty');
if (!url) throw new Error((this._t?.git as Record<string, unknown>)?.repo_url_empty as string || 'Remote repository created, but URL is empty');
this.remoteUrl = url;
addToast(`Repository created on ${config.provider}`, 'success');
} catch (e: unknown) {
@@ -782,11 +799,11 @@ export class GitManagerModel {
*/
async handleInit(): Promise<void> {
if (!this.selectedConfigId || !this.remoteUrl) {
addToast((this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Заполните все поля', 'error');
addToast((this._t?.git as Record<string, unknown>)?.init_fields_required as string || (this._t?.git as Record<string, unknown>)?.init_validation_error as string || 'Заполните все поля', 'error');
return;
}
if (!this.resolvedEnvId && !isNumericDashboardRef(this.dashboardId)) {
addToast('Environment must be selected to initialize Git for this dashboard.', 'error');
addToast((this._t?.git as Record<string, unknown>)?.env_required_for_init as string || 'Environment must be selected to initialize Git for this dashboard.', 'error');
return;
}
this.clearGitError();

View File

@@ -239,31 +239,39 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
});
describe("handleCreateRemoteRepo", () => {
it("returns early when no config", async () => { await model.handleCreateRemoteRepo(); expect(gitService.createRemoteRepository).not.toHaveBeenCalled(); });
it("returns early when no config", async () => {
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(false);
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
});
it("handles 409 already exists error", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1"; model.dashboardTitle = "Test";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
const err = new Error("already exists"); (err as any).status = 409;
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(err);
await model.handleCreateRemoteRepo();
await model.confirmCreateRemoteRepo();
expect(addToast).toHaveBeenCalledWith("Already exists", "warning", 0);
promptSpy.mockRestore();
});
it("handles other error via _setGitError", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockRejectedValue(new Error("Server error"));
await model.confirmCreateRemoteRepo();
expect(model.gitError).not.toBeNull();
expect(model.creatingRemoteRepo).toBe(false);
promptSpy.mockRestore();
});
it("returns early when user cancels prompt", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }];
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue(null);
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
expect(gitService.createRemoteRepository).not.toHaveBeenCalled();
promptSpy.mockRestore();
// Close dialog without confirming
model.showCreateRepoDialog = false;
model.pendingRepoName = "";
});
});
@@ -544,21 +552,23 @@ describe("GitManagerModel — L1 invariants (no render)", () => {
it("handleCreateRemoteRepo succeeds with clone_url", async () => {
model.configs = [{ id: "cfg-1", provider: "github" }]; model.selectedConfigId = "cfg-1";
model.dashboardTitle = "Test Dash";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ clone_url: "https://github.com/org/repo.git" });
await model.confirmCreateRemoteRepo();
expect(model.remoteUrl).toBe("https://github.com/org/repo.git");
promptSpy.mockRestore();
});
it("handleCreateRemoteRepo succeeds with html_url", async () => {
model.configs = [{ id: "cfg-1", provider: "gitlab" }]; model.selectedConfigId = "cfg-1";
model.dashboardTitle = "Test Dash";
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("my-repo");
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
await model.handleCreateRemoteRepo();
expect(model.showCreateRepoDialog).toBe(true);
model.pendingRepoName = "my-repo";
vi.mocked(gitService.createRemoteRepository).mockResolvedValue({ html_url: "https://gitlab.com/org/repo" });
await model.confirmCreateRemoteRepo();
expect(model.remoteUrl).toBe("https://gitlab.com/org/repo");
promptSpy.mockRestore();
});
});
});

View File

@@ -41,7 +41,7 @@
{#if show}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
class="fixed inset-0 z-50 flex items-center justify-center bg-surface-overlay"
onclick={handleBackdrop}
onkeydown={handleKeydown}
>

View File

@@ -9,15 +9,17 @@
/**
* Help tooltip component.
* @prop {string} text - Tooltip text content (plain text, supports both EN/RU inline).
* @prop {string} ariaLabel - Accessible label for the help button (default: "Help").
*/
let { text } = $props();
let { text, ariaLabel = 'Help' } = $props();
</script>
<span
class="group relative inline-flex items-center justify-center align-middle"
role="button"
tabindex="0"
aria-label={text}
aria-label={ariaLabel}
title={text}
>
<!-- Help circle icon -->
<svg
@@ -59,14 +61,15 @@
<span
class="
absolute top-full left-1/2 -translate-x-1/2
border-4 border-transparent border-t-gray-200
border-4 border-transparent border-t-border
"
aria-hidden="true"
></span>
<span
class="
absolute top-full left-1/2 -translate-x-1/2 mt-px
border-4 border-transparent border-t-white
border-4 border-transparent border-t-surface-card
"
aria-hidden="true"
></span>

View File

@@ -23,37 +23,30 @@ describe('HelpTooltip', () => {
expect(container.textContent).toContain('This is helpful');
});
it('sets aria-label on the container', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Instruction');
});
it('sets aria-label on the container', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction', ariaLabel: 'Help' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Help');
expect(span!.getAttribute('title')).toBe('Instruction');
});
it('renders tooltip with empty text when text prop is not provided', () => {
const { container } = render(HelpTooltip);
const tooltip = container.querySelector('span[role="button"]');
// When text is not provided, aria-label is omitted (undefined → attribute removed)
expect(tooltip!.getAttribute('aria-label')).toBeNull();
});
it('renders tooltip with custom ariaLabel', () => {
const { container } = render(HelpTooltip, { props: { text: 'Instruction', ariaLabel: 'Custom help' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Custom help');
});
it('renders tooltip text with empty default', () => {
const { container } = render(HelpTooltip);
// text is undefined, renders as empty string
const tooltipSpans = container.querySelectorAll('span.absolute');
expect(tooltipSpans.length).toBeGreaterThan(0);
});
it('renders tooltip with default ariaLabel when not provided', () => {
const { container } = render(HelpTooltip);
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('Help');
});
it('renders tooltip with explicit empty string text', () => {
const { container } = render(HelpTooltip, { props: { text: '' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('');
});
it('renders tooltip with explicit whitespace text', () => {
const { container } = render(HelpTooltip, { props: { text: ' ' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe(' ');
});
it('renders tooltip with empty string ariaLabel', () => {
const { container } = render(HelpTooltip, { props: { text: '', ariaLabel: '' } });
const span = container.querySelector('span[role="button"]');
expect(span!.getAttribute('aria-label')).toBe('');
});
});
// NOTE: The remaining uncovered branch (50% coverage) is a Svelte 5 compiler artifact
// from the compiled $props() default argument handling (`text = ""`).

View File

@@ -245,6 +245,7 @@ import { SvelteSet } from "svelte/reactivity";
statusMode="repository"
envId={selectedEnvId || null}
repositoriesOnly={activeTab === 'repos'}
onDashboardsChanged={(next: DashboardMetadata[]) => { allDashboards = next; }}
/>
{:else}
<EmptyState

View File

@@ -54,6 +54,7 @@ export default {
page: '#f8fafc', // slate-50
card: '#ffffff', // white
muted: '#f1f5f9', // slate-100
overlay: 'rgba(15, 23, 42, 0.5)', // slate-900/50 — modal backdrop
},
// ── Border hierarchy ──────────────────────────────────────
border: {