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,424 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { setupGithubImport } from './events';
3
+ import { installFetchMock, installWindowOpenMock, setupDomTest } from './test-dom';
4
+
5
+ function makeSseResponse(chunks: string[]): Response {
6
+ const stream = new ReadableStream({
7
+ start(controller) {
8
+ const enc = new TextEncoder();
9
+ for (const chunk of chunks) controller.enqueue(enc.encode(chunk));
10
+ controller.close();
11
+ },
12
+ });
13
+ return new Response(stream, {
14
+ headers: { 'Content-Type': 'text/event-stream' },
15
+ });
16
+ }
17
+
18
+ describe('GitHub import modal smoke', () => {
19
+ let cleanup: (() => void) | undefined;
20
+
21
+ beforeEach(() => {
22
+ const handle = setupDomTest({
23
+ url: 'http://localhost:3335/',
24
+ html: `
25
+ <button id="githubImportBtn">Import GitHub</button>
26
+ <div class="github-modal" id="githubModal">
27
+ <div class="github-modal-backdrop"></div>
28
+ <div class="github-modal-content">
29
+ <button id="githubModalClose">×</button>
30
+ <input id="githubUserInput" />
31
+ <select id="githubSortSelect">
32
+ <option value="updated">Recently Updated</option>
33
+ <option value="stars">Most Stars</option>
34
+ <option value="name">Name A→Z</option>
35
+ </select>
36
+ <button id="githubSearchBtn">Search</button>
37
+ <div id="githubUrlCloneRow" style="display:none">
38
+ <span id="githubDetectedUrl"></span>
39
+ <button id="githubUrlCloneBtn">Clone & Open</button>
40
+ </div>
41
+ <div id="githubFilterRow" style="display:none">
42
+ <input id="githubRepoFilter" />
43
+ </div>
44
+ <div id="githubProfile" style="display:none"></div>
45
+ <div id="githubReposGrid"></div>
46
+ <div id="githubPagination" style="display:none">
47
+ <button id="githubPrevPage"></button>
48
+ <span id="githubPageInfo"></span>
49
+ <button id="githubNextPage"></button>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <div id="cloneStatus"></div>
54
+ <select id="repoSelect"></select>
55
+ `,
56
+ });
57
+ cleanup = handle.cleanup;
58
+ });
59
+
60
+ afterEach(() => {
61
+ cleanup?.();
62
+ });
63
+
64
+ test('opens, searches, renders repos, and filters results', async () => {
65
+ const fetchMock = mock(async (input: string) => {
66
+ expect(input).toBe('/api/github/repos?user=7flash&page=1&sort=updated');
67
+ return new Response(JSON.stringify({
68
+ profile: {
69
+ login: '7flash',
70
+ name: '7flash',
71
+ public_repos: 2,
72
+ type: 'User',
73
+ avatar_url: 'https://example.com/avatar.png',
74
+ bio: 'builder',
75
+ },
76
+ repos: [
77
+ {
78
+ name: 'gitmaps',
79
+ clone_url: 'https://github.com/7flash/gitmaps.git',
80
+ description: 'Spatial code explorer',
81
+ language: 'TypeScript',
82
+ size: 2048,
83
+ updated_at: new Date().toISOString(),
84
+ stars: 10,
85
+ },
86
+ {
87
+ name: 'jsx-ai',
88
+ clone_url: 'https://github.com/7flash/jsx-ai.git',
89
+ description: 'JSX interface for structured LLM calls',
90
+ language: 'TypeScript',
91
+ size: 512,
92
+ updated_at: new Date().toISOString(),
93
+ stars: 5,
94
+ },
95
+ ],
96
+ page: 1,
97
+ hasNext: true,
98
+ hasPrev: false,
99
+ }), {
100
+ headers: { 'Content-Type': 'application/json' },
101
+ });
102
+ });
103
+
104
+ const openMock = mock(() => null as any);
105
+ const fetchHandle = installFetchMock(fetchMock as any);
106
+ const openHandle = installWindowOpenMock(openMock as any);
107
+
108
+ try {
109
+ setupGithubImport({} as any);
110
+
111
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
112
+ const modal = document.getElementById('githubModal') as HTMLElement;
113
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
114
+ const searchBtn = document.getElementById('githubSearchBtn') as HTMLButtonElement;
115
+ const profile = document.getElementById('githubProfile') as HTMLElement;
116
+ const grid = document.getElementById('githubReposGrid') as HTMLElement;
117
+ const filterRow = document.getElementById('githubFilterRow') as HTMLElement;
118
+ const filterInput = document.getElementById('githubRepoFilter') as HTMLInputElement;
119
+ const pageInfo = document.getElementById('githubPageInfo') as HTMLElement;
120
+ const nextBtn = document.getElementById('githubNextPage') as HTMLButtonElement;
121
+
122
+ openBtn.click();
123
+ expect(modal.classList.contains('active')).toBe(true);
124
+
125
+ userInput.value = '7flash';
126
+ searchBtn.click();
127
+ await Promise.resolve();
128
+ await Promise.resolve();
129
+
130
+ expect(fetchMock).toHaveBeenCalledTimes(1);
131
+ expect(localStorage.getItem('gitcanvas:lastGithubUser')).toBe('7flash');
132
+ expect(profile.style.display).toBe('flex');
133
+ expect(profile.textContent).toContain('@7flash');
134
+ expect(grid.textContent).toContain('gitmaps');
135
+ expect(grid.textContent).toContain('jsx-ai');
136
+ expect(filterRow.style.display).toBe('flex');
137
+ expect(pageInfo.textContent).toBe('Page 1');
138
+ expect(nextBtn.disabled).toBe(false);
139
+
140
+ filterInput.value = 'jsx';
141
+ filterInput.dispatchEvent(new window.Event('input', { bubbles: true }));
142
+
143
+ const cards = Array.from(document.querySelectorAll('.github-repo-card')) as HTMLElement[];
144
+ const visibleNames = cards.filter(card => card.style.display !== 'none').map(card => card.dataset.name);
145
+ expect(visibleNames).toEqual(['jsx-ai']);
146
+
147
+ cards[0].click();
148
+ expect(openMock).toHaveBeenCalledWith('https://github.com/7flash/gitmaps', '_blank');
149
+ } finally {
150
+ fetchHandle.restore();
151
+ openHandle.restore();
152
+ }
153
+ });
154
+
155
+ test('detects direct GitHub URLs and shows clone affordance', () => {
156
+ setupGithubImport({} as any);
157
+
158
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
159
+ const urlRow = document.getElementById('githubUrlCloneRow') as HTMLElement;
160
+ const detected = document.getElementById('githubDetectedUrl') as HTMLElement;
161
+
162
+ userInput.value = 'https://github.com/7flash/jsx-ai';
163
+ userInput.dispatchEvent(new window.Event('input', { bubbles: true }));
164
+
165
+ expect(urlRow.style.display).toBe('flex');
166
+ expect(detected.textContent).toBe('7flash/jsx-ai');
167
+ });
168
+
169
+ test('pressing Enter on a direct GitHub URL starts clone-stream and closes the modal', async () => {
170
+ const fetchMock = mock(() => new Promise<Response>(() => {}));
171
+ const fetchHandle = installFetchMock(fetchMock as any);
172
+
173
+ try {
174
+ setupGithubImport({} as any);
175
+
176
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
177
+ const modal = document.getElementById('githubModal') as HTMLElement;
178
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
179
+ const cloneStatus = document.getElementById('cloneStatus') as HTMLElement;
180
+
181
+ openBtn.click();
182
+ expect(modal.classList.contains('active')).toBe(true);
183
+
184
+ userInput.value = 'https://github.com/7flash/jsx-ai';
185
+ userInput.dispatchEvent(new window.Event('input', { bubbles: true }));
186
+ userInput.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
187
+ await Promise.resolve();
188
+
189
+ expect(modal.classList.contains('active')).toBe(false);
190
+ expect(fetchMock).toHaveBeenCalledTimes(1);
191
+ expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/repo/clone-stream');
192
+ expect(String(fetchMock.mock.calls[0]?.[1]?.body)).toContain('https://github.com/7flash/jsx-ai.git');
193
+ expect(cloneStatus.style.display).toBe('block');
194
+ expect(cloneStatus.textContent).toContain('Cloning');
195
+ } finally {
196
+ fetchHandle.restore();
197
+ }
198
+ });
199
+
200
+ test('repo card clone button starts clone-stream without opening the GitHub page', async () => {
201
+ const fetchMock = mock(async (input: string) => {
202
+ if (input.startsWith('/api/github/repos')) {
203
+ return new Response(JSON.stringify({
204
+ profile: {
205
+ login: '7flash',
206
+ name: '7flash',
207
+ public_repos: 1,
208
+ type: 'User',
209
+ avatar_url: 'https://example.com/avatar.png',
210
+ },
211
+ repos: [
212
+ {
213
+ name: 'gitmaps',
214
+ clone_url: 'https://github.com/7flash/gitmaps.git',
215
+ description: 'Spatial code explorer',
216
+ language: 'TypeScript',
217
+ size: 2048,
218
+ updated_at: new Date().toISOString(),
219
+ stars: 10,
220
+ },
221
+ ],
222
+ page: 1,
223
+ hasNext: false,
224
+ hasPrev: false,
225
+ }), { headers: { 'Content-Type': 'application/json' } });
226
+ }
227
+ return new Promise<Response>(() => {});
228
+ });
229
+
230
+ const openMock = mock(() => null as any);
231
+ const fetchHandle = installFetchMock(fetchMock as any);
232
+ const openHandle = installWindowOpenMock(openMock as any);
233
+
234
+ try {
235
+ setupGithubImport({} as any);
236
+
237
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
238
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
239
+ const searchBtn = document.getElementById('githubSearchBtn') as HTMLButtonElement;
240
+ const modal = document.getElementById('githubModal') as HTMLElement;
241
+ const cloneStatus = document.getElementById('cloneStatus') as HTMLElement;
242
+
243
+ openBtn.click();
244
+ userInput.value = '7flash';
245
+ searchBtn.click();
246
+ await Promise.resolve();
247
+ await Promise.resolve();
248
+
249
+ const cloneBtn = document.querySelector('.github-clone-btn[data-url="https://github.com/7flash/gitmaps.git"]') as HTMLButtonElement;
250
+ expect(cloneBtn).toBeTruthy();
251
+
252
+ cloneBtn.click();
253
+ await Promise.resolve();
254
+
255
+ expect(fetchMock).toHaveBeenCalledTimes(2);
256
+ expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/repo/clone-stream');
257
+ expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain('https://github.com/7flash/gitmaps.git');
258
+ expect(modal.classList.contains('active')).toBe(false);
259
+ expect(cloneStatus.style.display).toBe('block');
260
+ expect(cloneStatus.textContent).toContain('Cloning');
261
+ expect(openMock).not.toHaveBeenCalled();
262
+ } finally {
263
+ fetchHandle.restore();
264
+ openHandle.restore();
265
+ }
266
+ });
267
+
268
+ test('cached clone response shows success and selects the cached repo path', async () => {
269
+ const onRepoReady = mock(() => undefined);
270
+ const ctx = {
271
+ actor: { send: mock(() => undefined) },
272
+ onRepoReady,
273
+ snap: () => ({ context: { repoPath: '', zoom: 1, offsetX: 0, offsetY: 0, commits: [] }, value: { view: 'allfiles' } }),
274
+ fileCards: new Map(),
275
+ deferredCards: new Map(),
276
+ changedFilePaths: new Set(),
277
+ positions: new Map(),
278
+ hiddenFiles: new Set(),
279
+ allFilesData: [],
280
+ commitFilesData: [],
281
+ canvas: document.createElement('div'),
282
+ canvasViewport: document.createElement('div'),
283
+ svgOverlay: null,
284
+ loadingOverlay: null,
285
+ } as any;
286
+
287
+ const fetchMock = mock(async (input: string) => {
288
+ if (input === '/api/repo/clone-stream') {
289
+ return new Response(JSON.stringify({ path: 'C:/Code/gitmaps' }), {
290
+ headers: { 'Content-Type': 'application/json' },
291
+ });
292
+ }
293
+ if (input === '/api/repo/load') {
294
+ return new Promise<Response>(() => {});
295
+ }
296
+ throw new Error(`Unexpected fetch: ${input}`);
297
+ });
298
+
299
+ const fetchHandle = installFetchMock(fetchMock as any);
300
+
301
+ try {
302
+ setupGithubImport(ctx);
303
+
304
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
305
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
306
+ const cloneBtn = document.getElementById('githubUrlCloneBtn') as HTMLButtonElement;
307
+ const cloneStatus = document.getElementById('cloneStatus') as HTMLElement;
308
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
309
+
310
+ openBtn.click();
311
+ userInput.value = 'https://github.com/7flash/gitmaps';
312
+ userInput.dispatchEvent(new window.Event('input', { bubbles: true }));
313
+ cloneBtn.click();
314
+ await Promise.resolve();
315
+ await Promise.resolve();
316
+
317
+ expect(cloneStatus.className).toContain('success');
318
+ expect(cloneStatus.textContent).toContain('Updated — loading');
319
+ expect(repoSelect.value).toBe('C:/Code/gitmaps');
320
+ expect(fetchMock).toHaveBeenCalledTimes(1);
321
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/gitmaps');
322
+ } finally {
323
+ fetchHandle.restore();
324
+ }
325
+ });
326
+
327
+ test('SSE clone done updates success state and selects the cloned repo path', async () => {
328
+ const onRepoReady = mock(() => undefined);
329
+ const ctx = {
330
+ actor: { send: mock(() => undefined) },
331
+ onRepoReady,
332
+ snap: () => ({ context: { repoPath: '', zoom: 1, offsetX: 0, offsetY: 0, commits: [] }, value: { view: 'allfiles' } }),
333
+ fileCards: new Map(),
334
+ deferredCards: new Map(),
335
+ changedFilePaths: new Set(),
336
+ positions: new Map(),
337
+ hiddenFiles: new Set(),
338
+ allFilesData: [],
339
+ commitFilesData: [],
340
+ canvas: document.createElement('div'),
341
+ canvasViewport: document.createElement('div'),
342
+ svgOverlay: null,
343
+ loadingOverlay: null,
344
+ } as any;
345
+
346
+ const fetchMock = mock(async (input: string) => {
347
+ if (input === '/api/repo/clone-stream') {
348
+ return makeSseResponse([
349
+ 'event: progress\ndata: {"message":"Resolving repository","percent":25}\n\n',
350
+ 'event: done\ndata: {"path":"C:/Code/jsx-ai"}\n\n',
351
+ ]);
352
+ }
353
+ if (input === '/api/repo/load') {
354
+ return new Promise<Response>(() => {});
355
+ }
356
+ throw new Error(`Unexpected fetch: ${input}`);
357
+ });
358
+
359
+ const fetchHandle = installFetchMock(fetchMock as any);
360
+
361
+ try {
362
+ setupGithubImport(ctx);
363
+
364
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
365
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
366
+ const cloneBtn = document.getElementById('githubUrlCloneBtn') as HTMLButtonElement;
367
+ const cloneStatus = document.getElementById('cloneStatus') as HTMLElement;
368
+ const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
369
+
370
+ openBtn.click();
371
+ userInput.value = 'https://github.com/7flash/jsx-ai';
372
+ userInput.dispatchEvent(new window.Event('input', { bubbles: true }));
373
+ cloneBtn.click();
374
+ await Promise.resolve();
375
+ await Promise.resolve();
376
+ await Promise.resolve();
377
+
378
+ expect(cloneStatus.className).toContain('success');
379
+ expect(cloneStatus.textContent).toContain('Cloned — loading');
380
+ expect(repoSelect.value).toBe('C:/Code/jsx-ai');
381
+ expect(fetchMock).toHaveBeenCalledTimes(1);
382
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/jsx-ai');
383
+ } finally {
384
+ fetchHandle.restore();
385
+ }
386
+ });
387
+
388
+ test('SSE clone error updates the clone status to an error state', async () => {
389
+ const fetchMock = mock(async (input: string) => {
390
+ if (input === '/api/repo/clone-stream') {
391
+ return makeSseResponse([
392
+ 'event: progress\ndata: {"message":"Cloning","percent":40}\n\n',
393
+ 'event: error\ndata: {"error":"Repository not found"}\n\n',
394
+ ]);
395
+ }
396
+ throw new Error(`Unexpected fetch: ${input}`);
397
+ });
398
+
399
+ const fetchHandle = installFetchMock(fetchMock as any);
400
+
401
+ try {
402
+ setupGithubImport({} as any);
403
+
404
+ const openBtn = document.getElementById('githubImportBtn') as HTMLButtonElement;
405
+ const userInput = document.getElementById('githubUserInput') as HTMLInputElement;
406
+ const cloneBtn = document.getElementById('githubUrlCloneBtn') as HTMLButtonElement;
407
+ const cloneStatus = document.getElementById('cloneStatus') as HTMLElement;
408
+
409
+ openBtn.click();
410
+ userInput.value = 'https://github.com/7flash/missing-repo';
411
+ userInput.dispatchEvent(new window.Event('input', { bubbles: true }));
412
+ cloneBtn.click();
413
+ await Promise.resolve();
414
+ await Promise.resolve();
415
+ await Promise.resolve();
416
+
417
+ expect(cloneStatus.className).toContain('error');
418
+ expect(cloneStatus.textContent).toContain('Repository not found');
419
+ } finally {
420
+ fetchHandle.restore();
421
+ }
422
+ });
423
+ });
424
+
@@ -15,14 +15,27 @@ let _ctx: CanvasContext | null = null;
15
15
  /** Toggle the search panel */
