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
@@ -1,50 +1,112 @@
1
1
  /**
2
2
  * GET /api/auth/positions?repo=<url> — Load saved positions for a repo
3
- * POST /api/auth/positions — Save positions for a repo
4
- *
5
- * Enables shared repositories: each user has their own card layout
6
- * for the same cloned repository.
3
+ * POST /api/auth/positions — Save positions for a repo (Leader only)
4
+ *
5
+ * Leader/Follower enforcement:
6
+ * - Leaders (localhost/local network): Can read/write positions
7
+ * - Followers (remote/production): Read-only access
8
+ *
9
+ * This prevents unauthorized canvas modifications on production servers.
7
10
  */
8
- import { getSessionFromRequest, loadRepoPositions, saveRepoPositions } from '../../../lib/auth';
11
+ import {
12
+ getSessionFromRequest,
13
+ loadRepoPositions,
14
+ saveRepoPositions,
15
+ } from "../../../lib/auth";
16
+
17
+ /**
18
+ * Detect if request is from a leader (local) or follower (remote)
19
+ * Based on IP address - localhost and local network = leader
20
+ */
21
+ function isLeaderRequest(req: Request): boolean {
22
+ const forwarded = req.headers.get("x-forwarded-for");
23
+ const ip =
24
+ forwarded?.split(",")[0]?.trim() ||
25
+ req.headers.get("x-real-ip") ||
26
+ "unknown";
27
+
28
+ // Leader: localhost, local network IPs
29
+ const leaderPatterns = [
30
+ /^127\./, // 127.0.0.1
31
+ /^::1$/, // IPv6 localhost
32
+ /^192\.168\./, // Private network
33
+ /^10\./, // Private network
34
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private network
35
+ /^localhost$/i,
36
+ /^unknown$/i, // No IP header = likely local dev
37
+ ];
38
+
39
+ return leaderPatterns.some((pattern) => pattern.test(ip));
40
+ }
9
41
 
10
42
  export async function GET(req: Request) {
11
- const user = getSessionFromRequest(req);
12
- if (!user) {
13
- return Response.json({ authenticated: false });
14
- }
43
+ const url = new URL(req.url);
44
+ const repoUrl = url.searchParams.get("repo");
45
+ if (!repoUrl) {
46
+ return Response.json({ error: "repo param required" }, { status: 400 });
47
+ }
15
48
 
16
- const url = new URL(req.url);
17
- const repoUrl = url.searchParams.get('repo');
18
- if (!repoUrl) {
19
- return Response.json({ error: 'repo param required' }, { status: 400 });
20
- }
49
+ const user = getSessionFromRequest(req);
21
50
 
22
- const positionsJson = loadRepoPositions(user.id, repoUrl);
51
+ // Allow unauthenticated reads (for public repos)
52
+ if (!user) {
53
+ // Try to load from guest account (userId 0) or return empty
54
+ const positionsJson = loadRepoPositions(0, repoUrl);
23
55
  return Response.json({
24
- positions: positionsJson ? JSON.parse(positionsJson) : null,
25
- repoUrl,
56
+ positions: positionsJson ? JSON.parse(positionsJson) : null,
57
+ repoUrl,
58
+ authenticated: false,
26
59
  });
60
+ }
61
+
62
+ const positionsJson = loadRepoPositions(user.id, repoUrl);
63
+ return Response.json({
64
+ positions: positionsJson ? JSON.parse(positionsJson) : null,
65
+ repoUrl,
66
+ authenticated: true,
67
+ });
27
68
  }
28
69
 
