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
@@ -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
+ }
@@ -19,7 +19,7 @@ export const layerState = {
19
19
  activeLayerId: 'default' as string
20
20
  };
21
21
 
22
- const DEFAULT_LAYER: LayerData = { id: 'default', name: 'All Files (Default)', files: {} };
22
+ const DEFAULT_LAYER: LayerData = { id: 'default', name: 'Main', files: {} };
23
23
 
24
24
  export function initLayers(ctx: CanvasContext) {
25
25
  // Load from local storage for now or maybe an API? Let's use localStorage to persist across commits.
@@ -170,7 +170,7 @@ export function setActiveLayer(ctx: CanvasContext, id: string) {
170
170
  renderLayersUI(ctx);
171
171
  applyLayer(ctx);
172
172
 
173
- // User feedback
173
+ // User feedback — only show hint for empty layers
174
174
  const layer = layerState.layers.find(l => l.id === id);
175
175
  if (layer && id !== 'default') {
176
176
  const fileCount = Object.keys(layer.files).length;
@@ -179,14 +179,7 @@ export function setActiveLayer(ctx: CanvasContext, id: string) {
179
179
  `Layer "${layer.name}" is empty — right-click cards to move them here`,
180
180
  'info'
181
181
  ));
182
- } else {
183
- import('./utils').then(m => m.showToast(
184
- `Switched to "${layer.name}" (${fileCount} files)`,
185
- 'info'
186
- ));
187
182
  }
188
- } else if (id === 'default') {
189
- import('./utils').then(m => m.showToast('Switched to All Files', 'info'));
190
183
  }
191
184
  }
192
185
 
@@ -238,7 +231,7 @@ export function applyLayer(ctx: CanvasContext) {
238
231
  renderAllFilesOnCanvas(ctx, ctx.allFilesData);
239
232
  // Also repopulate the changed files panel with the new layer filter
240
233
  if (ctx.commitFilesData) {
241
- populateChangedFilesPanel(ctx.commitFilesData);
234
+ populateChangedFilesPanel(ctx, ctx.commitFilesData);
242
235
  }
243
236
  } else if (commitHash && commitHash !== 'allfiles') {
244
237
  selectCommit(ctx, commitHash, true);
@@ -296,6 +289,10 @@ export function renderLayersUI(ctx: CanvasContext) {
296
289
  className="layers-bar-add"
297
290
  id="newLayerBtn"
298
291
  title="Create a new Layer"
292
+ onClick={() => {
293
+ const name = prompt('Enter a name for the new layer:');
294
+ if (name) createLayer(ctx, name);
295
+ }}
299
296
  >
300
297
  + New Layer
301
298
  </button>
@@ -303,14 +300,16 @@ export function renderLayersUI(ctx: CanvasContext) {
303
300
  container
304
301
  );
305
302
 
306
- // Attach click handler via DOM (Melina onClick doesn't reliably bind here)
307
- const btn = document.getElementById('newLayerBtn');
308
- if (btn) {
309
- btn.onclick = () => {
310
- const name = prompt('Enter a name for the new layer:');
311
- if (name) createLayer(ctx, name);
312
- };
313
- }
303
+ // Belt-and-suspenders: also attach via DOM in case Melina onClick doesn't fire
304
+ requestAnimationFrame(() => {
305
+ const btn = document.getElementById('newLayerBtn');
306
+ if (btn) {
307
+ btn.onclick = () => {
308
+ const name = prompt('Enter a name for the new layer:');
309
+ if (name) createLayer(ctx, name);
310
+ };
311
+ }
312
+ });
314
313
  }
315
314
 
316
315
  // UI to configure section extraction
@@ -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
+ });