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
@@ -1,728 +1,740 @@
1
- // @ts-nocheck
2
- /**
3
- * Viewport culling + LOD (Level of Detail) system
4
- *
5
- * Cards outside the viewport have their content stripped (innerHTML = '')
6
- * and get `data-culled="true"`. When they scroll back into view, their
7
- * content is rebuilt from the stored file data.
8
- *
9
- * LOD System (zoom-aware):
10
- * zoom > LOD_ZOOM_THRESHOLD (0.25): Full file cards with content
11
- * zoom <= LOD_ZOOM_THRESHOLD: Lightweight "pill" placeholders
12
- *
13
- * This prevents mass-materialization when zooming out on large repos
14
- * (e.g. React with 6833 files). Instead of creating 6833 full DOM cards,
15
- * we create tiny colored rectangles that swap to full cards on zoom-in.
16
- *
17
- * Materialization throttle: When many deferred cards enter viewport at
18
- * once, we materialize them in batches (MAX_MATERIALIZE_PER_FRAME) to
19
- * prevent frame drops.
20
- *
21
- * Performance: O(n) per frame with n = total cards. The check is a simple
22
- * AABB overlap test — no spatial indexing needed for < 10K cards.
23
- */
24
- import { measure } from 'measure-fn';
25
- import type { CanvasContext } from './context';
26
- import { materializeViewport } from './galaxydraw-bridge';
27
-
28
- // ── Culling state ──────────────────────────────────────────
29
- let _cullRafPending = false;
30
- let _cullEnabled = true;
31
-
32
- // Margin in viewport pixels — cards within this margin outside the visible
33
- // area are pre-rendered so scrolling feels instant.
34
- const VIEWPORT_MARGIN = 500;
35
-
36
- // LOD threshold: below this zoom level, use lightweight pill placeholders
37
- const LOD_ZOOM_THRESHOLD = 0.25;
38
-
39
- // Maximum deferred cards to materialize per animation frame
40
- // Prevents frame drops when zooming out then back in on huge repos
41
- const MAX_MATERIALIZE_PER_FRAME = 8;
42
-
43
- // Cooldown: don't materialize during rapid pan/zoom — wait until settled
44
- let _lastTransformTime = 0;
45
- const MATERIALIZE_COOLDOWN_MS = 150;
46
-
47
- /** Call from updateCanvasTransform to signal active interaction */
48
- export function markTransformActive() {
49
- _lastTransformTime = performance.now();
50
- }
51
-
52
- // Track current LOD mode so we can detect transitions
53
- let _currentLodMode: 'full' | 'pill' = 'full';
54
-
55
- // Track pill elements for cleanup
56
- const pillCards = new Map<string, HTMLElement>();
57
-
58
- // ── Pinned cards — stay visible at any zoom level ──
59
- const PINNED_STORAGE_KEY = 'gitmaps:pinnedCards';
60
- let _pinnedCards: Set<string> = new Set();
61
-
62
- try {
63
- const stored = localStorage.getItem(PINNED_STORAGE_KEY);
64
- if (stored) _pinnedCards = new Set(JSON.parse(stored));
65
- } catch { }
66
-
67
- function _savePinnedCards() {
68
- localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify([..._pinnedCards]));
69
- }
70
-
71
- /** Toggle pin state for a card. Returns new pin state. */
72
- export function togglePinCard(path: string): boolean {
73
- if (_pinnedCards.has(path)) {
74
- _pinnedCards.delete(path);
75
- } else {
76
- _pinnedCards.add(path);
77
- }
78
- _savePinnedCards();
79
- return _pinnedCards.has(path);
80
- }
81
-
82
- /** Check if a card is pinned */
83
- export function isPinned(path: string): boolean {
84
- return _pinnedCards.has(path);
85
- }
86
-
87
- /** Get all pinned card paths */
88
- export function getPinnedCards(): Set<string> {
89
- return _pinnedCards;
90
- }
91
-
92
- // ── Status colors for pill cards
93
- const PILL_COLORS: Record<string, string> = {
94
- 'ts': '#3178c6',
95
- 'tsx': '#3178c6',
96
- 'js': '#f7df1e',
97
- 'jsx': '#f7df1e',
98
- 'json': '#292929',
99
- 'css': '#264de4',
100
- 'scss': '#cd6799',
101
- 'html': '#e34f26',
102
- 'md': '#083fa1',
103
- 'py': '#3776ab',
104
- 'rs': '#dea584',
105
- 'go': '#00add8',
106
- 'vue': '#42b883',
107
- 'svelte': '#ff3e00',
108
- 'toml': '#9c4221',
109
- 'yaml': '#cb171e',
110
- 'yml': '#cb171e',
111
- 'sh': '#89e051',
112
- 'sql': '#e38c00',
113
- };
114
-
115
- function getPillColor(path: string, isChanged: boolean): string {
116
- if (isChanged) return '#eab308'; // Yellow for changed files
117
- const ext = path.split('.').pop()?.toLowerCase() || '';
118
- return PILL_COLORS[ext] || '#6b7280'; // Default gray
119
- }
120
-
121
- /**
122
- * Create a lightweight pill placeholder for a file.
123
- * ~3 DOM nodes vs ~100+ for a full card = massive perf win at low zoom.
124
- * Uses vertical text to fit file names in compact card footprint.
125
- */
126
- function createPillCard(path: string, x: number, y: number, w: number, h: number, isChanged: boolean, animate = false): HTMLElement {
127
- const pill = document.createElement('div');
128
- pill.className = 'file-pill';
129
- pill.dataset.path = path;
130
- pill.style.cssText = `
131
- position: absolute;
132
- left: ${x}px;
133
- top: ${y}px;
134
- width: ${w}px;
135
- height: ${h}px;
136
- background: ${getPillColor(path, isChanged)};
137
- border-radius: 6px;
138
- opacity: ${animate ? '0' : '0.9'};
139
- contain: layout style;
140
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
141
- border: 1px solid rgba(255,255,255,0.12);
142
- overflow: hidden;
143
- cursor: pointer;
144
- user-select: none;
145
- transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
146
- transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
147
- `;
148
-
149
- // Animate pill entrance
150
- if (animate) {
151
- requestAnimationFrame(() => {
152
- pill.style.opacity = '0.9';
153
- pill.style.transform = 'scale(1)';
154
- });
155
- }
156
-
157
- // File name label — show parent dir for common ambiguous filenames
158
- const parts = path.split('/');
159
- const filename = parts.pop() || path;
160
- const AMBIGUOUS = ['route.ts', 'route.tsx', 'page.tsx', 'page.ts', 'index.ts', 'index.tsx', 'index.js', 'layout.tsx', 'middleware.ts'];
161
- const name = AMBIGUOUS.includes(filename) && parts.length > 0
162
- ? `${parts[parts.length - 1]}/${filename}`
163
- : filename;
164
- const label = document.createElement('span');
165
- label.className = 'file-pill-label';
166
- label.textContent = name;
167
- label.style.cssText = `
168
- position: absolute;
169
- top: 50%;
170
- left: 50%;
171
- transform: translate(-50%, -50%) rotate(-90deg);
172
- white-space: nowrap;
173
- font-size: 48px;
174
- font-weight: 700;
175
- color: #fff;
176
- overflow: hidden;
177
- text-overflow: ellipsis;
178
- max-width: ${h - 40}px;
179
- line-height: 1;
180
- letter-spacing: 2px;
181
- font-family: 'JetBrains Mono', monospace;
182
- text-shadow: 0 2px 8px rgba(0,0,0,0.7);
183
- pointer-events: none;
184
- `;
185
- pill.appendChild(label);
186
-
187
- return pill;
188
- }
189
-
190
- /**
191
- * Computes the visible world-coordinate rectangle from the current
192
- * viewport size, zoom, and offset.
193
- * Also returns zoom so callers don't need a separate ctx.snap().
194
- */
195
- function getVisibleWorldRect(ctx: CanvasContext) {
196
- const state = ctx.snap().context;
197
- const vp = ctx.canvasViewport;
198
- if (!vp) return null;
199
-
200
- const vpW = vp.clientWidth;
201
- const vpH = vp.clientHeight;
202
- const { zoom, offsetX, offsetY } = state;
203
-
204
- // Convert viewport corners to world coordinates
205
- // viewport pixel (0,0) → world: (-offsetX / zoom, -offsetY / zoom)
206
- // viewport pixel (vpW,vpH) → world: ((vpW - offsetX) / zoom, (vpH - offsetY) / zoom)
207
- const worldLeft = (-offsetX - VIEWPORT_MARGIN) / zoom;
208
- const worldTop = (-offsetY - VIEWPORT_MARGIN) / zoom;
209
- const worldRight = (vpW - offsetX + VIEWPORT_MARGIN) / zoom;
210
- const worldBottom = (vpH - offsetY + VIEWPORT_MARGIN) / zoom;
211
-
212
- return { left: worldLeft, top: worldTop, right: worldRight, bottom: worldBottom, zoom };
213
- }
214
-
215
- /**
216
- * Checks if a card overlaps the visible world rectangle.
217
- */
218
- function isCardVisible(card: HTMLElement, worldRect: { left: number; top: number; right: number; bottom: number }): boolean {
219
- const x = parseFloat(card.style.left) || 0;
220
- const y = parseFloat(card.style.top) || 0;
221
- // Use offsetWidth/Height if available, otherwise use reasonable defaults
222
- const w = card.offsetWidth || 580;
223
- const h = card.offsetHeight || 700;
224
-
225
- return (
226
- x + w > worldRect.left &&
227
- x < worldRect.right &&
228
- y + h > worldRect.top &&
229
- y < worldRect.bottom
230
- );
231
- }
232
-
233
- /**
234
- * Remove all pill placeholders from the canvas.
235
- */
236
- export function clearAllPills(ctx: CanvasContext) {
237
- for (const [, pill] of pillCards) {
238
- pill.remove();
239
- }
240
- pillCards.clear();
241
- }
242
-
243
- /**
244
- * Fade out all pills, then call cleanup callback.
245
- */
246
- function fadeOutPills(onComplete?: () => void) {
247
- for (const [, pill] of pillCards) {
248
- pill.style.opacity = '0';
249
- pill.style.transform = 'scale(0.92)';
250
- }
251
- if (onComplete) {
252
- setTimeout(onComplete, 250);
253
- }
254
- }
255
-
256
- /**
257
- * Transition from pill mode to full mode: remove pills for cards that
258
- * have been fully materialized. Uses fade-out if pill is visible.
259
- */
260
- function removePillForPath(path: string) {
261
- const pill = pillCards.get(path);
262
- if (pill) {
263
- // Fade out the pill as the card replaces it
264
- pill.style.opacity = '0';
265
- pill.style.transform = 'scale(0.92)';
266
- pillCards.delete(path);
267
- setTimeout(() => pill.remove(), 250);
268
- }
269
- }
270
-
271
- /**
272
- * Performs viewport culling on all file cards.
273
- * Cards outside the viewport get visibility:hidden + content-visibility:hidden
274
- * Cards inside the viewport get shown.
275
- * Also materializes deferred cards that enter the viewport (virtualization).
276
- *
277
- * LOD: At low zoom, uses pill placeholders instead of full cards.
278
- */
279
- export function performViewportCulling(ctx: CanvasContext) {
280
- if (!_cullEnabled || !ctx.canvas || ctx.fileCards.size === 0 && ctx.deferredCards.size === 0) return;
281
-
282
- const worldRect = getVisibleWorldRect(ctx);
283
- if (!worldRect) return;
284
-
285
- // Phase 4c: also materialize deferred CardManager cards
286
- materializeViewport(ctx);
287
-
288
- // Reuse zoom from worldRect (already snapped) — avoids redundant ctx.snap()
289
- const zoom = worldRect.zoom;
290
- const isLowZoom = zoom <= LOD_ZOOM_THRESHOLD;
291
- const newLodMode = isLowZoom ? 'pill' : 'full';
292
-
293
- let culled = 0;
294
- let shown = 0;
295
-
296
- // ── LOD mode transition (with smooth animation) ──
297
- if (newLodMode !== _currentLodMode) {
298
- if (newLodMode === 'pill') {
299
- // Transitioning to pill mode: hide full cards EXCEPT pinned ones
300
- for (const [path, card] of ctx.fileCards) {
301
- if (_pinnedCards.has(path)) {
302
- // Pinned cards stay visible — scale down for readability
303
- card.style.display = '';
304
- card.dataset.culled = 'false';
305
- card.dataset.pinned = 'true';
306
- card.style.zIndex = '50';
307
- continue;
308
- }
309
- card.style.display = 'none';
310
- card.dataset.culled = 'true';
311
- }
312
- } else {
313
- // Transitioning to full mode:
314
- // 1. Force-show ALL materialized full cards (they were hidden in pill mode)
315
- for (const [path, card] of ctx.fileCards) {
316
- card.style.display = '';
317
- card.style.contentVisibility = '';
318
- card.style.visibility = '';
319
- card.dataset.culled = 'false';
320
- }
321
- // 2. Remove all pills immediately (no fade — avoids ghost overlap)
322
- for (const [path, pill] of pillCards) {
323
- pill.style.display = 'none';
324
- pill.remove();
325
- }
326
- pillCards.clear();
327
- }
328
- _currentLodMode = newLodMode;
329
- }
330
-
331
- // 1. Handle existing DOM cards (cull/show)
332
- for (const [path, card] of ctx.fileCards) {
333
- if (isLowZoom) {
334
- // Pinned cards stay visible even in pill mode
335
- if (_pinnedCards.has(path)) {
336
- card.style.display = '';
337
- card.style.contentVisibility = '';
338
- card.style.visibility = '';
339
- card.dataset.culled = 'false';
340
- card.dataset.pinned = 'true';
341
- card.style.zIndex = '50';
342
- shown++;
343
- continue;
344
- }
345
- // In pill mode: force-hide non-pinned full cards
346
- card.style.display = 'none';
347
- card.dataset.culled = 'true';
348
- delete card.dataset.pinned;
349
- culled++;
350
- continue;
351
- }
352
-
353
- const visible = isCardVisible(card, worldRect);
354
- const wasCulled = card.dataset.culled === 'true';
355
-
356
- if (visible && wasCulled) {
357
- // Card entering viewport — show it with fade-in
358
- card.style.contentVisibility = '';
359
- card.style.visibility = '';
360
- card.style.opacity = '0';
361
- card.style.transition = 'opacity 0.25s ease';
362
- card.dataset.culled = 'false';
363
- removePillForPath(path);
364
- requestAnimationFrame(() => { card.style.opacity = ''; });
365
- // Cleanup transition once done to avoid overhead
366
- setTimeout(() => { card.style.transition = ''; }, 300);
367
- shown++;
368
- } else if (!visible && !wasCulled) {
369
- // Card leaving viewport hide it (keep dimensions for layout)
370
- card.style.contentVisibility = 'hidden';
371
- card.style.visibility = 'hidden';
372
- card.dataset.culled = 'true';
373
- culled++;
374
- } else if (visible) {
375
- shown++;
376
- } else {
377
- culled++;
378
- }
379
- }
380
-
381
- // 2. Handle pill mode — always create pills for visible items (deferred or materialized)
382
- if (isLowZoom) {
383
- // Create pills for deferred cards that are visible
384
- for (const [path, entry] of ctx.deferredCards) {
385
- const { file, x, y, size, isChanged } = entry;
386
- const cardW = size?.width || 580;
387
- const cardH = size?.height || 700;
388
-
389
- const inView = (
390
- x + cardW > worldRect.left &&
391
- x < worldRect.right &&
392
- y + cardH > worldRect.top &&
393
- y < worldRect.bottom
394
- );
395
-
396
- if (inView && !pillCards.has(path)) {
397
- const pill = createPillCard(path, x, y, cardW, cardH, !!isChanged, true);
398
- ctx.canvas.appendChild(pill);
399
- pillCards.set(path, pill);
400
- } else if (!inView && pillCards.has(path)) {
401
- removePillForPath(path);
402
- }
403
- }
404
-
405
- // Always create pills for existing DOM cards (even if deferredCards is empty)
406
- for (const [path, card] of ctx.fileCards) {
407
- if (!pillCards.has(path)) {
408
- const x = parseFloat(card.style.left) || 0;
409
- const y = parseFloat(card.style.top) || 0;
410
- const w = card.offsetWidth || 580;
411
- const h = card.offsetHeight || 700;
412
- const isChanged = card.dataset.changed === 'true';
413
-
414
- const inView = (
415
- x + w > worldRect.left &&
416
- x < worldRect.right &&
417
- y + h > worldRect.top &&
418
- y < worldRect.bottom
419
- );
420
-
421
- if (inView) {
422
- const pill = createPillCard(path, x, y, w, h, isChanged, true);
423
- ctx.canvas.appendChild(pill);
424
- pillCards.set(path, pill);
425
- }
426
- }
427
- }
428
-
429
- // Clean up pills that scrolled out of view
430
- for (const [path, pill] of pillCards) {
431
- const x = parseFloat(pill.style.left) || 0;
432
- const y = parseFloat(pill.style.top) || 0;
433
- const w = parseFloat(pill.style.width) || 580;
434
- const h = parseFloat(pill.style.height) || 80;
435
- const inView = (
436
- x + w > worldRect.left &&
437
- x < worldRect.right &&
438
- y + h > worldRect.top &&
439
- y < worldRect.bottom
440
- );
441
- if (!inView) {
442
- removePillForPath(path);
443
- }
444
- }
445
- } else if (ctx.deferredCards.size > 0) {
446
- // 3. Full mode: materialize deferred cards (throttled)
447
- // Skip materialization during active pan/zoom to keep frames smooth
448
- const timeSinceTransform = performance.now() - _lastTransformTime;
449
- if (timeSinceTransform < MATERIALIZE_COOLDOWN_MS) {
450
- // Still actively panning schedule a retry after cooldown
451
- setTimeout(() => scheduleViewportCulling(ctx), MATERIALIZE_COOLDOWN_MS);
452
- return { culled, shown, total: ctx.fileCards.size };
453
- }
454
-
455
- let materialized = 0;
456
- const toRemove: string[] = [];
457
-
458
- for (const [path, entry] of ctx.deferredCards) {
459
- if (materialized >= MAX_MATERIALIZE_PER_FRAME) break;
460
- // Skip hidden files — don't materialize them
461
- if (ctx.hiddenFiles.has(path)) { toRemove.push(path); continue; }
462
-
463
- const { file, x, y, size, isChanged } = entry;
464
- const cardW = size?.width || 580;
465
- const cardH = size?.height || 700;
466
-
467
- // AABB check against world rect
468
- const inView = (
469
- x + cardW > worldRect.left &&
470
- x < worldRect.right &&
471
- y + cardH > worldRect.top &&
472
- y < worldRect.bottom
473
- );
474
-
475
- if (inView) {
476
- // Lazy-import to avoid circular dependency
477
- const { createAllFileCard, setupCardInteraction } = require('./cards');
478
- const card = createAllFileCard(ctx, file, x, y, size);
479
- if (isChanged) {
480
- card.classList.add('file-card--changed');
481
- card.dataset.changed = 'true';
482
- }
483
- ctx.canvas.appendChild(card);
484
- ctx.fileCards.set(path, card);
485
- removePillForPath(path);
486
- toRemove.push(path);
487
- materialized++;
488
- shown++;
489
- }
490
- }
491
-
492
- // Remove materialized entries from deferred map
493
- for (const path of toRemove) {
494
- ctx.deferredCards.delete(path);
495
- }
496
-
497
- if (materialized > 0) {
498
- console.log(`[cull] Materialized ${materialized} deferred cards (${ctx.deferredCards.size} remaining)`);
499
- // If more cards need materializing, schedule another pass
500
- if (ctx.deferredCards.size > 0) {
501
- scheduleViewportCulling(ctx);
502
- }
503
- }
504
- }
505
-
506
- return { culled, shown, total: ctx.fileCards.size };
507
- }
508
-
509
- /**
510
- * Schedules a viewport culling pass on the next animation frame.
511
- * Debounced multiple calls per frame only result in one culling pass.
512
- */
513
- export function scheduleViewportCulling(ctx: CanvasContext) {
514
- if (_cullRafPending || !_cullEnabled) return;
515
- _cullRafPending = true;
516
- requestAnimationFrame(() => {
517
- _cullRafPending = false;
518
- performViewportCulling(ctx);
519
- });
520
- }
521
-
522
- /**
523
- * Enable/disable viewport culling.
524
- */
525
- export function setViewportCullingEnabled(enabled: boolean) {
526
- _cullEnabled = enabled;
527
- }
528
-
529
- /**
530
- * Force all cards to be visible (disable culling effect).
531
- * Call this before operations that need to measure all cards (e.g. fitAll).
532
- * Also materializes all deferred cards so they can be measured.
533
- */
534
- export function uncullAllCards(ctx: CanvasContext) {
535
- // Clear any pill placeholders
536
- clearAllPills(ctx);
537
- _currentLodMode = 'full';
538
-
539
- for (const [, card] of ctx.fileCards) {
540
- card.style.contentVisibility = '';
541
- card.style.visibility = '';
542
- card.dataset.culled = 'false';
543
- }
544
-
545
- // Materialize ALL deferred cards (needed for fitAll, arrangeGrid etc.)
546
- if (ctx.deferredCards.size > 0) {
547
- const { createAllFileCard } = require('./cards');
548
- for (const [path, entry] of ctx.deferredCards) {
549
- // Skip hidden files
550
- if (ctx.hiddenFiles.has(path)) continue;
551
- const { file, x, y, size, isChanged } = entry;
552
- const card = createAllFileCard(ctx, file, x, y, size);
553
- if (isChanged) {
554
- card.classList.add('file-card--changed');
555
- card.dataset.changed = 'true';
556
- }
557
- ctx.canvas.appendChild(card);
558
- ctx.fileCards.set(path, card);
559
- }
560
- console.log(`[uncull] Materialized all ${ctx.deferredCards.size} deferred cards`);
561
- ctx.deferredCards.clear();
562
- }
563
- }
564
-
565
- /**
566
- * Setup event-delegated interaction for pill cards.
567
- * One listener on the canvas handles all pill clicks/drags/double-clicks.
568
- * Much more efficient than per-pill listeners.
569
- */
570
- let _pillInteractionSetup = false;
571
- export function setupPillInteraction(ctx: CanvasContext) {
572
- if (_pillInteractionSetup || !ctx.canvas) return;
573
- _pillInteractionSetup = true;
574
-
575
- let pillAction: null | 'pending' | 'move' = null;
576
- let pillTarget: HTMLElement | null = null;
577
- let pillStartX = 0, pillStartY = 0;
578
- let pillMoveInfos: { pill: HTMLElement; path: string; startLeft: number; startTop: number }[] = [];
579
- const DRAG_THRESHOLD = 5;
580
-
581
- ctx.canvas.addEventListener('mousedown', (e: MouseEvent) => {
582
- if (e.button !== 0) return;
583
- const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
584
- if (!pill) return;
585
-
586
- e.stopPropagation();
587
- pillTarget = pill;
588
- pillAction = 'pending';
589
- pillStartX = e.clientX;
590
- pillStartY = e.clientY;
591
-
592
- window.addEventListener('mousemove', onPillMove);
593
- window.addEventListener('mouseup', onPillUp);
594
- });
595
-
596
- // Native dblclick to open editor modal (consistent with card dblclick)
597
- ctx.canvas.addEventListener('dblclick', (e: MouseEvent) => {
598
- const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
599
- if (!pill) return;
600
- e.stopPropagation();
601
- e.preventDefault();
602
- const pillPath = pill.dataset.path || '';
603
- if (pillPath) {
604
- const file = ctx.allFilesData?.find(f => f.path === pillPath) ||
605
- { path: pillPath, name: pillPath.split('/').pop(), lines: 0 };
606
- import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file));
607
- }
608
- });
609
-
610
- function onPillMove(e: MouseEvent) {
611
- if (!pillTarget) return;
612
- const state = ctx.snap().context;
613
- const dx = (e.clientX - pillStartX) / state.zoom;
614
- const dy = (e.clientY - pillStartY) / state.zoom;
615
-
616
- if (pillAction === 'pending') {
617
- const dist = Math.sqrt((e.clientX - pillStartX) ** 2 + (e.clientY - pillStartY) ** 2);
618
- if (dist < DRAG_THRESHOLD) return;
619
-
620
- pillAction = 'move';
621
- const pillPath = pillTarget.dataset.path || '';
622
-
623
- // If this pill isn't selected yet, select it
624
- const selected = state.selectedCards;
625
- if (!selected.includes(pillPath)) {
626
- if (!e.shiftKey && !e.ctrlKey) {
627
- ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
628
- } else {
629
- ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
630
- }
631
- updatePillSelectionHighlights(ctx);
632
- }
633
-
634
- // Collect all selected pills for multi-drag
635
- const nowSelected = ctx.snap().context.selectedCards;
636
- pillMoveInfos = [];
637
- nowSelected.forEach(path => {
638
- const p = pillCards.get(path);
639
- if (p) {
640
- pillMoveInfos.push({
641
- pill: p,
642
- path,
643
- startLeft: parseFloat(p.style.left) || 0,
644
- startTop: parseFloat(p.style.top) || 0,
645
- });
646
- }
647
- });
648
- }
649
-
650
- if (pillAction === 'move') {
651
- pillMoveInfos.forEach(info => {
652
- info.pill.style.left = `${info.startLeft + dx}px`;
653
- info.pill.style.top = `${info.startTop + dy}px`;
654
- });
655
- }
656
- }
657
-
658
- function onPillUp(e: MouseEvent) {
659
- window.removeEventListener('mousemove', onPillMove);
660
- window.removeEventListener('mouseup', onPillUp);
661
-
662
- if (!pillTarget) return;
663
- const pillPath = pillTarget.dataset.path || '';
664
-
665
- if (pillAction === 'pending') {
666
- // Single click → select (double-click handled by native dblclick listener)
667
- if (e.shiftKey || e.ctrlKey) {
668
- ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
669
- } else {
670
- ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
671
- }
672
- updatePillSelectionHighlights(ctx);
673
- } else if (pillAction === 'move') {
674
- // Save new positions for all moved pills
675
- const { savePosition } = require('./positions');
676
- pillMoveInfos.forEach(info => {
677
- const newX = parseFloat(info.pill.style.left) || 0;
678
- const newY = parseFloat(info.pill.style.top) || 0;
679
-
680
- // Update deferred card position
681
- const deferred = ctx.deferredCards.get(info.path);
682
- if (deferred) {
683
- deferred.x = newX;
684
- deferred.y = newY;
685
- }
686
-
687
- // Update materialized card position too (if exists)
688
- const card = ctx.fileCards.get(info.path);
689
- if (card) {
690
- card.style.left = `${newX}px`;
691
- card.style.top = `${newY}px`;
692
- }
693
-
694
- savePosition(ctx, 'allfiles', info.path, newX, newY);
695
- });
696
- pillMoveInfos = [];
697
- }
698
-
699
- pillAction = null;
700
- pillTarget = null;
701
- }
702
- }
703
-
704
- /**
705
- * Update pill selection highlights based on XState selectedCards.
706
- */
707
- export function updatePillSelectionHighlights(ctx: CanvasContext) {
708
- const selected = ctx.snap().context.selectedCards;
709
- for (const [path, pill] of pillCards) {
710
- if (selected.includes(path)) {
711
- pill.style.outline = '8px solid rgba(124, 58, 237, 1)';
712
- pill.style.outlineOffset = '6px';
713
- pill.style.boxShadow = '0 0 0 6px rgba(124, 58, 237, 0.5), 0 0 60px 20px rgba(124, 58, 237, 0.6), 0 0 100px 40px rgba(124, 58, 237, 0.3)';
714
- pill.style.zIndex = '100';
715
- pill.style.filter = 'brightness(1.3)';
716
- } else {
717
- pill.style.outline = '';
718
- pill.style.outlineOffset = '';
719
- pill.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
720
- pill.style.zIndex = '';
721
- pill.style.filter = '';
722
- }
723
- }
724
- // Also update full card highlights
725
- const { updateSelectionHighlights } = require('./cards');
726
- updateSelectionHighlights(ctx);
727
- }
728
-
1
+ // @ts-nocheck
2
+ /**
3
+ * Viewport culling + LOD (Level of Detail) system
4
+ *
5
+ * Cards outside the viewport have their content stripped (innerHTML = '')
6
+ * and get `data-culled="true"`. When they scroll back into view, their
7
+ * content is rebuilt from the stored file data.
8
+ *
9
+ * LOD System (zoom-aware):
10
+ * zoom > LOD_ZOOM_THRESHOLD (0.25): Full file cards with content
11
+ * zoom <= LOD_ZOOM_THRESHOLD: Lightweight "pill" placeholders
12
+ *
13
+ * This prevents mass-materialization when zooming out on large repos
14
+ * (e.g. React with 6833 files). Instead of creating 6833 full DOM cards,
15
+ * we create tiny colored rectangles that swap to full cards on zoom-in.
16
+ *
17
+ * Materialization throttle: When many deferred cards enter viewport at
18
+ * once, we materialize them in batches (MAX_MATERIALIZE_PER_FRAME) to
19
+ * prevent frame drops.
20
+ *
21
+ * Performance: O(n) per frame with n = total cards. The check is a simple
22
+ * AABB overlap test — no spatial indexing needed for < 10K cards.
23
+ */
24
+ import { measure } from 'measure-fn';
25
+ import type { CanvasContext } from './context';
26
+ import { materializeViewport } from './xydraw-bridge';
27
+
28
+ // ── Culling state ──────────────────────────────────────────
29
+ let _cullRafPending = false;
30
+ let _cullEnabled = true;
31
+
32
+ // Margin in viewport pixels — cards within this margin outside the visible
33
+ // area are pre-rendered so scrolling feels instant.
34
+ const VIEWPORT_MARGIN = 500;
35
+
36
+ // LOD threshold: below this zoom level, use lightweight pill placeholders
37
+ const LOD_ZOOM_THRESHOLD = 0.25;
38
+
39
+ // Maximum deferred cards to materialize per animation frame
40
+ // Prevents frame drops when zooming out then back in on huge repos
41
+ const MAX_MATERIALIZE_PER_FRAME = 8;
42
+
43
+ // Cooldown: don't materialize during rapid pan/zoom — wait until settled
44
+ let _lastTransformTime = 0;
45
+ const MATERIALIZE_COOLDOWN_MS = 150;
46
+
47
+ /** Call from updateCanvasTransform to signal active interaction */
48
+ export function markTransformActive() {
49
+ _lastTransformTime = performance.now();
50
+ }
51
+
52
+ // Track current LOD mode so we can detect transitions
53
+ let _currentLodMode: 'full' | 'pill' = 'full';
54
+
55
+ // Track pill elements for cleanup
56
+ const pillCards = new Map<string, HTMLElement>();
57
+
58
+ // ── Pinned cards — stay visible at any zoom level ──
59
+ const PINNED_STORAGE_KEY = 'gitmaps:pinnedCards';
60
+ let _pinnedCards: Set<string> = new Set();
61
+
62
+ try {
63
+ const stored = localStorage.getItem(PINNED_STORAGE_KEY);
64
+ if (stored) _pinnedCards = new Set(JSON.parse(stored));
65
+ } catch { }
66
+
67
+ function _savePinnedCards() {
68
+ localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify([..._pinnedCards]));
69
+ }
70
+
71
+ /** Toggle pin state for a card. Returns new pin state. */
72
+ export function togglePinCard(path: string): boolean {
73
+ if (_pinnedCards.has(path)) {
74
+ _pinnedCards.delete(path);
75
+ } else {
76
+ _pinnedCards.add(path);
77
+ }
78
+ _savePinnedCards();
79
+ return _pinnedCards.has(path);
80
+ }
81
+
82
+ /** Check if a card is pinned */
83
+ export function isPinned(path: string): boolean {
84
+ return _pinnedCards.has(path);
85
+ }
86
+
87
+ /** Get all pinned card paths */
88
+ export function getPinnedCards(): Set<string> {
89
+ return _pinnedCards;
90
+ }
91
+
92
+ // ── Status colors for pill cards
93
+ const PILL_COLORS: Record<string, string> = {
94
+ 'ts': '#3178c6',
95
+ 'tsx': '#3178c6',
96
+ 'js': '#f7df1e',
97
+ 'jsx': '#f7df1e',
98
+ 'json': '#292929',
99
+ 'css': '#264de4',
100
+ 'scss': '#cd6799',
101
+ 'html': '#e34f26',
102
+ 'md': '#083fa1',
103
+ 'py': '#3776ab',
104
+ 'rs': '#dea584',
105
+ 'go': '#00add8',
106
+ 'vue': '#42b883',
107
+ 'svelte': '#ff3e00',
108
+ 'toml': '#9c4221',
109
+ 'yaml': '#cb171e',
110
+ 'yml': '#cb171e',
111
+ 'sh': '#89e051',
112
+ 'sql': '#e38c00',
113
+ };
114
+
115
+ function getPillColor(path: string, isChanged: boolean): string {
116
+ if (isChanged) return '#eab308'; // Yellow for changed files
117
+ const ext = path.split('.').pop()?.toLowerCase() || '';
118
+ return PILL_COLORS[ext] || '#6b7280'; // Default gray
119
+ }
120
+
121
+ /**
122
+ * Create a lightweight pill placeholder for a file.
123
+ * ~3 DOM nodes vs ~100+ for a full card = massive perf win at low zoom.
124
+ * Uses vertical text to fit file names in compact card footprint.
125
+ */
126
+ function createPillCard(path: string, x: number, y: number, w: number, h: number, isChanged: boolean, animate = false): HTMLElement {
127
+ const pill = document.createElement('div');
128
+ pill.className = 'file-pill';
129
+ pill.dataset.path = path;
130
+ pill.style.cssText = `
131
+ position: absolute;
132
+ left: ${x}px;
133
+ top: ${y}px;
134
+ width: ${w}px;
135
+ height: ${h}px;
136
+ background: ${getPillColor(path, isChanged)};
137
+ border-radius: 6px;
138
+ opacity: ${animate ? '0' : '0.9'};
139
+ contain: layout style;
140
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
141
+ border: 1px solid rgba(255,255,255,0.12);
142
+ overflow: hidden;
143
+ cursor: pointer;
144
+ user-select: none;
145
+ transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
146
+ transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
147
+ `;
148
+
149
+ // Animate pill entrance
150
+ if (animate) {
151
+ requestAnimationFrame(() => {
152
+ pill.style.opacity = '0.9';
153
+ pill.style.transform = 'scale(1)';
154
+ });
155
+ }
156
+
157
+ // File name label — show parent dir for common ambiguous filenames
158
+ const parts = path.split('/');
159
+ const filename = parts.pop() || path;
160
+ const AMBIGUOUS = ['route.ts', 'route.tsx', 'page.tsx', 'page.ts', 'index.ts', 'index.tsx', 'index.js', 'layout.tsx', 'middleware.ts'];
161
+ const name = AMBIGUOUS.includes(filename) && parts.length > 0
162
+ ? `${parts[parts.length - 1]}/${filename}`
163
+ : filename;
164
+ const label = document.createElement('span');
165
+ label.className = 'file-pill-label';
166
+ label.textContent = name;
167
+ label.style.cssText = `
168
+ position: absolute;
169
+ top: 50%;
170
+ left: 50%;
171
+ transform: translate(-50%, -50%) rotate(-90deg);
172
+ white-space: nowrap;
173
+ font-size: 48px;
174
+ font-weight: 700;
175
+ color: #fff;
176
+ overflow: hidden;
177
+ text-overflow: ellipsis;
178
+ max-width: ${h - 40}px;
179
+ line-height: 1;
180
+ letter-spacing: 2px;
181
+ font-family: 'JetBrains Mono', monospace;
182
+ text-shadow: 0 2px 8px rgba(0,0,0,0.7);
183
+ pointer-events: none;
184
+ `;
185
+ pill.appendChild(label);
186
+
187
+ return pill;
188
+ }
189
+
190
+ /**
191
+ * Computes the visible world-coordinate rectangle from the current
192
+ * viewport size, zoom, and offset.
193
+ * Also returns zoom so callers don't need a separate ctx.snap().
194
+ */
195
+ function getVisibleWorldRect(ctx: CanvasContext) {
196
+ const state = ctx.snap().context;
197
+ const vp = ctx.canvasViewport;
198
+ if (!vp) return null;
199
+
200
+ const vpW = vp.clientWidth;
201
+ const vpH = vp.clientHeight;
202
+ const { zoom, offsetX, offsetY } = state;
203
+
204
+ // Convert viewport corners to world coordinates
205
+ // viewport pixel (0,0) → world: (-offsetX / zoom, -offsetY / zoom)
206
+ // viewport pixel (vpW,vpH) → world: ((vpW - offsetX) / zoom, (vpH - offsetY) / zoom)
207
+ const worldLeft = (-offsetX - VIEWPORT_MARGIN) / zoom;
208
+ const worldTop = (-offsetY - VIEWPORT_MARGIN) / zoom;
209
+ const worldRight = (vpW - offsetX + VIEWPORT_MARGIN) / zoom;
210
+ const worldBottom = (vpH - offsetY + VIEWPORT_MARGIN) / zoom;
211
+
212
+ return { left: worldLeft, top: worldTop, right: worldRight, bottom: worldBottom, zoom };
213
+ }
214
+
215
+ /**
216
+ * Checks if a card overlaps the visible world rectangle.
217
+ */
218
+ function isCardVisible(card: HTMLElement, worldRect: { left: number; top: number; right: number; bottom: number }): boolean {
219
+ const x = parseFloat(card.style.left) || 0;
220
+ const y = parseFloat(card.style.top) || 0;
221
+ // Use offsetWidth/Height if available, otherwise use reasonable defaults
222
+ const w = card.offsetWidth || 580;
223
+ const h = card.offsetHeight || 700;
224
+
225
+ return (
226
+ x + w > worldRect.left &&
227
+ x < worldRect.right &&
228
+ y + h > worldRect.top &&
229
+ y < worldRect.bottom
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Remove all pill placeholders from the canvas.
235
+ */
236
+ export function clearAllPills(ctx: CanvasContext) {
237
+ for (const [, pill] of pillCards) {
238
+ pill.remove();
239
+ }
240
+ pillCards.clear();
241
+ }
242
+
243
+ /**
244
+ * Fade out all pills, then call cleanup callback.
245
+ */
246
+ function fadeOutPills(onComplete?: () => void) {
247
+ for (const [, pill] of pillCards) {
248
+ pill.style.opacity = '0';
249
+ pill.style.transform = 'scale(0.92)';
250
+ }
251
+ if (onComplete) {
252
+ setTimeout(onComplete, 250);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Transition from pill mode to full mode: remove pills for cards that
258
+ * have been fully materialized. Uses fade-out if pill is visible.
259
+ */
260
+ function removePillForPath(path: string) {
261
+ const pill = pillCards.get(path);
262
+ if (pill) {
263
+ // Fade out the pill as the card replaces it
264
+ pill.style.opacity = '0';
265
+ pill.style.transform = 'scale(0.92)';
266
+ pillCards.delete(path);
267
+ setTimeout(() => pill.remove(), 250);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Performs viewport culling on all file cards.
273
+ * Cards outside the viewport get visibility:hidden + content-visibility:hidden
274
+ * Cards inside the viewport get shown.
275
+ * Also materializes deferred cards that enter the viewport (virtualization).
276
+ *
277
+ * LOD: At low zoom, uses pill placeholders instead of full cards.
278
+ */
279
+ export function performViewportCulling(ctx: CanvasContext) {
280
+ if (!_cullEnabled || !ctx.canvas || ctx.fileCards.size === 0 && ctx.deferredCards.size === 0) return;
281
+
282
+ const worldRect = getVisibleWorldRect(ctx);
283
+ if (!worldRect) return;
284
+
285
+ // Phase 4c: also materialize deferred CardManager cards
286
+ // Reuse zoom from worldRect (already snapped) — avoids redundant ctx.snap()
287
+ const zoom = worldRect.zoom;
288
+ const isLowZoom = zoom <= LOD_ZOOM_THRESHOLD;
289
+
290
+ // Important: never materialize full cards while in low-zoom pill mode.
291
+ // Otherwise CardManager keeps mounting heavyweight cards right when the
292
+ // UI is supposed to collapse into lightweight placeholders.
293
+ if (!isLowZoom) {
294
+ materializeViewport(ctx);
295
+ }
296
+ const newLodMode = isLowZoom ? 'pill' : 'full';
297
+
298
+ let culled = 0;
299
+ let shown = 0;
300
+
301
+ // ── LOD mode transition (with smooth animation) ──
302
+ if (newLodMode !== _currentLodMode) {
303
+ if (newLodMode === 'pill') {
304
+ // Transitioning to pill mode: hide full cards EXCEPT pinned ones
305
+ for (const [path, card] of ctx.fileCards) {
306
+ if (_pinnedCards.has(path)) {
307
+ // Pinned cards stay visible — scale down for readability
308
+ card.style.display = '';
309
+ card.dataset.culled = 'false';
310
+ card.dataset.pinned = 'true';
311
+ card.style.zIndex = '50';
312
+ continue;
313
+ }
314
+ card.style.display = 'none';
315
+ card.dataset.culled = 'true';
316
+ }
317
+ } else {
318
+ // Transitioning to full mode:
319
+ // 1. Force-show ALL materialized full cards (they were hidden in pill mode)
320
+ for (const [path, card] of ctx.fileCards) {
321
+ card.style.display = '';
322
+ card.style.contentVisibility = '';
323
+ card.style.visibility = '';
324
+ card.dataset.culled = 'false';
325
+ }
326
+ // 2. Remove all pills immediately (no fade — avoids ghost overlap)
327
+ for (const [path, pill] of pillCards) {
328
+ pill.style.display = 'none';
329
+ pill.remove();
330
+ }
331
+ pillCards.clear();
332
+ }
333
+ _currentLodMode = newLodMode;
334
+ }
335
+
336
+ // 1. Handle existing DOM cards (cull/show)
337
+ for (const [path, card] of ctx.fileCards) {
338
+ if (isLowZoom) {
339
+ // Pinned cards stay visible even in pill mode
340
+ if (_pinnedCards.has(path)) {
341
+ card.style.display = '';
342
+ card.style.contentVisibility = '';
343
+ card.style.visibility = '';
344
+ card.dataset.culled = 'false';
345
+ card.dataset.pinned = 'true';
346
+ card.style.zIndex = '50';
347
+ shown++;
348
+ continue;
349
+ }
350
+ // In pill mode: force-hide non-pinned full cards
351
+ card.style.display = 'none';
352
+ card.dataset.culled = 'true';
353
+ delete card.dataset.pinned;
354
+ culled++;
355
+ continue;
356
+ }
357
+
358
+ const visible = isCardVisible(card, worldRect);
359
+ const wasCulled = card.dataset.culled === 'true';
360
+
361
+ if (visible && wasCulled) {
362
+ // Card entering viewport — show it with fade-in
363
+ card.style.contentVisibility = '';
364
+ card.style.visibility = '';
365
+ card.style.opacity = '0';
366
+ card.style.transition = 'opacity 0.25s ease';
367
+ card.dataset.culled = 'false';
368
+ removePillForPath(path);
369
+ requestAnimationFrame(() => { card.style.opacity = ''; });
370
+ // Cleanup transition once done to avoid overhead
371
+ setTimeout(() => { card.style.transition = ''; }, 300);
372
+ shown++;
373
+ } else if (!visible && !wasCulled) {
374
+ // Card leaving viewport — hide it (keep dimensions for layout)
375
+ card.style.contentVisibility = 'hidden';
376
+ card.style.visibility = 'hidden';
377
+ card.dataset.culled = 'true';
378
+ culled++;
379
+ } else if (visible) {
380
+ shown++;
381
+ } else {
382
+ culled++;
383
+ }
384
+ }
385
+
386
+ // 2. Handle pill mode — always create pills for visible items (deferred or materialized)
387
+ if (isLowZoom) {
388
+ // Create pills for deferred cards that are visible
389
+ for (const [path, entry] of ctx.deferredCards) {
390
+ const { file, x, y, size, isChanged } = entry;
391
+ const cardW = size?.width || 580;
392
+ const cardH = size?.height || 700;
393
+
394
+ const inView = (
395
+ x + cardW > worldRect.left &&
396
+ x < worldRect.right &&
397
+ y + cardH > worldRect.top &&
398
+ y < worldRect.bottom
399
+ );
400
+
401
+ if (inView && !pillCards.has(path)) {
402
+ const pill = createPillCard(path, x, y, cardW, cardH, !!isChanged, true);
403
+ ctx.canvas.appendChild(pill);
404
+ pillCards.set(path, pill);
405
+ } else if (!inView && pillCards.has(path)) {
406
+ removePillForPath(path);
407
+ }
408
+ }
409
+
410
+ // Always create pills for existing DOM cards (even if deferredCards is empty)
411
+ for (const [path, card] of ctx.fileCards) {
412
+ if (!pillCards.has(path)) {
413
+ const x = parseFloat(card.style.left) || 0;
414
+ const y = parseFloat(card.style.top) || 0;
415
+ const w = card.offsetWidth || 580;
416
+ const h = card.offsetHeight || 700;
417
+ const isChanged = card.dataset.changed === 'true';
418
+
419
+ const inView = (
420
+ x + w > worldRect.left &&
421
+ x < worldRect.right &&
422
+ y + h > worldRect.top &&
423
+ y < worldRect.bottom
424
+ );
425
+
426
+ if (inView) {
427
+ const pill = createPillCard(path, x, y, w, h, isChanged, true);
428
+ ctx.canvas.appendChild(pill);
429
+ pillCards.set(path, pill);
430
+ }
431
+ }
432
+ }
433
+
434
+ // Clean up pills that scrolled out of view
435
+ for (const [path, pill] of pillCards) {
436
+ const x = parseFloat(pill.style.left) || 0;
437
+ const y = parseFloat(pill.style.top) || 0;
438
+ const w = parseFloat(pill.style.width) || 580;
439
+ const h = parseFloat(pill.style.height) || 80;
440
+ const inView = (
441
+ x + w > worldRect.left &&
442
+ x < worldRect.right &&
443
+ y + h > worldRect.top &&
444
+ y < worldRect.bottom
445
+ );
446
+ if (!inView) {
447
+ removePillForPath(path);
448
+ }
449
+ }
450
+ } else if (ctx.deferredCards.size > 0) {
451
+ // 3. Full mode: materialize deferred cards (throttled)
452
+ // Skip materialization during active pan/zoom to keep frames smooth
453
+ const timeSinceTransform = performance.now() - _lastTransformTime;
454
+ if (timeSinceTransform < MATERIALIZE_COOLDOWN_MS) {
455
+ // Still actively panning — schedule a retry after cooldown
456
+ setTimeout(() => scheduleViewportCulling(ctx), MATERIALIZE_COOLDOWN_MS);
457
+ return { culled, shown, total: ctx.fileCards.size };
458
+ }
459
+
460
+ let materialized = 0;
461
+ const toRemove: string[] = [];
462
+
463
+ for (const [path, entry] of ctx.deferredCards) {
464
+ if (materialized >= MAX_MATERIALIZE_PER_FRAME) break;
465
+ // Skip hidden files don't materialize them
466
+ if (ctx.hiddenFiles.has(path)) { toRemove.push(path); continue; }
467
+
468
+ const { file, x, y, size, isChanged } = entry;
469
+ const cardW = size?.width || 580;
470
+ const cardH = size?.height || 700;
471
+
472
+ // AABB check against world rect
473
+ const inView = (
474
+ x + cardW > worldRect.left &&
475
+ x < worldRect.right &&
476
+ y + cardH > worldRect.top &&
477
+ y < worldRect.bottom
478
+ );
479
+
480
+ if (inView) {
481
+ // Lazy-import to avoid circular dependency
482
+ const { createAllFileCard, setupCardInteraction } = require('./cards');
483
+ const card = createAllFileCard(ctx, file, x, y, size);
484
+ if (isChanged) {
485
+ card.classList.add('file-card--changed');
486
+ card.dataset.changed = 'true';
487
+ }
488
+ ctx.canvas.appendChild(card);
489
+ ctx.fileCards.set(path, card);
490
+ removePillForPath(path);
491
+ toRemove.push(path);
492
+ materialized++;
493
+ shown++;
494
+ }
495
+ }
496
+
497
+ // Remove materialized entries from deferred map
498
+ for (const path of toRemove) {
499
+ ctx.deferredCards.delete(path);
500
+ }
501
+
502
+ if (materialized > 0) {
503
+ console.log(`[cull] Materialized ${materialized} deferred cards (${ctx.deferredCards.size} remaining)`);
504
+ // If more cards need materializing, schedule another pass
505
+ if (ctx.deferredCards.size > 0) {
506
+ scheduleViewportCulling(ctx);
507
+ }
508
+ }
509
+ }
510
+
511
+ return { culled, shown, total: ctx.fileCards.size };
512
+ }
513
+
514
+ /**
515
+ * Schedules a viewport culling pass on the next animation frame.
516
+ * Debounced — multiple calls per frame only result in one culling pass.
517
+ */
518
+ export function scheduleViewportCulling(ctx: CanvasContext) {
519
+ if (_cullRafPending || !_cullEnabled) return;
520
+ _cullRafPending = true;
521
+ requestAnimationFrame(() => {
522
+ _cullRafPending = false;
523
+ const t0 = performance.now();
524
+ performViewportCulling(ctx);
525
+ const elapsed = performance.now() - t0;
526
+ // Report to perf overlay (lazy import avoids circular dep)
527
+ try { require('./perf-overlay').reportRenderTiming('cull', elapsed); } catch { }
528
+ });
529
+ }
530
+
531
+ /**
532
+ * Enable/disable viewport culling.
533
+ */
534
+ export function setViewportCullingEnabled(enabled: boolean) {
535
+ _cullEnabled = enabled;
536
+ }
537
+
538
+ /**
539
+ * Force all cards to be visible (disable culling effect).
540
+ * Call this before operations that need to measure all cards (e.g. fitAll).
541
+ * Also materializes all deferred cards so they can be measured.
542
+ */
543
+ export function uncullAllCards(ctx: CanvasContext) {
544
+ // Clear any pill placeholders
545
+ clearAllPills(ctx);
546
+ _currentLodMode = 'full';
547
+
548
+ for (const [, card] of ctx.fileCards) {
549
+ card.style.contentVisibility = '';
550
+ card.style.visibility = '';
551
+ card.dataset.culled = 'false';
552
+ }
553
+
554
+ // Materialize ALL deferred cards (needed for fitAll, arrangeGrid etc.)
555
+ if (ctx.deferredCards.size > 0) {
556
+ const { createAllFileCard } = require('./cards');
557
+ for (const [path, entry] of ctx.deferredCards) {
558
+ // Skip hidden files
559
+ if (ctx.hiddenFiles.has(path)) continue;
560
+ const { file, x, y, size, isChanged } = entry;
561
+ const card = createAllFileCard(ctx, file, x, y, size);
562
+ if (isChanged) {
563
+ card.classList.add('file-card--changed');
564
+ card.dataset.changed = 'true';
565
+ }
566
+ ctx.canvas.appendChild(card);
567
+ ctx.fileCards.set(path, card);
568
+ }
569
+ console.log(`[uncull] Materialized all ${ctx.deferredCards.size} deferred cards`);
570
+ ctx.deferredCards.clear();
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Setup event-delegated interaction for pill cards.
576
+ * One listener on the canvas handles all pill clicks/drags/double-clicks.
577
+ * Much more efficient than per-pill listeners.
578
+ */
579
+ let _pillInteractionSetup = false;
580
+ export function setupPillInteraction(ctx: CanvasContext) {
581
+ if (_pillInteractionSetup || !ctx.canvas) return;
582
+ _pillInteractionSetup = true;
583
+
584
+ let pillAction: null | 'pending' | 'move' = null;
585
+ let pillTarget: HTMLElement | null = null;
586
+ let pillStartX = 0, pillStartY = 0;
587
+ let pillMoveInfos: { pill: HTMLElement; path: string; startLeft: number; startTop: number }[] = [];
588
+ const DRAG_THRESHOLD = 5;
589
+
590
+ ctx.canvas.addEventListener('mousedown', (e: MouseEvent) => {
591
+ if (e.button !== 0) return;
592
+ const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
593
+ if (!pill) return;
594
+
595
+ e.stopPropagation();
596
+ pillTarget = pill;
597
+ pillAction = 'pending';
598
+ pillStartX = e.clientX;
599
+ pillStartY = e.clientY;
600
+
601
+ window.addEventListener('mousemove', onPillMove);
602
+ window.addEventListener('mouseup', onPillUp);
603
+ });
604
+
605
+ // Native dblclick to open editor modal (consistent with card dblclick)
606
+ ctx.canvas.addEventListener('dblclick', (e: MouseEvent) => {
607
+ const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
608
+ if (!pill) return;
609
+ e.stopPropagation();
610
+ e.preventDefault();
611
+ const pillPath = pill.dataset.path || '';
612
+ if (pillPath) {
613
+ const file = ctx.allFilesData?.find(f => f.path === pillPath) ||
614
+ { path: pillPath, name: pillPath.split('/').pop(), lines: 0 };
615
+ import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file));
616
+ }
617
+ });
618
+
619
+ function onPillMove(e: MouseEvent) {
620
+ if (!pillTarget) return;
621
+ const state = ctx.snap().context;
622
+ const dx = (e.clientX - pillStartX) / state.zoom;
623
+ const dy = (e.clientY - pillStartY) / state.zoom;
624
+
625
+ if (pillAction === 'pending') {
626
+ const dist = Math.sqrt((e.clientX - pillStartX) ** 2 + (e.clientY - pillStartY) ** 2);
627
+ if (dist < DRAG_THRESHOLD) return;
628
+
629
+ pillAction = 'move';
630
+ const pillPath = pillTarget.dataset.path || '';
631
+
632
+ // If this pill isn't selected yet, select it
633
+ const selected = state.selectedCards;
634
+ if (!selected.includes(pillPath)) {
635
+ if (!e.shiftKey && !e.ctrlKey) {
636
+ ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
637
+ } else {
638
+ ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
639
+ }
640
+ updatePillSelectionHighlights(ctx);
641
+ }
642
+
643
+ // Collect all selected pills for multi-drag
644
+ const nowSelected = ctx.snap().context.selectedCards;
645
+ pillMoveInfos = [];
646
+ nowSelected.forEach(path => {
647
+ const p = pillCards.get(path);
648
+ if (p) {
649
+ pillMoveInfos.push({
650
+ pill: p,
651
+ path,
652
+ startLeft: parseFloat(p.style.left) || 0,
653
+ startTop: parseFloat(p.style.top) || 0,
654
+ });
655
+ }
656
+ });
657
+ }
658
+
659
+ if (pillAction === 'move') {
660
+ pillMoveInfos.forEach(info => {
661
+ info.pill.style.left = `${info.startLeft + dx}px`;
662
+ info.pill.style.top = `${info.startTop + dy}px`;
663
+ });
664
+ }
665
+ }
666
+
667
+ function onPillUp(e: MouseEvent) {
668
+ window.removeEventListener('mousemove', onPillMove);
669
+ window.removeEventListener('mouseup', onPillUp);
670
+
671
+ if (!pillTarget) return;
672
+ const pillPath = pillTarget.dataset.path || '';
673
+
674
+ if (pillAction === 'pending') {
675
+ // Single click select (double-click handled by native dblclick listener)
676
+ if (e.shiftKey || e.ctrlKey) {
677
+ ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
678
+ } else {
679
+ ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
680
+ }
681
+ updatePillSelectionHighlights(ctx);
682
+ } else if (pillAction === 'move') {
683
+ // Save new positions for all moved pills
684
+ const { savePosition } = require('./positions');
685
+ pillMoveInfos.forEach(info => {
686
+ const newX = parseFloat(info.pill.style.left) || 0;
687
+ const newY = parseFloat(info.pill.style.top) || 0;
688
+
689
+ // Update deferred card position
690
+ const deferred = ctx.deferredCards.get(info.path);
691
+ if (deferred) {
692
+ deferred.x = newX;
693
+ deferred.y = newY;
694
+ }
695
+
696
+ // Update materialized card position too (if exists)
697
+ const card = ctx.fileCards.get(info.path);
698
+ if (card) {
699
+ card.style.left = `${newX}px`;
700
+ card.style.top = `${newY}px`;
701
+ }
702
+
703
+ savePosition(ctx, 'allfiles', info.path, newX, newY);
704
+ });
705
+ pillMoveInfos = [];
706
+ // Force minimap rebuild so dot positions reflect the drag result
707
+ const { forceMinimapRebuild } = require('./canvas');
708
+ forceMinimapRebuild(ctx);
709
+ }
710
+
711
+ pillAction = null;
712
+ pillTarget = null;
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Update pill selection highlights based on XState selectedCards.
718
+ */
719
+ export function updatePillSelectionHighlights(ctx: CanvasContext) {
720
+ const selected = ctx.snap().context.selectedCards;
721
+ for (const [path, pill] of pillCards) {
722
+ if (selected.includes(path)) {
723
+ pill.style.outline = '8px solid rgba(124, 58, 237, 1)';
724
+ pill.style.outlineOffset = '6px';
725
+ pill.style.boxShadow = '0 0 0 6px rgba(124, 58, 237, 0.5), 0 0 60px 20px rgba(124, 58, 237, 0.6), 0 0 100px 40px rgba(124, 58, 237, 0.3)';
726
+ pill.style.zIndex = '100';
727
+ pill.style.filter = 'brightness(1.3)';
728
+ } else {
729
+ pill.style.outline = '';
730
+ pill.style.outlineOffset = '';
731
+ pill.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
732
+ pill.style.zIndex = '';
733
+ pill.style.filter = '';
734
+ }
735
+ }
736
+ // Also update full card highlights
737
+ const { updateSelectionHighlights } = require('./cards');
738
+ updateSelectionHighlights(ctx);
739
+ }
740
+