gitmaps 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +267 -118
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/analytics.db +0 -0
  5. package/app/api/analytics/route.ts +64 -0
  6. package/app/api/auth/positions/route.ts +95 -33
  7. package/app/api/build-info/route.ts +19 -0
  8. package/app/api/chat/route.ts +13 -2
  9. package/app/api/og-image/route.ts +14 -0
  10. package/app/api/repo/file-content/route.ts +73 -20
  11. package/app/api/repo/load/route.test.ts +62 -0
  12. package/app/api/repo/load/route.ts +41 -1
  13. package/app/api/repo/pdf-thumb/route.ts +127 -0
  14. package/app/api/repo/resolve-slug/route.ts +51 -0
  15. package/app/api/repo/tree/route.ts +188 -104
  16. package/app/api/version/route.ts +26 -0
  17. package/app/globals.css +5706 -4938
  18. package/app/layout.tsx +1279 -490
  19. package/app/lib/auto-arrange.test.ts +158 -0
  20. package/app/lib/auto-arrange.ts +147 -0
  21. package/app/lib/canvas-export.ts +358 -358
  22. package/app/lib/canvas.ts +625 -564
  23. package/app/lib/cards.tsx +1361 -916
  24. package/app/lib/chat.tsx +65 -9
  25. package/app/lib/code-editor.ts +86 -2
  26. package/app/lib/context.test.ts +32 -0
  27. package/app/lib/context.ts +19 -3
  28. package/app/lib/cursor-sharing.ts +34 -0
  29. package/app/lib/events.tsx +71 -93
  30. package/app/lib/export-canvas.ts +287 -0
  31. package/app/lib/file-card-plugin.ts +148 -148
  32. package/app/lib/file-modal.tsx +49 -0
  33. package/app/lib/file-preview.ts +486 -427
  34. package/app/lib/github-import.test.ts +424 -0
  35. package/app/lib/initial-route-hydration.test.ts +283 -0
  36. package/app/lib/initial-route-hydration.ts +202 -0
  37. package/app/lib/landing-reset.test.ts +99 -0
  38. package/app/lib/landing-reset.ts +106 -0
  39. package/app/lib/landing-shell.test.ts +75 -0
  40. package/app/lib/large-repo-optimization.ts +37 -0
  41. package/app/lib/layout-snapshots.ts +320 -0
  42. package/app/lib/loading.test.ts +69 -0
  43. package/app/lib/loading.tsx +160 -45
  44. package/app/lib/mount-cleanup.test.ts +52 -0
  45. package/app/lib/mount-cleanup.ts +34 -0
  46. package/app/lib/mount-init.test.ts +123 -0
  47. package/app/lib/mount-init.ts +107 -0
  48. package/app/lib/mount-lifecycle.test.ts +39 -0
  49. package/app/lib/mount-lifecycle.ts +12 -0
  50. package/app/lib/mount-route-wiring.test.ts +87 -0
  51. package/app/lib/mount-route-wiring.ts +84 -0
  52. package/app/lib/multi-repo.ts +14 -0
  53. package/app/lib/onboarding-tutorial.ts +278 -0
  54. package/app/lib/positions.ts +190 -121
  55. package/app/lib/recent-commits.test.ts +947 -0
  56. package/app/lib/recent-commits.ts +227 -0
  57. package/app/lib/repo-handoff.test.ts +23 -0
  58. package/app/lib/repo-handoff.ts +16 -0
  59. package/app/lib/repo-progressive.ts +119 -0
  60. package/app/lib/repo-select.test.ts +61 -0
  61. package/app/lib/repo-select.ts +74 -0
  62. package/app/lib/repo.tsx +1383 -987
  63. package/app/lib/role.ts +228 -0
  64. package/app/lib/route-catchall.test.ts +27 -0
  65. package/app/lib/route-repo-entry.test.ts +95 -0
  66. package/app/lib/route-repo-entry.ts +36 -0
  67. package/app/lib/router-contract.test.ts +22 -0
  68. package/app/lib/router-contract.ts +19 -0
  69. package/app/lib/shared-layout.test.ts +86 -0
  70. package/app/lib/shared-layout.ts +82 -0
  71. package/app/lib/status-bar.test.ts +118 -0
  72. package/app/lib/status-bar.ts +365 -128
  73. package/app/lib/sync-controls.test.ts +43 -0
  74. package/app/lib/sync-controls.tsx +303 -0
  75. package/app/lib/test-dom.ts +145 -0
  76. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  77. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  78. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  79. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  80. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  81. package/app/lib/transclusion-smoke.test.ts +163 -0
  82. package/app/lib/tutorial.ts +301 -0
  83. package/app/lib/version.ts +93 -0
  84. package/app/lib/viewport-culling.ts +740 -735
  85. package/app/lib/virtual-files.ts +456 -0
  86. package/app/lib/webgl-text.ts +189 -0
  87. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
  88. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  89. package/app/og-image.png +0 -0
  90. package/app/page.client.tsx +70 -269
  91. package/app/page.tsx +15 -16
  92. package/app/state/machine.js +13 -0
  93. package/package.json +84 -75
  94. package/server.ts +10 -0
  95. package/app/[owner]/[repo]/page.tsx +0 -6
  96. package/app/[slug]/page.tsx +0 -6
  97. package/packages/galaxydraw/README.md +0 -296
  98. package/packages/galaxydraw/banner.png +0 -0
  99. package/packages/galaxydraw/demo/build-static.ts +0 -100
  100. package/packages/galaxydraw/demo/client.ts +0 -154
  101. package/packages/galaxydraw/demo/dist/client.js +0 -8
  102. package/packages/galaxydraw/demo/index.html +0 -256
  103. package/packages/galaxydraw/demo/server.ts +0 -96
  104. package/packages/galaxydraw/dist/index.js +0 -984
  105. package/packages/galaxydraw/dist/index.js.map +0 -16
  106. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  109. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  110. package/packages/galaxydraw/package.json +0 -49
  111. package/packages/galaxydraw/perf.test.ts +0 -284
  112. package/packages/galaxydraw/src/core/cards.ts +0 -435
  113. package/packages/galaxydraw/src/core/engine.ts +0 -339
  114. package/packages/galaxydraw/src/core/events.ts +0 -81
  115. package/packages/galaxydraw/src/core/layout.ts +0 -136
  116. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  117. package/packages/galaxydraw/src/core/state.ts +0 -177
  118. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  119. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  120. package/packages/galaxydraw/src/index.ts +0 -40
  121. package/packages/galaxydraw/tsconfig.json +0 -30
