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.
- package/README.md +267 -118
- package/app/[...slug]/page.client.tsx +1 -0
- package/app/[...slug]/page.tsx +6 -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/og-image/route.ts +14 -0
- package/app/api/repo/file-content/route.ts +73 -20
- 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/version/route.ts +26 -0
- package/app/globals.css +5706 -4938
- package/app/layout.tsx +1279 -490
- 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.ts +625 -564
- package/app/lib/cards.tsx +1361 -916
- package/app/lib/chat.tsx +65 -9
- package/app/lib/code-editor.ts +86 -2
- 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 +71 -93
- package/app/lib/export-canvas.ts +287 -0
- package/app/lib/file-card-plugin.ts +148 -148
- package/app/lib/file-modal.tsx +49 -0
- package/app/lib/file-preview.ts +486 -427
- package/app/lib/github-import.test.ts +424 -0
- 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/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/positions.ts +190 -121
- package/app/lib/recent-commits.test.ts +947 -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 -987
- 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/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 -735
- 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 -482
- package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
- package/app/og-image.png +0 -0
- package/app/page.client.tsx +70 -269
- package/app/page.tsx +15 -16
- package/app/state/machine.js +13 -0
- package/package.json +84 -75
- package/server.ts +10 -0
- package/app/[owner]/[repo]/page.tsx +0 -6
- package/app/[slug]/page.tsx +0 -6
- 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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recent Commits — tracks and displays recently loaded repositories.
|
|
3
|
+
*
|
|
4
|
+
* Shows the last few repos in the sidebar and keeps legacy localStorage
|
|
5
|
+
* formats from older builds from breaking the UI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = "gitcanvas:recentRepos";
|
|
9
|
+
const MAX_REPOS = 5;
|
|
10
|
+
|
|
11
|
+
export interface RecentRepo {
|
|
12
|
+
path: string;
|
|
13
|
+
name: string;
|
|
14
|
+
loadedAt: number;
|
|
15
|
+
commitCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeRecentRepo(entry: any): RecentRepo | null {
|
|
19
|
+
if (typeof entry === "string") {
|
|
20
|
+
const trimmed = entry.trim();
|
|
21
|
+
if (!trimmed || trimmed.includes("[object")) return null;
|
|
22
|
+
return {
|
|
23
|
+
path: trimmed,
|
|
24
|
+
name: trimmed.split(/[\\/]/).pop() || trimmed,
|
|
25
|
+
loadedAt: 0,
|
|
26
|
+
commitCount: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (entry && typeof entry === "object" && typeof entry.path === "string") {
|
|
31
|
+
const path = entry.path.trim();
|
|
32
|
+
if (!path || path.includes("[object")) return null;
|
|
33
|
+
return {
|
|
34
|
+
path,
|
|
35
|
+
name: typeof entry.name === "string" && entry.name.trim()
|
|
36
|
+
? entry.name.trim()
|
|
37
|
+
: path.split(/[\\/]/).pop() || path,
|
|
38
|
+
loadedAt: Number.isFinite(entry.loadedAt) ? entry.loadedAt : 0,
|
|
39
|
+
commitCount: Number.isFinite(entry.commitCount) ? entry.commitCount : 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dedupeRecentRepos(entries: RecentRepo[]): RecentRepo[] {
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
const result: RecentRepo[] = [];
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (seen.has(entry.path)) continue;
|
|
52
|
+
seen.add(entry.path);
|
|
53
|
+
result.push(entry);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result.slice(0, MAX_REPOS);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function persistRecentRepos(repos: RecentRepo[]): void {
|
|
60
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(dedupeRecentRepos(repos)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getRecentRepos(): RecentRepo[] {
|
|
64
|
+
try {
|
|
65
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
66
|
+
if (!raw) return [];
|
|
67
|
+
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!Array.isArray(parsed)) return [];
|
|
70
|
+
|
|
71
|
+
const normalized = parsed
|
|
72
|
+
.map(normalizeRecentRepo)
|
|
73
|
+
.filter(Boolean) as RecentRepo[];
|
|
74
|
+
|
|
75
|
+
const deduped = dedupeRecentRepos(normalized);
|
|
76
|
+
|
|
77
|
+
// Self-heal old / malformed localStorage so future renders stay clean.
|
|
78
|
+
if (JSON.stringify(parsed) !== JSON.stringify(deduped)) {
|
|
79
|
+
persistRecentRepos(deduped);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return deduped;
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function addRecentRepo(path: string, commitCount: number = 0): void {
|
|
89
|
+
const trimmedPath = path?.trim();
|
|
90
|
+
if (!trimmedPath) return;
|
|
91
|
+
|
|
92
|
+
const repos = getRecentRepos().filter((r) => r.path !== trimmedPath);
|
|
93
|
+
const name = trimmedPath.split(/[\\/]/).pop() || trimmedPath;
|
|
94
|
+
|
|
95
|
+
repos.unshift({
|
|
96
|
+
path: trimmedPath,
|
|
97
|
+
name,
|
|
98
|
+
loadedAt: Date.now(),
|
|
99
|
+
commitCount: Number.isFinite(commitCount) ? commitCount : 0,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
persistRecentRepos(repos);
|
|
103
|
+
renderRecentCommitsUI();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function removeRecentRepo(path: string): void {
|
|
107
|
+
const repos = getRecentRepos().filter((r) => r.path !== path);
|
|
108
|
+
persistRecentRepos(repos);
|
|
109
|
+
renderRecentCommitsUI();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderRecentReposList(): void {
|
|
113
|
+
const section = document.getElementById("recentCommits");
|
|
114
|
+
const list = document.getElementById("recentCommitsList");
|
|
115
|
+
if (!list) return;
|
|
116
|
+
|
|
117
|
+
const repos = getRecentRepos();
|
|
118
|
+
|
|
119
|
+
if (section) {
|
|
120
|
+
section.style.display = repos.length ? "block" : "none";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!repos.length) {
|
|
124
|
+
list.innerHTML =
|
|
125
|
+
'<div style="font-size:11px;color:var(--text-muted);padding:8px 0">No recent commits</div>';
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
list.innerHTML = repos
|
|
130
|
+
.map(
|
|
131
|
+
(repo) => `
|
|
132
|
+
<button
|
|
133
|
+
class="recent-commit-item"
|
|
134
|
+
data-path="${escapeHtml(repo.path)}"
|
|
135
|
+
title="${escapeHtml(repo.path)}"
|
|
136
|
+
style="display:block;width:100%;text-align:left;background:none;border:none;color:inherit;padding:8px 0;cursor:pointer"
|
|
137
|
+
>
|
|
138
|
+
<div style="font-size:12px;font-weight:600;color:var(--text-primary)">${escapeHtml(repo.name)}</div>
|
|
139
|
+
<div style="font-size:11px;color:var(--text-muted)">${repo.commitCount} commit${repo.commitCount === 1 ? "" : "s"} · ${formatTimeAgo(repo.loadedAt)}</div>
|
|
140
|
+
</button>
|
|
141
|
+
`,
|
|
142
|
+
)
|
|
143
|
+
.join("");
|
|
144
|
+
|
|
145
|
+
Array.from(list.querySelectorAll("[data-path]")).forEach((item) => {
|
|
146
|
+
item.addEventListener("click", async () => {
|
|
147
|
+
const path = item.dataset.path;
|
|
148
|
+
if (!path) return;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const { getCanvasContext } = require("./context");
|
|
152
|
+
const { handoffRepoLoad } = require("./repo-handoff");
|
|
153
|
+
const ctx = getCanvasContext();
|
|
154
|
+
if (!ctx) return;
|
|
155
|
+
await handoffRepoLoad(ctx, path);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error("Failed to load recent repo", err);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function renderRecentCommitsUI(): void {
|
|
164
|
+
renderRecentReposList();
|
|
165
|
+
|
|
166
|
+
const pullBtn = document.getElementById("pullBtn") as HTMLButtonElement | null;
|
|
167
|
+
if (!pullBtn || pullBtn.dataset.bound === "true") return;
|
|
168
|
+
pullBtn.dataset.bound = "true";
|
|
169
|
+
|
|
170
|
+
pullBtn.addEventListener("click", async () => {
|
|
171
|
+
const { showToast } = require("./utils");
|
|
172
|
+
const { getCanvasContext } = require("./context");
|
|
173
|
+
const ctx = getCanvasContext();
|
|
174
|
+
|
|
175
|
+
if (!ctx || !ctx.snap().context.repoPath) {
|
|
176
|
+
showToast("No repository loaded", "error");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pullBtn.disabled = true;
|
|
181
|
+
pullBtn.innerHTML = `
|
|
182
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
183
|
+
<circle cx="12" cy="12" r="10" strokeDasharray="30" strokeDashoffset="0">
|
|
184
|
+
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
|
|
185
|
+
</circle>
|
|
186
|
+
</svg>
|
|
187
|
+
Pulling...
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const { loadRepository } = require("./repo");
|
|
192
|
+
await loadRepository(ctx, ctx.snap().context.repoPath);
|
|
193
|
+
showToast("Pulled latest commits", "success");
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
showToast(`Pull failed: ${err.message}`, "error");
|
|
196
|
+
} finally {
|
|
197
|
+
pullBtn.disabled = false;
|
|
198
|
+
pullBtn.innerHTML = `
|
|
199
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
200
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
201
|
+
<polyline points="7 10 12 15 17 10" />
|
|
202
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
203
|
+
</svg>
|
|
204
|
+
Pull
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapeHtml(str: string): string {
|
|
211
|
+
if (!str) return "";
|
|
212
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function formatTimeAgo(timestamp: number): string {
|
|
216
|
+
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) return "Just now";
|
|
217
|
+
|
|
218
|
+
const diff = Math.max(0, Date.now() - timestamp);
|
|
219
|
+
const minutes = Math.floor(diff / 60000);
|
|
220
|
+
const hours = Math.floor(diff / 3600000);
|
|
221
|
+
const days = Math.floor(diff / 86400000);
|
|
222
|
+
|
|
223
|
+
if (minutes < 1) return "Just now";
|
|
224
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
225
|
+
if (hours < 24) return `${hours}h ago`;
|
|
226
|
+
return `${days}d ago`;
|
|
227
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { handoffRepoLoad } from './repo-handoff';
|
|
3
|
+
import { setupDomTest } from './test-dom';
|
|
4
|
+
|
|
5
|
+
describe('repo handoff helper', () => {
|
|
6
|
+
test('uses onRepoReady seam and syncs repo selection without loading immediately', () => {
|
|
7
|
+
const handle = setupDomTest({
|
|
8
|
+
html: '<select id="repoSelect"><option value="">Select</option><option value="C:/Code/gitmaps">gitmaps</option></select>',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const onRepoReady = mock(() => undefined);
|
|
13
|
+
const ctx = { onRepoReady } as any;
|
|
14
|
+
|
|
15
|
+
handoffRepoLoad(ctx, 'C:/Code/gitmaps');
|
|
16
|
+
|
|
17
|
+
expect((document.getElementById('repoSelect') as HTMLSelectElement).value).toBe('C:/Code/gitmaps');
|
|
18
|
+
expect(onRepoReady).toHaveBeenCalledWith('C:/Code/gitmaps');
|
|
19
|
+
} finally {
|
|
20
|
+
handle.cleanup();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CanvasContext } from './context';
|
|
2
|
+
import { loadRepository } from './repo';
|
|
3
|
+
|
|
4
|
+
export function syncRepoSelection(path: string) {
|
|
5
|
+
const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement | null;
|
|
6
|
+
if (repoSelect) repoSelect.value = path;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function handoffRepoLoad(ctx: CanvasContext, path: string) {
|
|
10
|
+
syncRepoSelection(path);
|
|
11
|
+
if ((ctx as any)?.onRepoReady) {
|
|
12
|
+
(ctx as any).onRepoReady(path);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
loadRepository(ctx, path);
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive File Loading — Optimized rendering for large repos (500+ files)
|
|
3
|
+
*
|
|
4
|
+
* Loads only visible cards initially, defers off-screen cards,
|
|
5
|
+
* and progressively loads as user pans/zooms.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CanvasContext } from './context';
|
|
9
|
+
import { measure } from 'measure-fn';
|
|
10
|
+
import { createAllFileCard } from './cards';
|
|
11
|
+
import { getPositionKey, setPathExpandedInPositions } from './positions';
|
|
12
|
+
|
|
13
|
+
const LARGE_REPO_THRESHOLD = 500;
|
|
14
|
+
const PROGRESSIVE_BATCH_SIZE = 100;
|
|
15
|
+
const LOAD_RADIUS = 2.0; // viewport widths
|
|
16
|
+
|
|
17
|
+
export function isLargeRepo(fileCount: number): boolean {
|
|
18
|
+
return fileCount >= LARGE_REPO_THRESHOLD;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldDeferCard(
|
|
22
|
+
cardX: number,
|
|
23
|
+
cardY: number,
|
|
24
|
+
viewportCenterX: number,
|
|
25
|
+
viewportCenterY: number,
|
|
26
|
+
viewportWidth: number,
|
|
27
|
+
viewportHeight: number
|
|
28
|
+
): boolean {
|
|
29
|
+
const dx = Math.abs(cardX - viewportCenterX);
|
|
30
|
+
const dy = Math.abs(cardY - viewportCenterY);
|
|
31
|
+
const maxDistX = viewportWidth * LOAD_RADIUS;
|
|
32
|
+
const maxDistY = viewportHeight * LOAD_RADIUS;
|
|
33
|
+
|
|
34
|
+
return dx > maxDistX || dy > maxDistY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function renderAllFilesProgressive(
|
|
38
|
+
ctx: CanvasContext,
|
|
39
|
+
files: any[]
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
return measure('canvas:renderProgressive', async () => {
|
|
42
|
+
const isLarge = isLargeRepo(files.length);
|
|
43
|
+
console.log(`[progressive] Loading ${files.length} files (large: ${isLarge})`);
|
|
44
|
+
|
|
45
|
+
// Get viewport info
|
|
46
|
+
const vpEl = ctx.canvasViewport;
|
|
47
|
+
const vpW = vpEl?.clientWidth || window.innerWidth;
|
|
48
|
+
const vpH = vpEl?.clientHeight || window.innerHeight;
|
|
49
|
+
const state = ctx.snap().context;
|
|
50
|
+
const zoom = state.zoom || 1;
|
|
51
|
+
const offsetX = state.offsetX || 0;
|
|
52
|
+
const offsetY = state.offsetY || 0;
|
|
53
|
+
|
|
54
|
+
const viewportCenterX = (-offsetX + vpW / 2) / zoom;
|
|
55
|
+
const viewportCenterY = (-offsetY + vpH / 2) / zoom;
|
|
56
|
+
|
|
57
|
+
// Separate visible and deferred files
|
|
58
|
+
const visibleFiles: any[] = [];
|
|
59
|
+
const deferredFiles: any[] = [];
|
|
60
|
+
|
|
61
|
+
files.forEach((file) => {
|
|
62
|
+
const posKey = getPositionKey('allfiles', file.path);
|
|
63
|
+
const pos = ctx.positions.get(posKey);
|
|
64
|
+
const x = pos?.x || 50;
|
|
65
|
+
const y = pos?.y || 50;
|
|
66
|
+
|
|
67
|
+
if (isLarge && shouldDeferCard(x, y, viewportCenterX, viewportCenterY, vpW, vpH)) {
|
|
68
|
+
deferredFiles.push(file);
|
|
69
|
+
ctx.deferredCards.set(file.path, { x, y, file });
|
|
70
|
+
} else {
|
|
71
|
+
visibleFiles.push(file);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
console.log(`[progressive] Visible: ${visibleFiles.length}, Deferred: ${deferredFiles.length}`);
|
|
76
|
+
|
|
77
|
+
// Load visible files immediately
|
|
78
|
+
await loadFileBatch(ctx, visibleFiles);
|
|
79
|
+
|
|
80
|
+
// Progressive loading for deferred files
|
|
81
|
+
if (deferredFiles.length > 0) {
|
|
82
|
+
// Load in batches on animation frames
|
|
83
|
+
let batchIndex = 0;
|
|
84
|
+
const loadNextBatch = () => {
|
|
85
|
+
const start = batchIndex * PROGRESSIVE_BATCH_SIZE;
|
|
86
|
+
const end = Math.min(start + PROGRESSIVE_BATCH_SIZE, deferredFiles.length);
|
|
87
|
+
const batch = deferredFiles.slice(start, end);
|
|
88
|
+
|
|
89
|
+
console.log(`[progressive] Loading batch ${batchIndex + 1} (${batch.length} files)`);
|
|
90
|
+
loadFileBatch(ctx, batch);
|
|
91
|
+
|
|
92
|
+
batchIndex++;
|
|
93
|
+
if (batchIndex * PROGRESSIVE_BATCH_SIZE < deferredFiles.length) {
|
|
94
|
+
requestAnimationFrame(loadNextBatch);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Start loading after a short delay
|
|
99
|
+
setTimeout(() => loadNextBatch(), 500);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function loadFileBatch(ctx: CanvasContext, files: any[]): Promise<void> {
|
|
105
|
+
const { createAllFileCard } = require('./cards');
|
|
106
|
+
const { performViewportCulling } = require('./viewport-culling');
|
|
107
|
+
|
|
108
|
+
files.forEach((file) => {
|
|
109
|
+
const card = createAllFileCard(ctx, file);
|
|
110
|
+
if (card) {
|
|
111
|
+
ctx.canvasContent.appendChild(card);
|
|
112
|
+
ctx.fileCards.set(file.path, card);
|
|
113
|
+
ctx.deferredCards.delete(file.path);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Re-run culling after loading batch
|
|
118
|
+
performViewportCulling(ctx);
|
|
119
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { appendDiscoveredRepos, populateRepoSelect } from './repo-select';
|
|
3
|
+
import { setupDomTest } from './test-dom';
|
|
4
|
+
|
|
5
|
+
describe('repo select smoke', () => {
|
|
6
|
+
let cleanup: (() => void) | undefined;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const handle = setupDomTest({
|
|
10
|
+
url: 'http://localhost:3335/',
|
|
11
|
+
html: '<select id="repoSelect"><option value="">Select a repository...</option></select>',
|
|
12
|
+
});
|
|
13
|
+
cleanup = handle.cleanup;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
cleanup?.();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('populates recent repos and preserves placeholder/new option flow', () => {
|
|
21
|
+
const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
22
|
+
|
|
23
|
+
populateRepoSelect(repoSelect, [
|
|
24
|
+
{ path: 'C:/Code/gitmaps', name: 'gitmaps' },
|
|
25
|
+
{ path: 'C:/Code/geeksy', name: 'geeksy' },
|
|
26
|
+
] as any, { hashPath: '' });
|
|
27
|
+
|
|
28
|
+
const options = Array.from(repoSelect.options).map((opt) => ({ value: opt.value, text: opt.textContent }));
|
|
29
|
+
expect(options).toEqual([
|
|
30
|
+
{ value: '', text: 'Select a repository...' },
|
|
31
|
+
{ value: 'C:/Code/gitmaps', text: 'gitmaps' },
|
|
32
|
+
{ value: 'C:/Code/geeksy', text: 'geeksy' },
|
|
33
|
+
{ value: '__new__', text: '+ Open new repo...' },
|
|
34
|
+
]);
|
|
35
|
+
expect(repoSelect.value).toBe('');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('appends discovered repos before the open-new option without duplicating known repos', () => {
|
|
39
|
+
const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
40
|
+
const added: string[] = [];
|
|
41
|
+
|
|
42
|
+
const recentRepos = [
|
|
43
|
+
{ path: 'C:/Code/gitmaps', name: 'gitmaps' },
|
|
44
|
+
];
|
|
45
|
+
populateRepoSelect(repoSelect, recentRepos as any, { hashPath: '' });
|
|
46
|
+
|
|
47
|
+
appendDiscoveredRepos(
|
|
48
|
+
repoSelect,
|
|
49
|
+
recentRepos as any,
|
|
50
|
+
[
|
|
51
|
+
{ path: 'C:/Code/gitmaps', name: 'gitmaps' },
|
|
52
|
+
{ path: 'C:/Code/new-repo', name: 'new-repo' },
|
|
53
|
+
],
|
|
54
|
+
(repoPath) => added.push(repoPath),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const values = Array.from(repoSelect.options).map((opt) => opt.value);
|
|
58
|
+
expect(values).toEqual(['', 'C:/Code/gitmaps', 'C:/Code/new-repo', '__new__']);
|
|
59
|
+
expect(added).toEqual(['C:/Code/new-repo']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getRecentRepos, type RecentRepo } from './recent-commits';
|
|
2
|
+
|
|
3
|
+
export interface RepoSelectItem {
|
|
4
|
+
path: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeRepoPath(repoPath: string): string {
|
|
9
|
+
return (repoPath || '').trim().replace(/\\/g, '/');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getShortRepoName(repoPath: string): string {
|
|
13
|
+
return normalizeRepoPath(repoPath).split('/').filter(Boolean).pop() || repoPath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function populateRepoSelect(
|
|
17
|
+
repoSelect: HTMLSelectElement,
|
|
18
|
+
recentRepos: Array<RecentRepo | RepoSelectItem | string> = getRecentRepos(),
|
|
19
|
+
options: { hashPath?: string } = {},
|
|
20
|
+
) {
|
|
21
|
+
while (repoSelect.options.length > 1) repoSelect.remove(1);
|
|
22
|
+
|
|
23
|
+
for (const repo of recentRepos) {
|
|
24
|
+
const rawRepoPath = typeof repo === 'string' ? repo : repo.path || '';
|
|
25
|
+
const repoPath = normalizeRepoPath(rawRepoPath);
|
|
26
|
+
if (!repoPath) continue;
|
|
27
|
+
|
|
28
|
+
const opt = document.createElement('option');
|
|
29
|
+
opt.value = repoPath;
|
|
30
|
+
opt.textContent = typeof repo === 'string'
|
|
31
|
+
? getShortRepoName(repoPath)
|
|
32
|
+
: repo.name || getShortRepoName(repoPath);
|
|
33
|
+
opt.title = rawRepoPath;
|
|
34
|
+
repoSelect.add(opt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const newOpt = document.createElement('option');
|
|
38
|
+
newOpt.value = '__new__';
|
|
39
|
+
newOpt.textContent = '+ Open new repo...';
|
|
40
|
+
newOpt.id = 'optNewLocal';
|
|
41
|
+
repoSelect.add(newOpt);
|
|
42
|
+
|
|
43
|
+
const hashPath = normalizeRepoPath(options.hashPath ?? decodeURIComponent(location.hash.slice(1)));
|
|
44
|
+
const knownPaths = recentRepos.map((repo) => normalizeRepoPath(typeof repo === 'string' ? repo : repo.path || ''));
|
|
45
|
+
if (hashPath && knownPaths.includes(hashPath)) {
|
|
46
|
+
repoSelect.value = hashPath;
|
|
47
|
+
} else if (!hashPath) {
|
|
48
|
+
repoSelect.value = '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function appendDiscoveredRepos(
|
|
53
|
+
repoSelect: HTMLSelectElement,
|
|
54
|
+
recentRepos: Array<RecentRepo | RepoSelectItem | string>,
|
|
55
|
+
discoveredRepos: RepoSelectItem[],
|
|
56
|
+
onNewRepo?: (repoPath: string) => void,
|
|
57
|
+
) {
|
|
58
|
+
const currentPaths = new Set(recentRepos.map((repo) => normalizeRepoPath(typeof repo === 'string' ? repo : repo.path || '')));
|
|
59
|
+
|
|
60
|
+
for (const repo of discoveredRepos) {
|
|
61
|
+
const repoPath = normalizeRepoPath(repo.path);
|
|
62
|
+
if (!repoPath || currentPaths.has(repoPath)) continue;
|
|
63
|
+
onNewRepo?.(repoPath);
|
|
64
|
+
|
|
65
|
+
const opt = document.createElement('option');
|
|
66
|
+
opt.value = repoPath;
|
|
67
|
+
opt.textContent = repo.name || getShortRepoName(repoPath);
|
|
68
|
+
opt.title = repo.path;
|
|
69
|
+
|
|
70
|
+
const newOpt = repoSelect.querySelector('option[value="__new__"]');
|
|
71
|
+
if (newOpt) repoSelect.insertBefore(opt, newOpt);
|
|
72
|
+
else repoSelect.add(opt);
|
|
73
|
+
}
|
|
74
|
+
}
|