This commit is contained in:
2026-05-27 23:24:12 +03:00
parent d2c60c87e2
commit 477e30a9f2
36 changed files with 1348 additions and 306 deletions

View File

@@ -1,13 +1,14 @@
<!-- #region GitWorkspacePanel [C:3] [TYPE Component] [SEMANTICS git, workspace, commit, diff, sync] -->
<!-- @BRIEF Git workspace panel: sync, commit message input, diff viewer, commit action. -->
<!-- #region GitWorkspacePanel [C:4] [TYPE Component] [SEMANTICS git, workspace, commit, diff, sync] -->
<!-- @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 when hasWorkspaceChanges && workspaceDiff. -->
<!-- @UX_STATE Loading -> GeneratingMessage spinner, workspace loading. -->
<!-- @UX_STATE Changes -> workspaceDiff rendered in chunks as user scrolls. -->
<!-- @UX_STATE Loading -> GeneratingMessage spinner, diff-chunks loading indicator. -->
<!-- @UX_STATE Error -> Toast on failure. -->
<!-- @UX_RECOVERY Scroll down to load more diff chunks; sentinel triggers lazy render. -->
<script>
import { t } from "$lib/i18n";
import { Button } from "$lib/ui";
@@ -30,86 +31,265 @@
onCommit,
} = $props();
// ── Render diff as HTML via diff2html ──
let diffHtml = $derived(workspaceDiff
? Diff2Html.html(workspaceDiff, {
// ── 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();
});
// 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: 'side-by-side',
drawFileList: true,
matching: 'lines',
highlight: true,
})
: '');
});
} catch (_e) {
return '<div class="p-4 text-sm text-red-500">Failed to render diff</div>';
}
});
const totalChunks = $derived(diffChunks.length);
const hasMoreChunks = $derived(renderedChunkCount < totalChunks);
</script>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="space-y-4">
<Button
variant="secondary"
onclick={onSync}
disabled={loading}
isLoading={loading}
class="w-full"
>
🔄 Синхронизировать из Superset
</Button>
<div class="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
<!-- Left sidebar: commit controls (sticky, no scroll) -->
<div class="w-full shrink-0 space-y-4 lg:w-80 xl:w-96">
<!-- Sync button -->
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<Button
variant="secondary"
onclick={onSync}
disabled={loading}
isLoading={loading}
class="w-full"
>
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-1.5 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Синхронизировать из Superset
</Button>
</div>
<div class="rounded-lg border border-slate-200 p-3">
<div class="mb-2 flex items-center justify-between">
<!-- Commit message -->
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700">Сообщение коммита</h3>
<button
class="inline-flex items-center text-xs text-primary hover:text-blue-800 disabled:opacity-50"
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:opacity-50"
onclick={onGenerateMessage}
disabled={generatingMessage || workspaceLoading}
>
✨ Сгенерировать LLM
<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="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>
LLM
</button>
</div>
<textarea
bind:value={commitMessage}
class={`h-36 w-full resize-none rounded border p-2 outline-none focus-visible:ring-2 focus-visible:ring-primary-ring ${generatingMessage ? 'animate-pulse bg-slate-100' : ''}`}
class={`h-32 w-full resize-none rounded-lg border border-slate-200 p-3 text-sm outline-none transition-colors focus:border-blue-400 focus:ring-2 focus:ring-blue-100 ${generatingMessage ? 'animate-pulse bg-slate-50' : 'bg-white'}`}
placeholder="Опишите изменения..."
></textarea>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
Файлов с изменениями: {changedFilesCount}
</div>
<!-- Commit action -->
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<div class="mb-3 flex items-center gap-2 rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" 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>
Файлов с изменениями: <strong class="text-slate-800">{changedFilesCount}</strong>
</div>
<Button
onclick={onCommit}
disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges}
isLoading={committing}
class="w-full"
>
Зафиксировать (Commit)
</Button>
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" bind:checked={autoPushAfterCommit} />
{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} {pushProviderLabel}
</label>
<Button
onclick={onCommit}
disabled={committing || workspaceLoading || !commitMessage || !hasWorkspaceChanges}
isLoading={committing}
class="w-full"
>
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-1.5 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
Зафиксировать (Commit)
</Button>
<label class="mt-3 flex items-center gap-2.5 rounded-md border border-slate-100 bg-slate-50 px-3 py-2 text-xs text-slate-600 transition-colors hover:bg-slate-100">
<input type="checkbox" bind:checked={autoPushAfterCommit} class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" />
<span>{$t.git?.auto_push_after_commit || 'Сделать push после commit в'} <strong class="font-medium text-slate-700">{pushProviderLabel}</strong></span>
</label>
</div>
</div>
<div class="flex min-h-[420px] flex-col overflow-hidden rounded-lg border border-slate-200 bg-slate-50">
<div class="border-b bg-slate-100 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600">
Diff (изменения)
<!-- Right area: diff preview — lazy chunked rendering -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-500" 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-slate-700">Diff (изменения)</span>
</div>
{#if hasWorkspaceChanges}
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200">
{changedFilesCount} файлов
{#if totalChunks > 0}
<span class="text-blue-400">· {renderedChunkCount}/{totalChunks}</span>
{/if}
</span>
{/if}
</div>
<div class="flex-1 overflow-auto p-3">
<div class="flex-1 overflow-auto bg-white p-4" id="diff-scroll-container">
{#if workspaceLoading}
<div class="space-y-2">
{#each Array(6) as _}
<div class="h-4 animate-pulse rounded bg-slate-200"></div>
<div class="space-y-3">
{#each Array(8) as _}
<div class="h-4 animate-pulse rounded bg-slate-100"></div>
{/each}
<div class="mt-6 grid grid-cols-2 gap-3">
{#each Array(6) as _}
<div class="h-6 animate-pulse rounded bg-slate-50"></div>
{/each}
</div>
</div>
{:else if hasWorkspaceChanges && diffHtml}
<div class="diff-view">{@html diffHtml}</div>
{:else if hasWorkspaceChanges && renderedHtml}
<div class="diff-view">{@html renderedHtml}</div>
<!-- Sentinel for IntersectionObserver — triggers next chunk load -->
{#if hasMoreChunks}
<div
bind:this={sentinelEl}
class="flex items-center justify-center py-6 text-sm text-slate-400"
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-blue-500"></div>
<span>{$t.git?.diff_loading || 'Загрузка изменений...'}</span>
</div>
{:else}
<span class="cursor-pointer text-blue-500 hover:text-blue-700" onclick={() => {
renderedChunkCount = Math.min(renderedChunkCount + CHUNKS_PER_PAGE, totalChunks);
}}>
{$t.git?.diff_show_more?.replace('{count}', totalChunks - renderedChunkCount) || `Показать ещё (${totalChunks - renderedChunkCount} файлов)`}
</span>
{/if}
</div>
{:else if totalChunks > 0}
<div class="flex items-center justify-center py-4 text-xs text-slate-400">
{$t.git?.diff_all_shown?.replace('{count}', totalChunks) || `Показаны все изменения (${totalChunks} файлов)`}
</div>
{/if}
{:else if hasWorkspaceChanges}
<pre class="overflow-x-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-slate-500">Загрузка diff... (повторите синхронизацию)</pre>
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-slate-200" 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>Загрузка diff... (нажмите «Синхронизировать»)</span>
</div>
{:else}
<div class="flex h-full items-center justify-center text-sm text-slate-500">
Нет изменений для коммита
<div class="flex h-full flex-col items-center justify-center gap-3 text-sm text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-slate-200" 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>Нет изменений для коммита</span>
<span class="text-xs text-slate-300">Синхронизируйте дашборд, чтобы увидеть изменения</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Custom styles for diff2html -->
<style>
:global(.diff-view .d2h-wrapper) {
font-size: 12px;
line-height: 1.6;
}
:global(.diff-view .d2h-file-header) {
background: #f8fafc;
border-color: #e2e8f0;
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: #f0fdf4;
}
:global(.diff-view .d2h-del) {
background-color: #fef2f2;
}
: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 -->