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,400 +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.visibility = 'visible';
102
- clone.style.contentVisibility = 'visible';
103
- clone.style.opacity = '1';
104
- clone.style.maxHeight = 'none';
105
- clone.style.width = `${POPUP_MAX_W - 2}px`;
106
- clone.style.overflow = 'visible';
107
- clone.style.pointerEvents = 'auto';
108
- clone.style.transition = 'none';
109
- clone.style.transform = 'none';
110
- clone.style.outline = 'none';
111
- clone.style.boxShadow = 'none';
112
- delete clone.dataset.culled;
113
- delete clone.dataset.expanded;
114
-
115
- // If the card used canvas-text rendering, re-render body as DOM HTML
116
- const canvasContainer = clone.querySelector('.canvas-container');
117
- if (canvasContainer) {
118
- const { _getCardFileData, _buildFileContentHTML } = require('./cards');
119
- const file = _getCardFileData(existingCard);
120
- if (file?.content) {
121
- const addedLines = file.addedLines || new Set();
122
- const deletedBeforeLine = file.deletedBeforeLine || new Map();
123
- const isAllAdded = file.status === 'added';
124
- const isAllDeleted = file.status === 'deleted';
125
- const html = _buildFileContentHTML(
126
- file.content, file.layerSections, addedLines, deletedBeforeLine,
127
- isAllAdded, isAllDeleted, false, file.lines
128
- );
129
- canvasContainer.outerHTML = html;
130
- }
131
- }
132
-
133
- return clone;
134
- }
135
-
136
- // Strategy 2: Render from deferred card data
137
- const deferred = _ctx.deferredCards.get(path);
138
- if (deferred) {
139
- // Temporarily force DOM rendering (canvas-text doesn't work in detached elements)
140
- const wasCanvasText = _ctx.useCanvasText;
141
- _ctx.useCanvasText = false;
142
- const { createAllFileCard } = require('./cards');
143
- const card = createAllFileCard(_ctx, deferred.file, 0, 0, null, true) as HTMLElement;
144
- _ctx.useCanvasText = wasCanvasText;
145
- card.style.position = 'relative';
146
- card.style.left = '0';
147
- card.style.top = '0';
148
- card.style.maxHeight = 'none';
149
- card.style.width = `${POPUP_MAX_W - 2}px`;
150
- card.style.overflow = 'visible';
151
- card.style.pointerEvents = 'auto';
152
-
153
- card.style.transition = 'none';
154
- return card;
155
- }
156
-
157
- return null;
158
- }
159
-
160
- // ─── Image preview support ───────────────────────────────
161
- const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']);
162
-
163
- function renderImagePreview(path: string): HTMLElement | null {
164
- if (!_ctx) return null;
165
- const state = _ctx.snap().context;
166
- const repoPath = state.repoPath;
167
- if (!repoPath) return null;
168
-
169
- const container = document.createElement('div');
170
- container.style.cssText = `
171
- padding: 12px;
172
- display: flex;
173
- flex-direction: column;
174
- align-items: center;
175
- gap: 8px;
176
- `;
177
-
178
- // File name label
179
- const label = document.createElement('div');
180
- label.style.cssText = 'font-size: 11px; color: var(--text-muted); font-family: monospace;';
181
- label.textContent = path.split('/').pop() || path;
182
- container.appendChild(label);
183
-
184
- // Image element
185
- const img = document.createElement('img');
186
- img.src = `/api/repo/file-raw?path=${encodeURIComponent(repoPath)}&filePath=${encodeURIComponent(path)}`;
187
- img.alt = path;
188
- img.style.cssText = `
189
- max-width: ${POPUP_MAX_W - 24}px;
190
- max-height: ${POPUP_MAX_H - 50}px;
191
- object-fit: contain;
192
- border-radius: 6px;
193
- background: repeating-conic-gradient(#222 0% 25%, #333 0% 50%) 50% / 16px 16px; /* checkerboard for transparency */
194
- `;
195
- img.onerror = () => {
196
- img.style.display = 'none';
197
- label.textContent = `⚠ Could not load: ${path.split('/').pop()}`;
198
- };
199
- container.appendChild(img);
200
-
201
- return container;
202
- }
203
-
204
- function showPopup(path: string, screenX: number, screenY: number) {
205
- const el = ensurePopup();
206
-
207
- // Check if this is an image file
208
- const ext = path.split('.').pop()?.toLowerCase() || '';
209
- let previewContent: HTMLElement | null;
210
-
211
- if (IMAGE_EXTENSIONS.has(ext)) {
212
- previewContent = renderImagePreview(path);
213
- } else {
214
- previewContent = renderPreviewCard(path);
215
- }
216
-
217
- if (!previewContent) {
218
- hidePopup();
219
- return;
220
- }
221
-
222
- // Clear previous and insert
223
- el.innerHTML = '';
224
- el.appendChild(previewContent);
225
-
226
- // Position: near mouse, clamped to viewport
227
- const vw = window.innerWidth;
228
- const vh = window.innerHeight;
229
-
230
- let x = screenX + OFFSET_X;
231
- let y = screenY + OFFSET_Y;
232
-
233
- // Clamp right edge
234
- if (x + POPUP_MAX_W > vw - 12) x = screenX - POPUP_MAX_W - OFFSET_X;
235
- // Clamp bottom edge
236
- if (y + POPUP_MAX_H > vh - 12) y = screenY - POPUP_MAX_H - OFFSET_Y;
237
- // Clamp left/top
238
- x = Math.max(8, x);
239
- y = Math.max(8, y);
240
-
241
- el.style.left = `${x}px`;
242
- el.style.top = `${y}px`;
243
- el.style.opacity = '1';
244
- el.style.transform = 'translateY(0) scale(1)';
245
- }
246
-
247
- function hidePopup() {
248
- if (showTimer) {
249
- clearTimeout(showTimer);
250
- showTimer = null;
251
- }
252
- currentCardPath = null;
253
- if (popup) {
254
- popup.style.opacity = '0';
255
- popup.style.transform = 'translateY(6px) scale(0.97)';
256
- // Clear content after fade to free memory
257
- setTimeout(() => {
258
- if (popup && popup.style.opacity === '0') {
259
- popup.innerHTML = '';
260
- }
261
- }, 200);
262
- }
263
- }
264
-
265
- // ─── Event handlers ──────────────────────────────────────
266
- function onMouseMove(e: MouseEvent) {
267
- if (!isPreviewEnabled) return;
268
- if (_isHoveringPopup) return; // Don't hide while interacting with popup
269
-
270
- const gdState = getGalaxyDrawState();
271
- if (!gdState || gdState.zoom >= PREVIEW_ZOOM_THRESHOLD) {
272
- hidePopup();
273
- return;
274
- }
275
-
276
- // Find the closest pill card or file card ancestor
277
- const target = e.target as HTMLElement;
278
- const pill = target.closest?.('.file-pill') as HTMLElement | null;
279
- const card = target.closest?.('.file-card') as HTMLElement | null;
280
- const element = pill || card;
281
-
282
- if (!element) {
283
- hidePopup();
284
- return;
285
- }
286
-
287
- const path = element.dataset.path || '';
288
- if (!path) {
289
- hidePopup();
290
- return;
291
- }
292
-
293
- if (path === currentCardPath) {
294
- // Already showing for this card — just reposition
295
- if (popup && popup.style.opacity === '1') {
296
- const vw = window.innerWidth;
297
- const vh = window.innerHeight;
298
- let x = e.clientX + OFFSET_X;
299
- let y = e.clientY + OFFSET_Y;
300
- if (x + POPUP_MAX_W > vw - 12) x = e.clientX - POPUP_MAX_W - OFFSET_X;
301
- if (y + POPUP_MAX_H > vh - 12) y = e.clientY - POPUP_MAX_H - OFFSET_Y;
302
- x = Math.max(8, x);
303
- y = Math.max(8, y);
304
- popup.style.left = `${x}px`;
305
- popup.style.top = `${y}px`;
306
- }
307
- return;
308
- }
309
-
310
- // New card — debounce show
311
- hidePopup();
312
- currentCardPath = path;
313
- showTimer = setTimeout(() => {
314
- // Re-verify zoom is still low
315
- const gd = getGalaxyDrawState();
316
- if (!gd || gd.zoom >= PREVIEW_ZOOM_THRESHOLD) return;
317
- showPopup(path, e.clientX, e.clientY);
318
- }, SHOW_DELAY_MS);
319
- }
320
-
321
- function onMouseOut(e: MouseEvent) {
322
- const related = e.relatedTarget as HTMLElement | null;
323
- if (related?.closest?.('.file-pill') || related?.closest?.('.file-card')) return;
324
- // Don't hide if mouse moved to the popup itself
325
- if (related?.closest?.('.file-preview-popup')) return;
326
- if (_isHoveringPopup) return;
327
- hidePopup();
328
- }
329
-
330
- // ─── Public API ──────────────────────────────────────────
331
-
332
- /**
333
- * Initialize file preview on the canvas viewport.
334
- * Call once after the canvas is mounted.
335
- * @param viewportEl - The canvas viewport element
336
- * @param ctx - The CanvasContext for looking up file data
337
- */
338
- export function initFilePreview(viewportEl: HTMLElement, ctx?: CanvasContext) {
339
- if (isInitialized) return;
340
- isInitialized = true;
341
- if (ctx) _ctx = ctx;
342
-
343
- viewportEl.addEventListener('mousemove', onMouseMove, { passive: true });
344
- viewportEl.addEventListener('mouseout', onMouseOut, { passive: true });
345
-
346
- // Hide on zoom change (catches scroll-zoom)
347
- viewportEl.addEventListener('wheel', () => {
348
- // Don't hide popup if user is hovering/scrolling it
349
- if (_isHoveringPopup) return;
350
- setTimeout(() => {
351
- if (_isHoveringPopup) return;
352
- const gd = getGalaxyDrawState();
353
- if (gd && gd.zoom >= PREVIEW_ZOOM_THRESHOLD) {
354
- hidePopup();
355
- }
356
- }, 50);
357
- }, { passive: true });
358
-
359
- console.log('[file-preview] Initialized full card preview below', (PREVIEW_ZOOM_THRESHOLD * 100).toFixed(0) + '% zoom');
360
- }
361
-
362
- /**
363
- * Destroy file preview. Call on cleanup.
364
- */
365
- export function destroyFilePreview(viewportEl: HTMLElement) {
366
- viewportEl.removeEventListener('mousemove', onMouseMove);
367
- viewportEl.removeEventListener('mouseout', onMouseOut);
368
- if (popup) {
369
- popup.remove();
370
- popup = null;
371
- }
372
- _ctx = null;
373
- isInitialized = false;
374
- }
375
-
376
- /**
377
- * Toggle file preview on/off. Persists to localStorage.
378
- */
379
- export function toggleFilePreview(): boolean {
380
- isPreviewEnabled = !isPreviewEnabled;
381
- localStorage.setItem('gitmaps:previewEnabled', String(isPreviewEnabled));
382
- if (!isPreviewEnabled) hidePopup();
383
- return isPreviewEnabled;
384
- }
385
-
386
- /**
387
- * Set file preview enabled state. Persists to localStorage.
388
- */
389
- export function setFilePreviewEnabled(enabled: boolean) {
390
- isPreviewEnabled = enabled;
391
- localStorage.setItem('gitmaps:previewEnabled', String(enabled));
392
- if (!enabled) hidePopup();
393
- }
394
-
395
- /**
396
- * Get current preview enabled state.
397
- */
398
- export function isFilePreviewEnabled(): boolean {
399
- return isPreviewEnabled;
400
- }
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
+ }