gitmaps 1.1.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 (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 +869 -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 +16 -7
  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,869 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import type { Window } from 'happy-dom';
3
+ import { addRecentRepo, getRecentRepos, removeRecentRepo, renderRecentCommitsUI } from './recent-commits';
4
+ import { setCanvasContext } from './context';
5
+ import { installFetchMock, setupDomTest } from './test-dom';
6
+
7
+ describe('recent commits sidebar', () => {
8
+ let window: Window;
9
+ let cleanup: (() => void) | undefined;
10
+
11
+ beforeEach(() => {
12
+ const handle = setupDomTest({
13
+ url: 'http://localhost:3335/',
14
+ html: `
15
+ <div id="recentCommits" style="display:none">
16
+ <div id="recentCommitsList"></div>
17
+ </div>
18
+ <select id="repoSelect">
19
+ <option value="">Choose</option>
20
+ <option value="C:/Code/gitmaps">gitmaps</option>
21
+ <option value="C:/Code/jsx-ai">jsx-ai</option>
22
+ </select>
23
+ <button id="pullBtn">Pull</button>
24
+ `,
25
+ });
26
+ window = handle.window;
27
+ cleanup = handle.cleanup;
28
+ });
29
+
30
+ afterEach(() => {
31
+ try {
32
+ (mock as any).restore?.();
33
+ } catch {}
34
+ setCanvasContext(null);
35
+ cleanup?.();
36
+ });
37
+
38
+ test('normalizes legacy localStorage entries and removes malformed ones', () => {
39
+ localStorage.setItem(
40
+ 'gitcanvas:recentRepos',
41
+ JSON.stringify([
42
+ 'C:/Code/alpha',
43
+ { path: 'C:/Code/beta', loadedAt: 'bad', commitCount: 3 },
44
+ { nope: true },
45
+ '[object HTMLDivElement]',
46
+ ]),
47
+ );
48
+
49
+ const repos = getRecentRepos();
50
+ expect(repos.map((repo) => repo.path)).toEqual(['C:/Code/alpha', 'C:/Code/beta']);
51
+ expect(repos[0]?.commitCount).toBe(0);
52
+ expect(repos[1]?.commitCount).toBe(3);
53
+ });
54
+
55
+ test('renders stable metadata instead of undefined/NaN values', () => {
56
+ localStorage.setItem(
57
+ 'gitcanvas:recentRepos',
58
+ JSON.stringify([{ path: 'C:/Code/epstein-files' }]),
59
+ );
60
+
61
+ renderRecentCommitsUI();
62
+
63
+ const text = document.getElementById('recentCommitsList')?.textContent || '';
64
+ expect(text).toContain('epstein-files');
65
+ expect(text).toContain('0 commits · Just now');
66
+ expect(text).not.toContain('undefined');
67
+ expect(text).not.toContain('NaN');
68
+ });
69
+
70
+ test('addRecentRepo persists object format and rerenders list', () => {
71
+ addRecentRepo('C:/Code/gitmaps', 12);
72
+
73
+ const stored = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
74
+ expect(stored[0]?.path).toBe('C:/Code/gitmaps');
75
+ expect(stored[0]?.commitCount).toBe(12);
76
+
77
+ const text = document.getElementById('recentCommitsList')?.textContent || '';
78
+ expect(text).toContain('gitmaps');
79
+ expect(text).toContain('12 commits');
80
+ });
81
+
82
+ test('recent repos section shows and hides as the list fills and empties', () => {
83
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
84
+
85
+ renderRecentCommitsUI();
86
+ expect(section.style.display).toBe('none');
87
+
88
+ addRecentRepo('C:/Code/gitmaps', 12);
89
+ expect(section.style.display).toBe('block');
90
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('gitmaps');
91
+
92
+ addRecentRepo('C:/Code/jsx-ai', 5);
93
+ expect(section.style.display).toBe('block');
94
+
95
+ removeRecentRepo('C:/Code/gitmaps');
96
+ expect(section.style.display).toBe('block');
97
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('jsx-ai');
98
+ expect(document.getElementById('recentCommitsList')?.textContent || '').not.toContain('gitmaps');
99
+
100
+ removeRecentRepo('C:/Code/jsx-ai');
101
+ expect(section.style.display).toBe('none');
102
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('No recent commits');
103
+ });
104
+
105
+ test('normalization back to empty hides the recent repos section', () => {
106
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
107
+
108
+ localStorage.setItem(
109
+ 'gitcanvas:recentRepos',
110
+ JSON.stringify([{ path: ' ' }, '[object HTMLDivElement]', { nope: true }]),
111
+ );
112
+
113
+ renderRecentCommitsUI();
114
+
115
+ expect(section.style.display).toBe('none');
116
+ expect(document.querySelectorAll('[data-path]').length).toBe(0);
117
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('No recent commits');
118
+ });
119
+
120
+ test('re-adding an existing repo moves it to the front without duplicates', () => {
121
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
122
+
123
+ addRecentRepo('C:/Code/gitmaps', 12);
124
+ addRecentRepo('C:/Code/jsx-ai', 5);
125
+ addRecentRepo('C:/Code/gitmaps', 13);
126
+
127
+ const repos = getRecentRepos();
128
+ expect(section.style.display).toBe('block');
129
+ expect(repos.map((repo) => repo.path)).toEqual(['C:/Code/gitmaps', 'C:/Code/jsx-ai']);
130
+ expect(repos[0]?.commitCount).toBe(13);
131
+
132
+ const stored = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
133
+ expect(stored.map((repo: any) => repo.path)).toEqual(['C:/Code/gitmaps', 'C:/Code/jsx-ai']);
134
+
135
+ const renderedButtons = Array.from(document.querySelectorAll('[data-path]')) as HTMLButtonElement[];
136
+ expect(renderedButtons.map((item) => item.dataset.path)).toEqual(['C:/Code/gitmaps', 'C:/Code/jsx-ai']);
137
+ expect(renderedButtons.filter((item) => item.dataset.path === 'C:/Code/gitmaps')).toHaveLength(1);
138
+ });
139
+
140
+ test('adding more than five repos keeps only the newest five in storage and UI order', () => {
141
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
142
+ const repoPaths = [
143
+ 'C:/Code/repo-1',
144
+ 'C:/Code/repo-2',
145
+ 'C:/Code/repo-3',
146
+ 'C:/Code/repo-4',
147
+ 'C:/Code/repo-5',
148
+ 'C:/Code/repo-6',
149
+ ];
150
+
151
+ repoPaths.forEach((path, index) => addRecentRepo(path, index + 1));
152
+
153
+ const expectedPaths = [
154
+ 'C:/Code/repo-6',
155
+ 'C:/Code/repo-5',
156
+ 'C:/Code/repo-4',
157
+ 'C:/Code/repo-3',
158
+ 'C:/Code/repo-2',
159
+ ];
160
+
161
+ const repos = getRecentRepos();
162
+ expect(section.style.display).toBe('block');
163
+ expect(repos).toHaveLength(5);
164
+ expect(repos.map((repo) => repo.path)).toEqual(expectedPaths);
165
+ expect(repos.map((repo) => repo.commitCount)).toEqual([6, 5, 4, 3, 2]);
166
+
167
+ const stored = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
168
+ expect(stored).toHaveLength(5);
169
+ expect(stored.map((repo: any) => repo.path)).toEqual(expectedPaths);
170
+
171
+ const renderedButtons = Array.from(document.querySelectorAll('[data-path]')) as HTMLButtonElement[];
172
+ expect(renderedButtons).toHaveLength(5);
173
+ expect(renderedButtons.map((item) => item.dataset.path)).toEqual(expectedPaths);
174
+ expect(document.getElementById('recentCommitsList')?.textContent || '').not.toContain('repo-1');
175
+ });
176
+
177
+ test('invalid recent repo JSON clears stale rendered items back to the empty state', () => {
178
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
179
+
180
+ addRecentRepo('C:/Code/gitmaps', 12);
181
+ expect(section.style.display).toBe('block');
182
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(1);
183
+
184
+ localStorage.setItem('gitcanvas:recentRepos', '{not valid json');
185
+ renderRecentCommitsUI();
186
+
187
+ expect(getRecentRepos()).toEqual([]);
188
+ expect(section.style.display).toBe('none');
189
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(0);
190
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('No recent commits');
191
+ });
192
+
193
+ test('non-array recent repo payload clears stale rendered items back to the empty state', () => {
194
+ const section = document.getElementById('recentCommits') as HTMLDivElement;
195
+
196
+ addRecentRepo('C:/Code/jsx-ai', 5);
197
+ expect(section.style.display).toBe('block');
198
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(1);
199
+
200
+ localStorage.setItem('gitcanvas:recentRepos', JSON.stringify({ path: 'C:/Code/gitmaps' }));
201
+ renderRecentCommitsUI();
202
+
203
+ expect(getRecentRepos()).toEqual([]);
204
+ expect(section.style.display).toBe('none');
205
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(0);
206
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('No recent commits');
207
+ });
208
+
209
+ test('clicking Pull without an active repo shows an error toast without entering loading state', async () => {
210
+ const ctx = {
211
+ snap: () => ({ context: { repoPath: '' } }),
212
+ } as any;
213
+ setCanvasContext(ctx);
214
+
215
+ renderRecentCommitsUI();
216
+
217
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
218
+ expect(pullBtn.disabled).toBeFalse();
219
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
220
+
221
+ pullBtn.click();
222
+ await Promise.resolve();
223
+
224
+ expect(pullBtn.disabled).toBeFalse();
225
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
226
+ expect(document.querySelector('.toast.error')?.textContent || '').toContain('No repository loaded');
227
+ });
228
+
229
+ test('clicking a recent repo starts the repo reload flow for that path', async () => {
230
+ const ctx = {
231
+ actor: { send: mock(() => undefined) },
232
+ snap: () => ({ context: { repoPath: '', zoom: 1, offsetX: 0, offsetY: 0, commits: [] }, value: { view: 'allfiles' } }),
233
+ fileCards: new Map(),
234
+ deferredCards: new Map(),
235
+ changedFilePaths: new Set(),
236
+ positions: new Map(),
237
+ hiddenFiles: new Set(),
238
+ allFilesData: [],
239
+ commitFilesData: [],
240
+ canvas: document.createElement('div'),
241
+ canvasViewport: document.createElement('div'),
242
+ svgOverlay: null,
243
+ loadingOverlay: null,
244
+ } as any;
245
+ setCanvasContext(ctx);
246
+
247
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
248
+ repoSelect.value = '';
249
+
250
+ const fetchMock = mock(async (input: string) => {
251
+ expect(input).toBe('/api/repo/load');
252
+ return new Response('boom', { status: 500 });
253
+ });
254
+ const fetchHandle = installFetchMock(fetchMock as any);
255
+
256
+ addRecentRepo('C:/Code/gitmaps', 12);
257
+
258
+ const item = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
259
+ expect(item).toBeTruthy();
260
+
261
+ try {
262
+ item.click();
263
+ await Promise.resolve();
264
+ await Promise.resolve();
265
+ } finally {
266
+ fetchHandle.restore();
267
+ }
268
+
269
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
270
+ expect(ctx.actor.send).toHaveBeenCalledWith({ type: 'LOAD_REPO', path: 'C:/Code/gitmaps' });
271
+ expect(fetchMock).toHaveBeenCalledTimes(1);
272
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ });
276
+ expect(String(fetchMock.mock.calls[0]?.[1]?.body)).toContain('C:/Code/gitmaps');
277
+ });
278
+
279
+ test('clicking a recent repo uses onRepoReady when provided', async () => {
280
+ const onRepoReady = mock(() => undefined);
281
+ const ctx = {
282
+ onRepoReady,
283
+ actor: { send: mock(() => undefined) },
284
+ snap: () => ({ context: { repoPath: '', zoom: 1, offsetX: 0, offsetY: 0, commits: [] }, value: { view: 'allfiles' } }),
285
+ fileCards: new Map(),
286
+ deferredCards: new Map(),
287
+ changedFilePaths: new Set(),
288
+ positions: new Map(),
289
+ hiddenFiles: new Set(),
290
+ allFilesData: [],
291
+ commitFilesData: [],
292
+ canvas: document.createElement('div'),
293
+ canvasViewport: document.createElement('div'),
294
+ svgOverlay: null,
295
+ loadingOverlay: null,
296
+ } as any;
297
+ setCanvasContext(ctx);
298
+
299
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
300
+ repoSelect.value = '';
301
+
302
+ addRecentRepo('C:/Code/jsx-ai', 5);
303
+
304
+ const item = document.querySelector('[data-path="C:/Code/jsx-ai"]') as HTMLButtonElement;
305
+ expect(item).toBeTruthy();
306
+
307
+ item.click();
308
+ await Promise.resolve();
309
+
310
+ expect(repoSelect.value).toBe('C:/Code/jsx-ai');
311
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/jsx-ai');
312
+ expect(ctx.actor.send).not.toHaveBeenCalled();
313
+ });
314
+
315
+ test('rerendering the recent repo list preserves click-through behavior for newly rendered entries', async () => {
316
+ const onRepoReady = mock(() => undefined);
317
+ const ctx = {
318
+ onRepoReady,
319
+ actor: { send: mock(() => undefined) },
320
+ snap: () => ({ context: { repoPath: '', zoom: 1, offsetX: 0, offsetY: 0, commits: [] }, value: { view: 'allfiles' } }),
321
+ fileCards: new Map(),
322
+ deferredCards: new Map(),
323
+ changedFilePaths: new Set(),
324
+ positions: new Map(),
325
+ hiddenFiles: new Set(),
326
+ allFilesData: [],
327
+ commitFilesData: [],
328
+ canvas: document.createElement('div'),
329
+ canvasViewport: document.createElement('div'),
330
+ svgOverlay: null,
331
+ loadingOverlay: null,
332
+ } as any;
333
+ setCanvasContext(ctx);
334
+
335
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
336
+ repoSelect.value = '';
337
+
338
+ addRecentRepo('C:/Code/gitmaps', 12);
339
+ renderRecentCommitsUI();
340
+ addRecentRepo('C:/Code/jsx-ai', 5);
341
+ renderRecentCommitsUI();
342
+
343
+ const item = document.querySelector('[data-path="C:/Code/jsx-ai"]') as HTMLButtonElement;
344
+ expect(item).toBeTruthy();
345
+
346
+ item.click();
347
+ await Promise.resolve();
348
+
349
+ expect(repoSelect.value).toBe('C:/Code/jsx-ai');
350
+ expect(onRepoReady).toHaveBeenCalledTimes(1);
351
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/jsx-ai');
352
+ expect(ctx.actor.send).not.toHaveBeenCalled();
353
+ });
354
+
355
+ test('clicking a recent repo stays inert when there is no canvas context', async () => {
356
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
357
+ repoSelect.value = '';
358
+
359
+ const fetchMock = mock(async () => new Response('{}'));
360
+ const fetchHandle = installFetchMock(fetchMock as any);
361
+
362
+ addRecentRepo('C:/Code/gitmaps', 12);
363
+
364
+ const item = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
365
+ expect(item).toBeTruthy();
366
+
367
+ try {
368
+ item.click();
369
+ await Promise.resolve();
370
+ } finally {
371
+ fetchHandle.restore();
372
+ }
373
+
374
+ expect(repoSelect.value).toBe('');
375
+ expect(fetchMock).not.toHaveBeenCalled();
376
+ });
377
+
378
+ test('malformed recent entries render empty state with no clickable repo path', () => {
379
+ localStorage.setItem(
380
+ 'gitcanvas:recentRepos',
381
+ JSON.stringify([{ path: ' ' }, '[object HTMLDivElement]', { nope: true }]),
382
+ );
383
+
384
+ renderRecentCommitsUI();
385
+
386
+ expect(document.querySelectorAll('[data-path]').length).toBe(0);
387
+ expect(document.getElementById('recentCommitsList')?.textContent || '').toContain('No recent commits');
388
+ expect((document.getElementById('recentCommits') as HTMLDivElement | null)?.style.display).toBe('none');
389
+ });
390
+
391
+ test('clicking Pull with an active repo shows loading state, calls loadRepository, and restores the button on success', async () => {
392
+ let resolveLoad!: () => void;
393
+ const loadPromise = new Promise<void>((resolve) => {
394
+ resolveLoad = resolve;
395
+ });
396
+ const loadRepositoryMock = mock(() => loadPromise);
397
+ const originalRepoModule = require('./repo');
398
+ mock.module('./repo', () => ({
399
+ ...originalRepoModule,
400
+ loadRepository: loadRepositoryMock,
401
+ }));
402
+
403
+ const ctx = {
404
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
405
+ } as any;
406
+ setCanvasContext(ctx);
407
+
408
+ renderRecentCommitsUI();
409
+
410
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
411
+
412
+ try {
413
+ pullBtn.click();
414
+ await Promise.resolve();
415
+
416
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
417
+ expect(pullBtn.disabled).toBeTrue();
418
+ expect((pullBtn.textContent || '').includes('Pulling...')).toBeTrue();
419
+
420
+ resolveLoad();
421
+ await Promise.resolve();
422
+ await Promise.resolve();
423
+
424
+ expect(pullBtn.disabled).toBeFalse();
425
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
426
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
427
+ } finally {
428
+ (mock as any).restore?.();
429
+ }
430
+ });
431
+
432
+ test('clicking Pull with an active repo restores the button and shows an error toast when loadRepository rejects', async () => {
433
+ let rejectLoad!: (error: Error) => void;
434
+ const loadPromise = new Promise<void>((_, reject) => {
435
+ rejectLoad = reject;
436
+ });
437
+ const loadRepositoryMock = mock(() => loadPromise);
438
+ const originalRepoModule = require('./repo');
439
+ mock.module('./repo', () => ({
440
+ ...originalRepoModule,
441
+ loadRepository: loadRepositoryMock,
442
+ }));
443
+
444
+ const ctx = {
445
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
446
+ } as any;
447
+ setCanvasContext(ctx);
448
+
449
+ renderRecentCommitsUI();
450
+
451
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
452
+
453
+ try {
454
+ pullBtn.click();
455
+ await Promise.resolve();
456
+
457
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
458
+ expect(pullBtn.disabled).toBeTrue();
459
+ expect((pullBtn.textContent || '').includes('Pulling...')).toBeTrue();
460
+
461
+ rejectLoad(new Error('network down'));
462
+ await Promise.resolve();
463
+ await Promise.resolve();
464
+
465
+ expect(pullBtn.disabled).toBeFalse();
466
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
467
+ expect(document.querySelector('.toast.error')?.textContent || '').toContain('Pull failed: network down');
468
+ } finally {
469
+ (mock as any).restore?.();
470
+ }
471
+ });
472
+
473
+ test('repeated renderRecentCommitsUI calls do not double-bind the Pull button handler', async () => {
474
+ const loadRepositoryMock = mock(async () => undefined);
475
+ const originalRepoModule = require('./repo');
476
+ mock.module('./repo', () => ({
477
+ ...originalRepoModule,
478
+ loadRepository: loadRepositoryMock,
479
+ }));
480
+
481
+ const ctx = {
482
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
483
+ } as any;
484
+ setCanvasContext(ctx);
485
+
486
+ renderRecentCommitsUI();
487
+ renderRecentCommitsUI();
488
+ renderRecentCommitsUI();
489
+
490
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
491
+
492
+ try {
493
+ pullBtn.click();
494
+ await Promise.resolve();
495
+ await Promise.resolve();
496
+
497
+ expect(pullBtn.dataset.bound).toBe('true');
498
+ expect(loadRepositoryMock).toHaveBeenCalledTimes(1);
499
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
500
+ expect(pullBtn.disabled).toBeFalse();
501
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
502
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
503
+ } finally {
504
+ (mock as any).restore?.();
505
+ }
506
+ });
507
+
508
+ test('replacing the Pull button DOM node and rerendering binds the handler exactly once on the new element', async () => {
509
+ const loadRepositoryMock = mock(async () => undefined);
510
+ const originalRepoModule = require('./repo');
511
+ mock.module('./repo', () => ({
512
+ ...originalRepoModule,
513
+ loadRepository: loadRepositoryMock,
514
+ }));
515
+
516
+ const ctx = {
517
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
518
+ } as any;
519
+ setCanvasContext(ctx);
520
+
521
+ renderRecentCommitsUI();
522
+
523
+ const originalPullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
524
+ const replacement = document.createElement('button');
525
+ replacement.id = 'pullBtn';
526
+ replacement.textContent = 'Pull';
527
+ originalPullBtn.replaceWith(replacement);
528
+
529
+ renderRecentCommitsUI();
530
+
531
+ const reboundPullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
532
+
533
+ try {
534
+ expect(reboundPullBtn).toBeTruthy();
535
+ expect(reboundPullBtn).not.toBe(originalPullBtn);
536
+ expect(reboundPullBtn.dataset.bound).toBe('true');
537
+
538
+ reboundPullBtn.click();
539
+ await Promise.resolve();
540
+ await Promise.resolve();
541
+
542
+ expect(loadRepositoryMock).toHaveBeenCalledTimes(1);
543
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
544
+ expect(reboundPullBtn.disabled).toBeFalse();
545
+ expect((reboundPullBtn.textContent || '').trim()).toBe('Pull');
546
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
547
+ } finally {
548
+ (mock as any).restore?.();
549
+ }
550
+ });
551
+
552
+ test('rerendering the recent repo list does not break the already-bound Pull button behavior', async () => {
553
+ const loadRepositoryMock = mock(async () => undefined);
554
+ const originalRepoModule = require('./repo');
555
+ mock.module('./repo', () => ({
556
+ ...originalRepoModule,
557
+ loadRepository: loadRepositoryMock,
558
+ }));
559
+
560
+ const ctx = {
561
+ snap: () => ({ context: { repoPath: 'C:/Code/gitmaps' } }),
562
+ } as any;
563
+ setCanvasContext(ctx);
564
+
565
+ renderRecentCommitsUI();
566
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
567
+
568
+ addRecentRepo('C:/Code/gitmaps', 12);
569
+ addRecentRepo('C:/Code/jsx-ai', 5);
570
+
571
+ try {
572
+ expect(pullBtn.dataset.bound).toBe('true');
573
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(2);
574
+
575
+ pullBtn.click();
576
+ await Promise.resolve();
577
+ await Promise.resolve();
578
+
579
+ expect(loadRepositoryMock).toHaveBeenCalledTimes(1);
580
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
581
+ expect(pullBtn.disabled).toBeFalse();
582
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
583
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
584
+ } finally {
585
+ (mock as any).restore?.();
586
+ }
587
+ });
588
+
589
+ test('pull-button and click-through behavior both survive the same recent-list rerender sequence', async () => {
590
+ const onRepoReady = mock((path: string) => {
591
+ currentRepoPath = path;
592
+ });
593
+ let currentRepoPath = 'C:/Code/gitmaps';
594
+ const loadRepositoryMock = mock(async () => undefined);
595
+ const originalRepoModule = require('./repo');
596
+ mock.module('./repo', () => ({
597
+ ...originalRepoModule,
598
+ loadRepository: loadRepositoryMock,
599
+ }));
600
+
601
+ const ctx = {
602
+ onRepoReady,
603
+ actor: { send: mock(() => undefined) },
604
+ snap: () => ({
605
+ context: { repoPath: currentRepoPath, zoom: 1, offsetX: 0, offsetY: 0, commits: [] },
606
+ value: { view: 'allfiles' },
607
+ }),
608
+ fileCards: new Map(),
609
+ deferredCards: new Map(),
610
+ changedFilePaths: new Set(),
611
+ positions: new Map(),
612
+ hiddenFiles: new Set(),
613
+ allFilesData: [],
614
+ commitFilesData: [],
615
+ canvas: document.createElement('div'),
616
+ canvasViewport: document.createElement('div'),
617
+ svgOverlay: null,
618
+ loadingOverlay: null,
619
+ } as any;
620
+ setCanvasContext(ctx);
621
+
622
+ renderRecentCommitsUI();
623
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
624
+
625
+ addRecentRepo('C:/Code/gitmaps', 12);
626
+ renderRecentCommitsUI();
627
+ addRecentRepo('C:/Code/jsx-ai', 5);
628
+ renderRecentCommitsUI();
629
+
630
+ const item = document.querySelector('[data-path="C:/Code/jsx-ai"]') as HTMLButtonElement;
631
+
632
+ try {
633
+ expect(pullBtn.dataset.bound).toBe('true');
634
+ expect(item).toBeTruthy();
635
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(2);
636
+
637
+ item.click();
638
+ await Promise.resolve();
639
+
640
+ expect(onRepoReady).toHaveBeenCalledTimes(1);
641
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/jsx-ai');
642
+ expect((document.getElementById('repoSelect') as HTMLSelectElement).value).toBe('C:/Code/jsx-ai');
643
+
644
+ pullBtn.click();
645
+ await Promise.resolve();
646
+ await Promise.resolve();
647
+
648
+ expect(loadRepositoryMock).toHaveBeenCalledTimes(1);
649
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/jsx-ai');
650
+ expect(pullBtn.disabled).toBeFalse();
651
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
652
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
653
+ expect(ctx.actor.send).not.toHaveBeenCalled();
654
+ } finally {
655
+ (mock as any).restore?.();
656
+ }
657
+ });
658
+
659
+ test('multiple rerender and click cycles keep using fresh recent-repo handlers without duplicate handoffs', async () => {
660
+ const onRepoReady = mock((path: string) => {
661
+ currentRepoPath = path;
662
+ });
663
+ let currentRepoPath = '';
664
+ const ctx = {
665
+ onRepoReady,
666
+ actor: { send: mock(() => undefined) },
667
+ snap: () => ({
668
+ context: { repoPath: currentRepoPath, zoom: 1, offsetX: 0, offsetY: 0, commits: [] },
669
+ value: { view: 'allfiles' },
670
+ }),
671
+ fileCards: new Map(),
672
+ deferredCards: new Map(),
673
+ changedFilePaths: new Set(),
674
+ positions: new Map(),
675
+ hiddenFiles: new Set(),
676
+ allFilesData: [],
677
+ commitFilesData: [],
678
+ canvas: document.createElement('div'),
679
+ canvasViewport: document.createElement('div'),
680
+ svgOverlay: null,
681
+ loadingOverlay: null,
682
+ } as any;
683
+ setCanvasContext(ctx);
684
+
685
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
686
+ repoSelect.value = '';
687
+
688
+ addRecentRepo('C:/Code/gitmaps', 12);
689
+ let firstCycleItem = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
690
+ expect(firstCycleItem).toBeTruthy();
691
+
692
+ firstCycleItem.click();
693
+ await Promise.resolve();
694
+
695
+ expect(onRepoReady).toHaveBeenCalledTimes(1);
696
+ expect(onRepoReady).toHaveBeenNthCalledWith(1, 'C:/Code/gitmaps');
697
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
698
+
699
+ addRecentRepo('C:/Code/jsx-ai', 5);
700
+ renderRecentCommitsUI();
701
+
702
+ const secondCycleItem = document.querySelector('[data-path="C:/Code/jsx-ai"]') as HTMLButtonElement;
703
+ expect(secondCycleItem).toBeTruthy();
704
+ expect(secondCycleItem).not.toBe(firstCycleItem);
705
+
706
+ secondCycleItem.click();
707
+ await Promise.resolve();
708
+
709
+ expect(onRepoReady).toHaveBeenCalledTimes(2);
710
+ expect(onRepoReady).toHaveBeenNthCalledWith(2, 'C:/Code/jsx-ai');
711
+ expect(repoSelect.value).toBe('C:/Code/jsx-ai');
712
+
713
+ addRecentRepo('C:/Code/gitmaps', 13);
714
+ renderRecentCommitsUI();
715
+
716
+ const thirdCycleItem = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
717
+ expect(thirdCycleItem).toBeTruthy();
718
+ expect(thirdCycleItem).not.toBe(firstCycleItem);
719
+
720
+ thirdCycleItem.click();
721
+ await Promise.resolve();
722
+
723
+ expect(onRepoReady).toHaveBeenCalledTimes(3);
724
+ expect(onRepoReady).toHaveBeenNthCalledWith(3, 'C:/Code/gitmaps');
725
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
726
+ expect(currentRepoPath).toBe('C:/Code/gitmaps');
727
+ expect(ctx.actor.send).not.toHaveBeenCalled();
728
+ });
729
+
730
+ test('removing a recent repo after repeated rerenders clears stale entries and preserves remaining click-through behavior', async () => {
731
+ const onRepoReady = mock((path: string) => {
732
+ currentRepoPath = path;
733
+ });
734
+ let currentRepoPath = '';
735
+ const ctx = {
736
+ onRepoReady,
737
+ actor: { send: mock(() => undefined) },
738
+ snap: () => ({
739
+ context: { repoPath: currentRepoPath, zoom: 1, offsetX: 0, offsetY: 0, commits: [] },
740
+ value: { view: 'allfiles' },
741
+ }),
742
+ fileCards: new Map(),
743
+ deferredCards: new Map(),
744
+ changedFilePaths: new Set(),
745
+ positions: new Map(),
746
+ hiddenFiles: new Set(),
747
+ allFilesData: [],
748
+ commitFilesData: [],
749
+ canvas: document.createElement('div'),
750
+ canvasViewport: document.createElement('div'),
751
+ svgOverlay: null,
752
+ loadingOverlay: null,
753
+ } as any;
754
+ setCanvasContext(ctx);
755
+
756
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
757
+ repoSelect.value = '';
758
+
759
+ addRecentRepo('C:/Code/gitmaps', 12);
760
+ addRecentRepo('C:/Code/jsx-ai', 5);
761
+ renderRecentCommitsUI();
762
+
763
+ const firstJsxItem = document.querySelector('[data-path="C:/Code/jsx-ai"]') as HTMLButtonElement;
764
+ expect(firstJsxItem).toBeTruthy();
765
+
766
+ firstJsxItem.click();
767
+ await Promise.resolve();
768
+
769
+ expect(onRepoReady).toHaveBeenCalledTimes(1);
770
+ expect(onRepoReady).toHaveBeenLastCalledWith('C:/Code/jsx-ai');
771
+ expect(repoSelect.value).toBe('C:/Code/jsx-ai');
772
+
773
+ addRecentRepo('C:/Code/gitmaps', 13);
774
+ renderRecentCommitsUI();
775
+ removeRecentRepo('C:/Code/jsx-ai');
776
+ renderRecentCommitsUI();
777
+
778
+ expect(document.querySelector('[data-path="C:/Code/jsx-ai"]')).toBeNull();
779
+
780
+ const remainingItem = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
781
+ expect(remainingItem).toBeTruthy();
782
+ expect(document.querySelectorAll('[data-path]')).toHaveLength(1);
783
+ expect(getRecentRepos().map((repo) => repo.path)).toEqual(['C:/Code/gitmaps']);
784
+
785
+ remainingItem.click();
786
+ await Promise.resolve();
787
+
788
+ expect(onRepoReady).toHaveBeenCalledTimes(2);
789
+ expect(onRepoReady).toHaveBeenNthCalledWith(2, 'C:/Code/gitmaps');
790
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
791
+ expect(currentRepoPath).toBe('C:/Code/gitmaps');
792
+ expect(ctx.actor.send).not.toHaveBeenCalled();
793
+ });
794
+
795
+ test('after rerenders and recent-repo removal, Pull uses the surviving clicked entry as the active repo path', async () => {
796
+ const loadRepositoryMock = mock(async () => undefined);
797
+ const originalRepoModule = require('./repo');
798
+ mock.module('./repo', () => ({
799
+ ...originalRepoModule,
800
+ loadRepository: loadRepositoryMock,
801
+ }));
802
+
803
+ let currentRepoPath = 'C:/Code/jsx-ai';
804
+ const onRepoReady = mock((path: string) => {
805
+ currentRepoPath = path;
806
+ });
807
+ const ctx = {
808
+ onRepoReady,
809
+ actor: { send: mock(() => undefined) },
810
+ snap: () => ({
811
+ context: { repoPath: currentRepoPath, zoom: 1, offsetX: 0, offsetY: 0, commits: [] },
812
+ value: { view: 'allfiles' },
813
+ }),
814
+ fileCards: new Map(),
815
+ deferredCards: new Map(),
816
+ changedFilePaths: new Set(),
817
+ positions: new Map(),
818
+ hiddenFiles: new Set(),
819
+ allFilesData: [],
820
+ commitFilesData: [],
821
+ canvas: document.createElement('div'),
822
+ canvasViewport: document.createElement('div'),
823
+ svgOverlay: null,
824
+ loadingOverlay: null,
825
+ } as any;
826
+ setCanvasContext(ctx);
827
+
828
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
829
+ repoSelect.value = '';
830
+
831
+ renderRecentCommitsUI();
832
+ addRecentRepo('C:/Code/gitmaps', 12);
833
+ addRecentRepo('C:/Code/jsx-ai', 5);
834
+ renderRecentCommitsUI();
835
+ removeRecentRepo('C:/Code/jsx-ai');
836
+ renderRecentCommitsUI();
837
+
838
+ const remainingItem = document.querySelector('[data-path="C:/Code/gitmaps"]') as HTMLButtonElement;
839
+ const pullBtn = document.getElementById('pullBtn') as HTMLButtonElement;
840
+
841
+ try {
842
+ expect(document.querySelector('[data-path="C:/Code/jsx-ai"]')).toBeNull();
843
+ expect(remainingItem).toBeTruthy();
844
+ expect(pullBtn.dataset.bound).toBe('true');
845
+
846
+ remainingItem.click();
847
+ await Promise.resolve();
848
+
849
+ expect(onRepoReady).toHaveBeenCalledTimes(1);
850
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/gitmaps');
851
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
852
+ expect(currentRepoPath).toBe('C:/Code/gitmaps');
853
+
854
+ pullBtn.click();
855
+ await Promise.resolve();
856
+ await Promise.resolve();
857
+
858
+ expect(loadRepositoryMock).toHaveBeenCalledTimes(1);
859
+ expect(loadRepositoryMock).toHaveBeenCalledWith(ctx, 'C:/Code/gitmaps');
860
+ expect(loadRepositoryMock).not.toHaveBeenCalledWith(ctx, 'C:/Code/jsx-ai');
861
+ expect(pullBtn.disabled).toBeFalse();
862
+ expect((pullBtn.textContent || '').trim()).toBe('Pull');
863
+ expect(document.querySelector('.toast.success')?.textContent || '').toContain('Pulled latest commits');
864
+ expect(ctx.actor.send).not.toHaveBeenCalled();
865
+ } finally {
866
+ (mock as any).restore?.();
867
+ }
868
+ });
869
+ });