gitmaps 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +267 -118
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/analytics.db +0 -0
  5. package/app/api/analytics/route.ts +64 -0
  6. package/app/api/auth/positions/route.ts +95 -33
  7. package/app/api/build-info/route.ts +19 -0
  8. package/app/api/chat/route.ts +13 -2
  9. package/app/api/og-image/route.ts +14 -0
  10. package/app/api/repo/file-content/route.ts +73 -20
  11. package/app/api/repo/load/route.test.ts +62 -0
  12. package/app/api/repo/load/route.ts +41 -1
  13. package/app/api/repo/pdf-thumb/route.ts +127 -0
  14. package/app/api/repo/resolve-slug/route.ts +51 -0
  15. package/app/api/repo/tree/route.ts +188 -104
  16. package/app/api/version/route.ts +26 -0
  17. package/app/globals.css +5706 -4938
  18. package/app/layout.tsx +1279 -490
  19. package/app/lib/auto-arrange.test.ts +158 -0
  20. package/app/lib/auto-arrange.ts +147 -0
  21. package/app/lib/canvas-export.ts +358 -358
  22. package/app/lib/canvas.ts +625 -564
  23. package/app/lib/cards.tsx +1361 -916
  24. package/app/lib/chat.tsx +65 -9
  25. package/app/lib/code-editor.ts +86 -2
  26. package/app/lib/context.test.ts +32 -0
  27. package/app/lib/context.ts +19 -3
  28. package/app/lib/cursor-sharing.ts +34 -0
  29. package/app/lib/events.tsx +71 -93
  30. package/app/lib/export-canvas.ts +287 -0
  31. package/app/lib/file-card-plugin.ts +148 -148
  32. package/app/lib/file-modal.tsx +49 -0
  33. package/app/lib/file-preview.ts +486 -427
  34. package/app/lib/github-import.test.ts +424 -0
  35. package/app/lib/initial-route-hydration.test.ts +283 -0
  36. package/app/lib/initial-route-hydration.ts +202 -0
  37. package/app/lib/landing-reset.test.ts +99 -0
  38. package/app/lib/landing-reset.ts +106 -0
  39. package/app/lib/landing-shell.test.ts +75 -0
  40. package/app/lib/large-repo-optimization.ts +37 -0
  41. package/app/lib/layout-snapshots.ts +320 -0
  42. package/app/lib/loading.test.ts +69 -0
  43. package/app/lib/loading.tsx +160 -45
  44. package/app/lib/mount-cleanup.test.ts +52 -0
  45. package/app/lib/mount-cleanup.ts +34 -0
  46. package/app/lib/mount-init.test.ts +123 -0
  47. package/app/lib/mount-init.ts +107 -0
  48. package/app/lib/mount-lifecycle.test.ts +39 -0
  49. package/app/lib/mount-lifecycle.ts +12 -0
  50. package/app/lib/mount-route-wiring.test.ts +87 -0
  51. package/app/lib/mount-route-wiring.ts +84 -0
  52. package/app/lib/multi-repo.ts +14 -0
  53. package/app/lib/onboarding-tutorial.ts +278 -0
  54. package/app/lib/positions.ts +190 -121
  55. package/app/lib/recent-commits.test.ts +869 -0
  56. package/app/lib/recent-commits.ts +227 -0
  57. package/app/lib/repo-handoff.test.ts +23 -0
  58. package/app/lib/repo-handoff.ts +16 -0
  59. package/app/lib/repo-progressive.ts +119 -0
  60. package/app/lib/repo-select.test.ts +61 -0
  61. package/app/lib/repo-select.ts +74 -0
  62. package/app/lib/repo.tsx +1383 -987
  63. package/app/lib/role.ts +228 -0
  64. package/app/lib/route-catchall.test.ts +27 -0
  65. package/app/lib/route-repo-entry.test.ts +95 -0
  66. package/app/lib/route-repo-entry.ts +36 -0
  67. package/app/lib/router-contract.test.ts +22 -0
  68. package/app/lib/router-contract.ts +19 -0
  69. package/app/lib/shared-layout.test.ts +86 -0
  70. package/app/lib/shared-layout.ts +82 -0
  71. package/app/lib/status-bar.test.ts +118 -0
  72. package/app/lib/status-bar.ts +365 -128
  73. package/app/lib/sync-controls.test.ts +43 -0
  74. package/app/lib/sync-controls.tsx +303 -0
  75. package/app/lib/test-dom.ts +145 -0
  76. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  77. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  78. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  79. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  80. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  81. package/app/lib/transclusion-smoke.test.ts +163 -0
  82. package/app/lib/tutorial.ts +301 -0
  83. package/app/lib/version.ts +93 -0
  84. package/app/lib/viewport-culling.ts +740 -735
  85. package/app/lib/virtual-files.ts +456 -0
  86. package/app/lib/webgl-text.ts +189 -0
  87. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
  88. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  89. package/app/og-image.png +0 -0
  90. package/app/page.client.tsx +70 -269
  91. package/app/page.tsx +15 -16
  92. package/app/state/machine.js +13 -0
  93. package/package.json +16 -7
  94. package/server.ts +10 -0
  95. package/app/[owner]/[repo]/page.tsx +0 -6
  96. package/app/[slug]/page.tsx +0 -6
  97. package/packages/galaxydraw/README.md +0 -296
  98. package/packages/galaxydraw/banner.png +0 -0
  99. package/packages/galaxydraw/demo/build-static.ts +0 -100
  100. package/packages/galaxydraw/demo/client.ts +0 -154
  101. package/packages/galaxydraw/demo/dist/client.js +0 -8
  102. package/packages/galaxydraw/demo/index.html +0 -256
  103. package/packages/galaxydraw/demo/server.ts +0 -96
  104. package/packages/galaxydraw/dist/index.js +0 -984
  105. package/packages/galaxydraw/dist/index.js.map +0 -16
  106. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  109. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  110. package/packages/galaxydraw/package.json +0 -49
  111. package/packages/galaxydraw/perf.test.ts +0 -284
  112. package/packages/galaxydraw/src/core/cards.ts +0 -435
  113. package/packages/galaxydraw/src/core/engine.ts +0 -339
  114. package/packages/galaxydraw/src/core/events.ts +0 -81
  115. package/packages/galaxydraw/src/core/layout.ts +0 -136
  116. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  117. package/packages/galaxydraw/src/core/state.ts +0 -177
  118. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  119. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  120. package/packages/galaxydraw/src/index.ts +0 -40
  121. package/packages/galaxydraw/tsconfig.json +0 -30
