gitmaps 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. package/server.ts +62 -0
@@ -0,0 +1,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 { 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
+ }