@@ -1,427 +1,486 @@
1
- // @ts-nocheck
2
- /**
3
- * File Preview — renders the EXACT same card component when hovering
4
- * over pill placeholders or file cards at low zoom levels.
5
- *
6
- * Instead of a simplified tooltip with plain text, this clones or
7
- * re-renders the full file card (diff markers, syntax highlighting,
8
- * status badges, connections) and shows it in a fixed popup container
9
- * at readable scale.
10
- *
11
- * Architecture:
12
- * - Single shared popup container (avoids DOM thrashing)
13
- * - Debounced show (180ms) to prevent flicker during fast panning
14
- * - Looks up file data from ctx.fileCards (clone existing) or
15
- * ctx.deferredCards (render fresh via createAllFileCard)
16
- * - Positioned near cursor, clamped to viewport bounds
17
- * - Hides on mouseout, zoom change above threshold, or scroll
18
- */
19
-
20
- import { getGalaxyDrawState } from './galaxydraw-bridge';
21
- import type { CanvasContext } from './context';
22
-
23
- // ─── Config ──────────────────────────────────────────────
24
- const PREVIEW_ZOOM_THRESHOLD = 0.25; // Match LOD_ZOOM_THRESHOLD in viewport-culling.ts
25
- const SHOW_DELAY_MS = 180;
26
- const OFFSET_X = 16;
27
- const OFFSET_Y = 16;
28
- const POPUP_MAX_W = 520;
29
- const POPUP_MAX_H = 600;
30
-
31
- // ─── State ───────────────────────────────────────────────
32
- let popup: HTMLElement | null = null;
33
- let showTimer: ReturnType<typeof setTimeout> | null = null;
34
- let currentCardPath: string | null = null;
35
- let isInitialized = false;
36
- let _ctx: CanvasContext | null = null;
37
- let isPreviewEnabled = localStorage.getItem('gitmaps:previewEnabled') !== 'false'; // enabled by default
38
- let _isHoveringPopup = false;
39
-
40
- // ─── Popup container ─────────────────────────────────────
41
- function ensurePopup(): HTMLElement {
42
- if (popup) return popup;
43
-
44
- popup = document.createElement('div');
45
- popup.className = 'file-preview-popup';
46
- popup.style.cssText = `
47
- position: fixed;
48
- z-index: 9999;
49
- pointer-events: auto;
50
- opacity: 0;
51
- transform: translateY(6px) scale(0.97);
52
- transition: opacity 0.18s ease, transform 0.18s ease;
53
- max-width: ${POPUP_MAX_W}px;
54
- max-height: ${POPUP_MAX_H}px;
55
- overflow-y: auto;
56
- overflow-x: hidden;
57
- border-radius: 12px;
58
- box-shadow:
59
- 0 12px 48px rgba(0, 0, 0, 0.6),
60
- 0 0 0 1px rgba(124, 58, 237, 0.25),
61
- 0 0 24px rgba(124, 58, 237, 0.12);
62
- background: var(--bg-primary, #0a0a14);
63
- `;
64
- // Keep popup visible while hovering over it (for scrolling)
65
- popup.addEventListener('mouseenter', () => { _isHoveringPopup = true; });
66
- popup.addEventListener('mouseleave', () => {
67
- _isHoveringPopup = false;
68
- hidePopup();
69
- });
70
- // Capture wheel events to scroll popup content, not zoom canvas
71
- popup.addEventListener('wheel', (e) => {
72
- e.stopPropagation();
73
- // Manually scroll the popup content
74
- popup.scrollTop += e.deltaY;
75
- e.preventDefault();
76
- }, { passive: false });
77
- document.body.appendChild(popup);
78
- return popup;
79
- }
80
-
81
- /**
82
- * Render the full card preview inside the popup container.
83
- * Strategy:
84
- * 1. If the card is already materialized in ctx.fileCards → deep clone it
85
- * 2. If it's deferred in ctx.deferredCards → render a fresh card
86
- *
87
- * Important: Canvas-text rendering (CanvasTextRenderer) doesn't survive
88
- * cloning, so we always force DOM-based HTML rendering for previews.
89
- */
90
- function renderPreviewCard(path: string): HTMLElement | null {
91
- if (!_ctx) return null;
92
-
93
- // Strategy 1: Clone existing materialized card
94
- const existingCard = _ctx.fileCards.get(path);
95
- if (existingCard) {
96
- const clone = existingCard.cloneNode(true) as HTMLElement;
97
- // Reset positioning we'll position the popup itself
98
- clone.style.position = 'relative';
99
- clone.style.left = '0';
100
- clone.style.top = '0';
101
- clone.style.display = 'block'; // CRITICAL: cards are display:none in pill mode
102
- clone.style.visibility = 'visible';
103
- clone.style.contentVisibility = 'visible';
104
- clone.style.opacity = '1';
105
- clone.style.maxHeight = 'none';
106
- clone.style.width = `${POPUP_MAX_W - 2}px`;
107
- clone.style.overflow = 'visible';
108
- clone.style.pointerEvents = 'auto';
109
- clone.style.transition = 'none';
110
- clone.style.transform = 'none';
111
- clone.style.outline = 'none';
112
- clone.style.boxShadow = 'none';
113
- delete clone.dataset.culled;
114
- delete clone.dataset.expanded;
115
-
116
- // Always re-render body with ALL lines (cards are 120-line limited)
117
- const { _getCardFileData, _buildFileContentHTML } = require('./cards');
118
- const file = _getCardFileData(existingCard);
119
- if (file?.content) {
120
- const addedLines = file.addedLines || new Set();
121
- const deletedBeforeLine = file.deletedBeforeLine || new Map();
122
- const isAllAdded = file.status === 'added';
123
- const isAllDeleted = file.status === 'deleted';
124
- const html = _buildFileContentHTML(
125
- file.content, file.layerSections, addedLines, deletedBeforeLine,
126
- isAllAdded, isAllDeleted, true, file.lines // true = expanded, show ALL lines
127
- );
128
- // Replace body content with full file
129
- const body = clone.querySelector('.file-card-body');
130
- if (body) body.innerHTML = html;
131
- // Also replace canvas-text container if present
132
- const canvasContainer = clone.querySelector('.canvas-container');
133
- if (canvasContainer) canvasContainer.outerHTML = html;
134
- }
135
-
136
- return clone;
137
- }
138
-
139
- // Strategy 2: Render from deferred card data
140
- const deferred = _ctx.deferredCards.get(path);
141
- if (deferred) {
142
- // Temporarily force DOM rendering (canvas-text doesn't work in detached elements)
143
- const wasCanvasText = _ctx.useCanvasText;
144
- _ctx.useCanvasText = false;
145
- const { createAllFileCard } = require('./cards');
146
- const card = createAllFileCard(_ctx, deferred.file, 0, 0, null, true) as HTMLElement;
147
- _ctx.useCanvasText = wasCanvasText;
148
- card.style.position = 'relative';
149
- card.style.left = '0';
150
- card.style.top = '0';
151
- card.style.maxHeight = 'none';
152
- card.style.width = `${POPUP_MAX_W - 2}px`;
153
- card.style.overflow = 'visible';
154
- card.style.pointerEvents = 'auto';
155
-
156
- card.style.transition = 'none';
157
- return card;
158
- }
159
-
160
- return null;
161
- }
162
-
163
- // ─── Image preview support ───────────────────────────────
164
- const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']);
165
-
166
- function renderImagePreview(path: string): HTMLElement | null {
167
- if (!_ctx) return null;
168
- const state = _ctx.snap().context;
169
- const repoPath = state.repoPath;
170
- if (!repoPath) return null;
171
-
172
- const container = document.createElement('div');
173
- container.style.cssText = `
174
- padding: 12px;
175
- display: flex;
176
- flex-direction: column;
177
- align-items: center;
178
- gap: 8px;
179
- `;
180
-
181
- // File name label
182
- const label = document.createElement('div');
183
- label.style.cssText = 'font-size: 11px; color: var(--text-muted); font-family: monospace;';
184
- label.textContent = path.split('/').pop() || path;
185
- container.appendChild(label);
186
-
187
- // Image element
188
- const img = document.createElement('img');
189
- img.src = `/api/repo/file-raw?path=${encodeURIComponent(repoPath)}&filePath=${encodeURIComponent(path)}`;
190
- img.alt = path;
191
- img.style.cssText = `
192
- max-width: ${POPUP_MAX_W - 24}px;
193
- max-height: ${POPUP_MAX_H - 50}px;
194
- object-fit: contain;
195
- border-radius: 6px;
196
- background: repeating-conic-gradient(#222 0% 25%, #333 0% 50%) 50% / 16px 16px; /* checkerboard for transparency */
197
- `;
198
- img.onerror = () => {
199
- img.style.display = 'none';
200
- label.textContent = `⚠ Could not load: ${path.split('/').pop()}`;
201
- };
202
- container.appendChild(img);
203
-
204
- return container;
205
- }
206
-
207
- function showPopup(path: string, screenX: number, screenY: number) {
208
- const el = ensurePopup();
209
-
210
- // Check if this is an image file
211
- const ext = path.split('.').pop()?.toLowerCase() || '';
212
- let previewContent: HTMLElement | null;
213
-
214
- if (IMAGE_EXTENSIONS.has(ext)) {
215
- previewContent = renderImagePreview(path);
216
- } else {
217
- previewContent = renderPreviewCard(path);
218
- }
219
-
220
- if (!previewContent) {
221
- hidePopup();
222
- return;
223
- }
224
-
225
- // Clear previous and insert
226
- el.innerHTML = '';
227
- el.appendChild(previewContent);
228
-
229
- // Position: near mouse, clamped to viewport
230
- const vw = window.innerWidth;
231
- const vh = window.innerHeight;
232
-
233
- let x = screenX + OFFSET_X;
234
- let y = screenY + OFFSET_Y;
235
-
236
- // Clamp right edge
237
- if (x + POPUP_MAX_W > vw - 12) x = screenX - POPUP_MAX_W - OFFSET_X;
238
- // Clamp bottom edge
239
- if (y + POPUP_MAX_H > vh - 12) y = screenY - POPUP_MAX_H - OFFSET_Y;
240
- // Clamp left/top
241
- x = Math.max(8, x);
242
- y = Math.max(8, y);
243
-
244
- el.style.left = `${x}px`;
245
- el.style.top = `${y}px`;
246
- el.style.opacity = '1';
247
- el.style.transform = 'translateY(0) scale(1)';
248
- }
249
-
250
- function hidePopup() {
251
- if (showTimer) {
252
- clearTimeout(showTimer);
253
- showTimer = null;
254
- }
255
- currentCardPath = null;
256
- if (popup) {
257
- popup.style.opacity = '0';
258
- popup.style.transform = 'translateY(6px) scale(0.97)';
259
- // Clear content after fade to free memory
260
- setTimeout(() => {
261
- if (popup && popup.style.opacity === '0') {
262
- popup.innerHTML = '';
263
- }
264
- }, 200);
265
- }
266
- }
267
-
268
- // ─── Event handlers ──────────────────────────────────────
269
- function onMouseMove(e: MouseEvent) {
270
- if (!isPreviewEnabled) return;
271
- if (_isHoveringPopup) return; // Don't hide while interacting with popup
272
-
273
- // Suppress popup during canvas panning (middle-button drag, space-held, isDragging)
274
- if (e.buttons & 4) { hidePopup(); return; } // middle mouse button held
275
- if (e.buttons & 1) { hidePopup(); return; } // left mouse button held (dragging)
276
- const viewport = document.querySelector('.canvas-viewport');
277
- if (viewport?.classList.contains('space-panning')) { hidePopup(); return; }
278
- if (_ctx?.isDragging) { hidePopup(); return; }
279
-
280
- const gdState = getGalaxyDrawState();
281
- if (!gdState || gdState.zoom >= PREVIEW_ZOOM_THRESHOLD) {
282
- hidePopup();
283
- return;
284
- }
285
-
286
- // Find the closest pill card or file card ancestor
287
- const target = e.target as HTMLElement;
288
- const pill = target.closest?.('.file-pill') as HTMLElement | null;
289
- const card = target.closest?.('.file-card') as HTMLElement | null;
290
- const element = pill || card;
291
-
292
- if (!element) {
293
- hidePopup();
294
- return;
295
- }
296
-
297
- const path = element.dataset.path || '';
298
- if (!path) {
299
- hidePopup();
300
- return;
301
- }
302
-
303
- if (path === currentCardPath) {
304
- // Already showing for this card — DON'T reposition.
305
- // Keep popup stationary so user can move their mouse to it for scrolling.
306
- return;
307
- }
308
-
309
- // New card — debounce show
310
- hidePopup();
311
- currentCardPath = path;
312
- showTimer = setTimeout(() => {
313
- // Re-verify zoom is still low
314
- const gd = getGalaxyDrawState();
315
- if (!gd || gd.zoom >= PREVIEW_ZOOM_THRESHOLD) return;
316
- showPopup(path, e.clientX, e.clientY);
317
- }, SHOW_DELAY_MS);
318
- }
319
-
320
- function onMouseOut(e: MouseEvent) {
321
- const related = e.relatedTarget as HTMLElement | null;
322
- if (related?.closest?.('.file-pill') || related?.closest?.('.file-card')) return;
323
- // Don't hide if mouse moved to the popup itself
324
- if (related?.closest?.('.file-preview-popup')) return;
325
- if (_isHoveringPopup) return;
326
- hidePopup();
327
- }
328
-
329
- /**
330
- * Wheel handler on viewport:
331
- * - If popup visible + mouse over pill/placeholder + NO Ctrl → scroll popup
332
- * - If Ctrl held → always let canvas zoom (never intercept)
333
- * - If zoom crosses threshold → hide popup
334
- */
335
- function onViewportWheel(e: WheelEvent) {
336
- // Ctrl+wheel = zoom never intercept
337
- if (e.ctrlKey || e.metaKey) {
338
- // Check if zoom crossed threshold after a tick
339
- if (currentCardPath) {
340
- setTimeout(() => {
341
- const gd = getGalaxyDrawState();
342
- if (gd && gd.zoom >= PREVIEW_ZOOM_THRESHOLD) hidePopup();
343
- }, 50);
344
- }
345
- return;
346
- }
347
-
348
- // If popup is visible and user is hovering over the pill/card that triggered it,
349
- // forward wheel events to scroll the popup content
350
- if (popup && currentCardPath && popup.style.opacity === '1') {
351
- const target = e.target as HTMLElement;
352
- const pill = target.closest?.('.file-pill') as HTMLElement | null;
353
- const card = target.closest?.('.file-card') as HTMLElement | null;
354
- const element = pill || card;
355
- if (element && element.dataset.path === currentCardPath) {
356
- // Only intercept if popup has scrollable content
357
- if (popup.scrollHeight > popup.clientHeight) {
358
- e.preventDefault();
359
- e.stopPropagation();
360
- popup.scrollTop += e.deltaY;
361
- }
362
- }
363
- }
364
- }
365
-
366
- // ─── Public API ──────────────────────────────────────────
367
-
368
- /**
369
- * Initialize file preview on the canvas viewport.
370
- * Call once after the canvas is mounted.
371
- * @param viewportEl - The canvas viewport element
372
- * @param ctx - The CanvasContext for looking up file data
373
- */
374
- export function initFilePreview(viewportEl: HTMLElement, ctx?: CanvasContext) {
375
- if (isInitialized) return;
376
- isInitialized = true;
377
- if (ctx) _ctx = ctx;
378
-
379
- viewportEl.addEventListener('mousemove', onMouseMove, { passive: true });
380
- viewportEl.addEventListener('mouseout', onMouseOut, { passive: true });
381
-
382
- // Wheel: scroll popup when hovering pill, Ctrl+wheel always zooms
383
- viewportEl.addEventListener('wheel', onViewportWheel, { passive: false });
384
-
385
- console.log('[file-preview] Initialized — full card preview below', (PREVIEW_ZOOM_THRESHOLD * 100).toFixed(0) + '% zoom');
386
- }
387
-
388
- /**
389
- * Destroy file preview. Call on cleanup.
390
- */
391
- export function destroyFilePreview(viewportEl: HTMLElement) {
392
- viewportEl.removeEventListener('mousemove', onMouseMove);
393
- viewportEl.removeEventListener('mouseout', onMouseOut);
394
- viewportEl.removeEventListener('wheel', onViewportWheel);
395
- if (popup) {
396
- popup.remove();
397
- popup = null;
398
- }
399
- _ctx = null;
400
- isInitialized = false;
401
- }
402
-
403
- /**
404
- * Toggle file preview on/off. Persists to localStorage.
405
- */
406
- export function toggleFilePreview(): boolean {
407
- isPreviewEnabled = !isPreviewEnabled;
408
- localStorage.setItem('gitmaps:previewEnabled', String(isPreviewEnabled));
409
- if (!isPreviewEnabled) hidePopup();
410
- return isPreviewEnabled;
411
- }
412
-
413
- /**
414
- * Set file preview enabled state. Persists to localStorage.
415
- */
416
- export function setFilePreviewEnabled(enabled: boolean) {
417
- isPreviewEnabled = enabled;
418
- localStorage.setItem('gitmaps:previewEnabled', String(enabled));
419
- if (!enabled) hidePopup();
420
- }
421
-
422
- /**
423
- * Get current preview enabled state.
424
- */
425
- export function isFilePreviewEnabled(): boolean {
426
- return isPreviewEnabled;
427
- }
1
+ // @ts-nocheck
2
+ /**
3
+ * File Preview — renders the EXACT same card component when hovering
4
+ * over pill placeholders or file cards at low zoom levels.
5
+ *
6
+ * Instead of a simplified tooltip with plain text, this clones or
7
+ * re-renders the full file card (diff markers, syntax highlighting,
8
+ * status badges, connections) and shows it in a fixed popup container
9
+ * at readable scale.
10
+ *
11
+ * Architecture:
12
+ * - Single shared popup container (avoids DOM thrashing)
13
+ * - Debounced show (180ms) to prevent flicker during fast panning
14
+ * - Looks up file data from ctx.fileCards (clone existing) or
15
+ * ctx.deferredCards (render fresh via createAllFileCard)
16
+ * - Positioned near cursor, clamped to viewport bounds
17
+ * - Hides on mouseout, zoom change above threshold, or scroll
18
+ */
19
+
20
+ import { getGalaxyDrawState } from "./xydraw-bridge";
21
+ import type { CanvasContext } from "./context";
22
+
23
+ // ─── Config ──────────────────────────────────────────────
24
+ const PREVIEW_ZOOM_THRESHOLD = 0.25; // Match LOD_ZOOM_THRESHOLD in viewport-culling.ts
25
+ const SHOW_DELAY_MS = 180;
26
+ const OFFSET_X = 16;
27
+ const OFFSET_Y = 16;
28
+ const POPUP_MAX_W = 520;
29
+ const POPUP_MAX_H = 600;
30
+
31
+ // ─── State ───────────────────────────────────────────────
32
+ let popup: HTMLElement | null = null;
33
+ let showTimer: ReturnType<typeof setTimeout> | null = null;
34
+ let currentCardPath: string | null = null;
35
+ let isInitialized = false;
36
+ let _ctx: CanvasContext | null = null;
37
+ let isPreviewEnabled =
38
+ localStorage.getItem("gitmaps:previewEnabled") !== "false"; // enabled by default
39
+ let _isHoveringPopup = false;
40
+
41
+ // ─── Popup container ─────────────────────────────────────
42
+ function ensurePopup(): HTMLElement {
43
+ if (popup) return popup;
44
+
45
+ popup = document.createElement("div");
46
+ popup.className = "file-preview-popup";
47
+ popup.style.cssText = `
48
+ position: fixed;
49
+ z-index: 9999;
50
+ pointer-events: auto;
51
+ opacity: 0;
52
+ transform: translateY(6px) scale(0.97);
53
+ transition: opacity 0.18s ease, transform 0.18s ease;
54
+ max-width: ${POPUP_MAX_W}px;
55
+ max-height: ${POPUP_MAX_H}px;
56
+ overflow-y: auto;
57
+ overflow-x: hidden;
58
+ border-radius: 12px;
59
+ box-shadow:
60
+ 0 12px 48px rgba(0, 0, 0, 0.6),
61
+ 0 0 0 1px rgba(124, 58, 237, 0.25),
62
+ 0 0 24px rgba(124, 58, 237, 0.12);
63
+ background: var(--bg-primary, #0a0a14);
64
+ `;
65
+ // Keep popup visible while hovering over it (for scrolling)
66
+ popup.addEventListener("mouseenter", () => {
67
+ _isHoveringPopup = true;
68
+ });
69
+ popup.addEventListener("mouseleave", () => {
70
+ _isHoveringPopup = false;
71
+ hidePopup();
72
+ });
73
+ // Capture wheel events to scroll popup content, not zoom canvas
74
+ popup.addEventListener(
75
+ "wheel",
76
+ (e) => {
77
+ e.stopPropagation();
78
+ // Manually scroll the popup content
79
+ popup.scrollTop += e.deltaY;
80
+ e.preventDefault();
81
+ },
82
+ { passive: false },
83
+ );
84
+ document.body.appendChild(popup);
85
+ return popup;
86
+ }
87
+
88
+ /**
89
+ * Render the full card preview inside the popup container.
90
+ * Strategy:
91
+ * 1. If the card is already materialized in ctx.fileCards → deep clone it
92
+ * 2. If it's deferred in ctx.deferredCards → render a fresh card
93
+ *
94
+ * Important: Canvas-text rendering (CanvasTextRenderer) doesn't survive
95
+ * cloning, so we always force DOM-based HTML rendering for previews.
96
+ */
97
+ function renderPreviewCard(path: string): HTMLElement | null {
98
+ if (!_ctx) return null;
99
+
100
+ // Strategy 1: Clone existing materialized card
101
+ const existingCard = _ctx.fileCards.get(path);
102
+ if (existingCard) {
103
+ const clone = existingCard.cloneNode(true) as HTMLElement;
104
+ // Reset positioning — we'll position the popup itself
105
+ clone.style.position = "relative";
106
+ clone.style.left = "0";
107
+ clone.style.top = "0";
108
+ clone.style.display = "block"; // CRITICAL: cards are display:none in pill mode
109
+ clone.style.visibility = "visible";
110
+ clone.style.contentVisibility = "visible";
111
+ clone.style.opacity = "1";
112
+ clone.style.maxHeight = "none";
113
+ clone.style.width = `${POPUP_MAX_W - 2}px`;
114
+ clone.style.overflow = "visible";
115
+ clone.style.pointerEvents = "auto";
116
+ clone.style.transition = "none";
117
+ clone.style.transform = "none";
118
+ clone.style.outline = "none";
119
+ clone.style.boxShadow = "none";
120
+ delete clone.dataset.culled;
121
+ delete clone.dataset.expanded;
122
+
123
+ // Always re-render body with ALL lines (cards are 120-line limited)
124
+ const { _getCardFileData, _buildFileContentHTML } = require("./cards");
125
+ const file = _getCardFileData(existingCard);
126
+ if (file?.content) {
127
+ const addedLines = file.addedLines || new Set();
128
+ const deletedBeforeLine = file.deletedBeforeLine || new Map();
129
+ const isAllAdded = file.status === "added";
130
+ const isAllDeleted = file.status === "deleted";
131
+ const html = _buildFileContentHTML(
132
+ file.content,
133
+ file.layerSections,
134
+ addedLines,
135
+ deletedBeforeLine,
136
+ isAllAdded,
137
+ isAllDeleted,
138
+ true,
139
+ file.lines, // true = expanded, show ALL lines
140
+ );
141
+ // Replace body content with full file
142
+ const body = clone.querySelector(".file-card-body");
143
+ if (body) body.innerHTML = html;
144
+ // Also replace canvas-text container if present
145
+ const canvasContainer = clone.querySelector(".canvas-container");
146
+ if (canvasContainer) canvasContainer.outerHTML = html;
147
+ }
148
+
149
+ return clone;
150
+ }
151
+
152
+ // Strategy 2: Render from deferred card data
153
+ const deferred = _ctx.deferredCards.get(path);
154
+ if (deferred) {
155
+ // Temporarily force DOM rendering (canvas-text doesn't work in detached elements)
156
+ const wasCanvasText = _ctx.useCanvasText;
157
+ _ctx.useCanvasText = false;
158
+ const { createAllFileCard } = require("./cards");
159
+ const card = createAllFileCard(
160
+ _ctx,
161
+ deferred.file,
162
+ 0,
163
+ 0,
164
+ null,
165
+ true,
166
+ ) as HTMLElement;
167
+ _ctx.useCanvasText = wasCanvasText;
168
+ card.style.position = "relative";
169
+ card.style.left = "0";
170
+ card.style.top = "0";
171
+ card.style.maxHeight = "none";
172
+ card.style.width = `${POPUP_MAX_W - 2}px`;
173
+ card.style.overflow = "visible";
174
+ card.style.pointerEvents = "auto";
175
+
176
+ card.style.transition = "none";
177
+ return card;
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ // ─── Image preview support ───────────────────────────────
184
+ const IMAGE_EXTENSIONS = new Set([
185
+ "png",
186
+ "jpg",
187
+ "jpeg",
188
+ "gif",
189
+ "svg",
190
+ "webp",
191
+ "ico",
192
+ "bmp",
193
+ "avif",
194
+ ]);
195
+
196
+ function renderImagePreview(path: string): HTMLElement | null {
197
+ if (!_ctx) return null;
198
+ const state = _ctx.snap().context;
199
+ const repoPath = state.repoPath;
200
+ if (!repoPath) return null;
201
+
202
+ const container = document.createElement("div");
203
+ container.style.cssText = `
204
+ padding: 12px;
205
+ display: flex;
206
+ flex-direction: column;
207
+ align-items: center;
208
+ gap: 8px;
209
+ `;
210
+
211
+ // File name label
212
+ const label = document.createElement("div");
213
+ label.style.cssText =
214
+ "font-size: 11px; color: var(--text-muted); font-family: monospace;";
215
+ label.textContent = path.split("/").pop() || path;
216
+ container.appendChild(label);
217
+
218
+ // Image element
219
+ const img = document.createElement("img");
220
+ img.src = `/api/repo/file-content?path=${encodeURIComponent(repoPath)}&filePath=file=${encodeURIComponent(path)}`;
221
+ img.alt = path;
222
+ img.style.cssText = `
223
+ max-width: ${POPUP_MAX_W - 24}px;
224
+ max-height: ${POPUP_MAX_H - 50}px;
225
+ object-fit: contain;
226
+ border-radius: 6px;
227
+ background: repeating-conic-gradient(#222 0% 25%, #333 0% 50%) 50% / 16px 16px; /* checkerboard for transparency */
228
+ `;
229
+ img.onerror = () => {
230
+ img.style.display = "none";
231
+ label.textContent = `⚠ Could not load: ${path.split("/").pop()}`;
232
+ };
233
+ container.appendChild(img);
234
+
235
+ return container;
236
+ }
237
+
238
+ function showPopup(path: string, screenX: number, screenY: number) {
239
+ // Cancel any pending show to prevent multiple popups
240
+ if (showTimer) {
241
+ clearTimeout(showTimer);
242
+ showTimer = null;
243
+ }
244
+
245
+ // Hide existing popup first to prevent duplicates
246
+ if (popup && popup.style.opacity === "1") {
247
+ popup.style.opacity = "0";
248
+ popup.innerHTML = "";
249
+ }
250
+
251
+ const el = ensurePopup();
252
+
253
+ // Check if this is an image file
254
+ const ext = path.split(".").pop()?.toLowerCase() || "";
255
+ let previewContent: HTMLElement | null;
256
+
257
+ if (IMAGE_EXTENSIONS.has(ext)) {
258
+ previewContent = renderImagePreview(path);
259
+ } else {
260
+ previewContent = renderPreviewCard(path);
261
+ }
262
+
263
+ if (!previewContent) {
264
+ hidePopup();
265
+ return;
266
+ }
267
+
268
+ // Clear previous and insert
269
+ el.innerHTML = "";
270
+ el.appendChild(previewContent);
271
+
272
+ // Position: near mouse, clamped to viewport
273
+ const vw = window.innerWidth;
274
+ const vh = window.innerHeight;
275
+
276
+ let x = screenX + OFFSET_X;
277
+ let y = screenY + OFFSET_Y;
278
+
279
+ // Clamp right edge
280
+ if (x + POPUP_MAX_W > vw - 12) x = screenX - POPUP_MAX_W - OFFSET_X;
281
+ // Clamp bottom edge
282
+ if (y + POPUP_MAX_H > vh - 12) y = screenY - POPUP_MAX_H - OFFSET_Y;
283
+ // Clamp left/top
284
+ x = Math.max(8, x);
285
+ y = Math.max(8, y);
286
+
287
+ el.style.left = `${x}px`;
288
+ el.style.top = `${y}px`;
289
+ el.style.opacity = "1";
290
+ el.style.transform = "translateY(0) scale(1)";
291
+ }
292
+
293
+ function hidePopup() {
294
+ if (showTimer) {
295
+ clearTimeout(showTimer);
296
+ showTimer = null;
297
+ }
298
+ currentCardPath = null;
299
+ if (popup) {
300
+ popup.style.opacity = "0";
301
+ popup.style.transform = "translateY(6px) scale(0.97)";
302
+ // Clear content after fade to free memory
303
+ setTimeout(() => {
304
+ if (popup && popup.style.opacity === "0") {
305
+ popup.innerHTML = "";
306
+ }
307
+ }, 200);
308
+ }
309
+ }
310
+
311
+ // ─── Event handlers ──────────────────────────────────────
312
+ function onMouseMove(e: MouseEvent) {
313
+ if (!isPreviewEnabled) return;
314
+ if (_isHoveringPopup) return; // Don't hide while interacting with popup
315
+
316
+ // Suppress popup during canvas panning (middle-button drag, space-held, isDragging)
317
+ if (e.buttons & 4) {
318
+ hidePopup();
319
+ return;
320
+ } // middle mouse button held
321
+ if (e.buttons & 1) {
322
+ hidePopup();
323
+ return;
324
+ } // left mouse button held (dragging)
325
+ const viewport = document.querySelector(".canvas-viewport");
326
+ if (viewport?.classList.contains("space-panning")) {
327
+ hidePopup();
328
+ return;
329
+ }
330
+ if (_ctx?.isDragging) {
331
+ hidePopup();
332
+ return;
333
+ }
334
+
335
+ const gdState = getGalaxyDrawState();
336
+ if (!gdState || gdState.zoom >= PREVIEW_ZOOM_THRESHOLD) {
337
+ hidePopup();
338
+ return;
339
+ }
340
+
341
+ // Find the closest pill card or file card ancestor
342
+ const target = e.target as HTMLElement;
343
+ const pill = target.closest?.(".file-pill") as HTMLElement | null;
344
+ const card = target.closest?.(".file-card") as HTMLElement | null;
345
+ const element = pill || card;
346
+
347
+ if (!element) {
348
+ hidePopup();
349
+ return;
350
+ }
351
+
352
+ const path = element.dataset.path || "";
353
+ if (!path) {
354
+ hidePopup();
355
+ return;
356
+ }
357
+
358
+ if (path === currentCardPath) {
359
+ // Already showing for this card — DON'T reposition.
360
+ // Keep popup stationary so user can move their mouse to it for scrolling.
361
+ return;
362
+ }
363
+
364
+ // New card — debounce show
365
+ hidePopup();
366
+ currentCardPath = path;
367
+ showTimer = setTimeout(() => {
368
+ // Re-verify zoom is still low
369
+ const gd = getGalaxyDrawState();
370
+ if (!gd || gd.zoom >= PREVIEW_ZOOM_THRESHOLD) return;
371
+ showPopup(path, e.clientX, e.clientY);
372
+ }, SHOW_DELAY_MS);
373
+ }
374
+
375
+ function onMouseOut(e: MouseEvent) {
376
+ const related = e.relatedTarget as HTMLElement | null;
377
+ if (related?.closest?.(".file-pill") || related?.closest?.(".file-card"))
378
+ return;
379
+ // Don't hide if mouse moved to the popup itself
380
+ if (related?.closest?.(".file-preview-popup")) return;
381
+ if (_isHoveringPopup) return;
382
+ hidePopup();
383
+ }
384
+
385
+ /**
386
+ * Wheel handler on viewport:
387
+ * - If popup visible + mouse over pill/placeholder + NO Ctrl → scroll popup
388
+ * - If Ctrl held → always let canvas zoom (never intercept)
389
+ * - If zoom crosses threshold hide popup
390
+ */
391
+ function onViewportWheel(e: WheelEvent) {
392
+ // Ctrl+wheel = zoom → never intercept
393
+ if (e.ctrlKey || e.metaKey) {
394
+ // Check if zoom crossed threshold after a tick
395
+ if (currentCardPath) {
396
+ setTimeout(() => {
397
+ const gd = getGalaxyDrawState();
398
+ if (gd && gd.zoom >= PREVIEW_ZOOM_THRESHOLD) hidePopup();
399
+ }, 50);
400
+ }
401
+ return;
402
+ }
403
+
404
+ // If popup is visible and user is hovering over the pill/card that triggered it,
405
+ // forward wheel events to scroll the popup content
406
+ if (popup && currentCardPath && popup.style.opacity === "1") {
407
+ const target = e.target as HTMLElement;
408
+ const pill = target.closest?.(".file-pill") as HTMLElement | null;
409
+ const card = target.closest?.(".file-card") as HTMLElement | null;
410
+ const element = pill || card;
411
+ if (element && element.dataset.path === currentCardPath) {
412
+ // Only intercept if popup has scrollable content
413
+ if (popup.scrollHeight > popup.clientHeight) {
414
+ e.preventDefault();
415
+ e.stopPropagation();
416
+ popup.scrollTop += e.deltaY;
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ // ─── Public API ──────────────────────────────────────────
423
+
424
+ /**
425
+ * Initialize file preview on the canvas viewport.
426
+ * Call once after the canvas is mounted.
427
+ * @param viewportEl - The canvas viewport element
428
+ * @param ctx - The CanvasContext for looking up file data
429
+ */
430
+ export function initFilePreview(viewportEl: HTMLElement, ctx?: CanvasContext) {
431
+ if (isInitialized) return;
432
+ isInitialized = true;
433
+ if (ctx) _ctx = ctx;
434
+
435
+ viewportEl.addEventListener("mousemove", onMouseMove, { passive: true });
436
+ viewportEl.addEventListener("mouseout", onMouseOut, { passive: true });
437
+
438
+ // Wheel: scroll popup when hovering pill, Ctrl+wheel always zooms
439
+ viewportEl.addEventListener("wheel", onViewportWheel, { passive: false });
440
+
441
+ console.log(
442
+ "[file-preview] Initialized — full card preview below",
443
+ (PREVIEW_ZOOM_THRESHOLD * 100).toFixed(0) + "% zoom",
444
+ );
445
+ }
446
+
447
+ /**
448
+ * Destroy file preview. Call on cleanup.
449
+ */
450
+ export function destroyFilePreview(viewportEl: HTMLElement) {
451
+ viewportEl.removeEventListener("mousemove", onMouseMove);
452
+ viewportEl.removeEventListener("mouseout", onMouseOut);
453
+ viewportEl.removeEventListener("wheel", onViewportWheel);
454
+ if (popup) {
455
+ popup.remove();
456
+ popup = null;
457
+ }
458
+ _ctx = null;
459
+ isInitialized = false;
460
+ }
461
+
462
+ /**
463
+ * Toggle file preview on/off. Persists to localStorage.
464
+ */
465
+ export function toggleFilePreview(): boolean {
466
+ isPreviewEnabled = !isPreviewEnabled;
467
+ localStorage.setItem("gitmaps:previewEnabled", String(isPreviewEnabled));
468
+ if (!isPreviewEnabled) hidePopup();
469
+ return isPreviewEnabled;
470
+ }
471
+
472
+ /**
473
+ * Set file preview enabled state. Persists to localStorage.
474
+ */
475
+ export function setFilePreviewEnabled(enabled: boolean) {
476
+ isPreviewEnabled = enabled;
477
+ localStorage.setItem("gitmaps:previewEnabled", String(enabled));
478
+ if (!enabled) hidePopup();
479
+ }
480
+
481
+ /**
482
+ * Get current preview enabled state.
483
+ */
484
+ export function isFilePreviewEnabled(): boolean {
485
+ return isPreviewEnabled;
486
+ }