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/cards.tsx CHANGED
@@ -1,914 +1,1361 @@
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
-
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 { isFollower, detectRole } from "./role";
12
+ import {
13
+ savePosition,
14
+ getPositionKey,
15
+ isPathExpandedInPositions,
16
+ setPathExpandedInPositions,
17
+ } from "./positions";
18
+ import {
19
+ updateMinimap,
20
+ updateCanvasTransform,
21
+ updateZoomUI,
22
+ jumpToFile,
23
+ forceMinimapRebuild,
24
+ } from "./canvas";
25
+ import { updateStatusBarSelected } from "./status-bar";
26
+ import {
27
+ renderConnections,
28
+ scheduleRenderConnections,
29
+ setupConnectionDrag,
30
+ hasPendingConnection,
31
+ } from "./connections";
32
+ import { highlightSyntax, buildModalDiffHTML } from "./syntax";
33
+ import {
34
+ filterFileContentByLayer,
35
+ layerState,
36
+ createLayer,
37
+ addFileToLayer,
38
+ removeFileFromLayer,
39
+ getActiveLayer,
40
+ } from "./layers";
41
+ import { openFileChatInModal } from "./chat";
42
+ import {
43
+ buildDiffMarkerStrip as _buildDiffMarkerStrip,
44
+ setupDeletedLinesOverlay as _setupDeletedLinesOverlay,
45
+ } from "./card-diff-markers";
46
+ import { updateHiddenLinesIndicator as _updateHiddenLinesIndicator } from "./card-expand";
47
+
48
+ // ─── Constants ──────────────────────────────────────────
49
+ const CORNER_CURSORS = {
50
+ tl: "nwse-resize",
51
+ tr: "nesw-resize",
52
+ bl: "nesw-resize",
53
+ br: "nwse-resize",
54
+ };
55
+
56
+ // Max lines rendered in DOM for collapsed (folded) cards.
57
+ // Files with more lines than this will show a truncated view until expanded with F.
58
+ // This is the #1 performance optimization — a 10K-line file produces 10K <span> elements
59
+ // which all participate in layout during pan/zoom, crushing frame rate.
60
+ const VISIBLE_LINE_LIMIT = 120;
61
+
62
+ const cardFileData = new WeakMap<HTMLElement, any>();
63
+
64
+ // ─── Accessor for cardFileData (used by card-expand.ts via lazy require) ──
65
+ export function _getCardFileData(card: HTMLElement) {
66
+ return cardFileData.get(card);
67
+ }
68
+
69
+ // ─── Expanded state persistence ─────────────────────────
70
+ // NOTE: Expanded state is now stored in the positions system (positions.ts)
71
+ // so it automatically syncs to the server for logged-in users.
72
+ // The old localStorage-only functions below are kept as thin wrappers
73
+ // for backward compatibility but should not be used directly.
74
+ // Use isPathExpandedInPositions / setPathExpandedInPositions from positions.ts.
75
+
76
+ /** @deprecated Use isPathExpandedInPositions(ctx, filePath) instead */
77
+ export function isPathExpanded(filePath: string): boolean {
78
+ // Legacy fallback: check localStorage for old data
79
+ // New code should use isPathExpandedInPositions which checks ctx.positions
80
+ const key = _getExpandedStorageKey();
81
+ if (!key) return false;
82
+ try {
83
+ const raw = localStorage.getItem(key);
84
+ if (raw) return new Set(JSON.parse(raw)).has(filePath);
85
+ } catch {}
86
+ return false;
87
+ }
88
+
89
+ /** @deprecated Use setPathExpandedInPositions(ctx, filePath, expanded) instead */
90
+ export function setPathExpanded(filePath: string, expanded: boolean) {
91
+ // Legacy: only used if ctx is not available
92
+ const key = _getExpandedStorageKey();
93
+ if (!key) return;
94
+ try {
95
+ const raw = localStorage.getItem(key);
96
+ const paths = raw ? new Set(JSON.parse(raw)) : new Set();
97
+ if (expanded) paths.add(filePath);
98
+ else paths.delete(filePath);
99
+ if (paths.size === 0) localStorage.removeItem(key);
100
+ else localStorage.setItem(key, JSON.stringify(Array.from(paths)));
101
+ } catch {}
102
+ }
103
+
104
+ function _getExpandedStorageKey(): string | null {
105
+ const hashSlug = decodeURIComponent(window.location.hash.replace("#", ""));
106
+ const repo =
107
+ (hashSlug && localStorage.getItem(`gitcanvas:slug:${hashSlug}`)) ||
108
+ localStorage.getItem("gitcanvas:lastRepo");
109
+ if (!repo) return null;
110
+ return `gitcanvas:expanded:${repo}`;
111
+ }
112
+
113
+ // ─── Selection highlights ───────────────────────────────
114
+ export function updateSelectionHighlights(ctx: CanvasContext) {
115
+ const selected = ctx.snap().context.selectedCards;
116
+ ctx.fileCards.forEach((card, path) => {
117
+ card.classList.toggle("selected", selected.includes(path));
118
+ });
119
+ updateStatusBarSelected(selected.length);
120
+ }
121
+
122
+ export function clearSelectionHighlights(ctx: CanvasContext) {
123
+ ctx.fileCards.forEach((card) => card.classList.remove("selected"));
124
+ }
125
+
126
+ // ─── Arrange toolbar visibility ─────────────────────────
127
+ export function updateArrangeToolbar(ctx: CanvasContext) {
128
+ measure("arrange:updateToolbar", () => {
129
+ const toolbar = document.getElementById("arrangeToolbar");
130
+ if (!toolbar) return;
131
+ const selected = ctx.snap().context.selectedCards;
132
+ toolbar.style.display = selected.length >= 2 ? "flex" : "none";
133
+ });
134
+ }
135
+
136
+ // ─── Corner detection for resize ────────────────────────
137
+ function isNearCorner(
138
+ e: MouseEvent,
139
+ card: HTMLElement,
140
+ cornerSize: number,
141
+ zoom: number,
142
+ ): string | null {
143
+ const rect = card.getBoundingClientRect();
144
+ const x = e.clientX - rect.left;
145
+ const y = e.clientY - rect.top;
146
+ const w = rect.width;
147
+ const h = rect.height;
148
+ // Scale corner hit-area: base size + proportion of card size, capped at 80px
149
+ // This makes large cards much easier to grab at corners
150
+ const dynamicCorner = Math.min(
151
+ 80,
152
+ Math.max(cornerSize, Math.min(w, h) * 0.12),
153
+ );
154
+ const c = dynamicCorner * zoom;
155
+
156
+ if (x > w - c && y > h - c) return "br";
157
+ if (x < c && y > h - c) return "bl";
158
+ if (x > w - c && y < c) return "tr";
159
+ if (x < c && y < c) return "tl";
160
+ return null;
161
+ }
162
+
163
+ // ─── Setup card interaction (click-select + drag) ────────
164
+ export function setupCardInteraction(
165
+ ctx: CanvasContext,
166
+ card: HTMLElement,
167
+ commitHash: string,
168
+ ) {
169
+ // Follower mode: read-only, no drag/edit
170
+ const role = detectRole();
171
+ console.log(
172
+ `[cards] setupCardInteraction: ${role} mode for ${card.dataset.path}`,
173
+ );
174
+
175
+ if (role === "follower") {
176
+ card.style.cursor = "default";
177
+ card.style.pointerEvents = "auto";
178
+ card.addEventListener("click", (e) => {
179
+ // Click to select is still allowed
180
+ const filePath = card.dataset.path || "";
181
+ ctx.actor.send({
182
+ type: "SELECT_CARD",
183
+ path: filePath,
184
+ shift: e.shiftKey || e.ctrlKey,
185
+ });
186
+ });
187
+ // Block drag by preventing mousedown
188
+ card.addEventListener("mousedown", (e) => {
189
+ e.preventDefault();
190
+ e.stopPropagation();
191
+ });
192
+ return;
193
+ }
194
+
195
+ let action = null; // null | 'move' | 'pending'
196
+ let startX: number, startY: number;
197
+ let moveStartPositions: any[] = [];
198
+ let rafPending = false;
199
+ const DRAG_THRESHOLD = 3;
200
+
201
+ function onMouseDown(e) {
202
+ // Only respond to left-click (button 0). Middle-click/right-click should not start card interaction.
203
+ if (e.button !== 0) return;
204
+ if (e.target.tagName === "BUTTON" || e.target.closest("button")) return;
205
+ const bodyEl = e.target.closest(".file-card-body");
206
+ if (
207
+ bodyEl &&
208
+ (e.offsetX > e.target.clientWidth || e.offsetY > e.target.clientHeight)
209
+ )
210
+ return;
211
+
212
+ // If a connection is pending and the click is inside body (on a diff-line),
213
+ // don't start drag let the connection click handler handle it
214
+ if (
215
+ hasPendingConnection() &&
216
+ bodyEl &&
217
+ (e.target as HTMLElement).closest(".diff-line")
218
+ )
219
+ return;
220
+
221
+ e.stopPropagation();
222
+ startX = e.clientX;
223
+ startY = e.clientY;
224
+ action = "pending";
225
+
226
+ window.addEventListener("mousemove", onMouseMove);
227
+ window.addEventListener("mouseup", onMouseUp);
228
+ }
229
+
230
+ function onMouseMove(e) {
231
+ const state = ctx.snap().context;
232
+ const dx = (e.clientX - startX) / state.zoom;
233
+ const dy = (e.clientY - startY) / state.zoom;
234
+
235
+ if (action === "pending") {
236
+ const screenDist = Math.sqrt(
237
+ (e.clientX - startX) ** 2 + (e.clientY - startY) ** 2,
238
+ );
239
+ if (screenDist < DRAG_THRESHOLD) return;
240
+
241
+ action = "move";
242
+ card.style.cursor = "move";
243
+
244
+ const selected = ctx.snap().context.selectedCards;
245
+ const cardPath = card.dataset.path;
246
+ if (!selected.includes(cardPath)) {
247
+ if (!e.shiftKey && !e.ctrlKey) {
248
+ ctx.actor.send({ type: "SELECT_CARD", path: cardPath, shift: false });
249
+ } else {
250
+ ctx.actor.send({ type: "SELECT_CARD", path: cardPath, shift: true });
251
+ }
252
+ updateSelectionHighlights(ctx);
253
+ updateArrangeToolbar(ctx);
254
+ }
255
+
256
+ const nowSelected = ctx.snap().context.selectedCards;
257
+ moveStartPositions = [];
258
+ nowSelected.forEach((path) => {
259
+ const c = ctx.fileCards.get(path);
260
+ if (c) {
261
+ c.style.cursor = "grabbing";
262
+ moveStartPositions.push({
263
+ card: c,
264
+ path,
265
+ startLeft: parseInt(c.style.left) || 0,
266
+ startTop: parseInt(c.style.top) || 0,
267
+ });
268
+ }
269
+ });
270
+ }
271
+
272
+ if (action === "move") {
273
+ moveStartPositions.forEach((info) => {
274
+ info.card.style.left = `${info.startLeft + dx}px`;
275
+ info.card.style.top = `${info.startTop + dy}px`;
276
+ });
277
+ // Throttle expensive DOM updates to once per frame
278
+ if (!rafPending) {
279
+ rafPending = true;
280
+ requestAnimationFrame(() => {
281
+ rafPending = false;
282
+ scheduleRenderConnections(ctx);
283
+ updateMinimap(ctx);
284
+ });
285
+ }
286
+ return;
287
+ }
288
+ }
289
+
290
+ function onMouseUp(e) {
291
+ window.removeEventListener("mousemove", onMouseMove);
292
+ window.removeEventListener("mouseup", onMouseUp);
293
+
294
+ if (action === "pending") {
295
+ if (e.shiftKey || e.ctrlKey) {
296
+ ctx.actor.send({
297
+ type: "SELECT_CARD",
298
+ path: card.dataset.path,
299
+ shift: true,
300
+ });
301
+ } else {
302
+ ctx.actor.send({
303
+ type: "SELECT_CARD",
304
+ path: card.dataset.path,
305
+ shift: false,
306
+ });
307
+ }
308
+ updateSelectionHighlights(ctx);
309
+ updateArrangeToolbar(ctx);
310
+ } else if (action === "move") {
311
+ document.body.style.cursor = "";
312
+ card.style.cursor = "";
313
+ moveStartPositions.forEach((info) => {
314
+ info.card.style.cursor = "";
315
+ });
316
+ moveStartPositions.forEach((info) => {
317
+ const x = parseInt(info.card.style.left) || 0;
318
+ const y = parseInt(info.card.style.top) || 0;
319
+ savePosition(ctx, commitHash, info.path, x, y, undefined, undefined, true);
320
+ });
321
+ moveStartPositions = [];
322
+ // Force minimap rebuild so dot positions reflect the drag result
323
+ forceMinimapRebuild(ctx);
324
+ }
325
+
326
+ action = null;
327
+ }
328
+
329
+ card.addEventListener("mousedown", onMouseDown);
330
+
331
+ // ── Double-click to open in editor modal ──
332
+ card.addEventListener("dblclick", (e) => {
333
+ // Don't trigger on buttons
334
+ if (
335
+ (e.target as HTMLElement).tagName === "BUTTON" ||
336
+ (e.target as HTMLElement).closest("button")
337
+ )
338
+ return;
339
+ e.preventDefault();
340
+ e.stopPropagation();
341
+
342
+ const filePath = card.dataset.path;
343
+ if (filePath) {
344
+ const file = ctx.allFilesData?.find((f) => f.path === filePath) || {
345
+ path: filePath,
346
+ name: filePath.split("/").pop(),
347
+ lines: 0,
348
+ };
349
+ // Read visible line from canvas text renderer scroll position
350
+ const renderer = (card as any)._canvasTextRenderer;
351
+ const initialLine = renderer ? renderer.getVisibleLine?.() : undefined;
352
+ import("./file-modal").then(({ openFileModal }) =>
353
+ openFileModal(ctx, file, undefined, initialLine),
354
+ );
355
+ }
356
+ });
357
+
358
+ // ── Right-click context menu ──
359
+ card.addEventListener("contextmenu", (e) => {
360
+ e.preventDefault();
361
+ e.stopPropagation();
362
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
363
+ });
364
+ }
365
+
366
+ // ─── Context menu & file history (extracted to card-context-menu.tsx) ────
367
+ import { showCardContextMenu, showFileHistory } from "./card-context-menu";
368
+ export { showCardContextMenu, showFileHistory };
369
+
370
+ // ─── Arrangement functions (extracted to card-arrangement.ts) ────
371
+ import { arrangeRow, arrangeColumn, arrangeGrid } from "./card-arrangement";
372
+ export { arrangeRow, arrangeColumn, arrangeGrid };
373
+
374
+ // ─── Scroll debounce ────────────────────────────────────
375
+ export function debounceSaveScroll(
376
+ ctx: CanvasContext,
377
+ filePath: string,
378
+ scrollTop: number,
379
+ ) {
380
+ if (ctx.scrollTimers[filePath]) clearTimeout(ctx.scrollTimers[filePath]);
381
+ ctx.scrollTimers[filePath] = setTimeout(() => {
382
+ ctx.actor.send({ type: "SAVE_SCROLL", path: filePath, scrollTop });
383
+ savePosition(ctx, "scroll", filePath, scrollTop, 0);
384
+ }, 300);
385
+ }
386
+
387
+ // ─── JSX sub-components for file card content ───────────
388
+
389
+ function DiffLine({
390
+ type,
391
+ lineNum,
392
+ content,
393
+ }: {
394
+ type: string;
395
+ lineNum: number;
396
+ content: string;
397
+ }) {
398
+ return (
399
+ <span className={`diff-line diff-${type}`} data-line={lineNum}>
400
+ <span className="line-num">{String(lineNum).padStart(4, " ")}</span>
401
+ {content}
402
+ </span>
403
+ );
404
+ }
405
+
406
+ function DiffHunk({ hunk, hunkIdx }: { hunk: any; hunkIdx: number }) {
407
+ const header = `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@${hunk.context ? " " + hunk.context : ""}`;
408
+ let oldLine = hunk.oldStart;
409
+ let newLine = hunk.newStart;
410
+
411
+ const currentItems: { type: string; ln: number; content: string }[] = [];
412
+ const previousItems: { type: string; ln: number; content: string }[] = [];
413
+
414
+ hunk.lines.forEach((l: any) => {
415
+ if (l.type === "add") {
416
+ currentItems.push({ type: "add", ln: newLine++, content: l.content });
417
+ } else if (l.type === "del") {
418
+ previousItems.push({ type: "del", ln: oldLine++, content: l.content });
419
+ } else {
420
+ const curLn = newLine++;
421
+ const prevLn = oldLine++;
422
+ currentItems.push({ type: "ctx", ln: curLn, content: l.content });
423
+ previousItems.push({ type: "ctx", ln: prevLn, content: l.content });
424
+ }
425
+ });
426
+
427
+ const hasDeletions = previousItems.some((l) => l.type === "del");
428
+
429
+ function toggle(e: Event, view: string) {
430
+ e.stopPropagation();
431
+ const hunkEl = (e.target as HTMLElement).closest(".diff-hunk");
432
+ if (!hunkEl) return;
433
+ hunkEl
434
+ .querySelectorAll(".hunk-toggle-btn")
435
+ .forEach((b) => b.classList.remove("active"));
436
+ (e.target as HTMLElement).classList.add("active");
437
+ const cur = hunkEl.querySelector(".hunk-pane--current") as HTMLElement;
438
+ const prev = hunkEl.querySelector(".hunk-pane--previous") as HTMLElement;
439
+ if (cur) cur.style.display = view === "current" ? "" : "none";
440
+ if (prev) prev.style.display = view === "previous" ? "" : "none";
441
+ }
442
+
443
+ return (
444
+ <div className="diff-hunk">
445
+ <div className="diff-hunk-header">
446
+ <span className="hunk-range">{header}</span>
447
+ {hasDeletions ? (
448
+ <span className="hunk-view-toggle" data-hunk={hunkIdx}>
449
+ <button
450
+ className="hunk-toggle-btn active"
451
+ data-view="current"
452
+ onclick={(e) => toggle(e, "current")}
453
+ >
454
+ Current
455
+ </button>
456
+ <button
457
+ className="hunk-toggle-btn"
458
+ data-view="previous"
459
+ onclick={(e) => toggle(e, "previous")}
460
+ >
461
+ Previous
462
+ </button>
463
+ </span>
464
+ ) : null}
465
+ </div>
466
+ <div className="diff-hunk-body">
467
+ <div className="hunk-pane hunk-pane--current">
468
+ <pre>
469
+ <code>
470
+ {currentItems.map((l) => (
471
+ <DiffLine type={l.type} lineNum={l.ln} content={l.content} />
472
+ ))}
473
+ </code>
474
+ </pre>
475
+ </div>
476
+ <div className="hunk-pane hunk-pane--previous" style="display:none">
477
+ <pre>
478
+ <code>
479
+ {previousItems.map((l) => (
480
+ <DiffLine type={l.type} lineNum={l.ln} content={l.content} />
481
+ ))}
482
+ </code>
483
+ </pre>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ );
488
+ }
489
+
490
+ function FileCardContent({ file }: { file: any }) {
491
+ const ext = file.name.split(".").pop()?.toLowerCase() || "";
492
+ const IMAGE_EXTS = new Set([
493
+ "png",
494
+ "jpg",
495
+ "jpeg",
496
+ "gif",
497
+ "webp",
498
+ "svg",
499
+ "bmp",
500
+ "ico",
501
+ ]);
502
+ const isImage = IMAGE_EXTS.has(ext);
503
+
504
+ if (isImage) {
505
+ const repoPath = (window as any).__GITCANVAS_REPO_PATH__ || "";
506
+ return (
507
+ <div
508
+ className="file-content-preview file-image-preview"
509
+ style={{
510
+ display: "flex",
511
+ alignItems: "center",
512
+ justifyContent: "center",
513
+ height: "100%",
514
+ background: "var(--bg-card)",
515
+ overflow: "hidden",
516
+ }}
517
+ >
518
+ <img
519
+ src={`/api/repo/file-content?path=${encodeURIComponent(repoPath)}&file=${encodeURIComponent(file.path)}`}
520
+ alt={file.name}
521
+ style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
522
+ loading="lazy"
523
+ />
524
+ </div>
525
+ );
526
+ }
527
+
528
+ if (file.status === "added" && file.content) {
529
+ const lines = file.content.split("\n");
530
+ return (
531
+ <div className="file-content-preview">
532
+ <pre>
533
+ <code>
534
+ {lines.map((line, i) =>
535
+ !file.visibleLineIndices || file.visibleLineIndices.has(i) ? (
536
+ <DiffLine type="add" lineNum={i + 1} content={line} />
537
+ ) : null,
538
+ )}
539
+ </code>
540
+ </pre>
541
+ </div>
542
+ );
543
+ }
544
+ if (file.status === "deleted" && file.content) {
545
+ const lines = file.content.split("\n");
546
+ return (
547
+ <div className="file-content-preview">
548
+ <pre>
549
+ <code>
550
+ {lines.map((line, i) =>
551
+ !file.visibleLineIndices || file.visibleLineIndices.has(i) ? (
552
+ <DiffLine type="del" lineNum={i + 1} content={line} />
553
+ ) : null,
554
+ )}
555
+ </code>
556
+ </pre>
557
+ </div>
558
+ );
559
+ }
560
+ if (
561
+ (file.status === "modified" ||
562
+ file.status === "renamed" ||
563
+ file.status === "copied") &&
564
+ file.hunks?.length > 0
565
+ ) {
566
+ return (
567
+ <div className="file-content-preview">
568
+ {file.hunks.map((hunk, idx) => (
569
+ <DiffHunk hunk={hunk} hunkIdx={idx} />
570
+ ))}
571
+ </div>
572
+ );
573
+ }
574
+ if (
575
+ (file.status === "renamed" || file.status === "copied") &&
576
+ (!file.hunks || file.hunks.length === 0)
577
+ ) {
578
+ const simText = file.similarity ? ` (${file.similarity}% similar)` : "";
579
+ return (
580
+ <div className="file-content-preview">
581
+ <pre>
582
+ <code>
583
+ <span className="rename-notice">
584
+ {"File " + file.status + simText + "\nNo content changes"}
585
+ </span>
586
+ </code>
587
+ </pre>
588
+ </div>
589
+ );
590
+ }
591
+ const msg = file.contentError || "No changes to display";
592
+ return (
593
+ <div className="file-content-preview">
594
+ <pre>
595
+ <code>
596
+ <span className="error-notice">{msg}</span>
597
+ </code>
598
+ </pre>
599
+ </div>
600
+ );
601
+ }
602
+
603
+ const STATUS_COLORS: Record<string, string> = {
604
+ added: "#22c55e",
605
+ modified: "#eab308",
606
+ deleted: "#ef4444",
607
+ renamed: "#a78bfa",
608
+ copied: "#60a5fa",
609
+ };
610
+ const STATUS_LABELS: Record<string, string> = {
611
+ added: "+ ADDED",
612
+ modified: "~ MODIFIED",
613
+ deleted: "- DELETED",
614
+ renamed: "→ RENAMED",
615
+ copied: "⊕ COPIED",
616
+ };
617
+
618
+ function _handleChatClick(ctx: CanvasContext, file: any) {
619
+ const filePath = file.path;
620
+ const content = file.content || "";
621
+ const status = file.status || "";
622
+
623
+ let extraContext = "";
624
+
625
+ if (file.hunks && file.hunks.length > 0) {
626
+ extraContext += `\n--- DIFF SUMMARY ---\n`;
627
+ extraContext += file.hunks
628
+ .map(
629
+ (h: any) =>
630
+ `@@ -${h.oldStart},${h.oldCount} +${h.newStart},${h.newCount} @@\n` +
631
+ h.lines
632
+ .map(
633
+ (l: any) =>
634
+ `${l.type === "add" ? "+" : l.type === "del" ? "-" : " "} ${l.content}`,
635
+ )
636
+ .join("\n"),
637
+ )
638
+ .join("\n");
639
+ }
640
+
641
+ const connections = ctx.snap().context.connections;
642
+ const relatedLinks = connections.filter(
643
+ (c: any) => c.sourceFile === filePath || c.targetFile === filePath,
644
+ );
645
+
646
+ if (relatedLinks.length > 0) {
647
+ extraContext += `\n\n--- ARCHITECTURE CONNECTIONS ---\n`;
648
+ extraContext += `This file is logically connected to the following modules in the visual graph:\n`;
649
+ relatedLinks.forEach((c: any) => {
650
+ if (c.sourceFile === filePath) {
651
+ extraContext += `- Outbound dependency on \`${c.targetFile}\` (Lines ${c.sourceLineStart}-${c.sourceLineEnd} -> Lines ${c.targetLineStart}-${c.targetLineEnd}). Note: "${c.comment || "None"}"\n`;
652
+ } else {
653
+ extraContext += `- Inbound dependency from \`${c.sourceFile}\` (Lines ${c.sourceLineStart}-${c.sourceLineEnd} -> Lines ${c.targetLineStart}-${c.targetLineEnd}). Note: "${c.comment || "None"}"\n`;
654
+ }
655
+ });
656
+ }
657
+
658
+ const repoPath = ctx.snap().context.repoPath;
659
+ openFileChatInModal(repoPath, filePath, content, status, extraContext);
660
+ }
661
+
662
+ // ─── Create file card (commit diff) ─────────────────────
663
+ export function createFileCard(
664
+ ctx: CanvasContext,
665
+ file: any,
666
+ x: number,
667
+ y: number,
668
+ commitHash: string,
669
+ skipInteraction = false,
670
+ ): HTMLElement {
671
+ const card = document.createElement("div");
672
+ card.className = `file-card file-card--${file.status || "modified"}`;
673
+ card.style.left = `${x}px`;
674
+ card.style.top = `${y}px`;
675
+ card.dataset.path = file.path;
676
+
677
+ if (file.layerSections && file.layerSections.length > 0) {
678
+ if (file.content) {
679
+ const { visibleLineIndices } = filterFileContentByLayer(
680
+ file.content,
681
+ file.layerSections,
682
+ );
683
+ file.visibleLineIndices = visibleLineIndices;
684
+ }
685
+ if (file.hunks) {
686
+ // Very simplistic filtering for hunks
687
+ file.hunks = file.hunks.filter((h) => {
688
+ // 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.
689
+ // Keep all hunks for now if layers view, else users might miss diffs.
690
+ return true;
691
+ });
692
+ }
693
+ }
694
+
695
+ // Apply saved size
696
+ const posKey = getPositionKey(file.path, commitHash);
697
+ if (ctx.positions.has(posKey)) {
698
+ const pos = ctx.positions.get(posKey);
699
+ if (pos.width) card.style.width = `${pos.width}px`;
700
+ if (pos.height) {
701
+ card.style.height = `${pos.height}px`;
702
+ card.style.maxHeight = `${pos.height}px`;
703
+ }
704
+ }
705
+
706
+ const ext = file.name.split(".").pop().toLowerCase();
707
+ const iconClass = getFileIconClass(ext);
708
+ const statusColor = STATUS_COLORS[file.status] || "#a855f7";
709
+ const statusLabel =
710
+ STATUS_LABELS[file.status] || file.status?.toUpperCase() || "CHANGED";
711
+ const hunkCount = file.hunks?.length || 0;
712
+ const metaInfo =
713
+ hunkCount > 0
714
+ ? `${hunkCount} hunk${hunkCount > 1 ? "s" : ""}`
715
+ : `${file.lines || 0} lines`;
716
+
717
+ const iconSvg = getFileIcon(file.type, ext);
718
+
719
+ // Render JSX into card
720
+ render(
721
+ <>
722
+ <div
723
+ className="file-card-header"
724
+ style={`border-left: 4px solid ${statusColor}`}
725
+ >
726
+ <div
727
+ className={`file-icon ${iconClass}`}
728
+ dangerouslySetInnerHTML={{ __html: iconSvg }}
729
+ />
730
+ <span className="file-name">{file.name}</span>
731
+ <span
732
+ className="file-status"
733
+ style={`background: ${statusColor}20; color: ${statusColor}; font-size: 11px; padding: 2px 8px; border-radius: 4px; font-weight: 600;`}
734
+ >
735
+ {statusLabel}
736
+ </span>
737
+ <span style="font-size: 10px; color: var(--text-muted); margin-left: auto;">
738
+ {metaInfo}
739
+ </span>
740
+ </div>
741
+ <div className="file-card-body">
742
+ {file.oldPath ? (
743
+ <div className="file-rename-path">
744
+ {file.oldPath} → {file.path}
745
+ {file.similarity ? (
746
+ <span className="rename-similarity">{file.similarity}%</span>
747
+ ) : null}
748
+ </div>
749
+ ) : (
750
+ <div className="file-path">{file.path}</div>
751
+ )}
752
+ <FileCardContent file={file} />
753
+ </div>
754
+ </>,
755
+ card,
756
+ );
757
+
758
+ cardFileData.set(card, file);
759
+
760
+ // When managed by CardManager, skip legacy drag/resize/z-order setup
761
+ // but ALWAYS attach context menu, double-click, and click-to-select
762
+ if (!skipInteraction) {
763
+ setupCardInteraction(ctx, card, commitHash);
764
+ } else {
765
+ card.addEventListener("contextmenu", (e) => {
766
+ e.preventDefault();
767
+ e.stopPropagation();
768
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
769
+ });
770
+ card.addEventListener("dblclick", (e) => {
771
+ if (
772
+ (e.target as HTMLElement).tagName === "BUTTON" ||
773
+ (e.target as HTMLElement).closest("button")
774
+ )
775
+ return;
776
+ e.preventDefault();
777
+ e.stopPropagation();
778
+ const filePath = card.dataset.path;
779
+ if (filePath) {
780
+ const file = ctx.allFilesData?.find((f) => f.path === filePath) || {
781
+ path: filePath,
782
+ name: filePath.split("/").pop(),
783
+ lines: 0,
784
+ };
785
+ import("./file-modal").then(({ openFileModal }) =>
786
+ openFileModal(ctx, file),
787
+ );
788
+ }
789
+ });
790
+ // Smart selection: don't deselect others on mousedown if card is already selected
791
+ // This allows multi-drag to work. Deselection is deferred to mouseup (click without drag).
792
+ let _dragOccurred = false;
793
+ card.addEventListener("mousedown", (e) => {
794
+ if (e.button !== 0) return;
795
+ if (
796
+ (e.target as HTMLElement).closest("button") ||
797
+ (e.target as HTMLElement).closest(".connect-btn")
798
+ )
799
+ return;
800
+ const filePath = card.dataset.path || "";
801
+ const multi = e.shiftKey || e.ctrlKey;
802
+ const selected = ctx.snap().context.selectedCards;
803
+ const alreadySelected = selected.includes(filePath);
804
+ _dragOccurred = false;
805
+
806
+ if (multi) {
807
+ // Shift/Ctrl: toggle selection
808
+ ctx.actor.send({ type: "SELECT_CARD", path: filePath, shift: true });
809
+ try {
810
+ const { getCardManager } = require("./xydraw-bridge");
811
+ const cm = getCardManager();
812
+ if (cm) {
813
+ if (alreadySelected) cm.deselect(filePath);
814
+ else cm.select(filePath, true);
815
+ }
816
+ } catch {}
817
+ } else if (!alreadySelected) {
818
+ // Not selected yet → replace selection with this card
819
+ ctx.actor.send({ type: "SELECT_CARD", path: filePath, shift: false });
820
+ try {
821
+ const { getCardManager } = require("./xydraw-bridge");
822
+ const cm = getCardManager();
823
+ if (cm) cm.select(filePath, false);
824
+ } catch {}
825
+ }
826
+ // If already selected without shift → do nothing on mousedown (allow multi-drag)
827
+ // Deselection of others happens on mouseup below
828
+
829
+ updateSelectionHighlights(ctx);
830
+ updateArrangeToolbar(ctx);
831
+ });
832
+ card.addEventListener("mouseup", (e) => {
833
+ if (e.button !== 0) return;
834
+ if (
835
+ (e.target as HTMLElement).closest("button") ||
836
+ (e.target as HTMLElement).closest(".connect-btn")
837
+ )
838
+ return;
839
+ // Only deselect others if: no shift, card was already selected, and no drag happened
840
+ const filePath = card.dataset.path || "";
841
+ const multi = e.shiftKey || e.ctrlKey;
842
+ if (multi) return; // shift-click handled in mousedown
843
+ const selected = ctx.snap().context.selectedCards;
844
+ if (
845
+ selected.length > 1 &&
846
+ selected.includes(filePath) &&
847
+ !_dragOccurred
848
+ ) {
849
+ ctx.actor.send({ type: "SELECT_CARD", path: filePath, shift: false });
850
+ try {
851
+ const { getCardManager } = require("./xydraw-bridge");
852
+ const cm = getCardManager();
853
+ if (cm) cm.select(filePath, false);
854
+ } catch {}
855
+ updateSelectionHighlights(ctx);
856
+ updateArrangeToolbar(ctx);
857
+ }
858
+ });
859
+ // Track drag state from engine for mouseup deselection logic
860
+ card.addEventListener("mousemove", () => {
861
+ _dragOccurred = true;
862
+ });
863
+ }
864
+ setupConnectionDrag(ctx, card, file.path);
865
+
866
+ // Expand button → open modal
867
+ const expandBtn = card.querySelector(".expand-btn");
868
+ if (expandBtn) {
869
+ expandBtn.addEventListener("click", (e) => {
870
+ e.stopPropagation();
871
+ openFileModal(ctx, file);
872
+ });
873
+ }
874
+
875
+ // AI button → open chat
876
+ const aiBtn = card.querySelector(".ai-btn");
877
+ if (aiBtn) {
878
+ aiBtn.addEventListener("click", (e) => {
879
+ e.stopPropagation();
880
+ _handleChatClick(ctx, file);
881
+ });
882
+ }
883
+
884
+ // Scroll listener
885
+ const body = card.querySelector(".file-card-body");
886
+ if (body) {
887
+ body.addEventListener("scroll", () => {
888
+ scheduleRenderConnections(ctx);
889
+ });
890
+ }
891
+
892
+ // Listen for resize from indicator drag
893
+ card.addEventListener("card-resized", ((e: CustomEvent) => {
894
+ const { path: p, width: w, height: h } = e.detail;
895
+ const state = ctx.snap().context;
896
+ const ch = state.currentCommitHash || "allfiles";
897
+ ctx.actor.send({ type: "RESIZE_CARD", path: p, width: w, height: h });
898
+ savePosition(
899
+ ctx,
900
+ ch,
901
+ p,
902
+ parseInt(card.style.left) || 0,
903
+ parseInt(card.style.top) || 0,
904
+ w,
905
+ h,
906
+ );
907
+ renderConnections(ctx);
908
+ }) as EventListener);
909
+
910
+ return card;
911
+ }
912
+
913
+ // ─── Build file content HTML with optional line limiting ─
914
+ // When isExpanded=false, only render VISIBLE_LINE_LIMIT lines to keep DOM small.
915
+ // When isExpanded=true (F key), render all lines for full scrolling.
916
+ export function _buildFileContentHTML(
917
+ content: string,
918
+ layerSections: any,
919
+ addedLines: Set<number>,
920
+ deletedBeforeLine: Map<number, string[]>,
921
+ isAllAdded: boolean,
922
+ isAllDeleted: boolean,
923
+ isExpanded: boolean,
924
+ totalFileLines: number,
925
+ ): string {
926
+ const { filteredContent, visibleLineIndices } = filterFileContentByLayer(
927
+ content,
928
+ layerSections,
929
+ );
930
+ const lines = content.split("\n");
931
+ const totalVisible = Array.from(visibleLineIndices).length;
932
+ const limit = isExpanded ? Infinity : VISIBLE_LINE_LIMIT;
933
+ let code = "";
934
+ let renderedCount = 0;
935
+
936
+ for (let i = 0; i < lines.length; i++) {
937
+ if (!visibleLineIndices.has(i)) continue;
938
+ if (renderedCount >= limit) break;
939
+
940
+ const line = lines[i];
941
+ const lineNum = i + 1;
942
+ const lineClass = isAllAdded
943
+ ? "diff-add"
944
+ : isAllDeleted
945
+ ? "diff-del"
946
+ : addedLines.has(lineNum)
947
+ ? "diff-add"
948
+ : "diff-ctx";
949
+ const hasDel = deletedBeforeLine.has(lineNum);
950
+ const delCount = hasDel ? deletedBeforeLine.get(lineNum)!.length : 0;
951
+ const delAttr = hasDel ? ` data-del-count="${delCount}"` : "";
952
+ const delLines = hasDel
953
+ ? ` data-del-lines="${encodeURIComponent(JSON.stringify(deletedBeforeLine.get(lineNum)))}"`
954
+ : "";
955
+ 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`;
956
+ renderedCount++;
957
+ }
958
+
959
+ const hiddenCount = totalVisible - renderedCount;
960
+ // Invisible sentinel for IntersectionObserver auto-loading (no visible text)
961
+ const truncNote =
962
+ hiddenCount > 0
963
+ ? `<span class="more-lines" data-auto-expand="true" style="display:block;height:1px;"></span>`
964
+ : "";
965
+ return `<div class="file-content-preview"><pre><code>${code}</code></pre>${truncNote}</div>`;
966
+ }
967
+
968
+ // ─── Create all-file card (working tree) ────────────────
969
+ export function createAllFileCard(
970
+ ctx: CanvasContext,
971
+ file: any,
972
+ x: number,
973
+ y: number,
974
+ savedSize: any,
975
+ skipInteraction = false,
976
+ ): HTMLElement {
977
+ const card = document.createElement("div");
978
+ card.className = "file-card";
979
+ // Guard against NaN/undefined positions (corrupted position records)
980
+ const safeX = isNaN(x) ? 0 : x;
981
+ const safeY = isNaN(y) ? 0 : y;
982
+ card.style.left = `${safeX}px`;
983
+ card.style.top = `${safeY}px`;
984
+ card.dataset.path = file.path;
985
+
986
+ if (savedSize) {
987
+ card.style.width = `${savedSize.width}px`;
988
+ card.style.height = `${savedSize.height}px`;
989
+ card.style.maxHeight = `${savedSize.height}px`;
990
+ }
991
+
992
+ const ext = file.ext || "";
993
+ const iconClass = getFileIconClass(ext);
994
+ const addedLines: Set<number> = file.addedLines || new Set();
995
+ const isAllAdded = file.status === "added";
996
+ const isAllDeleted = file.status === "deleted";
997
+
998
+ const deletedBeforeLine: Map<number, string[]> =
999
+ file.deletedBeforeLine || new Map();
1000
+
1001
+ // All files are now same fixed size - no expand persistence
1002
+
1003
+ let contentHTML = "";
1004
+ let canvasOptions: any = null;
1005
+ const useAdvancedRenderer = ctx.textRendererMode === 'canvas' || ctx.textRendererMode === 'webgl';
1006
+
1007
+ const IMAGE_EXTS = new Set([
1008
+ "png",
1009
+ "jpg",
1010
+ "jpeg",
1011
+ "gif",
1012
+ "webp",
1013
+ "svg",
1014
+ "bmp",
1015
+ "ico",
1016
+ ]);
1017
+ const PDF_EXTS = new Set(["pdf"]);
1018
+ const isImage = IMAGE_EXTS.has(ext);
1019
+ const isPdf = PDF_EXTS.has(ext);
1020
+
1021
+ if (isImage) {
1022
+ contentHTML = `<div class="file-content-preview file-image-preview" style="display:flex;align-items:center;justify-content:center;height:100%;background:var(--bg-card);overflow:hidden;">
1023
+ <img src="/api/repo/file-content?path=${encodeURIComponent(ctx.snap().context.repoPath || "")}&file=${encodeURIComponent(file.path)}"
1024
+ alt="${escapeHtml(file.name)}"
1025
+ style="max-width:100%;max-height:100%;object-fit:contain;"
1026
+ loading="lazy" />
1027
+ </div>`;
1028
+ } else if (isPdf) {
1029
+ contentHTML = `<div class="file-content-preview file-image-preview" style="display:flex;align-items:center;justify-content:center;height:100%;background:var(--bg-card);overflow:hidden;">
1030
+ <img src="/api/repo/pdf-thumb?path=${encodeURIComponent(ctx.snap().context.repoPath || "")}&file=${encodeURIComponent(file.path)}"
1031
+ alt="${escapeHtml(file.name)}"
1032
+ style="max-width:100%;max-height:100%;object-fit:contain;"
1033
+ loading="lazy"
1034
+ onerror="this.parentElement.innerHTML='<pre><code><span class=\\'error-notice\\'>PDF preview unavailable</span></code></pre>'" />
1035
+ </div>`;
1036
+ } else if (file.isBinary) {
1037
+ contentHTML = `<div class="file-content-preview"><pre><code><span class="error-notice">Binary file</span></code></pre></div>`;
1038
+ } else if (file.content) {
1039
+ if (useAdvancedRenderer) {
1040
+ canvasOptions = {
1041
+ content: file.content,
1042
+ addedLines,
1043
+ deletedBeforeLine,
1044
+ isAllAdded,
1045
+ isAllDeleted,
1046
+ visibleLineIndices: filterFileContentByLayer(
1047
+ file.content,
1048
+ file.layerSections,
1049
+ ).visibleLineIndices,
1050
+ filePath: file.path,
1051
+ };
1052
+ contentHTML = `<div class="file-content-preview canvas-container" style="position:relative; height: 100%; overflow: auto; background: var(--bg-card);"></div>`;
1053
+ } else {
1054
+ contentHTML = _buildFileContentHTML(
1055
+ file.content,
1056
+ file.layerSections,
1057
+ addedLines,
1058
+ deletedBeforeLine,
1059
+ isAllAdded,
1060
+ isAllDeleted,
1061
+ false,
1062
+ file.lines,
1063
+ );
1064
+ }
1065
+ } else {
1066
+ contentHTML = `<div class="file-content-preview"><pre><code><span class="error-notice">Could not read file</span></code></pre></div>`;
1067
+ }
1068
+
1069
+ const dir = file.path.includes("/")
1070
+ ? file.path.split("/").slice(0, -1).join("/")
1071
+ : "";
1072
+
1073
+ // Status badge for changed files
1074
+ const statusColors: Record<string, string> = {
1075
+ added: "#22c55e",
1076
+ modified: "#eab308",
1077
+ deleted: "#ef4444",
1078
+ renamed: "#60a5fa",
1079
+ copied: "#a78bfa",
1080
+ };
1081
+ const statusBadge =
1082
+ file.status && file.status !== "unmodified"
1083
+ ? `<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>`
1084
+ : "";
1085
+ const metaInfo = file.status
1086
+ ? statusBadge
1087
+ : `<span style="font-size: 10px; color: var(--text-muted); margin-left: auto;">${file.lines} lines</span>`;
1088
+
1089
+ card.innerHTML = `
1090
+ <div class="file-card-header">
1091
+ <div class="file-icon ${iconClass}">
1092
+ ${getFileIcon(file.type, ext)}
1093
+ </div>
1094
+ <span class="file-name">${escapeHtml(file.name)}</span>
1095
+ ${metaInfo}
1096
+
1097
+ </div>
1098
+ <div class="file-card-body">
1099
+ <div class="file-path">${escapeHtml(dir)}</div>
1100
+ ${contentHTML}
1101
+ </div>
1102
+ `;
1103
+
1104
+ // Store file data for re-rendering on expand/collapse
1105
+ cardFileData.set(card, file);
1106
+
1107
+ setupConnectionDrag(ctx, card, file.path);
1108
+ // When managed by CardManager, skip legacy drag/resize/z-order setup
1109
+ // but ALWAYS attach context menu, double-click, and click-to-select
1110
+ if (!skipInteraction) {
1111
+ setupCardInteraction(ctx, card, "allfiles");
1112
+ } else {
1113
+ // Context menu (right-click)
1114
+ card.addEventListener("contextmenu", (e) => {
1115
+ e.preventDefault();
1116
+ e.stopPropagation();
1117
+ showCardContextMenu(ctx, card, e.clientX, e.clientY);
1118
+ });
1119
+ // Double-click to open in editor modal
1120
+ card.addEventListener("dblclick", (e) => {
1121
+ if (
1122
+ (e.target as HTMLElement).tagName === "BUTTON" ||
1123
+ (e.target as HTMLElement).closest("button")
1124
+ )
1125
+ return;
1126
+ e.preventDefault();
1127
+ e.stopPropagation();
1128
+ const filePath = card.dataset.path;
1129
+ if (filePath) {
1130
+ const file = ctx.allFilesData?.find((f) => f.path === filePath) || {
1131
+ path: filePath,
1132
+ name: filePath.split("/").pop(),
1133
+ lines: 0,
1134
+ };
1135
+ import("./file-modal").then(({ openFileModal }) =>
1136
+ openFileModal(ctx, file),
1137
+ );
1138
+ }
1139
+ });
1140
+ // Click to select (sync both XState and CardManager)
1141
+ card.addEventListener("mousedown", (e) => {
1142
+ if (e.button !== 0) return;
1143
+ if (
1144
+ (e.target as HTMLElement).closest("button") ||
1145
+ (e.target as HTMLElement).closest(".connect-btn")
1146
+ )
1147
+ return;
1148
+ const filePath = card.dataset.path || "";
1149
+ const multi = e.shiftKey || e.ctrlKey;
1150
+
1151
+ try {
1152
+ const { getCardManager } = require("./xydraw-bridge");
1153
+ const cm = getCardManager();
1154
+ if (cm) {
1155
+ const alreadySelected = cm.selected.has(filePath);
1156
+
1157
+ if (multi) {
1158
+ // Shift/Ctrl click: toggle selection
1159
+ if (alreadySelected) {
1160
+ cm.deselect(filePath);
1161
+ } else {
1162
+ cm.select(filePath, true);
1163
+ }
1164
+ ctx.actor.send({
1165
+ type: "SELECT_CARD",
1166
+ path: filePath,
1167
+ shift: true,
1168
+ });
1169
+ } else if (alreadySelected && cm.selected.size > 1) {
1170
+ // Clicking already-selected card in a multi-selection:
1171
+ // Don't deselect yet — user might be starting a multi-drag.
1172
+ // Deselect on mouseup if no drag occurred.
1173
+ let dragged = false;
1174
+ const onMove = () => {
1175
+ dragged = true;
1176
+ };
1177
+ const onUp = () => {
1178
+ window.removeEventListener("mousemove", onMove);
1179
+ window.removeEventListener("mouseup", onUp);
1180
+ if (!dragged) {
1181
+ // No drag happened — deselect all others
1182
+ cm.deselectAll();
1183
+ cm.select(filePath, false);
1184
+ ctx.actor.send({
1185
+ type: "SELECT_CARD",
1186
+ path: filePath,
1187
+ shift: false,
1188
+ });
1189
+ updateSelectionHighlights(ctx);
1190
+ updateArrangeToolbar(ctx);
1191
+ }
1192
+ };
1193
+ window.addEventListener("mousemove", onMove);
1194
+ window.addEventListener("mouseup", onUp);
1195
+ } else {
1196
+ // Normal click: deselect all, select this one
1197
+ cm.select(filePath, false);
1198
+ ctx.actor.send({
1199
+ type: "SELECT_CARD",
1200
+ path: filePath,
1201
+ shift: false,
1202
+ });
1203
+ }
1204
+ }
1205
+ } catch {}
1206
+ updateSelectionHighlights(ctx);
1207
+ updateArrangeToolbar(ctx);
1208
+ });
1209
+ }
1210
+
1211
+ if (canvasOptions) {
1212
+ const previewEl = card.querySelector(".canvas-container") as HTMLElement;
1213
+ if (previewEl) {
1214
+ const mode = ctx.textRendererMode || 'dom';
1215
+
1216
+ if (mode === 'webgl') {
1217
+ // Use WebGL renderer (Pixi.js)
1218
+ import("./webgl-text").then(({ WebGLTextRenderer }) => {
1219
+ try {
1220
+ const renderer = new WebGLTextRenderer(previewEl, canvasOptions);
1221
+ (card as any)._webglTextRenderer = renderer;
1222
+ } catch (e) {
1223
+ console.error('[cards] WebGL text renderer failed, falling back to canvas:', e);
1224
+ import("./canvas-text").then(({ CanvasTextRenderer }) => {
1225
+ const renderer = new CanvasTextRenderer(previewEl, canvasOptions);
1226
+ (card as any)._canvasTextRenderer = renderer;
1227
+ });
1228
+ }
1229
+ });
1230
+ } else if (mode === 'canvas') {
1231
+ // Use Canvas 2D renderer
1232
+ import("./canvas-text").then(({ CanvasTextRenderer }) => {
1233
+ const renderer = new CanvasTextRenderer(previewEl, canvasOptions);
1234
+ (card as any)._canvasTextRenderer = renderer;
1235
+ });
1236
+ }
1237
+ // else: DOM mode (default) - no special renderer needed
1238
+ }
1239
+ }
1240
+
1241
+ const expandBtn = card.querySelector(".expand-btn");
1242
+ if (expandBtn) {
1243
+ expandBtn.addEventListener("click", (e) => {
1244
+ e.stopPropagation();
1245
+ openFileModal(ctx, file);
1246
+ });
1247
+ }
1248
+
1249
+ // AI button → open chat
1250
+ const aiBtn = card.querySelector(".ai-btn");
1251
+ if (aiBtn) {
1252
+ aiBtn.addEventListener("click", (e) => {
1253
+ e.stopPropagation();
1254
+ _handleChatClick(ctx, file);
1255
+ });
1256
+ }
1257
+
1258
+ const body = card.querySelector(".file-card-body") as HTMLElement;
1259
+ if (body) {
1260
+ body.addEventListener("scroll", () => {
1261
+ debounceSaveScroll(ctx, file.path, body.scrollTop);
1262
+ scheduleRenderConnections(ctx);
1263
+ });
1264
+ }
1265
+
1266
+ // ── Auto-load truncated lines when scrolled into view ──
1267
+ const moreLinesEl = card.querySelector(
1268
+ ".more-lines[data-auto-expand]",
1269
+ ) as HTMLElement;
1270
+ if (moreLinesEl && file.content && !file.isBinary) {
1271
+ const pre = card.querySelector(".file-content-preview pre") as HTMLElement;
1272
+ if (pre) {
1273
+ const observer = new IntersectionObserver(
1274
+ (entries) => {
1275
+ for (const entry of entries) {
1276
+ if (entry.isIntersecting) {
1277
+ observer.disconnect();
1278
+ // Re-render with all lines (expanded)
1279
+ const newHTML = _buildFileContentHTML(
1280
+ file.content,
1281
+ file.layerSections,
1282
+ addedLines,
1283
+ deletedBeforeLine,
1284
+ isAllAdded,
1285
+ isAllDeleted,
1286
+ true,
1287
+ file.lines,
1288
+ );
1289
+ const preview = card.querySelector(".file-content-preview");
1290
+ if (preview) preview.outerHTML = newHTML;
1291
+ }
1292
+ }
1293
+ },
1294
+ { root: pre, rootMargin: "200px" },
1295
+ );
1296
+ observer.observe(moreLinesEl);
1297
+ }
1298
+ }
1299
+
1300
+ // ── Diff marker strip (scrollbar annotations for changed lines) ──
1301
+ // Skip when advanced renderer mode is active — renders its own gutter
1302
+ if (
1303
+ (addedLines.size > 0 || deletedBeforeLine.size > 0) &&
1304
+ !isAllAdded &&
1305
+ file.content &&
1306
+ !useAdvancedRenderer
1307
+ ) {
1308
+ const totalLines = file.content.split("\n").length;
1309
+ _buildDiffMarkerStrip(
1310
+ card,
1311
+ body,
1312
+ addedLines,
1313
+ totalLines,
1314
+ deletedBeforeLine,
1315
+ file.hunks,
1316
+ );
1317
+ }
1318
+
1319
+ // ── Deleted lines hover overlay ──
1320
+ if (deletedBeforeLine.size > 0) {
1321
+ _setupDeletedLinesOverlay(card);
1322
+ }
1323
+
1324
+ // Listen for resize from indicator drag
1325
+ card.addEventListener("card-resized", ((e: CustomEvent) => {
1326
+ const { path: p, width: w, height: h } = e.detail;
1327
+ const state = ctx.snap().context;
1328
+ const ch = state.currentCommitHash || "allfiles";
1329
+ ctx.actor.send({ type: "RESIZE_CARD", path: p, width: w, height: h });
1330
+ savePosition(
1331
+ ctx,
1332
+ ch,
1333
+ p,
1334
+ parseInt(card.style.left) || 0,
1335
+ parseInt(card.style.top) || 0,
1336
+ w,
1337
+ h,
1338
+ );
1339
+ renderConnections(ctx);
1340
+ }) as EventListener);
1341
+
1342
+ return card;
1343
+ }
1344
+
1345
+ // ─── File expand modal (extracted to file-modal.tsx) ─────
1346
+ import { openFileModal } from "./file-modal";
1347
+ export { openFileModal };
1348
+
1349
+ // ─── Diff markers & card expand (extracted modules) ─────
1350
+ export {
1351
+ buildDiffMarkerStrip,
1352
+ scrollToLine,
1353
+ setupDeletedLinesOverlay,
1354
+ } from "./card-diff-markers";
1355
+ export {
1356
+ changeCardsFontSize,
1357
+ toggleCardExpand,
1358
+ expandCardByPath,
1359
+ fitScreenSize,
1360
+ updateHiddenLinesIndicator,
1361
+ } from "./card-expand";