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.
Files changed (121) hide show
  1. package/README.md +267 -118
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/analytics.db +0 -0
  5. package/app/api/analytics/route.ts +64 -0
  6. package/app/api/auth/positions/route.ts +95 -33
  7. package/app/api/build-info/route.ts +19 -0
  8. package/app/api/chat/route.ts +13 -2
  9. package/app/api/og-image/route.ts +14 -0
  10. package/app/api/repo/file-content/route.ts +73 -20
  11. package/app/api/repo/load/route.test.ts +62 -0
  12. package/app/api/repo/load/route.ts +41 -1
  13. package/app/api/repo/pdf-thumb/route.ts +127 -0
  14. package/app/api/repo/resolve-slug/route.ts +51 -0
  15. package/app/api/repo/tree/route.ts +188 -104
  16. package/app/api/version/route.ts +26 -0
  17. package/app/globals.css +5706 -4938
  18. package/app/layout.tsx +1279 -490
  19. package/app/lib/auto-arrange.test.ts +158 -0
  20. package/app/lib/auto-arrange.ts +147 -0
  21. package/app/lib/canvas-export.ts +358 -358
  22. package/app/lib/canvas.ts +625 -564
  23. package/app/lib/cards.tsx +1361 -916
  24. package/app/lib/chat.tsx +65 -9
  25. package/app/lib/code-editor.ts +86 -2
  26. package/app/lib/context.test.ts +32 -0
  27. package/app/lib/context.ts +19 -3
  28. package/app/lib/cursor-sharing.ts +34 -0
  29. package/app/lib/events.tsx +71 -93
  30. package/app/lib/export-canvas.ts +287 -0
  31. package/app/lib/file-card-plugin.ts +148 -148
  32. package/app/lib/file-modal.tsx +49 -0
  33. package/app/lib/file-preview.ts +486 -427
  34. package/app/lib/github-import.test.ts +424 -0
  35. package/app/lib/initial-route-hydration.test.ts +283 -0
  36. package/app/lib/initial-route-hydration.ts +202 -0
  37. package/app/lib/landing-reset.test.ts +99 -0
  38. package/app/lib/landing-reset.ts +106 -0
  39. package/app/lib/landing-shell.test.ts +75 -0
  40. package/app/lib/large-repo-optimization.ts +37 -0
  41. package/app/lib/layout-snapshots.ts +320 -0
  42. package/app/lib/loading.test.ts +69 -0
  43. package/app/lib/loading.tsx +160 -45
  44. package/app/lib/mount-cleanup.test.ts +52 -0
  45. package/app/lib/mount-cleanup.ts +34 -0
  46. package/app/lib/mount-init.test.ts +123 -0
  47. package/app/lib/mount-init.ts +107 -0
  48. package/app/lib/mount-lifecycle.test.ts +39 -0
  49. package/app/lib/mount-lifecycle.ts +12 -0
  50. package/app/lib/mount-route-wiring.test.ts +87 -0
  51. package/app/lib/mount-route-wiring.ts +84 -0
  52. package/app/lib/multi-repo.ts +14 -0
  53. package/app/lib/onboarding-tutorial.ts +278 -0
  54. package/app/lib/positions.ts +190 -121
  55. package/app/lib/recent-commits.test.ts +947 -0
  56. package/app/lib/recent-commits.ts +227 -0
  57. package/app/lib/repo-handoff.test.ts +23 -0
  58. package/app/lib/repo-handoff.ts +16 -0
  59. package/app/lib/repo-progressive.ts +119 -0
  60. package/app/lib/repo-select.test.ts +61 -0
  61. package/app/lib/repo-select.ts +74 -0
  62. package/app/lib/repo.tsx +1383 -987
  63. package/app/lib/role.ts +228 -0
  64. package/app/lib/route-catchall.test.ts +27 -0
  65. package/app/lib/route-repo-entry.test.ts +95 -0
  66. package/app/lib/route-repo-entry.ts +36 -0
  67. package/app/lib/router-contract.test.ts +22 -0
  68. package/app/lib/router-contract.ts +19 -0
  69. package/app/lib/shared-layout.test.ts +86 -0
  70. package/app/lib/shared-layout.ts +82 -0
  71. package/app/lib/status-bar.test.ts +118 -0
  72. package/app/lib/status-bar.ts +365 -128
  73. package/app/lib/sync-controls.test.ts +43 -0
  74. package/app/lib/sync-controls.tsx +303 -0
  75. package/app/lib/test-dom.ts +145 -0
  76. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  77. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  78. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  79. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  80. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  81. package/app/lib/transclusion-smoke.test.ts +163 -0
  82. package/app/lib/tutorial.ts +301 -0
  83. package/app/lib/version.ts +93 -0
  84. package/app/lib/viewport-culling.ts +740 -735
  85. package/app/lib/virtual-files.ts +456 -0
  86. package/app/lib/webgl-text.ts +189 -0
  87. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
  88. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  89. package/app/og-image.png +0 -0
  90. package/app/page.client.tsx +70 -269
  91. package/app/page.tsx +15 -16
  92. package/app/state/machine.js +13 -0
  93. package/package.json +84 -75
  94. package/server.ts +10 -0
  95. package/app/[owner]/[repo]/page.tsx +0 -6
  96. package/app/[slug]/page.tsx +0 -6
  97. package/packages/galaxydraw/README.md +0 -296
  98. package/packages/galaxydraw/banner.png +0 -0
  99. package/packages/galaxydraw/demo/build-static.ts +0 -100
  100. package/packages/galaxydraw/demo/client.ts +0 -154
  101. package/packages/galaxydraw/demo/dist/client.js +0 -8
  102. package/packages/galaxydraw/demo/index.html +0 -256
  103. package/packages/galaxydraw/demo/server.ts +0 -96
  104. package/packages/galaxydraw/dist/index.js +0 -984
  105. package/packages/galaxydraw/dist/index.js.map +0 -16
  106. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  109. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  110. package/packages/galaxydraw/package.json +0 -49
  111. package/packages/galaxydraw/perf.test.ts +0 -284
  112. package/packages/galaxydraw/src/core/cards.ts +0 -435
  113. package/packages/galaxydraw/src/core/engine.ts +0 -339
  114. package/packages/galaxydraw/src/core/events.ts +0 -81
  115. package/packages/galaxydraw/src/core/layout.ts +0 -136
  116. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  117. package/packages/galaxydraw/src/core/state.ts +0 -177
  118. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  119. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  120. package/packages/galaxydraw/src/index.ts +0 -40
  121. 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
+ });