gitmaps 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. package/server.ts +62 -0
@@ -0,0 +1,1037 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Connections — click-on-line to connect, render SVG lines with labels,
4
+ * left-side marker strip, navigate.
5
+ *
6
+ * Flow:
7
+ * 1. User clicks a line number/gutter → starts pending connection (source)
8
+ * 2. All cards show a subtle "click a target line" visual hint (border glow)
9
+ * 3. User clicks a line in another card → completes connection
10
+ * 4. Connection markers appear on the LEFT side of both cards
11
+ * 5. SVG bezier curves with gradient & filename labels connect the two lines
12
+ * 6. No toasts — visual feedback only (glows, highlights, labels)
13
+ */
14
+ import { measure } from 'measure-fn';
15
+ import { render } from 'melina/client';
16
+ import type { CanvasContext } from './context';
17
+ import { escapeHtml } from './utils';
18
+ import { updateCanvasTransform, updateZoomUI } from './canvas';
19
+
20
+ // ─── Pending connection state ────────────────────────────
21
+ let pendingConnection: {
22
+ sourceFile: string;
23
+ sourceLine: number;
24
+ sourceCard: HTMLElement;
25
+ } | null = null;
26
+
27
+ // ─── rAF-coalesced render scheduler ─────────────────────
28
+ // Multiple callers (scroll, drag, resize) may trigger renderConnections
29
+ // in quick succession. This batches them into a single animation frame.
30
+ let _renderPending = false;
31
+ let _renderCtx: CanvasContext | null = null;
32
+
33
+ /** Schedule a connection re-render on the next animation frame. Coalesces rapid calls. */
34
+ export function scheduleRenderConnections(ctx: CanvasContext) {
35
+ _renderCtx = ctx;
36
+ if (_renderPending) return;
37
+ _renderPending = true;
38
+ requestAnimationFrame(() => {
39
+ _renderPending = false;
40
+ if (_renderCtx) renderConnections(_renderCtx);
41
+ });
42
+ }
43
+
44
+ // ─── Status indicator element ────────────────────────────
45
+ let statusIndicator: HTMLElement | null = null;
46
+
47
+ function _showStatus(text: string) {
48
+ if (!statusIndicator) {
49
+ statusIndicator = document.createElement('div');
50
+ statusIndicator.className = 'conn-status-indicator';
51
+ document.body.appendChild(statusIndicator);
52
+ }
53
+ statusIndicator.textContent = text;
54
+ statusIndicator.classList.add('visible');
55
+ }
56
+
57
+ function _hideStatus() {
58
+ if (statusIndicator) {
59
+ statusIndicator.classList.remove('visible');
60
+ }
61
+ }
62
+
63
+ // ─── Setup click-on-line connection for a card ──────────
64
+ export function setupLineClickConnection(ctx: CanvasContext, card: HTMLElement, filePath: string) {
65
+ const body = card.querySelector('.file-card-body') as HTMLElement;
66
+ if (!body) return;
67
+
68
+ body.addEventListener('click', (e) => {
69
+ const lineEl = (e.target as HTMLElement).closest('.diff-line') as HTMLElement;
70
+ if (!lineEl) return;
71
+
72
+ const lineNum = parseInt(lineEl.dataset.line);
73
+ if (!lineNum) return;
74
+
75
+ // If we're in "connecting" mode and clicking a line in a DIFFERENT card
76
+ if (pendingConnection && pendingConnection.sourceCard !== card) {
77
+ e.stopPropagation();
78
+ e.preventDefault();
79
+
80
+ // Complete the connection
81
+ const srcName = pendingConnection.sourceFile.split('/').pop();
82
+ const tgtName = filePath.split('/').pop();
83
+ const conn = {
84
+ id: `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
85
+ sourceFile: pendingConnection.sourceFile,
86
+ sourceLineStart: pendingConnection.sourceLine,
87
+ sourceLineEnd: pendingConnection.sourceLine,
88
+ targetFile: filePath,
89
+ targetLineStart: lineNum,
90
+ targetLineEnd: lineNum,
91
+ comment: `${srcName}:${pendingConnection.sourceLine} → ${tgtName}:${lineNum}`,
92
+ };
93
+
94
+ ctx.actor.send({
95
+ type: 'START_CONNECTION',
96
+ sourceFile: conn.sourceFile,
97
+ lineStart: conn.sourceLineStart,
98
+ lineEnd: conn.sourceLineEnd,
99
+ });
100
+ ctx.actor.send({
101
+ type: 'COMPLETE_CONNECTION',
102
+ targetFile: conn.targetFile,
103
+ lineStart: conn.targetLineStart,
104
+ lineEnd: conn.targetLineEnd,
105
+ comment: conn.comment,
106
+ });
107
+
108
+ // Clear pending
109
+ _clearPending(ctx);
110
+
111
+ renderConnections(ctx);
112
+ buildConnectionMarkers(ctx);
113
+ saveConnections(ctx);
114
+ return;
115
+ }
116
+
117
+ // If clicking same card and already pending → cancel
118
+ if (pendingConnection && pendingConnection.sourceCard === card) {
119
+ _clearPending(ctx);
120
+ return;
121
+ }
122
+
123
+ // Must hold Alt key to START a new connection, to prevent conflict with dragging
124
+ if (!e.altKey) return;
125
+
126
+ // Start a new pending connection
127
+ e.stopPropagation();
128
+ const fileName = filePath.split('/').pop();
129
+ pendingConnection = { sourceFile: filePath, sourceLine: lineNum, sourceCard: card };
130
+
131
+ // Visual: highlight the source line
132
+ lineEl.classList.add('connection-source-line');
133
+ card.classList.add('connecting-source');
134
+
135
+ // Show file picker to select target file
136
+ _showTargetFilePicker(ctx, filePath);
137
+ });
138
+ }
139
+
140
+ function _clearPending(ctx: CanvasContext) {
141
+ if (!pendingConnection) return;
142
+ // Remove visual highlights
143
+ pendingConnection.sourceCard.querySelector('.connection-source-line')?.classList.remove('connection-source-line');
144
+ pendingConnection.sourceCard.classList.remove('connecting-source');
145
+ ctx.fileCards.forEach((c) => c.classList.remove('connect-target-ready'));
146
+ pendingConnection = null;
147
+ _hideStatus();
148
+ }
149
+
150
+ // ─── Cancel pending connection (called from Escape key) ──
151
+ export function cancelPendingConnection(ctx: CanvasContext) {
152
+ if (pendingConnection) {
153
+ _clearPending(ctx);
154
+ }
155
+ }
156
+
157
+ export function hasPendingConnection(): boolean {
158
+ return pendingConnection !== null;
159
+ }
160
+
161
+ // ─── Target file picker for connections ─────────────────
162
+ function _showTargetFilePicker(ctx: CanvasContext, sourceFile: string) {
163
+ // Remove existing picker if any
164
+ document.getElementById('connFilePickerOverlay')?.remove();
165
+
166
+ const overlay = document.createElement('div');
167
+ overlay.id = 'connFilePickerOverlay';
168
+ overlay.className = 'file-search-overlay';
169
+ document.body.appendChild(overlay);
170
+
171
+ const container = document.createElement('div');
172
+ container.className = 'file-search-container';
173
+
174
+ const header = document.createElement('div');
175
+ header.className = 'conn-picker-header';
176
+ const srcName = sourceFile.split('/').pop();
177
+ const srcLine = pendingConnection?.sourceLine || '?';
178
+ header.innerHTML = `<span class="conn-picker-from">Connect from <strong>${escapeHtml(srcName!)}:${srcLine}</strong> → select target file:</span>`;
179
+ container.appendChild(header);
180
+
181
+ const input = document.createElement('input');
182
+ input.type = 'text';
183
+ input.className = 'file-search-input';
184
+ input.placeholder = 'Filter files...';
185
+ input.autocomplete = 'off';
186
+ container.appendChild(input);
187
+
188
+ const resultsContainer = document.createElement('div');
189
+ resultsContainer.className = 'file-search-results';
190
+ container.appendChild(resultsContainer);
191
+ overlay.appendChild(container);
192
+
193
+ // Get all file paths except source
194
+ const allPaths = Array.from(ctx.fileCards.keys()).filter(p => p !== sourceFile);
195
+ let currentQuery = '';
196
+ let selectedIdx = 0;
197
+
198
+ function getMatches() {
199
+ const q = currentQuery.toLowerCase().trim();
200
+ return q ? allPaths.filter(p => p.toLowerCase().includes(q)).slice(0, 15) : allPaths.slice(0, 15);
201
+ }
202
+
203
+ function selectFile(path: string) {
204
+ overlay.remove();
205
+ // Navigate to the target card
206
+ const targetCard = ctx.fileCards.get(path);
207
+ if (!targetCard) return;
208
+
209
+ // Scroll viewport to center on target card
210
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
211
+ const state = ctx.snap().context;
212
+ const cardX = parseFloat(targetCard.style.left) || 0;
213
+ const cardY = parseFloat(targetCard.style.top) || 0;
214
+ const newOffsetX = -(cardX + targetCard.offsetWidth / 2) * state.zoom + vpRect.width / 2;
215
+ const newOffsetY = -(cardY + targetCard.offsetHeight / 2) * state.zoom + vpRect.height / 2;
216
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
217
+ updateCanvasTransform(ctx);
218
+
219
+ // Highlight target card
220
+ targetCard.classList.add('connect-target-ready');
221
+ targetCard.classList.add('card-flash');
222
+ setTimeout(() => targetCard.classList.remove('card-flash'), 1500);
223
+
224
+ const tgtName = path.split('/').pop();
225
+ _showStatus(`Click a line in ${tgtName} to complete connection`);
226
+ }
227
+
228
+ function close() {
229
+ overlay.remove();
230
+ _clearPending(ctx);
231
+ }
232
+
233
+ function renderResults() {
234
+ const matches = getMatches();
235
+ const q = currentQuery.toLowerCase().trim();
236
+ if (matches.length === 0 && q) {
237
+ resultsContainer.innerHTML = `<div class="file-search-empty">No files matching "${escapeHtml(q)}"</div>`;
238
+ } else {
239
+ resultsContainer.innerHTML = matches.map((path, i) => {
240
+ const name = path.split('/').pop() || path;
241
+ const dir = path.substring(0, path.length - name.length);
242
+ return `<div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(path)}">
243
+ <span class="search-file-dir">${escapeHtml(dir)}</span><span class="search-file-name">${escapeHtml(name)}</span>
244
+ </div>`;
245
+ }).join('');
246
+ resultsContainer.querySelectorAll('.file-search-item').forEach(el => {
247
+ el.addEventListener('click', () => selectFile((el as HTMLElement).dataset.path!));
248
+ });
249
+ }
250
+ }
251
+
252
+ input.addEventListener('input', () => {
253
+ currentQuery = input.value;
254
+ selectedIdx = 0;
255
+ renderResults();
256
+ });
257
+ input.addEventListener('keydown', (e) => {
258
+ const matches = getMatches();
259
+ if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, matches.length - 1); renderResults(); }
260
+ else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); renderResults(); }
261
+ else if (e.key === 'Enter') { e.preventDefault(); if (matches[selectedIdx]) selectFile(matches[selectedIdx]); }
262
+ else if (e.key === 'Escape') { e.preventDefault(); close(); }
263
+ });
264
+ overlay.addEventListener('click', (e) => {
265
+ if ((e.target as HTMLElement) === overlay) close();
266
+ });
267
+
268
+ renderResults();
269
+ requestAnimationFrame(() => input.focus());
270
+ }
271
+
272
+ // ─── Build connection marker strips (LEFT side of cards) ─
273
+ export function buildConnectionMarkers(ctx: CanvasContext) {
274
+ const state = ctx.snap().context;
275
+ const connections = state.connections || [];
276
+
277
+ // First clean up all existing markers
278
+ ctx.fileCards.forEach((card) => {
279
+ card.querySelectorAll('.conn-marker-strip').forEach(el => el.remove());
280
+ });
281
+
282
+ if (connections.length === 0) return;
283
+
284
+ // Group connections by file
285
+ const connsByFile = new Map<string, Array<{ line: number; conn: any; role: 'source' | 'target' }>>();
286
+
287
+ connections.forEach(conn => {
288
+ // Source side
289
+ if (!connsByFile.has(conn.sourceFile)) connsByFile.set(conn.sourceFile, []);
290
+ connsByFile.get(conn.sourceFile)!.push({
291
+ line: conn.sourceLineStart,
292
+ conn,
293
+ role: 'source',
294
+ });
295
+
296
+ // Target side
297
+ if (!connsByFile.has(conn.targetFile)) connsByFile.set(conn.targetFile, []);
298
+ connsByFile.get(conn.targetFile)!.push({
299
+ line: conn.targetLineStart,
300
+ conn,
301
+ role: 'target',
302
+ });
303
+ });
304
+
305
+ // Build marker strip for each file card
306
+ connsByFile.forEach((markers, filePath) => {
307
+ const card = ctx.fileCards.get(filePath);
308
+ if (!card) return;
309
+
310
+ const body = card.querySelector('.file-card-body') as HTMLElement;
311
+ if (!body) return;
312
+
313
+ const pre = body.querySelector('pre') as HTMLElement;
314
+ if (!pre) return;
315
+
316
+ // Make the pre position:relative so absolute markers inside it scroll with content
317
+ if (getComputedStyle(pre).position === 'static') {
318
+ pre.style.position = 'relative';
319
+ }
320
+
321
+ const strip = document.createElement('div');
322
+ strip.className = 'conn-marker-strip';
323
+
324
+ markers.forEach(({ line, conn, role }) => {
325
+ const marker = document.createElement('div');
326
+ marker.className = `conn-marker conn-marker--${role}`;
327
+
328
+ // Try to find the actual line element and use its offsetTop
329
+ const lineEl = pre.querySelector(`.diff-line[data-line="${line}"]`) as HTMLElement;
330
+ if (lineEl) {
331
+ // Position marker at the line element's vertical position
332
+ marker.style.top = `${lineEl.offsetTop + lineEl.offsetHeight / 2}px`;
333
+ } else {
334
+ // Fallback: estimate from total lines
335
+ const totalLines = pre.querySelectorAll('.diff-line').length || 1;
336
+ const lineH = pre.scrollHeight / totalLines;
337
+ marker.style.top = `${(line - 1) * lineH + lineH / 2}px`;
338
+ }
339
+
340
+ const otherFile = role === 'source' ? conn.targetFile : conn.sourceFile;
341
+ const otherLine = role === 'source' ? conn.targetLineStart : conn.sourceLineStart;
342
+ const otherName = otherFile.split('/').pop();
343
+ marker.title = `${role === 'source' ? '→' : '←'} ${otherName}:${otherLine}`;
344
+
345
+ marker.addEventListener('click', (e) => {
346
+ e.stopPropagation();
347
+ navigateToConnection(ctx, conn, role === 'source' ? 'target' : 'source');
348
+ });
349
+
350
+ // Right-click to delete
351
+ marker.addEventListener('contextmenu', (e) => {
352
+ e.preventDefault();
353
+ e.stopPropagation();
354
+ deleteConnection(ctx, conn.id);
355
+ });
356
+
357
+ strip.appendChild(marker);
358
+ });
359
+
360
+ // Append strip INSIDE the pre element so it scrolls with content
361
+ pre.appendChild(strip);
362
+
363
+ // ── Connection navigation in the file-path bar ──
364
+ const filePathEl = body.querySelector('.file-path') as HTMLElement;
365
+ if (filePathEl && markers.length > 0) {
366
+ // Remove any previously injected connection nav
367
+ filePathEl.querySelectorAll('.conn-nav-inline').forEach(e => e.remove());
368
+
369
+ // Ensure file-path is flex (might have been set by diff nav already)
370
+ filePathEl.style.display = 'flex';
371
+ filePathEl.style.alignItems = 'center';
372
+ filePathEl.style.justifyContent = 'space-between';
373
+
374
+ // If the path text wasn't already wrapped in a span (by diff nav), wrap it
375
+ if (!filePathEl.querySelector('.file-path-text')) {
376
+ const existingText = filePathEl.childNodes[0];
377
+ if (existingText && existingText.nodeType === Node.TEXT_NODE) {
378
+ const pathSpan = document.createElement('span');
379
+ pathSpan.className = 'file-path-text';
380
+ pathSpan.textContent = existingText.textContent || '';
381
+ pathSpan.style.overflow = 'hidden';
382
+ pathSpan.style.textOverflow = 'ellipsis';
383
+ filePathEl.replaceChild(pathSpan, existingText);
384
+ }
385
+ }
386
+
387
+ let connIdx = -1;
388
+ const sorted = [...markers].sort((a, b) => a.line - b.line);
389
+
390
+ const connNav = document.createElement('span');
391
+ connNav.className = 'conn-nav-inline';
392
+ connNav.title = `${sorted.length} connection${sorted.length > 1 ? 's' : ''}`;
393
+
394
+ const connLabel = document.createElement('span');
395
+ connLabel.className = 'conn-nav-label';
396
+ connLabel.textContent = `🔗${sorted.length}`;
397
+
398
+ const connPrev = document.createElement('button');
399
+ connPrev.className = 'conn-nav-btn';
400
+ connPrev.textContent = '◀';
401
+ connPrev.title = 'Previous connection';
402
+ connPrev.addEventListener('click', (e) => {
403
+ e.stopPropagation();
404
+ if (connIdx <= 0) connIdx = sorted.length - 1;
405
+ else connIdx--;
406
+ const m = sorted[connIdx];
407
+ navigateToConnection(ctx, m.conn, m.role === 'source' ? 'target' : 'source');
408
+ connLabel.textContent = `🔗${connIdx + 1}/${sorted.length}`;
409
+ });
410
+
411
+ const connNext = document.createElement('button');
412
+ connNext.className = 'conn-nav-btn';
413
+ connNext.textContent = '▶';
414
+ connNext.title = 'Next connection';
415
+ connNext.addEventListener('click', (e) => {
416
+ e.stopPropagation();
417
+ if (connIdx >= sorted.length - 1) connIdx = 0;
418
+ else connIdx++;
419
+ const m = sorted[connIdx];
420
+ navigateToConnection(ctx, m.conn, m.role === 'source' ? 'target' : 'source');
421
+ connLabel.textContent = `🔗${connIdx + 1}/${sorted.length}`;
422
+ });
423
+
424
+ connNav.appendChild(connPrev);
425
+ connNav.appendChild(connLabel);
426
+ connNav.appendChild(connNext);
427
+ filePathEl.appendChild(connNav);
428
+ }
429
+ });
430
+ }
431
+
432
+ // ─── SVG defs (gradients, filters) ──────────────────────
433
+ function _ensureSvgDefs(svg: SVGSVGElement) {
434
+ if (svg.querySelector('defs#conn-defs')) return;
435
+
436
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
437
+ defs.id = 'conn-defs';
438
+
439
+ // Connection gradient
440
+ const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
441
+ grad.id = 'conn-gradient';
442
+ grad.setAttribute('gradientUnits', 'userSpaceOnUse');
443
+
444
+ const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
445
+ stop1.setAttribute('offset', '0%');
446
+ stop1.setAttribute('stop-color', '#a78bfa');
447
+ const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
448
+ stop2.setAttribute('offset', '100%');
449
+ stop2.setAttribute('stop-color', '#60a5fa');
450
+ grad.appendChild(stop1);
451
+ grad.appendChild(stop2);
452
+ defs.appendChild(grad);
453
+
454
+ // Glow filter
455
+ const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
456
+ filter.id = 'conn-glow';
457
+ filter.setAttribute('x', '-20%');
458
+ filter.setAttribute('y', '-20%');
459
+ filter.setAttribute('width', '140%');
460
+ filter.setAttribute('height', '140%');
461
+ const blur = document.createElementNS('http://www.w3.org/2000/svg', 'feGaussianBlur');
462
+ blur.setAttribute('stdDeviation', '3');
463
+ blur.setAttribute('result', 'glow');
464
+ const merge = document.createElementNS('http://www.w3.org/2000/svg', 'feMerge');
465
+ const mNode1 = document.createElementNS('http://www.w3.org/2000/svg', 'feMergeNode');
466
+ mNode1.setAttribute('in', 'glow');
467
+ const mNode2 = document.createElementNS('http://www.w3.org/2000/svg', 'feMergeNode');
468
+ mNode2.setAttribute('in', 'SourceGraphic');
469
+ merge.appendChild(mNode1);
470
+ merge.appendChild(mNode2);
471
+ filter.appendChild(blur);
472
+ filter.appendChild(merge);
473
+ defs.appendChild(filter);
474
+
475
+ svg.insertBefore(defs, svg.firstChild);
476
+ }
477
+
478
+ // ─── Zoom-aware LOD helpers ─────────────────────────────
479
+ function _getFileDir(filePath: string): string {
480
+ const parts = filePath.split('/');
481
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
482
+ }
483
+
484
+ function _isSameDirectory(fileA: string, fileB: string): boolean {
485
+ return _getFileDir(fileA) === _getFileDir(fileB);
486
+ }
487
+
488
+ // ─── Render all SVG connection lines ────────────────────
489
+ export function renderConnections(ctx: CanvasContext) {
490
+ if (!ctx.svgOverlay) return;
491
+ ctx.svgOverlay.innerHTML = '';
492
+
493
+ const state = ctx.snap().context;
494
+ const connections = state.connections || [];
495
+ if (connections.length === 0) return;
496
+
497
+ _ensureSvgDefs(ctx.svgOverlay);
498
+
499
+ // ── Zoom-aware LOD ──────────────────────────────────
500
+ // At low zoom, connections become visual noise. Apply progressive filtering:
501
+ // zoom < 0.35 → only inter-directory connections, very faded
502
+ // zoom 0.35–0.6 → all connections, same-dir connections fade in
503
+ // zoom > 0.6 → everything at full visibility
504
+ const zoom = state.zoom || 1;
505
+ const FADE_LOW = 0.35; // below this: only cross-dir connections
506
+ const FADE_HIGH = 0.6; // above this: everything fully visible
507
+ const showLabels = zoom > 0.5; // labels unreadable below 50% anyway
508
+
509
+ connections.forEach(conn => {
510
+ const sourceCard = ctx.fileCards.get(conn.sourceFile);
511
+ const targetCard = ctx.fileCards.get(conn.targetFile);
512
+ if (!sourceCard || !targetCard) return;
513
+
514
+ // LOD: filter same-directory connections at low zoom
515
+ const sameDir = _isSameDirectory(conn.sourceFile, conn.targetFile);
516
+ if (sameDir && zoom < FADE_LOW) return; // skip entirely
517
+
518
+ // Compute opacity factor for this connection
519
+ let opacityFactor = 1;
520
+ if (sameDir && zoom < FADE_HIGH) {
521
+ // Smoothly fade same-dir connections between FADE_LOW and FADE_HIGH
522
+ opacityFactor = (zoom - FADE_LOW) / (FADE_HIGH - FADE_LOW);
523
+ opacityFactor = Math.max(0.08, Math.min(1, opacityFactor));
524
+ }
525
+ // All connections get slightly reduced opacity at very low zoom
526
+ if (zoom < FADE_LOW) {
527
+ opacityFactor *= 0.5;
528
+ }
529
+
530
+ // Scale stroke width inversely so lines don't bloat when zoomed out
531
+ const strokeScale = zoom < 1 ? Math.max(0.6, zoom) : 1;
532
+
533
+ // Get canvas-space coordinates for the line endpoints
534
+ const startPt = _getLinePoint(sourceCard, conn.sourceLineStart, 'right');
535
+ const endPt = _getLinePoint(targetCard, conn.targetLineStart, 'left');
536
+
537
+ // Decide curve direction based on card positions
538
+ const goingRight = endPt.x >= startPt.x;
539
+ const dx = Math.abs(endPt.x - startPt.x);
540
+ const dy = Math.abs(endPt.y - startPt.y);
541
+ const ctrlOffset = Math.max(60, Math.min(dx * 0.4, 200));
542
+
543
+ // Smooth Bezier Routing (More robust, avoids overlapping vertical lines)
544
+ let d: string;
545
+ if (goingRight) {
546
+ d = `M ${startPt.x} ${startPt.y} C ${startPt.x + ctrlOffset} ${startPt.y}, ${endPt.x - ctrlOffset} ${endPt.y}, ${endPt.x} ${endPt.y}`;
547
+ } else {
548
+ // Cards overlap or wrong order — route elegantly above them using smooth curves
549
+ const arcHeight = Math.max(120, dy * 0.4);
550
+ const topY = Math.min(startPt.y, endPt.y) - arcHeight;
551
+ const midX = (startPt.x + endPt.x) / 2;
552
+ d = `M ${startPt.x} ${startPt.y} C ${startPt.x + ctrlOffset} ${startPt.y}, ${startPt.x + ctrlOffset} ${topY}, ${midX} ${topY} C ${endPt.x - ctrlOffset} ${topY}, ${endPt.x - ctrlOffset} ${endPt.y}, ${endPt.x} ${endPt.y}`;
553
+ }
554
+
555
+ // Connection group
556
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
557
+ group.classList.add('conn-group');
558
+ group.dataset.connId = conn.id;
559
+
560
+ // Glow path (underneath)
561
+ const glowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
562
+ glowPath.setAttribute('d', d);
563
+ glowPath.setAttribute('stroke', '#a78bfa');
564
+ glowPath.setAttribute('stroke-width', String(6 * strokeScale));
565
+ glowPath.setAttribute('fill', 'none');
566
+ glowPath.setAttribute('stroke-linejoin', 'round');
567
+ glowPath.setAttribute('opacity', '0');
568
+ glowPath.classList.add('conn-glow-path');
569
+ group.appendChild(glowPath);
570
+
571
+ // Main path
572
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
573
+ path.setAttribute('d', d);
574
+ path.setAttribute('stroke', 'url(#conn-gradient)');
575
+ path.setAttribute('stroke-width', String(2.5 * strokeScale));
576
+ path.setAttribute('stroke-linejoin', 'round');
577
+ path.setAttribute('fill', 'none');
578
+ path.setAttribute('opacity', String(0.25 * opacityFactor));
579
+ path.classList.add('conn-main-path');
580
+ path.style.pointerEvents = 'none';
581
+
582
+ // Animated dash — scale dash pattern with zoom
583
+ const dashScale = Math.max(0.5, strokeScale);
584
+ path.setAttribute('stroke-dasharray', `${8 * dashScale} ${4 * dashScale}`);
585
+
586
+ group.appendChild(path);
587
+
588
+ // Endpoint circles — scale radius with zoom
589
+ const endpointRadius = Math.max(3, 5 * strokeScale);
590
+ const srcCircle = _makeEndpoint(startPt.x, startPt.y, '#a78bfa', endpointRadius, opacityFactor);
591
+ const tgtCircle = _makeEndpoint(endPt.x, endPt.y, '#60a5fa', endpointRadius, opacityFactor);
592
+ group.appendChild(srcCircle);
593
+ group.appendChild(tgtCircle);
594
+
595
+ // Label badge at midpoint (skip at low zoom — unreadable)
596
+ if (showLabels) {
597
+ const midX = (startPt.x + endPt.x) / 2;
598
+ const midY = (startPt.y + endPt.y) / 2 - (goingRight ? 0 : ctrlOffset * 0.3);
599
+
600
+ const srcName = conn.sourceFile.split('/').pop() || '';
601
+ const tgtName = conn.targetFile.split('/').pop() || '';
602
+ const labelText = `${srcName}:${conn.sourceLineStart} → ${tgtName}:${conn.targetLineStart}`;
603
+
604
+ const { group: labelGroup, deleteGroup } = _makeLabel(midX, midY, labelText);
605
+ labelGroup.classList.add('conn-label');
606
+ group.appendChild(labelGroup);
607
+
608
+ // Delete button logic
609
+ deleteGroup.addEventListener('click', (e) => {
610
+ e.preventDefault();
611
+ e.stopPropagation();
612
+ deleteConnection(ctx, conn.id);
613
+ });
614
+ }
615
+
616
+ // Navigate on circle click
617
+ srcCircle.addEventListener('click', (e) => {
618
+ e.stopPropagation();
619
+ navigateToConnection(ctx, conn, 'source');
620
+ });
621
+ tgtCircle.addEventListener('click', (e) => {
622
+ e.stopPropagation();
623
+ navigateToConnection(ctx, conn, 'target');
624
+ });
625
+
626
+ // Right-click circles → delete
627
+ srcCircle.addEventListener('contextmenu', (e) => {
628
+ e.preventDefault();
629
+ e.stopPropagation();
630
+ deleteConnection(ctx, conn.id);
631
+ });
632
+ tgtCircle.addEventListener('contextmenu', (e) => {
633
+ e.preventDefault();
634
+ e.stopPropagation();
635
+ deleteConnection(ctx, conn.id);
636
+ });
637
+
638
+ ctx.svgOverlay.appendChild(group);
639
+ });
640
+ }
641
+
642
+ function _makeEndpoint(x: number, y: number, color: string, radius = 5, opacityFactor = 1): SVGCircleElement {
643
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
644
+ circle.setAttribute('cx', String(x));
645
+ circle.setAttribute('cy', String(y));
646
+ circle.setAttribute('r', String(radius));
647
+ circle.setAttribute('fill', color);
648
+ circle.setAttribute('stroke', 'rgba(0,0,0,0.5)');
649
+ circle.setAttribute('stroke-width', '1');
650
+ if (opacityFactor < 1) circle.setAttribute('opacity', String(opacityFactor));
651
+ circle.style.transition = 'r 0.15s ease';
652
+ circle.style.pointerEvents = 'none';
653
+ return circle;
654
+ }
655
+
656
+ function _makeLabel(x: number, y: number, text: string): { group: SVGGElement, deleteGroup: SVGGElement } {
657
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
658
+ group.style.opacity = '0.85';
659
+ group.style.transition = 'opacity 0.15s ease';
660
+ group.style.pointerEvents = 'none';
661
+
662
+ const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
663
+ textEl.setAttribute('x', String(x));
664
+ textEl.setAttribute('y', String(y));
665
+ textEl.setAttribute('text-anchor', 'middle');
666
+ textEl.setAttribute('alignment-baseline', 'middle');
667
+ textEl.setAttribute('fill', '#e0e0f0');
668
+ textEl.setAttribute('font-size', '10');
669
+ textEl.setAttribute('font-family', "'JetBrains Mono', 'Fira Code', monospace");
670
+ textEl.setAttribute('font-weight', '500');
671
+ textEl.textContent = text;
672
+
673
+ // Background rect — sized by text length estimate
674
+ const padding = 8;
675
+ const charW = 6.2;
676
+ const textW = text.length * charW;
677
+ const w = textW + padding * 2 + 20; // Extra 20px for the X button
678
+ const h = 20;
679
+
680
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
681
+ rect.setAttribute('x', String(x - w / 2));
682
+ rect.setAttribute('y', String(y - h / 2));
683
+ rect.setAttribute('width', String(w));
684
+ rect.setAttribute('height', String(h));
685
+ rect.setAttribute('rx', '6');
686
+ rect.setAttribute('fill', 'rgba(20, 15, 40, 0.92)');
687
+ rect.setAttribute('stroke', 'rgba(167, 139, 250, 0.3)');
688
+ rect.setAttribute('stroke-width', '1');
689
+
690
+ group.appendChild(rect);
691
+ group.appendChild(textEl);
692
+
693
+ // X / Delete button group
694
+ const deleteGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
695
+ const delX = x + textW / 2 + 6;
696
+
697
+ const delRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
698
+ delRect.setAttribute('x', String(delX));
699
+ delRect.setAttribute('y', String(y - h / 2 + 3));
700
+ delRect.setAttribute('width', '14');
701
+ delRect.setAttribute('height', '14');
702
+ delRect.setAttribute('rx', '3');
703
+ delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.15)');
704
+ delRect.style.cursor = 'pointer';
705
+
706
+ const delText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
707
+ delText.setAttribute('x', String(delX + 7)); // center of 14px rect
708
+ delText.setAttribute('y', String(y));
709
+ delText.setAttribute('text-anchor', 'middle');
710
+ delText.setAttribute('alignment-baseline', 'middle');
711
+ delText.setAttribute('fill', '#ef4444');
712
+ delText.setAttribute('font-size', '10');
713
+ delText.setAttribute('font-family', 'sans-serif');
714
+ delText.setAttribute('font-weight', 'bold');
715
+ delText.textContent = '×';
716
+ delText.style.pointerEvents = 'none';
717
+
718
+ deleteGroup.appendChild(delRect);
719
+ deleteGroup.appendChild(delText);
720
+
721
+ // Delete hover effect
722
+ deleteGroup.addEventListener('mouseenter', () => delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.4)'));
723
+ deleteGroup.addEventListener('mouseleave', () => delRect.setAttribute('fill', 'rgba(239, 68, 68, 0.15)'));
724
+
725
+ group.appendChild(deleteGroup);
726
+
727
+ return { group, deleteGroup };
728
+ }
729
+
730
+ function _estimatePathLength(p1: { x: number, y: number }, p2: { x: number, y: number }): number {
731
+ const dx = p2.x - p1.x;
732
+ const dy = p2.y - p1.y;
733
+ return Math.sqrt(dx * dx + dy * dy) * 1.3; // rough bezier estimate
734
+ }
735
+
736
+ function _getLinePoint(card: HTMLElement, lineNum: number, side: 'left' | 'right'): { x: number; y: number } {
737
+ const cardX = parseFloat(card.style.left) || 0;
738
+ const cardY = parseFloat(card.style.top) || 0;
739
+ const cardW = card.offsetWidth;
740
+ const cardH = card.offsetHeight;
741
+
742
+ // Try to find the specific line element
743
+ const lineEl = card.querySelector(`.diff-line[data-line="${lineNum}"]`) as HTMLElement;
744
+ const body = card.querySelector('.file-card-body') as HTMLElement;
745
+
746
+ if (lineEl && body) {
747
+ const pre = body.querySelector('pre') as HTMLElement;
748
+
749
+ // Line's position within the pre/body content
750
+ // offsetTop is relative to offsetParent (pre if position:relative, otherwise body)
751
+ const lineOffsetInContent = lineEl.offsetTop;
752
+
753
+ // How much the body has scrolled
754
+ const scrollTop = body.scrollTop;
755
+
756
+ // The body's top edge relative to the card
757
+ // (header height + any other elements above body)
758
+ const bodyTopInCard = body.offsetTop;
759
+
760
+ // The pre's top edge relative to body (accounts for file-path bar)
761
+ const preTopInBody = pre ? pre.offsetTop : 0;
762
+
763
+ // Final Y: card position + body offset + pre offset + line position - scroll + half line height
764
+ const y = cardY + bodyTopInCard + preTopInBody + lineOffsetInContent - scrollTop + lineEl.offsetHeight / 2;
765
+ const x = side === 'left' ? cardX : cardX + cardW;
766
+
767
+ // Clamp y to be within visible card bounds
768
+ return {
769
+ x,
770
+ y: Math.max(cardY + bodyTopInCard, Math.min(cardY + cardH - 5, y)),
771
+ };
772
+ }
773
+
774
+ // Fallback: estimate from line number
775
+ const totalLines = card.querySelectorAll('.diff-line').length || 100;
776
+ const pct = lineNum / totalLines;
777
+ const headerH = 36;
778
+ const bodyH = cardH - headerH;
779
+
780
+ return {
781
+ x: side === 'left' ? cardX : cardX + cardW,
782
+ y: cardY + headerH + pct * bodyH,
783
+ };
784
+ }
785
+
786
+ // ─── Navigate to connection endpoint ────────────────────
787
+ export function navigateToConnection(ctx: CanvasContext, conn: any, navigateTo: 'source' | 'target' = 'target') {
788
+ measure('connection:navigate', () => {
789
+ const file = navigateTo === 'target' ? conn.targetFile : conn.sourceFile;
790
+ const line = navigateTo === 'target' ? conn.targetLineStart : conn.sourceLineStart;
791
+ const targetCard = ctx.fileCards.get(file);
792
+ if (!targetCard) return;
793
+
794
+ // Pan canvas to center on the target card
795
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
796
+ const state = ctx.snap().context;
797
+ const cardX = parseFloat(targetCard.style.left) || 0;
798
+ const cardY = parseFloat(targetCard.style.top) || 0;
799
+ const newOffsetX = -(cardX + targetCard.offsetWidth / 2) * state.zoom + vpRect.width / 2;
800
+ const newOffsetY = -(cardY + targetCard.offsetHeight / 2) * state.zoom + vpRect.height / 2;
801
+
802
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
803
+ updateCanvasTransform(ctx);
804
+
805
+ // Scroll to target line inside the card
806
+ const body = targetCard.querySelector('.file-card-body') as HTMLElement;
807
+ const lineEl = targetCard.querySelector(`.diff-line[data-line="${line}"]`) as HTMLElement;
808
+ if (body && lineEl) {
809
+ const pre = body.querySelector('pre') as HTMLElement || body;
810
+ const preRect = pre.getBoundingClientRect();
811
+ const lineRect = lineEl.getBoundingClientRect();
812
+ const zoom = preRect.height / pre.clientHeight || 1;
813
+ pre.scrollTop += (lineRect.top - preRect.top) / zoom;
814
+ }
815
+
816
+ // Flash the target line
817
+ targetCard.querySelectorAll('.diff-line').forEach(l => {
818
+ const ln = parseInt((l as HTMLElement).dataset.line);
819
+ if (ln === line) {
820
+ l.classList.add('line-flash');
821
+ setTimeout(() => l.classList.remove('line-flash'), 1500);
822
+ }
823
+ });
824
+ });
825
+ }
826
+
827
+ // ─── Save connections to server ─────────────────────────
828
+ export async function saveConnections(ctx: CanvasContext) {
829
+ const state = ctx.snap().context;
830
+ try {
831
+ await fetch('/api/connections', {
832
+ method: 'POST',
833
+ headers: { 'Content-Type': 'application/json' },
834
+ body: JSON.stringify({ connections: state.connections })
835
+ });
836
+ } catch (e) {
837
+ measure('connections:saveError', () => e);
838
+ }
839
+ }
840
+
841
+ // ─── Load connections from server ───────────────────────
842
+ export async function loadConnections(ctx: CanvasContext) {
843
+ return measure('connections:load', async () => {
844
+ try {
845
+ const response = await fetch('/api/connections');
846
+ if (!response.ok) return;
847
+ const data = await response.json();
848
+
849
+ if (data.connections && data.connections.length > 0) {
850
+ const conns = data.connections.map(c => ({
851
+ id: c.conn_id,
852
+ sourceFile: c.source_file,
853
+ sourceLineStart: c.source_line_start,
854
+ sourceLineEnd: c.source_line_end,
855
+ targetFile: c.target_file,
856
+ targetLineStart: c.target_line_start,
857
+ targetLineEnd: c.target_line_end,
858
+ comment: c.comment || '',
859
+ }));
860
+
861
+ conns.forEach(conn => {
862
+ ctx.actor.send({
863
+ type: 'START_CONNECTION',
864
+ sourceFile: conn.sourceFile,
865
+ lineStart: conn.sourceLineStart,
866
+ lineEnd: conn.sourceLineEnd,
867
+ });
868
+ ctx.actor.send({
869
+ type: 'COMPLETE_CONNECTION',
870
+ targetFile: conn.targetFile,
871
+ lineStart: conn.targetLineStart,
872
+ lineEnd: conn.targetLineEnd,
873
+ comment: conn.comment,
874
+ });
875
+ });
876
+
877
+ renderConnections(ctx);
878
+ buildConnectionMarkers(ctx);
879
+ }
880
+ } catch (e) {
881
+ measure('connections:loadError', () => e);
882
+ }
883
+ });
884
+ }
885
+
886
+ // ─── Delete a connection ────────────────────────────────
887
+ export function deleteConnection(ctx: CanvasContext, connId: string) {
888
+ ctx.actor.send({ type: 'DELETE_CONNECTION', id: connId });
889
+ renderConnections(ctx);
890
+ buildConnectionMarkers(ctx);
891
+ saveConnections(ctx);
892
+ populateConnectionsList();
893
+ }
894
+
895
+ // ─── Populate connections UI list ───────────────────────
896
+ let _cachedCtxForConnections: CanvasContext | null = null;
897
+ export function populateConnectionsList(ctx?: CanvasContext) {
898
+ if (ctx) _cachedCtxForConnections = ctx;
899
+ const currentCtx = ctx || _cachedCtxForConnections;
900
+ if (!currentCtx) return;
901
+
902
+ const listEl = document.getElementById('connectionsList');
903
+ const panel = document.getElementById('connectionsPanel');
904
+ const countEl = document.getElementById('connCount');
905
+ if (!listEl || !panel) return;
906
+
907
+ const connections = currentCtx.snap().context.connections || [];
908
+ if (countEl) countEl.textContent = String(connections.length);
909
+
910
+ if (connections.length === 0) {
911
+ render(<div style="padding: 16px; text-align: center; color: var(--text-muted); font-size: 0.8rem;">No connections</div>, listEl);
912
+ return;
913
+ }
914
+
915
+ render(
916
+ <div style="display: flex; flex-direction: column; gap: 4px; padding: 8px;">
917
+ {connections.map(conn => (
918
+ <div key={conn.id} className="changed-file-item" style="justify-content: space-between;">
919
+ <div style="display: flex; flex-direction: column; flex: 1; overflow: hidden; margin-right: 8px;" onClick={() => navigateToConnection(currentCtx, conn, 'target')}>
920
+ <span style="font-size: 0.72rem; color: var(--accent-tertiary); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
921
+ {conn.sourceFile.split('/').pop()}:{conn.sourceLineStart} → {conn.targetFile.split('/').pop()}:{conn.targetLineStart}
922
+ </span>
923
+ </div>
924
+ <button className="btn-ghost btn-xs" style="color: var(--error);" onClick={(e) => { e.stopPropagation(); deleteConnection(currentCtx, conn.id); }} title="Delete Connection">
925
+
926
+ </button>
927
+ </div>
928
+ ))}
929
+ </div>,
930
+ listEl
931
+ );
932
+ }
933
+
934
+ // ─── Legacy compat: setupConnectionDrag (now no-op) ─────
935
+ export function setupConnectionDrag(ctx: CanvasContext, card: HTMLElement, filePath: string) {
936
+ // Replaced by setupLineClickConnection
937
+ setupLineClickConnection(ctx, card, filePath);
938
+ }
939
+
940
+ // ─── Start connection from context menu ─────────────────
941
+ export function startConnectionFrom(ctx: CanvasContext, filePath: string) {
942
+ const card = ctx.fileCards.get(filePath);
943
+ if (!card) return;
944
+
945
+ // Set pending connection from line 1 of the file
946
+ pendingConnection = { sourceFile: filePath, sourceLine: 1, sourceCard: card };
947
+ card.classList.add('connecting-source');
948
+
949
+ // Open the target file picker
950
+ _showTargetFilePicker(ctx, filePath);
951
+ }
952
+
953
+ // ─── Auto-detect import connections ─────────────────────
954
+ export async function autoDetectImports(ctx: CanvasContext) {
955
+ return measure('connections:autoDetect', async () => {
956
+ const state = ctx.snap().context;
957
+ const repoPath = state.repoPath;
958
+ const commit = state.currentCommitHash || 'HEAD';
959
+
960
+ if (!repoPath) {
961
+ _showStatus('No repo loaded');
962
+ setTimeout(_hideStatus, 2000);
963
+ return;
964
+ }
965
+
966
+ _showStatus('🔍 Scanning imports...');
967
+
968
+ try {
969
+ const res = await fetch('/api/repo/imports', {
970
+ method: 'POST',
971
+ headers: { 'Content-Type': 'application/json' },
972
+ body: JSON.stringify({ path: repoPath, commit }),
973
+ });
974
+
975
+ if (!res.ok) {
976
+ _showStatus('❌ Import scan failed');
977
+ setTimeout(_hideStatus, 2000);
978
+ return;
979
+ }
980
+
981
+ const data = await res.json();
982
+ const edges = data.edges || [];
983
+
984
+ if (edges.length === 0) {
985
+ _showStatus('No imports found');
986
+ setTimeout(_hideStatus, 2000);
987
+ return;
988
+ }
989
+
990
+ // Filter to edges where BOTH files are on the canvas
991
+ const canvasFiles = new Set(ctx.fileCards.keys());
992
+ const existingConnections = state.connections || [];
993
+ const existingKeys = new Set(
994
+ existingConnections.map(c => `${c.sourceFile}→${c.targetFile}`)
995
+ );
996
+
997
+ let added = 0;
998
+ for (const edge of edges) {
999
+ if (!canvasFiles.has(edge.source) || !canvasFiles.has(edge.target)) continue;
1000
+ if (existingKeys.has(`${edge.source}→${edge.target}`)) continue;
1001
+
1002
+ const srcName = edge.source.split('/').pop();
1003
+ const tgtName = edge.target.split('/').pop();
1004
+
1005
+ ctx.actor.send({
1006
+ type: 'START_CONNECTION',
1007
+ sourceFile: edge.source,
1008
+ lineStart: edge.line,
1009
+ lineEnd: edge.line,
1010
+ });
1011
+ ctx.actor.send({
1012
+ type: 'COMPLETE_CONNECTION',
1013
+ targetFile: edge.target,
1014
+ lineStart: 1,
1015
+ lineEnd: 1,
1016
+ comment: `${srcName} → ${tgtName}`,
1017
+ });
1018
+
1019
+ added++;
1020
+ }
1021
+
1022
+ if (added > 0) {
1023
+ renderConnections(ctx);
1024
+ buildConnectionMarkers(ctx);
1025
+ saveConnections(ctx);
1026
+ _showStatus(`✅ ${added} import connections added`);
1027
+ } else {
1028
+ _showStatus('All imports already connected');
1029
+ }
1030
+
1031
+ setTimeout(_hideStatus, 3000);
1032
+ } catch (err) {
1033
+ _showStatus('❌ Import scan error');
1034
+ setTimeout(_hideStatus, 2000);
1035
+ }
1036
+ });
1037
+ }