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
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import Page from '../page';
3
+
4
+ type VNode = {
5
+ type?: any;
6
+ props?: Record<string, any>;
7
+ key?: any;
8
+ };
9
+
10
+ function walk(node: any, visit: (node: VNode) => void): void {
11
+ if (!node || typeof node === 'string' || typeof node === 'number') return;
12
+ visit(node);
13
+ const children = node.props?.children;
14
+ if (!children) return;
15
+ if (Array.isArray(children)) {
16
+ for (const child of children) walk(child, visit);
17
+ } else {
18
+ walk(children, visit);
19
+ }
20
+ }
21
+
22
+ function findById(tree: any, id: string): VNode | null {
23
+ let found: VNode | null = null;
24
+ walk(tree, (node) => {
25
+ if (!found && node.props?.id === id) found = node;
26
+ });
27
+ return found;
28
+ }
29
+
30
+ function collectByType(tree: any, type: string): VNode[] {
31
+ const nodes: VNode[] = [];
32
+ walk(tree, (node) => {
33
+ if (node.type === type) nodes.push(node);
34
+ });
35
+ return nodes;
36
+ }
37
+
38
+ function extractText(node: any): string {
39
+ if (!node) return '';
40
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
41
+ const children = node.props?.children;
42
+ if (Array.isArray(children)) return children.map(extractText).join('');
43
+ return extractText(children);
44
+ }
45
+
46
+ describe('landing shell smoke', () => {
47
+ const tree = Page();
48
+
49
+ test('renders the core landing shell ids used by the client bootstrap', () => {
50
+ expect(findById(tree, 'canvasViewport')).toBeTruthy();
51
+ expect(findById(tree, 'canvasContent')).toBeTruthy();
52
+ expect(findById(tree, 'connectionsOverlay')).toBeTruthy();
53
+ expect(findById(tree, 'landingOverlay')).toBeTruthy();
54
+ expect(findById(tree, 'landingParticles')).toBeTruthy();
55
+ });
56
+
57
+ test('includes featured repo links for direct route navigation', () => {
58
+ const anchors = collectByType(tree, 'a');
59
+ const hrefs = anchors.map((node) => node.props?.href).filter(Boolean);
60
+
61
+ expect(hrefs).toContain('/facebook/react');
62
+ expect(hrefs).toContain('/denoland/deno');
63
+ expect(hrefs).toContain('/oven-sh/bun');
64
+ });
65
+
66
+ test('keeps the primary landing guidance text visible', () => {
67
+ const landing = findById(tree, 'landingOverlay');
68
+ const text = extractText(landing);
69
+
70
+ expect(text).toContain('GitMaps');
71
+ expect(text).toContain('Explore popular repositories');
72
+ expect(text).toContain('Select a repo from the sidebar, or click a card above');
73
+ expect(text).toContain('Import any GitHub repo with the sidebar button');
74
+ });
75
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Large Repo Optimizations — Progressive card loading for 500+ file repos
3
+ *
4
+ * Strategies:
5
+ * 1. Load only visible cards + nearby cards initially
6
+ * 2. Defer cards > 2 viewport widths away
7
+ * 3. Progressive loading as user pans
8
+ * 4. Aggressive culling at low zoom for huge repos
9
+ */
10
+
11
+ const LARGE_REPO_THRESHOLD = 500;
12
+ const PROGRESSIVE_LOAD_RADIUS = 2.0; // viewport widths
13
+
14
+ export function isLargeRepo(fileCount: number): boolean {
15
+ return fileCount >= LARGE_REPO_THRESHOLD;
16
+ }
17
+
18
+ export function shouldDeferCard(cardX: number, cardY: number, viewportCenterX: number, viewportCenterY: number, viewportWidth: number, viewportHeight: number): boolean {
19
+ const dx = Math.abs(cardX - viewportCenterX);
20
+ const dy = Math.abs(cardY - viewportCenterY);
21
+ const maxDistX = viewportWidth * PROGRESSIVE_LOAD_RADIUS;
22
+ const maxDistY = viewportHeight * PROGRESSIVE_LOAD_RADIUS;
23
+
24
+ return dx > maxDistX || dy > maxDistY;
25
+ }
26
+
27
+ export function getLODThreshold(fileCount: number): number {
28
+ if (fileCount >= 2000) return 0.15; // Very aggressive culling
29
+ if (fileCount >= 1000) return 0.20; // Aggressive culling
30
+ return 0.25; // Default
31
+ }
32
+
33
+ export function getProgressiveBatchSize(fileCount: number): number {
34
+ if (fileCount >= 2000) return 50; // Load 50 cards at a time
35
+ if (fileCount >= 1000) return 100; // Load 100 cards at a time
36
+ return 200; // Load 200 cards at a time
37
+ }
@@ -0,0 +1,320 @@
1
+ // @ts-nocheck
2
+ import type { CanvasContext } from './context';
3
+ import { flushPositions } from './positions';
4
+ import { showToast } from './utils';
5
+ import { updateCanvasTransform } from './canvas';
6
+
7
+ interface Snapshot {
8
+ id: string;
9
+ name: string;
10
+ timestamp: number;
11
+ zoom: number;
12
+ offsetX: number;
13
+ offsetY: number;
14
+ positions: Record<string, any>;
15
+ hiddenFiles: string[];
16
+ cardSizes: Record<string, any>;
17
+ }
18
+
19
+ let _overlay: HTMLElement | null = null;
20
+ let _ctx: CanvasContext | null = null;
21
+
22
+ function getStorageKey(ctx: CanvasContext): string {
23
+ const repoPath = ctx.snap().context.repoPath || '';
24
+ return `gitcanvas:snapshots:${repoPath}`;
25
+ }
26
+
27
+ export function saveSnapshot(ctx: CanvasContext, name: string) {
28
+ if (!name.trim()) return;
29
+ const key = getStorageKey(ctx);
30
+ const existingRaw = localStorage.getItem(key);
31
+ const snapshots: Snapshot[] = existingRaw ? JSON.parse(existingRaw) : [];
32
+
33
+ const state = ctx.snap().context;
34
+
35
+ // Ensure current un-flushed positions are flushed
36
+ flushPositions(ctx);
37
+
38
+ const positionsObj: Record<string, any> = {};
39
+ for (const [k, v] of ctx.positions) {
40
+ positionsObj[k] = v;
41
+ }
42
+
43
+ const cardSizesObj: Record<string, any> = {};
44
+ if (ctx.cardSizes) {
45
+ for (const [k, v] of ctx.cardSizes) {
46
+ cardSizesObj[k] = v;
47
+ }
48
+ }
49
+
50
+ const snap: Snapshot = {
51
+ id: crypto.randomUUID(),
52
+ name: name.trim(),
53
+ timestamp: Date.now(),
54
+ zoom: state.zoom || 1,
55
+ offsetX: state.offsetX || 0,
56
+ offsetY: state.offsetY || 0,
57
+ positions: positionsObj,
58
+ hiddenFiles: Array.from(ctx.hiddenFiles || []),
59
+ cardSizes: cardSizesObj
60
+ };
61
+
62
+ snapshots.unshift(snap);
63
+ localStorage.setItem(key, JSON.stringify(snapshots));
64
+ showToast(`Saved snapshot: ${name}`, 'success');
65
+ renderSnapshotsList();
66
+ }
67
+
68
+ export function loadSnapshot(ctx: CanvasContext, snapshotId: string) {
69
+ const key = getStorageKey(ctx);
70
+ const existingRaw = localStorage.getItem(key);
71
+ if (!existingRaw) return;
72
+
73
+ const snapshots: Snapshot[] = JSON.parse(existingRaw);
74
+ const snap = snapshots.find(s => s.id === snapshotId);
75
+ if (!snap) return;
76
+
77
+ if (snap.positions) {
78
+ ctx.positions = new Map(Object.entries(snap.positions));
79
+ // flush to trigger saving
80
+ flushPositions(ctx);
81
+ }
82
+
83
+ if (snap.hiddenFiles) {
84
+ ctx.hiddenFiles = new Set(snap.hiddenFiles);
85
+ }
86
+
87
+ if (snap.cardSizes && ctx.cardSizes) {
88
+ ctx.cardSizes = new Map(Object.entries(snap.cardSizes));
89
+ }
90
+
91
+ if (snap.zoom !== undefined) ctx.actor.send({ type: 'SET_ZOOM', zoom: snap.zoom });
92
+ if (snap.offsetX !== undefined) ctx.actor.send({ type: 'SET_OFFSET', x: snap.offsetX, y: snap.offsetY });
93
+
94
+ if (ctx.cardSizes && snap.cardSizes) {
95
+ for (const [path, size] of Object.entries(snap.cardSizes)) {
96
+ ctx.actor.send({ type: 'RESIZE_CARD', path, width: (size as any).width, height: (size as any).height });
97
+ }
98
+ }
99
+
100
+ // Notify hidden UI updater
101
+ const { updateHiddenUI } = require('./hidden-files');
102
+ if (updateHiddenUI) updateHiddenUI(ctx);
103
+
104
+ updateCanvasTransform(ctx);
105
+
106
+ showToast(`Restored snapshot: ${snap.name}`, 'success');
107
+ }
108
+
109
+ export function deleteSnapshot(ctx: CanvasContext, snapshotId: string) {
110
+ const key = getStorageKey(ctx);
111
+ const existingRaw = localStorage.getItem(key);
112
+ if (!existingRaw) return;
113
+
114
+ let snapshots: Snapshot[] = JSON.parse(existingRaw);
115
+ snapshots = snapshots.filter(s => s.id !== snapshotId);
116
+ localStorage.setItem(key, JSON.stringify(snapshots));
117
+ showToast('Snapshot deleted', 'info');
118
+ renderSnapshotsList();
119
+ }
120
+
121
+ function ensureOverlay(): HTMLElement {
122
+ if (_overlay) return _overlay;
123
+
124
+ _overlay = document.createElement('div');
125
+ _overlay.id = 'snapshotsOverlay';
126
+ _overlay.style.cssText = `
127
+ position: fixed;
128
+ inset: 0;
129
+ z-index: 9999;
130
+ background: rgba(10, 10, 16, 0.75);
131
+ backdrop-filter: blur(12px);
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: center;
135
+ font-family: 'Inter', sans-serif;
136
+ `;
137
+
138
+ _overlay.innerHTML = `
139
+ <div style="
140
+ background: rgba(18, 18, 28, 0.95);
141
+ border: 1px solid rgba(124, 58, 237, 0.3);
142
+ border-radius: 12px;
143
+ width: 480px;
144
+ max-width: 90vw;
145
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
146
+ display: flex;
147
+ flex-direction: column;
148
+ overflow: hidden;
149
+ ">
150
+ <div style="padding:16px 20px; border-bottom:1px solid rgba(255,255,255,0.08); display:flex; align-items:center; justify-content:space-between;">
151
+ <div style="display:flex;align-items:center;gap:10px;">
152
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent-primary);">
153
+ <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
154
+ <circle cx="12" cy="13" r="4" />
155
+ </svg>
156
+ <span style="font-size:15px; font-weight:600; color:#fff;">Layout Snapshots</span>
157
+ </div>
158
+ <button id="snapshotsClose" style="background:none; border:none; color:rgba(255,255,255,0.5); cursor:pointer; font-size:20px; line-height:1; transition:color 0.2s;">&times;</button>
159
+ </div>
160
+
161
+ <div style="padding:20px; display:flex; gap:10px;">
162
+ <input type="text" id="snapshotNameInput" placeholder="Name for new snapshot..." autocomplete="off" style="
163
+ flex:1; padding:10px 14px; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.1); border-radius:8px; color:#fff; font-size:14px; outline:none; transition:border-color 0.2s;
164
+ " onfocus="this.style.borderColor='var(--accent-primary)'" onblur="this.style.borderColor='rgba(255,255,255,0.1)'">
165
+ <button id="snapshotSaveBtn" style="
166
+ background: var(--accent-primary); border:none; color:#fff; border-radius:8px; padding:0 20px; font-size:13px; font-weight:600; cursor:pointer; transition:filter 0.2s;
167
+ " onmouseover="this.style.filter='brightness(1.1)'" onmouseout="this.style.filter='none'">Save Config</button>
168
+ </div>
169
+
170
+ <div id="snapshotsList" style="flex:1; overflow-y:auto; max-height:400px; padding:0 12px 12px; display:flex; flex-direction:column; gap:8px;"></div>
171
+ </div>
172
+ `;
173
+
174
+ document.body.appendChild(_overlay);
175
+
176
+ _overlay.addEventListener('mousedown', (e) => {
177
+ if (e.target === _overlay) closeSnapshots();
178
+ });
179
+
180
+ _overlay.querySelector('#snapshotsClose')?.addEventListener('click', closeSnapshots);
181
+
182
+ _overlay.querySelector('#snapshotSaveBtn')?.addEventListener('click', () => {
183
+ const input = _overlay!.querySelector('#snapshotNameInput') as HTMLInputElement;
184
+ const name = input.value;
185
+ if (name && _ctx) {
186
+ saveSnapshot(_ctx, name);
187
+ input.value = '';
188
+ }
189
+ });
190
+
191
+ _overlay.querySelector('#snapshotNameInput')?.addEventListener('keydown', (e: KeyboardEvent) => {
192
+ if (e.key === 'Enter') {
193
+ const input = e.target as HTMLInputElement;
194
+ const name = input.value;
195
+ if (name && _ctx) {
196
+ saveSnapshot(_ctx, name);
197
+ input.value = '';
198
+ }
199
+ }
200
+ });
201
+
202
+ return _overlay;
203
+ }
204
+
205
+ function renderSnapshotsList() {
206
+ if (!_overlay || !_ctx) return;
207
+ const list = _overlay.querySelector('#snapshotsList');
208
+ if (!list) return;
209
+
210
+ const key = getStorageKey(_ctx);
211
+ const raw = localStorage.getItem(key);
212
+ let snapshots: Snapshot[] = [];
213
+ if (raw) snapshots = JSON.parse(raw);
214
+
215
+ if (snapshots.length === 0) {
216
+ list.innerHTML = `
217
+ <div style="padding:40px 20px; text-align:center; color:rgba(255,255,255,0.3); font-size:13px;">
218
+ No snapshots saved yet. <br>Save your current layout to easily restore it later.
219
+ </div>
220
+ `;
221
+ return;
222
+ }
223
+
224
+ list.innerHTML = snapshots.map(s => {
225
+ const date = new Date(s.timestamp).toLocaleString(undefined, {
226
+ month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
227
+ });
228
+ const count = Object.keys(s.positions || {}).length;
229
+
230
+ return `
231
+ <div class="snapshot-item" data-id="${s.id}" style="
232
+ display:flex; align-items:center; justify-content:space-between;
233
+ padding:12px 16px; background:rgba(255,255,255,0.03);
234
+ border:1px solid rgba(255,255,255,0.05); border-radius:8px;
235
+ transition:background 0.2s, border-color 0.2s; cursor:pointer;
236
+ " onmouseover="this.style.background='rgba(255,255,255,0.08)';this.style.borderColor='rgba(124,58,237,0.3)'"
237
+ onmouseout="this.style.background='rgba(255,255,255,0.03)';this.style.borderColor='rgba(255,255,255,0.05)'">
238
+ <div style="display:flex; flex-direction:column; gap:4px; pointer-events:none;">
239
+ <span style="font-size:14px; font-weight:500; color:#e2e8f0;">${s.name}</span>
240
+ <span style="font-size:11px; color:rgba(255,255,255,0.4);">
241
+ ${date} &middot; ${count} saved positions
242
+ </span>
243
+ </div>
244
+ <div style="display:flex; gap:8px;" class="snapshot-actions">
245
+ <button class="snap-load-btn" data-id="${s.id}" style="
246
+ background:rgba(124,58,237,0.2); color:#c4b5fd; border:none;
247
+ padding:6px 12px; border-radius:6px; font-size:12px; font-weight:600;
248
+ cursor:pointer; transition:background 0.2s;
249
+ " onmouseover="this.style.background='var(--accent-primary)';this.style.color='#fff'"
250
+ onmouseout="this.style.background='rgba(124,58,237,0.2)';this.style.color='#c4b5fd'">
251
+ Load View
252
+ </button>
253
+ <button class="snap-del-btn" data-id="${s.id}" style="
254
+ background:rgba(239,68,68,0.1); color:#fca5a5; border:none;
255
+ width:28px; height:28px; display:flex; align-items:center; justify-content:center;
256
+ border-radius:6px; cursor:pointer; transition:background 0.2s;
257
+ " onmouseover="this.style.background='rgba(239,68,68,0.3)';this.style.color='#fff'"
258
+ onmouseout="this.style.background='rgba(239,68,68,0.1)';this.style.color='#fca5a5'" title="Delete Snapshot">
259
+ &times;
260
+ </button>
261
+ </div>
262
+ </div>
263
+ `;
264
+ }).join('');
265
+
266
+ list.querySelectorAll('.snap-load-btn').forEach(btn => {
267
+ btn.addEventListener('click', (e) => {
268
+ e.stopPropagation();
269
+ const id = (e.currentTarget as HTMLElement).getAttribute('data-id');
270
+ if (id && _ctx) {
271
+ loadSnapshot(_ctx, id);
272
+ closeSnapshots();
273
+ }
274
+ });
275
+ });
276
+
277
+ list.querySelectorAll('.snap-del-btn').forEach(btn => {
278
+ btn.addEventListener('click', (e) => {
279
+ e.stopPropagation();
280
+ const id = (e.currentTarget as HTMLElement).getAttribute('data-id');
281
+ if (id && _ctx) {
282
+ deleteSnapshot(_ctx, id);
283
+ }
284
+ });
285
+ });
286
+
287
+ // Also load on full row click
288
+ list.querySelectorAll('.snapshot-item').forEach(item => {
289
+ item.addEventListener('click', (e) => {
290
+ // Prevent if clicking on action buttons
291
+ if ((e.target as HTMLElement).closest('.snapshot-actions')) return;
292
+
293
+ const id = (e.currentTarget as HTMLElement).getAttribute('data-id');
294
+ if (id && _ctx) {
295
+ loadSnapshot(_ctx, id);
296
+ closeSnapshots();
297
+ }
298
+ });
299
+ });
300
+ }
301
+
302
+ export function openSnapshots(ctx: CanvasContext) {
303
+ _ctx = ctx;
304
+ const el = ensureOverlay();
305
+ el.style.display = 'flex';
306
+ renderSnapshotsList();
307
+ const input = el.querySelector('#snapshotNameInput') as HTMLInputElement;
308
+ if (input) requestAnimationFrame(() => input.focus());
309
+ }
310
+
311
+ export function closeSnapshots() {
312
+ if (_overlay) _overlay.style.display = 'none';
313
+ }
314
+
315
+ export function initLayoutSnapshots(ctx: CanvasContext) {
316
+ const btn = document.getElementById('openSnapshots');
317
+ if (btn) {
318
+ btn.addEventListener('click', () => openSnapshots(ctx));
319
+ }
320
+ }
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ hideLoadingProgress,
4
+ showLoadingProgress,
5
+ updateLoadingFileCount,
6
+ updateLoadingMessage,
7
+ updateLoadingProgress,
8
+ } from './loading';
9
+ import { setupDomTest } from './test-dom';
10
+
11
+ describe('repo loading overlay smoke', () => {
12
+ let cleanup: (() => void) | undefined;
13
+ let ctx: any;
14
+
15
+ beforeEach(() => {
16
+ const handle = setupDomTest({ url: 'http://localhost:3335/' });
17
+ cleanup = handle.cleanup;
18
+ ctx = { loadingOverlay: null };
19
+ });
20
+
21
+ afterEach(() => {
22
+ cleanup?.();
23
+ });
24
+
25
+ test('renders file-count progress for a streamed repo load', () => {
26
+ showLoadingProgress(ctx, 'Loading repository...', 0);
27
+ updateLoadingProgress(ctx, 'C:/Code/gitmaps', 10);
28
+ updateLoadingMessage(ctx, 'Loading files — 120 total');
29
+ updateLoadingFileCount(ctx, 45, 120, '45 loaded • 75 remaining');
30
+
31
+ const overlay = document.querySelector('.loading-overlay') as HTMLElement;
32
+ expect(overlay).toBeTruthy();
33
+ expect(overlay.classList.contains('active')).toBe(true);
34
+ expect(overlay.textContent).toContain('Loading files — 120 total');
35
+ expect(overlay.textContent).toContain('45 loaded • 75 remaining');
36
+ expect(overlay.textContent).toContain('120 total • 45 loaded • 75 remaining');
37
+ expect(overlay.textContent).toContain('Total120');
38
+ expect(overlay.textContent).toContain('Loaded45');
39
+ expect(overlay.textContent).toContain('Remaining75');
40
+ expect(overlay.textContent).toContain('38% • 75 remaining');
41
+
42
+ const fill = overlay.querySelector('.loading-progress-fill') as HTMLElement;
43
+ expect(fill.style.width).toBe('38%');
44
+ });
45
+
46
+ test('keeps indexed file counts visible into the commit-diff phase', () => {
47
+ showLoadingProgress(ctx, 'Loading repository...', 0);
48
+ updateLoadingFileCount(ctx, 120, 120, 'Rendering 120 cards • 0 remaining');
49
+ updateLoadingMessage(ctx, 'Loading commit diff — 120 files indexed');
50
+ updateLoadingFileCount(ctx, 120, 120, 'Comparing selected commit against 120 indexed files');
51
+
52
+ const overlay = document.querySelector('.loading-overlay') as HTMLElement;
53
+ expect(overlay.textContent).toContain('Loading commit diff — 120 files indexed');
54
+ expect(overlay.textContent).toContain('Comparing selected commit against 120 indexed files');
55
+ expect(overlay.textContent).toContain('120 total • 120 loaded • 0 remaining');
56
+ expect(overlay.textContent).toContain('100% • 0 remaining');
57
+ });
58
+
59
+ test('hides the overlay cleanly after loading completes', () => {
60
+ document.body.classList.add('repo-loading');
61
+ showLoadingProgress(ctx, 'Loading repository...', 0);
62
+
63
+ hideLoadingProgress(ctx);
64
+
65
+ const overlay = document.querySelector('.loading-overlay') as HTMLElement;
66
+ expect(overlay.classList.contains('active')).toBe(false);
67
+ expect(document.body.classList.contains('repo-loading')).toBe(false);
68
+ });
69
+ });