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,914 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * File cards — creation (diff + all-files), interaction (click/drag/resize),
4
+ * selection, arrangement, and the file modal.
5
+ */
6
+ import { measure } from 'measure-fn';
7
+ import { render } from 'melina/client';
8
+ import type { CanvasContext } from './context';
9
+ import { escapeHtml, getFileIcon, getFileIconClass, showToast } from './utils';
10
+ import { hideSelectedFiles } from './hidden-files';
11
+ import { savePosition, getPositionKey, isPathExpandedInPositions, setPathExpandedInPositions } from './positions';
12
+ import { updateMinimap, updateCanvasTransform, updateZoomUI, jumpToFile } from './canvas';
13
+ import { updateStatusBarSelected } from './status-bar';
14
+ import { renderConnections, scheduleRenderConnections, setupConnectionDrag, hasPendingConnection } from './connections';
15
+ import { highlightSyntax, buildModalDiffHTML } from './syntax';
16
+ import { filterFileContentByLayer, layerState, createLayer, addFileToLayer, removeFileFromLayer, getActiveLayer } from './layers';
17
+ import { openFileChatInModal } from './chat';
18
+ import { buildDiffMarkerStrip as _buildDiffMarkerStrip, setupDeletedLinesOverlay as _setupDeletedLinesOverlay } from './card-diff-markers';
19
+ import { updateHiddenLinesIndicator as _updateHiddenLinesIndicator } from './card-expand';
20
+
21
+ // ─── Constants ──────────────────────────────────────────
22
+ const CORNER_CURSORS = { tl: 'nwse-resize', tr: 'nesw-resize', bl: 'nesw-resize', br: 'nwse-resize' };
23
+
24
+ // Max lines rendered in DOM for collapsed (folded) cards.
25
+ // Files with more lines than this will show a truncated view until expanded with F.
26
+ // This is the #1 performance optimization — a 10K-line file produces 10K <span> elements
27
+ // which all participate in layout during pan/zoom, crushing frame rate.
28
+ const VISIBLE_LINE_LIMIT = 120;
29
+
30
+ const cardFileData = new WeakMap<HTMLElement, any>();
31
+
32
+ // ─── Accessor for cardFileData (used by card-expand.ts via lazy require) ──
33
+ export function _getCardFileData(card: HTMLElement) { return cardFileData.get(card); }
34
+
35
+ // ─── Expanded state persistence ─────────────────────────
36
+ // NOTE: Expanded state is now stored in the positions system (positions.ts)
37
+ // so it automatically syncs to the server for logged-in users.
38
+ // The old localStorage-only functions below are kept as thin wrappers
39
+ // for backward compatibility but should not be used directly.
40
+ // Use isPathExpandedInPositions / setPathExpandedInPositions from positions.ts.
41
+
42
+ /** @deprecated Use isPathExpandedInPositions(ctx, filePath) instead */
43
+ export function isPathExpanded(filePath: string): boolean {
44
+ // Legacy fallback: check localStorage for old data
45
+ // New code should use isPathExpandedInPositions which checks ctx.positions
46
+ const key = _getExpandedStorageKey();
47
+ if (!key) return false;
48
+ try {
49
+ const raw = localStorage.getItem(key);
50
+ if (raw) return new Set(JSON.parse(raw)).has(filePath);
51
+ } catch { }
52
+ return false;
53
+ }
54
+
55
+ /** @deprecated Use setPathExpandedInPositions(ctx, filePath, expanded) instead */
56
+ export function setPathExpanded(filePath: string, expanded: boolean) {
57
+ // Legacy: only used if ctx is not available
58
+ const key = _getExpandedStorageKey();
59
+ if (!key) return;
60
+ try {
61
+ const raw = localStorage.getItem(key);
62
+ const paths = raw ? new Set(JSON.parse(raw)) : new Set();
63
+ if (expanded) paths.add(filePath);
64
+ else paths.delete(filePath);
65
+ if (paths.size === 0) localStorage.removeItem(key);
66
+ else localStorage.setItem(key, JSON.stringify(Array.from(paths)));
67
+ } catch { }
68
+ }
69
+
70
+ function _getExpandedStorageKey(): string | null {
71
+ const hashSlug = decodeURIComponent(window.location.hash.replace('#', ''));
72
+ const repo = (hashSlug && localStorage.getItem(`gitcanvas:slug:${hashSlug}`)) || localStorage.getItem('gitcanvas:lastRepo');
73
+ if (!repo) return null;
74
+ return `gitcanvas:expanded:${repo}`;
75
+ }
76
+
77
+ // ─── Selection highlights ───────────────────────────────
78
+ export function updateSelectionHighlights(ctx: CanvasContext) {
79
+ const selected = ctx.snap().context.selectedCards;
80
+ ctx.fileCards.forEach((card, path) => {
81
+ card.classList.toggle('selected', selected.includes(path));
82
+ });
83
+ updateStatusBarSelected(selected.length);
84
+ }
85
+
86
+ export function clearSelectionHighlights(ctx: CanvasContext) {
87
+ ctx.fileCards.forEach(card => card.classList.remove('selected'));
88
+ }
89
+
90
+ // ─── Arrange toolbar visibility ─────────────────────────
91
+ export function updateArrangeToolbar(ctx: CanvasContext) {
92
+ measure('arrange:updateToolbar', () => {
93
+ const toolbar = document.getElementById('arrangeToolbar');
94
+ if (!toolbar) return;
95
+ const selected = ctx.snap().context.selectedCards;
96
+ toolbar.style.display = selected.length >= 2 ? 'flex' : 'none';
97
+ });
98
+ }
99
+
100
+ // ─── Corner detection for resize ────────────────────────
101
+ function isNearCorner(e: MouseEvent, card: HTMLElement, cornerSize: number, zoom: number): string | null {
102
+ const rect = card.getBoundingClientRect();
103
+ const x = e.clientX - rect.left;
104
+ const y = e.clientY - rect.top;
105
+ const w = rect.width;
106
+ const h = rect.height;
107
+ // Scale corner hit-area: base size + proportion of card size, capped at 80px
108
+ // This makes large cards much easier to grab at corners
109
+ const dynamicCorner = Math.min(80, Math.max(cornerSize, Math.min(w, h) * 0.12));
110
+ const c = dynamicCorner * zoom;
111
+
112
+ if (x > w - c && y > h - c) return 'br';
113
+ if (x < c && y > h - c) return 'bl';
114
+ if (x > w - c && y < c) return 'tr';
115
+ if (x < c && y < c) return 'tl';
116
+ return null;
117
+ }
118
+
119
+ // ─── Setup card interaction (click-select + drag) ────────
120
+ export function setupCardInteraction(ctx: CanvasContext, card: HTMLElement, commitHash: string) {
121
+ let action = null; // null | 'move' | 'pending'
122
+ let startX: number, startY: number;
123
+ let moveStartPositions: any[] = [];
124
+ let rafPending = false;
125
+ const DRAG_THRESHOLD = 3;
126
+
127
+ function onMouseDown(e) {
128
+ // Only respond to left-click (button 0). Middle-click/right-click should not start card interaction.
129
+ if (e.button !== 0) return;
130
+ if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
131
+ const bodyEl = e.target.closest('.file-card-body');
132
+ if (bodyEl && (e.offsetX > e.target.clientWidth || e.offsetY > e.target.clientHeight)) return;
133
+
134
+ // If a connection is pending and the click is inside body (on a diff-line),
135
+ // don't start drag — let the connection click handler handle it
136
+ if (hasPendingConnection() && bodyEl && (e.target as HTMLElement).closest('.diff-line')) return;
137
+
138
+ e.stopPropagation();
139
+ startX = e.clientX;
140
+ startY = e.clientY;
141
+ action = 'pending';
142
+
143
+ window.addEventListener('mousemove', onMouseMove);
144
+ window.addEventListener('mouseup', onMouseUp);
145
+ }
146
+
147
+ function onMouseMove(e) {
148
+ const state = ctx.snap().context;
149
+ const dx = (e.clientX - startX) / state.zoom;
150
+ const dy = (e.clientY - startY) / state.zoom;
151
+
152
+ if (action === 'pending') {
153
+ const screenDist = Math.sqrt((e.clientX - startX) ** 2 + (e.clientY - startY) ** 2);
154
+ if (screenDist < DRAG_THRESHOLD) return;
155
+
156
+ action = 'move';
157
+ card.style.cursor = 'move';
158
+
159
+ const selected = ctx.snap().context.selectedCards;
160
+ const cardPath = card.dataset.path;
161
+ if (!selected.includes(cardPath)) {
162
+ if (!e.shiftKey && !e.ctrlKey) {
163
+ ctx.actor.send({ type: 'SELECT_CARD', path: cardPath, shift: false });
164
+ } else {
165
+ ctx.actor.send({ type: 'SELECT_CARD', path: cardPath, shift: true });
166
+ }
167
+ updateSelectionHighlights(ctx);
168
+ updateArrangeToolbar(ctx);
169
+ }
170
+
171
+ const nowSelected = ctx.snap().context.selectedCards;
172
+ moveStartPositions = [];
173
+ nowSelected.forEach(path => {
174
+ const c = ctx.fileCards.get(path);
175
+ if (c) {
176
+ c.style.cursor = 'grabbing';
177
+ moveStartPositions.push({
178
+ card: c,
179
+ path,
180
+ startLeft: parseInt(c.style.left) || 0,
181
+ startTop: parseInt(c.style.top) || 0,
182
+ });
183
+ }
184
+ });
185
+ }
186
+
187
+ if (action === 'move') {
188
+ moveStartPositions.forEach(info => {
189
+ info.card.style.left = `${info.startLeft + dx}px`;
190
+ info.card.style.top = `${info.startTop + dy}px`;
191
+ });
192
+ // Throttle expensive DOM updates to once per frame
193
+ if (!rafPending) {
194
+ rafPending = true;
195
+ requestAnimationFrame(() => {
196
+ rafPending = false;
197
+ scheduleRenderConnections(ctx);
198
+ updateMinimap(ctx);
199
+ });
200
+ }
201
+ return;
202
+ }
203
+ }
204
+
205
+ function onMouseUp(e) {
206
+ window.removeEventListener('mousemove', onMouseMove);
207
+ window.removeEventListener('mouseup', onMouseUp);
208
+
209
+ if (action === 'pending') {
210
+ if (e.shiftKey || e.ctrlKey) {
211
+ ctx.actor.send({ type: 'SELECT_CARD', path: card.dataset.path, shift: true });
212
+ } else {
213
+ ctx.actor.send({ type: 'SELECT_CARD', path: card.dataset.path, shift: false });
214
+ }
215
+ updateSelectionHighlights(ctx);
216
+ updateArrangeToolbar(ctx);
217
+ } else if (action === 'move') {
218
+ document.body.style.cursor = '';
219
+ card.style.cursor = '';
220
+ moveStartPositions.forEach(info => {
221
+ info.card.style.cursor = '';
222
+ });
223
+ moveStartPositions.forEach(info => {
224
+ const x = parseInt(info.card.style.left) || 0;
225
+ const y = parseInt(info.card.style.top) || 0;
226
+ savePosition(ctx, commitHash, info.path, x, y);
227
+ });
228
+ moveStartPositions = [];
229
+ }
230
+
231
+ action = null;
232
+ }
233
+
234
+ card.addEventListener('mousedown', onMouseDown);
235
+
236
+ // ── Double-click to open in editor modal ──
237
+ card.addEventListener('dblclick', (e) => {
238
+ // Don't trigger on buttons
239
+ if ((e.target as HTMLElement).tagName === 'BUTTON' || (e.target as HTMLElement).closest('button')) return;
240
+ e.preventDefault();
241
+ e.stopPropagation();
242
+
243
+ const filePath = card.dataset.path;
244
+ if (filePath) {
245
+ const file = ctx.allFilesData?.find(f => f.path === filePath) ||
246
+ { path: filePath, name: filePath.split('/').pop(), lines: 0 };
247
+ // Read visible line from canvas text renderer scroll position
248
+ const renderer = (card as any)._canvasTextRenderer;
249
+ const initialLine = renderer ? renderer.getVisibleLine?.() : undefined;
250
+ import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file, undefined, initialLine));
251
+ }
252
+ });
253
+
254
+ // ── Right-click context menu ──
255
+ card.addEventListener('contextmenu', (e) => {
256
+ e.preventDefault();
257
+ e.stopPropagation();
258
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
259
+ });
260
+ }
261
+
262
+ // ─── Context menu & file history (extracted to card-context-menu.tsx) ────
263
+ import { showCardContextMenu, showFileHistory } from './card-context-menu';
264
+ export { showCardContextMenu, showFileHistory };
265
+
266
+ // ─── Arrangement functions (extracted to card-arrangement.ts) ────
267
+ import { arrangeRow, arrangeColumn, arrangeGrid } from './card-arrangement';
268
+ export { arrangeRow, arrangeColumn, arrangeGrid };
269
+
270
+ // ─── Scroll debounce ────────────────────────────────────
271
+ export function debounceSaveScroll(ctx: CanvasContext, filePath: string, scrollTop: number) {
272
+ if (ctx.scrollTimers[filePath]) clearTimeout(ctx.scrollTimers[filePath]);
273
+ ctx.scrollTimers[filePath] = setTimeout(() => {
274
+ ctx.actor.send({ type: 'SAVE_SCROLL', path: filePath, scrollTop });
275
+ savePosition(ctx, 'scroll', filePath, scrollTop, 0);
276
+ }, 300);
277
+ }
278
+
279
+ // ─── JSX sub-components for file card content ───────────
280
+
281
+ function DiffLine({ type, lineNum, content }: { type: string; lineNum: number; content: string }) {
282
+ return (
283
+ <span className={`diff-line diff-${type}`} data-line={lineNum}>
284
+ <span className="line-num">{String(lineNum).padStart(4, ' ')}</span>
285
+ {content}
286
+ </span>
287
+ );
288
+ }
289
+
290
+ function DiffHunk({ hunk, hunkIdx }: { hunk: any; hunkIdx: number }) {
291
+ const header = `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@${hunk.context ? ' ' + hunk.context : ''}`;
292
+ let oldLine = hunk.oldStart;
293
+ let newLine = hunk.newStart;
294
+
295
+ const currentItems: { type: string; ln: number; content: string }[] = [];
296
+ const previousItems: { type: string; ln: number; content: string }[] = [];
297
+
298
+ hunk.lines.forEach((l: any) => {
299
+ if (l.type === 'add') {
300
+ currentItems.push({ type: 'add', ln: newLine++, content: l.content });
301
+ } else if (l.type === 'del') {
302
+ previousItems.push({ type: 'del', ln: oldLine++, content: l.content });
303
+ } else {
304
+ const curLn = newLine++;
305
+ const prevLn = oldLine++;
306
+ currentItems.push({ type: 'ctx', ln: curLn, content: l.content });
307
+ previousItems.push({ type: 'ctx', ln: prevLn, content: l.content });
308
+ }
309
+ });
310
+
311
+ const hasDeletions = previousItems.some(l => l.type === 'del');
312
+
313
+ function toggle(e: Event, view: string) {
314
+ e.stopPropagation();
315
+ const hunkEl = (e.target as HTMLElement).closest('.diff-hunk');
316
+ if (!hunkEl) return;
317
+ hunkEl.querySelectorAll('.hunk-toggle-btn').forEach(b => b.classList.remove('active'));
318
+ (e.target as HTMLElement).classList.add('active');
319
+ const cur = hunkEl.querySelector('.hunk-pane--current') as HTMLElement;
320
+ const prev = hunkEl.querySelector('.hunk-pane--previous') as HTMLElement;
321
+ if (cur) cur.style.display = view === 'current' ? '' : 'none';
322
+ if (prev) prev.style.display = view === 'previous' ? '' : 'none';
323
+ }
324
+
325
+ return (
326
+ <div className="diff-hunk">
327
+ <div className="diff-hunk-header">
328
+ <span className="hunk-range">{header}</span>
329
+ {hasDeletions ? (
330
+ <span className="hunk-view-toggle" data-hunk={hunkIdx}>
331
+ <button className="hunk-toggle-btn active" data-view="current" onclick={(e) => toggle(e, 'current')}>Current</button>
332
+ <button className="hunk-toggle-btn" data-view="previous" onclick={(e) => toggle(e, 'previous')}>Previous</button>
333
+ </span>
334
+ ) : null}
335
+ </div>
336
+ <div className="diff-hunk-body">
337
+ <div className="hunk-pane hunk-pane--current">
338
+ <pre><code>{currentItems.map(l => <DiffLine type={l.type} lineNum={l.ln} content={l.content} />)}</code></pre>
339
+ </div>
340
+ <div className="hunk-pane hunk-pane--previous" style="display:none">
341
+ <pre><code>{previousItems.map(l => <DiffLine type={l.type} lineNum={l.ln} content={l.content} />)}</code></pre>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ function FileCardContent({ file }: { file: any }) {
349
+ if (file.status === 'added' && file.content) {
350
+ const lines = file.content.split('\n');
351
+ return (
352
+ <div className="file-content-preview">
353
+ <pre><code>{lines.map((line, i) => (!file.visibleLineIndices || file.visibleLineIndices.has(i)) ? <DiffLine type="add" lineNum={i + 1} content={line} /> : null)}</code></pre>
354
+ </div>
355
+ );
356
+ }
357
+ if (file.status === 'deleted' && file.content) {
358
+ const lines = file.content.split('\n');
359
+ return (
360
+ <div className="file-content-preview">
361
+ <pre><code>{lines.map((line, i) => (!file.visibleLineIndices || file.visibleLineIndices.has(i)) ? <DiffLine type="del" lineNum={i + 1} content={line} /> : null)}</code></pre>
362
+ </div>
363
+ );
364
+ }
365
+ if ((file.status === 'modified' || file.status === 'renamed' || file.status === 'copied') && file.hunks?.length > 0) {
366
+ return (
367
+ <div className="file-content-preview">
368
+ {file.hunks.map((hunk, idx) => <DiffHunk hunk={hunk} hunkIdx={idx} />)}
369
+ </div>
370
+ );
371
+ }
372
+ if ((file.status === 'renamed' || file.status === 'copied') && (!file.hunks || file.hunks.length === 0)) {
373
+ const simText = file.similarity ? ` (${file.similarity}% similar)` : '';
374
+ return (
375
+ <div className="file-content-preview">
376
+ <pre><code><span className="rename-notice">{'File ' + file.status + simText + '\nNo content changes'}</span></code></pre>
377
+ </div>
378
+ );
379
+ }
380
+ const msg = file.contentError || 'No changes to display';
381
+ return (
382
+ <div className="file-content-preview">
383
+ <pre><code><span className="error-notice">{msg}</span></code></pre>
384
+ </div>
385
+ );
386
+ }
387
+
388
+ const STATUS_COLORS: Record<string, string> = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444', renamed: '#a78bfa', copied: '#60a5fa' };
389
+ const STATUS_LABELS: Record<string, string> = { added: '+ ADDED', modified: '~ MODIFIED', deleted: '- DELETED', renamed: '→ RENAMED', copied: '⊕ COPIED' };
390
+
391
+ function _handleChatClick(ctx: CanvasContext, file: any) {
392
+ const filePath = file.path;
393
+ const content = file.content || '';
394
+ const status = file.status || '';
395
+
396
+ let extraContext = '';
397
+
398
+ if (file.hunks && file.hunks.length > 0) {
399
+ extraContext += `\n--- DIFF SUMMARY ---\n`;
400
+ extraContext += file.hunks.map((h: any) =>
401
+ `@@ -${h.oldStart},${h.oldCount} +${h.newStart},${h.newCount} @@\n` +
402
+ h.lines.map((l: any) => `${l.type === 'add' ? '+' : l.type === 'del' ? '-' : ' '} ${l.content}`).join('\n')
403
+ ).join('\n');
404
+ }
405
+
406
+ const connections = ctx.snap().context.connections;
407
+ const relatedLinks = connections.filter((c: any) => c.sourceFile === filePath || c.targetFile === filePath);
408
+
409
+ if (relatedLinks.length > 0) {
410
+ extraContext += `\n\n--- ARCHITECTURE CONNECTIONS ---\n`;
411
+ extraContext += `This file is logically connected to the following modules in the visual graph:\n`;
412
+ relatedLinks.forEach((c: any) => {
413
+ if (c.sourceFile === filePath) {
414
+ extraContext += `- Outbound dependency on \`${c.targetFile}\` (Lines ${c.sourceLineStart}-${c.sourceLineEnd} -> Lines ${c.targetLineStart}-${c.targetLineEnd}). Note: "${c.comment || 'None'}"\n`;
415
+ } else {
416
+ extraContext += `- Inbound dependency from \`${c.sourceFile}\` (Lines ${c.sourceLineStart}-${c.sourceLineEnd} -> Lines ${c.targetLineStart}-${c.targetLineEnd}). Note: "${c.comment || 'None'}"\n`;
417
+ }
418
+ });
419
+ }
420
+
421
+ openFileChatInModal(filePath, content, status, extraContext);
422
+ }
423
+
424
+ // ─── Create file card (commit diff) ─────────────────────
425
+ export function createFileCard(ctx: CanvasContext, file: any, x: number, y: number, commitHash: string, skipInteraction = false): HTMLElement {
426
+ const card = document.createElement('div');
427
+ card.className = `file-card file-card--${file.status || 'modified'}`;
428
+ card.style.left = `${x}px`;
429
+ card.style.top = `${y}px`;
430
+ card.dataset.path = file.path;
431
+
432
+ if (file.layerSections && file.layerSections.length > 0) {
433
+ if (file.content) {
434
+ const { visibleLineIndices } = filterFileContentByLayer(file.content, file.layerSections);
435
+ file.visibleLineIndices = visibleLineIndices;
436
+ }
437
+ if (file.hunks) {
438
+ // Very simplistic filtering for hunks
439
+ file.hunks = file.hunks.filter(h => {
440
+ // If the hunk's content has ANY line overlapping with visible lines, keep it. But we don't have exactly the full file contents to compare.
441
+ // Keep all hunks for now if layers view, else users might miss diffs.
442
+ return true;
443
+ });
444
+ }
445
+ }
446
+
447
+ // Apply saved size
448
+ const posKey = getPositionKey(file.path, commitHash);
449
+ if (ctx.positions.has(posKey)) {
450
+ const pos = ctx.positions.get(posKey);
451
+ if (pos.width) card.style.width = `${pos.width}px`;
452
+ if (pos.height) {
453
+ card.style.height = `${pos.height}px`;
454
+ card.style.maxHeight = `${pos.height}px`;
455
+ }
456
+ }
457
+
458
+ const ext = file.name.split('.').pop().toLowerCase();
459
+ const iconClass = getFileIconClass(ext);
460
+ const statusColor = STATUS_COLORS[file.status] || '#a855f7';
461
+ const statusLabel = STATUS_LABELS[file.status] || file.status?.toUpperCase() || 'CHANGED';
462
+ const hunkCount = file.hunks?.length || 0;
463
+ const metaInfo = hunkCount > 0
464
+ ? `${hunkCount} hunk${hunkCount > 1 ? 's' : ''}`
465
+ : `${file.lines || 0} lines`;
466
+
467
+ const iconSvg = getFileIcon(file.type, ext);
468
+
469
+ // Render JSX into card
470
+ render(
471
+ <>
472
+ <div className="file-card-header" style={`border-left: 4px solid ${statusColor}`}>
473
+ <div className={`file-icon ${iconClass}`} dangerouslySetInnerHTML={{ __html: iconSvg }} />
474
+ <span className="file-name">{file.name}</span>
475
+ <span className="file-status" style={`background: ${statusColor}20; color: ${statusColor}; font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 600;`}>{statusLabel}</span>
476
+ <span style="font-size: 10px; color: var(--text-muted); margin-left: auto;">{metaInfo}</span>
477
+
478
+ </div>
479
+ <div className="file-card-body">
480
+ {file.oldPath ? (
481
+ <div className="file-rename-path">
482
+ {file.oldPath} → {file.path}
483
+ {file.similarity ? <span className="rename-similarity">{file.similarity}%</span> : null}
484
+ </div>
485
+ ) : (
486
+ <div className="file-path">{file.path}</div>
487
+ )}
488
+ <FileCardContent file={file} />
489
+ </div>
490
+ </>,
491
+ card
492
+ );
493
+
494
+ cardFileData.set(card, file);
495
+
496
+ // When managed by CardManager, skip legacy drag/resize/z-order setup
497
+ // but ALWAYS attach context menu, double-click, and click-to-select
498
+ if (!skipInteraction) {
499
+ setupCardInteraction(ctx, card, commitHash);
500
+ } else {
501
+ card.addEventListener('contextmenu', (e) => {
502
+ e.preventDefault();
503
+ e.stopPropagation();
504
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
505
+ });
506
+ card.addEventListener('dblclick', (e) => {
507
+ if ((e.target as HTMLElement).tagName === 'BUTTON' || (e.target as HTMLElement).closest('button')) return;
508
+ e.preventDefault();
509
+ e.stopPropagation();
510
+ const filePath = card.dataset.path;
511
+ if (filePath) {
512
+ const file = ctx.allFilesData?.find(f => f.path === filePath) ||
513
+ { path: filePath, name: filePath.split('/').pop(), lines: 0 };
514
+ import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file));
515
+ }
516
+ });
517
+ // Smart selection: don't deselect others on mousedown if card is already selected
518
+ // This allows multi-drag to work. Deselection is deferred to mouseup (click without drag).
519
+ let _dragOccurred = false;
520
+ card.addEventListener('mousedown', (e) => {
521
+ if (e.button !== 0) return;
522
+ if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('.connect-btn')) return;
523
+ const filePath = card.dataset.path || '';
524
+ const multi = e.shiftKey || e.ctrlKey;
525
+ const selected = ctx.snap().context.selectedCards;
526
+ const alreadySelected = selected.includes(filePath);
527
+ _dragOccurred = false;
528
+
529
+ if (multi) {
530
+ // Shift/Ctrl: toggle selection
531
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: true });
532
+ try {
533
+ const { getCardManager } = require('./galaxydraw-bridge');
534
+ const cm = getCardManager();
535
+ if (cm) {
536
+ if (alreadySelected) cm.deselect(filePath);
537
+ else cm.select(filePath, true);
538
+ }
539
+ } catch { }
540
+ } else if (!alreadySelected) {
541
+ // Not selected yet → replace selection with this card
542
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
543
+ try {
544
+ const { getCardManager } = require('./galaxydraw-bridge');
545
+ const cm = getCardManager();
546
+ if (cm) cm.select(filePath, false);
547
+ } catch { }
548
+ }
549
+ // If already selected without shift → do nothing on mousedown (allow multi-drag)
550
+ // Deselection of others happens on mouseup below
551
+
552
+ updateSelectionHighlights(ctx);
553
+ updateArrangeToolbar(ctx);
554
+ });
555
+ card.addEventListener('mouseup', (e) => {
556
+ if (e.button !== 0) return;
557
+ if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('.connect-btn')) return;
558
+ // Only deselect others if: no shift, card was already selected, and no drag happened
559
+ const filePath = card.dataset.path || '';
560
+ const multi = e.shiftKey || e.ctrlKey;
561
+ if (multi) return; // shift-click handled in mousedown
562
+ const selected = ctx.snap().context.selectedCards;
563
+ if (selected.length > 1 && selected.includes(filePath) && !_dragOccurred) {
564
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
565
+ try {
566
+ const { getCardManager } = require('./galaxydraw-bridge');
567
+ const cm = getCardManager();
568
+ if (cm) cm.select(filePath, false);
569
+ } catch { }
570
+ updateSelectionHighlights(ctx);
571
+ updateArrangeToolbar(ctx);
572
+ }
573
+ });
574
+ // Track drag state from engine for mouseup deselection logic
575
+ card.addEventListener('mousemove', () => { _dragOccurred = true; });
576
+ }
577
+ setupConnectionDrag(ctx, card, file.path);
578
+
579
+ // Expand button → open modal
580
+ const expandBtn = card.querySelector('.expand-btn');
581
+ if (expandBtn) {
582
+ expandBtn.addEventListener('click', (e) => {
583
+ e.stopPropagation();
584
+ openFileModal(ctx, file);
585
+ });
586
+ }
587
+
588
+ // AI button → open chat
589
+ const aiBtn = card.querySelector('.ai-btn');
590
+ if (aiBtn) {
591
+ aiBtn.addEventListener('click', (e) => {
592
+ e.stopPropagation();
593
+ _handleChatClick(ctx, file);
594
+ });
595
+ }
596
+
597
+ // Scroll listener
598
+ const body = card.querySelector('.file-card-body');
599
+ if (body) {
600
+ body.addEventListener('scroll', () => {
601
+ scheduleRenderConnections(ctx);
602
+ });
603
+ }
604
+
605
+ // Listen for resize from indicator drag
606
+ card.addEventListener('card-resized', ((e: CustomEvent) => {
607
+ const { path: p, width: w, height: h } = e.detail;
608
+ const state = ctx.snap().context;
609
+ const ch = state.currentCommitHash || 'allfiles';
610
+ ctx.actor.send({ type: 'RESIZE_CARD', path: p, width: w, height: h });
611
+ savePosition(ctx, ch, p, parseInt(card.style.left) || 0, parseInt(card.style.top) || 0, w, h);
612
+ renderConnections(ctx);
613
+ }) as EventListener);
614
+
615
+ return card;
616
+ }
617
+
618
+
619
+ // ─── Build file content HTML with optional line limiting ─
620
+ // When isExpanded=false, only render VISIBLE_LINE_LIMIT lines to keep DOM small.
621
+ // When isExpanded=true (F key), render all lines for full scrolling.
622
+ export function _buildFileContentHTML(
623
+ content: string,
624
+ layerSections: any,
625
+ addedLines: Set<number>,
626
+ deletedBeforeLine: Map<number, string[]>,
627
+ isAllAdded: boolean,
628
+ isAllDeleted: boolean,
629
+ isExpanded: boolean,
630
+ totalFileLines: number
631
+ ): string {
632
+ const { filteredContent, visibleLineIndices } = filterFileContentByLayer(content, layerSections);
633
+ const lines = content.split('\n');
634
+ const totalVisible = Array.from(visibleLineIndices).length;
635
+ const limit = isExpanded ? Infinity : VISIBLE_LINE_LIMIT;
636
+ let code = '';
637
+ let renderedCount = 0;
638
+
639
+ for (let i = 0; i < lines.length; i++) {
640
+ if (!visibleLineIndices.has(i)) continue;
641
+ if (renderedCount >= limit) break;
642
+
643
+ const line = lines[i];
644
+ const lineNum = i + 1;
645
+ const lineClass = isAllAdded ? 'diff-add'
646
+ : isAllDeleted ? 'diff-del'
647
+ : addedLines.has(lineNum) ? 'diff-add'
648
+ : 'diff-ctx';
649
+ const hasDel = deletedBeforeLine.has(lineNum);
650
+ const delCount = hasDel ? deletedBeforeLine.get(lineNum)!.length : 0;
651
+ const delAttr = hasDel ? ` data-del-count="${delCount}"` : '';
652
+ const delLines = hasDel ? ` data-del-lines="${encodeURIComponent(JSON.stringify(deletedBeforeLine.get(lineNum)))}"` : '';
653
+ code += `<span class="diff-line ${lineClass}${hasDel ? ' has-deleted' : ''}" data-line="${lineNum}"${delAttr}${delLines}><span class="line-num">${String(lineNum).padStart(4, ' ')}</span>${escapeHtml(line)}</span>\n`;
654
+ renderedCount++;
655
+ }
656
+
657
+ const hiddenCount = totalVisible - renderedCount;
658
+ // Invisible sentinel for IntersectionObserver auto-loading (no visible text)
659
+ const truncNote = hiddenCount > 0
660
+ ? `<span class="more-lines" data-auto-expand="true" style="display:block;height:1px;"></span>`
661
+ : '';
662
+ return `<div class="file-content-preview"><pre><code>${code}</code></pre>${truncNote}</div>`;
663
+ }
664
+
665
+ // ─── Create all-file card (working tree) ────────────────
666
+ export function createAllFileCard(ctx: CanvasContext, file: any, x: number, y: number, savedSize: any, skipInteraction = false): HTMLElement {
667
+ const card = document.createElement('div');
668
+ card.className = 'file-card';
669
+ // Guard against NaN/undefined positions (corrupted position records)
670
+ const safeX = isNaN(x) ? 0 : x;
671
+ const safeY = isNaN(y) ? 0 : y;
672
+ card.style.left = `${safeX}px`;
673
+ card.style.top = `${safeY}px`;
674
+ card.dataset.path = file.path;
675
+
676
+ if (savedSize) {
677
+ card.style.width = `${savedSize.width}px`;
678
+ card.style.height = `${savedSize.height}px`;
679
+ card.style.maxHeight = `${savedSize.height}px`;
680
+ }
681
+
682
+ const ext = file.ext || '';
683
+ const iconClass = getFileIconClass(ext);
684
+ const addedLines: Set<number> = file.addedLines || new Set();
685
+ const isAllAdded = file.status === 'added';
686
+ const isAllDeleted = file.status === 'deleted';
687
+
688
+ const deletedBeforeLine: Map<number, string[]> = file.deletedBeforeLine || new Map();
689
+
690
+ // All files are now same fixed size - no expand persistence
691
+
692
+ let contentHTML = '';
693
+ let useCanvasText = false;
694
+ let canvasOptions: any = null;
695
+
696
+ if (file.isBinary) {
697
+ contentHTML = `<div class="file-content-preview"><pre><code><span class="error-notice">Binary file</span></code></pre></div>`;
698
+ } else if (file.content) {
699
+ if (ctx.useCanvasText) {
700
+ useCanvasText = true;
701
+ canvasOptions = {
702
+ content: file.content,
703
+ addedLines,
704
+ deletedBeforeLine,
705
+ isAllAdded,
706
+ isAllDeleted,
707
+ visibleLineIndices: filterFileContentByLayer(file.content, file.layerSections).visibleLineIndices,
708
+ filePath: file.path,
709
+ };
710
+ contentHTML = `<div class="file-content-preview canvas-container" style="position:relative; height: 100%; overflow: auto; background: var(--bg-card);"></div>`;
711
+ } else {
712
+ contentHTML = _buildFileContentHTML(file.content, file.layerSections, addedLines, deletedBeforeLine, isAllAdded, isAllDeleted, false, file.lines);
713
+ }
714
+ } else {
715
+ contentHTML = `<div class="file-content-preview"><pre><code><span class="error-notice">Could not read file</span></code></pre></div>`;
716
+ }
717
+
718
+
719
+ const dir = file.path.includes('/') ? file.path.split('/').slice(0, -1).join('/') : '';
720
+
721
+ // Status badge for changed files
722
+ const statusColors: Record<string, string> = { added: '#22c55e', modified: '#eab308', deleted: '#ef4444', renamed: '#60a5fa', copied: '#a78bfa' };
723
+ const statusBadge = file.status && file.status !== 'unmodified'
724
+ ? `<span style="font-size: 9px; color: ${statusColors[file.status] || 'var(--text-muted)'}; margin-left: 4px; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(file.status)}${addedLines.size > 0 ? ` <span style="color:#22c55e">+${addedLines.size}</span>` : ''}${deletedBeforeLine.size > 0 ? ` <span style="color:#f87171">-${Array.from(deletedBeforeLine.values()).reduce((s, a) => s + a.length, 0)}</span>` : ''}</span>`
725
+ : '';
726
+ const metaInfo = file.status ? statusBadge : `<span style="font-size: 10px; color: var(--text-muted); margin-left: auto;">${file.lines} lines</span>`;
727
+
728
+ card.innerHTML = `
729
+ <div class="file-card-header">
730
+ <div class="file-icon ${iconClass}">
731
+ ${getFileIcon(file.type, ext)}
732
+ </div>
733
+ <span class="file-name">${escapeHtml(file.name)}</span>
734
+ ${metaInfo}
735
+
736
+ </div>
737
+ <div class="file-card-body">
738
+ <div class="file-path">${escapeHtml(dir)}</div>
739
+ ${contentHTML}
740
+ </div>
741
+ `;
742
+
743
+ // Store file data for re-rendering on expand/collapse
744
+ cardFileData.set(card, file);
745
+
746
+ setupConnectionDrag(ctx, card, file.path);
747
+ // When managed by CardManager, skip legacy drag/resize/z-order setup
748
+ // but ALWAYS attach context menu, double-click, and click-to-select
749
+ if (!skipInteraction) {
750
+ setupCardInteraction(ctx, card, 'allfiles');
751
+ } else {
752
+ // Context menu (right-click)
753
+ card.addEventListener('contextmenu', (e) => {
754
+ e.preventDefault();
755
+ e.stopPropagation();
756
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
757
+ });
758
+ // Double-click to open in editor modal
759
+ card.addEventListener('dblclick', (e) => {
760
+ if ((e.target as HTMLElement).tagName === 'BUTTON' || (e.target as HTMLElement).closest('button')) return;
761
+ e.preventDefault();
762
+ e.stopPropagation();
763
+ const filePath = card.dataset.path;
764
+ if (filePath) {
765
+ const file = ctx.allFilesData?.find(f => f.path === filePath) ||
766
+ { path: filePath, name: filePath.split('/').pop(), lines: 0 };
767
+ import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file));
768
+ }
769
+ });
770
+ // Click to select (sync both XState and CardManager)
771
+ card.addEventListener('mousedown', (e) => {
772
+ if (e.button !== 0) return;
773
+ if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('.connect-btn')) return;
774
+ const filePath = card.dataset.path || '';
775
+ const multi = e.shiftKey || e.ctrlKey;
776
+
777
+ try {
778
+ const { getCardManager } = require('./galaxydraw-bridge');
779
+ const cm = getCardManager();
780
+ if (cm) {
781
+ const alreadySelected = cm.selected.has(filePath);
782
+
783
+ if (multi) {
784
+ // Shift/Ctrl click: toggle selection
785
+ if (alreadySelected) {
786
+ cm.deselect(filePath);
787
+ } else {
788
+ cm.select(filePath, true);
789
+ }
790
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: true });
791
+ } else if (alreadySelected && cm.selected.size > 1) {
792
+ // Clicking already-selected card in a multi-selection:
793
+ // Don't deselect yet — user might be starting a multi-drag.
794
+ // Deselect on mouseup if no drag occurred.
795
+ let dragged = false;
796
+ const onMove = () => { dragged = true; };
797
+ const onUp = () => {
798
+ window.removeEventListener('mousemove', onMove);
799
+ window.removeEventListener('mouseup', onUp);
800
+ if (!dragged) {
801
+ // No drag happened — deselect all others
802
+ cm.deselectAll();
803
+ cm.select(filePath, false);
804
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
805
+ updateSelectionHighlights(ctx);
806
+ updateArrangeToolbar(ctx);
807
+ }
808
+ };
809
+ window.addEventListener('mousemove', onMove);
810
+ window.addEventListener('mouseup', onUp);
811
+ } else {
812
+ // Normal click: deselect all, select this one
813
+ cm.select(filePath, false);
814
+ ctx.actor.send({ type: 'SELECT_CARD', path: filePath, shift: false });
815
+ }
816
+ }
817
+ } catch { }
818
+ updateSelectionHighlights(ctx);
819
+ updateArrangeToolbar(ctx);
820
+ });
821
+ }
822
+
823
+ if (useCanvasText && canvasOptions) {
824
+ const previewEl = card.querySelector('.canvas-container') as HTMLElement;
825
+ if (previewEl) {
826
+ import('./canvas-text').then(({ CanvasTextRenderer }) => {
827
+ const renderer = new CanvasTextRenderer(previewEl, canvasOptions);
828
+ (card as any)._canvasTextRenderer = renderer;
829
+ });
830
+ }
831
+ }
832
+
833
+ const expandBtn = card.querySelector('.expand-btn');
834
+ if (expandBtn) {
835
+ expandBtn.addEventListener('click', (e) => {
836
+ e.stopPropagation();
837
+ openFileModal(ctx, file);
838
+ });
839
+ }
840
+
841
+ // AI button → open chat
842
+ const aiBtn = card.querySelector('.ai-btn');
843
+ if (aiBtn) {
844
+ aiBtn.addEventListener('click', (e) => {
845
+ e.stopPropagation();
846
+ _handleChatClick(ctx, file);
847
+ });
848
+ }
849
+
850
+ const body = card.querySelector('.file-card-body') as HTMLElement;
851
+ if (body) {
852
+ body.addEventListener('scroll', () => {
853
+ debounceSaveScroll(ctx, file.path, body.scrollTop);
854
+ scheduleRenderConnections(ctx);
855
+ });
856
+ }
857
+
858
+ // ── Auto-load truncated lines when scrolled into view ──
859
+ const moreLinesEl = card.querySelector('.more-lines[data-auto-expand]') as HTMLElement;
860
+ if (moreLinesEl && file.content && !file.isBinary) {
861
+ const pre = card.querySelector('.file-content-preview pre') as HTMLElement;
862
+ if (pre) {
863
+ const observer = new IntersectionObserver((entries) => {
864
+ for (const entry of entries) {
865
+ if (entry.isIntersecting) {
866
+ observer.disconnect();
867
+ // Re-render with all lines (expanded)
868
+ const newHTML = _buildFileContentHTML(
869
+ file.content, file.layerSections, addedLines, deletedBeforeLine,
870
+ isAllAdded, isAllDeleted, true, file.lines
871
+ );
872
+ const preview = card.querySelector('.file-content-preview');
873
+ if (preview) preview.outerHTML = newHTML;
874
+ }
875
+ }
876
+ }, { root: pre, rootMargin: '200px' });
877
+ observer.observe(moreLinesEl);
878
+ }
879
+ }
880
+
881
+ // ── Diff marker strip (scrollbar annotations for changed lines) ──
882
+ // Skip when canvas-text mode is active — CanvasTextRenderer builds its own gutter
883
+ if ((addedLines.size > 0 || deletedBeforeLine.size > 0) && !isAllAdded && file.content && !useCanvasText) {
884
+ const totalLines = file.content.split('\n').length;
885
+ _buildDiffMarkerStrip(card, body, addedLines, totalLines, deletedBeforeLine, file.hunks);
886
+ }
887
+
888
+ // ── Deleted lines hover overlay ──
889
+ if (deletedBeforeLine.size > 0) {
890
+ _setupDeletedLinesOverlay(card);
891
+ }
892
+
893
+ // Listen for resize from indicator drag
894
+ card.addEventListener('card-resized', ((e: CustomEvent) => {
895
+ const { path: p, width: w, height: h } = e.detail;
896
+ const state = ctx.snap().context;
897
+ const ch = state.currentCommitHash || 'allfiles';
898
+ ctx.actor.send({ type: 'RESIZE_CARD', path: p, width: w, height: h });
899
+ savePosition(ctx, ch, p, parseInt(card.style.left) || 0, parseInt(card.style.top) || 0, w, h);
900
+ renderConnections(ctx);
901
+ }) as EventListener);
902
+
903
+ return card;
904
+ }
905
+
906
+ // ─── File expand modal (extracted to file-modal.tsx) ─────
907
+ import { openFileModal } from './file-modal';
908
+ export { openFileModal };
909
+
910
+ // ─── Diff markers & card expand (extracted modules) ─────
911
+ export { buildDiffMarkerStrip, scrollToLine, setupDeletedLinesOverlay } from './card-diff-markers';
912
+ export { changeCardsFontSize, toggleCardExpand, expandCardByPath, fitScreenSize, updateHiddenLinesIndicator } from './card-expand';
913
+
914
+