gitmaps 1.0.0

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 +167 -0
  2. package/app/api/auth/favorites/route.ts +56 -0
  3. package/app/api/auth/github/callback/route.ts +103 -0
  4. package/app/api/auth/github/route.ts +32 -0
  5. package/app/api/auth/me/route.ts +52 -0
  6. package/app/api/auth/positions/route.ts +50 -0
  7. package/app/api/chat/route.ts +101 -0
  8. package/app/api/connections/route.ts +72 -0
  9. package/app/api/github/repos/route.ts +111 -0
  10. package/app/api/positions/route.ts +80 -0
  11. package/app/api/repo/branch-diff/route.ts +201 -0
  12. package/app/api/repo/branches/route.ts +53 -0
  13. package/app/api/repo/browse/route.ts +55 -0
  14. package/app/api/repo/clone/route.ts +78 -0
  15. package/app/api/repo/clone-stream/route.ts +131 -0
  16. package/app/api/repo/file-content/route.ts +28 -0
  17. package/app/api/repo/file-delete/route.ts +62 -0
  18. package/app/api/repo/file-history/route.ts +45 -0
  19. package/app/api/repo/file-rename/route.ts +83 -0
  20. package/app/api/repo/file-save/route.ts +45 -0
  21. package/app/api/repo/files/route.ts +169 -0
  22. package/app/api/repo/git-blame/route.ts +86 -0
  23. package/app/api/repo/git-commit/route.ts +40 -0
  24. package/app/api/repo/git-heatmap/route.ts +55 -0
  25. package/app/api/repo/imports/route.ts +154 -0
  26. package/app/api/repo/load/route.ts +56 -0
  27. package/app/api/repo/mode/route.ts +14 -0
  28. package/app/api/repo/search/route.ts +127 -0
  29. package/app/api/repo/tree/route.ts +104 -0
  30. package/app/api/repo/upload/route.ts +53 -0
  31. package/app/api/repo/validate-path.ts +53 -0
  32. package/app/canvas_users.db +0 -0
  33. package/app/canvas_users.db-shm +0 -0
  34. package/app/canvas_users.db-wal +0 -0
  35. package/app/globals.css +7899 -0
  36. package/app/layout.tsx +493 -0
  37. package/app/lib/auth.ts +193 -0
  38. package/app/lib/auto-save.ts +137 -0
  39. package/app/lib/branch-compare.ts +443 -0
  40. package/app/lib/breadcrumbs.ts +170 -0
  41. package/app/lib/canvas-export.ts +358 -0
  42. package/app/lib/canvas-text.ts +912 -0
  43. package/app/lib/canvas.ts +564 -0
  44. package/app/lib/card-arrangement.ts +188 -0
  45. package/app/lib/card-context-menu.tsx +453 -0
  46. package/app/lib/card-diff-markers.ts +270 -0
  47. package/app/lib/card-expand.ts +189 -0
  48. package/app/lib/card-groups.ts +246 -0
  49. package/app/lib/cards.tsx +914 -0
  50. package/app/lib/chat.tsx +308 -0
  51. package/app/lib/code-editor.ts +508 -0
  52. package/app/lib/command-palette.ts +262 -0
  53. package/app/lib/connections.tsx +1037 -0
  54. package/app/lib/context.ts +94 -0
  55. package/app/lib/cursor-sharing.ts +281 -0
  56. package/app/lib/dependency-graph.ts +438 -0
  57. package/app/lib/events.tsx +1747 -0
  58. package/app/lib/file-card-plugin.ts +134 -0
  59. package/app/lib/file-modal.tsx +849 -0
  60. package/app/lib/file-preview.ts +400 -0
  61. package/app/lib/file-tabs.ts +318 -0
  62. package/app/lib/galaxydraw-bridge.ts +477 -0
  63. package/app/lib/galaxydraw.test.ts +229 -0
  64. package/app/lib/global-search.ts +264 -0
  65. package/app/lib/goto-definition.ts +224 -0
  66. package/app/lib/heatmap.ts +178 -0
  67. package/app/lib/hidden-files.tsx +222 -0
  68. package/app/lib/layers.ts +0 -0
  69. package/app/lib/layers.tsx +365 -0
  70. package/app/lib/loading.tsx +45 -0
  71. package/app/lib/multi-repo.ts +286 -0
  72. package/app/lib/new-file-dialog.tsx +230 -0
  73. package/app/lib/onboarding.tsx +213 -0
  74. package/app/lib/perf-overlay.ts +360 -0
  75. package/app/lib/positions.ts +176 -0
  76. package/app/lib/pr-review.ts +374 -0
  77. package/app/lib/production-mode.ts +47 -0
  78. package/app/lib/repo.tsx +977 -0
  79. package/app/lib/settings-modal.tsx +374 -0
  80. package/app/lib/settings.ts +97 -0
  81. package/app/lib/shortcuts-panel.ts +141 -0
  82. package/app/lib/status-bar.ts +128 -0
  83. package/app/lib/symbol-outline.ts +212 -0
  84. package/app/lib/syntax.ts +177 -0
  85. package/app/lib/tab-diff.ts +238 -0
  86. package/app/lib/user.tsx +133 -0
  87. package/app/lib/utils.ts +78 -0
  88. package/app/lib/viewport-culling.ts +728 -0
  89. package/app/page.client.tsx +215 -0
  90. package/app/page.tsx +291 -0
  91. package/app/state/machine.js +196 -0
  92. package/app/styles/main.css +2168 -0
  93. package/banner.png +0 -0
  94. package/cli.ts +44 -0
  95. package/package.json +75 -0
  96. package/packages/galaxydraw/README.md +296 -0
  97. package/packages/galaxydraw/banner.png +0 -0
  98. package/packages/galaxydraw/demo/build-static.ts +100 -0
  99. package/packages/galaxydraw/demo/client.ts +154 -0
  100. package/packages/galaxydraw/demo/dist/client.js +8 -0
  101. package/packages/galaxydraw/demo/index.html +256 -0
  102. package/packages/galaxydraw/demo/server.ts +96 -0
  103. package/packages/galaxydraw/dist/index.js +984 -0
  104. package/packages/galaxydraw/dist/index.js.map +16 -0
  105. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  106. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  109. package/packages/galaxydraw/package.json +49 -0
  110. package/packages/galaxydraw/perf.test.ts +284 -0
  111. package/packages/galaxydraw/src/core/cards.ts +435 -0
  112. package/packages/galaxydraw/src/core/engine.ts +339 -0
  113. package/packages/galaxydraw/src/core/events.ts +81 -0
  114. package/packages/galaxydraw/src/core/layout.ts +136 -0
  115. package/packages/galaxydraw/src/core/minimap.ts +216 -0
  116. package/packages/galaxydraw/src/core/state.ts +177 -0
  117. package/packages/galaxydraw/src/core/viewport.ts +106 -0
  118. package/packages/galaxydraw/src/galaxydraw.css +166 -0
  119. package/packages/galaxydraw/src/index.ts +40 -0
  120. package/packages/galaxydraw/tsconfig.json +30 -0
  121. package/server.ts +62 -0
