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.
- package/README.md +167 -0
- package/app/api/auth/favorites/route.ts +56 -0
- package/app/api/auth/github/callback/route.ts +103 -0
- package/app/api/auth/github/route.ts +32 -0
- package/app/api/auth/me/route.ts +52 -0
- package/app/api/auth/positions/route.ts +50 -0
- package/app/api/chat/route.ts +101 -0
- package/app/api/connections/route.ts +72 -0
- package/app/api/github/repos/route.ts +111 -0
- package/app/api/positions/route.ts +80 -0
- package/app/api/repo/branch-diff/route.ts +201 -0
- package/app/api/repo/branches/route.ts +53 -0
- package/app/api/repo/browse/route.ts +55 -0
- package/app/api/repo/clone/route.ts +78 -0
- package/app/api/repo/clone-stream/route.ts +131 -0
- package/app/api/repo/file-content/route.ts +28 -0
- package/app/api/repo/file-delete/route.ts +62 -0
- package/app/api/repo/file-history/route.ts +45 -0
- package/app/api/repo/file-rename/route.ts +83 -0
- package/app/api/repo/file-save/route.ts +45 -0
- package/app/api/repo/files/route.ts +169 -0
- package/app/api/repo/git-blame/route.ts +86 -0
- package/app/api/repo/git-commit/route.ts +40 -0
- package/app/api/repo/git-heatmap/route.ts +55 -0
- package/app/api/repo/imports/route.ts +154 -0
- package/app/api/repo/load/route.ts +56 -0
- package/app/api/repo/mode/route.ts +14 -0
- package/app/api/repo/search/route.ts +127 -0
- package/app/api/repo/tree/route.ts +104 -0
- package/app/api/repo/upload/route.ts +53 -0
- package/app/api/repo/validate-path.ts +53 -0
- package/app/canvas_users.db +0 -0
- package/app/canvas_users.db-shm +0 -0
- package/app/canvas_users.db-wal +0 -0
- package/app/globals.css +7899 -0
- package/app/layout.tsx +493 -0
- package/app/lib/auth.ts +193 -0
- package/app/lib/auto-save.ts +137 -0
- package/app/lib/branch-compare.ts +443 -0
- package/app/lib/breadcrumbs.ts +170 -0
- package/app/lib/canvas-export.ts +358 -0
- package/app/lib/canvas-text.ts +912 -0
- package/app/lib/canvas.ts +564 -0
- package/app/lib/card-arrangement.ts +188 -0
- package/app/lib/card-context-menu.tsx +453 -0
- package/app/lib/card-diff-markers.ts +270 -0
- package/app/lib/card-expand.ts +189 -0
- package/app/lib/card-groups.ts +246 -0
- package/app/lib/cards.tsx +914 -0
- package/app/lib/chat.tsx +308 -0
- package/app/lib/code-editor.ts +508 -0
- package/app/lib/command-palette.ts +262 -0
- package/app/lib/connections.tsx +1037 -0
- package/app/lib/context.ts +94 -0
- package/app/lib/cursor-sharing.ts +281 -0
- package/app/lib/dependency-graph.ts +438 -0
- package/app/lib/events.tsx +1747 -0
- package/app/lib/file-card-plugin.ts +134 -0
- package/app/lib/file-modal.tsx +849 -0
- package/app/lib/file-preview.ts +400 -0
- package/app/lib/file-tabs.ts +318 -0
- package/app/lib/galaxydraw-bridge.ts +477 -0
- package/app/lib/galaxydraw.test.ts +229 -0
- package/app/lib/global-search.ts +264 -0
- package/app/lib/goto-definition.ts +224 -0
- package/app/lib/heatmap.ts +178 -0
- package/app/lib/hidden-files.tsx +222 -0
- package/app/lib/layers.ts +0 -0
- package/app/lib/layers.tsx +365 -0
- package/app/lib/loading.tsx +45 -0
- package/app/lib/multi-repo.ts +286 -0
- package/app/lib/new-file-dialog.tsx +230 -0
- package/app/lib/onboarding.tsx +213 -0
- package/app/lib/perf-overlay.ts +360 -0
- package/app/lib/positions.ts +176 -0
- package/app/lib/pr-review.ts +374 -0
- package/app/lib/production-mode.ts +47 -0
- package/app/lib/repo.tsx +977 -0
- package/app/lib/settings-modal.tsx +374 -0
- package/app/lib/settings.ts +97 -0
- package/app/lib/shortcuts-panel.ts +141 -0
- package/app/lib/status-bar.ts +128 -0
- package/app/lib/symbol-outline.ts +212 -0
- package/app/lib/syntax.ts +177 -0
- package/app/lib/tab-diff.ts +238 -0
- package/app/lib/user.tsx +133 -0
- package/app/lib/utils.ts +78 -0
- package/app/lib/viewport-culling.ts +728 -0
- package/app/page.client.tsx +215 -0
- package/app/page.tsx +291 -0
- package/app/state/machine.js +196 -0
- package/app/styles/main.css +2168 -0
- package/banner.png +0 -0
- package/cli.ts +44 -0
- package/package.json +75 -0
- package/packages/galaxydraw/README.md +296 -0
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +100 -0
- package/packages/galaxydraw/demo/client.ts +154 -0
- package/packages/galaxydraw/demo/dist/client.js +8 -0
- package/packages/galaxydraw/demo/index.html +256 -0
- package/packages/galaxydraw/demo/server.ts +96 -0
- package/packages/galaxydraw/dist/index.js +984 -0
- package/packages/galaxydraw/dist/index.js.map +16 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +49 -0
- package/packages/galaxydraw/perf.test.ts +284 -0
- package/packages/galaxydraw/src/core/cards.ts +435 -0
- package/packages/galaxydraw/src/core/engine.ts +339 -0
- package/packages/galaxydraw/src/core/events.ts +81 -0
- package/packages/galaxydraw/src/core/layout.ts +136 -0
- package/packages/galaxydraw/src/core/minimap.ts +216 -0
- package/packages/galaxydraw/src/core/state.ts +177 -0
- package/packages/galaxydraw/src/core/viewport.ts +106 -0
- package/packages/galaxydraw/src/galaxydraw.css +166 -0
- package/packages/galaxydraw/src/index.ts +40 -0
- package/packages/galaxydraw/tsconfig.json +30 -0
- 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
|
+
}
|