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:
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Справка"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = ""`).
|
||||
|
||||
@@ -245,6 +245,7 @@ import { SvelteSet } from "svelte/reactivity";
|
||||
statusMode="repository"
|
||||
envId={selectedEnvId || null}
|
||||
repositoriesOnly={activeTab === 'repos'}
|
||||
onDashboardsChanged={(next: DashboardMetadata[]) => { allDashboards = next; }}
|
||||
/>
|
||||
{:else}
|
||||
<EmptyState
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user