@@ -0,0 +1,111 @@
1
+ import { measure } from 'measure-fn';
2
+
3
+ /**
4
+ * GET /api/github/repos?user=<username>&page=<n>&sort=<updated|stars|name>
5
+ * Fetches public repositories from GitHub API for a given user/org.
6
+ * No auth required for public repos (60 req/hr rate limit).
7
+ */
8
+ export async function GET(req: Request) {
9
+ return measure('api:github:repos', async () => {
10
+ try {
11
+ const url = new URL(req.url);
12
+ const user = url.searchParams.get('user')?.trim();
13
+ const page = parseInt(url.searchParams.get('page') || '1');
14
+ const sort = url.searchParams.get('sort') || 'updated';
15
+ const perPage = 30;
16
+
17
+ if (!user) {
18
+ return Response.json({ error: 'user parameter is required' }, { status: 400 });
19
+ }
20
+
21
+ // Try as user first, then as org
22
+ let ghUrl = `https://api.github.com/users/${encodeURIComponent(user)}/repos?per_page=${perPage}&page=${page}&sort=${sort}&direction=desc`;
23
+
24
+ const headers: Record<string, string> = {
25
+ 'Accept': 'application/vnd.github.v3+json',
26
+ 'User-Agent': 'GitCanvas/1.0',
27
+ };
28
+
29
+ // Use GITHUB_TOKEN if available for higher rate limits
30
+ const token = process.env.GITHUB_TOKEN;
31
+ if (token) {
32
+ headers['Authorization'] = `Bearer ${token}`;
33
+ }
34
+
35
+ let res = await fetch(ghUrl, { headers });
36
+
37
+ // If user 404, try as org
38
+ if (res.status === 404) {
39
+ ghUrl = `https://api.github.com/orgs/${encodeURIComponent(user)}/repos?per_page=${perPage}&page=${page}&sort=${sort}&direction=desc`;
40
+ res = await fetch(ghUrl, { headers });
41
+ }
42
+
43
+ if (res.status === 403) {
44
+ const rateLimitReset = res.headers.get('x-ratelimit-reset');
45
+ const resetTime = rateLimitReset ? new Date(parseInt(rateLimitReset) * 1000).toLocaleTimeString() : 'soon';
46
+ return Response.json({
47
+ error: `GitHub API rate limit exceeded. Resets at ${resetTime}. Set GITHUB_TOKEN env var for higher limits.`
48
+ }, { status: 429 });
49
+ }
50
+
51
+ if (!res.ok) {
52
+ return Response.json({ error: `GitHub user/org "${user}" not found` }, { status: 404 });
53
+ }
54
+
55
+ const repos = await res.json();
56
+
57
+ // Parse Link header for pagination
58
+ const linkHeader = res.headers.get('link') || '';
59
+ const hasNext = linkHeader.includes('rel="next"');
60
+ const hasPrev = page > 1;
61
+
62
+ // Extract useful fields
63
+ const items = repos.map((r: any) => ({
64
+ name: r.name,
65
+ full_name: r.full_name,
66
+ description: r.description || '',
67
+ clone_url: r.clone_url,
68
+ html_url: r.html_url,
69
+ language: r.language,
70
+ stars: r.stargazers_count,
71
+ forks: r.forks_count,
72
+ updated_at: r.updated_at,
73
+ size: r.size, // KB
74
+ default_branch: r.default_branch,
75
+ is_fork: r.fork,
76
+ topics: r.topics || [],
77
+ }));
78
+
79
+ // Also fetch user/org profile info
80
+ let profile = null;
81
+ try {
82
+ const profileRes = await fetch(`https://api.github.com/users/${encodeURIComponent(user)}`, { headers });
83
+ if (profileRes.ok) {
84
+ const p = await profileRes.json();
85
+ profile = {
86
+ login: p.login,
87
+ name: p.name,
88
+ avatar_url: p.avatar_url,
89
+ bio: p.bio,
90
+ public_repos: p.public_repos,
91
+ type: p.type, // "User" or "Organization"
92
+ };
93
+ }
94
+ } catch { /* ignore profile fetch errors */ }
95
+
96
+ return Response.json({
97
+ repos: items,
98
+ page,
99
+ hasNext,
100
+ hasPrev,
101
+ profile,
102
+ });
103
+ } catch (error: any) {
104
+ console.error('api:github:repos:error', error);
105
+ return Response.json(
106
+ { error: error.message || 'Failed to fetch repos' },
107
+ { status: 500 }
108
+ );
109
+ }
110
+ });
111
+ }
@@ -0,0 +1,80 @@
1
+ import { measure, measureSync } from 'measure-fn';
2
+ import { Database, z } from 'sqlite-zod-orm';
3
+ import path from 'path';
4
+
5
+ const dbPath = path.join(process.cwd(), 'db', 'positions_v3.sqlite');
6
+
7
+ const db = new Database(dbPath, {
8
+ positions: z.object({
9
+ commit_hash: z.string(),
10
+ file_path: z.string(),
11
+ x: z.number(),
12
+ y: z.number(),
13
+ width: z.number().optional(),
14
+ height: z.number().optional(),
15
+ }),
16
+ }, {
17
+ indexes: { positions: ['commit_hash'] },
18
+ reactive: false,
19
+ });
20
+
21
+ export async function GET(req: Request) {
22
+ return measure('api:positions:get', async () => {
23
+ try {
24
+ const url = new URL(req.url);
25
+ const commitHash = url.searchParams.get('commit');
26
+
27
+ const query = commitHash
28
+ ? db.positions.select().where({ commit_hash: commitHash })
29
+ : db.positions.select();
30
+
31
+ const positions = query.all();
32
+
33
+ // Convert to map format
34
+ const positionMap: Record<string, { x: number; y: number; width?: number; height?: number }> = {};
35
+ for (const pos of positions) {
36
+ positionMap[`${pos.commit_hash}:${pos.file_path}`] = {
37
+ x: pos.x,
38
+ y: pos.y,
39
+ width: pos.width ?? undefined,
40
+ height: pos.height ?? undefined,
41
+ };
42
+ }
43
+
44
+ return Response.json(positionMap);
45
+ } catch (error: any) {
46
+ console.error('api:positions:get:error', error);
47
+ return new Response(`Error: ${error.message}`, { status: 500 });
48
+ }
49
+ });
50
+ }
51
+
52
+ export async function POST(req: Request) {
53
+ return measure('api:positions:save', async () => {
54
+ try {
55
+ const body = await req.json();
56
+ const { commitHash, filePath, x, y, width, height } = body;
57
+
58
+ if (!commitHash || !filePath || x === undefined || y === undefined) {
59
+ return new Response('commitHash, filePath, x, and y are required', { status: 400 });
60
+ }
61
+
62
+ db.positions.upsert(
63
+ { commit_hash: commitHash, file_path: filePath },
64
+ {
65
+ commit_hash: commitHash,
66
+ file_path: filePath,
67
+ x,
68
+ y,
69
+ width: width || undefined,
70
+ height: height || undefined,
71
+ },
72
+ );
73
+
74
+ return Response.json({ success: true });
75
+ } catch (error: any) {
76
+ console.error('api:positions:save:error', error);
77
+ return new Response(`Error: ${error.message}`, { status: 500 });
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,201 @@
1
+ import { measure } from 'measure-fn';
2
+ import simpleGit from 'simple-git';
3
+ import { validateRepoPath } from '../validate-path';
4
+
5
+ /**
6
+ * POST /api/repo/branch-diff
7
+ *
8
+ * Compare two branches and return the diff (changed files with hunks).
9
+ * Body: { path: string, base: string, compare: string }
10
+ *
11
+ * Returns the same shape as /api/repo/files (commit diff) so the
12
+ * existing card rendering (createFileCard, DiffCardPlugin) can be
13
+ * reused without changes.
14
+ */
15
+
16
+ interface DiffLine { type: string; content: string }
17
+ interface DiffHunk {
18
+ oldStart: number; oldCount: number;
19
+ newStart: number; newCount: number;
20
+ context: string; lines: DiffLine[];
21
+ }
22
+
23
+ function parseHunks(rawDiff: string): DiffHunk[] {
24
+ const allLines = rawDiff.split('\n');
25
+ const hunks: DiffHunk[] = [];
26
+ let currentHunk: DiffHunk | null = null;
27
+
28
+ for (const line of allLines) {
29
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
30
+ if (hunkMatch) {
31
+ if (currentHunk) hunks.push(currentHunk);
32
+ currentHunk = {
33
+ oldStart: parseInt(hunkMatch[1]),
34
+ oldCount: parseInt(hunkMatch[2] || '1'),
35
+ newStart: parseInt(hunkMatch[3]),
36
+ newCount: parseInt(hunkMatch[4] || '1'),
37
+ context: hunkMatch[5]?.trim() || '',
38
+ lines: [],
39
+ };
40
+ continue;
41
+ }
42
+
43
+ if (line.startsWith('diff ') || line.startsWith('index ') ||
44
+ line.startsWith('---') || line.startsWith('+++') ||
45
+ line.startsWith('similarity ') || line.startsWith('rename ') ||
46
+ line.startsWith('copy ')) continue;
47
+
48
+ if (!currentHunk) continue;
49
+
50
+ if (line.startsWith('+')) {
51
+ currentHunk.lines.push({ type: 'add', content: line.substring(1) });
52
+ } else if (line.startsWith('-')) {
53
+ currentHunk.lines.push({ type: 'del', content: line.substring(1) });
54
+ } else if (line.startsWith('\\')) {
55
+ // skip ""
56
+ } else {
57
+ currentHunk.lines.push({ type: 'ctx', content: line.startsWith(' ') ? line.substring(1) : line });
58
+ }
59
+ }
60
+
61
+ if (currentHunk) hunks.push(currentHunk);
62
+ return hunks;
63
+ }
64
+
65
+ export async function POST(req: Request) {
66
+ return measure('api:repo:branch-diff', async () => {
67
+ try {
68
+ const { path: repoPath, base, compare } = await req.json();
69
+
70
+ if (!repoPath || !base || !compare) {
71
+ return new Response('path, base, and compare are required', { status: 400 });
72
+ }
73
+
74
+ const blocked = validateRepoPath(repoPath);
75
+ if (blocked) return blocked;
76
+
77
+ const git = simpleGit(repoPath);
78
+
79
+ // Get list of changed files between the two branches
80
+ const nameStatus = await git.raw([
81
+ 'diff', '--name-status', '-M30%', `${base}...${compare}`
82
+ ]);
83
+
84
+ if (!nameStatus.trim()) {
85
+ // Still fetch branches even when no diff
86
+ const branchSummary = await git.branchLocal();
87
+ return Response.json({
88
+ files: [],
89
+ totalChanged: 0,
90
+ base,
91
+ compare,
92
+ mergeBase: base,
93
+ branches: branchSummary.all,
94
+ });
95
+ }
96
+
97
+ // Get merge base for accurate stats
98
+ let mergeBase = base;
99
+ try {
100
+ mergeBase = (await git.raw(['merge-base', base, compare])).trim();
101
+ } catch { /* use base as fallback */ }
102
+
103
+ const changedFiles = [];
104
+ const lines = nameStatus.trim().split('\n').filter(Boolean);
105
+
106
+ // Get branches list for the response
107
+ const branchSummary = await git.branchLocal();
108
+
109
+ for (const line of lines) {
110
+ const parts = line.split('\t');
111
+ const statusCode = parts[0];
112
+ if (!statusCode || parts.length < 2) continue;
113
+
114
+ const isRename = statusCode.startsWith('R');
115
+ const isCopy = statusCode.startsWith('C');
116
+
117
+ let filePath: string;
118
+ let oldPath: string | null = null;
119
+ let fileStatus: string;
120
+ let similarity: number | null = null;
121
+
122
+ if (isRename || isCopy) {
123
+ oldPath = parts[1];
124
+ filePath = parts[2];
125
+ fileStatus = isRename ? 'renamed' : 'copied';
126
+ similarity = parseInt(statusCode.substring(1)) || null;
127
+ } else {
128
+ filePath = parts[1];
129
+ fileStatus = statusCode === 'A' ? 'added'
130
+ : statusCode === 'D' ? 'deleted'
131
+ : statusCode === 'M' ? 'modified'
132
+ : statusCode;
133
+ }
134
+
135
+ const name = filePath.split('/').pop()!;
136
+
137
+ let content = null;
138
+ let hunks: DiffHunk[] = [];
139
+ let error = null;
140
+
141
+ if (fileStatus === 'added') {
142
+ try { content = await git.show([`${compare}:${filePath}`]); } catch (e: any) { error = e.message; }
143
+ } else if (fileStatus === 'deleted') {
144
+ try { content = await git.show([`${base}:${filePath}`]); } catch (e: any) { error = e.message; }
145
+ } else if (fileStatus === 'modified') {
146
+ try {
147
+ const rawDiff = await git.raw(['diff', '-U3', `${base}...${compare}`, '--', filePath]);
148
+ hunks = parseHunks(rawDiff);
149
+ } catch (e: any) { error = e.message; }
150
+ try { content = await git.show([`${compare}:${filePath}`]); } catch { /* hunks enough */ }
151
+ } else if (fileStatus === 'renamed' || fileStatus === 'copied') {
152
+ try {
153
+ const rawDiff = await git.raw([
154
+ 'diff', '-U3', '-M',
155
+ `${base}...${compare}`,
156
+ '--', oldPath!, filePath
157
+ ]);
158
+ hunks = parseHunks(rawDiff);
159
+ } catch (e: any) { error = e.message; }
160
+ try { content = await git.show([`${compare}:${filePath}`]); } catch { /* ignore */ }
161
+ }
162
+
163
+ changedFiles.push({
164
+ path: filePath,
165
+ name,
166
+ type: 'file',
167
+ status: fileStatus,
168
+ content,
169
+ hunks,
170
+ contentError: error,
171
+ lines: content ? content.split('\n').length : 0,
172
+ ...(oldPath ? { oldPath } : {}),
173
+ ...(similarity != null ? { similarity } : {}),
174
+ });
175
+ }
176
+
177
+ // Stats summary
178
+ let totalAdd = 0, totalDel = 0;
179
+ try {
180
+ const statOutput = await git.raw(['diff', '--stat', `${base}...${compare}`]);
181
+ const statMatch = statOutput.match(/(\d+) insertions?\(\+\)/);
182
+ const delMatch = statOutput.match(/(\d+) deletions?\(-\)/);
183
+ totalAdd = statMatch ? parseInt(statMatch[1]) : 0;
184
+ totalDel = delMatch ? parseInt(delMatch[1]) : 0;
185
+ } catch { /* ignore */ }
186
+
187
+ return Response.json({
188
+ files: changedFiles,
189
+ totalChanged: changedFiles.length,
190
+ base,
191
+ compare,
192
+ mergeBase,
193
+ stats: { totalAdd, totalDel },
194
+ branches: branchSummary.all,
195
+ });
196
+ } catch (error: any) {
197
+ console.error('api:repo:branch-diff:error', error);
198
+ return new Response(`Error: ${error.message}`, { status: 500 });
199
+ }
200
+ });
201
+ }
@@ -0,0 +1,53 @@
1
+ import { measure } from 'measure-fn';
2
+ import simpleGit from 'simple-git';
3
+ import { validateRepoPath } from '../validate-path';
4
+
5
+ /**
6
+ * POST /api/repo/branches
7
+ *
8
+ * List all branches (local + optionally remote) for a repository.
9
+ * Body: { path: string, includeRemote?: boolean }
10
+ *
11
+ * Returns: { branches: string[], current: string, remote?: string[] }
12
+ */
13
+
14
+ export async function POST(req: Request) {
15
+ return measure('api:repo:branches', async () => {
16
+ try {
17
+ const { path: repoPath, includeRemote } = await req.json();
18
+
19
+ if (!repoPath) {
20
+ return new Response('path is required', { status: 400 });
21
+ }
22
+
23
+ const blocked = validateRepoPath(repoPath);
24
+ if (blocked) return blocked;
25
+
26
+ const git = simpleGit(repoPath);
27
+
28
+ // Get local branches
29
+ const branchSummary = await git.branchLocal();
30
+ const result: any = {
31
+ branches: branchSummary.all,
32
+ current: branchSummary.current,
33
+ };
34
+
35
+ // Optionally include remote branches
36
+ if (includeRemote) {
37
+ try {
38
+ const remoteBranches = await git.branch(['-r']);
39
+ result.remote = remoteBranches.all
40
+ .filter(b => !b.includes('HEAD'))
41
+ .map(b => b.replace(/^origin\//, ''));
42
+ } catch {
43
+ result.remote = [];
44
+ }
45
+ }
46
+
47
+ return Response.json(result);
48
+ } catch (error: any) {
49
+ console.error('api:repo:branches:error', error);
50
+ return new Response(`Error: ${error.message}`, { status: 500 });
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,55 @@
1
+ import { measure } from 'measure-fn';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import { blockInProduction } from '../validate-path';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ export async function POST(req: Request) {
9
+ const blocked = blockInProduction('Folder browser');
10
+ if (blocked) return blocked;
11
+
12
+ return measure('api:repo:browse', async () => {
13
+ try {
14
+ // Use PowerShell with -EncodedCommand to avoid quoting issues
15
+ // Use TopMost Form to ensure the dialog pops up ABOVE the browser window
16
+ const psScript = `
17
+ Add-Type -AssemblyName System.Windows.Forms
18
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
19
+ $dialog.Description = "Select Git Repository"
20
+ $dialog.ShowNewFolderButton = $false
21
+
22
+ $form = New-Object System.Windows.Forms.Form
23
+ $form.TopMost = $true
24
+ $result = $dialog.ShowDialog($form)
25
+
26
+ if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
27
+ Write-Output $dialog.SelectedPath
28
+ } else {
29
+ Write-Output ""
30
+ }
31
+ $form.Dispose()
32
+ `.trim();
33
+
34
+ const encoded = Buffer.from(psScript, 'utf16le').toString('base64');
35
+
36
+ // Use async exec so we don't block the entire melina server (which freezes the UI's live updates)
37
+ const { stdout } = await execAsync(
38
+ `powershell -sta -WindowStyle Hidden -NoProfile -EncodedCommand ${encoded}`,
39
+ { encoding: 'utf-8', timeout: 86400000 }
40
+ );
41
+
42
+ const selected = stdout.trim();
43
+
44
+ if (!selected) {
45
+ return Response.json({ cancelled: true, path: null });
46
+ }
47
+
48
+ return Response.json({ cancelled: false, path: selected });
49
+ } catch (error: any) {
50
+ console.error('api:repo:browse:error', error);
51
+ // Even if cancelled or errored, don't crash the server. Let UI reset the dropdown.
52
+ return Response.json({ cancelled: true, path: null, error: error.message });
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,78 @@
1
+ import { measure } from 'measure-fn';
2
+ import simpleGit from 'simple-git';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+
6
+ const CLONES_DIR = path.join(process.cwd(), 'git-canvas', 'repos');
7
+
8
+ /**
9
+ * POST /api/repo/clone
10
+ * Body: { url: string }
11
+ * Clones a remote git repo into git-canvas/repos/<name> and returns the local path.
12
+ * If already cloned, returns existing path immediately.
13
+ */
14
+ export async function POST(req: Request) {
15
+ return measure('api:repo:clone', async () => {
16
+ try {
17
+ const { url } = await req.json() as { url: string };
18
+
19
+ if (!url || typeof url !== 'string') {
20
+ return Response.json({ error: 'url is required' }, { status: 400 });
21
+ }
22
+
23
+ // Validate URL format (git@... or https://...)
24
+ const isGitUrl = url.startsWith('git@') || url.startsWith('https://') || url.startsWith('http://') || url.endsWith('.git');
25
+ if (!isGitUrl) {
26
+ return Response.json({ error: 'Invalid git URL. Use https:// or git@ format.' }, { status: 400 });
27
+ }
28
+
29
+ // Derive folder name from URL
30
+ // e.g. https://github.com/user/repo.git → repo
31
+ // e.g. git@github.com:user/repo.git → repo
32
+ const repoName = url
33
+ .replace(/\.git$/, '')
34
+ .split('/')
35
+ .pop()!
36
+ .split(':')
37
+ .pop()!
38
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
39
+
40
+ if (!repoName) {
41
+ return Response.json({ error: 'Could not determine repository name from URL' }, { status: 400 });
42
+ }
43
+
44
+ // Ensure clones directory exists
45
+ fs.mkdirSync(CLONES_DIR, { recursive: true });
46
+
47
+ const targetPath = path.join(CLONES_DIR, repoName);
48
+
49
+ // Check if already cloned
50
+ if (fs.existsSync(path.join(targetPath, '.git'))) {
51
+ // Pull latest
52
+ try {
53
+ const git = simpleGit(targetPath);
54
+ await git.pull();
55
+ console.log(`[clone] Updated existing repo: ${repoName}`);
56
+ } catch {
57
+ // Pull failed (maybe detached HEAD, dirty, etc) — that's fine
58
+ console.log(`[clone] Using existing repo (pull skipped): ${repoName}`);
59
+ }
60
+ return Response.json({ ok: true, path: targetPath, cached: true });
61
+ }
62
+
63
+ // Clone
64
+ console.log(`[clone] Cloning ${url} → ${targetPath}`);
65
+ const git = simpleGit();
66
+ await git.clone(url, targetPath, ['--depth', '100']);
67
+
68
+ console.log(`[clone] ✅ Cloned ${repoName}`);
69
+ return Response.json({ ok: true, path: targetPath, cached: false });
70
+ } catch (error: any) {
71
+ console.error('api:repo:clone:error', error);
72
+ return Response.json(
73
+ { error: error.message || 'Clone failed' },
74
+ { status: 500 }
75
+ );
76
+ }
77
+ });
78
+ }