Files
ss-tools/frontend/src/components/git/GitWorkspacePanel.svelte
busya 4fc3356312 refactor(frontend): migrate Svelte stores from .ts to .svelte.ts runes
- Delete legacy .ts stores (auth, activity, assistantChat, datasetReview, environmentContext, health, sidebar, taskDrawer, translationRun)
- Create new .svelte.ts runes-based stores using  reactive primitives
- Migrate i18n: /i18n → /i18n/index.svelte.js
- Migrate toasts: /toasts → /toasts.svelte.js
- Update all component imports across 180+ files: components, pages, routes, lib
- Remove fromStore() wrappers — use store.value directly with Svelte 5 runes
- Update test mocks for new import paths
- Add @DEPRECATED annotation to legacy  alias
2026-06-02 09:54:18 +03:00

296 lines
14 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] -->
<!-- @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 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 lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import { Button } from "$lib/ui";
import * as Diff2Html from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
let {
hasWorkspaceChanges,
changedFilesCount,
workspaceLoading,
workspaceDiff = '',
committing,
generatingMessage,
commitMessage = $bindable(),
autoPushAfterCommit = $bindable(),
loading,
pushProviderLabel,
onSync,
onGenerateMessage,
onCommit,
} = $props();
// ── 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="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>
<!-- 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 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}
>
<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-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>
<!-- 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"
>
<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>
<!-- 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 bg-white p-4" id="diff-scroll-container">
{#if workspaceLoading}
<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 && 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}
<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 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 -->