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,228 @@
1
+ /**
2
+ * Role detection — Leader vs Follower
3
+ *
4
+ * Leader: Running locally (localhost, 127.0.0.1, 192.168.x.x)
5
+ * - Full control: move cards, edit files, arrange layers
6
+ * - Can push to remote servers
7
+ *
8
+ * Follower: Visiting gitmaps.xyz or any remote server
9
+ * - Read-only canvas
10
+ * - Can clone repo to their local
11
+ */
12
+
13
+ export type Role = "leader" | "follower";
14
+
15
+ let _cachedRole: Role | null = null;
16
+
17
+ export function detectRole(): Role {
18
+ if (_cachedRole) return _cachedRole;
19
+
20
+ const host = window.location.hostname;
21
+
22
+ // Leader: running on localhost or local network
23
+ const isLocalhost =
24
+ host === "localhost" || host === "127.0.0.1" || host === "::1";
25
+ const isLocalNetwork =
26
+ host.startsWith("192.168.") ||
27
+ host.startsWith("10.") ||
28
+ host.startsWith("172.");
29
+
30
+ // Debug logging
31
+ console.log(
32
+ `[role] Host: ${host}, Localhost: ${isLocalhost}, LocalNetwork: ${isLocalNetwork}`,
33
+ );
34
+
35
+ _cachedRole = isLocalhost || isLocalNetwork ? "leader" : "follower";
36
+
37
+ console.log(`[role] Detected as ${_cachedRole}`);
38
+ return _cachedRole;
39
+ }
40
+
41
+ export function isLeader(): boolean {
42
+ return detectRole() === "leader";
43
+ }
44
+
45
+ export function isFollower(): boolean {
46
+ return detectRole() === "follower";
47
+ }
48
+
49
+ export function clearRoleCache() {
50
+ _cachedRole = null;
51
+ }
52
+
53
+ export function renderRoleBadge(): void {
54
+ const existing = document.getElementById("roleBadge");
55
+ if (existing) existing.remove();
56
+
57
+ const role = detectRole();
58
+ const badge = document.createElement("div");
59
+ badge.id = "roleBadge";
60
+ badge.className = `role-badge role-badge--${role}`;
61
+
62
+ if (role === "leader") {
63
+ badge.innerHTML = `
64
+ <span class="role-badge-icon">👑</span>
65
+ <span class="role-badge-text">Leader</span>
66
+ <span class="role-badge-sub">Local Control</span>
67
+ `;
68
+ badge.title =
69
+ "You have full control - can move cards, edit files, and push to remote servers";
70
+ } else {
71
+ badge.innerHTML = `
72
+ <span class="role-badge-icon">👁️</span>
73
+ <span class="role-badge-text">Follower</span>
74
+ <span class="role-badge-sub">Read-Only</span>
75
+ <button class="clone-to-edit-btn" title="Clone repo locally to edit">
76
+ 📥 Clone to Edit
77
+ </button>
78
+ `;
79
+ badge.title = "Read-only mode - Clone this repo to edit locally";
80
+
81
+ // Wire up clone button
82
+ const cloneBtn = badge.querySelector(".clone-to-edit-btn");
83
+ if (cloneBtn) {
84
+ cloneBtn.addEventListener("click", (e) => {
85
+ e.stopPropagation();
86
+ handleCloneToEdit();
87
+ });
88
+ }
89
+ }
90
+
91
+ const toolbar =
92
+ document.querySelector(".toolbar-right") ||
93
+ document.querySelector(".status-bar");
94
+ if (toolbar) {
95
+ toolbar.insertBefore(badge, toolbar.firstChild);
96
+ }
97
+ }
98
+
99
+ async function handleCloneToEdit(): Promise<void> {
100
+ const { showToast } = await import("./utils");
101
+
102
+ // Get current repo from URL or context
103
+ const pathSegments = window.location.pathname.slice(1).split("/");
104
+ const repoSlug = pathSegments.filter((s) => s).join("/");
105
+
106
+ if (!repoSlug) {
107
+ showToast("No repository loaded", "error");
108
+ return;
109
+ }
110
+
111
+ // Show clone instructions
112
+ const instructions = `
113
+ <div style="text-align:left;padding:20px;">
114
+ <h3 style="margin-bottom:16px;font-size:16px;">🚀 Clone to Edit</h3>
115
+
116
+ <p style="margin-bottom:12px;color:var(--text-muted);">
117
+ To edit this repository, clone it locally and run GitMaps on your machine:
118
+ </p>
119
+
120
+ <div style="background:var(--bg-tertiary);padding:12px;border-radius:6px;margin:12px 0;font-family:var(--font-mono);font-size:12px;">
121
+ <div style="margin-bottom:8px;"># Clone the repository</div>
122
+ <div style="color:var(--accent-primary);">git clone https://github.com/${repoSlug}.git</div>
123
+ <div style="margin-top:8px;margin-bottom:8px;"># Navigate to the repo</div>
124
+ <div style="color:var(--accent-primary);">cd ${repoSlug.split("/").pop()}</div>
125
+ <div style="margin-top:8px;margin-bottom:8px;"># Run GitMaps locally</div>
126
+ <div style="color:var(--accent-primary);">bunx gitmaps@latest</div>
127
+ </div>
128
+
129
+ <p style="margin-top:12px;color:var(--text-muted);font-size:11px;">
130
+ Once running locally (http://localhost:3335), you'll be in <strong>Leader</strong> mode with full edit control.
131
+ </p>
132
+ </div>
133
+ `;
134
+
135
+ // Create modal
136
+ const modal = document.createElement("div");
137
+ modal.className = "clone-modal";
138
+ modal.innerHTML = `
139
+ <div class="clone-modal-backdrop"></div>
140
+ <div class="clone-modal-content">
141
+ ${instructions}
142
+ <div class="clone-modal-actions">
143
+ <button class="clone-modal-copy">📋 Copy Commands</button>
144
+ <button class="clone-modal-close">Close</button>
145
+ </div>
146
+ </div>
147
+ `;
148
+
149
+ document.body.appendChild(modal);
150
+
151
+ // Style the modal
152
+ const style = document.createElement("style");
153
+ style.textContent = `
154
+ .clone-modal {
155
+ position: fixed;
156
+ inset: 0;
157
+ z-index: 9999;
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ animation: fadeIn 0.15s ease;
162
+ }
163
+ .clone-modal-backdrop {
164
+ position: absolute;
165
+ inset: 0;
166
+ background: rgba(10, 10, 15, 0.8);
167
+ backdrop-filter: blur(4px);
168
+ }
169
+ .clone-modal-content {
170
+ position: relative;
171
+ background: var(--bg-secondary);
172
+ border: 1px solid var(--border-primary);
173
+ border-radius: 12px;
174
+ max-width: 500px;
175
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
176
+ }
177
+ .clone-modal-actions {
178
+ display: flex;
179
+ gap: 8px;
180
+ justify-content: flex-end;
181
+ padding: 16px 20px;
182
+ border-top: 1px solid var(--border-primary);
183
+ }
184
+ .clone-modal-actions button {
185
+ padding: 8px 16px;
186
+ border-radius: 6px;
187
+ border: none;
188
+ font-size: 12px;
189
+ font-weight: 600;
190
+ cursor: pointer;
191
+ transition: all 0.2s;
192
+ }
193
+ .clone-modal-copy {
194
+ background: var(--accent-primary);
195
+ color: white;
196
+ }
197
+ .clone-modal-copy:hover {
198
+ background: var(--accent-secondary);
199
+ }
200
+ .clone-modal-close {
201
+ background: var(--bg-tertiary);
202
+ color: var(--text-primary);
203
+ }
204
+ .clone-modal-close:hover {
205
+ background: var(--bg-card);
206
+ }
207
+ `;
208
+ document.head.appendChild(style);
209
+
210
+ // Wire up buttons
211
+ modal.querySelector(".clone-modal-copy")?.addEventListener("click", () => {
212
+ const commands = `git clone https://github.com/${repoSlug}.git\ncd ${repoSlug.split("/").pop()}\nbunx gitmaps@latest`;
213
+ navigator.clipboard.writeText(commands);
214
+ showToast("Commands copied to clipboard", "success");
215
+ });
216
+
217
+ modal.querySelector(".clone-modal-close")?.addEventListener("click", () => {
218
+ modal.remove();
219
+ style.remove();
220
+ });
221
+
222
+ modal
223
+ .querySelector(".clone-modal-backdrop")
224
+ ?.addEventListener("click", () => {
225
+ modal.remove();
226
+ style.remove();
227
+ });
228
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import path from 'path';
3
+ import {
4
+ assertRouteExpectation,
5
+ discoverContractRoutes,
6
+ gitmapsCatchAllRoutingContract,
7
+ } from './router-contract';
8
+
9
+ describe('GitMaps catch-all route integration', () => {
10
+ const appDir = path.resolve(import.meta.dir, '..');
11
+ const routes = discoverContractRoutes(appDir);
12
+
13
+ test('discovers the catch-all slug route', () => {
14
+ const catchAll = routes.find(r => r.pattern === '/*slug');
15
+ expect(catchAll).toBeDefined();
16
+ });
17
+
18
+ for (const expectation of gitmapsCatchAllRoutingContract) {
19
+ test(`${expectation.pathname} matches ${expectation.pattern} in the real app`, () => {
20
+ const result = assertRouteExpectation(routes, expectation);
21
+ expect(result.route.pattern).toBe(expectation.pattern);
22
+ if (expectation.params) {
23
+ expect(result.params).toEqual(expectation.params);
24
+ }
25
+ });
26
+ }
27
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+ import { getCurrentRouteSlug, handlePopstateRepoEntry, resolveMappedRepoPath } from './route-repo-entry';
3
+ import { setupDomTest } from './test-dom';
4
+
5
+ describe('route repo entry helper', () => {
6
+ test('reads the current decoded route slug', () => {
7
+ const handle = setupDomTest({ url: 'http://localhost:3335/team/platform/tools/gitmaps' });
8
+ try {
9
+ expect(getCurrentRouteSlug()).toBe('team/platform/tools/gitmaps');
10
+ } finally {
11
+ handle.cleanup();
12
+ }
13
+ });
14
+
15
+ test('resolves mapped repo path from localStorage', () => {
16
+ const handle = setupDomTest();
17
+ try {
18
+ localStorage.setItem('gitcanvas:slug:team/platform/tools/gitmaps', 'C:/Code/gitmaps');
19
+ expect(resolveMappedRepoPath('team/platform/tools/gitmaps')).toBe('C:/Code/gitmaps');
20
+ } finally {
21
+ handle.cleanup();
22
+ }
23
+ });
24
+
25
+ test('popstate handoff uses onRepoReady seam for mapped slug routes', () => {
26
+ const handle = setupDomTest({
27
+ url: 'http://localhost:3335/team/platform/tools/gitmaps',
28
+ html: '<select id="repoSelect"><option value="">Select</option><option value="C:/Code/gitmaps">gitmaps</option></select>',
29
+ });
30
+
31
+ try {
32
+ localStorage.setItem('gitcanvas:slug:team/platform/tools/gitmaps', 'C:/Code/gitmaps');
33
+ const onRepoReady = mock(() => undefined);
34
+ const updateFavoriteStar = mock(() => undefined);
35
+ const showLandingPlaceholder = mock(() => undefined);
36
+ const ctx = { onRepoReady } as any;
37
+
38
+ const resolvedPath = handlePopstateRepoEntry(ctx, {
39
+ currentRepoPath: '',
40
+ showLandingPlaceholder,
41
+ updateFavoriteStar,
42
+ });
43
+
44
+ expect(resolvedPath).toBe('C:/Code/gitmaps');
45
+ expect((document.getElementById('repoSelect') as HTMLSelectElement).value).toBe('C:/Code/gitmaps');
46
+ expect(onRepoReady).toHaveBeenCalledWith('C:/Code/gitmaps');
47
+ expect(updateFavoriteStar).toHaveBeenCalledWith('C:/Code/gitmaps');
48
+ expect(showLandingPlaceholder).not.toHaveBeenCalled();
49
+ } finally {
50
+ handle.cleanup();
51
+ }
52
+ });
53
+
54
+ test('popstate shows landing when route is empty', () => {
55
+ const handle = setupDomTest({ url: 'http://localhost:3335/' });
56
+ try {
57
+ const showLandingPlaceholder = mock(() => undefined);
58
+ const updateFavoriteStar = mock(() => undefined);
59
+
60
+ const resolvedPath = handlePopstateRepoEntry({} as any, {
61
+ currentRepoPath: '',
62
+ showLandingPlaceholder,
63
+ updateFavoriteStar,
64
+ });
65
+
66
+ expect(resolvedPath).toBeNull();
67
+ expect(showLandingPlaceholder).toHaveBeenCalledTimes(1);
68
+ expect(updateFavoriteStar).not.toHaveBeenCalled();
69
+ } finally {
70
+ handle.cleanup();
71
+ }
72
+ });
73
+
74
+ test('popstate is a no-op when route resolves to the current repo', () => {
75
+ const handle = setupDomTest({ url: 'http://localhost:3335/gitmaps' });
76
+ try {
77
+ const onRepoReady = mock(() => undefined);
78
+ const showLandingPlaceholder = mock(() => undefined);
79
+ const updateFavoriteStar = mock(() => undefined);
80
+
81
+ const resolvedPath = handlePopstateRepoEntry({ onRepoReady } as any, {
82
+ currentRepoPath: 'gitmaps',
83
+ showLandingPlaceholder,
84
+ updateFavoriteStar,
85
+ });
86
+
87
+ expect(resolvedPath).toBe('gitmaps');
88
+ expect(onRepoReady).not.toHaveBeenCalled();
89
+ expect(showLandingPlaceholder).not.toHaveBeenCalled();
90
+ expect(updateFavoriteStar).not.toHaveBeenCalled();
91
+ } finally {
92
+ handle.cleanup();
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,36 @@
1
+ import type { CanvasContext } from './context';
2
+ import { handoffRepoLoad } from './repo-handoff';
3
+
4
+ export function getCurrentRouteSlug(pathname = window.location.pathname): string {
5
+ return decodeURIComponent(pathname.replace(/^\//, ''));
6
+ }
7
+
8
+ export function resolveMappedRepoPath(slug: string): string {
9
+ return localStorage.getItem(`gitcanvas:slug:${slug}`) || slug;
10
+ }
11
+
12
+ export function handlePopstateRepoEntry(
13
+ ctx: CanvasContext,
14
+ options: {
15
+ disposed?: boolean;
16
+ currentRepoPath?: string;
17
+ showLandingPlaceholder: () => void;
18
+ updateFavoriteStar: (path: string) => void;
19
+ },
20
+ ) {
21
+ if (options.disposed) return null;
22
+
23
+ const slug = getCurrentRouteSlug();
24
+ if (!slug) {
25
+ options.showLandingPlaceholder();
26
+ return null;
27
+ }
28
+
29
+ const resolvedPath = resolveMappedRepoPath(slug);
30
+ if (resolvedPath && resolvedPath !== options.currentRepoPath) {
31
+ handoffRepoLoad(ctx, resolvedPath);
32
+ options.updateFavoriteStar(resolvedPath);
33
+ }
34
+
35
+ return resolvedPath;
36
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import path from 'path';
3
+ import {
4
+ assertRouteExpectation,
5
+ discoverContractRoutes,
6
+ gitmapsCatchAllRoutingContract,
7
+ } from './router-contract';
8
+
9
+ describe('Melina catch-all routing contract fixture', () => {
10
+ const fixtureAppDir = path.resolve(import.meta.dir, 'test-fixtures/router-contract');
11
+ const routes = discoverContractRoutes(fixtureAppDir);
12
+
13
+ for (const expectation of gitmapsCatchAllRoutingContract) {
14
+ test(`${expectation.pathname} resolves to ${expectation.pattern}`, () => {
15
+ const result = assertRouteExpectation(routes, expectation);
16
+ expect(result.route.pattern).toBe(expectation.pattern);
17
+ if (expectation.params) {
18
+ expect(result.params).toEqual(expectation.params);
19
+ }
20
+ });
21
+ }
22
+ });
@@ -0,0 +1,19 @@
1
+ export {
2
+ assertRouteExpectation,
3
+ catchAllRoutingContract,
4
+ discoverContractRoutes,
5
+ } from '../../../melina.js/src/server/router-contract';
6
+
7
+ export type { RouteExpectation } from '../../../melina.js/src/server/router-contract';
8
+
9
+ export const gitmapsCatchAllRoutingContract: RouteExpectation[] = [
10
+ { pathname: '/', pattern: '/' },
11
+ { pathname: '/galaxy-canvas', pattern: '/galaxy-canvas' },
12
+ { pathname: '/api/version', pattern: '/api/version' },
13
+ { pathname: '/starwar', pattern: '/*slug', params: { slug: 'starwar' } },
14
+ {
15
+ pathname: '/team/platform/tools/gitmaps',
16
+ pattern: '/*slug',
17
+ params: { slug: 'team/platform/tools/gitmaps' },
18
+ },
19
+ ];
@@ -0,0 +1,86 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+ import { applySharedLayout, clearSharedLayoutParam, decodeSharedLayout, getSharedLayoutParam } from './shared-layout';
3
+ import { setupDomTest } from './test-dom';
4
+
5
+ describe('shared layout helper', () => {
6
+ test('reads and clears the layout query param', () => {
7
+ const handle = setupDomTest({ url: 'http://localhost:3335/gitmaps?layout=abc123&foo=bar' });
8
+ try {
9
+ expect(getSharedLayoutParam()).toBe('abc123');
10
+ clearSharedLayoutParam();
11
+ expect(window.location.search).toBe('?foo=bar');
12
+ } finally {
13
+ handle.cleanup();
14
+ }
15
+ });
16
+
17
+ test('decodes base64 shared layout payloads', () => {
18
+ const payload = { zoom: 1.5, hiddenFiles: ['a.ts'] };
19
+ expect(decodeSharedLayout(btoa(JSON.stringify(payload)))).toEqual(payload);
20
+ });
21
+
22
+ test('applies shared layout state, clears the query param, and shows success toast', async () => {
23
+ const layout = btoa(JSON.stringify({
24
+ positions: { 'allfiles:src/index.ts': { x: 10, y: 20 } },
25
+ hiddenFiles: ['secret.ts'],
26
+ zoom: 1.25,
27
+ offsetX: 111,
28
+ offsetY: 222,
29
+ cardSizes: { 'src/index.ts': { width: 600, height: 700 } },
30
+ }));
31
+ const handle = setupDomTest({
32
+ url: `http://localhost:3335/gitmaps?layout=${encodeURIComponent(layout)}`,
33
+ html: '<button id="showHidden" style="display:none"></button><span id="hiddenCount"></span>',
34
+ });
35
+
36
+ try {
37
+ const sent: any[] = [];
38
+ const toast = mock(async () => undefined);
39
+ const clearParam = mock(() => clearSharedLayoutParam());
40
+ const triggerPersist = mock(() => undefined);
41
+ const ctx = {
42
+ actor: { send: mock((event: any) => sent.push(event)) },
43
+ positions: new Map(),
44
+ hiddenFiles: new Set<string>(),
45
+ } as any;
46
+
47
+ const applied = await applySharedLayout(ctx, {
48
+ clearParam,
49
+ triggerPersist,
50
+ showToast: toast,
51
+ });
52
+
53
+ expect(applied).toBe(true);
54
+ expect(Array.from(ctx.positions.entries())).toEqual([
55
+ ['allfiles:src/index.ts', { x: 10, y: 20 }],
56
+ ]);
57
+ expect(triggerPersist).toHaveBeenCalledWith(ctx);
58
+ expect(Array.from(ctx.hiddenFiles)).toEqual(['secret.ts']);
59
+ expect((document.getElementById('showHidden') as HTMLElement).style.display).toBe('inline-flex');
60
+ expect(document.getElementById('hiddenCount')?.textContent).toBe('1');
61
+ expect(sent).toContainEqual({ type: 'SET_ZOOM', zoom: 1.25 });
62
+ expect(sent).toContainEqual({ type: 'SET_OFFSET', x: 111, y: 222 });
63
+ expect(sent).toContainEqual({ type: 'RESIZE_CARD', path: 'src/index.ts', width: 600, height: 700 });
64
+ expect(clearParam).toHaveBeenCalledTimes(1);
65
+ expect(window.location.search).toBe('');
66
+ expect(toast).toHaveBeenCalledWith('Shared layout applied!', 'success');
67
+ expect(localStorage.getItem('gitcanvas:hiddenFiles')).toBe(JSON.stringify(['secret.ts']));
68
+ } finally {
69
+ handle.cleanup();
70
+ }
71
+ });
72
+
73
+ test('is a no-op when no layout query param exists', async () => {
74
+ const handle = setupDomTest({ url: 'http://localhost:3335/gitmaps' });
75
+ try {
76
+ const showToast = mock(async () => undefined);
77
+ const applied = await applySharedLayout({ actor: { send: mock(() => undefined) }, positions: new Map(), hiddenFiles: new Set() } as any, {
78
+ showToast,
79
+ });
80
+ expect(applied).toBe(false);
81
+ expect(showToast).not.toHaveBeenCalled();
82
+ } finally {
83
+ handle.cleanup();
84
+ }
85
+ });
86
+ });
@@ -0,0 +1,82 @@
1
+ import type { CanvasContext } from './context';
2
+ import { saveHiddenFiles, updateHiddenUI } from './hidden-files';
3
+ import { savePosition } from './positions';
4
+
5
+ export interface SharedLayoutPayload {
6
+ positions?: Record<string, any>;
7
+ hiddenFiles?: string[];
8
+ zoom?: number;
9
+ offsetX?: number;
10
+ offsetY?: number;
11
+ cardSizes?: Record<string, { width: number; height: number }>;
12
+ }
13
+
14
+ export function getSharedLayoutParam(search = window.location.search): string | null {
15
+ return new URLSearchParams(search).get('layout');
16
+ }
17
+
18
+ export function clearSharedLayoutParam(url = window.location.href) {
19
+ const cleanUrl = new URL(url);
20
+ cleanUrl.searchParams.delete('layout');
21
+ window.history.replaceState({}, '', cleanUrl.toString());
22
+ }
23
+
24
+ export function decodeSharedLayout(layout: string): SharedLayoutPayload {
25
+ return JSON.parse(atob(layout));
26
+ }
27
+
28
+ export async function applySharedLayout(
29
+ ctx: CanvasContext,
30
+ options?: {
31
+ search?: string;
32
+ decode?: (layout: string) => SharedLayoutPayload;
33
+ clearParam?: () => void;
34
+ triggerPersist?: (ctx: CanvasContext) => void;
35
+ showToast?: (message: string, type: string) => void | Promise<void>;
36
+ },
37
+ ) {
38
+ const sharedLayout = getSharedLayoutParam(options?.search);
39
+ if (!sharedLayout) return false;
40
+
41
+ const decode = options?.decode || decodeSharedLayout;
42
+ const clearParam = options?.clearParam || (() => clearSharedLayoutParam());
43
+ const triggerPersist = options?.triggerPersist || ((ctx: CanvasContext) => {
44
+ savePosition(ctx, '_share_', '_trigger_', 0, 0);
45
+ });
46
+ const showToast = options?.showToast || (async (message: string, type: string) => {
47
+ const utils = await import('./utils');
48
+ utils.showToast(message, type);
49
+ });
50
+
51
+ const parsed = decode(sharedLayout);
52
+
53
+ if (parsed.positions) {
54
+ ctx.positions = new Map(Object.entries(parsed.positions));
55
+ triggerPersist(ctx);
56
+ }
57
+ if (parsed.hiddenFiles) {
58
+ ctx.hiddenFiles = new Set(parsed.hiddenFiles);
59
+ saveHiddenFiles(ctx);
60
+ updateHiddenUI(ctx);
61
+ }
62
+ if (parsed.zoom !== undefined) {
63
+ ctx.actor.send({ type: 'SET_ZOOM', zoom: parsed.zoom });
64
+ }
65
+ if (parsed.offsetX !== undefined) {
66
+ ctx.actor.send({ type: 'SET_OFFSET', x: parsed.offsetX, y: parsed.offsetY });
67
+ }
68
+ if (parsed.cardSizes) {
69
+ for (const [path, size] of Object.entries(parsed.cardSizes)) {
70
+ ctx.actor.send({
71
+ type: 'RESIZE_CARD',
72
+ path,
73
+ width: (size as any).width,
74
+ height: (size as any).height,
75
+ });
76
+ }
77
+ }
78
+
79
+ clearParam();
80
+ await showToast('Shared layout applied!', 'success');
81
+ return true;
82
+ }