Converted 11 remaining production files:
- services/*.ts (toolsService, taskService, git-utils, storageService,
adminService, gitService)
- lib/auth/permissions.ts
- lib/components/layout/sidebarNavigation.ts
- lib/components/reports/reportTypeProfiles.ts
- components/git/useGitManager.ts
- routes/datasets/review/useReviewSession.ts
- routes/datasets/review/review-workspace-helpers.ts
- routes/settings/settings-utils.ts
All production files have proper GRACE contracts:
- C2/C3 with @BRIEF, @PRE, @POST, @SIDE_EFFECT, @RELATION
- Generic <T = unknown> for API calls
- Typed interfaces for state/options
Phase 3: 126 .svelte components → <script lang="ts">
Phase 4: 53 test .js files → .ts
Final verification:
- npm run build ✅
- npm run test ✅ (66 passed, 6 pre-existing failures unchanged)
Total: 0 .js files remain in production code
296 lines
14 KiB
Svelte
296 lines
14 KiB
Svelte
<!-- #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";
|
||
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 -->
|