gitmaps 1.0.0 → 1.1.0
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 +5 -11
- package/app/[owner]/[repo]/page.client.tsx +5 -0
- package/app/[owner]/[repo]/page.tsx +6 -0
- package/app/[slug]/page.client.tsx +5 -0
- package/app/[slug]/page.tsx +6 -0
- package/app/api/manifest.json/route.ts +20 -0
- package/app/api/pwa-icon/route.ts +14 -0
- package/app/api/repo/clone-stream/route.ts +20 -12
- package/app/api/repo/imports/route.ts +21 -3
- package/app/api/repo/list/route.ts +30 -0
- package/app/api/repo/upload/route.ts +6 -9
- package/app/api/sw.js/route.ts +70 -0
- package/app/galaxy-canvas/page.client.tsx +2 -0
- package/app/galaxy-canvas/page.tsx +5 -0
- package/app/globals.css +477 -95
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -2
- package/app/lib/canvas-text.ts +4 -72
- package/app/lib/canvas.ts +1 -1
- 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 +3 -1
- package/app/lib/connections.tsx +34 -43
- package/app/lib/events.tsx +25 -0
- package/app/lib/file-card-plugin.ts +14 -0
- package/app/lib/file-preview.ts +68 -41
- package/app/lib/galaxydraw-bridge.ts +5 -0
- package/app/lib/global-search.ts +48 -27
- package/app/lib/layers.tsx +17 -18
- package/app/lib/perf-overlay.ts +78 -0
- package/app/lib/positions.ts +1 -1
- package/app/lib/repo.tsx +18 -8
- package/app/lib/shortcuts-panel.ts +2 -0
- package/app/lib/viewport-culling.ts +7 -0
- package/app/page.client.tsx +72 -18
- package/app/page.tsx +22 -86
- package/banner.png +0 -0
- package/package.json +2 -2
- package/packages/galaxydraw/README.md +2 -2
- package/packages/galaxydraw/package.json +1 -1
- package/server.ts +1 -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/app/lib/global-search.ts
CHANGED
|
@@ -15,14 +15,27 @@ let _ctx: CanvasContext | null = null;
|
|
|
15
15
|
/** Toggle the search panel */
|
|
16
16
|
export function toggleGlobalSearch(ctx: CanvasContext) {
|
|
17
17
|
_ctx = ctx;
|
|
18
|
-
|
|
18
|
+
// Panel exists and is visible → close it
|
|
19
|
+
if (_panel && _panel.style.display !== 'none') {
|
|
19
20
|
closeSearch();
|
|
20
21
|
} else {
|
|
22
|
+
// Panel doesn't exist or is hidden → open/restore it
|
|
21
23
|
openSearch();
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function openSearch() {
|
|
28
|
+
// If panel was hidden (not destroyed), restore it
|
|
29
|
+
if (_panel && _panel.style.display === 'none') {
|
|
30
|
+
_panel.style.display = 'flex';
|
|
31
|
+
document.addEventListener('keydown', _onEsc);
|
|
32
|
+
requestAnimationFrame(() => _panel?.classList.add('visible'));
|
|
33
|
+
// Re-focus search input
|
|
34
|
+
const input = _panel.querySelector('#gsSearchInput') as HTMLInputElement;
|
|
35
|
+
input?.focus();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
if (_panel) return;
|
|
27
40
|
|
|
28
41
|
_panel = document.createElement('div');
|
|
@@ -82,10 +95,11 @@ export function closeSearch() {
|
|
|
82
95
|
if (!_panel) return;
|
|
83
96
|
document.removeEventListener('keydown', _onEsc);
|
|
84
97
|
_panel.classList.remove('visible');
|
|
85
|
-
//
|
|
98
|
+
// Hide instead of destroy — preserves query + results
|
|
86
99
|
setTimeout(() => {
|
|
87
|
-
_panel
|
|
88
|
-
|
|
100
|
+
if (_panel) {
|
|
101
|
+
_panel.style.display = 'none';
|
|
102
|
+
}
|
|
89
103
|
}, 200);
|
|
90
104
|
if (_abortController) { _abortController.abort(); _abortController = null; }
|
|
91
105
|
if (_searchTimeout) { clearTimeout(_searchTimeout); _searchTimeout = null; }
|
|
@@ -228,37 +242,44 @@ function getFileIcon(name: string): string {
|
|
|
228
242
|
return icons[ext] || '📄';
|
|
229
243
|
}
|
|
230
244
|
|
|
231
|
-
/**
|
|
245
|
+
/** Jump to a file on the canvas from search results */
|
|
232
246
|
function openFileFromSearch(filePath: string, line: number) {
|
|
233
247
|
if (!_ctx) return;
|
|
234
248
|
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
name: filePath.split('/').pop() || filePath,
|
|
239
|
-
content: '',
|
|
240
|
-
lines: 0,
|
|
241
|
-
};
|
|
249
|
+
// Jump to the file on the canvas (handles layer switching and centering)
|
|
250
|
+
import('./canvas').then(({ jumpToFile }) => {
|
|
251
|
+
jumpToFile(_ctx!, filePath);
|
|
242
252
|
|
|
243
|
-
|
|
244
|
-
import('./file-modal').then(({ openFileModal }) => {
|
|
245
|
-
openFileModal(_ctx!, file, 'edit');
|
|
246
|
-
// Scroll to line after editor loads
|
|
253
|
+
// After jump animation settles, scroll to the matching line
|
|
247
254
|
if (line > 1) {
|
|
248
255
|
setTimeout(() => {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
});
|
|
256
|
+
const card = _ctx?.fileCards.get(filePath);
|
|
257
|
+
if (!card) return;
|
|
258
|
+
const body = card.querySelector('.file-card-body') as HTMLElement;
|
|
259
|
+
if (!body) return;
|
|
260
|
+
// Find the line element
|
|
261
|
+
const lineEl = body.querySelector(`[data-line="${line}"]`) as HTMLElement;
|
|
262
|
+
if (lineEl) {
|
|
263
|
+
lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
264
|
+
// Flash highlight
|
|
265
|
+
lineEl.style.background = 'rgba(124, 58, 237, 0.3)';
|
|
266
|
+
setTimeout(() => { lineEl.style.background = ''; }, 2000);
|
|
257
267
|
}
|
|
258
|
-
},
|
|
268
|
+
}, 600); // Wait for jump animation
|
|
259
269
|
}
|
|
260
270
|
});
|
|
261
271
|
|
|
262
|
-
//
|
|
263
|
-
|
|
272
|
+
// Hide panel but don't destroy — preserve state
|
|
273
|
+
if (_panel) {
|
|
274
|
+
_panel.classList.remove('visible');
|
|
275
|
+
_panel.style.pointerEvents = 'none';
|
|
276
|
+
_panel.style.opacity = '0';
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
if (_panel) {
|
|
279
|
+
_panel.style.display = 'none';
|
|
280
|
+
_panel.style.pointerEvents = '';
|
|
281
|
+
_panel.style.opacity = '';
|
|
282
|
+
}
|
|
283
|
+
}, 200);
|
|
284
|
+
}
|
|
264
285
|
}
|
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
|
package/app/lib/perf-overlay.ts
CHANGED
|
@@ -26,11 +26,22 @@ let _lastFpsTime = 0;
|
|
|
26
26
|
let _currentFps = 0;
|
|
27
27
|
let _fpsHistory: number[] = [];
|
|
28
28
|
const FPS_HISTORY_LENGTH = 60; // 1 second of history at 60fps
|
|
29
|
+
let _lastFrameTime = 0; // ms per frame
|
|
29
30
|
|
|
30
31
|
// DOM count tracking (expensive, sample every ~500ms)
|
|
31
32
|
let _lastDomCount = 0;
|
|
32
33
|
let _lastDomTime = 0;
|
|
33
34
|
|
|
35
|
+
// Render timing (external instrumentation can set these)
|
|
36
|
+
let _lastCullTimeMs = 0;
|
|
37
|
+
let _lastRenderTimeMs = 0;
|
|
38
|
+
|
|
39
|
+
/** Set cull/render timing from external code (viewport-culling, connections) */
|
|
40
|
+
export function reportRenderTiming(phase: 'cull' | 'render', ms: number) {
|
|
41
|
+
if (phase === 'cull') _lastCullTimeMs = ms;
|
|
42
|
+
else _lastRenderTimeMs = ms;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
// ── DOM Elements (cached) ──────────────────────────────────
|
|
35
46
|
let _elFps: HTMLElement;
|
|
36
47
|
let _elFpsBar: HTMLElement;
|
|
@@ -39,6 +50,10 @@ let _elDom: HTMLElement;
|
|
|
39
50
|
let _elCards: HTMLElement;
|
|
40
51
|
let _elZoom: HTMLElement;
|
|
41
52
|
let _elMemory: HTMLElement;
|
|
53
|
+
let _elFrameTime: HTMLElement;
|
|
54
|
+
let _elConnections: HTMLElement;
|
|
55
|
+
let _elRenderBudget: HTMLElement;
|
|
56
|
+
let _elRenderBudgetBar: HTMLElement;
|
|
42
57
|
|
|
43
58
|
/**
|
|
44
59
|
* Creates the overlay DOM once.
|
|
@@ -91,6 +106,23 @@ function createOverlay(): HTMLElement {
|
|
|
91
106
|
<div class="perf-label">Zoom</div>
|
|
92
107
|
<div class="perf-value" id="perf-zoom">--</div>
|
|
93
108
|
</div>
|
|
109
|
+
<div class="perf-stat">
|
|
110
|
+
<div class="perf-label">Frame</div>
|
|
111
|
+
<div class="perf-value" id="perf-frametime">--</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="perf-stat">
|
|
114
|
+
<div class="perf-label">Lines</div>
|
|
115
|
+
<div class="perf-value" id="perf-connections">--</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="perf-stat" style="margin-top:6px;">
|
|
119
|
+
<div class="perf-label">Render Budget</div>
|
|
120
|
+
<div style="display:flex;align-items:center;gap:6px">
|
|
121
|
+
<div class="perf-value" id="perf-render-budget" style="min-width:50px">--</div>
|
|
122
|
+
<div class="perf-bar-wrap" style="flex:1">
|
|
123
|
+
<div class="perf-bar" id="perf-render-budget-bar" style="width:0%;background:#22c55e;"></div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
94
126
|
</div>
|
|
95
127
|
<div class="perf-stat" style="margin-top:6px;">
|
|
96
128
|
<div class="perf-label">Memory</div>
|
|
@@ -147,6 +179,10 @@ function createOverlay(): HTMLElement {
|
|
|
147
179
|
_elCards = el.querySelector('#perf-cards')!;
|
|
148
180
|
_elZoom = el.querySelector('#perf-zoom')!;
|
|
149
181
|
_elMemory = el.querySelector('#perf-memory')!;
|
|
182
|
+
_elFrameTime = el.querySelector('#perf-frametime')!;
|
|
183
|
+
_elConnections = el.querySelector('#perf-connections')!;
|
|
184
|
+
_elRenderBudget = el.querySelector('#perf-render-budget')!;
|
|
185
|
+
_elRenderBudgetBar = el.querySelector('#perf-render-budget-bar')!;
|
|
150
186
|
|
|
151
187
|
// Close button
|
|
152
188
|
el.querySelector('#perf-close')!.addEventListener('click', () => togglePerfOverlay(_ctx!));
|
|
@@ -250,6 +286,17 @@ function drawFpsGraph() {
|
|
|
250
286
|
function measureFrame(timestamp: number) {
|
|
251
287
|
if (!_visible || !_ctx) return;
|
|
252
288
|
|
|
289
|
+
// Frame time (ms since last frame)
|
|
290
|
+
if (_lastFrameTime > 0) {
|
|
291
|
+
const frameMs = timestamp - _lastFrameTime;
|
|
292
|
+
const frameMsRounded = Math.round(frameMs * 10) / 10;
|
|
293
|
+
_elFrameTime.textContent = frameMsRounded + 'ms';
|
|
294
|
+
if (frameMs > 33) _elFrameTime.style.color = '#ef4444'; // < 30fps
|
|
295
|
+
else if (frameMs > 20) _elFrameTime.style.color = '#fbbf24'; // < 50fps
|
|
296
|
+
else _elFrameTime.style.color = '#e0e0f0';
|
|
297
|
+
}
|
|
298
|
+
_lastFrameTime = timestamp;
|
|
299
|
+
|
|
253
300
|
_frameCount++;
|
|
254
301
|
|
|
255
302
|
// Calculate FPS every 500ms
|
|
@@ -295,6 +342,16 @@ function measureFrame(timestamp: number) {
|
|
|
295
342
|
_elCards.style.color = culled > 0 ? '#22c55e' : '#e0e0f0';
|
|
296
343
|
}
|
|
297
344
|
|
|
345
|
+
// Connection line count
|
|
346
|
+
const svgLayer = _ctx.connectionLayer || document.querySelector('.connections-layer');
|
|
347
|
+
if (svgLayer) {
|
|
348
|
+
const lineCount = svgLayer.querySelectorAll('line, path').length;
|
|
349
|
+
_elConnections.textContent = lineCount.toLocaleString();
|
|
350
|
+
if (lineCount > 1000) _elConnections.style.color = '#ef4444';
|
|
351
|
+
else if (lineCount > 500) _elConnections.style.color = '#fbbf24';
|
|
352
|
+
else _elConnections.style.color = '#e0e0f0';
|
|
353
|
+
}
|
|
354
|
+
|
|
298
355
|
// Zoom level
|
|
299
356
|
if (_ctx.snap) {
|
|
300
357
|
try {
|
|
@@ -304,6 +361,24 @@ function measureFrame(timestamp: number) {
|
|
|
304
361
|
} catch (_) { }
|
|
305
362
|
}
|
|
306
363
|
|
|
364
|
+
// Render budget: cull + render time vs 16.67ms target
|
|
365
|
+
const totalRenderMs = _lastCullTimeMs + _lastRenderTimeMs;
|
|
366
|
+
if (totalRenderMs > 0) {
|
|
367
|
+
const budgetPct = Math.min((totalRenderMs / 16.67) * 100, 100);
|
|
368
|
+
_elRenderBudget.textContent = totalRenderMs.toFixed(1) + 'ms';
|
|
369
|
+
_elRenderBudgetBar.style.width = budgetPct + '%';
|
|
370
|
+
if (totalRenderMs > 16.67) {
|
|
371
|
+
_elRenderBudget.style.color = '#ef4444';
|
|
372
|
+
_elRenderBudgetBar.style.background = '#ef4444';
|
|
373
|
+
} else if (totalRenderMs > 10) {
|
|
374
|
+
_elRenderBudget.style.color = '#fbbf24';
|
|
375
|
+
_elRenderBudgetBar.style.background = '#fbbf24';
|
|
376
|
+
} else {
|
|
377
|
+
_elRenderBudget.style.color = '#22c55e';
|
|
378
|
+
_elRenderBudgetBar.style.background = '#22c55e';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
307
382
|
// Memory (Chrome only)
|
|
308
383
|
const perf = (performance as any);
|
|
309
384
|
if (perf.memory) {
|
|
@@ -352,6 +427,9 @@ export function togglePerfOverlay(ctx: CanvasContext) {
|
|
|
352
427
|
export function setupPerfOverlay(ctx: CanvasContext) {
|
|
353
428
|
_ctx = ctx;
|
|
354
429
|
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
430
|
+
// Don't steal Shift+P from text inputs
|
|
431
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
432
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
|
355
433
|
if (e.shiftKey && e.key === 'P') {
|
|
356
434
|
e.preventDefault();
|
|
357
435
|
togglePerfOverlay(ctx);
|
package/app/lib/positions.ts
CHANGED
|
@@ -71,7 +71,7 @@ export async function loadSavedPositions(ctx: CanvasContext) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ─── Persist all positions (debounced) ───────────────────
|
|
74
|
-
function flushPositions(ctx: CanvasContext) {
|
|
74
|
+
export function flushPositions(ctx: CanvasContext) {
|
|
75
75
|
const repoPath = getRepoPath(ctx);
|
|
76
76
|
if (!repoPath) return;
|
|
77
77
|
|
package/app/lib/repo.tsx
CHANGED
|
@@ -59,12 +59,21 @@ export async function loadRepository(ctx: CanvasContext, repoPath: string) {
|
|
|
59
59
|
const landing = document.getElementById('landingOverlay');
|
|
60
60
|
if (landing) landing.style.display = 'none';
|
|
61
61
|
|
|
62
|
-
//
|
|
62
|
+
// Determine the best URL slug to display:
|
|
63
|
+
// If the current URL is already a GitHub owner/repo slug that maps to this repo, keep it.
|
|
64
|
+
// Otherwise fall back to the short folder name.
|
|
65
|
+
const currentPath = decodeURIComponent(window.location.pathname.replace(/^\//, ''));
|
|
66
|
+
const isCurrentGitHubSlug = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(currentPath)
|
|
67
|
+
&& localStorage.getItem(`gitcanvas:slug:${currentPath}`) === repoPath;
|
|
63
68
|
const repoSlug = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
|
|
64
|
-
|
|
69
|
+
const displaySlug = isCurrentGitHubSlug ? currentPath : repoSlug;
|
|
70
|
+
history.replaceState(null, '', '/' + (displaySlug.includes('/') ? displaySlug : encodeURIComponent(displaySlug)));
|
|
65
71
|
localStorage.setItem('gitcanvas:lastRepo', repoPath);
|
|
66
|
-
//
|
|
72
|
+
// Store slug→path mapping for URL-based loading (both short and GitHub-style)
|
|
67
73
|
localStorage.setItem(`gitcanvas:slug:${repoSlug}`, repoPath);
|
|
74
|
+
if (isCurrentGitHubSlug) {
|
|
75
|
+
localStorage.setItem(`gitcanvas:slug:${currentPath}`, repoPath);
|
|
76
|
+
}
|
|
68
77
|
updateStatusBarRepo(repoPath);
|
|
69
78
|
// Save to recent repos list
|
|
70
79
|
const recentKey = 'gitcanvas:recentRepos';
|
|
@@ -392,7 +401,7 @@ export async function selectCommit(ctx: CanvasContext, hash: string) {
|
|
|
392
401
|
updateStatusBarFiles(ctx.fileCards.size);
|
|
393
402
|
|
|
394
403
|
// Populate changed files panel with diff stats
|
|
395
|
-
populateChangedFilesPanel(data.files);
|
|
404
|
+
populateChangedFilesPanel(ctx, data.files);
|
|
396
405
|
} catch (err) {
|
|
397
406
|
_showCommitProgress(false);
|
|
398
407
|
measure('commit:selectError', () => err);
|
|
@@ -853,7 +862,7 @@ export function switchView(ctx: CanvasContext, mode: string) {
|
|
|
853
862
|
// We have commit files in state — render them
|
|
854
863
|
ctx.commitFilesData = state.commitFiles;
|
|
855
864
|
renderFilesOnCanvas(ctx, state.commitFiles, state.currentCommitHash);
|
|
856
|
-
populateChangedFilesPanel(state.commitFiles);
|
|
865
|
+
populateChangedFilesPanel(ctx, state.commitFiles);
|
|
857
866
|
const fileCountEl = document.getElementById('fileCount');
|
|
858
867
|
if (fileCountEl) fileCountEl.textContent = state.commitFiles.length;
|
|
859
868
|
} else {
|
|
@@ -887,7 +896,7 @@ function ChangedFilesList({ fileStats, totalAdd, totalDel, count }: {
|
|
|
887
896
|
const statusIcons = { added: '+', modified: '~', deleted: '−', renamed: '→', copied: '⊕' };
|
|
888
897
|
|
|
889
898
|
return (
|
|
890
|
-
|
|
899
|
+
<div className="changed-files-container-inner" style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
891
900
|
<div className="changed-files-summary">
|
|
892
901
|
<span className="stat-add">+{totalAdd}</span>
|
|
893
902
|
<span className="stat-del">−{totalDel}</span>
|
|
@@ -921,11 +930,12 @@ function ChangedFilesList({ fileStats, totalAdd, totalDel, count }: {
|
|
|
921
930
|
</div>
|
|
922
931
|
);
|
|
923
932
|
})}
|
|
924
|
-
|
|
933
|
+
</div>
|
|
925
934
|
);
|
|
926
935
|
}
|
|
927
936
|
|
|
928
|
-
export function populateChangedFilesPanel(files: any[]) {
|
|
937
|
+
export function populateChangedFilesPanel(ctx: CanvasContext, files: any[]) {
|
|
938
|
+
setPanelCtx(ctx);
|
|
929
939
|
const panel = document.getElementById('changedFilesPanel');
|
|
930
940
|
const listEl = document.getElementById('changedFilesList');
|
|
931
941
|
if (!panel || !listEl) return;
|
|
@@ -42,6 +42,8 @@ const SHORTCUTS = [
|
|
|
42
42
|
{
|
|
43
43
|
category: 'Tools', items: [
|
|
44
44
|
{ keys: ['H'], description: 'Toggle git heatmap (no selection)' },
|
|
45
|
+
{ keys: ['Shift', 'P'], description: 'Performance overlay' },
|
|
46
|
+
{ keys: ['Ctrl', 'G'], description: 'Toggle dependency graph' },
|
|
45
47
|
{ keys: ['Ctrl', 'N'], description: 'Create new file' },
|
|
46
48
|
{ keys: ['Shift + Click line'], description: 'Start connection' },
|
|
47
49
|
{ keys: ['Ctrl', 'Shift', 'E'], description: 'Export canvas as PNG' },
|
|
@@ -515,7 +515,11 @@ export function scheduleViewportCulling(ctx: CanvasContext) {
|
|
|
515
515
|
_cullRafPending = true;
|
|
516
516
|
requestAnimationFrame(() => {
|
|
517
517
|
_cullRafPending = false;
|
|
518
|
+
const t0 = performance.now();
|
|
518
519
|
performViewportCulling(ctx);
|
|
520
|
+
const elapsed = performance.now() - t0;
|
|
521
|
+
// Report to perf overlay (lazy import avoids circular dep)
|
|
522
|
+
try { require('./perf-overlay').reportRenderTiming('cull', elapsed); } catch { }
|
|
519
523
|
});
|
|
520
524
|
}
|
|
521
525
|
|
|
@@ -694,6 +698,9 @@ export function setupPillInteraction(ctx: CanvasContext) {
|
|
|
694
698
|
savePosition(ctx, 'allfiles', info.path, newX, newY);
|
|
695
699
|
});
|
|
696
700
|
pillMoveInfos = [];
|
|
701
|
+
// Force minimap rebuild so dot positions reflect the drag result
|
|
702
|
+
const { forceMinimapRebuild } = require('./canvas');
|
|
703
|
+
forceMinimapRebuild(ctx);
|
|
697
704
|
}
|
|
698
705
|
|
|
699
706
|
pillAction = null;
|
package/app/page.client.tsx
CHANGED
|
@@ -75,7 +75,7 @@ export default function mount(): () => void {
|
|
|
75
75
|
if (disposed) return; // bail if cleaned up during await
|
|
76
76
|
loadHiddenFiles(ctx);
|
|
77
77
|
updateHiddenUI(ctx);
|
|
78
|
-
|
|
78
|
+
loadConnections(ctx);
|
|
79
79
|
if (disposed) return; // bail if cleaned up during await
|
|
80
80
|
|
|
81
81
|
// Init auth UI
|
|
@@ -119,11 +119,71 @@ export default function mount(): () => void {
|
|
|
119
119
|
}
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
-
// Check URL
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
// Check URL path for repo slug (e.g. /starwar or /galaxy-canvas/starwar)
|
|
123
|
+
// Fallback: also check hash for legacy URLs (e.g. #starwar)
|
|
124
|
+
const rawPath = decodeURIComponent(window.location.pathname.replace(/^\//, ''));
|
|
125
|
+
// Strip the route-name prefix if we're served at /galaxy-canvas
|
|
126
|
+
const pathSlug = rawPath.replace(/^galaxy-canvas\/?/, '');
|
|
127
|
+
const hashSlug = decodeURIComponent(window.location.hash.replace('#', ''));
|
|
128
|
+
const urlSlug = pathSlug || hashSlug;
|
|
129
|
+
|
|
130
|
+
if (urlSlug) {
|
|
131
|
+
// Migrate legacy hash URL to path URL
|
|
132
|
+
if (hashSlug && !pathSlug) {
|
|
133
|
+
history.replaceState(null, '', '/' + encodeURIComponent(hashSlug));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Detect GitHub owner/repo pattern (exactly one /, no \ or : which indicate local paths)
|
|
137
|
+
const isGitHubSlug = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(urlSlug)
|
|
138
|
+
&& !urlSlug.includes('\\') && !urlSlug.includes(':');
|
|
139
|
+
|
|
140
|
+
let resolvedPath: string;
|
|
141
|
+
|
|
142
|
+
if (isGitHubSlug) {
|
|
143
|
+
// Check if we already have a localStorage mapping for this GitHub slug
|
|
144
|
+
const cached = localStorage.getItem(`gitcanvas:slug:${urlSlug}`);
|
|
145
|
+
if (cached) {
|
|
146
|
+
resolvedPath = cached;
|
|
147
|
+
} else {
|
|
148
|
+
// Clone from GitHub and use the local clone path
|
|
149
|
+
const landing = document.getElementById('landingOverlay');
|
|
150
|
+
if (landing) landing.style.display = 'none';
|
|
151
|
+
|
|
152
|
+
// Show loading state
|
|
153
|
+
const loadingEl = document.getElementById('loadingProgress');
|
|
154
|
+
if (loadingEl) {
|
|
155
|
+
loadingEl.style.display = 'flex';
|
|
156
|
+
const msgEl = loadingEl.querySelector('.loading-message');
|
|
157
|
+
if (msgEl) msgEl.textContent = `Cloning ${urlSlug} from GitHub...`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const cloneRes = await fetch('/api/repo/clone', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ url: `https://github.com/${urlSlug}.git` }),
|
|
165
|
+
});
|
|
166
|
+
if (!cloneRes.ok) {
|
|
167
|
+
const err = await cloneRes.json().catch(() => ({ error: 'Clone failed' }));
|
|
168
|
+
throw new Error(err.error || 'Clone failed');
|
|
169
|
+
}
|
|
170
|
+
const cloneData = await cloneRes.json();
|
|
171
|
+
resolvedPath = cloneData.path;
|
|
172
|
+
|
|
173
|
+
// Store slug→path mapping so future visits are instant
|
|
174
|
+
localStorage.setItem(`gitcanvas:slug:${urlSlug}`, resolvedPath);
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
console.error(`[gitmaps] Failed to clone ${urlSlug}:`, err);
|
|
177
|
+
const { showToast } = await import('./lib/utils');
|
|
178
|
+
showToast(`Failed to clone ${urlSlug}: ${err.message}`, 'error');
|
|
179
|
+
// Fall through — show landing
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Resolve slug to full path (check localStorage mapping)
|
|
185
|
+
resolvedPath = localStorage.getItem(`gitcanvas:slug:${urlSlug}`) || urlSlug;
|
|
186
|
+
}
|
|
127
187
|
|
|
128
188
|
// Hide landing immediately since we have a repo
|
|
129
189
|
const landing = document.getElementById('landingOverlay');
|
|
@@ -145,9 +205,6 @@ export default function mount(): () => void {
|
|
|
145
205
|
updateZoomUI(ctx);
|
|
146
206
|
|
|
147
207
|
if (!disposed) {
|
|
148
|
-
import('./lib/pr-review').then(({ initReviewStore }) => {
|
|
149
|
-
initReviewStore(hashValue);
|
|
150
|
-
});
|
|
151
208
|
loadRepository(ctx, resolvedPath);
|
|
152
209
|
updateFavoriteStar(resolvedPath);
|
|
153
210
|
}
|
|
@@ -157,9 +214,9 @@ export default function mount(): () => void {
|
|
|
157
214
|
const sel2 = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
158
215
|
if (sel2) sel2.value = saved;
|
|
159
216
|
|
|
160
|
-
// Set URL
|
|
217
|
+
// Set URL path to friendly slug instead of full path
|
|
161
218
|
const savedSlug = saved.replace(/\\/g, '/').split('/').filter(Boolean).pop() || saved;
|
|
162
|
-
history.replaceState(null, '', '
|
|
219
|
+
history.replaceState(null, '', '/' + encodeURIComponent(savedSlug));
|
|
163
220
|
// Store slug→path mapping
|
|
164
221
|
localStorage.setItem(`gitcanvas:slug:${savedSlug}`, saved);
|
|
165
222
|
|
|
@@ -176,19 +233,16 @@ export default function mount(): () => void {
|
|
|
176
233
|
|
|
177
234
|
// Actually load the repo data
|
|
178
235
|
if (!disposed) {
|
|
179
|
-
import('./lib/pr-review').then(({ initReviewStore }) => {
|
|
180
|
-
initReviewStore(savedSlug);
|
|
181
|
-
});
|
|
182
236
|
loadRepository(ctx, saved);
|
|
183
237
|
}
|
|
184
238
|
}
|
|
185
239
|
}
|
|
186
240
|
|
|
187
|
-
// Listen for
|
|
188
|
-
window.addEventListener('
|
|
241
|
+
// Listen for popstate (back/forward navigation with path-based routing)
|
|
242
|
+
window.addEventListener('popstate', () => {
|
|
189
243
|
if (disposed) return;
|
|
190
|
-
const
|
|
191
|
-
const resolvedPath = localStorage.getItem(`gitcanvas:slug:${
|
|
244
|
+
const slug = decodeURIComponent(window.location.pathname.replace(/^\//, ''));
|
|
245
|
+
const resolvedPath = localStorage.getItem(`gitcanvas:slug:${slug}`) || slug;
|
|
192
246
|
if (resolvedPath && resolvedPath !== ctx.snap().context.repoPath) {
|
|
193
247
|
const sel3 = document.getElementById('repoSelect') as HTMLSelectElement;
|
|
194
248
|
if (sel3) sel3.value = resolvedPath;
|