16
16
  export function toggleGlobalSearch(ctx: CanvasContext) {
17
17
  _ctx = ctx;
18
- if (_panel) {
18
+ // Panel exists and is visible → close it
19
+ if (_panel && _panel.style.display !== 'none') {
19
20
  closeSearch();
20
21
  } else {
22
+ // Panel doesn't exist or is hidden → open/restore it
21
23
  openSearch();
22
24
  }
23
25
  }
24
26
 
25
27
  function openSearch() {
28
+ // If panel was hidden (not destroyed), restore it
29
+ if (_panel && _panel.style.display === 'none') {
30
+ _panel.style.display = 'flex';
31
+ document.addEventListener('keydown', _onEsc);
32
+ requestAnimationFrame(() => _panel?.classList.add('visible'));
33
+ // Re-focus search input
34
+ const input = _panel.querySelector('#gsSearchInput') as HTMLInputElement;
35
+ input?.focus();
36
+ return;
37
+ }
38
+
26
39
  if (_panel) return;
27
40
 
28
41
  _panel = document.createElement('div');
@@ -82,10 +95,11 @@ export function closeSearch() {
82
95
  if (!_panel) return;
83
96
  document.removeEventListener('keydown', _onEsc);
84
97
  _panel.classList.remove('visible');
85
- // Wait for slide-out animation
98
+ // Hide instead of destroy — preserves query + results
86
99
  setTimeout(() => {
87
- _panel?.remove();
88
- _panel = null;
100
+ if (_panel) {
101
+ _panel.style.display = 'none';
102
+ }
89
103
  }, 200);
90
104
  if (_abortController) { _abortController.abort(); _abortController = null; }
91
105
  if (_searchTimeout) { clearTimeout(_searchTimeout); _searchTimeout = null; }
@@ -228,37 +242,44 @@ function getFileIcon(name: string): string {
228
242
  return icons[ext] || '📄';
229
243
  }
230
244
 
231
- /** Open a file from search results in the editor modal */
245
+ /** Jump to a file on the canvas from search results */
232
246
  function openFileFromSearch(filePath: string, line: number) {
233
247
  if (!_ctx) return;
234
248
 
235
- // Create a minimal file stub
236
- const file = {
237
- path: filePath,
238
- name: filePath.split('/').pop() || filePath,
239
- content: '',
240
- lines: 0,
241
- };
249
+ // Jump to the file on the canvas (handles layer switching and centering)
250
+ import('./canvas').then(({ jumpToFile }) => {
251
+ jumpToFile(_ctx!, filePath);
242
252
 
243
- // Import and open the file modal
244
- import('./file-modal').then(({ openFileModal }) => {
245
- openFileModal(_ctx!, file, 'edit');
246
- // Scroll to line after editor loads
253
+ // After jump animation settles, scroll to the matching line
247
254
  if (line > 1) {
248
255
  setTimeout(() => {
249
- const editContainer = document.getElementById('modalEditContainer');
250
- const editor = (editContainer as any)?._cmEditor;
251
- if (editor?.view) {
252
- const lineInfo = editor.view.state.doc.line(Math.min(line, editor.view.state.doc.lines));
253
- editor.view.dispatch({
254
- selection: { anchor: lineInfo.from },
255
- scrollIntoView: true,
256
- });
256
+ const card = _ctx?.fileCards.get(filePath);
257
+ if (!card) return;
258
+ const body = card.querySelector('.file-card-body') as HTMLElement;
259
+ if (!body) return;
260
+ // Find the line element
261
+ const lineEl = body.querySelector(`[data-line="${line}"]`) as HTMLElement;
262
+ if (lineEl) {
263
+ lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
264
+ // Flash highlight
265
+ lineEl.style.background = 'rgba(124, 58, 237, 0.3)';
266
+ setTimeout(() => { lineEl.style.background = ''; }, 2000);
257
267
  }
258
- }, 500);
268
+ }, 600); // Wait for jump animation
259
269
  }
260
270
  });
261
271
 
262
- // Close search panel
263
- closeSearch();
272
+ // Hide panel but don't destroy — preserve state
273
+ if (_panel) {
274
+ _panel.classList.remove('visible');
275
+ _panel.style.pointerEvents = 'none';
276
+ _panel.style.opacity = '0';
277
+ setTimeout(() => {
278
+ if (_panel) {
279
+ _panel.style.display = 'none';
280
+ _panel.style.pointerEvents = '';
281
+ _panel.style.opacity = '';
282
+ }
283
+ }, 200);
284
+ }
264
285
  }