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,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
+ }
@@ -42,6 +42,8 @@ const SHORTCUTS = [
42
42
  {
43
43
  category: 'Tools', items: [
44
44
  { keys: ['H'], description: 'Toggle git heatmap (no selection)' },
45
+ { keys: ['Shift', 'P'], description: 'Performance overlay' },
46
+ { keys: ['Ctrl', 'G'], description: 'Toggle dependency graph' },
45
47
  { keys: ['Ctrl', 'N'], description: 'Create new file' },
46
48
  { keys: ['Shift + Click line'], description: 'Start connection' },
47
49
  { keys: ['Ctrl', 'Shift', 'E'], description: 'Export canvas as PNG' },