551 lines
28 KiB
Svelte
551 lines
28 KiB
Svelte
<!-- #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 -->
|