29
70
  export async function POST(req: Request) {
30
- const user = getSessionFromRequest(req);
31
- if (!user) {
32
- return Response.json({ error: 'Not authenticated' }, { status: 401 });
33
- }
71
+ // Enforce leader-only writes
72
+ const isLeader = isLeaderRequest(req);
73
+ if (!isLeader) {
74
+ return Response.json(
75
+ {
76
+ error:
77
+ "Write access denied: Follower mode (read-only). Run GitMaps locally to edit canvas.",
78
+ code: "FOLLOWER_READ_ONLY",
79
+ },
80
+ { status: 403 },
81
+ );
82
+ }
83
+
84
+ const user = getSessionFromRequest(req);
34
85
 
35
- try {
36
- const body = await req.json() as {
37
- repoUrl: string;
38
- positions: Record<string, any>;
39
- };
86
+ // Allow local dev without auth (guest mode - use userId 0)
87
+ const userId = user?.id ?? 0;
40
88
 
41
- if (!body.repoUrl) {
42
- return Response.json({ error: 'repoUrl required' }, { status: 400 });
43
- }
89
+ try {
90
+ const body = (await req.json()) as {
91
+ repoUrl: string;
92
+ positions: Record<string, any>;
93
+ };
44
94
 
45
- saveRepoPositions(user.id, body.repoUrl, JSON.stringify(body.positions || {}));
46
- return Response.json({ ok: true });
47
- } catch (err: any) {
48
- return Response.json({ error: err.message }, { status: 400 });
95
+ if (!body.repoUrl) {
96
+ return Response.json({ error: "repoUrl required" }, { status: 400 });
49
97
  }
98
+
99
+ saveRepoPositions(
100
+ userId,
101
+ body.repoUrl,
102
+ JSON.stringify(body.positions || {}),
103
+ );
104
+ return Response.json({
105
+ ok: true,
106
+ mode: "leader",
107
+ syncedAt: new Date().toISOString(),
108
+ });
109
+ } catch (err: any) {
110
+ return Response.json({ error: err.message }, { status: 400 });
111
+ }
50
112
  }
@@ -0,0 +1,19 @@
1
+ import path from 'path';
2
+
3
+ function runGit(args: string[]): string {
4
+ const repoRoot = path.resolve(import.meta.dir, '../../..');
5
+ const proc = Bun.spawnSync(['git', ...args], {
6
+ cwd: repoRoot,
7
+ stdout: 'pipe',
8
+ stderr: 'pipe',
9
+ });
10
+
11
+ if (proc.exitCode !== 0) return '';
12
+ return proc.stdout.toString().trim();
13
+ }
14
+
15
+ export async function GET() {
16
+ const commit = process.env.GIT_COMMIT_HASH || runGit(['rev-parse', '--short', 'HEAD']) || 'unknown';
17
+ const commitDate = process.env.GIT_COMMIT_DATE || runGit(['log', '-1', '--format=%cs']) || '';
18
+ return Response.json({ commit, commitDate });
19
+ }
@@ -41,7 +41,12 @@ Help the user understand this file. You can:
41
41
  - Suggest refactorings
42
42
  - Explain the diff/changes
43
43
 
44
- Be concise but thorough. Use code blocks with language tags for code examples. Reference line numbers when relevant.`;
44
+ Be concise but thorough. Use code blocks with language tags for code examples. Reference line numbers when relevant.
45
+
46
+ To suggest physical refactors or file changes, ALWAYS use the following format:
47
+ <edit_file path="path/to/file.ts">
48
+ // Full replacement file content goes here
49
+ </edit_file>`;
45
50
  } else if (canvasContext) {
46
51
  systemPrompt = `You are an AI code assistant helping analyze a Git repository's commit changes.
47
52
 
@@ -51,7 +56,13 @@ CURRENT COMMIT: ${canvasContext.commitHash || 'none'} — ${canvasContext.commit
51
56
  FILES ON CANVAS:
52
57
  ${(canvasContext.files || []).map(f => `- ${f.path} (${f.status})`).join('\n')}
53
58
 
54
- Help the user understand the codebase, the commit changes, relationships between files, architecture patterns, and potential issues. Be concise and actionable.`;
59
+ Help the user understand the codebase, the commit changes, relationships between files, architecture patterns, and potential issues. Be concise and actionable.
60
+
61
+ To suggest physical refactors or file changes, ALWAYS use the following format:
62
+ <edit_file path="path/to/file.ts">
63
+ // Full replacement file content goes here
64
+ </edit_file>
65
+ You may output multiple <edit_file> blocks in a single response to perform bulk refactoring across multiple files.`;
55
66
  } else {
56
67
  systemPrompt = `You are an AI code assistant. Help the user with their coding questions. Be concise and use code blocks with language tags.`;
57
68
  }
@@ -0,0 +1,20 @@
1
+ // GET /api/manifest.json — PWA Web App Manifest
2
+ export function GET() {
3
+ return Response.json({
4
+ name: 'GitMaps — Spatial Code Explorer',
5
+ short_name: 'GitMaps',
6
+ description: 'Transcend the file tree. Explore code on an infinite canvas with layers, time-travel, and a minimap.',
7
+ start_url: '/',
8
+ display: 'standalone',
9
+ background_color: '#0a0a0f',
10
+ theme_color: '#7c3aed',
11
+ orientation: 'any',
12
+ categories: ['developer-tools', 'productivity'],
13
+ icons: [
14
+ { src: '/api/pwa-icon?size=192', sizes: '192x192', type: 'image/png' },
15
+ { src: '/api/pwa-icon?size=512', sizes: '512x512', type: 'image/png' },
16
+ ],
17
+ }, {
18
+ headers: { 'Content-Type': 'application/manifest+json' },
19
+ });
20
+ }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const ogBuffer = readFileSync(join(import.meta.dir, '..', '..', 'og-image.png'));
5
+
6
+ // GET /api/og-image — serves the Open Graph social sharing image
7
+ export function GET() {
8
+ return new Response(ogBuffer, {
9
+ headers: {
10
+ 'Content-Type': 'image/png',
11
+ 'Cache-Control': 'public, max-age=86400',
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const iconBuffer = readFileSync(join(import.meta.dir, '..', '..', 'icon.png'));
5
+
6
+ // GET /api/pwa-icon — serves the app icon (any size param ignored, returns PNG)
7
+ export function GET() {
8
+ return new Response(iconBuffer, {
9
+ headers: {
10
+ 'Content-Type': 'image/png',
11
+ 'Cache-Control': 'public, max-age=86400',
12
+ },
13
+ });
14
+ }
@@ -1,7 +1,7 @@
1
1
  import { measure } from 'measure-fn';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
- import { spawn } from 'child_process';
4
+
5
5
 
6
6
  const CLONES_DIR = path.join(process.cwd(), 'git-canvas', 'repos');
7
7
 
@@ -46,8 +46,8 @@ export async function POST(req: Request) {
46
46
  if (fs.existsSync(path.join(targetPath, '.git'))) {
47
47
  return measure('api:repo:clone-stream:cached', async () => {
48
48
  try {
49
- const pull = spawn('git', ['pull'], { cwd: targetPath, stdio: 'pipe' });
50
- await new Promise<void>((resolve) => pull.on('close', resolve));
49
+ const pull = Bun.spawn(['git', 'pull'], { cwd: targetPath, stdio: ['ignore', 'pipe', 'pipe'] });
50
+ await pull.exited;
51
51
  console.log(`[clone-stream] Updated existing repo: ${repoName}`);
52
52
  } catch {
53
53
  console.log(`[clone-stream] Using existing repo (pull skipped): ${repoName}`);
@@ -69,7 +69,7 @@ export async function POST(req: Request) {
69
69
 
70
70
  sendSSE('progress', { message: `Starting clone of ${repoName}...`, percent: 0 });
71
71
 
72
- const gitProc = spawn('git', ['clone', '--depth', '100', '--progress', url, targetPath], {
72
+ const gitProc = Bun.spawn(['git', 'clone', '--depth', '100', '--progress', url, targetPath], {
73
73
  stdio: ['ignore', 'pipe', 'pipe']
74
74
  });
75
75
 
@@ -99,10 +99,20 @@ export async function POST(req: Request) {
99
99
  }
100
100
  }
101
101
 
102
- gitProc.stderr.on('data', parseProgress);
103
- gitProc.stdout.on('data', parseProgress);
102
+ async function consumeStream(stream: ReadableStream) {
103
+ try {
104
+ for await (const chunk of stream) {
105
+ parseProgress(Buffer.from(chunk));
106
+ }
107
+ } catch (e) {
108
+ console.error('[clone-stream] stream parse error:', e);
109
+ }
110
+ }
104
111
 
105
- gitProc.on('close', (code) => {
112
+ if (gitProc.stderr) consumeStream(gitProc.stderr);
113
+ if (gitProc.stdout) consumeStream(gitProc.stdout);
114
+
115
+ gitProc.exited.then(code => {
106
116
  if (code === 0) {
107
117
  console.log(`[clone-stream] ✅ Cloned ${repoName}`);
108
118
  sendSSE('done', { ok: true, path: targetPath, cached: false });
@@ -110,13 +120,11 @@ export async function POST(req: Request) {
110
120
  console.error(`[clone-stream] ✗ git clone exited with code ${code}`);
111
121
  sendSSE('error', { error: `git clone failed (exit code ${code})` });
112
122
  }
113
- try { controller.close(); } catch { /* already closed */ }
114
- });
115
-
116
- gitProc.on('error', (err) => {
123
+ try { controller.close(); } catch { }
124
+ }).catch(err => {
117
125
  console.error('[clone-stream] spawn error:', err);
118
126
  sendSSE('error', { error: err.message || 'Failed to start git' });
119
- try { controller.close(); } catch { /* already closed */ }
127
+ try { controller.close(); } catch { }
120
128
  });
121
129
  }
122
130
  });
@@ -1,28 +1,81 @@
1
- import { measure } from 'measure-fn';
2
- import simpleGit from 'simple-git';
3
- import { validateRepoPath } from '../validate-path';
1
+ import { measure } from "measure-fn";
2
+ import simpleGit from "simple-git";
3
+ import { readFileSync } from "fs";
4
+ import path from "path";
5
+ import { validateRepoPath } from "../validate-path";
6
+
7
+ const MIME_TYPES: Record<string, string> = {
8
+ ".png": "image/png",
9
+ ".jpg": "image/jpeg",
10
+ ".jpeg": "image/jpeg",
11
+ ".gif": "image/gif",
12
+ ".webp": "image/webp",
13
+ ".svg": "image/svg+xml",
14
+ ".bmp": "image/bmp",
15
+ ".ico": "image/x-icon",
16
+ };
4
17
 
5
18
  export async function POST(req: Request) {
6
- return measure('api:repo:file-content', async () => {
7
- try {
8
- const { path: repoPath, commit, filePath } = await req.json();
19
+ return measure("api:repo:file-content", async () => {
20
+ try {
21
+ const { path: repoPath, commit, filePath } = await req.json();
22
+
23
+ if (!repoPath || !commit || !filePath) {
24
+ return new Response(
25
+ "Repository path, commit, and file path are required",
26
+ { status: 400 },
27
+ );
28
+ }
29
+
30
+ const blocked = validateRepoPath(repoPath);
31
+ if (blocked) return blocked;
32
+
33
+ const git = simpleGit(repoPath);
34
+ const content = await git.show([`${commit}:${filePath}`]);
35
+
36
+ return Response.json({ content });
37
+ } catch (error: any) {
38
+ console.error("api:repo:file-content:error", error);
39
+ return new Response(`Error: ${error.message}`, { status: 500 });
40
+ }
41
+ });
42
+ }
43
+
44
+ export async function GET(req: Request) {
45
+ return measure("api:repo:file-image", async () => {
46
+ try {
47
+ const url = new URL(req.url);
48
+ const repoPath = url.searchParams.get("path");
49
+ const file = url.searchParams.get("file");
50
+
51
+ if (!repoPath || !file) {
52
+ return new Response("Repository path and file are required", {
53
+ status: 400,
54
+ });
55
+ }
9
56
 
10
- if (!repoPath || !commit || !filePath) {
11
- return new Response('Repository path, commit, and file path are required', { status: 400 });
12
- }
57
+ const blocked = validateRepoPath(repoPath);
58
+ if (blocked) return blocked;
13
59
 
14
- const blocked = validateRepoPath(repoPath);
15
- if (blocked) return blocked;
60
+ const ext = path.extname(file).toLowerCase();
61
+ const mimeType = MIME_TYPES[ext];
16
62
 
17
- const git = simpleGit(repoPath);
63
+ if (!mimeType) {
64
+ return new Response("Not an image file", { status: 400 });
65
+ }
18
66
 
19
- // Get file content at this commit
20
- const content = await git.show([`${commit}:${filePath}`]);
67
+ const fullPath = path.join(repoPath, file);
68
+ const buffer = readFileSync(fullPath);
21
69
 
22
- return Response.json({ content });
23
- } catch (error: any) {
24
- console.error('api:repo:file-content:error', error);
25
- return new Response(`Error: ${error.message}`, { status: 500 });
26
- }
27
- });
70
+ return new Response(buffer, {
71
+ headers: {
72
+ "Content-Type": mimeType,
73
+ "Cache-Control": "public, max-age=31536000",
74
+ },
75
+ });
76
+ } catch (error: any) {
77
+ console.error("api:repo:file-image:error", error);
78
+ return new Response(`Error: ${error.message}`, { status: 500 });
79
+ }
80
+ });
28
81
  }
@@ -1,6 +1,7 @@
1
1
  import { measure } from 'measure-fn';
2
2
  import simpleGit from 'simple-git';
3
3
  import { validateRepoPath } from '../validate-path';
4
+ import { join } from 'path';
4
5
 
5
6
  /**
6
7
  * POST /api/repo/imports
@@ -96,13 +97,30 @@ export async function POST(req: Request) {
96
97
  });
97
98
 
98
99
  // Scan each source file for imports (limit to first 200 files for perf)
99
- const filesToScan = sourceFiles.slice(0, 200);
100
+ const filesToScan = sourceFiles.slice(0, 300);
100
101
  const edges: { source: string; target: string; line: number }[] = [];
101
102
 
103
+ const isWorkingTree = !commit || commit === 'allfiles' || commit === 'HEAD' || commit === '';
104
+
102
105
  await Promise.allSettled(filesToScan.map(async (filePath) => {
103
106
  try {
104
- const content = await git.show([`${commit}:${filePath}`]);
105
- const lines = content.split('\n');
107
+ let text = '';
108
+ if (isWorkingTree) {
109
+ try {
110
+ const file = Bun.file(join(repoPath, filePath));
111
+ if (await file.exists()) {
112
+ text = await file.text();
113
+ }
114
+ } catch {
115
+ // Fallback if failed
116
+ }
117
+ }
118
+ if (!text) {
119
+ text = await git.show([`${commit === 'allfiles' ? 'HEAD' : commit}:${filePath}`]);
120
+ }
121
+ if (!text) return;
122
+
123
+ const lines = text.split('\n');
106
124
 
107
125
  for (let i = 0; i < Math.min(lines.length, 100); i++) {
108
126
  // Only scan first 100 lines (imports are at the top)
@@ -0,0 +1,30 @@
1
+ // app/api/repo/list/route.ts — Lists repos from the git-canvas/repos directory
2
+ import { readdirSync, statSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ export async function GET() {
6
+ const reposDir = join(process.cwd(), 'git-canvas', 'repos');
7
+ const repos: { name: string; path: string }[] = [];
8
+
9
+ if (existsSync(reposDir)) {
10
+ try {
11
+ const entries = readdirSync(reposDir);
12
+ for (const entry of entries) {
13
+ const fullPath = join(reposDir, entry);
14
+ try {
15
+ const stat = statSync(fullPath);
16
+ if (stat.isDirectory()) {
17
+ // Check if it's a git repo (has .git)
18
+ const isGit = existsSync(join(fullPath, '.git'));
19
+ repos.push({
20
+ name: entry,
21
+ path: fullPath.replace(/\\/g, '/'),
22
+ });
23
+ }
24
+ } catch { }
25
+ }
26
+ } catch { }
27
+ }
28
+
29
+ return Response.json({ repos });
30
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { extractCanonicalForgeSlugInfo } from './route';
3
+
4
+ describe('extractCanonicalForgeSlugInfo', () => {
5
+ test('parses GitHub HTTPS remote', () => {
6
+ expect(
7
+ extractCanonicalForgeSlugInfo('https://github.com/7flash/gitmaps.git')
8
+ ).toEqual({
9
+ slug: '7flash/gitmaps',
10
+ source: 'github.com · https://github.com/7flash/gitmaps',
11
+ });
12
+ });
13
+
14
+ test('parses GitHub SSH remote', () => {
15
+ expect(
16
+ extractCanonicalForgeSlugInfo('git@github.com:7flash/gitmaps.git')
17
+ ).toEqual({
18
+ slug: '7flash/gitmaps',
19
+ source: 'github.com · git@github.com:7flash/gitmaps',
20
+ });
21
+ });
22
+
23
+ test('preserves deep GitLab namespace', () => {
24
+ expect(
25
+ extractCanonicalForgeSlugInfo('git@gitlab.com:team/platform/tools/gitmaps.git')
26
+ ).toEqual({
27
+ slug: 'team/platform/tools/gitmaps',
28
+ source: 'gitlab.com · git@gitlab.com:team/platform/tools/gitmaps',
29
+ });
30
+ });
31
+
32
+ test('filters forge helper path segments like scm', () => {
33
+ expect(
34
+ extractCanonicalForgeSlugInfo('https://git.example.com/scm/team/gitmaps.git')
35
+ ).toEqual({
36
+ slug: 'team/gitmaps',
37
+ source: 'git.example.com · https://git.example.com/scm/team/gitmaps',
38
+ });
39
+ });
40
+
41
+ test('returns null slug for too-deep namespaces', () => {
42
+ expect(
43
+ extractCanonicalForgeSlugInfo('https://git.example.com/a/b/c/d/e/f.git')
44
+ ).toEqual({
45
+ slug: null,
46
+ source: 'https://git.example.com/a/b/c/d/e/f',
47
+ });
48
+ });
49
+
50
+ test('returns null slug for invalid segments', () => {
51
+ expect(
52
+ extractCanonicalForgeSlugInfo('https://git.example.com/team/bad:name.git')
53
+ ).toEqual({
54
+ slug: null,
55
+ source: 'https://git.example.com/team/bad:name',
56
+ });
57
+ });
58
+
59
+ test('handles missing remote gracefully', () => {
60
+ expect(extractCanonicalForgeSlugInfo(null)).toEqual({ slug: null, source: '' });
61
+ });
62
+ });
@@ -3,6 +3,33 @@ import simpleGit from 'simple-git';
3
3
  import path from 'path';
4
4
  import { validateRepoPath } from '../validate-path';
5
5
 
6
+ export function extractCanonicalForgeSlugInfo(remoteUrl?: string | null): { slug: string | null; source: string } {
7
+ if (!remoteUrl) return { slug: null, source: '' };
8
+
9
+ const normalized = remoteUrl.trim().replace(/\.git$/i, '');
10
+
11
+ const sshMatch = normalized.match(/^[^@]+@([^:]+):(.+)$/);
12
+ const httpsMatch = normalized.match(/^(?:https?|ssh):\/\/([^/]+)\/(.+)$/i);
13
+ const host = sshMatch?.[1] || httpsMatch?.[1] || '';
14
+ const pathPart = sshMatch?.[2] || httpsMatch?.[2];
15
+ if (!pathPart) return { slug: null, source: normalized };
16
+
17
+ const segments = pathPart
18
+ .split('/')
19
+ .map(s => s.trim())
20
+ .filter(Boolean)
21
+ .filter(s => s !== '-' && s !== 'scm');
22
+
23
+ if (segments.length < 2) return { slug: null, source: normalized };
24
+ if (segments.length > 5) return { slug: null, source: normalized };
25
+ if (segments.some(part => /[:\\]/.test(part))) return { slug: null, source: normalized };
26
+
27
+ return {
28
+ slug: segments.join('/'),
29
+ source: host ? `${host} · ${normalized}` : normalized,
30
+ };
31
+ }
32
+
6
33
  export async function POST(req: Request) {
7
34
  return measure('api:repo:load', async () => {
8
35
  try {
@@ -47,7 +74,20 @@ export async function POST(req: Request) {
47
74
  refs: commit.refs ? commit.refs.split(',').map(r => r.trim()).filter(Boolean) : []
48
75
  }));
49
76
 
50
- return Response.json({ commits });
77
+ let canonicalSlug: string | null = null;
78
+ let canonicalSlugSource = '';
79
+ try {
80
+ const remotes = await git.getRemotes(true);
81
+ const origin = remotes.find(r => r.name === 'origin') || remotes[0];
82
+ const info = extractCanonicalForgeSlugInfo(origin?.refs?.fetch || origin?.refs?.push || null);
83
+ canonicalSlug = info.slug;
84
+ canonicalSlugSource = info.source;
85
+ } catch {
86
+ canonicalSlug = null;
87
+ canonicalSlugSource = '';
88
+ }
89
+
90
+ return Response.json({ commits, canonicalSlug, canonicalSlugSource });
51
91
  } catch (error: any) {
52
92
  console.error('api:repo:load:error', error);
53
93
  return new Response(`Error: ${error.message}`, { status: 500 });