gitmaps 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +265 -122
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/[owner]/[repo]/page.client.tsx +5 -0
  5. package/app/[slug]/page.client.tsx +5 -0
  6. package/app/analytics.db +0 -0
  7. package/app/api/analytics/route.ts +64 -0
  8. package/app/api/auth/positions/route.ts +95 -33
  9. package/app/api/build-info/route.ts +19 -0
  10. package/app/api/chat/route.ts +13 -2
  11. package/app/api/manifest.json/route.ts +20 -0
  12. package/app/api/og-image/route.ts +14 -0
  13. package/app/api/pwa-icon/route.ts +14 -0
  14. package/app/api/repo/clone-stream/route.ts +20 -12
  15. package/app/api/repo/file-content/route.ts +73 -20
  16. package/app/api/repo/imports/route.ts +21 -3
  17. package/app/api/repo/list/route.ts +30 -0
  18. package/app/api/repo/load/route.test.ts +62 -0
  19. package/app/api/repo/load/route.ts +41 -1
  20. package/app/api/repo/pdf-thumb/route.ts +127 -0
  21. package/app/api/repo/resolve-slug/route.ts +51 -0
  22. package/app/api/repo/tree/route.ts +188 -104
  23. package/app/api/repo/upload/route.ts +6 -9
  24. package/app/api/sw.js/route.ts +70 -0
  25. package/app/api/version/route.ts +26 -0
  26. package/app/galaxy-canvas/page.client.tsx +2 -0
  27. package/app/galaxy-canvas/page.tsx +5 -0
  28. package/app/globals.css +5844 -4694
  29. package/app/icon.png +0 -0
  30. package/app/layout.tsx +1284 -467
  31. package/app/lib/auto-arrange.test.ts +158 -0
  32. package/app/lib/auto-arrange.ts +147 -0
  33. package/app/lib/canvas-export.ts +358 -358
  34. package/app/lib/canvas-text.ts +4 -72
  35. package/app/lib/canvas.ts +625 -564
  36. package/app/lib/card-arrangement.ts +21 -7
  37. package/app/lib/card-context-menu.tsx +2 -2
  38. package/app/lib/card-groups.ts +9 -2
  39. package/app/lib/cards.tsx +1361 -914
  40. package/app/lib/chat.tsx +65 -9
  41. package/app/lib/code-editor.ts +86 -2
  42. package/app/lib/connections.tsx +34 -43
  43. package/app/lib/context.test.ts +32 -0
  44. package/app/lib/context.ts +19 -3
  45. package/app/lib/cursor-sharing.ts +34 -0
  46. package/app/lib/events.tsx +76 -73
  47. package/app/lib/export-canvas.ts +287 -0
  48. package/app/lib/file-card-plugin.ts +148 -134
  49. package/app/lib/file-modal.tsx +49 -0
  50. package/app/lib/file-preview.ts +486 -400
  51. package/app/lib/github-import.test.ts +424 -0
  52. package/app/lib/global-search.ts +48 -27
  53. package/app/lib/initial-route-hydration.test.ts +283 -0
  54. package/app/lib/initial-route-hydration.ts +202 -0
  55. package/app/lib/landing-reset.test.ts +99 -0
  56. package/app/lib/landing-reset.ts +106 -0
  57. package/app/lib/landing-shell.test.ts +75 -0
  58. package/app/lib/large-repo-optimization.ts +37 -0
  59. package/app/lib/layers.tsx +17 -18
  60. package/app/lib/layout-snapshots.ts +320 -0
  61. package/app/lib/loading.test.ts +69 -0
  62. package/app/lib/loading.tsx +160 -45
  63. package/app/lib/mount-cleanup.test.ts +52 -0
  64. package/app/lib/mount-cleanup.ts +34 -0
  65. package/app/lib/mount-init.test.ts +123 -0
  66. package/app/lib/mount-init.ts +107 -0
  67. package/app/lib/mount-lifecycle.test.ts +39 -0
  68. package/app/lib/mount-lifecycle.ts +12 -0
  69. package/app/lib/mount-route-wiring.test.ts +87 -0
  70. package/app/lib/mount-route-wiring.ts +84 -0
  71. package/app/lib/multi-repo.ts +14 -0
  72. package/app/lib/onboarding-tutorial.ts +278 -0
  73. package/app/lib/perf-overlay.ts +78 -0
  74. package/app/lib/positions.ts +191 -122
  75. package/app/lib/recent-commits.test.ts +869 -0
  76. package/app/lib/recent-commits.ts +227 -0
  77. package/app/lib/repo-handoff.test.ts +23 -0
  78. package/app/lib/repo-handoff.ts +16 -0
  79. package/app/lib/repo-progressive.ts +119 -0
  80. package/app/lib/repo-select.test.ts +61 -0
  81. package/app/lib/repo-select.ts +74 -0
  82. package/app/lib/repo.tsx +1383 -977
  83. package/app/lib/role.ts +228 -0
  84. package/app/lib/route-catchall.test.ts +27 -0
  85. package/app/lib/route-repo-entry.test.ts +95 -0
  86. package/app/lib/route-repo-entry.ts +36 -0
  87. package/app/lib/router-contract.test.ts +22 -0
  88. package/app/lib/router-contract.ts +19 -0
  89. package/app/lib/shared-layout.test.ts +86 -0
  90. package/app/lib/shared-layout.ts +82 -0
  91. package/app/lib/shortcuts-panel.ts +2 -0
  92. package/app/lib/status-bar.test.ts +118 -0
  93. package/app/lib/status-bar.ts +365 -128
  94. package/app/lib/sync-controls.test.ts +43 -0
  95. package/app/lib/sync-controls.tsx +303 -0
  96. package/app/lib/test-dom.ts +145 -0
  97. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  98. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  99. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  100. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  101. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  102. package/app/lib/transclusion-smoke.test.ts +163 -0
  103. package/app/lib/tutorial.ts +301 -0
  104. package/app/lib/version.ts +93 -0
  105. package/app/lib/viewport-culling.ts +740 -728
  106. package/app/lib/virtual-files.ts +456 -0
  107. package/app/lib/webgl-text.ts +189 -0
  108. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
  109. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  110. package/app/og-image.png +0 -0
  111. package/app/page.client.tsx +70 -215
  112. package/app/page.tsx +27 -92
  113. package/app/state/machine.js +13 -0
  114. package/banner.png +0 -0
  115. package/package.json +17 -8
  116. package/server.ts +11 -1
  117. package/app/api/connections/route.ts +0 -72
  118. package/app/api/positions/route.ts +0 -80
  119. package/app/api/repo/browse/route.ts +0 -55
  120. package/app/lib/pr-review.ts +0 -374
  121. package/packages/galaxydraw/README.md +0 -296
  122. package/packages/galaxydraw/banner.png +0 -0
  123. package/packages/galaxydraw/demo/build-static.ts +0 -100
  124. package/packages/galaxydraw/demo/client.ts +0 -154
  125. package/packages/galaxydraw/demo/dist/client.js +0 -8
  126. package/packages/galaxydraw/demo/index.html +0 -256
  127. package/packages/galaxydraw/demo/server.ts +0 -96
  128. package/packages/galaxydraw/dist/index.js +0 -984
  129. package/packages/galaxydraw/dist/index.js.map +0 -16
  130. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  131. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  132. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  133. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  134. package/packages/galaxydraw/package.json +0 -49
  135. package/packages/galaxydraw/perf.test.ts +0 -284
  136. package/packages/galaxydraw/src/core/cards.ts +0 -435
  137. package/packages/galaxydraw/src/core/engine.ts +0 -339
  138. package/packages/galaxydraw/src/core/events.ts +0 -81
  139. package/packages/galaxydraw/src/core/layout.ts +0 -136
  140. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  141. package/packages/galaxydraw/src/core/state.ts +0 -177
  142. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  143. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  144. package/packages/galaxydraw/src/index.ts +0 -40
  145. package/packages/galaxydraw/tsconfig.json +0 -30
