Files
ss-tools/frontend/src/lib/components/git/GitWorkspacePanel.svelte

551 lines
28 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- #region GitWorkspacePanel [C:4] [TYPE Component] [SEMANTICS git, workspace, commit, diff, sync] -->
<!-- @ingroup Components -->
<!-- @BRIEF Git workspace panel: sync, commit, lazy-chunked diff viewer (IntersectionObserver). -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [GitUtils] -->
<!-- @RELATION CALLS -> [EXT:frontend:gitService] -->
<!-- @PRE hasWorkspaceChanges and workspaceDiff are propagated from GitManager. -->
<!-- @UX_STATE Idle -> "Нет изменений" placeholder when !hasWorkspaceChanges. -->
<!-- @UX_STATE Changes -> workspaceDiff rendered in chunks as user scrolls. -->
<!-- @UX_STATE SummaryLoading -> Agent progress is visible; save and diff controls remain interactive. -->
<!-- @UX_STATE SummaryReady -> Free-form LLM explanation is visible before collapsed YAML details. -->
<!-- @UX_STATE SummaryError -> Inline failure and retry action replace the explanation. -->
<!-- @UX_STATE Loading -> GeneratingMessage spinner, diff-chunks loading indicator. -->
<!-- @UX_STATE Error -> Toast on commit-message failure. -->
<!-- @UX_RECOVERY Retry LLM summary independently; scroll down to load more diff chunks. -->
<!-- @RATIONALE IntersectionObserver-based lazy chunked diff rendering chosen because Git diffs can reach 10K+ lines —
rendering the entire diff at once freezes the browser (layout thrashing on syntax-highlighted DOM). Chunk size of
100 lines with sentinel-triggered expansion keeps initial render under 16ms frame budget while preserving
diff2html fidelity. Workspace changes, commit message generation, and sync operations are consolidated in one
panel because they share the same workspace state atom — splitting them would duplicate diff loading and
workspace status polling. -->
<!-- @REJECTED Virtual scrolling (fixed-height rows) rejected — diff lines vary in height (context vs added vs removed
with inline highlighting), making row height estimation unreliable. Server-side diff pagination rejected — adds
network latency on every scroll and breaks diff2html's side-by-side mode which requires the full diff context.
Web Worker diff parsing rejected — diff2html bundles its own parser; offloading to a worker saves ~50ms on a
10K-line diff but adds worker bootstrap overhead that negates the gain for typical diffs (< 500 lines). -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import { Button, Icon } from "$lib/ui";
import MarkdownRenderer from '$lib/components/assistant/MarkdownRenderer.svelte';
import * as Diff2Html from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
import CommitHistory from './CommitHistory.svelte';
import { getSemanticWorkspaceFiles } from '../../../services/git-utils.js';
type WorkspaceStatus = {
current_branch?: string;
sync_state?: string;
upstream_branch?: string | null;
ahead_count?: number;
behind_count?: number;
staged_files?: string[];
modified_files?: string[];
untracked_files?: string[];
last_commit_hash?: string | null;
last_commit_author?: string | null;
last_commit_date?: string | null;
[key: string]: unknown;
} | null;
let {
hasWorkspaceChanges,
changedFilesCount,
workspaceStatus = null,
workspaceLoading,
workspaceDiff = '',
workspaceSummary = '',
workspaceSummaryState = 'idle',
workspaceSummaryError = '',
committing,
generatingMessage,
commitMessage = $bindable(),
autoPushAfterCommit = $bindable(),
loading,
pushProviderLabel,
dashboardId = '',
envId = null,
commitHistoryKey = 0,
onSync,
onGenerateMessage,
onGenerateSummary,
onCommit,
} = $props();
const changeCategoryRules = [
{ key: 'charts', test: (path: string) => path.includes('/charts/') || path.startsWith('charts/') },
{ key: 'datasets', test: (path: string) => path.includes('/datasets/') || path.startsWith('datasets/') },
{ key: 'databases', test: (path: string) => path.includes('/databases/') || path.startsWith('databases/') },
{ key: 'filters', test: (path: string) => path.includes('/filters/') || path.includes('native_filter') || path.includes('filter') },
{ key: 'dashboard', test: (path: string) => path.includes('/dashboards/') || path.startsWith('dashboards/') || path.includes('dashboard') },
];
const categoryLabel = (key: string) => {
const labels = $t.git?.change_categories || {};
return labels[key] || key;
};
let changedFiles = $derived.by(() => {
return getSemanticWorkspaceFiles(workspaceStatus as WorkspaceStatus);
});
let changeSummary = $derived.by(() => {
const buckets = new Map<string, string[]>();
for (const file of changedFiles) {
const normalized = String(file || '').toLowerCase();
const match = changeCategoryRules.find((rule) => rule.test(normalized));
const key = match?.key || 'other';
buckets.set(key, [...(buckets.get(key) || []), file]);
}
return Array.from(buckets.entries()).map(([key, files]) => ({ key, files }));
});
// ── Lazy chunked diff rendering ──
// Split diff into per-file sections on "diff --git " header
let diffChunks = $derived.by(() => {
if (!workspaceDiff) return [];
const header = 'diff --git ';
const parts = [];
let start = 0;
while (true) {
const idx = workspaceDiff.indexOf(header, start);
if (idx < 0) break;
// Include the "diff --git " prefix in the chunk
const chunkStart = idx;
const nextIdx = workspaceDiff.indexOf(header, idx + 1);
const chunkEnd = nextIdx >= 0 ? nextIdx : workspaceDiff.length;
parts.push(workspaceDiff.slice(chunkStart, chunkEnd));
start = nextIdx >= 0 ? nextIdx : workspaceDiff.length;
if (nextIdx < 0) break;
}
// Fallback: if no "diff --git" headers, treat entire diff as one chunk
if (parts.length === 0 && workspaceDiff.trim()) {
parts.push(workspaceDiff);
}
return parts;
});
const CHUNKS_PER_PAGE = 3; // render 3 file diffs at a time
let renderedChunkCount = $state(0);
let sentinelEl = $state(null);
let chunkLoading = $state(false);
// When diff changes, reset to first chunk page
$effect(() => {
if (workspaceDiff) {
renderedChunkCount = Math.min(CHUNKS_PER_PAGE, diffChunks.length);
}
});
// IntersectionObserver on sentinel to load more chunks
$effect(() => {
const el = sentinelEl;
if (!el) return;
if (renderedChunkCount >= diffChunks.length) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && renderedChunkCount < diffChunks.length) {
chunkLoading = true;
// Yield to let browser paint the loading indicator
requestAnimationFrame(() => {
requestAnimationFrame(() => {
renderedChunkCount = Math.min(
renderedChunkCount + CHUNKS_PER_PAGE,
diffChunks.length
);
chunkLoading = false;
});
});
}
}
},
{ rootMargin: '200px 0px' } // trigger 200px before sentinel enters viewport
);
observer.observe(el);
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;
if (count === 0 || diffChunks.length === 0) return '';
const visible = diffChunks.slice(0, count).join('\n');
try {
return Diff2Html.html(visible, {
outputFormat: diffViewMode,
drawFileList: false,
matching: 'lines',
highlight: true,
});
} catch {
return `<div class="p-4 text-sm text-destructive">${$t.git?.diff_render_failed || 'Failed to render diff'}</div>`;
}
});
const totalChunks = $derived(diffChunks.length);
const hasMoreChunks = $derived(renderedChunkCount < totalChunks);
let rawDiffSearch = $state('');
let rawDiffLines = $derived.by(() => {
const lines = String(workspaceDiff || '').split('\n');
const query = rawDiffSearch.trim().toLowerCase();
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">
<!-- Left sidebar: sequential save controls -->
<div class="w-full shrink-0 space-y-4 lg:w-80 xl:w-96">
<div id="git-workspace-save" class="rounded-lg border border-border bg-surface-card p-4 shadow-sm" tabindex="-1">
<div class="mb-3">
<h3 class="text-sm font-semibold text-text">{$t.git?.workspace_save_title || 'Сохранить версию'}</h3>
<p class="mt-0.5 text-xs leading-4 text-text-muted">{$t.git?.workspace_save_hint || 'Опишите смысл изменений — это описание увидят при проверке и публикации.'}</p>
</div>
<label for="git-commit-message" class="mb-1.5 block text-xs font-medium text-text">{$t.git?.workspace_version_description || 'Описание версии'}</label>
<textarea
id="git-commit-message"
bind:value={commitMessage}
class={`h-28 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>
{#if hasWorkspaceChanges && !commitMessage.trim()}
<p class="mt-1.5 text-xs text-warning" role="status">{$t.git?.workspace_description_required || 'Добавьте описание версии, чтобы сохранить её.'}</p>
{/if}
<div class="my-3 flex items-center gap-2 rounded-lg bg-surface-page px-3 py-2 text-sm text-text-muted">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="h-4 w-4 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{$t.git?.files_with_changes || 'Файлов с изменениями:'} <strong class="text-text">{changedFilesCount}</strong>
</div>
<Button
onclick={onCommit}
disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges}
isLoading={committing}
class="w-full"
size="lg"
>
<Icon name="check" size={16} class="-ml-1 mr-1.5" strokeWidth={2} />
{$t.git?.workspace_save_button || 'Сохранить версию'}
</Button>
<details class="mt-3 rounded-md border border-border bg-surface-page text-xs text-text-muted">
<summary class="cursor-pointer px-3 py-2 font-medium">{$t.git?.workspace_save_options || 'Параметры сохранения'}</summary>
<label class="flex items-center gap-2.5 border-t border-border px-3 py-2 transition-colors hover:bg-surface-muted">
<input type="checkbox" bind:checked={autoPushAfterCommit} class="h-4 w-4 rounded border-border-strong text-primary focus:ring-primary-ring" />
<span>{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} <strong class="font-medium text-text">{pushProviderLabel}</strong></span>
</label>
</details>
</div>
<!-- Compact inline actions: AI generate + Sync -->
<div class="flex gap-2">
<Button
variant="secondary"
size="sm"
onclick={onGenerateMessage}
disabled={generatingMessage || workspaceLoading}
class="flex-1"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" 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="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z"/></svg>
{$t.git?.generate_description || '✨ AI'}
</Button>
<Button
variant="secondary"
size="sm"
onclick={onSync}
disabled={loading}
isLoading={loading}
class="flex-1"
>
<Icon name="refresh" size={14} strokeWidth={2} />
{$t.git?.workspace_refresh_changes || 'Обновить изменения'}
</Button>
</div>
</div>
<!-- Right area: diff preview — lazy chunked rendering -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-surface-card shadow-sm">
<div class="flex items-center justify-between border-b border-border bg-surface-page px-4 py-3">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" 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="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
<span class="text-sm font-semibold text-text">{$t.git?.workspace_changes_title || 'Изменения версии'}</span>
</div>
{#if hasWorkspaceChanges}
<span class="inline-flex items-center gap-1 rounded-full bg-primary-light px-2.5 py-0.5 text-xs font-medium text-primary ring-1 ring-inset ring-primary-ring">
{($t.git?.files_count || '{count} файлов').replace('{count}', String(changedFilesCount))}
</span>
{/if}
</div>
<div class="flex-1 overflow-auto bg-surface-card p-4" id="diff-scroll-container" bind:this={diffContainerEl}>
{#if hasWorkspaceChanges && changeSummary.length > 0}
<div class="mb-4 rounded border border-border bg-surface-page p-2">
<div class="mb-1 text-xs font-medium text-text-muted">{$t.git?.semantic_summary || 'Change summary'}</div>
<div class="flex flex-wrap gap-1.5">
{#each changeSummary as category}
<span class="inline-flex items-center gap-1 rounded bg-surface-card px-2 py-0.5 text-xs text-text-subtle border border-border/50">
{categoryLabel(category.key)} <span class="text-text-muted">({category.files.length})</span>
</span>
{/each}
</div>
</div>
{/if}
{#if hasWorkspaceChanges}
<section
class="mb-4 rounded-lg border border-primary/20 bg-primary-light p-3"
aria-label={$t.git?.semantic_key_changes || 'Ключевые изменения'}
aria-busy={workspaceSummaryState === 'loading'}
>
<div class="flex items-center justify-between gap-3">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted">{$t.git?.semantic_key_changes || 'Ключевые изменения'}</h4>
{#if workspaceSummaryState === 'ready'}
<Button variant="ghost" size="sm" class="gap-1" onclick={onGenerateSummary}>
<Icon name="refresh" size={13} strokeWidth={2} />
{$t.git?.workspace_summary_regenerate || 'Сформулировать иначе'}
</Button>
{/if}
</div>
{#if workspaceSummaryState === 'loading'}
<div class="mt-2 flex items-start gap-2 text-sm text-text-muted" role="status" aria-live="polite">
<span class="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary/30 border-t-primary"></span>
<div>
<p class="font-medium text-text">{$t.git?.workspace_summary_loading || 'Агент анализирует изменения…'}</p>
<p class="mt-0.5 text-xs">{$t.git?.workspace_summary_loading_hint || 'Это может занять несколько минут. Можно продолжать работу с версией.'}</p>
</div>
</div>
{:else if workspaceSummaryState === 'ready' && workspaceSummary}
<div class="mt-2" data-testid="workspace-summary-markdown">
<MarkdownRenderer source={workspaceSummary} blockHtml />
</div>
<p class="mt-2 text-xs text-text-muted">{$t.git?.workspace_summary_ai_notice || 'Описание сформировано AI по текущему diff.'}</p>
{:else if workspaceSummaryState === 'error'}
<div class="mt-2 flex flex-wrap items-center justify-between gap-2" role="alert">
<div>
<p class="text-sm font-medium text-warning">{$t.git?.workspace_summary_failed || 'Не удалось описать изменения'}</p>
<p class="mt-0.5 text-xs text-text-muted">{workspaceSummaryError || $t.git?.workspace_summary_failed_hint || 'Проверьте настройку LLM и повторите запрос.'}</p>
</div>
<Button variant="secondary" size="sm" class="gap-1" onclick={onGenerateSummary}>
<Icon name="refresh" size={13} strokeWidth={2} />
{$t.git?.workspace_summary_retry || 'Повторить'}
</Button>
</div>
{:else}
<Button variant="secondary" size="sm" class="mt-2" onclick={onGenerateSummary}>
{$t.git?.workspace_summary_generate || 'Описать изменения с помощью AI'}
</Button>
{/if}
</section>
{/if}
{#if workspaceLoading}
<div class="space-y-3">
{#each Array(8) as _}
<div class="h-4 animate-pulse rounded bg-surface-muted"></div>
{/each}
<div class="mt-6 grid grid-cols-2 gap-3">
{#each Array(6) as _}
<div class="h-6 animate-pulse rounded bg-surface-page"></div>
{/each}
</div>
</div>
{:else if hasWorkspaceChanges && renderedHtml}
<details class="rounded-lg border border-border bg-surface-page">
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2.5 text-sm font-semibold text-text">
<Icon name="code" size={16} class="text-text-muted" strokeWidth={2} />
{$t.git?.technical_yaml_preview || 'Технический YAML diff'}
</summary>
<div class="border-t border-border bg-surface-card p-3">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<button
type="button"
class="cursor-pointer rounded text-xs font-medium text-primary hover:text-primary-hover focus-visible:ring-2 focus-visible:ring-primary-ring"
onclick={scrollToRawDiff}
>
{$t.git?.open_raw_diff || 'Открыть текстовый 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 || 'Режим'}:</span>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${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 || 'Одна колонка'}
</button>
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-ring ${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 || 'Две колонки'}
</button>
</div>
</div>
<details id="git-raw-diff" bind:this={rawDiffDetailsEl} class="mb-3 rounded-lg border border-border bg-surface-page">
<summary class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs font-medium text-text">
<Icon name="code" size={14} class="text-text-muted" strokeWidth={2} />
{$t.git?.raw_diff_title || 'Текстовый YAML diff'}
</summary>
<div class="border-t border-border p-3">
<input
bind:value={rawDiffSearch}
class="mb-3 h-9 w-full rounded-md border border-border-strong bg-surface-card px-3 text-sm text-text outline-none focus:border-primary-ring focus:ring-2 focus:ring-primary-ring"
placeholder={$t.git?.raw_diff_search || 'Поиск по diff...'}
/>
<pre class="max-h-80 overflow-auto rounded-md border border-border bg-surface-card p-3 text-xs leading-relaxed text-text"><code>{rawDiffLines.join('\n')}</code></pre>
{#if rawDiffLines.length >= 600}
<div class="mt-2 text-xs text-text-muted">{$t.git?.raw_diff_limited || 'Показаны первые 600 подходящих строк.'}</div>
{/if}
</div>
</details>
<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>
<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
bind:this={sentinelEl}
class="flex items-center justify-center py-6 text-sm text-text-subtle"
role="status"
aria-live="polite"
>
{#if chunkLoading}
<div class="flex items-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-ring"></div>
<span>{$t.git?.diff_loading || 'Загрузка изменений...'}</span>
</div>
{:else}
<button type="button" class="cursor-pointer text-primary hover:text-primary" onclick={() => {
renderedChunkCount = Math.min(renderedChunkCount + CHUNKS_PER_PAGE, totalChunks);
}}>
{$t.git?.diff_show_more?.replace('{count}', totalChunks - renderedChunkCount) || `Показать ещё (${totalChunks - renderedChunkCount} файлов)`}
</button>
{/if}
</div>
{:else if totalChunks > 0}
<div class="flex items-center justify-center py-4 text-xs text-text-subtle">
{$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
</div>
{/if}
</div>
</details>
{:else if hasWorkspaceChanges}
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-text-subtle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>
<span>{$t.git?.diff_loading_hint || 'Загрузка diff... (нажмите «Синхронизировать»)'}</span>
</div>
{:else}
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-text-subtle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-text-subtle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"/></svg>
<span>{$t.git?.no_changes_to_commit || 'Нет изменений для коммита'}</span>
<span class="text-xs text-text-subtle">{$t.git?.sync_to_see_changes || 'Синхронизируйте дашборд, чтобы увидеть изменения'}</span>
</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>
<!-- 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;
}
:global(.diff-view .d2h-file-header) {
background: hsl(var(--surface-muted));
border-color: hsl(var(--border));
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
}
:global(.diff-view .d2h-code-line) {
padding: 0 12px;
}
:global(.diff-view .d2h-ins) {
background-color: hsl(var(--success-light));
}
:global(.diff-view .d2h-del) {
background-color: hsl(var(--destructive-light));
}
:global(.diff-view .d2h-code-side-linenumber) {
width: 48px;
min-width: 48px;
}
:global(.diff-view table.d2h-diff-table) {
width: 100%;
}
:global(.diff-view .d2h-side-sides) {
min-width: 100%;
}
/* Fix: absolute-positioned line numbers must scroll with their parent container */
:global(.diff-view .d2h-file-side-diff) {
position: relative;
}
:global(.diff-view .d2h-code-wrapper) {
position: relative;
}
</style>
<!-- #endregion GitWorkspacePanel -->