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.
Files changed (46) hide show
  1. package/README.md +5 -11
  2. package/app/[owner]/[repo]/page.client.tsx +5 -0
  3. package/app/[owner]/[repo]/page.tsx +6 -0
  4. package/app/[slug]/page.client.tsx +5 -0
  5. package/app/[slug]/page.tsx +6 -0
  6. package/app/api/manifest.json/route.ts +20 -0
  7. package/app/api/pwa-icon/route.ts +14 -0
  8. package/app/api/repo/clone-stream/route.ts +20 -12
  9. package/app/api/repo/imports/route.ts +21 -3
  10. package/app/api/repo/list/route.ts +30 -0
  11. package/app/api/repo/upload/route.ts +6 -9
  12. package/app/api/sw.js/route.ts +70 -0
  13. package/app/galaxy-canvas/page.client.tsx +2 -0
  14. package/app/galaxy-canvas/page.tsx +5 -0
  15. package/app/globals.css +477 -95
  16. package/app/icon.png +0 -0
  17. package/app/layout.tsx +30 -2
  18. package/app/lib/canvas-text.ts +4 -72
  19. package/app/lib/canvas.ts +1 -1
  20. package/app/lib/card-arrangement.ts +21 -7
  21. package/app/lib/card-context-menu.tsx +2 -2
  22. package/app/lib/card-groups.ts +9 -2
  23. package/app/lib/cards.tsx +3 -1
  24. package/app/lib/connections.tsx +34 -43
  25. package/app/lib/events.tsx +25 -0
  26. package/app/lib/file-card-plugin.ts +14 -0
  27. package/app/lib/file-preview.ts +68 -41
  28. package/app/lib/galaxydraw-bridge.ts +5 -0
  29. package/app/lib/global-search.ts +48 -27
  30. package/app/lib/layers.tsx +17 -18
  31. package/app/lib/perf-overlay.ts +78 -0
  32. package/app/lib/positions.ts +1 -1
  33. package/app/lib/repo.tsx +18 -8
  34. package/app/lib/shortcuts-panel.ts +2 -0
  35. package/app/lib/viewport-culling.ts +7 -0
  36. package/app/page.client.tsx +72 -18
  37. package/app/page.tsx +22 -86
  38. package/banner.png +0 -0
  39. package/package.json +2 -2
  40. package/packages/galaxydraw/README.md +2 -2
  41. package/packages/galaxydraw/package.json +1 -1
  42. package/server.ts +1 -1
  43. package/app/api/connections/route.ts +0 -72
  44. package/app/api/positions/route.ts +0 -80
  45. package/app/api/repo/browse/route.ts +0 -55
  46. package/app/lib/pr-review.ts +0 -374
@@ -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
- if (_panel) {
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
- // Wait for slide-out animation
98
+ // Hide instead of destroy — preserves query + results
86
99
  setTimeout(() => {
87
- _panel?.remove();
88
- _panel = null;
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
- /** Open a file from search results in the editor modal */
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
- // Create a minimal file stub
236
- const file = {
237
- path: filePath,
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
- // Import and open the file modal
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 editContainer = document.getElementById('modalEditContainer');
250
- const editor = (editContainer as any)?._cmEditor;
251
- if (editor?.view) {
252
- const lineInfo = editor.view.state.doc.line(Math.min(line, editor.view.state.doc.lines));
253
- editor.view.dispatch({
254
- selection: { anchor: lineInfo.from },
255
- scrollIntoView: true,
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
- }, 500);
268
+ }, 600); // Wait for jump animation
259
269
  }
260
270
  });
261
271
 
262
- // Close search panel
263
- closeSearch();
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
  }
@@ -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
@@ -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);
@@ -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
- // Set URL hash to a friendly slug (folder name) instead of full path
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
- history.replaceState(null, '', '#' + encodeURIComponent(repoSlug));
69
+ const displaySlug = isCurrentGitHubSlug ? currentPath : repoSlug;
70
+ history.replaceState(null, '', '/' + (displaySlug.includes('/') ? displaySlug : encodeURIComponent(displaySlug)));
65
71
  localStorage.setItem('gitcanvas:lastRepo', repoPath);
66
- // Also store slug→path mapping for URL-based loading
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;
@@ -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
- await loadConnections(ctx);
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 hash for repo slug (or legacy full path)
123
- const hashValue = decodeURIComponent(window.location.hash.replace('#', ''));
124
- if (hashValue) {
125
- // Resolve slug to full path (check localStorage mapping)
126
- const resolvedPath = localStorage.getItem(`gitcanvas:slug:${hashValue}`) || hashValue;
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 hash to friendly slug instead of full path
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, '', '#' + encodeURIComponent(savedSlug));
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 hash changes
188
- window.addEventListener('hashchange', () => {
241
+ // Listen for popstate (back/forward navigation with path-based routing)
242
+ window.addEventListener('popstate', () => {
189
243
  if (disposed) return;
190
- const hashSlug = decodeURIComponent(window.location.hash.replace('#', ''));
191
- const resolvedPath = localStorage.getItem(`gitcanvas:slug:${hashSlug}`) || hashSlug;
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;