gitmaps 1.0.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 (145) hide show
  1. package/README.md +265 -122
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/[owner]/[repo]/page.client.tsx +5 -0
  5. package/app/[slug]/page.client.tsx +5 -0
  6. package/app/analytics.db +0 -0
  7. package/app/api/analytics/route.ts +64 -0
  8. package/app/api/auth/positions/route.ts +95 -33
  9. package/app/api/build-info/route.ts +19 -0
  10. package/app/api/chat/route.ts +13 -2
  11. package/app/api/manifest.json/route.ts +20 -0
  12. package/app/api/og-image/route.ts +14 -0
  13. package/app/api/pwa-icon/route.ts +14 -0
  14. package/app/api/repo/clone-stream/route.ts +20 -12
  15. package/app/api/repo/file-content/route.ts +73 -20
  16. package/app/api/repo/imports/route.ts +21 -3
  17. package/app/api/repo/list/route.ts +30 -0
  18. package/app/api/repo/load/route.test.ts +62 -0
  19. package/app/api/repo/load/route.ts +41 -1
  20. package/app/api/repo/pdf-thumb/route.ts +127 -0
  21. package/app/api/repo/resolve-slug/route.ts +51 -0
  22. package/app/api/repo/tree/route.ts +188 -104
  23. package/app/api/repo/upload/route.ts +6 -9
  24. package/app/api/sw.js/route.ts +70 -0
  25. package/app/api/version/route.ts +26 -0
  26. package/app/galaxy-canvas/page.client.tsx +2 -0
  27. package/app/galaxy-canvas/page.tsx +5 -0
  28. package/app/globals.css +5844 -4694
  29. package/app/icon.png +0 -0
  30. package/app/layout.tsx +1284 -467
  31. package/app/lib/auto-arrange.test.ts +158 -0
  32. package/app/lib/auto-arrange.ts +147 -0
  33. package/app/lib/canvas-export.ts +358 -358
  34. package/app/lib/canvas-text.ts +4 -72
  35. package/app/lib/canvas.ts +625 -564
  36. package/app/lib/card-arrangement.ts +21 -7
  37. package/app/lib/card-context-menu.tsx +2 -2
  38. package/app/lib/card-groups.ts +9 -2
  39. package/app/lib/cards.tsx +1361 -914
  40. package/app/lib/chat.tsx +65 -9
  41. package/app/lib/code-editor.ts +86 -2
  42. package/app/lib/connections.tsx +34 -43
  43. package/app/lib/context.test.ts +32 -0
  44. package/app/lib/context.ts +19 -3
  45. package/app/lib/cursor-sharing.ts +34 -0
  46. package/app/lib/events.tsx +76 -73
  47. package/app/lib/export-canvas.ts +287 -0
  48. package/app/lib/file-card-plugin.ts +148 -134
  49. package/app/lib/file-modal.tsx +49 -0
  50. package/app/lib/file-preview.ts +486 -400
  51. package/app/lib/github-import.test.ts +424 -0
  52. package/app/lib/global-search.ts +48 -27
  53. package/app/lib/initial-route-hydration.test.ts +283 -0
  54. package/app/lib/initial-route-hydration.ts +202 -0
  55. package/app/lib/landing-reset.test.ts +99 -0
  56. package/app/lib/landing-reset.ts +106 -0
  57. package/app/lib/landing-shell.test.ts +75 -0
  58. package/app/lib/large-repo-optimization.ts +37 -0
  59. package/app/lib/layers.tsx +17 -18
  60. package/app/lib/layout-snapshots.ts +320 -0
  61. package/app/lib/loading.test.ts +69 -0
  62. package/app/lib/loading.tsx +160 -45
  63. package/app/lib/mount-cleanup.test.ts +52 -0
  64. package/app/lib/mount-cleanup.ts +34 -0
  65. package/app/lib/mount-init.test.ts +123 -0
  66. package/app/lib/mount-init.ts +107 -0
  67. package/app/lib/mount-lifecycle.test.ts +39 -0
  68. package/app/lib/mount-lifecycle.ts +12 -0
  69. package/app/lib/mount-route-wiring.test.ts +87 -0
  70. package/app/lib/mount-route-wiring.ts +84 -0
  71. package/app/lib/multi-repo.ts +14 -0
  72. package/app/lib/onboarding-tutorial.ts +278 -0
  73. package/app/lib/perf-overlay.ts +78 -0
  74. package/app/lib/positions.ts +191 -122
  75. package/app/lib/recent-commits.test.ts +869 -0
  76. package/app/lib/recent-commits.ts +227 -0
  77. package/app/lib/repo-handoff.test.ts +23 -0
  78. package/app/lib/repo-handoff.ts +16 -0
  79. package/app/lib/repo-progressive.ts +119 -0
  80. package/app/lib/repo-select.test.ts +61 -0
  81. package/app/lib/repo-select.ts +74 -0
  82. package/app/lib/repo.tsx +1383 -977
  83. package/app/lib/role.ts +228 -0
  84. package/app/lib/route-catchall.test.ts +27 -0
  85. package/app/lib/route-repo-entry.test.ts +95 -0
  86. package/app/lib/route-repo-entry.ts +36 -0
  87. package/app/lib/router-contract.test.ts +22 -0
  88. package/app/lib/router-contract.ts +19 -0
  89. package/app/lib/shared-layout.test.ts +86 -0
  90. package/app/lib/shared-layout.ts +82 -0
  91. package/app/lib/shortcuts-panel.ts +2 -0
  92. package/app/lib/status-bar.test.ts +118 -0
  93. package/app/lib/status-bar.ts +365 -128
  94. package/app/lib/sync-controls.test.ts +43 -0
  95. package/app/lib/sync-controls.tsx +303 -0
  96. package/app/lib/test-dom.ts +145 -0
  97. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  98. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  99. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  100. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  101. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  102. package/app/lib/transclusion-smoke.test.ts +163 -0
  103. package/app/lib/tutorial.ts +301 -0
  104. package/app/lib/version.ts +93 -0
  105. package/app/lib/viewport-culling.ts +740 -728
  106. package/app/lib/virtual-files.ts +456 -0
  107. package/app/lib/webgl-text.ts +189 -0
  108. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
  109. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  110. package/app/og-image.png +0 -0
  111. package/app/page.client.tsx +70 -215
  112. package/app/page.tsx +27 -92
  113. package/app/state/machine.js +13 -0
  114. package/banner.png +0 -0
  115. package/package.json +17 -8
  116. package/server.ts +11 -1
  117. package/app/api/connections/route.ts +0 -72
  118. package/app/api/positions/route.ts +0 -80
  119. package/app/api/repo/browse/route.ts +0 -55
  120. package/app/lib/pr-review.ts +0 -374
  121. package/packages/galaxydraw/README.md +0 -296
  122. package/packages/galaxydraw/banner.png +0 -0
  123. package/packages/galaxydraw/demo/build-static.ts +0 -100
  124. package/packages/galaxydraw/demo/client.ts +0 -154
  125. package/packages/galaxydraw/demo/dist/client.js +0 -8
  126. package/packages/galaxydraw/demo/index.html +0 -256
  127. package/packages/galaxydraw/demo/server.ts +0 -96
  128. package/packages/galaxydraw/dist/index.js +0 -984
  129. package/packages/galaxydraw/dist/index.js.map +0 -16
  130. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  131. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  132. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  133. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  134. package/packages/galaxydraw/package.json +0 -49
  135. package/packages/galaxydraw/perf.test.ts +0 -284
  136. package/packages/galaxydraw/src/core/cards.ts +0 -435
  137. package/packages/galaxydraw/src/core/engine.ts +0 -339
  138. package/packages/galaxydraw/src/core/events.ts +0 -81
  139. package/packages/galaxydraw/src/core/layout.ts +0 -136
  140. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  141. package/packages/galaxydraw/src/core/state.ts +0 -177
  142. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  143. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  144. package/packages/galaxydraw/src/index.ts +0 -40
  145. package/packages/galaxydraw/tsconfig.json +0 -30
