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.
- package/README.md +265 -122
- package/app/[...slug]/page.client.tsx +1 -0
- package/app/[...slug]/page.tsx +6 -0
- package/app/[owner]/[repo]/page.client.tsx +5 -0
- package/app/[slug]/page.client.tsx +5 -0
- package/app/analytics.db +0 -0
- package/app/api/analytics/route.ts +64 -0
- package/app/api/auth/positions/route.ts +95 -33
- package/app/api/build-info/route.ts +19 -0
- package/app/api/chat/route.ts +13 -2
- package/app/api/manifest.json/route.ts +20 -0
- package/app/api/og-image/route.ts +14 -0
- package/app/api/pwa-icon/route.ts +14 -0
- package/app/api/repo/clone-stream/route.ts +20 -12
- package/app/api/repo/file-content/route.ts +73 -20
- package/app/api/repo/imports/route.ts +21 -3
- package/app/api/repo/list/route.ts +30 -0
- package/app/api/repo/load/route.test.ts +62 -0
- package/app/api/repo/load/route.ts +41 -1
- package/app/api/repo/pdf-thumb/route.ts +127 -0
- package/app/api/repo/resolve-slug/route.ts +51 -0
- package/app/api/repo/tree/route.ts +188 -104
- package/app/api/repo/upload/route.ts +6 -9
- package/app/api/sw.js/route.ts +70 -0
- package/app/api/version/route.ts +26 -0
- package/app/galaxy-canvas/page.client.tsx +2 -0
- package/app/galaxy-canvas/page.tsx +5 -0
- package/app/globals.css +5844 -4694
- package/app/icon.png +0 -0
- package/app/layout.tsx +1284 -467
- package/app/lib/auto-arrange.test.ts +158 -0
- package/app/lib/auto-arrange.ts +147 -0
- package/app/lib/canvas-export.ts +358 -358
- package/app/lib/canvas-text.ts +4 -72
- package/app/lib/canvas.ts +625 -564
- package/app/lib/card-arrangement.ts +21 -7
- package/app/lib/card-context-menu.tsx +2 -2
- package/app/lib/card-groups.ts +9 -2
- package/app/lib/cards.tsx +1361 -914
- package/app/lib/chat.tsx +65 -9
- package/app/lib/code-editor.ts +86 -2
- package/app/lib/connections.tsx +34 -43
- package/app/lib/context.test.ts +32 -0
- package/app/lib/context.ts +19 -3
- package/app/lib/cursor-sharing.ts +34 -0
- package/app/lib/events.tsx +76 -73
- package/app/lib/export-canvas.ts +287 -0
- package/app/lib/file-card-plugin.ts +148 -134
- package/app/lib/file-modal.tsx +49 -0
- package/app/lib/file-preview.ts +486 -400
- package/app/lib/github-import.test.ts +424 -0
- package/app/lib/global-search.ts +48 -27
- package/app/lib/initial-route-hydration.test.ts +283 -0
- package/app/lib/initial-route-hydration.ts +202 -0
- package/app/lib/landing-reset.test.ts +99 -0
- package/app/lib/landing-reset.ts +106 -0
- package/app/lib/landing-shell.test.ts +75 -0
- package/app/lib/large-repo-optimization.ts +37 -0
- package/app/lib/layers.tsx +17 -18
- package/app/lib/layout-snapshots.ts +320 -0
- package/app/lib/loading.test.ts +69 -0
- package/app/lib/loading.tsx +160 -45
- package/app/lib/mount-cleanup.test.ts +52 -0
- package/app/lib/mount-cleanup.ts +34 -0
- package/app/lib/mount-init.test.ts +123 -0
- package/app/lib/mount-init.ts +107 -0
- package/app/lib/mount-lifecycle.test.ts +39 -0
- package/app/lib/mount-lifecycle.ts +12 -0
- package/app/lib/mount-route-wiring.test.ts +87 -0
- package/app/lib/mount-route-wiring.ts +84 -0
- package/app/lib/multi-repo.ts +14 -0
- package/app/lib/onboarding-tutorial.ts +278 -0
- package/app/lib/perf-overlay.ts +78 -0
- package/app/lib/positions.ts +191 -122
- package/app/lib/recent-commits.test.ts +869 -0
- package/app/lib/recent-commits.ts +227 -0
- package/app/lib/repo-handoff.test.ts +23 -0
- package/app/lib/repo-handoff.ts +16 -0
- package/app/lib/repo-progressive.ts +119 -0
- package/app/lib/repo-select.test.ts +61 -0
- package/app/lib/repo-select.ts +74 -0
- package/app/lib/repo.tsx +1383 -977
- package/app/lib/role.ts +228 -0
- package/app/lib/route-catchall.test.ts +27 -0
- package/app/lib/route-repo-entry.test.ts +95 -0
- package/app/lib/route-repo-entry.ts +36 -0
- package/app/lib/router-contract.test.ts +22 -0
- package/app/lib/router-contract.ts +19 -0
- package/app/lib/shared-layout.test.ts +86 -0
- package/app/lib/shared-layout.ts +82 -0
- package/app/lib/shortcuts-panel.ts +2 -0
- package/app/lib/status-bar.test.ts +118 -0
- package/app/lib/status-bar.ts +365 -128
- package/app/lib/sync-controls.test.ts +43 -0
- package/app/lib/sync-controls.tsx +303 -0
- package/app/lib/test-dom.ts +145 -0
- package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
- package/app/lib/transclusion-smoke.test.ts +163 -0
- package/app/lib/tutorial.ts +301 -0
- package/app/lib/version.ts +93 -0
- package/app/lib/viewport-culling.ts +740 -728
- package/app/lib/virtual-files.ts +456 -0
- package/app/lib/webgl-text.ts +189 -0
- package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
- package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
- package/app/og-image.png +0 -0
- package/app/page.client.tsx +70 -215
- package/app/page.tsx +27 -92
- package/app/state/machine.js +13 -0
- package/banner.png +0 -0
- package/package.json +17 -8
- package/server.ts +11 -1
- package/app/api/connections/route.ts +0 -72
- package/app/api/positions/route.ts +0 -80
- package/app/api/repo/browse/route.ts +0 -55
- package/app/lib/pr-review.ts +0 -374
- package/packages/galaxydraw/README.md +0 -296
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +0 -100
- package/packages/galaxydraw/demo/client.ts +0 -154
- package/packages/galaxydraw/demo/dist/client.js +0 -8
- package/packages/galaxydraw/demo/index.html +0 -256
- package/packages/galaxydraw/demo/server.ts +0 -96
- package/packages/galaxydraw/dist/index.js +0 -984
- package/packages/galaxydraw/dist/index.js.map +0 -16
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +0 -49
- package/packages/galaxydraw/perf.test.ts +0 -284
- package/packages/galaxydraw/src/core/cards.ts +0 -435
- package/packages/galaxydraw/src/core/engine.ts +0 -339
- package/packages/galaxydraw/src/core/events.ts +0 -81
- package/packages/galaxydraw/src/core/layout.ts +0 -136
- package/packages/galaxydraw/src/core/minimap.ts +0 -216
- package/packages/galaxydraw/src/core/state.ts +0 -177
- package/packages/galaxydraw/src/core/viewport.ts +0 -106
- package/packages/galaxydraw/src/galaxydraw.css +0 -166
- package/packages/galaxydraw/src/index.ts +0 -40
- 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
|
+
}
|
package/app/lib/layers.tsx
CHANGED
|
@@ -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: '
|
|
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
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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;">×</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} · ${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
|
+
×
|
|
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
|
+
});
|