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,1747 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Canvas interaction setup + global event listeners.
4
+ *
5
+ * Ported faithfully from the original page.client.tsx monolith.
6
+ * Wheel behavior:
7
+ * Ctrl/Meta + scroll → zoom canvas (always, even over cards)
8
+ * Over scrollable hunk/preview → scroll that pane (Shift = horiz)
9
+ * Space held + scroll → pan canvas
10
+ * Plain scroll (no Space) → no-op
11
+ * Mouse:
12
+ * Space/middle-click/Alt+click → pan
13
+ * Left click on empty canvas → rectangle selection
14
+ * Shift+click → additive selection
15
+ * Keyboard:
16
+ * Space hold → pan mode
17
+ * H/V/G → arrange row/column/grid
18
+ * Ctrl+A → select all
19
+ * Escape → deselect + close modals
20
+ * Delete/Backspace → hide selected
21
+ */
22
+ import { measure } from 'measure-fn';
23
+ import { render } from 'melina/client';
24
+ import type { CanvasContext } from './context';
25
+ import { showToast, escapeHtml } from './utils';
26
+ import { createLayer, getActiveLayer, addSectionToLayer } from './layers';
27
+ import { updateCanvasTransform, updateZoomUI, updateMinimap, fitAllFiles, setupMinimapClick } from './canvas';
28
+ import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './galaxydraw-bridge';
29
+ import { hideSelectedFiles, showHiddenFilesModal as showHiddenModal } from './hidden-files';
30
+ import { updatePillSelectionHighlights } from './viewport-culling';
31
+ import { clearSelectionHighlights, updateSelectionHighlights, updateArrangeToolbar, arrangeRow, arrangeColumn, arrangeGrid, toggleCardExpand, fitScreenSize, changeCardsFontSize } from './cards';
32
+ import { loadRepository, rerenderCurrentView, selectCommit } from './repo';
33
+ import { toggleCanvasChat } from './chat';
34
+ import { exportCanvasAsPNG, exportViewportAsPNG } from './canvas-export';
35
+ import { cancelPendingConnection, hasPendingConnection } from './connections';
36
+ import { promptAddSection } from './layers';
37
+
38
+ // ─── Recent repos helper ────────────────────────────────
39
+ function _addRecentRepo(path: string) {
40
+ const key = 'gitcanvas:recentRepos';
41
+ const recent: string[] = JSON.parse(localStorage.getItem(key) || '[]');
42
+ // Remove if already exists, then prepend
43
+ const filtered = recent.filter(r => r !== path);
44
+ filtered.unshift(path);
45
+ // Keep max 10
46
+ localStorage.setItem(key, JSON.stringify(filtered.slice(0, 10)));
47
+ }
48
+
49
+ function _refreshRepoDropdown() {
50
+ const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
51
+ if (!repoSel) return;
52
+ const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
53
+ while (repoSel.options.length > 1) repoSel.remove(1);
54
+ updatedRepos.forEach(repo => {
55
+ const opt = document.createElement('option');
56
+ opt.value = repo;
57
+ opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
58
+ opt.title = repo;
59
+ repoSel.add(opt);
60
+ });
61
+ const newOpt = document.createElement('option');
62
+ newOpt.value = '__new__';
63
+ newOpt.textContent = '+ Open new repo...';
64
+ repoSel.add(newOpt);
65
+ }
66
+
67
+ // ─── Canvas interaction (pan/zoom/select) ───────────────
68
+ export function setupCanvasInteraction(ctx: CanvasContext) {
69
+ if (!ctx.canvasViewport) return;
70
+ measure('canvas:setupInteraction', () => {
71
+ let rafPendingPan = false;
72
+ let rafPendingSelect = false;
73
+
74
+ // Delta-based drag state — tracks last mouse position for panByDelta()
75
+ let lastDragX = 0;
76
+ let lastDragY = 0;
77
+
78
+ // ── Wheel behavior ──
79
+ ctx.canvasViewport.addEventListener('wheel', (e) => {
80
+ const state = ctx.snap().context;
81
+
82
+ // Ctrl+scroll = zoom (ALWAYS, even over file cards)
83
+ if (e.ctrlKey || e.metaKey) {
84
+ e.preventDefault();
85
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
86
+ zoomTowardScreen(ctx, e.clientX, e.clientY, factor);
87
+ updateCanvasTransform(ctx);
88
+ updateZoomUI(ctx);
89
+ return;
90
+ }
91
+
92
+ // Check if hovering over a scrollable pane
93
+ const target = e.target as HTMLElement;
94
+ const hunkPane = target.closest('.hunk-pane, .diff-hunk-body') as HTMLElement | null;
95
+ const previewPre = target.closest('.file-content-preview pre') as HTMLElement | null;
96
+ const cardBody = target.closest('.file-card-body') as HTMLElement | null;
97
+ const scrollContainer = hunkPane || previewPre || cardBody;
98
+
99
+ if (scrollContainer) {
100
+ // Always consume scroll events inside scrollable content
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+
104
+ if (e.shiftKey) {
105
+ // Shift+scroll = horizontal scroll within pane
106
+ scrollContainer.scrollLeft += e.deltaY;
107
+ } else {
108
+ // Plain scroll = vertical scroll within pane
109
+ scrollContainer.scrollTop += e.deltaY;
110
+ }
111
+ return;
112
+ }
113
+
114
+ // Canvas behavior when not over scrollable content
115
+ e.preventDefault();
116
+
117
+ // In simple mode: plain scroll = zoom (like WARMAPS)
118
+ if (ctx.controlMode === 'simple') {
119
+ const factor = e.deltaY > 0 ? 0.9 : 1.1;
120
+ zoomTowardScreen(ctx, e.clientX, e.clientY, factor);
121
+ updateCanvasTransform(ctx);
122
+ updateZoomUI(ctx);
123
+ return;
124
+ }
125
+
126
+ // Advanced mode: pan only when Space is held
127
+ if (!ctx.spaceHeld) {
128
+ return;
129
+ }
130
+
131
+ if (e.shiftKey) {
132
+ const panSpeed = 1.5;
133
+ panByDelta(ctx, -(e.deltaY * panSpeed), 0);
134
+ updateCanvasTransform(ctx);
135
+ updateMinimap(ctx);
136
+ } else {
137
+ const panSpeed = 1.5;
138
+ panByDelta(ctx, -(e.deltaX * panSpeed), -(e.deltaY * panSpeed));
139
+ updateCanvasTransform(ctx);
140
+ updateMinimap(ctx);
141
+ }
142
+ }, { passive: false });
143
+
144
+ // ── Selection rectangle state ──
145
+ let selectionRect: HTMLElement | null = null;
146
+ let selRectStartWorldX = 0, selRectStartWorldY = 0;
147
+ let isRectSelecting = false;
148
+
149
+ // ── Mousedown on viewport ──
150
+ ctx.canvasViewport.addEventListener('mousedown', (e) => {
151
+ // Space held, middle-click or Alt+click = pan (ALWAYS, both modes)
152
+ if (e.button === 1 || e.altKey || ctx.spaceHeld) {
153
+ ctx.isDragging = true;
154
+ lastDragX = e.clientX;
155
+ lastDragY = e.clientY;
156
+ ctx.canvasViewport.style.cursor = 'grabbing';
157
+ e.preventDefault();
158
+ e.stopPropagation();
159
+ return;
160
+ }
161
+
162
+ const insideCard = (e.target as HTMLElement).closest('.file-card') || (e.target as HTMLElement).closest('.file-pill');
163
+ if (insideCard) return;
164
+
165
+ // Left click on empty canvas — behavior depends on control mode
166
+ if (e.button === 0) {
167
+ if (ctx.controlMode === 'simple') {
168
+ // SIMPLE MODE: left-click on empty canvas = pan
169
+ ctx.isDragging = true;
170
+ lastDragX = e.clientX;
171
+ lastDragY = e.clientY;
172
+ ctx.canvasViewport.style.cursor = 'grabbing';
173
+ e.preventDefault();
174
+ } else {
175
+ // ADVANCED MODE: left-click on empty canvas = rect selection
176
+ if (!e.shiftKey) {
177
+ ctx.actor.send({ type: 'DESELECT_ALL' });
178
+ clearSelectionHighlights(ctx);
179
+ }
180
+
181
+ isRectSelecting = true;
182
+ const world = screenToWorld(ctx, e.clientX, e.clientY);
183
+ selRectStartWorldX = world.x;
184
+ selRectStartWorldY = world.y;
185
+
186
+ selectionRect = document.createElement('div');
187
+ selectionRect.className = 'selection-rect';
188
+ selectionRect.style.left = `${selRectStartWorldX}px`;
189
+ selectionRect.style.top = `${selRectStartWorldY}px`;
190
+ selectionRect.style.width = '0px';
191
+ selectionRect.style.height = '0px';
192
+ ctx.canvas.appendChild(selectionRect);
193
+ ctx.canvasViewport.style.cursor = 'crosshair';
194
+ }
195
+ }
196
+ });
197
+
198
+ // ── Global mousemove (pan + rect select) ──
199
+ window.addEventListener('mousemove', (e) => {
200
+ if (ctx.isDragging) {
201
+ // Delta-based pan via galaxydraw engine
202
+ const dx = e.clientX - lastDragX;
203
+ const dy = e.clientY - lastDragY;
204
+ lastDragX = e.clientX;
205
+ lastDragY = e.clientY;
206
+
207
+ panByDelta(ctx, dx, dy);
208
+
209
+ // Throttle transform + minimap to one frame
210
+ if (!rafPendingPan) {
211
+ rafPendingPan = true;
212
+ requestAnimationFrame(() => {
213
+ rafPendingPan = false;
214
+ updateCanvasTransform(ctx);
215
+ });
216
+ }
217
+ return;
218
+ }
219
+
220
+ if (isRectSelecting && selectionRect) {
221
+ const world = screenToWorld(ctx, e.clientX, e.clientY);
222
+ const worldX = world.x;
223
+ const worldY = world.y;
224
+
225
+ const rx = Math.min(selRectStartWorldX, worldX);
226
+ const ry = Math.min(selRectStartWorldY, worldY);
227
+ const rw = Math.abs(worldX - selRectStartWorldX);
228
+ const rh = Math.abs(worldY - selRectStartWorldY);
229
+
230
+ selectionRect.style.left = `${rx}px`;
231
+ selectionRect.style.top = `${ry}px`;
232
+ selectionRect.style.width = `${rw}px`;
233
+ selectionRect.style.height = `${rh}px`;
234
+
235
+ // Throttle live-highlight to one per frame
236
+ if (!rafPendingSelect) {
237
+ rafPendingSelect = true;
238
+ requestAnimationFrame(() => {
239
+ rafPendingSelect = false;
240
+ // Highlight DOM cards
241
+ ctx.fileCards.forEach((card, path) => {
242
+ const cx = parseFloat(card.style.left) || 0;
243
+ const cy = parseFloat(card.style.top) || 0;
244
+ const cw = card.offsetWidth || 580;
245
+ const ch = card.offsetHeight || 200;
246
+ const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
247
+ card.classList.toggle('selected', overlaps);
248
+ });
249
+ // Also highlight pill cards (zoomed out)
250
+ const pillEls = ctx.canvas?.querySelectorAll('.file-pill') as NodeListOf<HTMLElement>;
251
+ if (pillEls) {
252
+ pillEls.forEach(pill => {
253
+ const cx = parseFloat(pill.style.left) || 0;
254
+ const cy = parseFloat(pill.style.top) || 0;
255
+ const cw = parseFloat(pill.style.width) || 580;
256
+ const ch = parseFloat(pill.style.height) || 700;
257
+ const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
258
+ if (overlaps) {
259
+ pill.style.outline = '8px solid rgba(124, 58, 237, 1)';
260
+ pill.style.outlineOffset = '6px';
261
+ pill.style.filter = 'brightness(1.3)';
262
+ } else {
263
+ pill.style.outline = '';
264
+ pill.style.outlineOffset = '';
265
+ pill.style.filter = '';
266
+ }
267
+ });
268
+ }
269
+ });
270
+ }
271
+ }
272
+ });
273
+
274
+ // ── Global mouseup (pan + rect select) ──
275
+ window.addEventListener('mouseup', (e) => {
276
+ if (ctx.isDragging) {
277
+ ctx.isDragging = false;
278
+ ctx.canvasViewport.style.cursor = '';
279
+ return;
280
+ }
281
+
282
+ if (isRectSelecting) {
283
+ isRectSelecting = false;
284
+ ctx.canvasViewport.style.cursor = '';
285
+
286
+ if (selectionRect) {
287
+ const rx = parseFloat(selectionRect.style.left);
288
+ const ry = parseFloat(selectionRect.style.top);
289
+ const rw = parseFloat(selectionRect.style.width);
290
+ const rh = parseFloat(selectionRect.style.height);
291
+
292
+ const selected: string[] = [];
293
+ // Check materialized DOM cards
294
+ ctx.fileCards.forEach((card, path) => {
295
+ const cx = parseFloat(card.style.left) || 0;
296
+ const cy = parseFloat(card.style.top) || 0;
297
+ const cw = card.offsetWidth || 580;
298
+ const ch = card.offsetHeight || 200;
299
+
300
+ const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
301
+ if (overlaps) selected.push(path);
302
+ });
303
+
304
+ // Also check deferred cards (pill mode / zoomed out)
305
+ if (ctx.deferredCards) {
306
+ for (const [path, entry] of ctx.deferredCards) {
307
+ if (selected.includes(path)) continue;
308
+ const { x: cx, y: cy, size } = entry;
309
+ const cw = size?.width || 580;
310
+ const ch = size?.height || 700;
311
+ const overlaps = cx + cw > rx && cx < rx + rw && cy + ch > ry && cy < ry + rh;
312
+ if (overlaps) selected.push(path);
313
+ }
314
+ }
315
+
316
+ if (selected.length > 0) {
317
+ selected.forEach((path, i) => {
318
+ ctx.actor.send({ type: 'SELECT_CARD', path, shift: i > 0 || e.shiftKey });
319
+ });
320
+ } else if (!e.shiftKey) {
321
+ ctx.actor.send({ type: 'DESELECT_ALL' });
322
+ }
323
+
324
+ updatePillSelectionHighlights(ctx);
325
+ updateArrangeToolbar(ctx);
326
+
327
+ selectionRect.remove();
328
+ selectionRect = null;
329
+ }
330
+ }
331
+ });
332
+ });
333
+ }
334
+
335
+ // ─── Paste repo path from clipboard ─────────────────────
336
+ async function pasteRepoPath(ctx: CanvasContext) {
337
+ return measure('repo:paste', async () => {
338
+ try {
339
+ const text = await navigator.clipboard.readText();
340
+ if (text && text.trim()) {
341
+ const input = document.getElementById('repoPath') as HTMLInputElement;
342
+ input.value = text.trim();
343
+ input.focus();
344
+ showToast('Pasted from clipboard', 'info');
345
+ } else {
346
+ showToast('Clipboard is empty — type or paste a repo path', 'info');
347
+ }
348
+ } catch (err) {
349
+ measure('repo:pasteError', () => err);
350
+ showToast('Paste failed — type the path manually', 'error');
351
+ }
352
+ });
353
+ }
354
+
355
+ // ─── Preview modal close ────────────────────────────────
356
+ function closePreview() {
357
+ const modal = document.getElementById('filePreviewModal');
358
+ if (modal) modal.classList.remove('active');
359
+ }
360
+
361
+ // ─── Changed files panel setup ──────────────────────────
362
+ function setupChangedFilesPanel() {
363
+ measure('panel:setupChangedFiles', () => {
364
+ const toggleBtn = document.getElementById('toggleChangedFiles');
365
+ const panel = document.getElementById('changedFilesPanel');
366
+ const closeBtn = document.getElementById('closeChangedFiles');
367
+
368
+ // Restore persisted state — default to open so changed files appear on commit select
369
+ if (panel) {
370
+ const wasClosed = localStorage.getItem('gitcanvas:changedFilesPanelClosed');
371
+ // Default open unless explicitly closed by user
372
+ panel.dataset.manuallyClosed = wasClosed === 'true' ? 'true' : 'false';
373
+ panel.style.display = 'none';
374
+ }
375
+
376
+ if (toggleBtn && panel) {
377
+ toggleBtn.addEventListener('click', () => {
378
+ const isVisible = panel.style.display !== 'none';
379
+ panel.style.display = isVisible ? 'none' : 'flex';
380
+ panel.dataset.manuallyClosed = isVisible ? 'true' : 'false';
381
+ localStorage.setItem('gitcanvas:changedFilesPanelClosed', isVisible ? 'true' : 'false');
382
+ });
383
+ }
384
+
385
+ if (closeBtn && panel) {
386
+ closeBtn.addEventListener('click', () => {
387
+ panel.style.display = 'none';
388
+ panel.dataset.manuallyClosed = 'true';
389
+ localStorage.setItem('gitcanvas:changedFilesPanelClosed', 'true');
390
+ });
391
+ }
392
+ });
393
+ }
394
+
395
+ function setupConnectionsPanel(ctx: CanvasContext) {
396
+ measure('panel:setupConnections', () => {
397
+ const toggleBtn = document.getElementById('toggleConnectionsPanel');
398
+ const panel = document.getElementById('connectionsPanel');
399
+ const closeBtn = document.getElementById('closeConnectionsPanel');
400
+
401
+ if (toggleBtn && panel) {
402
+ toggleBtn.addEventListener('click', () => {
403
+ const isVisible = panel.style.display !== 'none';
404
+ panel.style.display = isVisible ? 'none' : 'flex';
405
+ if (!isVisible) {
406
+ import('./connections').then(m => m.populateConnectionsList(ctx));
407
+ }
408
+ });
409
+ }
410
+ if (closeBtn && panel) {
411
+ closeBtn.addEventListener('click', () => panel.style.display = 'none');
412
+ }
413
+ });
414
+ }
415
+
416
+ // ─── Global event listeners ─────────────────────────────
417
+ export function setupEventListeners(ctx: CanvasContext) {
418
+ measure('events:setup', () => {
419
+ setupChangedFilesPanel();
420
+ setupConnectionsPanel(ctx);
421
+
422
+ // Text rendering mode toggle (Canvas vs DOM)
423
+ const textToggle = document.getElementById('toggleCanvasText');
424
+ if (textToggle) {
425
+ ctx.useCanvasText = localStorage.getItem('gitcanvas:useCanvasText') !== 'false';
426
+ textToggle.classList.toggle('active', ctx.useCanvasText);
427
+ textToggle.addEventListener('click', () => {
428
+ ctx.useCanvasText = !ctx.useCanvasText;
429
+ localStorage.setItem('gitcanvas:useCanvasText', String(ctx.useCanvasText));
430
+ textToggle.classList.toggle('active', ctx.useCanvasText);
431
+
432
+ // Re-render currently visible cards
433
+ rerenderCurrentView(ctx);
434
+ });
435
+ }
436
+
437
+ // Control mode toggle (Simple vs Advanced)
438
+ const modeToggle = document.getElementById('toggleControlMode');
439
+ if (modeToggle) {
440
+ // Set initial icon based on stored mode
441
+ const updateModeIcon = () => {
442
+ const icon = document.getElementById('controlModeIcon');
443
+ if (!icon) return;
444
+ if (ctx.controlMode === 'simple') {
445
+ // Hand icon for simple mode
446
+ icon.innerHTML = '<path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1M14 7V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v3M10 7V5a2 2 0 0 0-2-2a2 2 0 0 0-2 2v5M6 10V8a2 2 0 0 0-2-2a2 2 0 0 0-2 2v7a7 7 0 0 0 7 7h3a7 7 0 0 0 7-7v-3a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/>';
447
+ modeToggle.classList.add('active');
448
+ modeToggle.title = 'Simple mode (drag = pan). Click to switch to Advanced.';
449
+ } else {
450
+ // Crosshair icon for advanced mode
451
+ icon.innerHTML = '<circle cx="12" cy="12" r="10"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>';
452
+ modeToggle.classList.remove('active');
453
+ modeToggle.title = 'Advanced mode (space+drag = pan). Click to switch to Simple.';
454
+ }
455
+ };
456
+ updateModeIcon();
457
+
458
+ modeToggle.addEventListener('click', () => {
459
+ ctx.controlMode = ctx.controlMode === 'simple' ? 'advanced' : 'simple';
460
+ localStorage.setItem('gitcanvas:controlMode', ctx.controlMode);
461
+ updateModeIcon();
462
+
463
+ // Update cursor
464
+ if (ctx.canvasViewport) {
465
+ ctx.canvasViewport.style.cursor = '';
466
+ }
467
+
468
+ // Show toast
469
+ import('./utils').then(m => {
470
+ m.showToast(
471
+ ctx.controlMode === 'simple'
472
+ ? 'Simple mode: Drag to pan, scroll to zoom'
473
+ : 'Advanced mode: Space+drag to pan, Ctrl+scroll to zoom',
474
+ 'info'
475
+ );
476
+ });
477
+ });
478
+ }
479
+
480
+ // Connections visibility toggle
481
+ const connToggle = document.getElementById('toggleConnections');
482
+ if (connToggle) {
483
+ // Default OFF — connections are distracting on first load
484
+ let connectionsVisible = localStorage.getItem('gitcanvas:connectionsVisible') === 'true';
485
+ const svg = document.getElementById('connectionsOverlay') as HTMLElement;
486
+ if (svg && !connectionsVisible) svg.style.display = 'none';
487
+ connToggle.classList.toggle('active', connectionsVisible);
488
+ connToggle.addEventListener('click', () => {
489
+ connectionsVisible = !connectionsVisible;
490
+ if (svg) svg.style.display = connectionsVisible ? '' : 'none';
491
+ // Also toggle marker strips on cards
492
+ document.querySelectorAll('.connection-markers').forEach(el => {
493
+ (el as HTMLElement).style.display = connectionsVisible ? '' : 'none';
494
+ });
495
+ connToggle.classList.toggle('active', connectionsVisible);
496
+ connToggle.title = connectionsVisible ? 'Hide connection lines' : 'Show connection lines';
497
+ localStorage.setItem('gitcanvas:connectionsVisible', String(connectionsVisible));
498
+ });
499
+ }
500
+
501
+ // Auto-detect imports button
502
+ const autoImportsBtn = document.getElementById('autoDetectImports');
503
+ if (autoImportsBtn) {
504
+ autoImportsBtn.addEventListener('click', () => {
505
+ import('./connections').then(m => m.autoDetectImports(ctx));
506
+ });
507
+ // Show button when repo is loaded (observer or direct check)
508
+ const showIfRepo = () => {
509
+ const state = ctx.snap().context;
510
+ if (state.repoPath) autoImportsBtn.style.display = '';
511
+ };
512
+ // Check periodically until repo loads
513
+ const checkInterval = setInterval(() => {
514
+ showIfRepo();
515
+ if (ctx.snap().context.repoPath) clearInterval(checkInterval);
516
+ }, 2000);
517
+ }
518
+
519
+ // Dependency graph toggle (button is in layout.tsx toolbar)
520
+ const depBtn = document.getElementById('dep-graph-btn');
521
+ if (depBtn) {
522
+ depBtn.addEventListener('click', () => {
523
+ import('./dependency-graph').then(m => m.toggleDependencyGraph(ctx));
524
+ });
525
+ }
526
+ import('./dependency-graph').then(m => m.setupDependencyGraphShortcut(ctx));
527
+
528
+ // Repo dropdown selector
529
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
530
+ if (repoSelect) {
531
+ // Populate dropdown from recent repos
532
+ const recentRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
533
+ // Clear except first placeholder
534
+ while (repoSelect.options.length > 1) repoSelect.remove(1);
535
+ recentRepos.forEach(repo => {
536
+ const opt = document.createElement('option');
537
+ opt.value = repo;
538
+ // Show short name (last folder part) + full path
539
+ const shortName = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
540
+ opt.textContent = shortName;
541
+ opt.title = repo;
542
+ repoSelect.add(opt);
543
+ });
544
+ // "Open new repo..." option at the end
545
+ const newOpt = document.createElement('option');
546
+ newOpt.value = '__new__';
547
+ newOpt.textContent = '+ Open new repo...';
548
+ repoSelect.add(newOpt);
549
+
550
+ // Set initial value from hash — otherwise keep placeholder
551
+ const hashPath = decodeURIComponent(location.hash.slice(1));
552
+ if (hashPath && recentRepos.includes(hashPath)) {
553
+ repoSelect.value = hashPath;
554
+ } else if (!hashPath) {
555
+ repoSelect.value = ''; // Keep "Select a repository..." shown
556
+ }
557
+
558
+ repoSelect.addEventListener('change', async () => {
559
+ const val = repoSelect.value;
560
+ if (val === '__new__') {
561
+ // Ask the user via native browser prompt instead of buggy OS-level popup
562
+ const path = window.prompt('Enter the absolute path to your Git repository\n\nExample: C:\\Code\\my-project', '');
563
+ if (path && path.trim()) {
564
+ const cleanPath = path.trim();
565
+ _addRecentRepo(cleanPath);
566
+ loadRepository(ctx, cleanPath);
567
+ // Re-populate dropdown options
568
+ const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
569
+ while (repoSelect.options.length > 1) repoSelect.remove(1);
570
+ updatedRepos.forEach(repo => {
571
+ const opt = document.createElement('option');
572
+ opt.value = repo;
573
+ opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
574
+ opt.title = repo;
575
+ repoSelect.add(opt);
576
+ });
577
+ const newOptRefresh = document.createElement('option');
578
+ newOptRefresh.value = '__new__';
579
+ newOptRefresh.textContent = '+ Open new repo...';
580
+ newOptRefresh.id = 'optNewLocal';
581
+ repoSelect.add(newOptRefresh);
582
+ repoSelect.value = cleanPath;
583
+ } else {
584
+ // Reset selection
585
+ repoSelect.value = '';
586
+ }
587
+ } else if (val) {
588
+ loadRepository(ctx, val);
589
+ }
590
+ });
591
+
592
+ // ── Mode detection: hide local-only options in SaaS mode ──
593
+ fetch('/api/repo/mode').then(r => r.json()).then((modeData: any) => {
594
+ if (modeData.mode === 'saas') {
595
+ // Hide the "Open new repo..." local path option
596
+ const localOpt = repoSelect.querySelector('option[value="__new__"]');
597
+ if (localOpt) (localOpt as HTMLElement).style.display = 'none';
598
+ }
599
+ }).catch(() => { });
600
+ }
601
+
602
+ // ── Featured repo cards on landing page ──
603
+ document.querySelectorAll('.repo-card-btn[data-repo]').forEach(btn => {
604
+ btn.addEventListener('click', () => {
605
+ const repoUrl = (btn as HTMLElement).dataset.repo;
606
+ if (repoUrl) {
607
+ _triggerClone(ctx, repoUrl);
608
+ }
609
+ });
610
+ });
611
+
612
+ // Zoom slider
613
+ document.getElementById('zoomSlider')?.addEventListener('input', (e) => {
614
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: parseFloat((e.target as HTMLInputElement).value) });
615
+ updateCanvasTransform(ctx);
616
+ updateZoomUI(ctx);
617
+ });
618
+
619
+ // ── Sticky Zoom Pill controls ──
620
+ document.getElementById('stickyZoomSlider')?.addEventListener('input', (e) => {
621
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: parseFloat((e.target as HTMLInputElement).value) });
622
+ updateCanvasTransform(ctx);
623
+ updateZoomUI(ctx);
624
+ });
625
+
626
+ document.getElementById('stickyZoomOut')?.addEventListener('click', () => {
627
+ const state = ctx.snap().context;
628
+ const newZoom = Math.max(0.1, state.zoom - 0.1);
629
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
630
+ updateCanvasTransform(ctx);
631
+ updateZoomUI(ctx);
632
+ });
633
+
634
+ document.getElementById('stickyZoomIn')?.addEventListener('click', () => {
635
+ const state = ctx.snap().context;
636
+ const newZoom = Math.min(3, state.zoom + 0.1);
637
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
638
+ updateCanvasTransform(ctx);
639
+ updateZoomUI(ctx);
640
+ });
641
+
642
+ document.getElementById('stickyFitAll')?.addEventListener('click', () => fitAllFiles(ctx));
643
+
644
+ // Reset
645
+ document.getElementById('resetView')?.addEventListener('click', () => {
646
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: 1 });
647
+ ctx.actor.send({ type: 'SET_OFFSET', x: 0, y: 0 });
648
+ updateCanvasTransform(ctx);
649
+ updateZoomUI(ctx);
650
+ });
651
+
652
+ // Fit All
653
+ document.getElementById('fitAll')?.addEventListener('click', () => fitAllFiles(ctx));
654
+
655
+ // All-files mode is always active — no view switching needed
656
+
657
+ // Hidden files button
658
+ document.getElementById('showHidden')?.addEventListener('click', () => showHiddenModal(ctx, () => rerenderCurrentView(ctx)));
659
+
660
+ // Arrange toolbar buttons
661
+ document.getElementById('arrangeRow')?.addEventListener('click', () => arrangeRow(ctx));
662
+ document.getElementById('arrangeCol')?.addEventListener('click', () => arrangeColumn(ctx));
663
+ document.getElementById('arrangeColumn')?.addEventListener('click', () => arrangeColumn(ctx));
664
+ document.getElementById('arrangeGrid')?.addEventListener('click', () => arrangeGrid(ctx));
665
+ document.getElementById('arrangeExpand')?.addEventListener('click', () => {
666
+ const selected = ctx.snap().context.selectedCards;
667
+ if (selected.length > 0) toggleCardExpand(ctx);
668
+ });
669
+ document.getElementById('arrangeFit')?.addEventListener('click', () => {
670
+ const selected = ctx.snap().context.selectedCards;
671
+ if (selected.length > 0) fitScreenSize(ctx);
672
+ });
673
+ document.getElementById('arrangeAI')?.addEventListener('click', () => {
674
+ const selected = ctx.snap().context.selectedCards;
675
+ if (selected.length > 0) toggleCanvasChat(ctx);
676
+ });
677
+
678
+ // Close preview
679
+ document.getElementById('closePreview')?.addEventListener('click', closePreview);
680
+ document.querySelector('.modal-backdrop')?.addEventListener('click', closePreview);
681
+
682
+
683
+ // AI chat toggle
684
+ document.getElementById('toggleCanvasChat')?.addEventListener('click', () => toggleCanvasChat(ctx));
685
+
686
+ // Replayable onboarding
687
+ document.getElementById('helpOnboarding')?.addEventListener('click', () => {
688
+ import('./onboarding').then(m => m.startOnboarding(ctx));
689
+ });
690
+
691
+ // Share Layout
692
+ document.getElementById('shareLayout')?.addEventListener('click', () => {
693
+ measure('share:layout', () => {
694
+ const state = ctx.snap();
695
+ if (!state.context.repoPath) {
696
+ showToast('Load a repository first to share its layout.', 'error');
697
+ return;
698
+ }
699
+ const layoutData = {
700
+ positions: Object.fromEntries(ctx.positions),
701
+ hiddenFiles: Array.from(ctx.hiddenFiles),
702
+ zoom: state.context.zoom,
703
+ offsetX: state.context.offsetX,
704
+ offsetY: state.context.offsetY,
705
+ cardSizes: state.context.cardSizes,
706
+ };
707
+ const encoded = btoa(JSON.stringify(layoutData));
708
+ const url = new URL(window.location.href);
709
+ // Strip existing layout param if any
710
+ url.searchParams.set('layout', encoded);
711
+
712
+ navigator.clipboard.writeText(url.toString()).then(() => {
713
+ showToast('Layout link copied to clipboard!', 'success');
714
+ }).catch(() => {
715
+ showToast('Failed to copy to clipboard', 'error');
716
+ });
717
+ });
718
+ });
719
+
720
+ // Settings modal
721
+ document.getElementById('openSettings')?.addEventListener('click', () => {
722
+ import('./settings-modal').then(({ openSettingsModal }) => openSettingsModal(ctx));
723
+ });
724
+
725
+ // Global search
726
+ document.getElementById('openGlobalSearch')?.addEventListener('click', () => {
727
+ import('./global-search').then(({ toggleGlobalSearch }) => toggleGlobalSearch(ctx));
728
+ });
729
+
730
+ // Branch comparison
731
+ document.getElementById('openBranchCompare')?.addEventListener('click', () => {
732
+ import('./branch-compare').then(({ toggleDrawer }) => toggleDrawer(ctx));
733
+ });
734
+
735
+ // Apply saved settings on startup
736
+ import('./settings-modal').then(({ applyAllSettings }) => applyAllSettings(ctx));
737
+
738
+ // Clean up expired auto-save drafts
739
+ import('./auto-save').then(({ cleanExpiredDrafts }) => cleanExpiredDrafts());
740
+
741
+ // ── Keyboard shortcuts ──
742
+ window.addEventListener('keydown', (e) => {
743
+ // Space-bar canvas panning
744
+ if (e.code === 'Space' && !e.repeat) {
745
+ if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
746
+ e.preventDefault();
747
+ ctx.spaceHeld = true;
748
+ ctx.canvasViewport.classList.add('space-panning');
749
+ return;
750
+ }
751
+
752
+ // Don't interfere with input fields for all other shortcuts
753
+ if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
754
+
755
+ if (e.key === 'Escape') {
756
+ closePreview();
757
+ const hiddenModal = document.getElementById('hiddenFilesModal');
758
+ if (hiddenModal) hiddenModal.remove();
759
+ // Cancel click-to-connect if pending
760
+ if (hasPendingConnection()) {
761
+ cancelPendingConnection(ctx);
762
+ return;
763
+ }
764
+ if (ctx.snap().context.pendingConnection) {
765
+ ctx.actor.send({ type: 'CANCEL_CONNECTION' });
766
+ }
767
+ // Deselect all cards
768
+ ctx.actor.send({ type: 'DESELECT_ALL' });
769
+ clearSelectionHighlights(ctx);
770
+ updatePillSelectionHighlights(ctx);
771
+ updateArrangeToolbar(ctx);
772
+ }
773
+
774
+ if (e.key === 'Delete' || e.key === 'Backspace') {
775
+ const selected = ctx.snap().context.selectedCards;
776
+ if (selected.length > 0) {
777
+ e.preventDefault();
778
+ hideSelectedFiles(ctx, selected);
779
+ }
780
+ }
781
+
782
+ // Arrangement hotkeys
783
+ if (e.key === 'h' || e.key === 'H') {
784
+ const selected = ctx.snap().context.selectedCards;
785
+ if (selected.length >= 2) {
786
+ e.preventDefault(); arrangeRow(ctx);
787
+ } else if (selected.length === 0) {
788
+ // Toggle git heatmap overlay
789
+ e.preventDefault();
790
+ const repoPath = ctx.snap().context.repoPath;
791
+ if (repoPath) {
792
+ import('./heatmap').then(async ({ toggleHeatmap, injectHeatmapCSS }) => {
793
+ injectHeatmapCSS();
794
+ const active = await toggleHeatmap(repoPath);
795
+ import('./settings').then(({ updateSettings }) => updateSettings({ heatmapEnabled: active }));
796
+ import('./utils').then(m => m.showToast(
797
+ active ? '🔥 Heatmap ON — hot files glow red' : 'Heatmap OFF',
798
+ 'info'
799
+ ));
800
+ });
801
+ }
802
+ }
803
+ }
804
+ if (e.key === 'v' || e.key === 'V') {
805
+ const selected = ctx.snap().context.selectedCards;
806
+ if (selected.length >= 2) { e.preventDefault(); arrangeColumn(ctx); }
807
+ }
808
+ if (e.key === 'g' || e.key === 'G') {
809
+ const selected = ctx.snap().context.selectedCards;
810
+ if (selected.length >= 2) { e.preventDefault(); arrangeGrid(ctx); }
811
+ }
812
+
813
+ // Select all with Ctrl+A
814
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
815
+ e.preventDefault();
816
+ ctx.fileCards.forEach((card, path) => {
817
+ ctx.actor.send({ type: 'SELECT_CARD', path, shift: true });
818
+ });
819
+ // Also select deferred cards (pill mode at low zoom)
820
+ if (ctx.deferredCards) {
821
+ for (const [path] of ctx.deferredCards) {
822
+ ctx.actor.send({ type: 'SELECT_CARD', path, shift: true });
823
+ }
824
+ }
825
+ updatePillSelectionHighlights(ctx);
826
+ updateArrangeToolbar(ctx);
827
+ }
828
+
829
+ // F key: no longer used for expand (canvas text handles all lines)
830
+
831
+ // W = Fit selected cards to screen/viewport size
832
+ if (e.key === 'w' || e.key === 'W') {
833
+ const selected = ctx.snap().context.selectedCards;
834
+ if (selected.length > 0) {
835
+ e.preventDefault();
836
+ fitScreenSize(ctx);
837
+ }
838
+ }
839
+
840
+ // Ctrl + / Ctrl - = increase/decrease card font size
841
+ if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
842
+ e.preventDefault();
843
+ changeCardsFontSize(ctx, 1);
844
+ }
845
+ if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '_')) {
846
+ e.preventDefault();
847
+ changeCardsFontSize(ctx, -1);
848
+ }
849
+
850
+ // Removed: I key AI chat toggle (conflicts with typing, not useful in production)
851
+
852
+ // ← → = Navigate commits
853
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
854
+ const state = ctx.snap().context;
855
+ const commits = state.commits;
856
+ if (commits.length === 0) return;
857
+ const currentIdx = commits.findIndex(c => c.hash === state.currentCommitHash);
858
+ let newIdx;
859
+ if (e.key === 'ArrowLeft') {
860
+ newIdx = currentIdx > 0 ? currentIdx - 1 : commits.length - 1;
861
+ } else {
862
+ newIdx = currentIdx < commits.length - 1 ? currentIdx + 1 : 0;
863
+ }
864
+ e.preventDefault();
865
+ selectCommit(ctx, commits[newIdx].hash);
866
+ // Scroll the commit into view in sidebar
867
+ const commitEl = document.querySelector(`.commit-item[data-hash="${commits[newIdx].hash}"]`);
868
+ if (commitEl) commitEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
869
+ }
870
+
871
+ // Ctrl+F = Global search sidebar
872
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'f') {
873
+ e.preventDefault();
874
+ import('./global-search').then(m => m.toggleGlobalSearch(ctx));
875
+ return;
876
+ }
877
+
878
+ // Ctrl+O or Ctrl+K or Ctrl+P = File search / command palette
879
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key.toLowerCase() === 'o' || e.key.toLowerCase() === 'k' || e.key.toLowerCase() === 'p')) {
880
+ e.preventDefault();
881
+ openFileSearch(ctx);
882
+ return;
883
+ }
884
+
885
+ // Ctrl+Shift+E = Export canvas as PNG
886
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'e') {
887
+ e.preventDefault();
888
+ exportCanvasAsPNG(ctx);
889
+ }
890
+
891
+ // Ctrl+Shift+V = Export viewport as PNG
892
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'v') {
893
+ e.preventDefault();
894
+ exportViewportAsPNG(ctx);
895
+ }
896
+
897
+ // Ctrl+N = Create new file
898
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') {
899
+ e.preventDefault();
900
+ import('./new-file-dialog').then(m => m.showNewFileDialog(ctx));
901
+ }
902
+
903
+ // Ctrl+Shift+F removed — Ctrl+F now opens global search directly
904
+ });
905
+
906
+ // ── Prevent browser page zoom (Ctrl+scroll, Ctrl+0) ──
907
+ // Ctrl+scroll is already handled by the canvas wheel handler above.
908
+ // This global handler catches it at document level for any remaining cases.
909
+ document.addEventListener('wheel', (e) => {
910
+ if (e.ctrlKey || e.metaKey) {
911
+ e.preventDefault();
912
+ }
913
+ }, { passive: false });
914
+
915
+ // Prevent Ctrl+0 (reset browser zoom)
916
+ window.addEventListener('keydown', (e) => {
917
+ if ((e.ctrlKey || e.metaKey) && e.key === '0') {
918
+ e.preventDefault();
919
+ }
920
+ });
921
+
922
+ // Space-bar release
923
+ window.addEventListener('keyup', (e) => {
924
+ if (e.code === 'Space') {
925
+ ctx.spaceHeld = false;
926
+ ctx.canvasViewport.classList.remove('space-panning');
927
+ if (ctx.isDragging) {
928
+ ctx.isDragging = false;
929
+ ctx.canvasViewport.style.cursor = '';
930
+ }
931
+ }
932
+ });
933
+
934
+ // Window blur to reset space state
935
+ window.addEventListener('blur', () => {
936
+ if (ctx.spaceHeld) {
937
+ ctx.spaceHeld = false;
938
+ ctx.canvasViewport.classList.remove('space-panning');
939
+ if (ctx.isDragging) {
940
+ ctx.isDragging = false;
941
+ ctx.canvasViewport.style.cursor = '';
942
+ }
943
+ }
944
+ });
945
+
946
+ // GitHub Import Modal
947
+ setupGithubImport(ctx);
948
+
949
+ // Minimap click navigation
950
+ setupMinimapClick(ctx);
951
+
952
+ // Local Directory Drag-and-Drop
953
+ setupDragAndDrop(ctx);
954
+
955
+ // Collaborative cursor sharing (WebSocket)
956
+ import('./cursor-sharing').then(({ initCursorSharing }) => initCursorSharing(ctx));
957
+ });
958
+ }
959
+
960
+ // ─── File search overlay ────────────────────────────────
961
+ function openFileSearch(ctx: CanvasContext) {
962
+ // Remove existing if open
963
+ document.getElementById('fileSearchOverlay')?.remove();
964
+
965
+ const overlay = document.createElement('div');
966
+ overlay.id = 'fileSearchOverlay';
967
+ overlay.className = 'file-search-overlay';
968
+ document.body.appendChild(overlay);
969
+
970
+ interface SearchMatch {
971
+ path: string;
972
+ line?: number;
973
+ snippet?: string;
974
+ isContentMatch: boolean;
975
+ }
976
+
977
+ function getAllFiles() {
978
+ if (ctx.allFilesData && ctx.allFilesData.length > 0) {
979
+ return ctx.allFilesData;
980
+ }
981
+ return [];
982
+ }
983
+
984
+ let selectedIdx = 0;
985
+ let currentQuery = '';
986
+
987
+ function navigateToFile(match: SearchMatch) {
988
+ const mgr = getCardManager();
989
+ const activeCard = mgr?.cards.get(match.path);
990
+ const deferredCard = mgr?.deferred.get(match.path);
991
+
992
+ if (!activeCard && !deferredCard) {
993
+ const layer = getActiveLayer();
994
+ if (layer && ctx.allFilesActive) {
995
+ // Instantly add the whole file to the active layer
996
+ addSectionToLayer(ctx, layer.id, match.path, '', '');
997
+
998
+ // Wait for the active layer to apply/render then jump
999
+ setTimeout(() => {
1000
+ const card = ctx.fileCards.get(match.path);
1001
+ if (card) {
1002
+ close();
1003
+ doNavigate(match.path, card, match.line);
1004
+ }
1005
+ }, 50);
1006
+ } else if (!ctx.allFilesActive) {
1007
+ showToast("File was not modified in the current view.", 'info');
1008
+ }
1009
+ return;
1010
+ }
1011
+
1012
+ close();
1013
+
1014
+ // If active, use its DOM element. If deferred, use its stored coordinates.
1015
+ if (activeCard) {
1016
+ doNavigate(match.path, activeCard, match.line);
1017
+ } else if (deferredCard) {
1018
+ doNavigateDeferred(match.path, deferredCard, match.line);
1019
+ }
1020
+ }
1021
+
1022
+ function doNavigateDeferred(path: string, deferredCard: any, line?: number) {
1023
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
1024
+ const state = ctx.snap().context;
1025
+ const newOffsetX = -(deferredCard.x + deferredCard.width / 2) * state.zoom + vpRect.width / 2;
1026
+ const newOffsetY = -(deferredCard.y + deferredCard.height / 2) * state.zoom + vpRect.height / 2;
1027
+
1028
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
1029
+ updateCanvasTransform(ctx); // This triggers CardManager.materializeInRect()
1030
+
1031
+ // Wait a frame for materialization to hit DOM
1032
+ requestAnimationFrame(() => {
1033
+ const materializedCard = getCardManager()?.cards.get(path);
1034
+ if (materializedCard) {
1035
+ _animateAndSelectCard(path, materializedCard, line);
1036
+ }
1037
+ });
1038
+ }
1039
+
1040
+ function doNavigate(path: string, card: HTMLElement, line?: number) {
1041
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
1042
+ const state = ctx.snap().context;
1043
+ const cardX = parseFloat(card.style.left) || 0;
1044
+ const cardY = parseFloat(card.style.top) || 0;
1045
+ const newOffsetX = -(cardX + card.offsetWidth / 2) * state.zoom + vpRect.width / 2;
1046
+ const newOffsetY = -(cardY + card.offsetHeight / 2) * state.zoom + vpRect.height / 2;
1047
+
1048
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
1049
+ updateCanvasTransform(ctx);
1050
+
1051
+ _animateAndSelectCard(path, card, line);
1052
+ }
1053
+
1054
+ function _animateAndSelectCard(path: string, card: HTMLElement, line?: number) {
1055
+ card.classList.add('card-flash');
1056
+ setTimeout(() => card.classList.remove('card-flash'), 1500);
1057
+ ctx.actor.send({ type: 'SELECT_CARD', path, shift: false });
1058
+ updatePillSelectionHighlights(ctx);
1059
+ updateArrangeToolbar(ctx);
1060
+
1061
+ if (line) {
1062
+ requestAnimationFrame(() => {
1063
+ const body = card.querySelector('.file-card-body');
1064
+ if (body) {
1065
+ const rowHeight = 20; // approximate row height
1066
+ body.scrollTop = (line - 1) * rowHeight - body.clientHeight / 2;
1067
+ }
1068
+ });
1069
+ }
1070
+ }
1071
+
1072
+ function close() {
1073
+ render(null, overlay);
1074
+ overlay.remove();
1075
+ }
1076
+
1077
+ function highlightMatch(text: string, q: string): string {
1078
+ if (!q) return escapeHtml(text);
1079
+ const lowText = text.toLowerCase();
1080
+ q = q.toLowerCase();
1081
+
1082
+ const exactIdx = lowText.indexOf(q);
1083
+ if (exactIdx >= 0) {
1084
+ return escapeHtml(text.substring(0, exactIdx)) +
1085
+ '<mark>' + escapeHtml(text.substring(exactIdx, exactIdx + q.length)) + '</mark>' +
1086
+ escapeHtml(text.substring(exactIdx + q.length));
1087
+ }
1088
+
1089
+ let qIdx = 0;
1090
+ let result = '';
1091
+ for (let i = 0; i < text.length; i++) {
1092
+ if (qIdx < q.length && lowText[i] === q[qIdx]) {
1093
+ result += '<mark>' + escapeHtml(text[i]) + '</mark>';
1094
+ qIdx++;
1095
+ } else {
1096
+ result += escapeHtml(text[i]);
1097
+ }
1098
+ }
1099
+ return result;
1100
+ }
1101
+
1102
+ function fuzzyScore(str: string, query: string): number {
1103
+ const strictIdx = str.toLowerCase().indexOf(query);
1104
+ if (strictIdx >= 0) return 1000 - strictIdx; // Exact matches are highly ranked
1105
+
1106
+ let qIdx = 0;
1107
+ let sIdx = 0;
1108
+ let score = 0;
1109
+ let streak = 0;
1110
+ const lowStr = str.toLowerCase();
1111
+
1112
+ while (sIdx < lowStr.length && qIdx < query.length) {
1113
+ if (lowStr[sIdx] === query[qIdx]) {
1114
+ score += 1 + (streak * 2);
1115
+ streak++;
1116
+ qIdx++;
1117
+ } else {
1118
+ streak = 0;
1119
+ }
1120
+ sIdx++;
1121
+ }
1122
+
1123
+ return qIdx === query.length ? score : -Infinity;
1124
+ }
1125
+
1126
+ function getMatches(): SearchMatch[] {
1127
+ const files = getAllFiles();
1128
+ const q = currentQuery.toLowerCase().trim();
1129
+
1130
+ let pathOnlySearch = false; // By default search both
1131
+ if (q.startsWith('f:')) {
1132
+ pathOnlySearch = true;
1133
+ }
1134
+
1135
+ const actualQuery = q.replace(/^f:/, '').trim();
1136
+ if (!actualQuery) {
1137
+ // Return top files randomly if no query yet
1138
+ return files.slice(0, 15).map(f => ({ path: f.path, isContentMatch: false }));
1139
+ }
1140
+
1141
+ const rawResults: { match: SearchMatch, score: number }[] = [];
1142
+ let itemsScanned = 0;
1143
+
1144
+ for (const f of files) {
1145
+ if (itemsScanned > 5000) break; // Prevent deep-stall on massive repos
1146
+
1147
+ // Path match check
1148
+ const pathScore = fuzzyScore(f.path, actualQuery);
1149
+ if (pathScore > -Infinity) {
1150
+ rawResults.push({ match: { path: f.path, isContentMatch: false }, score: pathScore + 500 }); // Bonus for path
1151
+ }
1152
+ itemsScanned++;
1153
+
1154
+ // Content match check
1155
+ if (!pathOnlySearch && f.content) {
1156
+ const lines = f.content.split('\n');
1157
+ for (let i = 0; i < lines.length; i++) {
1158
+ const lineScore = fuzzyScore(lines[i], actualQuery);
1159
+ if (lineScore > -Infinity) {
1160
+ rawResults.push({
1161
+ match: {
1162
+ path: f.path,
1163
+ line: i + 1,
1164
+ snippet: lines[i].trim().substring(0, 100), // Max 100 chars in preview
1165
+ isContentMatch: true
1166
+ },
1167
+ score: lineScore
1168
+ });
1169
+ itemsScanned++;
1170
+ if (rawResults.length > 500) break; // Hard limit pool
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ // Sort by score descending and return top 15
1177
+ rawResults.sort((a, b) => b.score - a.score);
1178
+ return rawResults.slice(0, 15).map(r => r.match);
1179
+ }
1180
+
1181
+ function handleKeydown(e: KeyboardEvent) {
1182
+ const matches = getMatches();
1183
+ if (e.key === 'ArrowDown') {
1184
+ e.preventDefault();
1185
+ selectedIdx = Math.min(selectedIdx + 1, matches.length - 1);
1186
+ rerenderResults();
1187
+ } else if (e.key === 'ArrowUp') {
1188
+ e.preventDefault();
1189
+ selectedIdx = Math.max(selectedIdx - 1, 0);
1190
+ rerenderResults();
1191
+ } else if (e.key === 'Enter') {
1192
+ e.preventDefault();
1193
+ if (matches[selectedIdx]) navigateToFile(matches[selectedIdx]);
1194
+ } else if (e.key === 'Escape') {
1195
+ e.preventDefault();
1196
+ close();
1197
+ }
1198
+ }
1199
+
1200
+ function handleOverlayClick(e: MouseEvent) {
1201
+ if ((e.target as HTMLElement) === overlay || (e.target as HTMLElement).classList.contains('file-search-overlay')) {
1202
+ close();
1203
+ }
1204
+ }
1205
+
1206
+ // Build the container with a stable input + a results div that gets re-rendered
1207
+ const container = document.createElement('div');
1208
+ container.className = 'file-search-container';
1209
+
1210
+ const input = document.createElement('input');
1211
+ input.type = 'text';
1212
+ input.className = 'file-search-input';
1213
+ input.placeholder = 'Search paths (f:) or full text...';
1214
+ input.autocomplete = 'off';
1215
+ input.addEventListener('input', (e) => {
1216
+ currentQuery = (e.target as HTMLInputElement).value;
1217
+ selectedIdx = 0;
1218
+ rerenderResults();
1219
+ });
1220
+ input.addEventListener('keydown', handleKeydown);
1221
+ container.appendChild(input);
1222
+
1223
+ const resultsContainer = document.createElement('div');
1224
+ resultsContainer.className = 'file-search-results';
1225
+ container.appendChild(resultsContainer);
1226
+ overlay.appendChild(container);
1227
+
1228
+ function rerenderResults() {
1229
+ const matches = getMatches();
1230
+ const q = currentQuery.replace(/^f:/, '').toLowerCase().trim();
1231
+ if (matches.length === 0 && q) {
1232
+ resultsContainer.innerHTML = `<div class="file-search-empty">No results for "${escapeHtml(q)}"</div>`;
1233
+ } else {
1234
+ resultsContainer.innerHTML = matches.map((m, i) => {
1235
+ if (m.isContentMatch) {
1236
+ return `
1237
+ <div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(m.path)}" data-line="${m.line}">
1238
+ <div class="search-file-name" style="font-size: 0.75rem; color: var(--text-muted)">${escapeHtml(m.path)}:${m.line}</div>
1239
+ <div class="search-file-snippet">${highlightMatch(m.snippet || '', q)}</div>
1240
+ </div>`;
1241
+ } else {
1242
+ return `
1243
+ <div class="file-search-item ${i === selectedIdx ? 'selected' : ''}" data-path="${escapeHtml(m.path)}">
1244
+ <span class="search-file-name">${highlightMatch(m.path, q)}</span>
1245
+ </div>`;
1246
+ }
1247
+ }).join('');
1248
+ // Attach click handlers
1249
+ resultsContainer.querySelectorAll('.file-search-item').forEach(el => {
1250
+ el.addEventListener('click', () => {
1251
+ const path = (el as HTMLElement).dataset.path!;
1252
+ const line = (el as HTMLElement).dataset.line ? parseInt((el as HTMLElement).dataset.line!) : undefined;
1253
+ navigateToFile({ path, line, isContentMatch: !!line });
1254
+ });
1255
+ });
1256
+ }
1257
+
1258
+ // Scroll selected into view securely
1259
+ const selectedEl = resultsContainer.querySelector('.file-search-item.selected');
1260
+ if (selectedEl) {
1261
+ selectedEl.scrollIntoView({ block: 'nearest' });
1262
+ }
1263
+ }
1264
+
1265
+ rerenderResults();
1266
+ setTimeout(() => input.focus(), 50);
1267
+
1268
+ overlay.addEventListener('click', handleOverlayClick);
1269
+ requestAnimationFrame(() => {
1270
+ const input = overlay.querySelector('.file-search-input') as HTMLInputElement;
1271
+ if (input) input.focus();
1272
+ });
1273
+ }
1274
+
1275
+ // ─── Language color map ─────────────────────────────────
1276
+ const LANG_COLORS: Record<string, string> = {
1277
+ TypeScript: '#3178c6', JavaScript: '#f1e05a', Python: '#3572A5',
1278
+ Rust: '#dea584', Go: '#00ADD8', Java: '#b07219', C: '#555555',
1279
+ 'C++': '#f34b7d', 'C#': '#178600', Ruby: '#701516', PHP: '#4F5D95',
1280
+ Swift: '#F05138', Kotlin: '#A97BFF', Dart: '#00B4AB', Lua: '#000080',
1281
+ Shell: '#89e051', HTML: '#e34c26', CSS: '#563d7c', Vue: '#41b883',
1282
+ Svelte: '#ff3e00', Zig: '#ec915c', Elixir: '#6e4a7e', Haskell: '#5e5086',
1283
+ Scala: '#c22d40', OCaml: '#3be133', Nix: '#7e7eff',
1284
+ };
1285
+
1286
+ // ─── GitHub Import Modal Handler ────────────────────────
1287
+ function setupGithubImport(ctx: CanvasContext) {
1288
+ const modal = document.getElementById('githubModal');
1289
+ const openBtn = document.getElementById('githubImportBtn');
1290
+ const closeBtn = document.getElementById('githubModalClose');
1291
+ const backdrop = modal?.querySelector('.github-modal-backdrop');
1292
+ const searchBtn = document.getElementById('githubSearchBtn');
1293
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
1294
+ const sortSelect = document.getElementById('githubSortSelect') as HTMLSelectElement;
1295
+ const grid = document.getElementById('githubReposGrid');
1296
+ const profileDiv = document.getElementById('githubProfile');
1297
+ const pagination = document.getElementById('githubPagination');
1298
+ const prevBtn = document.getElementById('githubPrevPage') as HTMLButtonElement;
1299
+ const nextBtn = document.getElementById('githubNextPage') as HTMLButtonElement;
1300
+ const pageInfo = document.getElementById('githubPageInfo');
1301
+ const urlCloneRow = document.getElementById('githubUrlCloneRow');
1302
+ const urlCloneBtn = document.getElementById('githubUrlCloneBtn');
1303
+ const detectedUrlSpan = document.getElementById('githubDetectedUrl');
1304
+ const filterRow = document.getElementById('githubFilterRow');
1305
+ const filterInput = document.getElementById('githubRepoFilter') as HTMLInputElement;
1306
+
1307
+ if (!modal || !openBtn || !grid) return;
1308
+
1309
+ let currentPage = 1;
1310
+ let currentUser = '';
1311
+ let isLoading = false;
1312
+ let allRenderedCards: HTMLElement[] = [];
1313
+
1314
+ // ── URL detection ──
1315
+ const GITHUB_URL_RE = /^https?:\/\/(www\.)?github\.com\/[^/]+\/[^/]+/;
1316
+ function extractRepoUrl(text: string): string | null {
1317
+ const match = text.trim().match(GITHUB_URL_RE);
1318
+ return match ? match[0].replace(/\.git$/, '') + '.git' : null;
1319
+ }
1320
+ function extractUserFromUrl(text: string): string | null {
1321
+ const m = text.trim().match(/github\.com\/([^/]+)/);
1322
+ return m ? m[1] : null;
1323
+ }
1324
+
1325
+ function updateUrlDetection() {
1326
+ const val = userInput?.value.trim() || '';
1327
+ const url = extractRepoUrl(val);
1328
+ if (url && urlCloneRow && detectedUrlSpan) {
1329
+ // URL detected — show clone row, extract repo name
1330
+ const parts = url.replace('.git', '').split('/');
1331
+ const repoName = parts.slice(-2).join('/');
1332
+ detectedUrlSpan.textContent = repoName;
1333
+ urlCloneRow.style.display = 'flex';
1334
+ } else if (urlCloneRow) {
1335
+ urlCloneRow.style.display = 'none';
1336
+ }
1337
+ }
1338
+
1339
+ userInput?.addEventListener('input', updateUrlDetection);
1340
+
1341
+ function openModal() {
1342
+ modal!.classList.add('active');
1343
+ requestAnimationFrame(() => userInput?.focus());
1344
+ }
1345
+
1346
+ function closeModal() {
1347
+ modal!.classList.remove('active');
1348
+ }
1349
+
1350
+ openBtn.addEventListener('click', openModal);
1351
+ closeBtn?.addEventListener('click', closeModal);
1352
+ backdrop?.addEventListener('click', closeModal);
1353
+ window.addEventListener('keydown', (e) => {
1354
+ if (e.key === 'Escape' && modal!.classList.contains('active')) closeModal();
1355
+ });
1356
+
1357
+ // ── Direct URL clone from modal ──
1358
+ urlCloneBtn?.addEventListener('click', () => {
1359
+ const url = extractRepoUrl(userInput?.value.trim() || '');
1360
+ if (url) {
1361
+ closeModal();
1362
+ _triggerClone(ctx, url);
1363
+ }
1364
+ });
1365
+
1366
+ // ── Repo name filter ──
1367
+ filterInput?.addEventListener('input', () => {
1368
+ const q = filterInput.value.trim().toLowerCase();
1369
+ for (const card of allRenderedCards) {
1370
+ const name = (card.dataset.name || '').toLowerCase();
1371
+ const desc = card.querySelector('.github-repo-desc')?.textContent?.toLowerCase() || '';
1372
+ card.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
1373
+ }
1374
+ });
1375
+
1376
+ async function searchRepos(page = 1) {
1377
+ let user = userInput?.value.trim();
1378
+ if (!user || isLoading) return;
1379
+
1380
+ // If it's a URL, extract the username/org from it
1381
+ const urlUser = extractUserFromUrl(user);
1382
+ if (urlUser) user = urlUser;
1383
+
1384
+ isLoading = true;
1385
+ currentUser = user;
1386
+ currentPage = page;
1387
+ const sort = sortSelect?.value || 'updated';
1388
+
1389
+ // Save last searched user
1390
+ localStorage.setItem('gitcanvas:lastGithubUser', user);
1391
+
1392
+ grid!.innerHTML = `
1393
+ <div class="github-loading">
1394
+ <div class="github-spinner"></div>
1395
+ <p>Fetching repos for <strong>${escapeHtml(user)}</strong>...</p>
1396
+ </div>
1397
+ `;
1398
+ if (pagination) pagination.style.display = 'none';
1399
+ if (filterRow) filterRow.style.display = 'none';
1400
+ if (filterInput) filterInput.value = '';
1401
+ allRenderedCards = [];
1402
+
1403
+ try {
1404
+ const res = await fetch(`/api/github/repos?user=${encodeURIComponent(user)}&page=${page}&sort=${sort}`);
1405
+ const data = await res.json();
1406
+
1407
+ if (!res.ok || data.error) {
1408
+ grid!.innerHTML = `<div class="github-error">${escapeHtml(data.error || 'Failed to fetch')}</div>`;
1409
+ isLoading = false;
1410
+ return;
1411
+ }
1412
+
1413
+ // Render profile
1414
+ if (data.profile && profileDiv) {
1415
+ profileDiv.style.display = 'flex';
1416
+ profileDiv.innerHTML = `
1417
+ <img class="github-avatar" src="${data.profile.avatar_url}" alt="${escapeHtml(data.profile.login)}" />
1418
+ <div class="github-profile-info">
1419
+ <strong>${escapeHtml(data.profile.name || data.profile.login)}</strong>
1420
+ <span class="github-profile-meta">
1421
+ @${escapeHtml(data.profile.login)} &middot; ${data.profile.public_repos} repos
1422
+ ${data.profile.type === 'Organization' ? ' &middot; Organization' : ''}
1423
+ </span>
1424
+ ${data.profile.bio ? `<span class="github-profile-bio">${escapeHtml(data.profile.bio)}</span>` : ''}
1425
+ </div>
1426
+ `;
1427
+ }
1428
+
1429
+ // Render repos
1430
+ if (data.repos.length === 0) {
1431
+ grid!.innerHTML = `<div class="github-empty-state"><p>No repositories found for "${escapeHtml(user)}"</p></div>`;
1432
+ } else {
1433
+ // Show filter row when there are results
1434
+ if (filterRow) filterRow.style.display = 'flex';
1435
+
1436
+ grid!.innerHTML = data.repos.map((repo: any) => {
1437
+ const langColor = LANG_COLORS[repo.language] || '#8b8b8b';
1438
+ const sizeStr = repo.size > 1024 ? `${(repo.size / 1024).toFixed(1)} MB` : `${repo.size} KB`;
1439
+ const updatedDate = new Date(repo.updated_at);
1440
+ const timeAgo = _timeAgo(updatedDate);
1441
+
1442
+ return `
1443
+ <div class="github-repo-card" data-clone-url="${escapeHtml(repo.clone_url)}" data-name="${escapeHtml(repo.name)}">
1444
+ <div class="github-repo-header">
1445
+ <span class="github-repo-name">${escapeHtml(repo.name)}</span>
1446
+ ${repo.stars > 0 ? `<span class="github-repo-stars">\u2b50 ${repo.stars}</span>` : ''}
1447
+ </div>
1448
+ ${repo.description ? `<p class="github-repo-desc">${escapeHtml(repo.description.length > 120 ? repo.description.slice(0, 117) + '...' : repo.description)}</p>` : '<p class="github-repo-desc" style="opacity:0.3">No description</p>'}
1449
+ <div class="github-repo-meta">
1450
+ ${repo.language ? `<span class="github-repo-lang"><span class="lang-dot" style="background:${langColor}"></span>${escapeHtml(repo.language)}</span>` : ''}
1451
+ <span class="github-repo-size">${sizeStr}</span>
1452
+ <span class="github-repo-updated">${timeAgo}</span>
1453
+ </div>
1454
+ <button class="github-clone-btn" data-url="${escapeHtml(repo.clone_url)}">Clone &amp; Open</button>
1455
+ </div>
1456
+ `;
1457
+ }).join('');
1458
+
1459
+ // Track rendered cards for filtering
1460
+ allRenderedCards = Array.from(grid!.querySelectorAll('.github-repo-card')) as HTMLElement[];
1461
+
1462
+ // Attach clone handlers
1463
+ grid!.querySelectorAll('.github-clone-btn').forEach(btn => {
1464
+ btn.addEventListener('click', (e) => {
1465
+ e.stopPropagation();
1466
+ const url = (btn as HTMLElement).dataset.url!;
1467
+ closeModal();
1468
+ _triggerClone(ctx, url);
1469
+ });
1470
+ });
1471
+
1472
+ // Click on card opens GitHub page
1473
+ grid!.querySelectorAll('.github-repo-card').forEach(card => {
1474
+ card.addEventListener('click', (e) => {
1475
+ if ((e.target as HTMLElement).closest('.github-clone-btn')) return;
1476
+ const name = (card as HTMLElement).dataset.name;
1477
+ window.open(`https://github.com/${currentUser}/${name}`, '_blank');
1478
+ });
1479
+ });
1480
+ }
1481
+
1482
+ // Pagination
1483
+ if (data.hasNext || data.hasPrev) {
1484
+ if (pagination) pagination.style.display = 'flex';
1485
+ if (prevBtn) prevBtn.disabled = !data.hasPrev;
1486
+ if (nextBtn) nextBtn.disabled = !data.hasNext;
1487
+ if (pageInfo) pageInfo.textContent = `Page ${data.page}`;
1488
+ } else {
1489
+ if (pagination) pagination.style.display = 'none';
1490
+ }
1491
+
1492
+ } catch (err: any) {
1493
+ grid!.innerHTML = `<div class="github-error">Network error: ${escapeHtml(err.message)}</div>`;
1494
+ } finally {
1495
+ isLoading = false;
1496
+ }
1497
+ }
1498
+
1499
+ searchBtn?.addEventListener('click', () => searchRepos(1));
1500
+ userInput?.addEventListener('keydown', (e) => {
1501
+ if (e.key === 'Enter') {
1502
+ e.preventDefault();
1503
+ // If URL detected, clone directly on Enter
1504
+ const url = extractRepoUrl(userInput.value.trim());
1505
+ if (url) {
1506
+ closeModal();
1507
+ _triggerClone(ctx, url);
1508
+ } else {
1509
+ searchRepos(1);
1510
+ }
1511
+ }
1512
+ });
1513
+ sortSelect?.addEventListener('change', () => {
1514
+ if (currentUser) searchRepos(1);
1515
+ });
1516
+ prevBtn?.addEventListener('click', () => searchRepos(currentPage - 1));
1517
+ nextBtn?.addEventListener('click', () => searchRepos(currentPage + 1));
1518
+
1519
+ // Load last searched user from localStorage
1520
+ const lastUser = localStorage.getItem('gitcanvas:lastGithubUser');
1521
+ if (lastUser && userInput) userInput.value = lastUser;
1522
+ }
1523
+
1524
+ // ─── Trigger clone (self-contained, uses clone-stream API) ──
1525
+ function _triggerClone(ctx: CanvasContext, url: string) {
1526
+ const cloneStatus = document.getElementById('cloneStatus');
1527
+ if (!cloneStatus) return;
1528
+
1529
+ cloneStatus.style.display = 'block';
1530
+ cloneStatus.className = 'clone-status cloning';
1531
+ cloneStatus.innerHTML = `
1532
+ <div class="clone-progress-text">⏳ Cloning...</div>
1533
+ <div class="clone-progress-bar"><div class="clone-progress-fill" style="width: 0%"></div></div>
1534
+ `;
1535
+
1536
+ const progressText = cloneStatus.querySelector('.clone-progress-text') as HTMLElement;
1537
+ const progressFill = cloneStatus.querySelector('.clone-progress-fill') as HTMLElement;
1538
+
1539
+ fetch('/api/repo/clone-stream', {
1540
+ method: 'POST',
1541
+ headers: { 'Content-Type': 'application/json' },
1542
+ body: JSON.stringify({ url })
1543
+ }).then(async (res) => {
1544
+ const contentType = res.headers.get('content-type') || '';
1545
+ if (contentType.includes('application/json')) {
1546
+ const data = await res.json();
1547
+ if (!res.ok || data.error) {
1548
+ cloneStatus.className = 'clone-status error';
1549
+ cloneStatus.textContent = '❌ ' + (data.error || 'Clone failed');
1550
+ setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
1551
+ return;
1552
+ }
1553
+ // Cached
1554
+ cloneStatus.className = 'clone-status success';
1555
+ cloneStatus.textContent = '✅ Updated — loading...';
1556
+ _addRecentRepo(data.path);
1557
+ _refreshRepoDropdown();
1558
+ const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1559
+ if (repoSel) repoSel.value = data.path;
1560
+ loadRepository(ctx, data.path);
1561
+ setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1562
+ return;
1563
+ }
1564
+
1565
+ // SSE stream
1566
+ const reader = res.body!.getReader();
1567
+ const decoder = new TextDecoder();
1568
+ let buffer = '';
1569
+ while (true) {
1570
+ const { done, value } = await reader.read();
1571
+ if (done) break;
1572
+ buffer += decoder.decode(value, { stream: true });
1573
+ const events = buffer.split('\n\n');
1574
+ buffer = events.pop() || '';
1575
+ for (const evt of events) {
1576
+ if (!evt.trim()) continue;
1577
+ const eventMatch = evt.match(/^event:\s*(.+)/m);
1578
+ const dataMatch = evt.match(/^data:\s*(.+)/m);
1579
+ if (!dataMatch) continue;
1580
+ try {
1581
+ const payload = JSON.parse(dataMatch[1]);
1582
+ const evtType = eventMatch?.[1] || 'progress';
1583
+ if (evtType === 'progress' && progressText && progressFill) {
1584
+ progressText.textContent = `⏳ ${payload.message || 'Cloning...'}`;
1585
+ if (payload.percent != null) progressFill.style.width = `${payload.percent}%`;
1586
+ } else if (evtType === 'done') {
1587
+ cloneStatus.className = 'clone-status success';
1588
+ cloneStatus.textContent = '✅ Cloned — loading...';
1589
+ _addRecentRepo(payload.path);
1590
+ _refreshRepoDropdown();
1591
+ const repoSel2 = document.getElementById('repoSelect') as HTMLSelectElement;
1592
+ if (repoSel2) repoSel2.value = payload.path;
1593
+ loadRepository(ctx, payload.path);
1594
+ setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1595
+ } else if (evtType === 'error') {
1596
+ cloneStatus.className = 'clone-status error';
1597
+ cloneStatus.textContent = '❌ ' + (payload.error || 'Clone failed');
1598
+ setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
1599
+ }
1600
+ } catch { /* skip unparseable */ }
1601
+ }
1602
+ }
1603
+ }).catch(err => {
1604
+ cloneStatus.className = 'clone-status error';
1605
+ cloneStatus.textContent = '❌ ' + err.message;
1606
+ setTimeout(() => { cloneStatus.style.display = 'none'; }, 5000);
1607
+ });
1608
+ }
1609
+
1610
+ // ─── Time ago helper ────────────────────────────────────
1611
+ function _timeAgo(date: Date): string {
1612
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1613
+ if (seconds < 60) return 'just now';
1614
+ const minutes = Math.floor(seconds / 60);
1615
+ if (minutes < 60) return `${minutes}m ago`;
1616
+ const hours = Math.floor(minutes / 60);
1617
+ if (hours < 24) return `${hours}h ago`;
1618
+ const days = Math.floor(hours / 24);
1619
+ if (days < 30) return `${days}d ago`;
1620
+ const months = Math.floor(days / 30);
1621
+ if (months < 12) return `${months}mo ago`;
1622
+ return `${Math.floor(months / 12)}y ago`;
1623
+ }
1624
+
1625
+ // ─── Local Directory Drag and Drop ──────────────────────
1626
+ function setupDragAndDrop(ctx: CanvasContext) {
1627
+ window.addEventListener('dragover', (e) => {
1628
+ // Allow dropping if we drag over canvas/viewport
1629
+ e.preventDefault();
1630
+ if (e.dataTransfer) {
1631
+ e.dataTransfer.dropEffect = 'copy';
1632
+ }
1633
+ });
1634
+
1635
+ window.addEventListener('drop', async (e) => {
1636
+ e.preventDefault();
1637
+
1638
+ if (!e.dataTransfer || !e.dataTransfer.items) return;
1639
+ const items = e.dataTransfer.items;
1640
+
1641
+ const filesToUpload: File[] = [];
1642
+
1643
+ // Helper to recursively read directory contents
1644
+ async function readEntry(entry: any, path = '') {
1645
+ if (entry.isFile) {
1646
+ const file: any = await new Promise(resolve => entry.file(resolve));
1647
+ // Ignore heavy directories
1648
+ if (!path.includes('node_modules/') && !path.includes('.git/') && !path.includes('.bun/')) {
1649
+ file.fullPath = path + entry.name;
1650
+ filesToUpload.push(file);
1651
+ }
1652
+ } else if (entry.isDirectory) {
1653
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.bun') return;
1654
+ const dirReader = entry.createReader();
1655
+ const entries: any[] = await new Promise(resolve => {
1656
+ const results: any[] = [];
1657
+ const readNext = () => {
1658
+ dirReader.readEntries((ent: any[]) => {
1659
+ if (ent.length === 0) resolve(results);
1660
+ else { results.push(...ent); readNext(); }
1661
+ });
1662
+ };
1663
+ readNext();
1664
+ });
1665
+ for (const ent of entries) {
1666
+ await readEntry(ent, path + entry.name + '/');
1667
+ }
1668
+ }
1669
+ }
1670
+
1671
+ // Display a loading indication
1672
+ const cloneStatus = document.getElementById('cloneStatus');
1673
+ const cloneInput = document.getElementById('cloneUrlInput') as HTMLInputElement;
1674
+ if (cloneStatus) {
1675
+ cloneStatus.style.display = 'block';
1676
+ cloneStatus.className = 'clone-status cloning';
1677
+ cloneStatus.innerHTML = `
1678
+ <div class="clone-progress-text">⏳ Reading dropped files...</div>
1679
+ <div class="clone-progress-bar"><div class="clone-progress-fill" style="width: 50%"></div></div>
1680
+ `;
1681
+ }
1682
+
1683
+ try {
1684
+ for (let i = 0; i < items.length; i++) {
1685
+ const item = items[i];
1686
+ if (item.kind === 'file') {
1687
+ const entry = item.webkitGetAsEntry();
1688
+ if (entry) await readEntry(entry);
1689
+ }
1690
+ }
1691
+
1692
+ if (filesToUpload.length === 0) {
1693
+ if (cloneStatus) {
1694
+ cloneStatus.className = 'clone-status error';
1695
+ cloneStatus.textContent = '❌ No valid files found in drop';
1696
+ setTimeout(() => cloneStatus.style.display = 'none', 3000);
1697
+ }
1698
+ return;
1699
+ }
1700
+
1701
+ if (cloneStatus) {
1702
+ const progressText = cloneStatus.querySelector('.clone-progress-text');
1703
+ if (progressText) progressText.textContent = `⏳ Uploading ${filesToUpload.length} files...`;
1704
+ }
1705
+
1706
+ const formData = new FormData();
1707
+ filesToUpload.forEach(f => {
1708
+ formData.append('files', f, (f as any).fullPath);
1709
+ });
1710
+
1711
+ const res = await fetch('/api/repo/upload', {
1712
+ method: 'POST',
1713
+ body: formData
1714
+ });
1715
+
1716
+ const data = await res.json();
1717
+
1718
+ if (!res.ok || data.error) {
1719
+ throw new Error(data.error || 'Upload failed');
1720
+ }
1721
+
1722
+ if (cloneStatus) {
1723
+ cloneStatus.className = 'clone-status success';
1724
+ cloneStatus.textContent = '✅ Upload complete — loading...';
1725
+ if (cloneInput) cloneInput.value = '';
1726
+
1727
+ // Add to recent repos dropdown and load it
1728
+ const repoPath = data.path;
1729
+ _addRecentRepo(repoPath);
1730
+ _refreshRepoDropdown();
1731
+ const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1732
+ if (repoSel) repoSel.value = repoPath;
1733
+ import('./repo').then(m => m.loadRepository(ctx, repoPath));
1734
+
1735
+ setTimeout(() => cloneStatus.style.display = 'none', 3000);
1736
+ }
1737
+
1738
+ } catch (err: any) {
1739
+ if (cloneStatus) {
1740
+ cloneStatus.className = 'clone-status error';
1741
+ cloneStatus.textContent = '❌ ' + (err.message || 'Error processing drop');
1742
+ setTimeout(() => cloneStatus.style.display = 'none', 5000);
1743
+ }
1744
+ }
1745
+ });
1746
+ }
1747
+