package/app/lib/repo.tsx CHANGED
@@ -1,987 +1,1383 @@
1
- // @ts-nocheck
2
- /**
3
- * Repository management — load, commit timeline, select commit, all-files.
4
- */
5
- import { measure } from 'measure-fn';
6
- import { render } from 'melina/client';
7
- import type { CanvasContext } from './context';
8
- import { escapeHtml, formatDate, showToast } from './utils';
9
- import { clearCanvas, getAutoColumnCount, updateCanvasTransform, updateZoomUI, updateMinimap, forceMinimapRebuild } from './canvas';
10
- import { performViewportCulling } from './viewport-culling';
11
- import { getPositionKey, loadSavedPositions } from './positions';
12
- import { updateHiddenUI } from './hidden-files';
13
- import { showLoadingProgress, updateLoadingProgress, hideLoadingProgress } from './loading';
14
- import { createFileCard, createAllFileCard, debounceSaveScroll, expandCardByPath } from './cards';
15
- import { getActiveLayer } from './layers';
16
- import { renderConnections, buildConnectionMarkers } from './connections';
17
- import { renderAllFilesViaCardManager, materializeViewport } from './galaxydraw-bridge';
18
- import { registerRepo, renderRepoTabs, getNextRepoOffset, isMultiRepoLoad, getLoadedRepos } from './multi-repo';
19
- import { updateStatusBarRepo, updateStatusBarCommit, updateStatusBarFiles } from './status-bar';
20
-
21
- // Shared: reference to ctx for changed-files panel navigation
22
- let _panelCtx: CanvasContext | null = null;
23
- export function setPanelCtx(ctx: CanvasContext) { _panelCtx = ctx; }
24
-
25
- // Dedup guard: prevent concurrent or duplicate loadRepository calls
26
- let _loadingRepo: string | null = null;
27
-
28
- // ─── Load repository ─────────────────────────────────────
29
- export async function loadRepository(ctx: CanvasContext, repoPath: string) {
30
- if (!repoPath) return;
31
-
32
- // Prevent duplicate loads of the same repo (e.g. mount triggers both hash + localStorage paths)
33
- if (_loadingRepo === repoPath) {
34
- console.log(`[repo] Skipping duplicate load for "${repoPath}" — already loading`);
35
- return;
36
- }
37
- _loadingRepo = repoPath;
38
- _panelCtx = ctx;
39
- ctx.actor.send({ type: 'LOAD_REPO', path: repoPath });
40
-
41
- return measure('repo:load', async () => {
42
- try {
43
- showLoadingProgress(ctx, 'Loading repository...');
44
- updateLoadingProgress(ctx, repoPath);
45
-
46
- const response = await fetch('/api/repo/load', {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- body: JSON.stringify({ path: repoPath })
50
- });
51
-
52
- if (!response.ok) throw new Error(await response.text());
53
-
54
- updateLoadingProgress(ctx, 'Parsing commits...');
55
- const data = await response.json();
56
- ctx.actor.send({ type: 'REPO_LOADED', commits: data.commits });
57
-
58
- // Hide landing overlay
59
- const landing = document.getElementById('landingOverlay');
60
- if (landing) landing.style.display = 'none';
61
-
62
- // Determine the best URL slug to display:
63
- // If the current URL is already a GitHub owner/repo slug that maps to this repo, keep it.
64
- // Otherwise fall back to the short folder name.
65
- const currentPath = decodeURIComponent(window.location.pathname.replace(/^\//, ''));
66
- const isCurrentGitHubSlug = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(currentPath)
67
- && localStorage.getItem(`gitcanvas:slug:${currentPath}`) === repoPath;
68
- const repoSlug = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
69
- const displaySlug = isCurrentGitHubSlug ? currentPath : repoSlug;
70
- history.replaceState(null, '', '/' + (displaySlug.includes('/') ? displaySlug : encodeURIComponent(displaySlug)));
71
- localStorage.setItem('gitcanvas:lastRepo', repoPath);
72
- // Store slug→path mapping for URL-based loading (both short and GitHub-style)
73
- localStorage.setItem(`gitcanvas:slug:${repoSlug}`, repoPath);
74
- if (isCurrentGitHubSlug) {
75
- localStorage.setItem(`gitcanvas:slug:${currentPath}`, repoPath);
76
- }
77
- updateStatusBarRepo(repoPath);
78
- // Save to recent repos list
79
- const recentKey = 'gitcanvas:recentRepos';
80
- const recent: string[] = JSON.parse(localStorage.getItem(recentKey) || '[]');
81
- const filtered = recent.filter(r => r !== repoPath);
82
- filtered.unshift(repoPath);
83
- localStorage.setItem(recentKey, JSON.stringify(filtered.slice(0, 10)));
84
- // Update dropdown if it exists
85
- const sel = document.getElementById('repoSelect') as HTMLSelectElement;
86
- if (sel) sel.value = repoPath;
87
-
88
- updateLoadingProgress(ctx, `Found ${data.commits.length} commits, rendering timeline...`);
89
- renderCommitTimeline(ctx);
90
-
91
- // Reload positions for the new repo BEFORE rendering files
92
- // so cards get placed at their correct saved locations
93
- ctx.snap().context.repoPath = repoPath;
94
- await loadSavedPositions(ctx);
95
-
96
- const viewState = ctx.snap().value?.view;
97
- // Always load all files first
98
- updateLoadingProgress(ctx, 'Loading all files...');
99
- await loadAllFiles(ctx);
100
-
101
- // Then select the first commit to get diff data
102
- if (data.commits.length > 0) {
103
- updateLoadingProgress(ctx, 'Loading commit diff...');
104
- await selectCommit(ctx, data.commits[0].hash);
105
- }
106
-
107
- hideLoadingProgress(ctx);
108
- _loadingRepo = null; // Allow future reloads
109
-
110
- // Re-render timeline after all async work — the initial renderCommitTimeline
111
- // at line 76 can get clobbered if DOM re-renders during loadAllFiles/selectCommit
112
- renderCommitTimeline(ctx);
113
-
114
- showToast(`Loaded ${data.commits.length} commits`, 'success');
115
-
116
- // Register in multi-repo workspace
117
- registerRepo(ctx, repoPath, data.commits, ctx.allFilesData || []);
118
- renderRepoTabs(ctx);
119
-
120
- // Trigger onboarding for first-time users
121
- if (!localStorage.getItem('gitcanvas:onboarded')) {
122
- import('./onboarding').then(m => m.startOnboarding(ctx));
123
- }
124
- } catch (err) {
125
- hideLoadingProgress(ctx);
126
- _loadingRepo = null; // Allow retry
127
- ctx.actor.send({ type: 'REPO_ERROR', error: err.message });
128
- measure('repo:loadError', () => err);
129
- showToast(`Failed: ${err.message} `, 'error');
130
- }
131
- });
132
- }
133
-
134
- // ─── Load all files (working tree) ───────────────────────
135
- export async function loadAllFiles(ctx: CanvasContext) {
136
- const state = ctx.snap().context;
137
- if (!state.repoPath) return;
138
-
139
- return measure('allfiles:load', async () => {
140
- try {
141
- const response = await fetch('/api/repo/tree', {
142
- method: 'POST',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ path: state.repoPath })
145
- });
146
-
147
- if (!response.ok) throw new Error(await response.text());
148
-
149
- const data = await response.json();
150
- ctx.actor.send({ type: 'ALL_FILES_LOADED', files: data.files });
151
- ctx.allFilesData = data.files;
152
- renderAllFilesOnCanvas(ctx, data.files);
153
- const fileCountEl = document.getElementById('fileCount');
154
- if (fileCountEl) fileCountEl.textContent = data.total;
155
- } catch (err) {
156
- measure('allfiles:loadError', () => err);
157
- showToast(`Failed to load files: ${err.message} `, 'error');
158
- }
159
- });
160
- }
161
-
162
- // ─── JSX Components for commit sidebar ──────────────────
163
- function CommitItem({ commit, lane, color, onClick }: { commit: any; lane: number; color: string; onClick: () => void }) {
164
- // Derive handle from email (part before @) — more useful than git config name
165
- const handle = commit.email
166
- ? commit.email.split('@')[0]
167
- : commit.author;
168
-
169
- // Calculate indentation based on visual lanes
170
- const paddingLeft = 16 + lane * 14;
171
-
172
- return (
173
- <div
174
- className="commit-item"
175
- data-hash={commit.hash}
176
- data-lane={lane}
177
- style={`padding-left: ${paddingLeft}px; --timeline-color: ${color};`}
178
- onClick={onClick}
179
- >
180
- <div className="commit-hash">{commit.hash.substring(0, 7)}</div>
181
- <div className="commit-message">
182
- {commit.refs && commit.refs.length > 0 && (
183
- <span className="commit-refs">
184
- {commit.refs.map(r => <span className="commit-ref-badge">{r}</span>)}
185
- </span>
186
- )}
187
- {commit.message}
188
- </div>
189
- <div className="commit-meta">
190
- <span className="commit-author">👤 {handle}</span>
191
- <span>{formatDate(commit.date)}</span>
192
- </div>
193
- </div>
194
- );
195
- }
196
-
197
- function CommitInfo({ hash, message, allFiles, changedCount }: {
198
- hash?: string; message?: string; allFiles?: boolean; changedCount?: number;
199
- }) {
200
- return (
201
- <>
202
- {allFiles && <span style="color: var(--accent-tertiary)">All Files</span>}
203
- {hash ? (
204
- <span className="commit-hash">{hash.substring(0, 7)}</span>
205
- ) : null}
206
- {message ? (
207
- <span style="color: var(--text-secondary)">{message}</span>
208
- ) : null}
209
- {!hash && allFiles ? (
210
- <span style="color: var(--text-muted)">Working tree</span>
211
- ) : null}
212
- {changedCount !== undefined ? (
213
- <span style="color: var(--text-muted); font-size: 0.7rem">• {changedCount} changed</span>
214
- ) : null}
215
- </>
216
- );
217
- }
218
-
219
- function updateCommitInfo(hash?: string, message?: string, allFiles?: boolean, changedCount?: number) {
220
- const el = document.getElementById('currentCommitInfo');
221
- if (el) render(<CommitInfo hash={hash} message={message} allFiles={allFiles} changedCount={changedCount} />, el);
222
- }
223
-
224
- // ─── Commit timeline render ──────────────────────────────
225
- export function renderCommitTimeline(ctx: CanvasContext) {
226
- measure('timeline:render', () => {
227
- const container = document.getElementById('timelineContainer');
228
- const countBadge = document.getElementById('commitCount');
229
- const state = ctx.snap().context;
230
- const commitsList = state.commits;
231
-
232
- if (countBadge) countBadge.textContent = commitsList.length;
233
-
234
- if (!container) return;
235
-
236
- if (commitsList.length === 0) {
237
- render(
238
- <div className="empty-state">
239
- <span style="opacity:0.4;font-size:32px">🕐</span>
240
- <p>No commits found</p>
241
- </div>,
242
- container
243
- );
244
- return;
245
- }
246
-
247
- // Branch graph calculation
248
- const lanes: (string | null)[] = [];
249
- const nodes: any[] = [];
250
- const colors = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'];
251
-
252
- commitsList.forEach((commit, i) => {
253
- // Find the lane reserved for this commit by a previous parent assignment
254
- let laneIndex = lanes.indexOf(commit.hash);
255
- if (laneIndex < 0) {
256
- // No lane reserved — find first empty slot
257
- laneIndex = lanes.findIndex(h => !h);
258
- if (laneIndex < 0) laneIndex = lanes.length;
259
- }
260
- // Clear the reservation (we're processing this commit now)
261
- lanes[laneIndex] = null;
262
- nodes.push({ hash: commit.hash, lane: laneIndex, index: i });
263
-
264
- if (commit.parents && commit.parents.length > 0) {
265
- commit.parents.forEach((pHash, pIndex) => {
266
- const pLaneIndex = lanes.indexOf(pHash);
267
- if (pIndex === 0) {
268
- // First parent: continue in the same lane
269
- if (pLaneIndex < 0) {
270
- lanes[laneIndex] = pHash;
271
- }
272
- // If parent already has a lane (from another child),
273
- // just leave laneIndex free — the edge drawing handles the visual connection
274
- } else {
275
- // Additional parents (merge): assign to a different lane
276
- if (pLaneIndex < 0) {
277
- let empty = lanes.findIndex(h => !h);
278
- if (empty < 0) empty = lanes.length;
279
- lanes[empty] = pHash;
280
- }
281
- }
282
- });
283
- }
284
- });
285
-
286
- render(
287
- <div style="position:relative;">
288
- <svg id="timelineGraph" style="position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:0;"></svg>
289
- <div id="timelineItems">
290
- {commitsList.map((commit, i) => (
291
- <CommitItem
292
- key={commit.hash}
293
- commit={commit}
294
- lane={nodes[i].lane}
295
- color={colors[nodes[i].lane % colors.length]}
296
- onClick={() => selectCommit(ctx, commit.hash)}
297
- />
298
- ))}
299
- </div>
300
- </div>,
301
- container
302
- );
303
-
304
- requestAnimationFrame(() => {
305
- const graph = document.getElementById('timelineGraph');
306
- if (!graph) return;
307
- const items = document.querySelectorAll('.commit-item');
308
- const coords = new Map<string, { x: number, y: number, color: string }>();
309
-
310
- let maxLane = 0;
311
- items.forEach((item: HTMLElement) => {
312
- const hash = item.dataset.hash;
313
- const lane = parseInt(item.dataset.lane || '0');
314
- if (lane > maxLane) maxLane = lane;
315
- // Center of the lane dot, shifted to accommodate the graph drawing
316
- const x = 16 + lane * 14;
317
- // offsetTop is relative to the relative parent div we just wrapped it in
318
- const y = item.offsetTop + item.offsetHeight / 2;
319
- coords.set(hash, { x, y, color: colors[lane % colors.length] });
320
- });
321
-
322
- let svgContent = '';
323
-
324
- // Draw edges
325
- commitsList.forEach(commit => {
326
- const start = coords.get(commit.hash);
327
- if (!start) return;
328
-
329
- (commit.parents || []).forEach((pHash, pIdx) => {
330
- const end = coords.get(pHash);
331
- if (!end) return;
332
-
333
- const isMerge = pIdx > 0;
334
- const pathColor = isMerge ? end.color : start.color;
335
-
336
- if (start.x === end.x) {
337
- svgContent += `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
338
- } else {
339
- const midY = start.y + (end.y - start.y) / 2;
340
- svgContent += `<path d="M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}" fill="none" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
341
- }
342
- });
343
- });
344
-
345
- // Draw nodes
346
- commitsList.forEach(commit => {
347
- const p = coords.get(commit.hash);
348
- if (!p) return;
349
- let dot = `<circle cx="${p.x}" cy="${p.y}" r="4.5" fill="${p.color}" stroke="var(--bg-secondary)" stroke-width="2" />`;
350
- if (commit.refs && commit.refs.length > 0) {
351
- dot += `<circle cx="${p.x}" cy="${p.y}" r="7" fill="none" stroke="${p.color}" stroke-opacity="0.8" stroke-width="1.5" />`;
352
- }
353
- svgContent += dot;
354
- });
355
-
356
- graph.innerHTML = svgContent;
357
- });
358
- });
359
- }
360
-
361
- // ─── Select commit ───────────────────────────────────────
362
- export async function selectCommit(ctx: CanvasContext, hash: string) {
363
- return measure('commit:select', async () => {
364
- ctx.actor.send({ type: 'SELECT_COMMIT', hash });
365
-
366
- document.querySelectorAll('.commit-item').forEach(el => {
367
- el.classList.toggle('active', el.dataset.hash === hash);
368
- });
369
-
370
- const state = ctx.snap().context;
371
- const commit = state.commits.find(c => c.hash === hash);
372
-
373
- // Show non-blocking inline progress bar (not overlay)
374
- _showCommitProgress(true, `${hash.substring(0, 7)} — ${commit?.message || ''}`);
375
-
376
- try {
377
- const response = await fetch('/api/repo/files', {
378
- method: 'POST',
379
- headers: { 'Content-Type': 'application/json' },
380
- body: JSON.stringify({ path: state.repoPath, commit: hash })
381
- });
382
-
383
- if (!response.ok) throw new Error(await response.text());
384
-
385
- const data = await response.json();
386
- ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
387
- ctx.commitFilesData = data.files;
388
-
389
- // Always re-render all files with highlighted changes
390
- ctx.changedFilePaths = new Set(data.files.map(f => f.path));
391
- if (ctx.allFilesData && ctx.allFilesData.length > 0) {
392
- renderAllFilesOnCanvas(ctx, ctx.allFilesData);
393
- }
394
-
395
- updateCommitInfo(hash, commit?.message || '', true, data.files.length);
396
-
397
- const fileCountEl = document.getElementById('fileCount');
398
- if (fileCountEl) fileCountEl.textContent = ctx.fileCards.size;
399
- _showCommitProgress(false);
400
- updateStatusBarCommit(hash);
401
- updateStatusBarFiles(ctx.fileCards.size);
402
-
403
- // Populate changed files panel with diff stats
404
- populateChangedFilesPanel(ctx, data.files);
405
- } catch (err) {
406
- _showCommitProgress(false);
407
- measure('commit:selectError', () => err);
408
- showToast(`Failed: ${err.message} `, 'error');
409
- }
410
- });
411
- }
412
-
413
- // ─── Inline commit progress bar (non-blocking) ──────────
414
- function _showCommitProgress(show: boolean, text?: string) {
415
- let bar = document.getElementById('commitProgressBar');
416
- if (show) {
417
- if (!bar) {
418
- bar = document.createElement('div');
419
- bar.id = 'commitProgressBar';
420
- bar.className = 'commit-progress-bar';
421
- const canvasArea = document.querySelector('.canvas-area');
422
- if (canvasArea) {
423
- canvasArea.insertBefore(bar, canvasArea.querySelector('.canvas-viewport'));
424
- } else {
425
- document.body.appendChild(bar);
426
- }
427
- }
428
- bar.innerHTML = `<div class="commit-progress-track"><div class="commit-progress-fill"></div></div>${text ? `<span class="commit-progress-text">${text}</span>` : ''}`;
429
- bar.style.display = 'flex';
430
- } else if (bar) {
431
- bar.style.display = 'none';
432
- }
433
- }
434
-
435
- // ─── Render files on canvas (commits mode) ───────────────
436
- export function renderFilesOnCanvas(ctx: CanvasContext, files: any[], commitHash: string) {
437
- measure('canvas:renderFiles', () => {
438
- clearCanvas(ctx);
439
-
440
- const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
441
- let layerFiles = visibleFiles;
442
- const activeLayer = getActiveLayer();
443
- if (activeLayer) {
444
- layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
445
- }
446
-
447
- const cols = Math.min(layerFiles.length, getAutoColumnCount(ctx));
448
- const cardWidth = 580;
449
- const cardHeight = 700;
450
- const gap = 40;
451
-
452
- layerFiles.forEach((f, index) => {
453
- const posKey = getPositionKey(f.path, commitHash);
454
- let x: number, y: number;
455
-
456
- if (ctx.positions.has(posKey)) {
457
- const pos = ctx.positions.get(posKey);
458
- x = pos.x; y = pos.y;
459
- } else {
460
- const col = index % cols;
461
- const row = Math.floor(index / cols);
462
- x = 50 + col * (cardWidth + gap);
463
- y = 50 + row * (cardHeight + gap);
464
- }
465
-
466
- const file = { ...f };
467
- if (activeLayer && activeLayer.files[file.path]) {
468
- file.layerSections = activeLayer.files[file.path].sections;
469
- }
470
-
471
- const card = createFileCard(ctx, file, x, y, commitHash);
472
- ctx.canvas.appendChild(card);
473
- ctx.fileCards.set(file.path, card);
474
- });
475
- renderConnections(ctx);
476
- buildConnectionMarkers(ctx);
477
- forceMinimapRebuild(ctx);
478
- // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
479
- requestAnimationFrame(() => performViewportCulling(ctx));
480
- });
481
- }
482
-
483
- // ─── Render all files on canvas (working tree) ──────────
484
- // Virtualized: only creates DOM for cards in/near the viewport.
485
- // Remaining cards are deferred and materialized on-demand by viewport culling.
486
- export function renderAllFilesOnCanvas(ctx: CanvasContext, files: any[]) {
487
- measure('canvas:renderAllFiles', () => {
488
- // In multi-repo mode, don't clear canvas if adding a second repo
489
- const isAdditionalRepo = isMultiRepoLoad();
490
- if (!isAdditionalRepo) {
491
- clearCanvas(ctx);
492
- ctx.deferredCards.clear();
493
- }
494
-
495
- // ── Phase 4c: Try CardManager path first ──
496
- const handled = renderAllFilesViaCardManager(ctx, files);
497
- if (handled) {
498
- renderConnections(ctx);
499
- buildConnectionMarkers(ctx);
500
- forceMinimapRebuild(ctx);
501
- // Materialize any deferred cards visible in initial viewport
502
- requestAnimationFrame(() => {
503
- materializeViewport(ctx);
504
- performViewportCulling(ctx);
505
- });
506
- return;
507
- }
508
-
509
- // ── Legacy fallback (CardManager not initialized) ──
510
- const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
511
- updateHiddenUI(ctx);
512
-
513
- // Build a map of changed file data (commit diff info)
514
- const changedFileDataMap = new Map<string, any>();
515
- if (ctx.commitFilesData) {
516
- ctx.commitFilesData.forEach(f => changedFileDataMap.set(f.path, f));
517
- }
518
-
519
- let layerFiles = visibleFiles;
520
- const activeLayer = getActiveLayer();
521
- if (activeLayer) {
522
- layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
523
- } else {
524
- // Default layer: exclude files that have been moved to other layers
525
- const { isFileMovedFromDefault } = require('./layers');
526
- layerFiles = visibleFiles.filter(f => !isFileMovedFromDefault(f.path));
527
- }
528
- // Sort by directory to group files spatially (makes dir-labels coherent)
529
- layerFiles.sort((a, b) => {
530
- const dirA = a.path.includes('/') ? a.path.substring(0, a.path.lastIndexOf('/')) : '.';
531
- const dirB = b.path.includes('/') ? b.path.substring(0, b.path.lastIndexOf('/')) : '.';
532
- if (dirA !== dirB) return dirA.localeCompare(dirB);
533
- const nameA = a.path.split('/').pop() || a.path;
534
- const nameB = b.path.split('/').pop() || b.path;
535
- return nameA.localeCompare(nameB);
536
- });
537
-
538
- // Square-ish grid: use ceil(sqrt(n)) columns for a dense rectangle
539
- const count = layerFiles.length;
540
- const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
541
- const defaultCardWidth = 580;
542
- const defaultCardHeight = 700;
543
- const gap = 20;
544
- const cellW = defaultCardWidth + gap;
545
- const cellH = defaultCardHeight + gap;
546
-
547
- // Determine initial viewport rect for virtualization
548
- const MARGIN = 800; // px beyond viewport to pre-create
549
- const state = ctx.snap().context;
550
- const vpEl = ctx.canvasViewport;
551
- const vpW = vpEl?.clientWidth || window.innerWidth;
552
- const vpH = vpEl?.clientHeight || window.innerHeight;
553
- const zoom = state.zoom || 1;
554
- const offsetX = state.offsetX || 0;
555
- const offsetY = state.offsetY || 0;
556
- const worldLeft = (-offsetX - MARGIN) / zoom;
557
- const worldTop = (-offsetY - MARGIN) / zoom;
558
- const worldRight = (vpW - offsetX + MARGIN) / zoom;
559
- const worldBottom = (vpH - offsetY + MARGIN) / zoom;
560
-
561
- let createdCount = 0;
562
- let deferredCount = 0;
563
-
564
- // Cache XState state once outside the loop avoids N snapshots for N files
565
- const cachedCardSizes = ctx.snap().context.cardSizes || {};
566
-
567
- // Multi-repo: offset grid origin to the right of existing repos
568
- const gridOriginX = isAdditionalRepo ? getNextRepoOffset() : 50;
569
- const gridOriginY = 50;
570
-
571
- layerFiles.forEach((f, index) => {
572
- const isChanged = ctx.changedFilePaths.has(f.path);
573
- const posKey = `allfiles:${f.path}`;
574
- let x: number, y: number;
575
-
576
- if (ctx.positions.has(posKey)) {
577
- const pos = ctx.positions.get(posKey);
578
- x = pos.x; y = pos.y;
579
- } else {
580
- const col = index % cols;
581
- const row = Math.floor(index / cols);
582
- x = gridOriginX + col * cellW;
583
- y = gridOriginY + row * cellH;
584
- }
585
-
586
- // Get saved size (from cached snapshot no per-file ctx.snap() call)
587
- let size = cachedCardSizes[f.path];
588
- if (!size && ctx.positions.has(posKey)) {
589
- const pos = ctx.positions.get(posKey);
590
- if (pos.width) size = { width: pos.width, height: pos.height };
591
- }
592
-
593
- // Merge diff data into the file for highlighting
594
- let fileWithDiff = { ...f };
595
- if (activeLayer && activeLayer.files[fileWithDiff.path]) {
596
- fileWithDiff.layerSections = activeLayer.files[fileWithDiff.path].sections;
597
- }
598
-
599
- if (isChanged && changedFileDataMap.has(fileWithDiff.path)) {
600
- const diffData = changedFileDataMap.get(fileWithDiff.path);
601
-
602
- // Use full content from diff data if available (has the latest version)
603
- if (diffData.content) {
604
- fileWithDiff.content = diffData.content;
605
- fileWithDiff.lines = diffData.content.split('\n').length;
606
- }
607
- fileWithDiff.status = diffData.status;
608
- fileWithDiff.hunks = diffData.hunks;
609
-
610
- // Compute added/deleted line info from hunks
611
- if (diffData.hunks?.length > 0) {
612
- const addedLines = new Set<number>();
613
- // Map: newLineNumber array of deleted line texts to show before that line
614
- const deletedBeforeLine = new Map<number, string[]>();
615
- for (const hunk of diffData.hunks) {
616
- let newLine = hunk.newStart;
617
- let pendingDeleted: string[] = [];
618
- for (const l of hunk.lines) {
619
- if (l.type === 'add') {
620
- addedLines.add(newLine);
621
- // Attach any pending deleted lines before this added line
622
- if (pendingDeleted.length > 0) {
623
- const existing = deletedBeforeLine.get(newLine) || [];
624
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
625
- pendingDeleted = [];
626
- }
627
- newLine++;
628
- } else if (l.type === 'del') {
629
- pendingDeleted.push(l.content);
630
- } else {
631
- // Context line — flush pending deleted before this
632
- if (pendingDeleted.length > 0) {
633
- const existing = deletedBeforeLine.get(newLine) || [];
634
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
635
- pendingDeleted = [];
636
- }
637
- newLine++;
638
- }
639
- }
640
- // Flush remaining deleted lines after the hunk
641
- if (pendingDeleted.length > 0) {
642
- const existing = deletedBeforeLine.get(newLine) || [];
643
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
644
- }
645
- }
646
- fileWithDiff.addedLines = addedLines;
647
- fileWithDiff.deletedBeforeLine = deletedBeforeLine;
648
- }
649
- }
650
-
651
- // All files use uniform default size unless user has a custom saved size
652
- if (!size) {
653
- size = { width: defaultCardWidth, height: defaultCardHeight };
654
- }
655
-
656
- // ── Virtualization: check if card is near the viewport ──
657
- const cardW = size?.width || defaultCardWidth;
658
- const cardH = size?.height || defaultCardHeight;
659
- const inViewport = (
660
- x + cardW > worldLeft &&
661
- x < worldRight &&
662
- y + cardH > worldTop &&
663
- y < worldBottom
664
- );
665
-
666
- if (inViewport) {
667
- // Create DOM immediately
668
- const card = createAllFileCard(ctx, fileWithDiff, x, y, size);
669
- if (isChanged) {
670
- card.classList.add('file-card--changed');
671
- card.dataset.changed = 'true';
672
- }
673
- ctx.canvas.appendChild(card);
674
- ctx.fileCards.set(f.path, card);
675
-
676
- // Restore scroll position
677
- const scrollKey = `scroll:${f.path}`;
678
- if (ctx.positions.has(scrollKey)) {
679
- const savedScroll = ctx.positions.get(scrollKey);
680
- requestAnimationFrame(() => {
681
- const body = card.querySelector('.file-card-body');
682
- if (body && savedScroll.x) body.scrollTop = savedScroll.x;
683
- });
684
- }
685
- createdCount++;
686
- } else {
687
- // Defer: store data for lazy creation when it enters viewport
688
- ctx.deferredCards.set(f.path, { file: fileWithDiff, x, y, size, isChanged });
689
- deferredCount++;
690
- }
691
- });
692
-
693
- console.log(`[render] Created ${createdCount} cards, deferred ${deferredCount} (total: ${count})`);
694
-
695
- renderConnections(ctx);
696
- buildConnectionMarkers(ctx);
697
- renderDirectoryLabels(ctx);
698
- forceMinimapRebuild(ctx);
699
- // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
700
- requestAnimationFrame(() => performViewportCulling(ctx));
701
- });
702
- }
703
-
704
- // ─── Directory labels on canvas ──────────────────────────
705
- // Groups visible file cards by parent directory and renders
706
- // a world-space label above each directory cluster.
707
- function renderDirectoryLabels(ctx: CanvasContext) {
708
- // Remove existing labels
709
- ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
710
-
711
- // Group cards by parent directory
712
- const groups = new Map<string, { minX: number; minY: number; maxX: number; count: number }>();
713
-
714
- const processCard = (path: string, x: number, y: number, w: number) => {
715
- const dir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '.';
716
- const g = groups.get(dir);
717
- if (g) {
718
- g.minX = Math.min(g.minX, x);
719
- g.minY = Math.min(g.minY, y);
720
- g.maxX = Math.max(g.maxX, x + w);
721
- g.count++;
722
- } else {
723
- groups.set(dir, { minX: x, minY: y, maxX: x + w, count: 1 });
724
- }
725
- };
726
-
727
- // Created cards (in DOM)
728
- ctx.fileCards.forEach((card, path) => {
729
- const x = parseFloat(card.style.left) || 0;
730
- const y = parseFloat(card.style.top) || 0;
731
- const w = card.offsetWidth || 580;
732
- processCard(path, x, y, w);
733
- });
734
-
735
- // Deferred cards (not yet in DOM)
736
- ctx.deferredCards.forEach((info, path) => {
737
- const w = info.size?.width || 580;
738
- processCard(path, info.x, info.y, w);
739
- });
740
-
741
- // Only show labels if we have multiple directories
742
- if (groups.size <= 1) return;
743
-
744
- const frag = document.createDocumentFragment();
745
- for (const [dir, g] of groups) {
746
- const label = document.createElement('div');
747
- label.className = 'dir-label';
748
- label.dataset.dir = dir;
749
- const centerX = (g.minX + g.maxX) / 2;
750
- label.style.left = `${centerX}px`;
751
- label.style.top = `${g.minY - 36}px`;
752
- label.style.transform = 'translateX(-50%)';
753
- label.innerHTML = `<span class="dir-label-icon">📁</span> ${dir}<span class="dir-label-count">${g.count}</span>`;
754
-
755
- // Click to collapse directory into a group card
756
- label.addEventListener('click', (e) => {
757
- e.stopPropagation();
758
- import('./card-groups').then(({ toggleDirectoryCollapse }) => {
759
- toggleDirectoryCollapse(ctx, dir);
760
- });
761
- });
762
-
763
- frag.appendChild(label);
764
- }
765
- ctx.canvas?.appendChild(frag);
766
- }
767
-
768
- // ─── Highlight changed files without re-rendering ────────
769
- export function highlightChangedFiles(ctx: CanvasContext) {
770
- measure('allfiles:highlight', () => {
771
- const hasChanges = ctx.changedFilePaths.size > 0;
772
- ctx.fileCards.forEach((card, path) => {
773
- const isChanged = hasChanges && ctx.changedFilePaths.has(path);
774
- card.classList.toggle('file-card--changed', isChanged);
775
- card.classList.toggle('file-card--unchanged', hasChanges && !isChanged);
776
- card.dataset.changed = isChanged ? 'true' : '';
777
- });
778
-
779
- // Rebuild minimap to reflect new highlighting
780
- forceMinimapRebuild(ctx);
781
- });
782
- }
783
-
784
- // ─── Switch view mode ────────────────────────────────────
785
- export function switchView(ctx: CanvasContext, mode: string) {
786
- if (mode === 'allfiles') {
787
- ctx.actor.send({ type: 'SWITCH_TO_ALLFILES' });
788
- ctx.allFilesActive = true;
789
- } else {
790
- ctx.actor.send({ type: 'SWITCH_TO_COMMITS' });
791
- ctx.allFilesActive = false;
792
- ctx.changedFilePaths.clear();
793
- ctx.commitFilesData = null;
794
- }
795
-
796
- document.getElementById('modeCommits')?.classList.toggle('active', mode === 'commits');
797
- document.getElementById('modeAllFiles')?.classList.toggle('active', mode === 'allfiles');
798
-
799
- if (mode === 'allfiles') {
800
- const state = ctx.snap().context;
801
- const commitInfo = document.getElementById('currentCommitInfo');
802
-
803
- if (state.currentCommitHash) {
804
- const commit = state.commits.find(c => c.hash === state.currentCommitHash);
805
- if (commitInfo) {
806
- updateCommitInfo(state.currentCommitHash, commit?.message || '', true);
807
- }
808
- } else {
809
- if (commitInfo) {
810
- updateCommitInfo(undefined, undefined, true);
811
- }
812
- }
813
-
814
- if (state.repoPath) {
815
- // If we have a selected commit, fetch its changed files first
816
- // so we can properly highlight/render them as diff cards
817
- const doRender = async () => {
818
- // Fetch commit files if we have a commit but don't have diff data yet
819
- if (state.currentCommitHash && (!ctx.commitFilesData || ctx.commitFilesData.length === 0)) {
820
- try {
821
- const response = await fetch('/api/repo/files', {
822
- method: 'POST',
823
- headers: { 'Content-Type': 'application/json' },
824
- body: JSON.stringify({ path: state.repoPath, commit: state.currentCommitHash })
825
- });
826
- if (response.ok) {
827
- const data = await response.json();
828
- ctx.commitFilesData = data.files;
829
- ctx.changedFilePaths = new Set(data.files.map(f => f.path));
830
- ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
831
- }
832
- } catch (err) {
833
- // Continue without diff data
834
- }
835
- } else if (state.commitFiles.length > 0) {
836
- ctx.commitFilesData = state.commitFiles;
837
- ctx.changedFilePaths = new Set(state.commitFiles.map(f => f.path));
838
- }
839
-
840
- // Now load and render all files
841
- if (ctx.allFilesData && ctx.allFilesData.length > 0) {
842
- renderAllFilesOnCanvas(ctx, ctx.allFilesData);
843
- const fileCountEl = document.getElementById('fileCount');
844
- if (fileCountEl) fileCountEl.textContent = ctx.allFilesData.length;
845
- } else {
846
- await loadAllFiles(ctx);
847
- }
848
- };
849
- doRender();
850
- }
851
- } else {
852
- const state = ctx.snap().context;
853
-
854
- // Always re-render the commit timeline sidebar
855
- renderCommitTimeline(ctx);
856
-
857
- if (state.currentCommitHash) {
858
- const commit = state.commits.find(c => c.hash === state.currentCommitHash);
859
- updateCommitInfo(state.currentCommitHash, commit?.message || '');
860
-
861
- if (state.commitFiles.length > 0) {
862
- // We have commit files in state — render them
863
- ctx.commitFilesData = state.commitFiles;
864
- renderFilesOnCanvas(ctx, state.commitFiles, state.currentCommitHash);
865
- populateChangedFilesPanel(ctx, state.commitFiles);
866
- const fileCountEl = document.getElementById('fileCount');
867
- if (fileCountEl) fileCountEl.textContent = state.commitFiles.length;
868
- } else {
869
- // Re-fetch commit files since we cleared commitFilesData
870
- selectCommit(ctx, state.currentCommitHash);
871
- }
872
-
873
- // Re-highlight active commit in sidebar
874
- requestAnimationFrame(() => {
875
- document.querySelectorAll('.commit-item').forEach(el => {
876
- (el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.hash === state.currentCommitHash);
877
- });
878
- });
879
- }
880
- }
881
- }
882
-
883
- // ─── Re-render current view ──────────────────────────────
884
- export function rerenderCurrentView(ctx: CanvasContext) {
885
- const data = ctx.allFilesData || ctx.snap().context.allFiles;
886
- if (data && data.length > 0) {
887
- renderAllFilesOnCanvas(ctx, data);
888
- }
889
- }
890
-
891
- // ─── Changed files panel (JSX) ──────────────────────────
892
- function ChangedFilesList({ fileStats, totalAdd, totalDel, count }: {
893
- fileStats: any[]; totalAdd: number; totalDel: number; count: number;
894
- }) {
895
- const statusColors = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444', renamed: '#a78bfa', copied: '#60a5fa' };
896
- const statusIcons = { added: '+', modified: '~', deleted: '−', renamed: '→', copied: '⊕' };
897
-
898
- return (
899
- <div className="changed-files-container-inner" style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
900
- <div className="changed-files-summary">
901
- <span className="stat-add">+{totalAdd}</span>
902
- <span className="stat-del">−{totalDel}</span>
903
- <span className="stat-files">{count} file{count > 1 ? 's' : ''}</span>
904
- </div>
905
- {fileStats.map(f => {
906
- const color = statusColors[f.status] || '#a855f7';
907
- const icon = statusIcons[f.status] || '~';
908
- const name = f.path.split('/').pop();
909
- const dir = f.path.includes('/') ? f.path.substring(0, f.path.lastIndexOf('/')) : '';
910
- return (
911
- <div
912
- key={f.path}
913
- className="changed-file-item"
914
- title={f.path}
915
- onClick={() => {
916
- if (!_panelCtx) return;
917
- // Animated zoom+pan to the file
918
- import('./canvas').then(({ jumpToFile }) => {
919
- jumpToFile(_panelCtx!, f.path);
920
- });
921
- }}
922
- >
923
- <span className="changed-file-status" style={`color: ${color} `}>{icon}</span>
924
- <span className="changed-file-name">{name}</span>
925
- {dir ? <span className="changed-file-dir">{dir}</span> : null}
926
- <span className="changed-file-stats">
927
- {f.additions > 0 ? <span className="stat-add">+{f.additions}</span> : null}
928
- {f.deletions > 0 ? <span className="stat-del">−{f.deletions}</span> : null}
929
- </span>
930
- </div>
931
- );
932
- })}
933
- </div>
934
- );
935
- }
936
-
937
- export function populateChangedFilesPanel(ctx: CanvasContext, files: any[]) {
938
- setPanelCtx(ctx);
939
- const panel = document.getElementById('changedFilesPanel');
940
- const listEl = document.getElementById('changedFilesList');
941
- if (!panel || !listEl) return;
942
-
943
- if (files.length === 0) {
944
- panel.style.display = 'none';
945
- return;
946
- }
947
-
948
- // Filter by active layer — only show changed files that are in the layer
949
- const activeLayer = getActiveLayer();
950
- const filteredFiles = activeLayer
951
- ? files.filter(f => !!activeLayer.files[f.path])
952
- : files;
953
-
954
- if (filteredFiles.length === 0) {
955
- panel.style.display = 'none';
956
- return;
957
- }
958
-
959
- let totalAdd = 0, totalDel = 0;
960
- const fileStats = filteredFiles.map(f => {
961
- let additions = 0, deletions = 0;
962
- if (f.hunks) {
963
- f.hunks.forEach(h => {
964
- h.lines.forEach(l => {
965
- if (l.type === 'add') additions++;
966
- else if (l.type === 'del') deletions++;
967
- });
968
- });
969
- } else if (f.status === 'added' && f.content) {
970
- additions = f.content.split('\n').length;
971
- } else if (f.status === 'deleted' && f.content) {
972
- deletions = f.content.split('\n').length;
973
- }
974
- totalAdd += additions;
975
- totalDel += deletions;
976
- return { ...f, additions, deletions };
977
- });
978
-
979
- render(
980
- <ChangedFilesList fileStats={fileStats} totalAdd={totalAdd} totalDel={totalDel} count={filteredFiles.length} />,
981
- listEl
982
- );
983
-
984
- if (panel.dataset.manuallyClosed !== 'true') {
985
- panel.style.display = 'flex';
986
- }
987
- }
1
+ // @ts-nocheck
2
+ /**
3
+ * Repository management — load, commit timeline, select commit, all-files.
4
+ */
5
+ import { measure } from "measure-fn";
6
+ import { render } from "melina/client";
7
+ import type { CanvasContext } from "./context";
8
+ import { escapeHtml, formatDate, showToast } from "./utils";
9
+ import {
10
+ clearCanvas,
11
+ getAutoColumnCount,
12
+ updateCanvasTransform,
13
+ updateZoomUI,
14
+ updateMinimap,
15
+ forceMinimapRebuild,
16
+ } from "./canvas";
17
+ import { performViewportCulling } from "./viewport-culling";
18
+ import { getPositionKey, loadSavedPositions } from "./positions";
19
+ import { updateHiddenUI } from "./hidden-files";
20
+ import { processVirtualFileSet } from "./virtual-files";
21
+ import {
22
+ showLoadingProgress,
23
+ updateLoadingProgress,
24
+ updateLoadingFileCount,
25
+ updateLoadingMessage,
26
+ hideLoadingProgress,
27
+ } from "./loading";
28
+ import {
29
+ createFileCard,
30
+ createAllFileCard,
31
+ debounceSaveScroll,
32
+ expandCardByPath,
33
+ } from "./cards";
34
+ import { getActiveLayer } from "./layers";
35
+ import { renderConnections, buildConnectionMarkers } from "./connections";
36
+ import {
37
+ renderAllFilesViaCardManager,
38
+ materializeViewport,
39
+ } from "./xydraw-bridge";
40
+ import {
41
+ registerRepo,
42
+ renderRepoTabs,
43
+ getNextRepoOffset,
44
+ isMultiRepoLoad,
45
+ getLoadedRepos,
46
+ } from "./multi-repo";
47
+ import {
48
+ updateStatusBarRepo,
49
+ updateStatusBarCommit,
50
+ updateStatusBarFiles,
51
+ } from "./status-bar";
52
+
53
+ // Shared: reference to ctx for changed-files panel navigation
54
+ let _panelCtx: CanvasContext | null = null;
55
+ export function setPanelCtx(ctx: CanvasContext) {
56
+ _panelCtx = ctx;
57
+ }
58
+
59
+ // Dedup guard: prevent concurrent or duplicate loadRepository calls
60
+ let _loadingRepo: string | null = null;
61
+ let _repoLoadRequestId = 0;
62
+ const LARGE_REPO_AUTO_COMMIT_THRESHOLD = 1000;
63
+
64
+ // ─── Load repository ─────────────────────────────────────
65
+ export async function loadRepository(ctx: CanvasContext, repoPath: string) {
66
+ if (!repoPath) return;
67
+
68
+ // Always init layers when loading a repo — ensures layers bar is visible
69
+ const { initLayers, renderLayersUI } = await import('./layers');
70
+ ctx.snap().context.repoPath = repoPath;
71
+ initLayers(ctx);
72
+ renderLayersUI(ctx);
73
+
74
+ // Prevent duplicate loads of the same repo (e.g. mount triggers both hash + localStorage paths)
75
+ if (_loadingRepo === repoPath) {
76
+ console.log(
77
+ `[repo] Skipping duplicate load for "${repoPath}" — already loading`,
78
+ );
79
+ return;
80
+ }
81
+
82
+ const requestId = ++_repoLoadRequestId;
83
+ const isStale = () => requestId !== _repoLoadRequestId;
84
+ const clearLoadingGuard = () => {
85
+ if (!isStale() && _loadingRepo === repoPath) {
86
+ _loadingRepo = null;
87
+ }
88
+ };
89
+
90
+ _loadingRepo = repoPath;
91
+ _panelCtx = ctx;
92
+ ctx.actor.send({ type: "LOAD_REPO", path: repoPath });
93
+
94
+ return measure("repo:load", async () => {
95
+ try {
96
+ document.body.classList.remove("landing-placeholder-visible");
97
+ showLoadingProgress(ctx, "Loading repository...", 0);
98
+ updateLoadingProgress(ctx, repoPath, 10);
99
+
100
+ const response = await fetch("/api/repo/load", {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ path: repoPath }),
104
+ });
105
+
106
+ if (!response.ok) throw new Error(await response.text());
107
+
108
+ updateLoadingProgress(ctx, "Parsing commits...", 30);
109
+ const data = await response.json();
110
+ if (isStale()) {
111
+ console.log(`[repo] Ignoring stale load result for "${repoPath}"`);
112
+ return;
113
+ }
114
+ ctx.actor.send({ type: "REPO_LOADED", commits: data.commits });
115
+
116
+ // Add to recent repos
117
+ const { addRecentRepo } = require("./recent-commits");
118
+ addRecentRepo(repoPath, data.commits.length);
119
+
120
+ // Set global repo path for image URLs
121
+ (window as any).__GITCANVAS_REPO_PATH__ = repoPath;
122
+
123
+ // Hide landing overlay
124
+ const landing = document.getElementById("landingOverlay");
125
+ if (landing) landing.style.display = "none";
126
+
127
+ // Determine the best URL slug to display:
128
+ // If the current URL is already a GitHub owner/repo slug that maps to this repo, keep it.
129
+ // Otherwise fall back to the short folder name.
130
+ const canonicalSlug = data.canonicalSlug || "";
131
+ const currentPath = decodeURIComponent(
132
+ window.location.pathname.replace(/^\//, ""),
133
+ );
134
+ const isCurrentCanonicalSlug =
135
+ currentPath.includes("/") &&
136
+ !currentPath.includes("\\") &&
137
+ !currentPath.includes(":") &&
138
+ localStorage.getItem(`gitcanvas:slug:${currentPath}`) === repoPath;
139
+ const repoSlug =
140
+ repoPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ||
141
+ repoPath;
142
+ const displaySlug = isCurrentCanonicalSlug
143
+ ? currentPath
144
+ : canonicalSlug || repoSlug;
145
+ const commitHash = data.commits[0]?.hash || "";
146
+ history.replaceState(
147
+ null,
148
+ "",
149
+ "/" +
150
+ (displaySlug.includes("/")
151
+ ? displaySlug
152
+ : encodeURIComponent(displaySlug)) +
153
+ (commitHash ? `#${commitHash}` : ""),
154
+ );
155
+ localStorage.setItem("gitcanvas:lastRepo", repoPath);
156
+ // Store slug→path mapping for URL-based loading (both short and GitHub-style)
157
+ localStorage.setItem(`gitcanvas:slug:${repoSlug}`, repoPath);
158
+ if (canonicalSlug) {
159
+ localStorage.setItem(`gitcanvas:slug:${canonicalSlug}`, repoPath);
160
+ }
161
+ if (isCurrentCanonicalSlug) {
162
+ localStorage.setItem(`gitcanvas:slug:${currentPath}`, repoPath);
163
+ }
164
+ updateStatusBarRepo(
165
+ repoPath,
166
+ canonicalSlug || "",
167
+ data.canonicalSlugSource || "",
168
+ );
169
+ if (canonicalSlug) {
170
+ console.info(
171
+ `[gitmaps] canonical slug: ${canonicalSlug} ← ${repoPath}${data.canonicalSlugSource ? ` (${data.canonicalSlugSource})` : ""}`,
172
+ );
173
+ }
174
+ // Update dropdown if it exists
175
+ const sel = document.getElementById("repoSelect") as HTMLSelectElement;
176
+ if (sel) sel.value = repoPath;
177
+
178
+ updateLoadingProgress(
179
+ ctx,
180
+ `Found ${data.commits.length} commits, rendering timeline...`,
181
+ 50,
182
+ );
183
+ renderCommitTimeline(ctx);
184
+
185
+ // Reload positions for the new repo BEFORE rendering files
186
+ // so cards get placed at their correct saved locations
187
+ ctx.snap().context.repoPath = repoPath;
188
+ await loadSavedPositions(ctx);
189
+ if (isStale()) return;
190
+
191
+ const viewState = ctx.snap().value?.view;
192
+ // Always load all files first — loadAllFiles now shows real file count progress
193
+ await loadAllFiles(ctx);
194
+ if (isStale()) return;
195
+
196
+ // Then select commit (from URL hash or first commit)
197
+ if (data.commits.length > 0) {
198
+ const totalFiles = ctx.allFilesData?.length || 0;
199
+ const hashFromUrl = window.location.hash?.replace("#", "");
200
+ const commitToSelect =
201
+ hashFromUrl && data.commits.find((c) => c.hash === hashFromUrl)
202
+ ? hashFromUrl
203
+ : data.commits[0].hash;
204
+
205
+ if (totalFiles > LARGE_REPO_AUTO_COMMIT_THRESHOLD) {
206
+ const selectedCommit =
207
+ data.commits.find((c) => c.hash === commitToSelect) || data.commits[0];
208
+ ctx.actor.send({ type: "SELECT_COMMIT", hash: commitToSelect });
209
+ updateCommitInfo(commitToSelect, selectedCommit?.message || "", true);
210
+ updateStatusBarCommit(commitToSelect);
211
+ const [basePath] = window.location.href.split("#");
212
+ history.replaceState(null, "", `${basePath}#${commitToSelect}`);
213
+ console.info(
214
+ `[repo] Skipping initial commit diff render for large repo (${totalFiles} files): ${repoPath}`,
215
+ );
216
+ } else {
217
+ updateLoadingMessage(
218
+ ctx,
219
+ totalFiles > 0
220
+ ? `Loading commit diff — ${totalFiles} files indexed`
221
+ : "Loading commit diff...",
222
+ );
223
+ if (totalFiles > 0) {
224
+ updateLoadingFileCount(
225
+ ctx,
226
+ totalFiles,
227
+ totalFiles,
228
+ `Comparing selected commit against ${totalFiles} indexed files`,
229
+ );
230
+ }
231
+ await selectCommit(ctx, commitToSelect);
232
+ if (isStale()) return;
233
+ }
234
+ }
235
+
236
+ // Generate virtual transclusion cards only after the final commit/file render settles.
237
+ setTimeout(() => {
238
+ if (!isStale()) processVirtualFiles(ctx);
239
+ }, 120);
240
+
241
+ updateLoadingProgress(ctx, "Done!", 100);
242
+ hideLoadingProgress(ctx);
243
+ clearLoadingGuard(); // Allow future reloads
244
+
245
+ // Re-render timeline after all async work — the initial renderCommitTimeline
246
+ // at line 76 can get clobbered if DOM re-renders during loadAllFiles/selectCommit
247
+ renderCommitTimeline(ctx);
248
+
249
+ showToast(`Loaded ${data.commits.length} commits`, "success");
250
+
251
+ // Register in multi-repo workspace
252
+ registerRepo(ctx, repoPath, data.commits, ctx.allFilesData || []);
253
+ renderRepoTabs(ctx);
254
+
255
+ // Onboarding removed users learn by exploring the canvas directly
256
+ } catch (err) {
257
+ if (!isStale()) {
258
+ hideLoadingProgress(ctx);
259
+ clearLoadingGuard(); // Allow retry
260
+ ctx.actor.send({ type: "REPO_ERROR", error: err.message });
261
+ measure("repo:loadError", () => err);
262
+ console.error("[repo:loadError] Full error:", err, err?.stack);
263
+ (window as any).__lastLoadError = { message: err?.message, stack: err?.stack, name: err?.name, err: String(err) };
264
+ showToast(`Failed: ${err.message} `, "error");
265
+ }
266
+ }
267
+ });
268
+ }
269
+
270
+ // ─── Load all files (working tree) ───────────────────────
271
+ export async function loadAllFiles(ctx: CanvasContext) {
272
+ const state = ctx.snap().context;
273
+ if (!state.repoPath) return;
274
+
275
+ return measure("allfiles:load", async () => {
276
+ try {
277
+ // Use streaming endpoint for real progress
278
+ const response = await fetch("/api/repo/tree", {
279
+ signal: AbortSignal.timeout(300000),
280
+ method: "POST",
281
+ headers: { "Content-Type": "application/json" },
282
+ body: JSON.stringify({ path: state.repoPath, stream: true }),
283
+ });
284
+
285
+ if (!response.ok) throw new Error(await response.text());
286
+
287
+ const allFiles: any[] = [];
288
+ let total = 0;
289
+
290
+ // Parse NDJSON stream
291
+ const reader = response.body!.getReader();
292
+ const decoder = new TextDecoder();
293
+ let buffer = "";
294
+
295
+ while (true) {
296
+ const { done, value } = await reader.read();
297
+ if (done) break;
298
+
299
+ buffer += decoder.decode(value, { stream: true });
300
+ const lines = buffer.split("\n");
301
+ buffer = lines.pop() || ""; // Keep incomplete last line in buffer
302
+
303
+ for (const line of lines) {
304
+ if (!line.trim()) continue;
305
+ try {
306
+ const chunk = JSON.parse(line);
307
+
308
+ if (chunk.total !== undefined && !chunk.files) {
309
+ // First message: total file count
310
+ total = chunk.total;
311
+ updateLoadingMessage(ctx, `Loading files — ${total} total`);
312
+ updateLoadingFileCount(ctx, 0, total, state.repoPath);
313
+ continue;
314
+ }
315
+
316
+ if (chunk.files) {
317
+ allFiles.push(...chunk.files);
318
+ const loaded = chunk.loaded || allFiles.length;
319
+ const remaining = Math.max(total - loaded, 0);
320
+ updateLoadingFileCount(
321
+ ctx,
322
+ loaded,
323
+ total,
324
+ `${loaded} loaded • ${remaining} remaining`,
325
+ );
326
+ }
327
+ } catch (e) {
328
+ console.warn("[tree-stream] Failed to parse line:", line, e);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Process remaining buffer
334
+ if (buffer.trim()) {
335
+ try {
336
+ const chunk = JSON.parse(buffer);
337
+ if (chunk.files) allFiles.push(...chunk.files);
338
+ } catch (e) { /* ignore */ }
339
+ }
340
+
341
+ ctx.actor.send({ type: "ALL_FILES_LOADED", files: allFiles });
342
+ ctx.allFilesData = allFiles;
343
+ updateLoadingFileCount(
344
+ ctx,
345
+ total,
346
+ total,
347
+ `Rendering ${total} cards • 0 remaining`,
348
+ );
349
+ renderAllFilesOnCanvas(ctx, allFiles);
350
+ const fileCountEl = document.getElementById("fileCount");
351
+ if (fileCountEl) fileCountEl.textContent = allFiles.length;
352
+ // Auto-fit view after loading files
353
+ setTimeout(() => {
354
+ const { fitAllFiles } = require("./canvas");
355
+ fitAllFiles(ctx);
356
+ }, 100);
357
+ // Virtual transclusion cards are generated after the final repo render
358
+ // (later in loadRepository), otherwise commit selection can clear them.
359
+ } catch (err) {
360
+ measure("allfiles:loadError", () => err);
361
+ showToast(`Failed to load files: ${err.message} `, "error");
362
+ }
363
+ });
364
+ }
365
+
366
+ // ─── JSX Components for commit sidebar ──────────────────
367
+ function CommitItem({
368
+ commit,
369
+ lane,
370
+ color,
371
+ onClick,
372
+ }: {
373
+ commit: any;
374
+ lane: number;
375
+ color: string;
376
+ onClick: () => void;
377
+ }) {
378
+ // Derive handle from email (part before @) — more useful than git config name
379
+ const handle = commit.email ? commit.email.split("@")[0] : commit.author;
380
+
381
+ // Calculate indentation based on visual lanes
382
+ const paddingLeft = 16 + lane * 14;
383
+
384
+ return (
385
+ <div
386
+ className="commit-item"
387
+ data-hash={commit.hash}
388
+ data-lane={lane}
389
+ style={`padding-left: ${paddingLeft}px; --timeline-color: ${color};`}
390
+ onClick={onClick}
391
+ >
392
+ <div className="commit-hash">{commit.hash.substring(0, 7)}</div>
393
+ <div className="commit-message">
394
+ {commit.refs && commit.refs.length > 0 && (
395
+ <span className="commit-refs">
396
+ {commit.refs.map((r) => (
397
+ <span className="commit-ref-badge">{r}</span>
398
+ ))}
399
+ </span>
400
+ )}
401
+ {commit.message}
402
+ </div>
403
+ <div className="commit-meta">
404
+ <span className="commit-author">👤 {handle}</span>
405
+ <span>{formatDate(commit.date)}</span>
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ function CommitInfo({
412
+ hash,
413
+ message,
414
+ allFiles,
415
+ changedCount,
416
+ }: {
417
+ hash?: string;
418
+ message?: string;
419
+ allFiles?: boolean;
420
+ changedCount?: number;
421
+ }) {
422
+ return (
423
+ <>
424
+ {allFiles && <span style="color: var(--accent-tertiary)">All Files</span>}
425
+ {hash ? (
426
+ <span className="commit-hash">{hash.substring(0, 7)}</span>
427
+ ) : null}
428
+ {message ? (
429
+ <span style="color: var(--text-secondary)">{message}</span>
430
+ ) : null}
431
+ {!hash && allFiles ? (
432
+ <span style="color: var(--text-muted)">Working tree</span>
433
+ ) : null}
434
+ {changedCount !== undefined ? (
435
+ <span style="color: var(--text-muted); font-size: 0.7rem">
436
+ {changedCount} changed
437
+ </span>
438
+ ) : null}
439
+ </>
440
+ );
441
+ }
442
+
443
+ function updateCommitInfo(
444
+ hash?: string,
445
+ message?: string,
446
+ allFiles?: boolean,
447
+ changedCount?: number,
448
+ ) {
449
+ const el = document.getElementById("currentCommitInfo");
450
+ if (el)
451
+ render(
452
+ <CommitInfo
453
+ hash={hash}
454
+ message={message}
455
+ allFiles={allFiles}
456
+ changedCount={changedCount}
457
+ />,
458
+ el,
459
+ );
460
+ }
461
+
462
+ // ─── Commit timeline render ──────────────────────────────
463
+ export function renderCommitTimeline(ctx: CanvasContext) {
464
+ measure("timeline:render", () => {
465
+ const container = document.getElementById("timelineContainer");
466
+ const countBadge = document.getElementById("commitCount");
467
+ const state = ctx.snap().context;
468
+ const commitsList = state.commits;
469
+
470
+ if (countBadge) countBadge.textContent = commitsList.length;
471
+
472
+ if (!container) return;
473
+
474
+ if (commitsList.length === 0) {
475
+ render(
476
+ <div className="empty-state">
477
+ <span style="opacity:0.4;font-size:32px">🕐</span>
478
+ <p>No commits found</p>
479
+ </div>,
480
+ container,
481
+ );
482
+ return;
483
+ }
484
+
485
+ // Branch graph calculation
486
+ const lanes: (string | null)[] = [];
487
+ const nodes: any[] = [];
488
+ const colors = [
489
+ "#7c3aed",
490
+ "#3b82f6",
491
+ "#10b981",
492
+ "#f59e0b",
493
+ "#ef4444",
494
+ "#ec4899",
495
+ "#06b6d4",
496
+ ];
497
+
498
+ commitsList.forEach((commit, i) => {
499
+ // Find the lane reserved for this commit by a previous parent assignment
500
+ let laneIndex = lanes.indexOf(commit.hash);
501
+ if (laneIndex < 0) {
502
+ // No lane reserved — find first empty slot
503
+ laneIndex = lanes.findIndex((h) => !h);
504
+ if (laneIndex < 0) laneIndex = lanes.length;
505
+ }
506
+ // Clear the reservation (we're processing this commit now)
507
+ lanes[laneIndex] = null;
508
+ nodes.push({ hash: commit.hash, lane: laneIndex, index: i });
509
+
510
+ if (commit.parents && commit.parents.length > 0) {
511
+ commit.parents.forEach((pHash, pIndex) => {
512
+ const pLaneIndex = lanes.indexOf(pHash);
513
+ if (pIndex === 0) {
514
+ // First parent: continue in the same lane
515
+ if (pLaneIndex < 0) {
516
+ lanes[laneIndex] = pHash;
517
+ }
518
+ // If parent already has a lane (from another child),
519
+ // just leave laneIndex free — the edge drawing handles the visual connection
520
+ } else {
521
+ // Additional parents (merge): assign to a different lane
522
+ if (pLaneIndex < 0) {
523
+ let empty = lanes.findIndex((h) => !h);
524
+ if (empty < 0) empty = lanes.length;
525
+ lanes[empty] = pHash;
526
+ }
527
+ }
528
+ });
529
+ }
530
+ });
531
+
532
+ render(
533
+ <div style="position:relative;">
534
+ <svg
535
+ id="timelineGraph"
536
+ style="position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:0;"
537
+ ></svg>
538
+ <div id="timelineItems">
539
+ {commitsList.map((commit, i) => (
540
+ <CommitItem
541
+ key={commit.hash}
542
+ commit={commit}
543
+ lane={nodes[i].lane}
544
+ color={colors[nodes[i].lane % colors.length]}
545
+ onClick={() => selectCommit(ctx, commit.hash)}
546
+ />
547
+ ))}
548
+ </div>
549
+ </div>,
550
+ container,
551
+ );
552
+
553
+ requestAnimationFrame(() => {
554
+ const graph = document.getElementById("timelineGraph");
555
+ if (!graph) return;
556
+ const items = document.querySelectorAll(".commit-item");
557
+ const coords = new Map<string, { x: number; y: number; color: string }>();
558
+
559
+ let maxLane = 0;
560
+ items.forEach((item: HTMLElement) => {
561
+ const hash = item.dataset.hash;
562
+ const lane = parseInt(item.dataset.lane || "0");
563
+ if (lane > maxLane) maxLane = lane;
564
+ // Center of the lane dot, shifted to accommodate the graph drawing
565
+ const x = 16 + lane * 14;
566
+ // offsetTop is relative to the relative parent div we just wrapped it in
567
+ const y = item.offsetTop + item.offsetHeight / 2;
568
+ coords.set(hash, { x, y, color: colors[lane % colors.length] });
569
+ });
570
+
571
+ let svgContent = "";
572
+
573
+ // Draw edges
574
+ commitsList.forEach((commit) => {
575
+ const start = coords.get(commit.hash);
576
+ if (!start) return;
577
+
578
+ (commit.parents || []).forEach((pHash, pIdx) => {
579
+ const end = coords.get(pHash);
580
+ if (!end) return;
581
+
582
+ const isMerge = pIdx > 0;
583
+ const pathColor = isMerge ? end.color : start.color;
584
+
585
+ if (start.x === end.x) {
586
+ svgContent += `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
587
+ } else {
588
+ const midY = start.y + (end.y - start.y) / 2;
589
+ svgContent += `<path d="M ${start.x} ${start.y} C ${start.x} ${midY}, ${end.x} ${midY}, ${end.x} ${end.y}" fill="none" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
590
+ }
591
+ });
592
+ });
593
+
594
+ // Draw nodes
595
+ commitsList.forEach((commit) => {
596
+ const p = coords.get(commit.hash);
597
+ if (!p) return;
598
+ let dot = `<circle cx="${p.x}" cy="${p.y}" r="4.5" fill="${p.color}" stroke="var(--bg-secondary)" stroke-width="2" />`;
599
+ if (commit.refs && commit.refs.length > 0) {
600
+ dot += `<circle cx="${p.x}" cy="${p.y}" r="7" fill="none" stroke="${p.color}" stroke-opacity="0.8" stroke-width="1.5" />`;
601
+ }
602
+ svgContent += dot;
603
+ });
604
+
605
+ graph.innerHTML = svgContent;
606
+ });
607
+ });
608
+ }
609
+
610
+ // ─── Select commit ───────────────────────────────────────
611
+ export async function selectCommit(ctx: CanvasContext, hash: string) {
612
+ return measure("commit:select", async () => {
613
+ ctx.actor.send({ type: "SELECT_COMMIT", hash });
614
+
615
+ document.querySelectorAll(".commit-item").forEach((el) => {
616
+ el.classList.toggle("active", el.dataset.hash === hash);
617
+ });
618
+
619
+ const state = ctx.snap().context;
620
+ const commit = state.commits.find((c) => c.hash === hash);
621
+
622
+ // Show non-blocking inline progress bar (not overlay)
623
+ const indexedFiles = ctx.allFilesData?.length || 0;
624
+ _showCommitProgress(
625
+ true,
626
+ indexedFiles > 0
627
+ ? `${hash.substring(0, 7)} • ${indexedFiles} indexed files`
628
+ : `${hash.substring(0, 7)} ${commit?.message || ""}`,
629
+ );
630
+
631
+ try {
632
+ const response = await fetch("/api/repo/files", {
633
+ method: "POST",
634
+ headers: { "Content-Type": "application/json" },
635
+ body: JSON.stringify({ path: state.repoPath, commit: hash }),
636
+ });
637
+
638
+ if (!response.ok) throw new Error(await response.text());
639
+
640
+ const data = await response.json();
641
+ ctx.actor.send({ type: "COMMIT_FILES_LOADED", files: data.files });
642
+ ctx.commitFilesData = data.files;
643
+
644
+ // Always re-render all files with highlighted changes
645
+ ctx.changedFilePaths = new Set(data.files.map((f) => f.path));
646
+ if (ctx.allFilesData && ctx.allFilesData.length > 0) {
647
+ renderAllFilesOnCanvas(ctx, ctx.allFilesData);
648
+ }
649
+
650
+ updateCommitInfo(hash, commit?.message || "", true, data.files.length);
651
+
652
+ const fileCountEl = document.getElementById("fileCount");
653
+ if (fileCountEl) fileCountEl.textContent = ctx.fileCards.size;
654
+ _showCommitProgress(false);
655
+ updateStatusBarCommit(hash);
656
+ updateStatusBarFiles(ctx.fileCards.size);
657
+
658
+ // Update URL hash for shareable links
659
+ const [basePath] = window.location.href.split("#");
660
+ history.replaceState(null, "", `${basePath}#${hash}`);
661
+
662
+ // Populate changed files panel with diff stats
663
+ populateChangedFilesPanel(ctx, data.files);
664
+ } catch (err) {
665
+ _showCommitProgress(false);
666
+ measure("commit:selectError", () => err);
667
+ showToast(`Failed: ${err.message} `, "error");
668
+ }
669
+ });
670
+ }
671
+
672
+ // ─── Inline commit progress bar (non-blocking) ──────────
673
+ function _showCommitProgress(show: boolean, text?: string) {
674
+ let bar = document.getElementById("commitProgressBar");
675
+ if (show) {
676
+ if (!bar) {
677
+ bar = document.createElement("div");
678
+ bar.id = "commitProgressBar";
679
+ bar.className = "commit-progress-bar";
680
+ const canvasArea = document.querySelector(".canvas-area");
681
+ if (canvasArea) {
682
+ canvasArea.insertBefore(
683
+ bar,
684
+ canvasArea.querySelector(".canvas-viewport"),
685
+ );
686
+ } else {
687
+ document.body.appendChild(bar);
688
+ }
689
+ }
690
+ bar.innerHTML = `<div class="commit-progress-track"><div class="commit-progress-fill"></div></div>${text ? `<span class="commit-progress-text">${text}</span>` : ""}`;
691
+ bar.style.display = "flex";
692
+ } else if (bar) {
693
+ bar.style.display = "none";
694
+ }
695
+ }
696
+
697
+ // ─── Render files on canvas (commits mode) ───────────────
698
+ export function renderFilesOnCanvas(
699
+ ctx: CanvasContext,
700
+ files: any[],
701
+ commitHash: string,
702
+ ) {
703
+ measure("canvas:renderFiles", () => {
704
+ clearCanvas(ctx);
705
+
706
+ const visibleFiles = files.filter((f) => !ctx.hiddenFiles.has(f.path));
707
+ let layerFiles = visibleFiles;
708
+ const activeLayer = getActiveLayer();
709
+ if (activeLayer) {
710
+ layerFiles = visibleFiles.filter((f) => !!activeLayer.files[f.path]);
711
+ }
712
+
713
+ const cols = Math.min(layerFiles.length, getAutoColumnCount(ctx));
714
+ const cardWidth = 580;
715
+ const cardHeight = 700;
716
+ const gap = 40;
717
+
718
+ layerFiles.forEach((f, index) => {
719
+ const posKey = getPositionKey(f.path, commitHash);
720
+ let x: number, y: number;
721
+
722
+ if (ctx.positions.has(posKey)) {
723
+ const pos = ctx.positions.get(posKey);
724
+ x = pos.x;
725
+ y = pos.y;
726
+ } else {
727
+ const col = index % cols;
728
+ const row = Math.floor(index / cols);
729
+ x = 50 + col * (cardWidth + gap);
730
+ y = 50 + row * (cardHeight + gap);
731
+ }
732
+
733
+ const file = { ...f };
734
+ if (activeLayer && activeLayer.files[file.path]) {
735
+ file.layerSections = activeLayer.files[file.path].sections;
736
+ }
737
+
738
+ const card = createFileCard(ctx, file, x, y, commitHash);
739
+ ctx.canvas.appendChild(card);
740
+ ctx.fileCards.set(file.path, card);
741
+ });
742
+ renderConnections(ctx);
743
+ buildConnectionMarkers(ctx);
744
+ forceMinimapRebuild(ctx);
745
+ // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
746
+ requestAnimationFrame(() => performViewportCulling(ctx));
747
+ });
748
+ }
749
+
750
+ // ─── Render all files on canvas (working tree) ──────────
751
+ // Virtualized: only creates DOM for cards in/near the viewport.
752
+ // Remaining cards are deferred and materialized on-demand by viewport culling.
753
+ export function renderAllFilesOnCanvas(ctx: CanvasContext, files: any[]) {
754
+ measure("canvas:renderAllFiles", () => {
755
+ // In multi-repo mode, don't clear canvas if adding a second repo
756
+ const isAdditionalRepo = isMultiRepoLoad();
757
+ if (!isAdditionalRepo) {
758
+ clearCanvas(ctx);
759
+ ctx.deferredCards.clear();
760
+ }
761
+
762
+ // ── Phase 4c: Try CardManager path first ──
763
+ const handled = renderAllFilesViaCardManager(ctx, files);
764
+ if (handled) {
765
+ renderConnections(ctx);
766
+ buildConnectionMarkers(ctx);
767
+ forceMinimapRebuild(ctx);
768
+ // Materialize any deferred cards visible in initial viewport
769
+ requestAnimationFrame(() => {
770
+ materializeViewport(ctx);
771
+ performViewportCulling(ctx);
772
+ });
773
+ return;
774
+ }
775
+
776
+ // ── Legacy fallback (CardManager not initialized) ──
777
+ const visibleFiles = files.filter((f) => !ctx.hiddenFiles.has(f.path));
778
+ updateHiddenUI(ctx);
779
+
780
+ // Build a map of changed file data (commit diff info)
781
+ const changedFileDataMap = new Map<string, any>();
782
+ if (ctx.commitFilesData) {
783
+ ctx.commitFilesData.forEach((f) => changedFileDataMap.set(f.path, f));
784
+ }
785
+
786
+ let layerFiles = visibleFiles;
787
+ const activeLayer = getActiveLayer();
788
+ if (activeLayer) {
789
+ layerFiles = visibleFiles.filter((f) => !!activeLayer.files[f.path]);
790
+ } else {
791
+ // Default layer: exclude files that have been moved to other layers
792
+ const { isFileMovedFromDefault } = require("./layers");
793
+ layerFiles = visibleFiles.filter((f) => !isFileMovedFromDefault(f.path));
794
+ }
795
+ // Sort by directory to group files spatially (makes dir-labels coherent)
796
+ layerFiles.sort((a, b) => {
797
+ const dirA = a.path.includes("/")
798
+ ? a.path.substring(0, a.path.lastIndexOf("/"))
799
+ : ".";
800
+ const dirB = b.path.includes("/")
801
+ ? b.path.substring(0, b.path.lastIndexOf("/"))
802
+ : ".";
803
+ if (dirA !== dirB) return dirA.localeCompare(dirB);
804
+ const nameA = a.path.split("/").pop() || a.path;
805
+ const nameB = b.path.split("/").pop() || b.path;
806
+ return nameA.localeCompare(nameB);
807
+ });
808
+
809
+ // Square-ish grid: use ceil(sqrt(n)) columns for a dense rectangle
810
+ const count = layerFiles.length;
811
+ const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
812
+ const defaultCardWidth = 580;
813
+ const defaultCardHeight = 700;
814
+ const gap = 20;
815
+ const cellW = defaultCardWidth + gap;
816
+ const cellH = defaultCardHeight + gap;
817
+
818
+ // Auto-arrange: group files by directory for spatial clustering
819
+ const { arrangeByDirectory } = require("./auto-arrange");
820
+ const autoPositions = arrangeByDirectory(layerFiles, {
821
+ cardWidth: defaultCardWidth,
822
+ cardHeight: defaultCardHeight,
823
+ fileGap: gap,
824
+ dirGap: 80,
825
+ originX: isAdditionalRepo ? getNextRepoOffset() : 50,
826
+ originY: 50,
827
+ });
828
+
829
+ // Determine initial viewport rect for virtualization
830
+ const MARGIN = 800; // px beyond viewport to pre-create
831
+ const state = ctx.snap().context;
832
+ const vpEl = ctx.canvasViewport;
833
+ const vpW = vpEl?.clientWidth || window.innerWidth;
834
+ const vpH = vpEl?.clientHeight || window.innerHeight;
835
+ const zoom = state.zoom || 1;
836
+ const offsetX = state.offsetX || 0;
837
+ const offsetY = state.offsetY || 0;
838
+ const worldLeft = (-offsetX - MARGIN) / zoom;
839
+ const worldTop = (-offsetY - MARGIN) / zoom;
840
+ const worldRight = (vpW - offsetX + MARGIN) / zoom;
841
+ const worldBottom = (vpH - offsetY + MARGIN) / zoom;
842
+
843
+ let createdCount = 0;
844
+ let deferredCount = 0;
845
+
846
+ // Cache XState state once outside the loop — avoids N snapshots for N files
847
+ const cachedCardSizes = ctx.snap().context.cardSizes || {};
848
+
849
+ layerFiles.forEach((f, index) => {
850
+ const isChanged = ctx.changedFilePaths.has(f.path);
851
+ const posKey = `allfiles:${f.path}`;
852
+ let x: number, y: number;
853
+
854
+ if (ctx.positions.has(posKey)) {
855
+ const pos = ctx.positions.get(posKey);
856
+ x = pos.x;
857
+ y = pos.y;
858
+ } else if (autoPositions.has(f.path)) {
859
+ const pos = autoPositions.get(f.path);
860
+ x = pos.x;
861
+ y = pos.y;
862
+ } else {
863
+ const col = index % cols;
864
+ const row = Math.floor(index / cols);
865
+ x = 50 + col * cellW;
866
+ y = 50 + row * cellH;
867
+ }
868
+
869
+ // Get saved size (from cached snapshot — no per-file ctx.snap() call)
870
+ let size = cachedCardSizes[f.path];
871
+ if (!size && ctx.positions.has(posKey)) {
872
+ const pos = ctx.positions.get(posKey);
873
+ if (pos.width) size = { width: pos.width, height: pos.height };
874
+ }
875
+
876
+ // Merge diff data into the file for highlighting
877
+ let fileWithDiff = { ...f };
878
+ if (activeLayer && activeLayer.files[fileWithDiff.path]) {
879
+ fileWithDiff.layerSections =
880
+ activeLayer.files[fileWithDiff.path].sections;
881
+ }
882
+
883
+ if (isChanged && changedFileDataMap.has(fileWithDiff.path)) {
884
+ const diffData = changedFileDataMap.get(fileWithDiff.path);
885
+
886
+ // Use full content from diff data if available (has the latest version)
887
+ if (diffData.content) {
888
+ fileWithDiff.content = diffData.content;
889
+ fileWithDiff.lines = diffData.content.split("\n").length;
890
+ }
891
+ fileWithDiff.status = diffData.status;
892
+ fileWithDiff.hunks = diffData.hunks;
893
+
894
+ // Compute added/deleted line info from hunks
895
+ if (diffData.hunks?.length > 0) {
896
+ const addedLines = new Set<number>();
897
+ // Map: newLineNumber → array of deleted line texts to show before that line
898
+ const deletedBeforeLine = new Map<number, string[]>();
899
+ for (const hunk of diffData.hunks) {
900
+ let newLine = hunk.newStart;
901
+ let pendingDeleted: string[] = [];
902
+ for (const l of hunk.lines) {
903
+ if (l.type === "add") {
904
+ addedLines.add(newLine);
905
+ // Attach any pending deleted lines before this added line
906
+ if (pendingDeleted.length > 0) {
907
+ const existing = deletedBeforeLine.get(newLine) || [];
908
+ deletedBeforeLine.set(
909
+ newLine,
910
+ existing.concat(pendingDeleted),
911
+ );
912
+ pendingDeleted = [];
913
+ }
914
+ newLine++;
915
+ } else if (l.type === "del") {
916
+ pendingDeleted.push(l.content);
917
+ } else {
918
+ // Context line flush pending deleted before this
919
+ if (pendingDeleted.length > 0) {
920
+ const existing = deletedBeforeLine.get(newLine) || [];
921
+ deletedBeforeLine.set(
922
+ newLine,
923
+ existing.concat(pendingDeleted),
924
+ );
925
+ pendingDeleted = [];
926
+ }
927
+ newLine++;
928
+ }
929
+ }
930
+ // Flush remaining deleted lines after the hunk
931
+ if (pendingDeleted.length > 0) {
932
+ const existing = deletedBeforeLine.get(newLine) || [];
933
+ deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
934
+ }
935
+ }
936
+ fileWithDiff.addedLines = addedLines;
937
+ fileWithDiff.deletedBeforeLine = deletedBeforeLine;
938
+ }
939
+ }
940
+
941
+ // All files use uniform default size unless user has a custom saved size
942
+ if (!size) {
943
+ size = { width: defaultCardWidth, height: defaultCardHeight };
944
+ }
945
+
946
+ // ── Virtualization: check if card is near the viewport ──
947
+ const cardW = size?.width || defaultCardWidth;
948
+ const cardH = size?.height || defaultCardHeight;
949
+ const inViewport =
950
+ x + cardW > worldLeft &&
951
+ x < worldRight &&
952
+ y + cardH > worldTop &&
953
+ y < worldBottom;
954
+
955
+ if (inViewport) {
956
+ // Create DOM immediately
957
+ const card = createAllFileCard(ctx, fileWithDiff, x, y, size);
958
+ if (isChanged) {
959
+ card.classList.add("file-card--changed");
960
+ card.dataset.changed = "true";
961
+ }
962
+ ctx.canvas.appendChild(card);
963
+ ctx.fileCards.set(f.path, card);
964
+
965
+ // Restore scroll position
966
+ const scrollKey = `scroll:${f.path}`;
967
+ if (ctx.positions.has(scrollKey)) {
968
+ const savedScroll = ctx.positions.get(scrollKey);
969
+ requestAnimationFrame(() => {
970
+ const body = card.querySelector(".file-card-body");
971
+ if (body && savedScroll.x) body.scrollTop = savedScroll.x;
972
+ });
973
+ }
974
+ createdCount++;
975
+ } else {
976
+ // Defer: store data for lazy creation when it enters viewport
977
+ ctx.deferredCards.set(f.path, {
978
+ file: fileWithDiff,
979
+ x,
980
+ y,
981
+ size,
982
+ isChanged,
983
+ });
984
+ deferredCount++;
985
+ }
986
+ });
987
+
988
+ console.log(
989
+ `[render] Created ${createdCount} cards, deferred ${deferredCount} (total: ${count})`,
990
+ );
991
+
992
+ renderConnections(ctx);
993
+ buildConnectionMarkers(ctx);
994
+ renderDirectoryLabels(ctx);
995
+ forceMinimapRebuild(ctx);
996
+ // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
997
+ requestAnimationFrame(() => performViewportCulling(ctx));
998
+ });
999
+ }
1000
+
1001
+ // ─── Directory labels on canvas ──────────────────────────
1002
+ // Groups visible file cards by parent directory and renders
1003
+ // a world-space label above each directory cluster.
1004
+ function renderDirectoryLabels(ctx: CanvasContext) {
1005
+ // Remove existing labels
1006
+ ctx.canvas?.querySelectorAll(".dir-label").forEach((el) => el.remove());
1007
+
1008
+ // Group cards by parent directory
1009
+ const groups = new Map<
1010
+ string,
1011
+ { minX: number; minY: number; maxX: number; count: number }
1012
+ >();
1013
+
1014
+ const processCard = (path: string, x: number, y: number, w: number) => {
1015
+ const dir = path.includes("/")
1016
+ ? path.substring(0, path.lastIndexOf("/"))
1017
+ : ".";
1018
+ const g = groups.get(dir);
1019
+ if (g) {
1020
+ g.minX = Math.min(g.minX, x);
1021
+ g.minY = Math.min(g.minY, y);
1022
+ g.maxX = Math.max(g.maxX, x + w);
1023
+ g.count++;
1024
+ } else {
1025
+ groups.set(dir, { minX: x, minY: y, maxX: x + w, count: 1 });
1026
+ }
1027
+ };
1028
+
1029
+ // Created cards (in DOM)
1030
+ ctx.fileCards.forEach((card, path) => {
1031
+ const x = parseFloat(card.style.left) || 0;
1032
+ const y = parseFloat(card.style.top) || 0;
1033
+ const w = card.offsetWidth || 580;
1034
+ processCard(path, x, y, w);
1035
+ });
1036
+
1037
+ // Deferred cards (not yet in DOM)
1038
+ ctx.deferredCards.forEach((info, path) => {
1039
+ const w = info.size?.width || 580;
1040
+ processCard(path, info.x, info.y, w);
1041
+ });
1042
+
1043
+ // Only show labels if we have multiple directories
1044
+ if (groups.size <= 1) return;
1045
+
1046
+ const frag = document.createDocumentFragment();
1047
+ for (const [dir, g] of groups) {
1048
+ const label = document.createElement("div");
1049
+ label.className = "dir-label";
1050
+ label.dataset.dir = dir;
1051
+ const centerX = (g.minX + g.maxX) / 2;
1052
+ label.style.left = `${centerX}px`;
1053
+ label.style.top = `${g.minY - 36}px`;
1054
+ label.style.transform = "translateX(-50%)";
1055
+ label.innerHTML = `<span class="dir-label-icon">📁</span> ${dir}<span class="dir-label-count">${g.count}</span>`;
1056
+
1057
+ // Click to collapse directory into a group card
1058
+ label.addEventListener("click", (e) => {
1059
+ e.stopPropagation();
1060
+ import("./card-groups").then(({ toggleDirectoryCollapse }) => {
1061
+ toggleDirectoryCollapse(ctx, dir);
1062
+ });
1063
+ });
1064
+
1065
+ frag.appendChild(label);
1066
+ }
1067
+ ctx.canvas?.appendChild(frag);
1068
+ }
1069
+
1070
+ // ─── Highlight changed files without re-rendering ────────
1071
+ export function highlightChangedFiles(ctx: CanvasContext) {
1072
+ measure("allfiles:highlight", () => {
1073
+ const hasChanges = ctx.changedFilePaths.size > 0;
1074
+ ctx.fileCards.forEach((card, path) => {
1075
+ const isChanged = hasChanges && ctx.changedFilePaths.has(path);
1076
+ card.classList.toggle("file-card--changed", isChanged);
1077
+ card.classList.toggle("file-card--unchanged", hasChanges && !isChanged);
1078
+ card.dataset.changed = isChanged ? "true" : "";
1079
+ });
1080
+
1081
+ // Rebuild minimap to reflect new highlighting
1082
+ forceMinimapRebuild(ctx);
1083
+ });
1084
+ }
1085
+
1086
+ // ─── Switch view mode ────────────────────────────────────
1087
+ export function switchView(ctx: CanvasContext, mode: string) {
1088
+ if (mode === "allfiles") {
1089
+ ctx.actor.send({ type: "SWITCH_TO_ALLFILES" });
1090
+ ctx.allFilesActive = true;
1091
+ } else {
1092
+ ctx.actor.send({ type: "SWITCH_TO_COMMITS" });
1093
+ ctx.allFilesActive = false;
1094
+ ctx.changedFilePaths.clear();
1095
+ ctx.commitFilesData = null;
1096
+ }
1097
+
1098
+ document
1099
+ .getElementById("modeCommits")
1100
+ ?.classList.toggle("active", mode === "commits");
1101
+ document
1102
+ .getElementById("modeAllFiles")
1103
+ ?.classList.toggle("active", mode === "allfiles");
1104
+
1105
+ if (mode === "allfiles") {
1106
+ const state = ctx.snap().context;
1107
+ const commitInfo = document.getElementById("currentCommitInfo");
1108
+
1109
+ if (state.currentCommitHash) {
1110
+ const commit = state.commits.find(
1111
+ (c) => c.hash === state.currentCommitHash,
1112
+ );
1113
+ if (commitInfo) {
1114
+ updateCommitInfo(state.currentCommitHash, commit?.message || "", true);
1115
+ }
1116
+ } else {
1117
+ if (commitInfo) {
1118
+ updateCommitInfo(undefined, undefined, true);
1119
+ }
1120
+ }
1121
+
1122
+ if (state.repoPath) {
1123
+ // If we have a selected commit, fetch its changed files first
1124
+ // so we can properly highlight/render them as diff cards
1125
+ const doRender = async () => {
1126
+ // Fetch commit files if we have a commit but don't have diff data yet
1127
+ if (
1128
+ state.currentCommitHash &&
1129
+ (!ctx.commitFilesData || ctx.commitFilesData.length === 0)
1130
+ ) {
1131
+ try {
1132
+ const response = await fetch("/api/repo/files", {
1133
+ method: "POST",
1134
+ headers: { "Content-Type": "application/json" },
1135
+ body: JSON.stringify({
1136
+ path: state.repoPath,
1137
+ commit: state.currentCommitHash,
1138
+ }),
1139
+ });
1140
+ if (response.ok) {
1141
+ const data = await response.json();
1142
+ ctx.commitFilesData = data.files;
1143
+ ctx.changedFilePaths = new Set(data.files.map((f) => f.path));
1144
+ ctx.actor.send({
1145
+ type: "COMMIT_FILES_LOADED",
1146
+ files: data.files,
1147
+ });
1148
+ }
1149
+ } catch (err) {
1150
+ // Continue without diff data
1151
+ }
1152
+ } else if (state.commitFiles.length > 0) {
1153
+ ctx.commitFilesData = state.commitFiles;
1154
+ ctx.changedFilePaths = new Set(state.commitFiles.map((f) => f.path));
1155
+ }
1156
+
1157
+ // Now load and render all files
1158
+ if (ctx.allFilesData && ctx.allFilesData.length > 0) {
1159
+ renderAllFilesOnCanvas(ctx, ctx.allFilesData);
1160
+ const fileCountEl = document.getElementById("fileCount");
1161
+ if (fileCountEl) fileCountEl.textContent = ctx.allFilesData.length;
1162
+ } else {
1163
+ await loadAllFiles(ctx);
1164
+ }
1165
+ };
1166
+ doRender();
1167
+ }
1168
+ } else {
1169
+ const state = ctx.snap().context;
1170
+
1171
+ // Always re-render the commit timeline sidebar
1172
+ renderCommitTimeline(ctx);
1173
+
1174
+ if (state.currentCommitHash) {
1175
+ const commit = state.commits.find(
1176
+ (c) => c.hash === state.currentCommitHash,
1177
+ );
1178
+ updateCommitInfo(state.currentCommitHash, commit?.message || "");
1179
+
1180
+ if (state.commitFiles.length > 0) {
1181
+ // We have commit files in state — render them
1182
+ ctx.commitFilesData = state.commitFiles;
1183
+ renderFilesOnCanvas(ctx, state.commitFiles, state.currentCommitHash);
1184
+ populateChangedFilesPanel(ctx, state.commitFiles);
1185
+ const fileCountEl = document.getElementById("fileCount");
1186
+ if (fileCountEl) fileCountEl.textContent = state.commitFiles.length;
1187
+ } else {
1188
+ // Re-fetch commit files since we cleared commitFilesData
1189
+ selectCommit(ctx, state.currentCommitHash);
1190
+ }
1191
+
1192
+ // Re-highlight active commit in sidebar
1193
+ requestAnimationFrame(() => {
1194
+ document.querySelectorAll(".commit-item").forEach((el) => {
1195
+ (el as HTMLElement).classList.toggle(
1196
+ "active",
1197
+ (el as HTMLElement).dataset.hash === state.currentCommitHash,
1198
+ );
1199
+ });
1200
+ });
1201
+ }
1202
+ }
1203
+ }
1204
+
1205
+ // ─── Re-render current view ──────────────────────────────
1206
+ export function rerenderCurrentView(ctx: CanvasContext) {
1207
+ const data = ctx.allFilesData || ctx.snap().context.allFiles;
1208
+ if (data && data.length > 0) {
1209
+ renderAllFilesOnCanvas(ctx, data);
1210
+ }
1211
+ }
1212
+
1213
+ // ─── Changed files panel (JSX) ──────────────────────────
1214
+ function ChangedFilesList({
1215
+ fileStats,
1216
+ totalAdd,
1217
+ totalDel,
1218
+ count,
1219
+ }: {
1220
+ fileStats: any[];
1221
+ totalAdd: number;
1222
+ totalDel: number;
1223
+ count: number;
1224
+ }) {
1225
+ const statusColors = {
1226
+ added: "#22c55e",
1227
+ modified: "#eab308",
1228
+ deleted: "#ef4444",
1229
+ renamed: "#a78bfa",
1230
+ copied: "#60a5fa",
1231
+ };
1232
+ const statusIcons = {
1233
+ added: "+",
1234
+ modified: "~",
1235
+ deleted: "−",
1236
+ renamed: "→",
1237
+ copied: "⊕",
1238
+ };
1239
+
1240
+ return (
1241
+ <div
1242
+ className="changed-files-container-inner"
1243
+ style={{
1244
+ width: "100%",
1245
+ height: "100%",
1246
+ display: "flex",
1247
+ flexDirection: "column",
1248
+ }}
1249
+ >
1250
+ <div className="changed-files-summary">
1251
+ <span className="stat-add">+{totalAdd}</span>
1252
+ <span className="stat-del">−{totalDel}</span>
1253
+ <span className="stat-files">
1254
+ {count} file{count > 1 ? "s" : ""}
1255
+ </span>
1256
+ </div>
1257
+ {fileStats.map((f) => {
1258
+ const color = statusColors[f.status] || "#a855f7";
1259
+ const icon = statusIcons[f.status] || "~";
1260
+ const name = f.path.split("/").pop();
1261
+ const dir = f.path.includes("/")
1262
+ ? f.path.substring(0, f.path.lastIndexOf("/"))
1263
+ : "";
1264
+ return (
1265
+ <div
1266
+ key={f.path}
1267
+ className="changed-file-item"
1268
+ title={f.path}
1269
+ onClick={() => {
1270
+ if (!_panelCtx) return;
1271
+ // Animated zoom+pan to the file
1272
+ import("./canvas").then(({ jumpToFile }) => {
1273
+ jumpToFile(_panelCtx!, f.path);
1274
+ });
1275
+ }}
1276
+ >
1277
+ <span className="changed-file-status" style={`color: ${color} `}>
1278
+ {icon}
1279
+ </span>
1280
+ <span className="changed-file-name">{name}</span>
1281
+ {dir ? <span className="changed-file-dir">{dir}</span> : null}
1282
+ <span className="changed-file-stats">
1283
+ {f.additions > 0 ? (
1284
+ <span className="stat-add">+{f.additions}</span>
1285
+ ) : null}
1286
+ {f.deletions > 0 ? (
1287
+ <span className="stat-del">−{f.deletions}</span>
1288
+ ) : null}
1289
+ </span>
1290
+ </div>
1291
+ );
1292
+ })}
1293
+ </div>
1294
+ );
1295
+ }
1296
+
1297
+ export function populateChangedFilesPanel(ctx: CanvasContext, files: any[]) {
1298
+ setPanelCtx(ctx);
1299
+ const panel = document.getElementById("changedFilesPanel");
1300
+ const listEl = document.getElementById("changedFilesList");
1301
+ if (!panel || !listEl) return;
1302
+
1303
+ if (files.length === 0) {
1304
+ panel.style.display = "none";
1305
+ return;
1306
+ }
1307
+
1308
+ // Filter by active layer — only show changed files that are in the layer
1309
+ const activeLayer = getActiveLayer();
1310
+ const filteredFiles = activeLayer
1311
+ ? files.filter((f) => !!activeLayer.files[f.path])
1312
+ : files;
1313
+
1314
+ if (filteredFiles.length === 0) {
1315
+ panel.style.display = "none";
1316
+ return;
1317
+ }
1318
+
1319
+ let totalAdd = 0,
1320
+ totalDel = 0;
1321
+ const fileStats = filteredFiles.map((f) => {
1322
+ let additions = 0,
1323
+ deletions = 0;
1324
+ if (f.hunks) {
1325
+ f.hunks.forEach((h) => {
1326
+ h.lines.forEach((l) => {
1327
+ if (l.type === "add") additions++;
1328
+ else if (l.type === "del") deletions++;
1329
+ });
1330
+ });
1331
+ } else if (f.status === "added" && f.content) {
1332
+ additions = f.content.split("\n").length;
1333
+ } else if (f.status === "deleted" && f.content) {
1334
+ deletions = f.content.split("\n").length;
1335
+ }
1336
+ totalAdd += additions;
1337
+ totalDel += deletions;
1338
+ return { ...f, additions, deletions };
1339
+ });
1340
+
1341
+ render(
1342
+ <ChangedFilesList
1343
+ fileStats={fileStats}
1344
+ totalAdd={totalAdd}
1345
+ totalDel={totalDel}
1346
+ count={filteredFiles.length}
1347
+ />,
1348
+ listEl,
1349
+ );
1350
+
1351
+ if (panel.dataset.manuallyClosed !== "true") {
1352
+ panel.style.display = "flex";
1353
+ }
1354
+ }
1355
+
1356
+ // ─── Virtual Files Integration ───────────────────────────
1357
+ /**
1358
+ * Process large files for virtual card creation
1359
+ * Called after files are loaded to detect compression opportunities
1360
+ */
1361
+ export async function processVirtualFiles(ctx: CanvasContext): Promise<void> {
1362
+ const files = ctx.allFilesData || [];
1363
+ if (files.length === 0) return;
1364
+
1365
+ try {
1366
+ const created = await processVirtualFileSet(ctx, files);
1367
+ (window as any).__virtualStats = {
1368
+ fileCount: files.length,
1369
+ created,
1370
+ virtualCards: document.querySelectorAll('.virtual-card').length,
1371
+ };
1372
+ if (created > 0) {
1373
+ console.log(`[virtual-files] Created ${created} transclusion cards`);
1374
+ }
1375
+ } catch (err) {
1376
+ (window as any).__virtualStats = {
1377
+ fileCount: files.length,
1378
+ created: 0,
1379
+ error: err?.message || String(err),
1380
+ };
1381
+ console.warn(`[virtual-files] Failed to process transclusion cards:`, err);
1382
+ }
1383
+ }