package/app/lib/repo.tsx CHANGED
@@ -1,977 +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
- // Set URL hash to a friendly slug (folder name) instead of full path
63
- const repoSlug = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
64
- history.replaceState(null, '', '#' + encodeURIComponent(repoSlug));
65
- localStorage.setItem('gitcanvas:lastRepo', repoPath);
66
- // Also store slug→path mapping for URL-based loading
67
- localStorage.setItem(`gitcanvas:slug:${repoSlug}`, repoPath);
68
- updateStatusBarRepo(repoPath);
69
- // Save to recent repos list
70
- const recentKey = 'gitcanvas:recentRepos';
71
- const recent: string[] = JSON.parse(localStorage.getItem(recentKey) || '[]');
72
- const filtered = recent.filter(r => r !== repoPath);
73
- filtered.unshift(repoPath);
74
- localStorage.setItem(recentKey, JSON.stringify(filtered.slice(0, 10)));
75
- // Update dropdown if it exists
76
- const sel = document.getElementById('repoSelect') as HTMLSelectElement;
77
- if (sel) sel.value = repoPath;
78
-
79
- updateLoadingProgress(ctx, `Found ${data.commits.length} commits, rendering timeline...`);
80
- renderCommitTimeline(ctx);
81
-
82
- // Reload positions for the new repo BEFORE rendering files
83
- // so cards get placed at their correct saved locations
84
- ctx.snap().context.repoPath = repoPath;
85
- await loadSavedPositions(ctx);
86
-
87
- const viewState = ctx.snap().value?.view;
88
- // Always load all files first
89
- updateLoadingProgress(ctx, 'Loading all files...');
90
- await loadAllFiles(ctx);
91
-
92
- // Then select the first commit to get diff data
93
- if (data.commits.length > 0) {
94
- updateLoadingProgress(ctx, 'Loading commit diff...');
95
- await selectCommit(ctx, data.commits[0].hash);
96
- }
97
-
98
- hideLoadingProgress(ctx);
99
- _loadingRepo = null; // Allow future reloads
100
-
101
- // Re-render timeline after all async work — the initial renderCommitTimeline
102
- // at line 76 can get clobbered if DOM re-renders during loadAllFiles/selectCommit
103
- renderCommitTimeline(ctx);
104
-
105
- showToast(`Loaded ${data.commits.length} commits`, 'success');
106
-
107
- // Register in multi-repo workspace
108
- registerRepo(ctx, repoPath, data.commits, ctx.allFilesData || []);
109
- renderRepoTabs(ctx);
110
-
111
- // Trigger onboarding for first-time users
112
- if (!localStorage.getItem('gitcanvas:onboarded')) {
113
- import('./onboarding').then(m => m.startOnboarding(ctx));
114
- }
115
- } catch (err) {
116
- hideLoadingProgress(ctx);
117
- _loadingRepo = null; // Allow retry
118
- ctx.actor.send({ type: 'REPO_ERROR', error: err.message });
119
- measure('repo:loadError', () => err);
120
- showToast(`Failed: ${err.message} `, 'error');
121
- }
122
- });
123
- }
124
-
125
- // ─── Load all files (working tree) ───────────────────────
126
- export async function loadAllFiles(ctx: CanvasContext) {
127
- const state = ctx.snap().context;
128
- if (!state.repoPath) return;
129
-
130
- return measure('allfiles:load', async () => {
131
- try {
132
- const response = await fetch('/api/repo/tree', {
133
- method: 'POST',
134
- headers: { 'Content-Type': 'application/json' },
135
- body: JSON.stringify({ path: state.repoPath })
136
- });
137
-
138
- if (!response.ok) throw new Error(await response.text());
139
-
140
- const data = await response.json();
141
- ctx.actor.send({ type: 'ALL_FILES_LOADED', files: data.files });
142
- ctx.allFilesData = data.files;
143
- renderAllFilesOnCanvas(ctx, data.files);
144
- const fileCountEl = document.getElementById('fileCount');
145
- if (fileCountEl) fileCountEl.textContent = data.total;
146
- } catch (err) {
147
- measure('allfiles:loadError', () => err);
148
- showToast(`Failed to load files: ${err.message} `, 'error');
149
- }
150
- });
151
- }
152
-
153
- // ─── JSX Components for commit sidebar ──────────────────
154
- function CommitItem({ commit, lane, color, onClick }: { commit: any; lane: number; color: string; onClick: () => void }) {
155
- // Derive handle from email (part before @) — more useful than git config name
156
- const handle = commit.email
157
- ? commit.email.split('@')[0]
158
- : commit.author;
159
-
160
- // Calculate indentation based on visual lanes
161
- const paddingLeft = 16 + lane * 14;
162
-
163
- return (
164
- <div
165
- className="commit-item"
166
- data-hash={commit.hash}
167
- data-lane={lane}
168
- style={`padding-left: ${paddingLeft}px; --timeline-color: ${color};`}
169
- onClick={onClick}
170
- >
171
- <div className="commit-hash">{commit.hash.substring(0, 7)}</div>
172
- <div className="commit-message">
173
- {commit.refs && commit.refs.length > 0 && (
174
- <span className="commit-refs">
175
- {commit.refs.map(r => <span className="commit-ref-badge">{r}</span>)}
176
- </span>
177
- )}
178
- {commit.message}
179
- </div>
180
- <div className="commit-meta">
181
- <span className="commit-author">👤 {handle}</span>
182
- <span>{formatDate(commit.date)}</span>
183
- </div>
184
- </div>
185
- );
186
- }
187
-
188
- function CommitInfo({ hash, message, allFiles, changedCount }: {
189
- hash?: string; message?: string; allFiles?: boolean; changedCount?: number;
190
- }) {
191
- return (
192
- <>
193
- {allFiles && <span style="color: var(--accent-tertiary)">All Files</span>}
194
- {hash ? (
195
- <span className="commit-hash">{hash.substring(0, 7)}</span>
196
- ) : null}
197
- {message ? (
198
- <span style="color: var(--text-secondary)">{message}</span>
199
- ) : null}
200
- {!hash && allFiles ? (
201
- <span style="color: var(--text-muted)">Working tree</span>
202
- ) : null}
203
- {changedCount !== undefined ? (
204
- <span style="color: var(--text-muted); font-size: 0.7rem">• {changedCount} changed</span>
205
- ) : null}
206
- </>
207
- );
208
- }
209
-
210
- function updateCommitInfo(hash?: string, message?: string, allFiles?: boolean, changedCount?: number) {
211
- const el = document.getElementById('currentCommitInfo');
212
- if (el) render(<CommitInfo hash={hash} message={message} allFiles={allFiles} changedCount={changedCount} />, el);
213
- }
214
-
215
- // ─── Commit timeline render ──────────────────────────────
216
- export function renderCommitTimeline(ctx: CanvasContext) {
217
- measure('timeline:render', () => {
218
- const container = document.getElementById('timelineContainer');
219
- const countBadge = document.getElementById('commitCount');
220
- const state = ctx.snap().context;
221
- const commitsList = state.commits;
222
-
223
- if (countBadge) countBadge.textContent = commitsList.length;
224
-
225
- if (!container) return;
226
-
227
- if (commitsList.length === 0) {
228
- render(
229
- <div className="empty-state">
230
- <span style="opacity:0.4;font-size:32px">🕐</span>
231
- <p>No commits found</p>
232
- </div>,
233
- container
234
- );
235
- return;
236
- }
237
-
238
- // Branch graph calculation
239
- const lanes: (string | null)[] = [];
240
- const nodes: any[] = [];
241
- const colors = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'];
242
-
243
- commitsList.forEach((commit, i) => {
244
- // Find the lane reserved for this commit by a previous parent assignment
245
- let laneIndex = lanes.indexOf(commit.hash);
246
- if (laneIndex < 0) {
247
- // No lane reserved — find first empty slot
248
- laneIndex = lanes.findIndex(h => !h);
249
- if (laneIndex < 0) laneIndex = lanes.length;
250
- }
251
- // Clear the reservation (we're processing this commit now)
252
- lanes[laneIndex] = null;
253
- nodes.push({ hash: commit.hash, lane: laneIndex, index: i });
254
-
255
- if (commit.parents && commit.parents.length > 0) {
256
- commit.parents.forEach((pHash, pIndex) => {
257
- const pLaneIndex = lanes.indexOf(pHash);
258
- if (pIndex === 0) {
259
- // First parent: continue in the same lane
260
- if (pLaneIndex < 0) {
261
- lanes[laneIndex] = pHash;
262
- }
263
- // If parent already has a lane (from another child),
264
- // just leave laneIndex free — the edge drawing handles the visual connection
265
- } else {
266
- // Additional parents (merge): assign to a different lane
267
- if (pLaneIndex < 0) {
268
- let empty = lanes.findIndex(h => !h);
269
- if (empty < 0) empty = lanes.length;
270
- lanes[empty] = pHash;
271
- }
272
- }
273
- });
274
- }
275
- });
276
-
277
- render(
278
- <div style="position:relative;">
279
- <svg id="timelineGraph" style="position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:0;"></svg>
280
- <div id="timelineItems">
281
- {commitsList.map((commit, i) => (
282
- <CommitItem
283
- key={commit.hash}
284
- commit={commit}
285
- lane={nodes[i].lane}
286
- color={colors[nodes[i].lane % colors.length]}
287
- onClick={() => selectCommit(ctx, commit.hash)}
288
- />
289
- ))}
290
- </div>
291
- </div>,
292
- container
293
- );
294
-
295
- requestAnimationFrame(() => {
296
- const graph = document.getElementById('timelineGraph');
297
- if (!graph) return;
298
- const items = document.querySelectorAll('.commit-item');
299
- const coords = new Map<string, { x: number, y: number, color: string }>();
300
-
301
- let maxLane = 0;
302
- items.forEach((item: HTMLElement) => {
303
- const hash = item.dataset.hash;
304
- const lane = parseInt(item.dataset.lane || '0');
305
- if (lane > maxLane) maxLane = lane;
306
- // Center of the lane dot, shifted to accommodate the graph drawing
307
- const x = 16 + lane * 14;
308
- // offsetTop is relative to the relative parent div we just wrapped it in
309
- const y = item.offsetTop + item.offsetHeight / 2;
310
- coords.set(hash, { x, y, color: colors[lane % colors.length] });
311
- });
312
-
313
- let svgContent = '';
314
-
315
- // Draw edges
316
- commitsList.forEach(commit => {
317
- const start = coords.get(commit.hash);
318
- if (!start) return;
319
-
320
- (commit.parents || []).forEach((pHash, pIdx) => {
321
- const end = coords.get(pHash);
322
- if (!end) return;
323
-
324
- const isMerge = pIdx > 0;
325
- const pathColor = isMerge ? end.color : start.color;
326
-
327
- if (start.x === end.x) {
328
- svgContent += `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${pathColor}" stroke-opacity="0.6" stroke-width="2" />`;
329
- } else {
330
- const midY = start.y + (end.y - start.y) / 2;
331
- 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" />`;
332
- }
333
- });
334
- });
335
-
336
- // Draw nodes
337
- commitsList.forEach(commit => {
338
- const p = coords.get(commit.hash);
339
- if (!p) return;
340
- let dot = `<circle cx="${p.x}" cy="${p.y}" r="4.5" fill="${p.color}" stroke="var(--bg-secondary)" stroke-width="2" />`;
341
- if (commit.refs && commit.refs.length > 0) {
342
- dot += `<circle cx="${p.x}" cy="${p.y}" r="7" fill="none" stroke="${p.color}" stroke-opacity="0.8" stroke-width="1.5" />`;
343
- }
344
- svgContent += dot;
345
- });
346
-
347
- graph.innerHTML = svgContent;
348
- });
349
- });
350
- }
351
-
352
- // ─── Select commit ───────────────────────────────────────
353
- export async function selectCommit(ctx: CanvasContext, hash: string) {
354
- return measure('commit:select', async () => {
355
- ctx.actor.send({ type: 'SELECT_COMMIT', hash });
356
-
357
- document.querySelectorAll('.commit-item').forEach(el => {
358
- el.classList.toggle('active', el.dataset.hash === hash);
359
- });
360
-
361
- const state = ctx.snap().context;
362
- const commit = state.commits.find(c => c.hash === hash);
363
-
364
- // Show non-blocking inline progress bar (not overlay)
365
- _showCommitProgress(true, `${hash.substring(0, 7)} — ${commit?.message || ''}`);
366
-
367
- try {
368
- const response = await fetch('/api/repo/files', {
369
- method: 'POST',
370
- headers: { 'Content-Type': 'application/json' },
371
- body: JSON.stringify({ path: state.repoPath, commit: hash })
372
- });
373
-
374
- if (!response.ok) throw new Error(await response.text());
375
-
376
- const data = await response.json();
377
- ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
378
- ctx.commitFilesData = data.files;
379
-
380
- // Always re-render all files with highlighted changes
381
- ctx.changedFilePaths = new Set(data.files.map(f => f.path));
382
- if (ctx.allFilesData && ctx.allFilesData.length > 0) {
383
- renderAllFilesOnCanvas(ctx, ctx.allFilesData);
384
- }
385
-
386
- updateCommitInfo(hash, commit?.message || '', true, data.files.length);
387
-
388
- const fileCountEl = document.getElementById('fileCount');
389
- if (fileCountEl) fileCountEl.textContent = ctx.fileCards.size;
390
- _showCommitProgress(false);
391
- updateStatusBarCommit(hash);
392
- updateStatusBarFiles(ctx.fileCards.size);
393
-
394
- // Populate changed files panel with diff stats
395
- populateChangedFilesPanel(data.files);
396
- } catch (err) {
397
- _showCommitProgress(false);
398
- measure('commit:selectError', () => err);
399
- showToast(`Failed: ${err.message} `, 'error');
400
- }
401
- });
402
- }
403
-
404
- // ─── Inline commit progress bar (non-blocking) ──────────
405
- function _showCommitProgress(show: boolean, text?: string) {
406
- let bar = document.getElementById('commitProgressBar');
407
- if (show) {
408
- if (!bar) {
409
- bar = document.createElement('div');
410
- bar.id = 'commitProgressBar';
411
- bar.className = 'commit-progress-bar';
412
- const canvasArea = document.querySelector('.canvas-area');
413
- if (canvasArea) {
414
- canvasArea.insertBefore(bar, canvasArea.querySelector('.canvas-viewport'));
415
- } else {
416
- document.body.appendChild(bar);
417
- }
418
- }
419
- bar.innerHTML = `<div class="commit-progress-track"><div class="commit-progress-fill"></div></div>${text ? `<span class="commit-progress-text">${text}</span>` : ''}`;
420
- bar.style.display = 'flex';
421
- } else if (bar) {
422
- bar.style.display = 'none';
423
- }
424
- }
425
-
426
- // ─── Render files on canvas (commits mode) ───────────────
427
- export function renderFilesOnCanvas(ctx: CanvasContext, files: any[], commitHash: string) {
428
- measure('canvas:renderFiles', () => {
429
- clearCanvas(ctx);
430
-
431
- const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
432
- let layerFiles = visibleFiles;
433
- const activeLayer = getActiveLayer();
434
- if (activeLayer) {
435
- layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
436
- }
437
-
438
- const cols = Math.min(layerFiles.length, getAutoColumnCount(ctx));
439
- const cardWidth = 580;
440
- const cardHeight = 700;
441
- const gap = 40;
442
-
443
- layerFiles.forEach((f, index) => {
444
- const posKey = getPositionKey(f.path, commitHash);
445
- let x: number, y: number;
446
-
447
- if (ctx.positions.has(posKey)) {
448
- const pos = ctx.positions.get(posKey);
449
- x = pos.x; y = pos.y;
450
- } else {
451
- const col = index % cols;
452
- const row = Math.floor(index / cols);
453
- x = 50 + col * (cardWidth + gap);
454
- y = 50 + row * (cardHeight + gap);
455
- }
456
-
457
- const file = { ...f };
458
- if (activeLayer && activeLayer.files[file.path]) {
459
- file.layerSections = activeLayer.files[file.path].sections;
460
- }
461
-
462
- const card = createFileCard(ctx, file, x, y, commitHash);
463
- ctx.canvas.appendChild(card);
464
- ctx.fileCards.set(file.path, card);
465
- });
466
- renderConnections(ctx);
467
- buildConnectionMarkers(ctx);
468
- forceMinimapRebuild(ctx);
469
- // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
470
- requestAnimationFrame(() => performViewportCulling(ctx));
471
- });
472
- }
473
-
474
- // ─── Render all files on canvas (working tree) ──────────
475
- // Virtualized: only creates DOM for cards in/near the viewport.
476
- // Remaining cards are deferred and materialized on-demand by viewport culling.
477
- export function renderAllFilesOnCanvas(ctx: CanvasContext, files: any[]) {
478
- measure('canvas:renderAllFiles', () => {
479
- // In multi-repo mode, don't clear canvas if adding a second repo
480
- const isAdditionalRepo = isMultiRepoLoad();
481
- if (!isAdditionalRepo) {
482
- clearCanvas(ctx);
483
- ctx.deferredCards.clear();
484
- }
485
-
486
- // ── Phase 4c: Try CardManager path first ──
487
- const handled = renderAllFilesViaCardManager(ctx, files);
488
- if (handled) {
489
- renderConnections(ctx);
490
- buildConnectionMarkers(ctx);
491
- forceMinimapRebuild(ctx);
492
- // Materialize any deferred cards visible in initial viewport
493
- requestAnimationFrame(() => {
494
- materializeViewport(ctx);
495
- performViewportCulling(ctx);
496
- });
497
- return;
498
- }
499
-
500
- // ── Legacy fallback (CardManager not initialized) ──
501
- const visibleFiles = files.filter(f => !ctx.hiddenFiles.has(f.path));
502
- updateHiddenUI(ctx);
503
-
504
- // Build a map of changed file data (commit diff info)
505
- const changedFileDataMap = new Map<string, any>();
506
- if (ctx.commitFilesData) {
507
- ctx.commitFilesData.forEach(f => changedFileDataMap.set(f.path, f));
508
- }
509
-
510
- let layerFiles = visibleFiles;
511
- const activeLayer = getActiveLayer();
512
- if (activeLayer) {
513
- layerFiles = visibleFiles.filter(f => !!activeLayer.files[f.path]);
514
- } else {
515
- // Default layer: exclude files that have been moved to other layers
516
- const { isFileMovedFromDefault } = require('./layers');
517
- layerFiles = visibleFiles.filter(f => !isFileMovedFromDefault(f.path));
518
- }
519
- // Sort by directory to group files spatially (makes dir-labels coherent)
520
- layerFiles.sort((a, b) => {
521
- const dirA = a.path.includes('/') ? a.path.substring(0, a.path.lastIndexOf('/')) : '.';
522
- const dirB = b.path.includes('/') ? b.path.substring(0, b.path.lastIndexOf('/')) : '.';
523
- if (dirA !== dirB) return dirA.localeCompare(dirB);
524
- const nameA = a.path.split('/').pop() || a.path;
525
- const nameB = b.path.split('/').pop() || b.path;
526
- return nameA.localeCompare(nameB);
527
- });
528
-
529
- // Square-ish grid: use ceil(sqrt(n)) columns for a dense rectangle
530
- const count = layerFiles.length;
531
- const cols = Math.max(1, Math.ceil(Math.sqrt(count)));
532
- const defaultCardWidth = 580;
533
- const defaultCardHeight = 700;
534
- const gap = 20;
535
- const cellW = defaultCardWidth + gap;
536
- const cellH = defaultCardHeight + gap;
537
-
538
- // Determine initial viewport rect for virtualization
539
- const MARGIN = 800; // px beyond viewport to pre-create
540
- const state = ctx.snap().context;
541
- const vpEl = ctx.canvasViewport;
542
- const vpW = vpEl?.clientWidth || window.innerWidth;
543
- const vpH = vpEl?.clientHeight || window.innerHeight;
544
- const zoom = state.zoom || 1;
545
- const offsetX = state.offsetX || 0;
546
- const offsetY = state.offsetY || 0;
547
- const worldLeft = (-offsetX - MARGIN) / zoom;
548
- const worldTop = (-offsetY - MARGIN) / zoom;
549
- const worldRight = (vpW - offsetX + MARGIN) / zoom;
550
- const worldBottom = (vpH - offsetY + MARGIN) / zoom;
551
-
552
- let createdCount = 0;
553
- let deferredCount = 0;
554
-
555
- // Cache XState state once outside the loop — avoids N snapshots for N files
556
- const cachedCardSizes = ctx.snap().context.cardSizes || {};
557
-
558
- // Multi-repo: offset grid origin to the right of existing repos
559
- const gridOriginX = isAdditionalRepo ? getNextRepoOffset() : 50;
560
- const gridOriginY = 50;
561
-
562
- layerFiles.forEach((f, index) => {
563
- const isChanged = ctx.changedFilePaths.has(f.path);
564
- const posKey = `allfiles:${f.path}`;
565
- let x: number, y: number;
566
-
567
- if (ctx.positions.has(posKey)) {
568
- const pos = ctx.positions.get(posKey);
569
- x = pos.x; y = pos.y;
570
- } else {
571
- const col = index % cols;
572
- const row = Math.floor(index / cols);
573
- x = gridOriginX + col * cellW;
574
- y = gridOriginY + row * cellH;
575
- }
576
-
577
- // Get saved size (from cached snapshot — no per-file ctx.snap() call)
578
- let size = cachedCardSizes[f.path];
579
- if (!size && ctx.positions.has(posKey)) {
580
- const pos = ctx.positions.get(posKey);
581
- if (pos.width) size = { width: pos.width, height: pos.height };
582
- }
583
-
584
- // Merge diff data into the file for highlighting
585
- let fileWithDiff = { ...f };
586
- if (activeLayer && activeLayer.files[fileWithDiff.path]) {
587
- fileWithDiff.layerSections = activeLayer.files[fileWithDiff.path].sections;
588
- }
589
-
590
- if (isChanged && changedFileDataMap.has(fileWithDiff.path)) {
591
- const diffData = changedFileDataMap.get(fileWithDiff.path);
592
-
593
- // Use full content from diff data if available (has the latest version)
594
- if (diffData.content) {
595
- fileWithDiff.content = diffData.content;
596
- fileWithDiff.lines = diffData.content.split('\n').length;
597
- }
598
- fileWithDiff.status = diffData.status;
599
- fileWithDiff.hunks = diffData.hunks;
600
-
601
- // Compute added/deleted line info from hunks
602
- if (diffData.hunks?.length > 0) {
603
- const addedLines = new Set<number>();
604
- // Map: newLineNumber → array of deleted line texts to show before that line
605
- const deletedBeforeLine = new Map<number, string[]>();
606
- for (const hunk of diffData.hunks) {
607
- let newLine = hunk.newStart;
608
- let pendingDeleted: string[] = [];
609
- for (const l of hunk.lines) {
610
- if (l.type === 'add') {
611
- addedLines.add(newLine);
612
- // Attach any pending deleted lines before this added line
613
- if (pendingDeleted.length > 0) {
614
- const existing = deletedBeforeLine.get(newLine) || [];
615
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
616
- pendingDeleted = [];
617
- }
618
- newLine++;
619
- } else if (l.type === 'del') {
620
- pendingDeleted.push(l.content);
621
- } else {
622
- // Context line flush pending deleted before this
623
- if (pendingDeleted.length > 0) {
624
- const existing = deletedBeforeLine.get(newLine) || [];
625
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
626
- pendingDeleted = [];
627
- }
628
- newLine++;
629
- }
630
- }
631
- // Flush remaining deleted lines after the hunk
632
- if (pendingDeleted.length > 0) {
633
- const existing = deletedBeforeLine.get(newLine) || [];
634
- deletedBeforeLine.set(newLine, existing.concat(pendingDeleted));
635
- }
636
- }
637
- fileWithDiff.addedLines = addedLines;
638
- fileWithDiff.deletedBeforeLine = deletedBeforeLine;
639
- }
640
- }
641
-
642
- // All files use uniform default size unless user has a custom saved size
643
- if (!size) {
644
- size = { width: defaultCardWidth, height: defaultCardHeight };
645
- }
646
-
647
- // ── Virtualization: check if card is near the viewport ──
648
- const cardW = size?.width || defaultCardWidth;
649
- const cardH = size?.height || defaultCardHeight;
650
- const inViewport = (
651
- x + cardW > worldLeft &&
652
- x < worldRight &&
653
- y + cardH > worldTop &&
654
- y < worldBottom
655
- );
656
-
657
- if (inViewport) {
658
- // Create DOM immediately
659
- const card = createAllFileCard(ctx, fileWithDiff, x, y, size);
660
- if (isChanged) {
661
- card.classList.add('file-card--changed');
662
- card.dataset.changed = 'true';
663
- }
664
- ctx.canvas.appendChild(card);
665
- ctx.fileCards.set(f.path, card);
666
-
667
- // Restore scroll position
668
- const scrollKey = `scroll:${f.path}`;
669
- if (ctx.positions.has(scrollKey)) {
670
- const savedScroll = ctx.positions.get(scrollKey);
671
- requestAnimationFrame(() => {
672
- const body = card.querySelector('.file-card-body');
673
- if (body && savedScroll.x) body.scrollTop = savedScroll.x;
674
- });
675
- }
676
- createdCount++;
677
- } else {
678
- // Defer: store data for lazy creation when it enters viewport
679
- ctx.deferredCards.set(f.path, { file: fileWithDiff, x, y, size, isChanged });
680
- deferredCount++;
681
- }
682
- });
683
-
684
- console.log(`[render] Created ${createdCount} cards, deferred ${deferredCount} (total: ${count})`);
685
-
686
- renderConnections(ctx);
687
- buildConnectionMarkers(ctx);
688
- renderDirectoryLabels(ctx);
689
- forceMinimapRebuild(ctx);
690
- // Cull off-screen cards after browser layout (needs rAF for valid dimensions)
691
- requestAnimationFrame(() => performViewportCulling(ctx));
692
- });
693
- }
694
-
695
- // ─── Directory labels on canvas ──────────────────────────
696
- // Groups visible file cards by parent directory and renders
697
- // a world-space label above each directory cluster.
698
- function renderDirectoryLabels(ctx: CanvasContext) {
699
- // Remove existing labels
700
- ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
701
-
702
- // Group cards by parent directory
703
- const groups = new Map<string, { minX: number; minY: number; maxX: number; count: number }>();
704
-
705
- const processCard = (path: string, x: number, y: number, w: number) => {
706
- const dir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '.';
707
- const g = groups.get(dir);
708
- if (g) {
709
- g.minX = Math.min(g.minX, x);
710
- g.minY = Math.min(g.minY, y);
711
- g.maxX = Math.max(g.maxX, x + w);
712
- g.count++;
713
- } else {
714
- groups.set(dir, { minX: x, minY: y, maxX: x + w, count: 1 });
715
- }
716
- };
717
-
718
- // Created cards (in DOM)
719
- ctx.fileCards.forEach((card, path) => {
720
- const x = parseFloat(card.style.left) || 0;
721
- const y = parseFloat(card.style.top) || 0;
722
- const w = card.offsetWidth || 580;
723
- processCard(path, x, y, w);
724
- });
725
-
726
- // Deferred cards (not yet in DOM)
727
- ctx.deferredCards.forEach((info, path) => {
728
- const w = info.size?.width || 580;
729
- processCard(path, info.x, info.y, w);
730
- });
731
-
732
- // Only show labels if we have multiple directories
733
- if (groups.size <= 1) return;
734
-
735
- const frag = document.createDocumentFragment();
736
- for (const [dir, g] of groups) {
737
- const label = document.createElement('div');
738
- label.className = 'dir-label';
739
- label.dataset.dir = dir;
740
- const centerX = (g.minX + g.maxX) / 2;
741
- label.style.left = `${centerX}px`;
742
- label.style.top = `${g.minY - 36}px`;
743
- label.style.transform = 'translateX(-50%)';
744
- label.innerHTML = `<span class="dir-label-icon">📁</span> ${dir}<span class="dir-label-count">${g.count}</span>`;
745
-
746
- // Click to collapse directory into a group card
747
- label.addEventListener('click', (e) => {
748
- e.stopPropagation();
749
- import('./card-groups').then(({ toggleDirectoryCollapse }) => {
750
- toggleDirectoryCollapse(ctx, dir);
751
- });
752
- });
753
-
754
- frag.appendChild(label);
755
- }
756
- ctx.canvas?.appendChild(frag);
757
- }
758
-
759
- // ─── Highlight changed files without re-rendering ────────
760
- export function highlightChangedFiles(ctx: CanvasContext) {
761
- measure('allfiles:highlight', () => {
762
- const hasChanges = ctx.changedFilePaths.size > 0;
763
- ctx.fileCards.forEach((card, path) => {
764
- const isChanged = hasChanges && ctx.changedFilePaths.has(path);
765
- card.classList.toggle('file-card--changed', isChanged);
766
- card.classList.toggle('file-card--unchanged', hasChanges && !isChanged);
767
- card.dataset.changed = isChanged ? 'true' : '';
768
- });
769
-
770
- // Rebuild minimap to reflect new highlighting
771
- forceMinimapRebuild(ctx);
772
- });
773
- }
774
-
775
- // ─── Switch view mode ────────────────────────────────────
776
- export function switchView(ctx: CanvasContext, mode: string) {
777
- if (mode === 'allfiles') {
778
- ctx.actor.send({ type: 'SWITCH_TO_ALLFILES' });
779
- ctx.allFilesActive = true;
780
- } else {
781
- ctx.actor.send({ type: 'SWITCH_TO_COMMITS' });
782
- ctx.allFilesActive = false;
783
- ctx.changedFilePaths.clear();
784
- ctx.commitFilesData = null;
785
- }
786
-
787
- document.getElementById('modeCommits')?.classList.toggle('active', mode === 'commits');
788
- document.getElementById('modeAllFiles')?.classList.toggle('active', mode === 'allfiles');
789
-
790
- if (mode === 'allfiles') {
791
- const state = ctx.snap().context;
792
- const commitInfo = document.getElementById('currentCommitInfo');
793
-
794
- if (state.currentCommitHash) {
795
- const commit = state.commits.find(c => c.hash === state.currentCommitHash);
796
- if (commitInfo) {
797
- updateCommitInfo(state.currentCommitHash, commit?.message || '', true);
798
- }
799
- } else {
800
- if (commitInfo) {
801
- updateCommitInfo(undefined, undefined, true);
802
- }
803
- }
804
-
805
- if (state.repoPath) {
806
- // If we have a selected commit, fetch its changed files first
807
- // so we can properly highlight/render them as diff cards
808
- const doRender = async () => {
809
- // Fetch commit files if we have a commit but don't have diff data yet
810
- if (state.currentCommitHash && (!ctx.commitFilesData || ctx.commitFilesData.length === 0)) {
811
- try {
812
- const response = await fetch('/api/repo/files', {
813
- method: 'POST',
814
- headers: { 'Content-Type': 'application/json' },
815
- body: JSON.stringify({ path: state.repoPath, commit: state.currentCommitHash })
816
- });
817
- if (response.ok) {
818
- const data = await response.json();
819
- ctx.commitFilesData = data.files;
820
- ctx.changedFilePaths = new Set(data.files.map(f => f.path));
821
- ctx.actor.send({ type: 'COMMIT_FILES_LOADED', files: data.files });
822
- }
823
- } catch (err) {
824
- // Continue without diff data
825
- }
826
- } else if (state.commitFiles.length > 0) {
827
- ctx.commitFilesData = state.commitFiles;
828
- ctx.changedFilePaths = new Set(state.commitFiles.map(f => f.path));
829
- }
830
-
831
- // Now load and render all files
832
- if (ctx.allFilesData && ctx.allFilesData.length > 0) {
833
- renderAllFilesOnCanvas(ctx, ctx.allFilesData);
834
- const fileCountEl = document.getElementById('fileCount');
835
- if (fileCountEl) fileCountEl.textContent = ctx.allFilesData.length;
836
- } else {
837
- await loadAllFiles(ctx);
838
- }
839
- };
840
- doRender();
841
- }
842
- } else {
843
- const state = ctx.snap().context;
844
-
845
- // Always re-render the commit timeline sidebar
846
- renderCommitTimeline(ctx);
847
-
848
- if (state.currentCommitHash) {
849
- const commit = state.commits.find(c => c.hash === state.currentCommitHash);
850
- updateCommitInfo(state.currentCommitHash, commit?.message || '');
851
-
852
- if (state.commitFiles.length > 0) {
853
- // We have commit files in state — render them
854
- ctx.commitFilesData = state.commitFiles;
855
- renderFilesOnCanvas(ctx, state.commitFiles, state.currentCommitHash);
856
- populateChangedFilesPanel(state.commitFiles);
857
- const fileCountEl = document.getElementById('fileCount');
858
- if (fileCountEl) fileCountEl.textContent = state.commitFiles.length;
859
- } else {
860
- // Re-fetch commit files since we cleared commitFilesData
861
- selectCommit(ctx, state.currentCommitHash);
862
- }
863
-
864
- // Re-highlight active commit in sidebar
865
- requestAnimationFrame(() => {
866
- document.querySelectorAll('.commit-item').forEach(el => {
867
- (el as HTMLElement).classList.toggle('active', (el as HTMLElement).dataset.hash === state.currentCommitHash);
868
- });
869
- });
870
- }
871
- }
872
- }
873
-
874
- // ─── Re-render current view ──────────────────────────────
875
- export function rerenderCurrentView(ctx: CanvasContext) {
876
- const data = ctx.allFilesData || ctx.snap().context.allFiles;
877
- if (data && data.length > 0) {
878
- renderAllFilesOnCanvas(ctx, data);
879
- }
880
- }
881
-
882
- // ─── Changed files panel (JSX) ──────────────────────────
883
- function ChangedFilesList({ fileStats, totalAdd, totalDel, count }: {
884
- fileStats: any[]; totalAdd: number; totalDel: number; count: number;
885
- }) {
886
- const statusColors = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444', renamed: '#a78bfa', copied: '#60a5fa' };
887
- const statusIcons = { added: '+', modified: '~', deleted: '−', renamed: '→', copied: '⊕' };
888
-
889
- return (
890
- <>
891
- <div className="changed-files-summary">
892
- <span className="stat-add">+{totalAdd}</span>
893
- <span className="stat-del">−{totalDel}</span>
894
- <span className="stat-files">{count} file{count > 1 ? 's' : ''}</span>
895
- </div>
896
- {fileStats.map(f => {
897
- const color = statusColors[f.status] || '#a855f7';
898
- const icon = statusIcons[f.status] || '~';
899
- const name = f.path.split('/').pop();
900
- const dir = f.path.includes('/') ? f.path.substring(0, f.path.lastIndexOf('/')) : '';
901
- return (
902
- <div
903
- key={f.path}
904
- className="changed-file-item"
905
- title={f.path}
906
- onClick={() => {
907
- if (!_panelCtx) return;
908
- // Animated zoom+pan to the file
909
- import('./canvas').then(({ jumpToFile }) => {
910
- jumpToFile(_panelCtx!, f.path);
911
- });
912
- }}
913
- >
914
- <span className="changed-file-status" style={`color: ${color} `}>{icon}</span>
915
- <span className="changed-file-name">{name}</span>
916
- {dir ? <span className="changed-file-dir">{dir}</span> : null}
917
- <span className="changed-file-stats">
918
- {f.additions > 0 ? <span className="stat-add">+{f.additions}</span> : null}
919
- {f.deletions > 0 ? <span className="stat-del">−{f.deletions}</span> : null}
920
- </span>
921
- </div>
922
- );
923
- })}
924
- </>
925
- );
926
- }
927
-
928
- export function populateChangedFilesPanel(files: any[]) {
929
- const panel = document.getElementById('changedFilesPanel');
930
- const listEl = document.getElementById('changedFilesList');
931
- if (!panel || !listEl) return;
932
-
933
- if (files.length === 0) {
934
- panel.style.display = 'none';
935
- return;
936
- }
937
-
938
- // Filter by active layer — only show changed files that are in the layer
939
- const activeLayer = getActiveLayer();
940
- const filteredFiles = activeLayer
941
- ? files.filter(f => !!activeLayer.files[f.path])
942
- : files;
943
-
944
- if (filteredFiles.length === 0) {
945
- panel.style.display = 'none';
946
- return;
947
- }
948
-
949
- let totalAdd = 0, totalDel = 0;
950
- const fileStats = filteredFiles.map(f => {
951
- let additions = 0, deletions = 0;
952
- if (f.hunks) {
953
- f.hunks.forEach(h => {
954
- h.lines.forEach(l => {
955
- if (l.type === 'add') additions++;
956
- else if (l.type === 'del') deletions++;
957
- });
958
- });
959
- } else if (f.status === 'added' && f.content) {
960
- additions = f.content.split('\n').length;
961
- } else if (f.status === 'deleted' && f.content) {
962
- deletions = f.content.split('\n').length;
963
- }
964
- totalAdd += additions;
965
- totalDel += deletions;
966
- return { ...f, additions, deletions };
967
- });
968
-
969
- render(
970
- <ChangedFilesList fileStats={fileStats} totalAdd={totalAdd} totalDel={totalDel} count={filteredFiles.length} />,
971
- listEl
972
- );
973
-
974
- if (panel.dataset.manuallyClosed !== 'true') {
975
- panel.style.display = 'flex';
976
- }
977
- }
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
+ }