gitmaps 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +265 -122
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/[owner]/[repo]/page.client.tsx +5 -0
  5. package/app/[slug]/page.client.tsx +5 -0
  6. package/app/analytics.db +0 -0
  7. package/app/api/analytics/route.ts +64 -0
  8. package/app/api/auth/positions/route.ts +95 -33
  9. package/app/api/build-info/route.ts +19 -0
  10. package/app/api/chat/route.ts +13 -2
  11. package/app/api/manifest.json/route.ts +20 -0
  12. package/app/api/og-image/route.ts +14 -0
  13. package/app/api/pwa-icon/route.ts +14 -0
  14. package/app/api/repo/clone-stream/route.ts +20 -12
  15. package/app/api/repo/file-content/route.ts +73 -20
  16. package/app/api/repo/imports/route.ts +21 -3
  17. package/app/api/repo/list/route.ts +30 -0
  18. package/app/api/repo/load/route.test.ts +62 -0
  19. package/app/api/repo/load/route.ts +41 -1
  20. package/app/api/repo/pdf-thumb/route.ts +127 -0
  21. package/app/api/repo/resolve-slug/route.ts +51 -0
  22. package/app/api/repo/tree/route.ts +188 -104
  23. package/app/api/repo/upload/route.ts +6 -9
  24. package/app/api/sw.js/route.ts +70 -0
  25. package/app/api/version/route.ts +26 -0
  26. package/app/galaxy-canvas/page.client.tsx +2 -0
  27. package/app/galaxy-canvas/page.tsx +5 -0
  28. package/app/globals.css +5844 -4694
  29. package/app/icon.png +0 -0
  30. package/app/layout.tsx +1284 -467
  31. package/app/lib/auto-arrange.test.ts +158 -0
  32. package/app/lib/auto-arrange.ts +147 -0
  33. package/app/lib/canvas-export.ts +358 -358
  34. package/app/lib/canvas-text.ts +4 -72
  35. package/app/lib/canvas.ts +625 -564
  36. package/app/lib/card-arrangement.ts +21 -7
  37. package/app/lib/card-context-menu.tsx +2 -2
  38. package/app/lib/card-groups.ts +9 -2
  39. package/app/lib/cards.tsx +1361 -914
  40. package/app/lib/chat.tsx +65 -9
  41. package/app/lib/code-editor.ts +86 -2
  42. package/app/lib/connections.tsx +34 -43
  43. package/app/lib/context.test.ts +32 -0
  44. package/app/lib/context.ts +19 -3
  45. package/app/lib/cursor-sharing.ts +34 -0
  46. package/app/lib/events.tsx +76 -73
  47. package/app/lib/export-canvas.ts +287 -0
  48. package/app/lib/file-card-plugin.ts +148 -134
  49. package/app/lib/file-modal.tsx +49 -0
  50. package/app/lib/file-preview.ts +486 -400
  51. package/app/lib/github-import.test.ts +424 -0
  52. package/app/lib/global-search.ts +48 -27
  53. package/app/lib/initial-route-hydration.test.ts +283 -0
  54. package/app/lib/initial-route-hydration.ts +202 -0
  55. package/app/lib/landing-reset.test.ts +99 -0
  56. package/app/lib/landing-reset.ts +106 -0
  57. package/app/lib/landing-shell.test.ts +75 -0
  58. package/app/lib/large-repo-optimization.ts +37 -0
  59. package/app/lib/layers.tsx +17 -18
  60. package/app/lib/layout-snapshots.ts +320 -0
  61. package/app/lib/loading.test.ts +69 -0
  62. package/app/lib/loading.tsx +160 -45
  63. package/app/lib/mount-cleanup.test.ts +52 -0
  64. package/app/lib/mount-cleanup.ts +34 -0
  65. package/app/lib/mount-init.test.ts +123 -0
  66. package/app/lib/mount-init.ts +107 -0
  67. package/app/lib/mount-lifecycle.test.ts +39 -0
  68. package/app/lib/mount-lifecycle.ts +12 -0
  69. package/app/lib/mount-route-wiring.test.ts +87 -0
  70. package/app/lib/mount-route-wiring.ts +84 -0
  71. package/app/lib/multi-repo.ts +14 -0
  72. package/app/lib/onboarding-tutorial.ts +278 -0
  73. package/app/lib/perf-overlay.ts +78 -0
  74. package/app/lib/positions.ts +191 -122
  75. package/app/lib/recent-commits.test.ts +869 -0
  76. package/app/lib/recent-commits.ts +227 -0
  77. package/app/lib/repo-handoff.test.ts +23 -0
  78. package/app/lib/repo-handoff.ts +16 -0
  79. package/app/lib/repo-progressive.ts +119 -0
  80. package/app/lib/repo-select.test.ts +61 -0
  81. package/app/lib/repo-select.ts +74 -0
  82. package/app/lib/repo.tsx +1383 -977
  83. package/app/lib/role.ts +228 -0
  84. package/app/lib/route-catchall.test.ts +27 -0
  85. package/app/lib/route-repo-entry.test.ts +95 -0
  86. package/app/lib/route-repo-entry.ts +36 -0
  87. package/app/lib/router-contract.test.ts +22 -0
  88. package/app/lib/router-contract.ts +19 -0
  89. package/app/lib/shared-layout.test.ts +86 -0
  90. package/app/lib/shared-layout.ts +82 -0
  91. package/app/lib/shortcuts-panel.ts +2 -0
  92. package/app/lib/status-bar.test.ts +118 -0
  93. package/app/lib/status-bar.ts +365 -128
  94. package/app/lib/sync-controls.test.ts +43 -0
  95. package/app/lib/sync-controls.tsx +303 -0
  96. package/app/lib/test-dom.ts +145 -0
  97. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  98. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  99. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  100. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  101. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  102. package/app/lib/transclusion-smoke.test.ts +163 -0
  103. package/app/lib/tutorial.ts +301 -0
  104. package/app/lib/version.ts +93 -0
  105. package/app/lib/viewport-culling.ts +740 -728
  106. package/app/lib/virtual-files.ts +456 -0
  107. package/app/lib/webgl-text.ts +189 -0
  108. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
  109. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  110. package/app/og-image.png +0 -0
  111. package/app/page.client.tsx +70 -215
  112. package/app/page.tsx +27 -92
  113. package/app/state/machine.js +13 -0
  114. package/banner.png +0 -0
  115. package/package.json +17 -8
  116. package/server.ts +11 -1
  117. package/app/api/connections/route.ts +0 -72
  118. package/app/api/positions/route.ts +0 -80
  119. package/app/api/repo/browse/route.ts +0 -55
  120. package/app/lib/pr-review.ts +0 -374
  121. package/packages/galaxydraw/README.md +0 -296
  122. package/packages/galaxydraw/banner.png +0 -0
  123. package/packages/galaxydraw/demo/build-static.ts +0 -100
  124. package/packages/galaxydraw/demo/client.ts +0 -154
  125. package/packages/galaxydraw/demo/dist/client.js +0 -8
  126. package/packages/galaxydraw/demo/index.html +0 -256
  127. package/packages/galaxydraw/demo/server.ts +0 -96
  128. package/packages/galaxydraw/dist/index.js +0 -984
  129. package/packages/galaxydraw/dist/index.js.map +0 -16
  130. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  131. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  132. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  133. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  134. package/packages/galaxydraw/package.json +0 -49
  135. package/packages/galaxydraw/perf.test.ts +0 -284
  136. package/packages/galaxydraw/src/core/cards.ts +0 -435
  137. package/packages/galaxydraw/src/core/engine.ts +0 -339
  138. package/packages/galaxydraw/src/core/events.ts +0 -81
  139. package/packages/galaxydraw/src/core/layout.ts +0 -136
  140. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  141. package/packages/galaxydraw/src/core/state.ts +0 -177
  142. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  143. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  144. package/packages/galaxydraw/src/index.ts +0 -40
  145. package/packages/galaxydraw/tsconfig.json +0 -30
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import type { Window } from 'happy-dom';
3
+ import { initStatusBar, updateStatusBarRepo } from './status-bar';
4
+ import { setupDomTest } from './test-dom';
5
+
6
+ function makeContext() {
7
+ return {
8
+ snap() {
9
+ return {
10
+ context: {
11
+ zoom: 1,
12
+ repoPath: '',
13
+ mode: 'simple',
14
+ currentCommitHash: '',
15
+ },
16
+ };
17
+ },
18
+ fileCards: new Map(),
19
+ } as any;
20
+ }
21
+
22
+ function pressKey(target: EventTarget, key: string, options: Record<string, any> = {}) {
23
+ const event = new KeyboardEvent('keydown', {
24
+ key,
25
+ bubbles: true,
26
+ cancelable: true,
27
+ ...options,
28
+ });
29
+ target.dispatchEvent(event);
30
+ return event;
31
+ }
32
+
33
+ describe('status bar canonical slug accessibility', () => {
34
+ let window: Window;
35
+
36
+ let cleanup: (() => void) | undefined;
37
+
38
+ beforeEach(() => {
39
+ const handle = setupDomTest({
40
+ url: 'http://localhost:3335/',
41
+ html: '<div class="canvas-area"></div>',
42
+ clipboard: { writeText: async () => {} },
43
+ });
44
+ window = handle.window;
45
+ cleanup = handle.cleanup;
46
+ });
47
+
48
+ afterEach(() => {
49
+ cleanup?.();
50
+ });
51
+
52
+ test('Enter opens slug popover and focuses first button', () => {
53
+ initStatusBar(makeContext());
54
+ updateStatusBarRepo('/repos/gitmaps', '7flash/gitmaps', 'github.com · https://github.com/7flash/gitmaps.git');
55
+
56
+ const slugButton = document.getElementById('sbSlug') as HTMLButtonElement;
57
+ slugButton.focus();
58
+ pressKey(slugButton, 'Enter');
59
+
60
+ const popover = document.getElementById('sbSlugPopover');
61
+ expect(popover).toBeTruthy();
62
+ expect(slugButton.getAttribute('aria-expanded')).toBe('true');
63
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('×');
64
+ });
65
+
66
+ test('ArrowDown opens popover and focuses first control', () => {
67
+ initStatusBar(makeContext());
68
+ updateStatusBarRepo('/repos/gitmaps', '7flash/gitmaps', 'github.com · https://github.com/7flash/gitmaps.git');
69
+
70
+ const slugButton = document.getElementById('sbSlug') as HTMLButtonElement;
71
+ slugButton.focus();
72
+ pressKey(slugButton, 'ArrowDown');
73
+
74
+ expect(document.getElementById('sbSlugPopover')).toBeTruthy();
75
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('×');
76
+ });
77
+
78
+ test('Escape closes popover and restores focus to slug button', () => {
79
+ initStatusBar(makeContext());
80
+ updateStatusBarRepo('/repos/gitmaps', '7flash/gitmaps', 'github.com · https://github.com/7flash/gitmaps.git');
81
+
82
+ const slugButton = document.getElementById('sbSlug') as HTMLButtonElement;
83
+ slugButton.focus();
84
+ pressKey(slugButton, 'Enter');
85
+ pressKey(document, 'Escape');
86
+
87
+ expect(document.getElementById('sbSlugPopover')).toBeNull();
88
+ expect(document.activeElement).toBe(slugButton);
89
+ expect(slugButton.getAttribute('aria-expanded')).toBe('false');
90
+ });
91
+
92
+ test('Tab and arrow keys cycle between popover buttons', () => {
93
+ initStatusBar(makeContext());
94
+ updateStatusBarRepo('/repos/gitmaps', '7flash/gitmaps', 'github.com · https://github.com/7flash/gitmaps.git');
95
+
96
+ const slugButton = document.getElementById('sbSlug') as HTMLButtonElement;
97
+ slugButton.focus();
98
+ pressKey(slugButton, 'Enter');
99
+
100
+ const popover = document.getElementById('sbSlugPopover') as HTMLElement;
101
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('×');
102
+
103
+ pressKey(popover, 'Tab');
104
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('Copy slug');
105
+
106
+ pressKey(popover, 'ArrowRight');
107
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('Copy source');
108
+
109
+ pressKey(popover, 'ArrowDown');
110
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('Copy both');
111
+
112
+ pressKey(popover, 'ArrowLeft');
113
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('Copy source');
114
+
115
+ pressKey(popover, 'Tab', { shiftKey: true });
116
+ expect((document.activeElement as HTMLElement | null)?.textContent).toBe('Copy slug');
117
+ });
118
+ });
@@ -1,128 +1,365 @@
1
- /**
2
- * Status Bar — VS Code-style bottom bar for GitMaps
3
- *
4
- * Shows: zoom %, file count, selected count, repo name, mode.
5
- * Updates reactively via exported update functions.
6
- */
7
-
8
- import type { CanvasContext } from './context';
9
-
10
- let bar: HTMLElement | null = null;
11
- let ctx: CanvasContext | null = null;
12
-
13
- // Cached state for efficient updates
14
- let _zoom = 1;
15
- let _fileCount = 0;
16
- let _selectedCount = 0;
17
- let _repoName = '';
18
- let _mode = 'Simple';
19
- let _commitHash = '';
20
-
21
- function createBar(): HTMLElement {
22
- const el = document.createElement('div');
23
- el.id = 'status-bar';
24
- el.innerHTML = `
25
- <div class="sb-left">
26
- <span class="sb-item sb-repo" id="sbRepo" title="Current repository"></span>
27
- <span class="sb-item sb-commit" id="sbCommit" title="Current commit"></span>
28
- </div>
29
- <div class="sb-right">
30
- <span class="sb-item sb-mode" id="sbMode" title="Interaction mode"></span>
31
- <span class="sb-item sb-selected" id="sbSelected" title="Selected cards"></span>
32
- <span class="sb-item sb-files" id="sbFiles" title="Total files on canvas"></span>
33
- <span class="sb-item sb-zoom" id="sbZoom" title="Zoom level (scroll to zoom)"></span>
34
- </div>
35
- `;
36
- return el;
37
- }
38
-
39
- function render() {
40
- if (!bar) return;
41
-
42
- const repoEl = bar.querySelector('#sbRepo') as HTMLElement;
43
- const commitEl = bar.querySelector('#sbCommit') as HTMLElement;
44
- const modeEl = bar.querySelector('#sbMode') as HTMLElement;
45
- const selectedEl = bar.querySelector('#sbSelected') as HTMLElement;
46
- const filesEl = bar.querySelector('#sbFiles') as HTMLElement;
47
- const zoomEl = bar.querySelector('#sbZoom') as HTMLElement;
48
-
49
- if (repoEl) repoEl.textContent = _repoName ? `📂 ${_repoName}` : '';
50
- if (commitEl) commitEl.textContent = _commitHash ? `⊙ ${_commitHash.substring(0, 7)}` : '';
51
- if (modeEl) {
52
- modeEl.textContent = `${_mode === 'Advanced' ? '🎯' : '✋'} ${_mode}`;
53
- modeEl.className = `sb-item sb-mode sb-mode--${_mode.toLowerCase()}`;
54
- }
55
- if (selectedEl) {
56
- selectedEl.textContent = _selectedCount > 0 ? `☑ ${_selectedCount} selected` : '';
57
- selectedEl.style.display = _selectedCount > 0 ? '' : 'none';
58
- }
59
- if (filesEl) filesEl.textContent = `📄 ${_fileCount} files`;
60
- if (zoomEl) zoomEl.textContent = `🔍 ${Math.round(_zoom * 100)}%`;
61
- }
62
-
63
- // ─── Public API ──────────────────────────────────────────
64
-
65
- export function initStatusBar(context: CanvasContext) {
66
- ctx = context;
67
- bar = createBar();
68
-
69
- // Insert after canvas-area
70
- const canvasArea = document.querySelector('.canvas-area');
71
- if (canvasArea) {
72
- canvasArea.parentElement?.insertBefore(bar, canvasArea.nextSibling);
73
- } else {
74
- document.body.appendChild(bar);
75
- }
76
-
77
- // Initial sync
78
- const state = ctx.snap().context;
79
- _zoom = state.zoom || 1;
80
- _repoName = (state.repoPath || '').split('/').pop() || '';
81
- _fileCount = ctx.fileCards.size;
82
- _mode = state.mode === 'advanced' ? 'Advanced' : 'Simple';
83
- _commitHash = state.currentCommitHash || '';
84
- render();
85
- }
86
-
87
- export function updateStatusBarZoom(zoom: number) {
88
- if (Math.round(zoom * 100) === Math.round(_zoom * 100)) return;
89
- _zoom = zoom;
90
- const el = bar?.querySelector('#sbZoom') as HTMLElement;
91
- if (el) el.textContent = `🔍 ${Math.round(zoom * 100)}%`;
92
- }
93
-
94
- export function updateStatusBarFiles(count: number) {
95
- _fileCount = count;
96
- const el = bar?.querySelector('#sbFiles') as HTMLElement;
97
- if (el) el.textContent = `📄 ${count} files`;
98
- }
99
-
100
- export function updateStatusBarSelected(count: number) {
101
- _selectedCount = count;
102
- const el = bar?.querySelector('#sbSelected') as HTMLElement;
103
- if (el) {
104
- el.textContent = count > 0 ? `☑ ${count} selected` : '';
105
- el.style.display = count > 0 ? '' : 'none';
106
- }
107
- }
108
-
109
- export function updateStatusBarRepo(repoPath: string) {
110
- _repoName = repoPath.split('/').pop() || repoPath.split('\\').pop() || '';
111
- const el = bar?.querySelector('#sbRepo') as HTMLElement;
112
- if (el) el.textContent = `📂 ${_repoName}`;
113
- }
114
-
115
- export function updateStatusBarCommit(hash: string) {
116
- _commitHash = hash;
117
- const el = bar?.querySelector('#sbCommit') as HTMLElement;
118
- if (el) el.textContent = hash ? `⊙ ${hash.substring(0, 7)}` : '';
119
- }
120
-
121
- export function updateStatusBarMode(mode: string) {
122
- _mode = mode;
123
- const el = bar?.querySelector('#sbMode') as HTMLElement;
124
- if (el) {
125
- el.textContent = `${mode === 'Advanced' ? '🎯' : '✋'} ${mode}`;
126
- el.className = `sb-item sb-mode sb-mode--${mode.toLowerCase()}`;
127
- }
128
- }
1
+ /**
2
+ * Status Bar — VS Code-style bottom bar for GitMaps
3
+ *
4
+ * Shows: zoom %, file count, selected count, repo name, canonical slug, mode.
5
+ * Updates reactively via exported update functions.
6
+ */
7
+
8
+ import type { CanvasContext } from './context';
9
+ import { escapeHtml, showToast } from './utils';
10
+
11
+ let bar: HTMLElement | null = null;
12
+ let ctx: CanvasContext | null = null;
13
+ let slugPopoverEl: HTMLElement | null = null;
14
+ let slugTriggerEl: HTMLElement | null = null;
15
+
16
+ // Cached state for efficient updates
17
+ let _zoom = 1;
18
+ let _fileCount = 0;
19
+ let _selectedCount = 0;
20
+ let _repoName = '';
21
+ let _repoPath = '';
22
+ let _repoSlug = '';
23
+ let _repoSlugSource = '';
24
+ let _mode = 'Simple';
25
+ let _commitHash = '';
26
+
27
+ function summarizeSlugSource(source: string): string {
28
+ if (!source) return '';
29
+
30
+ const [host] = source.split(' · ');
31
+ if (host) return `via ${host}`;
32
+ if (source.length <= 36) return source;
33
+ return `${source.slice(0, 33)}...`;
34
+ }
35
+
36
+ function getSlugSourceDetails(source: string): { host: string; remoteUrl: string } {
37
+ if (!source) return { host: '', remoteUrl: '' };
38
+
39
+ const [host, remoteUrl] = source.split(' · ');
40
+ return {
41
+ host: remoteUrl ? host : '',
42
+ remoteUrl: remoteUrl || source,
43
+ };
44
+ }
45
+
46
+ function getPopoverButtons(): HTMLElement[] {
47
+ if (!slugPopoverEl) return [];
48
+ return Array.from(slugPopoverEl.querySelectorAll('button:not([disabled])')) as HTMLElement[];
49
+ }
50
+
51
+ function focusPopoverButton(index: number) {
52
+ const buttons = getPopoverButtons();
53
+ if (buttons.length === 0) return;
54
+ const nextIndex = (index + buttons.length) % buttons.length;
55
+ buttons[nextIndex]?.focus();
56
+ }
57
+
58
+ async function copyText(text: string, successMessage: string) {
59
+ try {
60
+ await navigator.clipboard.writeText(text);
61
+ showToast(successMessage, 'success');
62
+ } catch {
63
+ showToast('Failed to copy to clipboard', 'error');
64
+ }
65
+ }
66
+
67
+ async function copyCanonicalSlug(includeSource = false) {
68
+ if (!_repoSlug) return;
69
+
70
+ const text = includeSource && _repoSlugSource
71
+ ? `${_repoSlug}\n${_repoSlugSource}`
72
+ : _repoSlug;
73
+
74
+ await copyText(
75
+ text,
76
+ includeSource && _repoSlugSource
77
+ ? 'Copied canonical slug + source'
78
+ : `Copied canonical slug: ${_repoSlug}`,
79
+ );
80
+ }
81
+
82
+ function closeSlugPopover(restoreFocus = false) {
83
+ slugPopoverEl?.remove();
84
+ slugPopoverEl = null;
85
+
86
+ if (slugTriggerEl) {
87
+ slugTriggerEl.setAttribute('aria-expanded', 'false');
88
+ if (restoreFocus) slugTriggerEl.focus();
89
+ }
90
+ }
91
+
92
+ function renderSlugPopover() {
93
+ if (!bar || !_repoSlug || !slugTriggerEl) return;
94
+
95
+ closeSlugPopover();
96
+
97
+ const { host, remoteUrl } = getSlugSourceDetails(_repoSlugSource);
98
+ const safeSlug = escapeHtml(_repoSlug);
99
+ const safeHost = escapeHtml(host || 'Local/unknown');
100
+ const safeRemoteUrl = escapeHtml(remoteUrl || 'Not available');
101
+
102
+ const popover = document.createElement('div');
103
+ popover.className = 'sb-slug-popover';
104
+ popover.id = 'sbSlugPopover';
105
+ popover.setAttribute('role', 'dialog');
106
+ popover.setAttribute('aria-label', 'Canonical slug details');
107
+ popover.innerHTML = `
108
+ <div class="sb-slug-popover__header">
109
+ <div>
110
+ <div class="sb-slug-popover__eyebrow">Canonical route</div>
111
+ <div class="sb-slug-popover__slug">/${safeSlug}</div>
112
+ </div>
113
+ <button class="sb-slug-popover__close" type="button" aria-label="Close canonical slug details">×</button>
114
+ </div>
115
+ <div class="sb-slug-popover__meta">
116
+ <div class="sb-slug-popover__row">
117
+ <span class="sb-slug-popover__label">Host</span>
118
+ <span class="sb-slug-popover__value">${safeHost}</span>
119
+ </div>
120
+ <div class="sb-slug-popover__row">
121
+ <span class="sb-slug-popover__label">Remote</span>
122
+ <span class="sb-slug-popover__value sb-slug-popover__value--multiline">${safeRemoteUrl}</span>
123
+ </div>
124
+ </div>
125
+ <div class="sb-slug-popover__actions">
126
+ <button class="sb-slug-popover__action" type="button" data-copy="slug">Copy slug</button>
127
+ <button class="sb-slug-popover__action" type="button" data-copy="source" ${_repoSlugSource ? '' : 'disabled'}>Copy source</button>
128
+ <button class="sb-slug-popover__action" type="button" data-copy="both" ${_repoSlugSource ? '' : 'disabled'}>Copy both</button>
129
+ </div>
130
+ `;
131
+
132
+ bar.appendChild(popover);
133
+ slugPopoverEl = popover;
134
+ slugTriggerEl.setAttribute('aria-expanded', 'true');
135
+
136
+ const slugRect = slugTriggerEl.getBoundingClientRect();
137
+ const barRect = bar.getBoundingClientRect();
138
+ const left = Math.max(8, Math.min(slugRect.left - barRect.left, Math.max(8, barRect.width - 360)));
139
+ popover.style.left = `${left}px`;
140
+ popover.style.bottom = '30px';
141
+
142
+ popover.querySelector('.sb-slug-popover__close')?.addEventListener('click', () => {
143
+ closeSlugPopover(true);
144
+ });
145
+
146
+ popover.querySelector('[data-copy="slug"]')?.addEventListener('click', () => {
147
+ void copyText(_repoSlug, `Copied canonical slug: ${_repoSlug}`);
148
+ });
149
+
150
+ popover.querySelector('[data-copy="source"]')?.addEventListener('click', () => {
151
+ if (_repoSlugSource) {
152
+ void copyText(_repoSlugSource, 'Copied canonical slug source');
153
+ }
154
+ });
155
+
156
+ popover.querySelector('[data-copy="both"]')?.addEventListener('click', () => {
157
+ if (_repoSlugSource) {
158
+ void copyText(`${_repoSlug}\n${_repoSlugSource}`, 'Copied canonical slug + source');
159
+ }
160
+ });
161
+
162
+ popover.addEventListener('keydown', (event) => {
163
+ const buttons = getPopoverButtons();
164
+ if (buttons.length === 0) return;
165
+
166
+ const activeIndex = buttons.findIndex(button => button === document.activeElement);
167
+
168
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
169
+ event.preventDefault();
170
+ focusPopoverButton(activeIndex + 1);
171
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
172
+ event.preventDefault();
173
+ focusPopoverButton(activeIndex - 1);
174
+ } else if (event.key === 'Tab') {
175
+ event.preventDefault();
176
+ focusPopoverButton(activeIndex + (event.shiftKey ? -1 : 1));
177
+ }
178
+ });
179
+
180
+ const firstButton = getPopoverButtons()[0];
181
+ firstButton?.focus();
182
+ }
183
+
184
+ function toggleSlugPopover() {
185
+ if (slugPopoverEl) {
186
+ closeSlugPopover(true);
187
+ return;
188
+ }
189
+ renderSlugPopover();
190
+ }
191
+
192
+ function createBar(): HTMLElement {
193
+ const el = document.createElement('div');
194
+ el.id = 'status-bar';
195
+ el.innerHTML = `
196
+ <div class="sb-left">
197
+ <span class="sb-item sb-repo" id="sbRepo" title="Current repository"></span>
198
+ <button class="sb-item sb-slug" id="sbSlug" title="Canonical remote slug" style="display:none" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="sbSlugPopover"></button>
199
+ <span class="sb-item sb-commit" id="sbCommit" title="Current commit"></span>
200
+ </div>
201
+ <div class="sb-right">
202
+ <span class="sb-item sb-mode" id="sbMode" title="Interaction mode"></span>
203
+ <span class="sb-item sb-selected" id="sbSelected" title="Selected cards"></span>
204
+ <span class="sb-item sb-files" id="sbFiles" title="Total files on canvas"></span>
205
+ <span class="sb-item sb-zoom" id="sbZoom" title="Zoom level (scroll to zoom)"></span>
206
+ </div>
207
+ `;
208
+ return el;
209
+ }
210
+
211
+ function render() {
212
+ if (!bar) return;
213
+
214
+ const repoEl = bar.querySelector('#sbRepo') as HTMLElement;
215
+ const slugEl = bar.querySelector('#sbSlug') as HTMLButtonElement;
216
+ const commitEl = bar.querySelector('#sbCommit') as HTMLElement;
217
+ const modeEl = bar.querySelector('#sbMode') as HTMLElement;
218
+ const selectedEl = bar.querySelector('#sbSelected') as HTMLElement;
219
+ const filesEl = bar.querySelector('#sbFiles') as HTMLElement;
220
+ const zoomEl = bar.querySelector('#sbZoom') as HTMLElement;
221
+
222
+ if (repoEl) {
223
+ repoEl.textContent = _repoName ? `📂 ${_repoName}` : '';
224
+ repoEl.title = _repoPath || 'Current repository';
225
+ }
226
+ if (slugEl) {
227
+ const slugSourceSummary = summarizeSlugSource(_repoSlugSource);
228
+ slugEl.textContent = _repoSlug
229
+ ? `↗ ${_repoSlug}${slugSourceSummary ? ` · ${slugSourceSummary}` : ''}`
230
+ : '';
231
+ slugEl.style.display = _repoSlug ? '' : 'none';
232
+ slugEl.title = _repoSlug
233
+ ? (_repoSlugSource
234
+ ? `Canonical slug: ${_repoSlug}\nSource: ${_repoSlugSource}\nClick or press Enter/Space to inspect · Shift+Click to copy slug + source`
235
+ : `Canonical slug: ${_repoSlug}\nClick or press Enter/Space to inspect`)
236
+ : 'Canonical remote slug';
237
+ slugEl.setAttribute('aria-label', _repoSlugSource
238
+ ? `Canonical slug ${_repoSlug}. Press Enter or Space for details. Shift click copies slug and source.`
239
+ : `Canonical slug ${_repoSlug}. Press Enter or Space for details.`);
240
+ slugTriggerEl = slugEl;
241
+ }
242
+ if (commitEl) commitEl.textContent = _commitHash ? `⊙ ${_commitHash.substring(0, 7)}` : '';
243
+ if (modeEl) {
244
+ modeEl.textContent = `${_mode === 'Advanced' ? '🎯' : '✋'} ${_mode}`;
245
+ modeEl.className = `sb-item sb-mode sb-mode--${_mode.toLowerCase()}`;
246
+ }
247
+ if (selectedEl) {
248
+ selectedEl.textContent = _selectedCount > 0 ? `☑ ${_selectedCount} selected` : '';
249
+ selectedEl.style.display = _selectedCount > 0 ? '' : 'none';
250
+ }
251
+ if (filesEl) filesEl.textContent = `📄 ${_fileCount} files`;
252
+ if (zoomEl) zoomEl.textContent = `🔍 ${Math.round(_zoom * 100)}%`;
253
+
254
+ if (!_repoSlug) closeSlugPopover();
255
+ }
256
+
257
+ function installGlobalSlugPopoverHandlers() {
258
+ document.addEventListener('click', (event) => {
259
+ if (!slugPopoverEl) return;
260
+ const target = event.target as Node | null;
261
+ if (!target) return;
262
+ if (slugPopoverEl.contains(target)) return;
263
+ if ((target as HTMLElement).closest?.('#sbSlug')) return;
264
+ closeSlugPopover();
265
+ });
266
+
267
+ document.addEventListener('keydown', (event) => {
268
+ if (event.key === 'Escape') closeSlugPopover(true);
269
+ });
270
+ }
271
+
272
+ // ─── Public API ──────────────────────────────────────────
273
+
274
+ export function initStatusBar(context: CanvasContext) {
275
+ ctx = context;
276
+ bar = createBar();
277
+
278
+ const canvasArea = document.querySelector('.canvas-area');
279
+ if (canvasArea) {
280
+ canvasArea.parentElement?.insertBefore(bar, canvasArea.nextSibling);
281
+ } else {
282
+ document.body.appendChild(bar);
283
+ }
284
+
285
+ const slugEl = bar.querySelector('#sbSlug') as HTMLButtonElement | null;
286
+ slugTriggerEl = slugEl;
287
+
288
+ slugEl?.addEventListener('click', (event) => {
289
+ const mouseEvent = event as MouseEvent;
290
+ if (mouseEvent.shiftKey) {
291
+ void copyCanonicalSlug(true);
292
+ return;
293
+ }
294
+ toggleSlugPopover();
295
+ });
296
+
297
+ slugEl?.addEventListener('keydown', (event) => {
298
+ const keyboardEvent = event as KeyboardEvent;
299
+ if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
300
+ keyboardEvent.preventDefault();
301
+ toggleSlugPopover();
302
+ } else if (keyboardEvent.key === 'ArrowDown' && !slugPopoverEl) {
303
+ keyboardEvent.preventDefault();
304
+ renderSlugPopover();
305
+ }
306
+ });
307
+
308
+ installGlobalSlugPopoverHandlers();
309
+
310
+ const state = ctx.snap().context;
311
+ _zoom = state.zoom || 1;
312
+ _repoPath = state.repoPath || '';
313
+ _repoName = (_repoPath || '').split('/').pop() || '';
314
+ _repoSlug = '';
315
+ _repoSlugSource = '';
316
+ _fileCount = ctx.fileCards.size;
317
+ _mode = state.mode === 'advanced' ? 'Advanced' : 'Simple';
318
+ _commitHash = state.currentCommitHash || '';
319
+ render();
320
+ }
321
+
322
+ export function updateStatusBarZoom(zoom: number) {
323
+ if (Math.round(zoom * 100) === Math.round(_zoom * 100)) return;
324
+ _zoom = zoom;
325
+ const el = bar?.querySelector('#sbZoom') as HTMLElement;
326
+ if (el) el.textContent = `🔍 ${Math.round(zoom * 100)}%`;
327
+ }
328
+
329
+ export function updateStatusBarFiles(count: number) {
330
+ _fileCount = count;
331
+ const el = bar?.querySelector('#sbFiles') as HTMLElement;
332
+ if (el) el.textContent = `📄 ${count} files`;
333
+ }
334
+
335
+ export function updateStatusBarSelected(count: number) {
336
+ _selectedCount = count;
337
+ const el = bar?.querySelector('#sbSelected') as HTMLElement;
338
+ if (el) {
339
+ el.textContent = count > 0 ? `☑ ${count} selected` : '';
340
+ el.style.display = count > 0 ? '' : 'none';
341
+ }
342
+ }
343
+
344
+ export function updateStatusBarRepo(repoPath: string, canonicalSlug = '', canonicalSource = '') {
345
+ _repoPath = repoPath;
346
+ _repoName = repoPath.split('/').pop() || repoPath.split('\\').pop() || '';
347
+ _repoSlug = canonicalSlug || '';
348
+ _repoSlugSource = canonicalSource || '';
349
+ render();
350
+ }
351
+
352
+ export function updateStatusBarCommit(hash: string) {
353
+ _commitHash = hash;
354
+ const el = bar?.querySelector('#sbCommit') as HTMLElement;
355
+ if (el) el.textContent = hash ? `⊙ ${hash.substring(0, 7)}` : '';
356
+ }
357
+
358
+ export function updateStatusBarMode(mode: string) {
359
+ _mode = mode;
360
+ const el = bar?.querySelector('#sbMode') as HTMLElement;
361
+ if (el) {
362
+ el.textContent = `${mode === 'Advanced' ? '🎯' : '✋'} ${mode}`;
363
+ el.className = `sb-item sb-mode sb-mode--${mode.toLowerCase()}`;
364
+ }
365
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { clearRoleCache } from './role';
3
+ import { createSyncControlsUI } from './sync-controls';
4
+ import { setCanvasContext } from './context';
5
+ import { setupDomTest } from './test-dom';
6
+
7
+ describe('sync controls context source', () => {
8
+ test('push button is wired when shared context exists and no window global is set', () => {
9
+ const handle = setupDomTest({
10
+ url: 'http://localhost:3335/',
11
+ html: '<div class="toolbar-right"></div>',
12
+ });
13
+
14
+ try {
15
+ clearRoleCache();
16
+ const ctx = {
17
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
18
+ positions: new Map([['allfiles:src/index.ts', { x: 1, y: 2 }]]),
19
+ allFilesData: [],
20
+ } as any;
21
+
22
+ setCanvasContext(ctx);
23
+ (window as any).__GITCANVAS_CTX__ = null;
24
+
25
+ const ui = createSyncControlsUI();
26
+ document.body.appendChild(ui);
27
+
28
+ const pushBtn = document.getElementById('pushBtn') as HTMLButtonElement;
29
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
30
+
31
+ expect(pushBtn).toBeTruthy();
32
+ expect(pullBtn).toBeTruthy();
33
+ expect(pushBtn.disabled).toBe(false);
34
+ expect(pullBtn.disabled).toBe(false);
35
+ expect(pushBtn.textContent).toContain('Push');
36
+ expect(pullBtn.textContent).toContain('Pull');
37
+ } finally {
38
+ setCanvasContext(null);
39
+ clearRoleCache();
40
+ handle.cleanup();
41
+ }
42
+ });
43
+ });