package/app/lib/canvas.ts CHANGED
@@ -1,564 +1,625 @@
1
- // @ts-nocheck
2
- /**
3
- * Canvas transform, zoom, minimap, fit-all.
4
- */
5
- import { measure } from 'measure-fn';
6
- import { updateStatusBarZoom } from './status-bar';
7
- import type { CanvasContext } from './context';
8
- import { scheduleViewportCulling, uncullAllCards, markTransformActive, clearAllPills } from './viewport-culling';
9
- import { getGalaxyDrawState } from './galaxydraw-bridge';
10
-
11
- // ─── Minimap cached state (avoids full rebuild on every pan/zoom) ──
12
- let _mmCache: {
13
- minX: number; minY: number; maxX: number; maxY: number;
14
- scale: number; mmW: number; mmH: number;
15
- dotEls: Map<string, { dot: HTMLElement; label: HTMLElement }>;
16
- } | null = null;
17
- let _mmRebuildTimer: any = null;
18
-
19
- export function restoreViewport(ctx: CanvasContext) {
20
- const state = ctx.snap().context;
21
- if (!state.repoPath) return;
22
- try {
23
- const saved = localStorage.getItem(`gitcanvas:viewport:${state.repoPath}`);
24
- if (saved) {
25
- const vp = JSON.parse(saved);
26
- if (vp.zoom) ctx.actor.send({ type: 'SET_ZOOM', zoom: vp.zoom });
27
- if (vp.x !== undefined && vp.y !== undefined) ctx.actor.send({ type: 'SET_OFFSET', x: vp.x, y: vp.y });
28
- }
29
- } catch (e) { }
30
- }
31
-
32
- // ─── Update canvas CSS transform from state ─────────────
33
- export function updateCanvasTransform(ctx: CanvasContext) {
34
- if (!ctx.canvas) return;
35
- markTransformActive(); // Signal that user is actively panning/zooming
36
- const state = ctx.snap().context;
37
-
38
- // Phase 2: delegate to GalaxyDraw state engine if available
39
- const gdState = getGalaxyDrawState();
40
- if (gdState) {
41
- // Sync XState GalaxyDraw
42
- gdState.zoom = state.zoom;
43
- gdState.offsetX = state.offsetX;
44
- gdState.offsetY = state.offsetY;
45
- gdState.applyTransform();
46
- } else {
47
- // Fallback: manual transform (pre-bridge init)
48
- ctx.canvas.style.transform = `translate(${Math.round(state.offsetX)}px, ${Math.round(state.offsetY)}px) scale(${state.zoom})`;
49
- }
50
-
51
- // Cheap: only move the viewport rect using cached bounds
52
- updateMinimapViewport(ctx);
53
- // Schedule viewport culling (debounced to next rAF)
54
- scheduleViewportCulling(ctx);
55
-
56
- if (state.repoPath) {
57
- if ((window as any)._saveViewportTimer) clearTimeout((window as any)._saveViewportTimer);
58
- (window as any)._saveViewportTimer = setTimeout(() => {
59
- localStorage.setItem(`gitcanvas:viewport:${state.repoPath}`, JSON.stringify({
60
- zoom: state.zoom,
61
- x: state.offsetX,
62
- y: state.offsetY
63
- }));
64
- }, 300);
65
- }
66
-
67
- // Notify cursor sharing of viewport change
68
- window.dispatchEvent(new Event('gitcanvas:viewport-changed'));
69
- }
70
-
71
- // ─── Update zoom slider UI ──────────────────────────────
72
- export function updateZoomUI(ctx: CanvasContext) {
73
- const state = ctx.snap().context;
74
- const zoomPct = `${Math.round(state.zoom * 100)}%`;
75
-
76
- // Sidebar slider
77
- const slider = document.getElementById('zoomSlider') as HTMLInputElement;
78
- const value = document.getElementById('zoomValue');
79
- if (slider) slider.value = state.zoom;
80
- if (value) value.textContent = zoomPct;
81
-
82
- // Sticky zoom pill
83
- const stickySlider = document.getElementById('stickyZoomSlider') as HTMLInputElement;
84
- const stickyValue = document.getElementById('stickyZoomValue');
85
- if (stickySlider) stickySlider.value = String(state.zoom);
86
- if (stickyValue) stickyValue.textContent = zoomPct;
87
-
88
- updateStatusBarZoom(state.zoom);
89
- }
90
-
91
- // ─── Cheap viewport-only minimap update ─────────────────
92
- function updateMinimapViewport(ctx: CanvasContext) {
93
- const viewport = document.getElementById('minimapViewport');
94
- if (!viewport || !_mmCache || !ctx.canvasViewport) return;
95
-
96
- const state = ctx.snap().context;
97
- const canvasRect = ctx.canvasViewport.getBoundingClientRect();
98
- const { scale, minX, minY } = _mmCache;
99
-
100
- const vpWorldW = canvasRect.width / state.zoom;
101
- const vpWorldH = canvasRect.height / state.zoom;
102
- const vpWorldX = -state.offsetX / state.zoom;
103
- const vpWorldY = -state.offsetY / state.zoom;
104
-
105
- viewport.style.width = `${vpWorldW * scale}px`;
106
- viewport.style.height = `${vpWorldH * scale}px`;
107
- viewport.style.left = `${(vpWorldX - minX) * scale}px`;
108
- viewport.style.top = `${(vpWorldY - minY) * scale}px`;
109
- }
110
-
111
- // ─── Full minimap rebuild (debounced, expensive) ────────
112
- export function updateMinimap(ctx: CanvasContext) {
113
- // Debounce full rebuilds to max once per 120ms
114
- if (_mmRebuildTimer) clearTimeout(_mmRebuildTimer);
115
- _mmRebuildTimer = setTimeout(() => {
116
- _mmRebuildTimer = null;
117
- _rebuildMinimap(ctx);
118
- }, 120);
119
- // Always do cheap viewport update immediately
120
- updateMinimapViewport(ctx);
121
- }
122
-
123
- /** Force an immediate full minimap rebuild (skip debounce). */
124
- export function forceMinimapRebuild(ctx: CanvasContext) {
125
- if (_mmRebuildTimer) { clearTimeout(_mmRebuildTimer); _mmRebuildTimer = null; }
126
- _rebuildMinimap(ctx);
127
- }
128
-
129
- function _rebuildMinimap(ctx: CanvasContext) {
130
- const minimap = document.getElementById('minimap');
131
- const viewport = document.getElementById('minimapViewport');
132
- const state = ctx.snap().context;
133
-
134
- if (!minimap || !viewport) return;
135
-
136
- // Remove old labels/dots
137
- if (_mmCache) {
138
- _mmCache.dotEls.forEach(({ dot, label }) => { dot.remove(); label.remove(); });
139
- }
140
-
141
- // Calculate actual bounding box from all file cards (DOM + deferred)
142
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
143
- const cardInfos: { x: number; y: number; w: number; h: number; name: string; status: string; path: string; changed: boolean; displayPath?: string }[] = [];
144
-
145
- ctx.fileCards.forEach((card, path) => {
146
- const x = parseFloat(card.style.left);
147
- const y = parseFloat(card.style.top);
148
- // Skip cards with invalid positions (NaN poisons Math.min/max)
149
- if (isNaN(x) || isNaN(y)) return;
150
- const w = card.offsetWidth || 580;
151
- const h = card.offsetHeight || 200;
152
- const name = path.split('/').pop() || path;
153
- const parts = path.split('/');
154
- const displayPath = parts.length > 1 ? parts.slice(-2).join('/') : name;
155
- const status = card.dataset.status || card.className.match(/file-card--(\w+)/)?.[1] || 'default';
156
- const changed = card.dataset.changed === 'true';
157
-
158
- minX = Math.min(minX, x);
159
- minY = Math.min(minY, y);
160
- maxX = Math.max(maxX, x + w);
161
- maxY = Math.max(maxY, y + h);
162
-
163
- cardInfos.push({ x, y, w, h, name, displayPath, status, path, changed });
164
- });
165
-
166
- // Also include deferred cards (not yet in DOM but positioned on canvas)
167
- if (ctx.deferredCards) {
168
- ctx.deferredCards.forEach((entry, path) => {
169
- // Skip if already in fileCards (shouldn't happen, but safety)
170
- if (ctx.fileCards.has(path)) return;
171
- const x = entry.x;
172
- const y = entry.y;
173
- // Skip cards with invalid positions
174
- if (isNaN(x) || isNaN(y)) return;
175
- const w = entry.size?.width || 580;
176
- const h = entry.size?.height || 700;
177
- const name = path.split('/').pop() || path;
178
- const parts = path.split('/');
179
- const displayPath = parts.length > 1 ? parts.slice(-2).join('/') : name;
180
- const changed = !!entry.isChanged;
181
-
182
- minX = Math.min(minX, x);
183
- minY = Math.min(minY, y);
184
- maxX = Math.max(maxX, x + w);
185
- maxY = Math.max(maxY, y + h);
186
-
187
- cardInfos.push({ x, y, w, h, name, displayPath, status: changed ? 'modified' : 'default', path, changed });
188
- });
189
- }
190
-
191
- // If no cards, just hide viewport
192
- if (cardInfos.length === 0) {
193
- viewport.style.display = 'none';
194
- _mmCache = null;
195
- return;
196
- }
197
- viewport.style.display = '';
198
-
199
- // Add padding around content
200
- const pad = 200;
201
- minX -= pad; minY -= pad;
202
- maxX += pad; maxY += pad;
203
-
204
- const contentW = maxX - minX;
205
- const contentH = maxY - minY;
206
- const mmW = minimap.offsetWidth;
207
- const mmH = minimap.offsetHeight;
208
-
209
- // Guard: if minimap hasn't been laid out yet, defer rebuild
210
- if (mmW === 0 || mmH === 0 || contentW <= 0 || contentH <= 0) {
211
- requestAnimationFrame(() => _rebuildMinimap(ctx));
212
- return;
213
- }
214
-
215
- // Scale to fit content in minimap
216
- const scale = Math.min(mmW / contentW, mmH / contentH);
217
-
218
- // Build DOM in a fragment for one reflow
219
- const frag = document.createDocumentFragment();
220
- const dotEls = new Map<string, { dot: HTMLElement; label: HTMLElement }>();
221
-
222
- cardInfos.forEach((info, idx) => {
223
- const dotX = (info.x - minX) * scale;
224
- const dotY = (info.y - minY) * scale;
225
- const dotW = Math.max(2, info.w * scale);
226
- const dotH = Math.max(1, info.h * scale);
227
-
228
- // Colored dot for file
229
- const dot = document.createElement('div');
230
- const statusClass = ['added', 'modified', 'deleted', 'renamed', 'copied'].includes(info.status) ? info.status : 'default';
231
- dot.className = `minimap-dot minimap-dot--${statusClass}`;
232
- // In all-files mode, highlight changed files
233
- if (info.changed) {
234
- dot.classList.add('minimap-dot--changed');
235
- }
236
- dot.dataset.path = info.name;
237
- dot.style.cssText = `left:${dotX}px;top:${dotY}px;width:${dotW}px;height:${dotH}px`;
238
- frag.appendChild(dot);
239
-
240
- // File name label
241
- const label = document.createElement('div');
242
- label.className = 'minimap-label';
243
- label.textContent = info.name;
244
- label.style.cssText = `left:${dotX}px;top:${dotY}px;width:${dotW}px;height:${dotH}px`;
245
-
246
- if (dotH > dotW * 1.5) {
247
- const fontSize = Math.max(3, Math.min(dotW * 0.7, 7));
248
- label.style.fontSize = `${fontSize}px`;
249
- label.style.writingMode = 'vertical-rl';
250
- label.style.textOrientation = 'mixed';
251
- label.style.whiteSpace = 'nowrap';
252
- } else {
253
- const fontSize = Math.max(3, Math.min(dotH * 0.6, dotW * 0.15, 7));
254
- label.style.fontSize = `${fontSize}px`;
255
- label.style.whiteSpace = 'nowrap';
256
- }
257
- frag.appendChild(label);
258
- dotEls.set(info.path, { dot, label });
259
-
260
- // Hover tooltip: show enlarged file name
261
- dot.addEventListener('mouseenter', () => {
262
- // Remove any existing tooltip
263
- minimap.querySelector('.minimap-tooltip')?.remove();
264
- const tooltip = document.createElement('div');
265
- tooltip.className = 'minimap-tooltip';
266
- tooltip.textContent = info.displayPath;
267
- tooltip.style.left = `${dotX + dotW / 2}px`;
268
- tooltip.style.top = `${dotY}px`;
269
- minimap.appendChild(tooltip);
270
- });
271
- dot.addEventListener('mouseleave', () => {
272
- minimap.querySelector('.minimap-tooltip')?.remove();
273
- });
274
- });
275
-
276
- minimap.appendChild(frag);
277
-
278
- // Cache bounds + scale + elements for cheap viewport-only updates
279
- _mmCache = { minX, minY, maxX, maxY, scale, mmW, mmH, dotEls };
280
-
281
- // Viewport rectangle (immediate)
282
- const canvasRect = ctx.canvasViewport.getBoundingClientRect();
283
- const vpWorldW = canvasRect.width / state.zoom;
284
- const vpWorldH = canvasRect.height / state.zoom;
285
- const vpWorldX = -state.offsetX / state.zoom;
286
- const vpWorldY = -state.offsetY / state.zoom;
287
-
288
- viewport.style.width = `${vpWorldW * scale}px`;
289
- viewport.style.height = `${vpWorldH * scale}px`;
290
- viewport.style.left = `${(vpWorldX - minX) * scale}px`;
291
- viewport.style.top = `${(vpWorldY - minY) * scale}px`;
292
- }
293
-
294
- // ─── Jump to a specific file on the canvas ──────────────
295
- export function jumpToFile(ctx: CanvasContext, filePath: string) {
296
- measure('canvas:jumpToFile', () => {
297
- let card = ctx.fileCards.get(filePath);
298
- let cardX: number, cardY: number, cardW: number, cardH: number;
299
-
300
- if (card) {
301
- cardX = parseFloat(card.style.left) || 0;
302
- cardY = parseFloat(card.style.top) || 0;
303
- cardW = card.offsetWidth || 580;
304
- cardH = card.offsetHeight || 200;
305
- } else if (ctx.deferredCards?.has(filePath)) {
306
- // Card is deferred (not yet in DOM) — get position from deferred data
307
- const entry = ctx.deferredCards.get(filePath)!;
308
- cardX = entry.x;
309
- cardY = entry.y;
310
- cardW = entry.size?.width || 580;
311
- cardH = entry.size?.height || 700;
312
- } else {
313
- // File not on current layer — try switching to its layer
314
- import('./layers').then(({ navigateToFileInLayer }) => {
315
- const switched = navigateToFileInLayer(ctx, filePath);
316
- if (switched) {
317
- // Layer switched and canvas re-rendered — retry jump after re-render settles
318
- setTimeout(() => jumpToFile(ctx, filePath), 500);
319
- }
320
- });
321
- return;
322
- }
323
-
324
- const vpRect = ctx.canvasViewport.getBoundingClientRect();
325
- const state = ctx.snap().context;
326
-
327
- // Target zoom: bring to readable level (at least 0.6, or current if already zoomed in)
328
- const targetZoom = Math.max(0.6, Math.min(state.zoom, 1));
329
- const newOffsetX = vpRect.width / 2 - (cardX + cardW / 2) * targetZoom;
330
- const newOffsetY = vpRect.height / 2 - (cardY + cardH / 2) * targetZoom;
331
-
332
- // Animate using CSS transition on the canvas element
333
- const canvasEl = ctx.canvas;
334
- if (canvasEl) {
335
- canvasEl.style.transition = 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
336
- }
337
-
338
- ctx.actor.send({ type: 'SET_ZOOM', zoom: targetZoom });
339
- ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
340
- updateCanvasTransform(ctx);
341
- updateZoomUI(ctx);
342
- updateMinimap(ctx);
343
-
344
- // Clean up transition after animation completes
345
- setTimeout(() => {
346
- if (canvasEl) {
347
- canvasEl.style.transition = '';
348
- }
349
- // Re-cull after animation settles (may need to materialize the card)
350
- scheduleViewportCulling(ctx);
351
-
352
- // Flash highlight on the card (may have been materialized by culling)
353
- const finalCard = ctx.fileCards.get(filePath);
354
- if (finalCard) {
355
- finalCard.style.outline = '2px solid var(--accent-primary)';
356
- finalCard.style.outlineOffset = '4px';
357
- setTimeout(() => {
358
- finalCard.style.outline = '';
359
- finalCard.style.outlineOffset = '';
360
- }, 1500);
361
- }
362
- }, 420);
363
- });
364
- }
365
-
366
- // ─── Fit all files in viewport ──────────────────────────
367
- export function fitAllFiles(ctx: CanvasContext) {
368
- measure('canvas:fitAll', () => {
369
- if (ctx.fileCards.size === 0 && (!ctx.deferredCards || ctx.deferredCards.size === 0)) {
370
- if (!ctx.canvasViewport) return;
371
- }
372
-
373
- // Temporarily uncull all cards so offsetWidth/Height are measurable
374
- uncullAllCards(ctx);
375
-
376
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
377
- ctx.fileCards.forEach(card => {
378
- const x = parseInt(card.style.left);
379
- const y = parseInt(card.style.top);
380
- if (isNaN(x) || isNaN(y)) return; // Skip cards without positions
381
- minX = Math.min(minX, x);
382
- minY = Math.min(minY, y);
383
- maxX = Math.max(maxX, x + (card.offsetWidth || 580));
384
- maxY = Math.max(maxY, y + (card.offsetHeight || 700));
385
- });
386
-
387
- // Include deferred cards in bounds
388
- if (ctx.deferredCards) {
389
- ctx.deferredCards.forEach((entry) => {
390
- minX = Math.min(minX, entry.x);
391
- minY = Math.min(minY, entry.y);
392
- maxX = Math.max(maxX, entry.x + (entry.size?.width || 580));
393
- maxY = Math.max(maxY, entry.y + (entry.size?.height || 700));
394
- });
395
- }
396
-
397
- const viewportRect = ctx.canvasViewport.getBoundingClientRect();
398
- const contentWidth = maxX - minX + 100;
399
- const contentHeight = maxY - minY + 100;
400
-
401
- const newZoom = Math.min(
402
- viewportRect.width / contentWidth,
403
- viewportRect.height / contentHeight,
404
- 1
405
- );
406
-
407
- const newOffsetX = (viewportRect.width - contentWidth * newZoom) / 2 - minX * newZoom + 50;
408
- const newOffsetY = (viewportRect.height - contentHeight * newZoom) / 2 - minY * newZoom + 50;
409
-
410
- ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
411
- ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
412
- updateCanvasTransform(ctx); // this also schedules re-culling
413
- updateZoomUI(ctx);
414
- });
415
- }
416
-
417
- // ─── Setup minimap click + scroll + resize handler ──────
418
- export function setupMinimapClick(ctx: CanvasContext) {
419
- measure('minimap:setupClick', () => {
420
- const minimap = document.getElementById('minimap');
421
- if (!minimap) return;
422
-
423
- // ── Resize handle ──
424
- const resizeHandle = document.createElement('div');
425
- resizeHandle.className = 'minimap-resize-handle';
426
- resizeHandle.textContent = '⤡';
427
- minimap.parentElement?.insertBefore(resizeHandle, minimap);
428
- // position handle at top-left of minimap
429
- resizeHandle.style.position = 'absolute';
430
- resizeHandle.style.bottom = `${minimap.offsetHeight - 2}px`;
431
- resizeHandle.style.right = `${minimap.offsetWidth - 2}px`;
432
-
433
- let isResizing = false;
434
- let resizeStartX = 0, resizeStartY = 0;
435
- let startW = 0, startH = 0;
436
-
437
- resizeHandle.addEventListener('mousedown', (e) => {
438
- e.preventDefault();
439
- e.stopPropagation();
440
- isResizing = true;
441
- resizeStartX = e.clientX;
442
- resizeStartY = e.clientY;
443
- startW = minimap.offsetWidth;
444
- startH = minimap.offsetHeight;
445
- document.body.style.cursor = 'nwse-resize';
446
- });
447
-
448
- window.addEventListener('mousemove', (e) => {
449
- if (!isResizing) return;
450
- // Dragging top-left: moving left increases width, moving up increases height
451
- const dx = resizeStartX - e.clientX;
452
- const dy = resizeStartY - e.clientY;
453
- const newW = Math.max(100, Math.min(600, startW + dx));
454
- const newH = Math.max(70, Math.min(400, startH + dy));
455
- minimap.style.width = `${newW}px`;
456
- minimap.style.height = `${newH}px`;
457
- // Reposition handle
458
- resizeHandle.style.bottom = `${newH - 2}px`;
459
- resizeHandle.style.right = `${newW - 2}px`;
460
- });
461
-
462
- window.addEventListener('mouseup', () => {
463
- if (isResizing) {
464
- isResizing = false;
465
- document.body.style.cursor = '';
466
- // Rebuild minimap to fit new size
467
- _rebuildMinimap(ctx);
468
- }
469
- });
470
-
471
- // Scroll over minimap pan camera (same as Space+scroll on canvas)
472
- minimap.addEventListener('wheel', (e) => {
473
- e.preventDefault();
474
- e.stopPropagation();
475
-
476
- const state = ctx.snap().context;
477
- const panSpeed = 1.5;
478
-
479
- if (e.shiftKey) {
480
- // Shift+scroll = horizontal pan
481
- const dx = e.deltaY * panSpeed;
482
- ctx.actor.send({ type: 'SET_OFFSET', x: state.offsetX - dx, y: state.offsetY });
483
- } else {
484
- // Vertical scroll = vertical pan, deltaX for horizontal
485
- const dy = e.deltaY * panSpeed;
486
- const dx = e.deltaX * panSpeed;
487
- ctx.actor.send({ type: 'SET_OFFSET', x: state.offsetX - dx, y: state.offsetY - dy });
488
- }
489
-
490
- updateCanvasTransform(ctx);
491
- updateMinimap(ctx);
492
- }, { passive: false });
493
-
494
- minimap.addEventListener('click', (e) => {
495
- const target = e.target as HTMLElement;
496
-
497
- if (target.classList.contains('minimap-dot') && target.dataset.path) {
498
- for (const [path] of ctx.fileCards) {
499
- const name = path.split('/').pop() || path;
500
- if (name === target.dataset.path) {
501
- jumpToFile(ctx, path);
502
- return;
503
- }
504
- }
505
- return;
506
- }
507
-
508
- const rect = minimap.getBoundingClientRect();
509
- const clickX = e.clientX - rect.left;
510
- const clickY = e.clientY - rect.top;
511
-
512
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
513
- ctx.fileCards.forEach((card) => {
514
- const x = parseFloat(card.style.left) || 0;
515
- const y = parseFloat(card.style.top) || 0;
516
- const w = card.offsetWidth || 580;
517
- const h = card.offsetHeight || 200;
518
- minX = Math.min(minX, x); minY = Math.min(minY, y);
519
- maxX = Math.max(maxX, x + w); maxY = Math.max(maxY, y + h);
520
- });
521
- if (minX === Infinity) return;
522
-
523
- const pad = 200;
524
- minX -= pad; minY -= pad;
525
- maxX += pad; maxY += pad;
526
- const contentW = maxX - minX;
527
- const contentH = maxY - minY;
528
- const mmW = minimap.offsetWidth;
529
- const mmH = minimap.offsetHeight;
530
- const scale = Math.min(mmW / contentW, mmH / contentH);
531
-
532
- const worldX = clickX / scale + minX;
533
- const worldY = clickY / scale + minY;
534
-
535
- const state = ctx.snap().context;
536
- const vpRect = ctx.canvasViewport.getBoundingClientRect();
537
- const newOffsetX = vpRect.width / 2 - worldX * state.zoom;
538
- const newOffsetY = vpRect.height / 2 - worldY * state.zoom;
539
-
540
- ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
541
- updateCanvasTransform(ctx);
542
- updateMinimap(ctx);
543
- });
544
- });
545
- }
546
-
547
- // ─── Clear all cards from canvas ────────────────────────
548
- export function clearCanvas(ctx: CanvasContext) {
549
- ctx.fileCards.forEach(card => card.remove());
550
- ctx.fileCards.clear();
551
- ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
552
- // Clear pill placeholders (zoomed-out view) — clears both DOM and internal Map
553
- clearAllPills(ctx);
554
- if (ctx.svgOverlay) ctx.svgOverlay.innerHTML = '';
555
- }
556
-
557
- // ─── Auto column count based on viewport width ─────────
558
- export function getAutoColumnCount(ctx: CanvasContext): number {
559
- const vpWidth = ctx.canvasViewport?.getBoundingClientRect().width || window.innerWidth;
560
- const cardWidth = 580;
561
- const gap = 40;
562
- const margin = 100;
563
- return Math.max(1, Math.floor((vpWidth - margin) / (cardWidth + gap)));
564
- }
1
+ // @ts-nocheck
2
+ /**
3
+ * Canvas transform, zoom, minimap, fit-all.
4
+ */
5
+ import { measure } from 'measure-fn';
6
+ import { updateStatusBarZoom } from './status-bar';
7
+ import type { CanvasContext } from './context';
8
+ import { scheduleViewportCulling, markTransformActive, clearAllPills } from './viewport-culling';
9
+ import { clearVirtualCards } from './virtual-files';
10
+ import { getGalaxyDrawState } from './xydraw-bridge';
11
+
12
+ // ─── Minimap cached state (avoids full rebuild on every pan/zoom) ──
13
+ let _mmCache: {
14
+ minX: number; minY: number; maxX: number; maxY: number;
15
+ scale: number; mmW: number; mmH: number;
16
+ dotEls: Map<string, { dot: HTMLElement; label: HTMLElement }>;
17
+ } | null = null;
18
+ let _mmRebuildTimer: any = null;
19
+
20
+ export function restoreViewport(ctx: CanvasContext) {
21
+ const state = ctx.snap().context;
22
+ if (!state.repoPath) return;
23
+
24
+ // Priority 0: ?layout= query param (full shared layout)
25
+ const layoutParam = new URLSearchParams(window.location.search).get('layout');
26
+ if (layoutParam) {
27
+ try {
28
+ const layout = JSON.parse(atob(layoutParam));
29
+ if (layout.zoom) ctx.actor.send({ type: 'SET_ZOOM', zoom: layout.zoom });
30
+ if (layout.offsetX !== undefined && layout.offsetY !== undefined) {
31
+ ctx.actor.send({ type: 'SET_OFFSET', x: layout.offsetX, y: layout.offsetY });
32
+ }
33
+ // Restore card positions
34
+ if (layout.positions) {
35
+ for (const [path, pos] of Object.entries(layout.positions)) {
36
+ ctx.positions.set(path, pos as any);
37
+ }
38
+ }
39
+ // Restore hidden files
40
+ if (layout.hiddenFiles) {
41
+ for (const f of layout.hiddenFiles) ctx.hiddenFiles.add(f);
42
+ }
43
+ // Clean URL
44
+ const cleanUrl = window.location.pathname;
45
+ history.replaceState(null, '', cleanUrl);
46
+ return;
47
+ } catch { }
48
+ }
49
+
50
+ // Priority 1: URL hash (viewport-only shared link)
51
+ const hashVp = parseViewportHash();
52
+ if (hashVp) {
53
+ if (hashVp.z) ctx.actor.send({ type: 'SET_ZOOM', zoom: hashVp.z });
54
+ if (hashVp.x !== undefined && hashVp.y !== undefined) ctx.actor.send({ type: 'SET_OFFSET', x: hashVp.x, y: hashVp.y });
55
+ history.replaceState(null, '', window.location.pathname + window.location.search);
56
+ return;
57
+ }
58
+
59
+ // Priority 2: localStorage (returning user)
60
+ try {
61
+ const saved = localStorage.getItem(`gitcanvas:viewport:${state.repoPath}`);
62
+ if (saved) {
63
+ const vp = JSON.parse(saved);
64
+ if (vp.zoom) ctx.actor.send({ type: 'SET_ZOOM', zoom: vp.zoom });
65
+ if (vp.x !== undefined && vp.y !== undefined) ctx.actor.send({ type: 'SET_OFFSET', x: vp.x, y: vp.y });
66
+ }
67
+ } catch (e) { }
68
+ }
69
+
70
+ /** Parse viewport state from URL hash: #z=0.5&x=-1000&y=-500 */
71
+ function parseViewportHash(): { z?: number; x?: number; y?: number } | null {
72
+ const hash = window.location.hash.slice(1);
73
+ if (!hash) return null;
74
+ const params = new URLSearchParams(hash);
75
+ const z = params.get('z');
76
+ const x = params.get('x');
77
+ const y = params.get('y');
78
+ if (!z && !x && !y) return null;
79
+ return {
80
+ z: z ? parseFloat(z) : undefined,
81
+ x: x ? parseFloat(x) : undefined,
82
+ y: y ? parseFloat(y) : undefined,
83
+ };
84
+ }
85
+
86
+ /** Get a shareable URL with current viewport encoded in hash */
87
+ export function getShareableLink(ctx: CanvasContext): string {
88
+ const state = ctx.snap().context;
89
+ const z = state.zoom.toFixed(3);
90
+ const x = Math.round(state.offsetX);
91
+ const y = Math.round(state.offsetY);
92
+ return `${window.location.origin}${window.location.pathname}#z=${z}&x=${x}&y=${y}`;
93
+ }
94
+
95
+ // ─── Update canvas CSS transform from state ─────────────
96
+ export function updateCanvasTransform(ctx: CanvasContext) {
97
+ if (!ctx.canvas) return;
98
+ markTransformActive(); // Signal that user is actively panning/zooming
99
+ const state = ctx.snap().context;
100
+
101
+ // Phase 2: delegate to GalaxyDraw state engine if available
102
+ const gdState = getGalaxyDrawState();
103
+ if (gdState) {
104
+ // Sync XState → GalaxyDraw
105
+ gdState.zoom = state.zoom;
106
+ gdState.offsetX = state.offsetX;
107
+ gdState.offsetY = state.offsetY;
108
+ gdState.applyTransform();
109
+ } else {
110
+ // Fallback: manual transform (pre-bridge init)
111
+ ctx.canvas.style.transform = `translate(${Math.round(state.offsetX)}px, ${Math.round(state.offsetY)}px) scale(${state.zoom})`;
112
+ }
113
+
114
+ // Cheap: only move the viewport rect using cached bounds
115
+ updateMinimapViewport(ctx);
116
+ // Schedule viewport culling (debounced to next rAF)
117
+ scheduleViewportCulling(ctx);
118
+
119
+ if (state.repoPath) {
120
+ if ((window as any)._saveViewportTimer) clearTimeout((window as any)._saveViewportTimer);
121
+ (window as any)._saveViewportTimer = setTimeout(() => {
122
+ localStorage.setItem(`gitcanvas:viewport:${state.repoPath}`, JSON.stringify({
123
+ zoom: state.zoom,
124
+ x: state.offsetX,
125
+ y: state.offsetY
126
+ }));
127
+ }, 300);
128
+ }
129
+
130
+ // Notify cursor sharing of viewport change
131
+ window.dispatchEvent(new Event('gitcanvas:viewport-changed'));
132
+ }
133
+
134
+ // ─── Update zoom slider UI ──────────────────────────────
135
+ export function updateZoomUI(ctx: CanvasContext) {
136
+ const state = ctx.snap().context;
137
+ const zoomPct = `${Math.round(state.zoom * 100)}%`;
138
+
139
+ // Sidebar slider
140
+ const slider = document.getElementById('zoomSlider') as HTMLInputElement;
141
+ const value = document.getElementById('zoomValue');
142
+ if (slider) slider.value = state.zoom;
143
+ if (value) value.textContent = zoomPct;
144
+
145
+ // Sticky zoom pill
146
+ const stickySlider = document.getElementById('stickyZoomSlider') as HTMLInputElement;
147
+ const stickyValue = document.getElementById('stickyZoomValue');
148
+ if (stickySlider) stickySlider.value = String(state.zoom);
149
+ if (stickyValue) stickyValue.textContent = zoomPct;
150
+
151
+ updateStatusBarZoom(state.zoom);
152
+ }
153
+
154
+ // ─── Cheap viewport-only minimap update ─────────────────
155
+ function updateMinimapViewport(ctx: CanvasContext) {
156
+ const viewport = document.getElementById('minimapViewport');
157
+ if (!viewport || !_mmCache || !ctx.canvasViewport) return;
158
+
159
+ const state = ctx.snap().context;
160
+ const canvasRect = ctx.canvasViewport.getBoundingClientRect();
161
+ const { scale, minX, minY } = _mmCache;
162
+
163
+ const vpWorldW = canvasRect.width / state.zoom;
164
+ const vpWorldH = canvasRect.height / state.zoom;
165
+ const vpWorldX = -state.offsetX / state.zoom;
166
+ const vpWorldY = -state.offsetY / state.zoom;
167
+
168
+ viewport.style.width = `${vpWorldW * scale}px`;
169
+ viewport.style.height = `${vpWorldH * scale}px`;
170
+ viewport.style.left = `${(vpWorldX - minX) * scale}px`;
171
+ viewport.style.top = `${(vpWorldY - minY) * scale}px`;
172
+ }
173
+
174
+ // ─── Full minimap rebuild (debounced, expensive) ────────
175
+ export function updateMinimap(ctx: CanvasContext) {
176
+ // Debounce full rebuilds to max once per 120ms
177
+ if (_mmRebuildTimer) clearTimeout(_mmRebuildTimer);
178
+ _mmRebuildTimer = setTimeout(() => {
179
+ _mmRebuildTimer = null;
180
+ _rebuildMinimap(ctx);
181
+ }, 120);
182
+ // Always do cheap viewport update immediately
183
+ updateMinimapViewport(ctx);
184
+ }
185
+
186
+ /** Force an immediate full minimap rebuild (skip debounce). */
187
+ export function forceMinimapRebuild(ctx: CanvasContext) {
188
+ if (_mmRebuildTimer) { clearTimeout(_mmRebuildTimer); _mmRebuildTimer = null; }
189
+ _rebuildMinimap(ctx);
190
+ }
191
+
192
+ function _rebuildMinimap(ctx: CanvasContext) {
193
+ const minimap = document.getElementById('minimap');
194
+ const viewport = document.getElementById('minimapViewport');
195
+ const state = ctx.snap().context;
196
+
197
+ if (!minimap || !viewport) return;
198
+
199
+ // Remove old labels/dots
200
+ if (_mmCache) {
201
+ _mmCache.dotEls.forEach(({ dot, label }) => { dot.remove(); label.remove(); });
202
+ }
203
+
204
+ // Calculate actual bounding box from all file cards (DOM + deferred)
205
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
206
+ const cardInfos: { x: number; y: number; w: number; h: number; name: string; status: string; path: string; changed: boolean; displayPath?: string }[] = [];
207
+
208
+ ctx.fileCards.forEach((card, path) => {
209
+ const x = parseFloat(card.style.left);
210
+ const y = parseFloat(card.style.top);
211
+ // Skip cards with invalid positions (NaN poisons Math.min/max)
212
+ if (isNaN(x) || isNaN(y)) return;
213
+ const w = card.offsetWidth || 580;
214
+ const h = card.offsetHeight || 700;
215
+ const name = path.split('/').pop() || path;
216
+ const parts = path.split('/');
217
+ const displayPath = parts.length > 1 ? parts.slice(-2).join('/') : name;
218
+ const status = card.dataset.status || card.className.match(/file-card--(\w+)/)?.[1] || 'default';
219
+ const changed = card.dataset.changed === 'true';
220
+
221
+ minX = Math.min(minX, x);
222
+ minY = Math.min(minY, y);
223
+ maxX = Math.max(maxX, x + w);
224
+ maxY = Math.max(maxY, y + h);
225
+
226
+ cardInfos.push({ x, y, w, h, name, displayPath, status, path, changed });
227
+ });
228
+
229
+ // Also include deferred cards (not yet in DOM but positioned on canvas)
230
+ if (ctx.deferredCards) {
231
+ ctx.deferredCards.forEach((entry, path) => {
232
+ // Skip if already in fileCards (shouldn't happen, but safety)
233
+ if (ctx.fileCards.has(path)) return;
234
+ const x = entry.x;
235
+ const y = entry.y;
236
+ // Skip cards with invalid positions
237
+ if (isNaN(x) || isNaN(y)) return;
238
+ const w = entry.size?.width || 580;
239
+ const h = entry.size?.height || 700;
240
+ const name = path.split('/').pop() || path;
241
+ const parts = path.split('/');
242
+ const displayPath = parts.length > 1 ? parts.slice(-2).join('/') : name;
243
+ const changed = !!entry.isChanged;
244
+
245
+ minX = Math.min(minX, x);
246
+ minY = Math.min(minY, y);
247
+ maxX = Math.max(maxX, x + w);
248
+ maxY = Math.max(maxY, y + h);
249
+
250
+ cardInfos.push({ x, y, w, h, name, displayPath, status: changed ? 'modified' : 'default', path, changed });
251
+ });
252
+ }
253
+
254
+ // If no cards, just hide viewport
255
+ if (cardInfos.length === 0) {
256
+ viewport.style.display = 'none';
257
+ _mmCache = null;
258
+ return;
259
+ }
260
+ viewport.style.display = '';
261
+
262
+ // Add padding around content
263
+ const pad = 200;
264
+ minX -= pad; minY -= pad;
265
+ maxX += pad; maxY += pad;
266
+
267
+ const contentW = maxX - minX;
268
+ const contentH = maxY - minY;
269
+ const mmW = minimap.offsetWidth;
270
+ const mmH = minimap.offsetHeight;
271
+
272
+ // Guard: if minimap hasn't been laid out yet, defer rebuild
273
+ if (mmW === 0 || mmH === 0 || contentW <= 0 || contentH <= 0) {
274
+ requestAnimationFrame(() => _rebuildMinimap(ctx));
275
+ return;
276
+ }
277
+
278
+ // Scale to fit content in minimap
279
+ const scale = Math.min(mmW / contentW, mmH / contentH);
280
+
281
+ // Build DOM in a fragment for one reflow
282
+ const frag = document.createDocumentFragment();
283
+ const dotEls = new Map<string, { dot: HTMLElement; label: HTMLElement }>();
284
+
285
+ cardInfos.forEach((info, idx) => {
286
+ const dotX = (info.x - minX) * scale;
287
+ const dotY = (info.y - minY) * scale;
288
+ const dotW = Math.max(2, info.w * scale);
289
+ const dotH = Math.max(1, info.h * scale);
290
+
291
+ // Colored dot for file
292
+ const dot = document.createElement('div');
293
+ const statusClass = ['added', 'modified', 'deleted', 'renamed', 'copied'].includes(info.status) ? info.status : 'default';
294
+ dot.className = `minimap-dot minimap-dot--${statusClass}`;
295
+ // In all-files mode, highlight changed files
296
+ if (info.changed) {
297
+ dot.classList.add('minimap-dot--changed');
298
+ }
299
+ dot.dataset.path = info.name;
300
+ dot.style.cssText = `left:${dotX}px;top:${dotY}px;width:${dotW}px;height:${dotH}px`;
301
+ frag.appendChild(dot);
302
+
303
+ // File name label
304
+ const label = document.createElement('div');
305
+ label.className = 'minimap-label';
306
+ label.textContent = info.name;
307
+ label.style.cssText = `left:${dotX}px;top:${dotY}px;width:${dotW}px;height:${dotH}px`;
308
+
309
+ if (dotH > dotW * 1.5) {
310
+ const fontSize = Math.max(3, Math.min(dotW * 0.7, 7));
311
+ label.style.fontSize = `${fontSize}px`;
312
+ label.style.writingMode = 'vertical-rl';
313
+ label.style.textOrientation = 'mixed';
314
+ label.style.whiteSpace = 'nowrap';
315
+ } else {
316
+ const fontSize = Math.max(3, Math.min(dotH * 0.6, dotW * 0.15, 7));
317
+ label.style.fontSize = `${fontSize}px`;
318
+ label.style.whiteSpace = 'nowrap';
319
+ }
320
+ frag.appendChild(label);
321
+ dotEls.set(info.path, { dot, label });
322
+
323
+ // Hover tooltip: show enlarged file name
324
+ dot.addEventListener('mouseenter', () => {
325
+ // Remove any existing tooltip
326
+ minimap.querySelector('.minimap-tooltip')?.remove();
327
+ const tooltip = document.createElement('div');
328
+ tooltip.className = 'minimap-tooltip';
329
+ tooltip.textContent = info.displayPath;
330
+ tooltip.style.left = `${dotX + dotW / 2}px`;
331
+ tooltip.style.top = `${dotY}px`;
332
+ minimap.appendChild(tooltip);
333
+ });
334
+ dot.addEventListener('mouseleave', () => {
335
+ minimap.querySelector('.minimap-tooltip')?.remove();
336
+ });
337
+ });
338
+
339
+ minimap.appendChild(frag);
340
+
341
+ // Cache bounds + scale + elements for cheap viewport-only updates
342
+ _mmCache = { minX, minY, maxX, maxY, scale, mmW, mmH, dotEls };
343
+
344
+ // Viewport rectangle (immediate)
345
+ const canvasRect = ctx.canvasViewport.getBoundingClientRect();
346
+ const vpWorldW = canvasRect.width / state.zoom;
347
+ const vpWorldH = canvasRect.height / state.zoom;
348
+ const vpWorldX = -state.offsetX / state.zoom;
349
+ const vpWorldY = -state.offsetY / state.zoom;
350
+
351
+ viewport.style.width = `${vpWorldW * scale}px`;
352
+ viewport.style.height = `${vpWorldH * scale}px`;
353
+ viewport.style.left = `${(vpWorldX - minX) * scale}px`;
354
+ viewport.style.top = `${(vpWorldY - minY) * scale}px`;
355
+ }
356
+
357
+ // ─── Jump to a specific file on the canvas ──────────────
358
+ export function jumpToFile(ctx: CanvasContext, filePath: string) {
359
+ measure('canvas:jumpToFile', () => {
360
+ let card = ctx.fileCards.get(filePath);
361
+ let cardX: number, cardY: number, cardW: number, cardH: number;
362
+
363
+ if (card) {
364
+ cardX = parseFloat(card.style.left) || 0;
365
+ cardY = parseFloat(card.style.top) || 0;
366
+ cardW = card.offsetWidth || 580;
367
+ cardH = card.offsetHeight || 200;
368
+ } else if (ctx.deferredCards?.has(filePath)) {
369
+ // Card is deferred (not yet in DOM) get position from deferred data
370
+ const entry = ctx.deferredCards.get(filePath)!;
371
+ cardX = entry.x;
372
+ cardY = entry.y;
373
+ cardW = entry.size?.width || 580;
374
+ cardH = entry.size?.height || 700;
375
+ } else {
376
+ // File not on current layer try switching to its layer
377
+ import('./layers').then(({ navigateToFileInLayer }) => {
378
+ const switched = navigateToFileInLayer(ctx, filePath);
379
+ if (switched) {
380
+ // Layer switched and canvas re-rendered retry jump after re-render settles
381
+ setTimeout(() => jumpToFile(ctx, filePath), 500);
382
+ }
383
+ });
384
+ return;
385
+ }
386
+
387
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
388
+ const state = ctx.snap().context;
389
+
390
+ // Target zoom: bring to readable level (at least 0.6, or current if already zoomed in)
391
+ const targetZoom = Math.max(0.6, Math.min(state.zoom, 1));
392
+ const newOffsetX = vpRect.width / 2 - (cardX + cardW / 2) * targetZoom;
393
+ const newOffsetY = vpRect.height / 2 - (cardY + cardH / 2) * targetZoom;
394
+
395
+ // Animate using CSS transition on the canvas element
396
+ const canvasEl = ctx.canvas;
397
+ if (canvasEl) {
398
+ canvasEl.style.transition = 'transform 400ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
399
+ }
400
+
401
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: targetZoom });
402
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
403
+ updateCanvasTransform(ctx);
404
+ updateZoomUI(ctx);
405
+ updateMinimap(ctx);
406
+
407
+ // Clean up transition after animation completes
408
+ setTimeout(() => {
409
+ if (canvasEl) {
410
+ canvasEl.style.transition = '';
411
+ }
412
+ // Re-cull after animation settles (may need to materialize the card)
413
+ scheduleViewportCulling(ctx);
414
+
415
+ // Flash highlight on the card (may have been materialized by culling)
416
+ const finalCard = ctx.fileCards.get(filePath);
417
+ if (finalCard) {
418
+ finalCard.style.outline = '2px solid var(--accent-primary)';
419
+ finalCard.style.outlineOffset = '4px';
420
+ setTimeout(() => {
421
+ finalCard.style.outline = '';
422
+ finalCard.style.outlineOffset = '';
423
+ }, 1500);
424
+ }
425
+ }, 420);
426
+ });
427
+ }
428
+
429
+ // ─── Fit all files in viewport ──────────────────────────
430
+ export function fitAllFiles(ctx: CanvasContext) {
431
+ measure('canvas:fitAll', () => {
432
+ if (ctx.fileCards.size === 0 && (!ctx.deferredCards || ctx.deferredCards.size === 0)) {
433
+ if (!ctx.canvasViewport) return;
434
+ }
435
+
436
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
437
+ ctx.fileCards.forEach(card => {
438
+ const x = parseInt(card.style.left);
439
+ const y = parseInt(card.style.top);
440
+ if (isNaN(x) || isNaN(y)) return; // Skip cards without positions
441
+ minX = Math.min(minX, x);
442
+ minY = Math.min(minY, y);
443
+ maxX = Math.max(maxX, x + (card.offsetWidth || 580));
444
+ maxY = Math.max(maxY, y + (card.offsetHeight || 700));
445
+ });
446
+
447
+ // Include deferred cards in bounds
448
+ if (ctx.deferredCards) {
449
+ ctx.deferredCards.forEach((entry) => {
450
+ minX = Math.min(minX, entry.x);
451
+ minY = Math.min(minY, entry.y);
452
+ maxX = Math.max(maxX, entry.x + (entry.size?.width || 580));
453
+ maxY = Math.max(maxY, entry.y + (entry.size?.height || 700));
454
+ });
455
+ }
456
+
457
+ const viewportRect = ctx.canvasViewport.getBoundingClientRect();
458
+ const contentWidth = maxX - minX + 100;
459
+ const contentHeight = maxY - minY + 100;
460
+
461
+ const newZoom = Math.min(
462
+ viewportRect.width / contentWidth,
463
+ viewportRect.height / contentHeight,
464
+ 1
465
+ );
466
+
467
+ const newOffsetX = (viewportRect.width - contentWidth * newZoom) / 2 - minX * newZoom + 50;
468
+ const newOffsetY = (viewportRect.height - contentHeight * newZoom) / 2 - minY * newZoom + 50;
469
+
470
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: newZoom });
471
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
472
+ updateCanvasTransform(ctx); // this also schedules re-culling
473
+ updateZoomUI(ctx);
474
+ });
475
+ }
476
+
477
+ // ─── Setup minimap click + scroll + resize handler ──────
478
+ export function setupMinimapClick(ctx: CanvasContext) {
479
+ measure('minimap:setupClick', () => {
480
+ const minimap = document.getElementById('minimap');
481
+ if (!minimap) return;
482
+
483
+ // ── Resize handle ──
484
+ const resizeHandle = document.createElement('div');
485
+ resizeHandle.className = 'minimap-resize-handle';
486
+ resizeHandle.textContent = '⤡';
487
+ minimap.parentElement?.insertBefore(resizeHandle, minimap);
488
+ // position handle at top-left of minimap
489
+ resizeHandle.style.position = 'absolute';
490
+ resizeHandle.style.bottom = `${minimap.offsetHeight - 2}px`;
491
+ resizeHandle.style.right = `${minimap.offsetWidth - 2}px`;
492
+
493
+ let isResizing = false;
494
+ let resizeStartX = 0, resizeStartY = 0;
495
+ let startW = 0, startH = 0;
496
+
497
+ resizeHandle.addEventListener('mousedown', (e) => {
498
+ e.preventDefault();
499
+ e.stopPropagation();
500
+ isResizing = true;
501
+ resizeStartX = e.clientX;
502
+ resizeStartY = e.clientY;
503
+ startW = minimap.offsetWidth;
504
+ startH = minimap.offsetHeight;
505
+ document.body.style.cursor = 'nwse-resize';
506
+ });
507
+
508
+ window.addEventListener('mousemove', (e) => {
509
+ if (!isResizing) return;
510
+ // Dragging top-left: moving left increases width, moving up increases height
511
+ const dx = resizeStartX - e.clientX;
512
+ const dy = resizeStartY - e.clientY;
513
+ const newW = Math.max(100, Math.min(600, startW + dx));
514
+ const newH = Math.max(70, Math.min(400, startH + dy));
515
+ minimap.style.width = `${newW}px`;
516
+ minimap.style.height = `${newH}px`;
517
+ // Reposition handle
518
+ resizeHandle.style.bottom = `${newH - 2}px`;
519
+ resizeHandle.style.right = `${newW - 2}px`;
520
+ });
521
+
522
+ window.addEventListener('mouseup', () => {
523
+ if (isResizing) {
524
+ isResizing = false;
525
+ document.body.style.cursor = '';
526
+ // Rebuild minimap to fit new size
527
+ _rebuildMinimap(ctx);
528
+ }
529
+ });
530
+
531
+ // Scroll over minimap → pan camera (same as Space+scroll on canvas)
532
+ minimap.addEventListener('wheel', (e) => {
533
+ e.preventDefault();
534
+ e.stopPropagation();
535
+
536
+ const state = ctx.snap().context;
537
+ const panSpeed = 1.5;
538
+
539
+ if (e.shiftKey) {
540
+ // Shift+scroll = horizontal pan
541
+ const dx = e.deltaY * panSpeed;
542
+ ctx.actor.send({ type: 'SET_OFFSET', x: state.offsetX - dx, y: state.offsetY });
543
+ } else {
544
+ // Vertical scroll = vertical pan, deltaX for horizontal
545
+ const dy = e.deltaY * panSpeed;
546
+ const dx = e.deltaX * panSpeed;
547
+ ctx.actor.send({ type: 'SET_OFFSET', x: state.offsetX - dx, y: state.offsetY - dy });
548
+ }
549
+
550
+ updateCanvasTransform(ctx);
551
+ updateMinimap(ctx);
552
+ }, { passive: false });
553
+
554
+ minimap.addEventListener('click', (e) => {
555
+ const target = e.target as HTMLElement;
556
+
557
+ if (target.classList.contains('minimap-dot') && target.dataset.path) {
558
+ for (const [path] of ctx.fileCards) {
559
+ const name = path.split('/').pop() || path;
560
+ if (name === target.dataset.path) {
561
+ jumpToFile(ctx, path);
562
+ return;
563
+ }
564
+ }
565
+ return;
566
+ }
567
+
568
+ const rect = minimap.getBoundingClientRect();
569
+ const clickX = e.clientX - rect.left;
570
+ const clickY = e.clientY - rect.top;
571
+
572
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
573
+ ctx.fileCards.forEach((card) => {
574
+ const x = parseFloat(card.style.left) || 0;
575
+ const y = parseFloat(card.style.top) || 0;
576
+ const w = card.offsetWidth || 580;
577
+ const h = card.offsetHeight || 200;
578
+ minX = Math.min(minX, x); minY = Math.min(minY, y);
579
+ maxX = Math.max(maxX, x + w); maxY = Math.max(maxY, y + h);
580
+ });
581
+ if (minX === Infinity) return;
582
+
583
+ const pad = 200;
584
+ minX -= pad; minY -= pad;
585
+ maxX += pad; maxY += pad;
586
+ const contentW = maxX - minX;
587
+ const contentH = maxY - minY;
588
+ const mmW = minimap.offsetWidth;
589
+ const mmH = minimap.offsetHeight;
590
+ const scale = Math.min(mmW / contentW, mmH / contentH);
591
+
592
+ const worldX = clickX / scale + minX;
593
+ const worldY = clickY / scale + minY;
594
+
595
+ const state = ctx.snap().context;
596
+ const vpRect = ctx.canvasViewport.getBoundingClientRect();
597
+ const newOffsetX = vpRect.width / 2 - worldX * state.zoom;
598
+ const newOffsetY = vpRect.height / 2 - worldY * state.zoom;
599
+
600
+ ctx.actor.send({ type: 'SET_OFFSET', x: newOffsetX, y: newOffsetY });
601
+ updateCanvasTransform(ctx);
602
+ updateMinimap(ctx);
603
+ });
604
+ });
605
+ }
606
+
607
+ // ─── Clear all cards from canvas ────────────────────────
608
+ export function clearCanvas(ctx: CanvasContext) {
609
+ ctx.fileCards.forEach(card => card.remove());
610
+ ctx.fileCards.clear();
611
+ ctx.canvas?.querySelectorAll('.dir-label').forEach(el => el.remove());
612
+ clearVirtualCards(ctx);
613
+ // Clear pill placeholders (zoomed-out view) — clears both DOM and internal Map
614
+ clearAllPills(ctx);
615
+ if (ctx.svgOverlay) ctx.svgOverlay.innerHTML = '';
616
+ }
617
+
618
+ // ─── Auto column count based on viewport width ─────────
619
+ export function getAutoColumnCount(ctx: CanvasContext): number {
620
+ const vpWidth = ctx.canvasViewport?.getBoundingClientRect().width || window.innerWidth;
621
+ const cardWidth = 580;
622
+ const gap = 40;
623
+ const margin = 100;
624
+ return Math.max(1, Math.floor((vpWidth - margin) / (cardWidth + gap)));
625
+ }