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,40 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
return measure('api:repo:git-commit', async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { path: repoPath, filePath, message } = await req.json();
|
|
9
|
+
|
|
10
|
+
if (!repoPath || !filePath || !message) {
|
|
11
|
+
return new Response('Repository path, file path, and commit message are required', { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const blocked = validateRepoPath(repoPath);
|
|
15
|
+
if (blocked) return blocked;
|
|
16
|
+
|
|
17
|
+
const git = simpleGit(repoPath);
|
|
18
|
+
|
|
19
|
+
// Stage the specific file
|
|
20
|
+
await git.add(filePath);
|
|
21
|
+
|
|
22
|
+
// Get the staged diff for context
|
|
23
|
+
const diffSummary = await git.diffSummary(['--cached']);
|
|
24
|
+
|
|
25
|
+
// Commit
|
|
26
|
+
const result = await git.commit(message, filePath);
|
|
27
|
+
|
|
28
|
+
return Response.json({
|
|
29
|
+
success: true,
|
|
30
|
+
hash: result.commit || '',
|
|
31
|
+
summary: result.summary || {},
|
|
32
|
+
branch: result.branch || '',
|
|
33
|
+
filesChanged: diffSummary.files?.length || 1,
|
|
34
|
+
});
|
|
35
|
+
} catch (error: any) {
|
|
36
|
+
console.error('api:repo:git-commit:error', error);
|
|
37
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git Heatmap API — returns commit frequency per file for the given time range.
|
|
7
|
+
* Used by the canvas heatmap overlay to color-code files by activity.
|
|
8
|
+
*/
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
return measure('api:repo:git-heatmap', async () => {
|
|
11
|
+
try {
|
|
12
|
+
const { path: repoPath, days = 90 } = await req.json();
|
|
13
|
+
|
|
14
|
+
if (!repoPath) {
|
|
15
|
+
return new Response('Repository path is required', { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const blocked = validateRepoPath(repoPath);
|
|
19
|
+
if (blocked) return blocked;
|
|
20
|
+
|
|
21
|
+
const git = simpleGit(repoPath);
|
|
22
|
+
const since = `${days} days ago`;
|
|
23
|
+
|
|
24
|
+
// Get all file changes in the time range: one filename per line
|
|
25
|
+
const raw = await git.raw([
|
|
26
|
+
'log', '--format=format:', '--name-only', `--since=${since}`
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Count occurrences of each file
|
|
30
|
+
const counts: Record<string, number> = {};
|
|
31
|
+
let maxCount = 0;
|
|
32
|
+
|
|
33
|
+
for (const line of raw.split('\n')) {
|
|
34
|
+
const file = line.trim();
|
|
35
|
+
if (!file) continue;
|
|
36
|
+
counts[file] = (counts[file] || 0) + 1;
|
|
37
|
+
if (counts[file]! > maxCount) maxCount = counts[file]!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build sorted array with normalized heat (0-1)
|
|
41
|
+
const files = Object.entries(counts)
|
|
42
|
+
.map(([file, count]) => ({
|
|
43
|
+
file,
|
|
44
|
+
commits: count,
|
|
45
|
+
heat: maxCount > 0 ? count / maxCount : 0,
|
|
46
|
+
}))
|
|
47
|
+
.sort((a, b) => b.commits - a.commits);
|
|
48
|
+
|
|
49
|
+
return Response.json({ files, maxCommits: maxCount, days, totalFiles: files.length });
|
|
50
|
+
} catch (error: any) {
|
|
51
|
+
console.error('api:repo:git-heatmap:error', error);
|
|
52
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/repo/imports
|
|
7
|
+
* Body: { path: string, commit: string }
|
|
8
|
+
*
|
|
9
|
+
* Scans all source files at the given commit and returns import/require
|
|
10
|
+
* relationships as edges: { source: string, target: string, line: number }[]
|
|
11
|
+
*
|
|
12
|
+
* Supports: ES import, CommonJS require, CSS @import, Python import
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
17
|
+
'.vue', '.svelte',
|
|
18
|
+
'.css', '.scss', '.less',
|
|
19
|
+
'.py',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Match import/require patterns and extract the module specifier
|
|
23
|
+
const IMPORT_PATTERNS = [
|
|
24
|
+
// ES: import ... from 'module' or import 'module'
|
|
25
|
+
/(?:import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"])/g,
|
|
26
|
+
// ES: export ... from 'module'
|
|
27
|
+
/(?:export\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"])/g,
|
|
28
|
+
// CommonJS: require('module')
|
|
29
|
+
/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
30
|
+
// CSS: @import 'file' or @import url('file')
|
|
31
|
+
/@import\s+(?:url\s*\(\s*)?['"]([^'"]+)['"]/g,
|
|
32
|
+
// Python: from module import ... or import module
|
|
33
|
+
/(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/g,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function resolveImport(sourceFile: string, specifier: string, allFiles: string[]): string | null {
|
|
37
|
+
// Skip node_modules / external packages
|
|
38
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('/')) return null;
|
|
39
|
+
|
|
40
|
+
// Get directory of source file
|
|
41
|
+
const sourceDir = sourceFile.includes('/') ? sourceFile.substring(0, sourceFile.lastIndexOf('/')) : '';
|
|
42
|
+
|
|
43
|
+
// Resolve relative path
|
|
44
|
+
let resolved: string;
|
|
45
|
+
if (specifier.startsWith('/')) {
|
|
46
|
+
resolved = specifier.substring(1);
|
|
47
|
+
} else {
|
|
48
|
+
const parts = sourceDir.split('/').filter(Boolean);
|
|
49
|
+
const specParts = specifier.split('/');
|
|
50
|
+
for (const sp of specParts) {
|
|
51
|
+
if (sp === '..') parts.pop();
|
|
52
|
+
else if (sp !== '.') parts.push(sp);
|
|
53
|
+
}
|
|
54
|
+
resolved = parts.join('/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Try exact match first
|
|
58
|
+
if (allFiles.includes(resolved)) return resolved;
|
|
59
|
+
|
|
60
|
+
// Try adding extensions
|
|
61
|
+
const tryExts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.css', '.scss', '.vue', '.svelte'];
|
|
62
|
+
for (const ext of tryExts) {
|
|
63
|
+
if (allFiles.includes(resolved + ext)) return resolved + ext;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Try /index
|
|
67
|
+
for (const ext of tryExts) {
|
|
68
|
+
if (allFiles.includes(resolved + '/index' + ext)) return resolved + '/index' + ext;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function POST(req: Request) {
|
|
75
|
+
return measure('api:repo:imports', async () => {
|
|
76
|
+
try {
|
|
77
|
+
const { path: repoPath, commit } = await req.json();
|
|
78
|
+
|
|
79
|
+
if (!repoPath || !commit) {
|
|
80
|
+
return Response.json({ error: 'path and commit required' }, { status: 400 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const blocked = validateRepoPath(repoPath);
|
|
84
|
+
if (blocked) return blocked;
|
|
85
|
+
|
|
86
|
+
const git = simpleGit(repoPath);
|
|
87
|
+
|
|
88
|
+
// Get all files at this commit
|
|
89
|
+
const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', commit]);
|
|
90
|
+
const allFiles = lsOutput.split('\n').filter(Boolean);
|
|
91
|
+
|
|
92
|
+
// Filter to source files
|
|
93
|
+
const sourceFiles = allFiles.filter(f => {
|
|
94
|
+
const ext = '.' + f.split('.').pop()?.toLowerCase();
|
|
95
|
+
return SOURCE_EXTENSIONS.has(ext);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Scan each source file for imports (limit to first 200 files for perf)
|
|
99
|
+
const filesToScan = sourceFiles.slice(0, 200);
|
|
100
|
+
const edges: { source: string; target: string; line: number }[] = [];
|
|
101
|
+
|
|
102
|
+
await Promise.allSettled(filesToScan.map(async (filePath) => {
|
|
103
|
+
try {
|
|
104
|
+
const content = await git.show([`${commit}:${filePath}`]);
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < Math.min(lines.length, 100); i++) {
|
|
108
|
+
// Only scan first 100 lines (imports are at the top)
|
|
109
|
+
const line = lines[i];
|
|
110
|
+
|
|
111
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
112
|
+
// Reset lastIndex for global regex
|
|
113
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
114
|
+
let match;
|
|
115
|
+
while ((match = regex.exec(line)) !== null) {
|
|
116
|
+
const specifier = match[1] || match[2];
|
|
117
|
+
if (!specifier) continue;
|
|
118
|
+
|
|
119
|
+
const resolved = resolveImport(filePath, specifier, allFiles);
|
|
120
|
+
if (resolved && resolved !== filePath) {
|
|
121
|
+
edges.push({
|
|
122
|
+
source: filePath,
|
|
123
|
+
target: resolved,
|
|
124
|
+
line: i + 1,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch { /* file might be binary or unreadable */ }
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
// Deduplicate edges
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
const unique = edges.filter(e => {
|
|
136
|
+
const key = `${e.source}→${e.target}`;
|
|
137
|
+
if (seen.has(key)) return false;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log(`[imports] ${filesToScan.length} files scanned, ${unique.length} import edges found`);
|
|
143
|
+
|
|
144
|
+
return Response.json({
|
|
145
|
+
edges: unique,
|
|
146
|
+
filesScanned: filesToScan.length,
|
|
147
|
+
totalFiles: allFiles.length,
|
|
148
|
+
});
|
|
149
|
+
} catch (error: any) {
|
|
150
|
+
console.error('api:repo:imports:error', error);
|
|
151
|
+
return Response.json({ error: error.message }, { status: 500 });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validateRepoPath } from '../validate-path';
|
|
5
|
+
|
|
6
|
+
export async function POST(req: Request) {
|
|
7
|
+
return measure('api:repo:load', async () => {
|
|
8
|
+
try {
|
|
9
|
+
const { path: repoPath } = await req.json();
|
|
10
|
+
|
|
11
|
+
if (!repoPath) {
|
|
12
|
+
return new Response('Repository path is required', { status: 400 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const blocked = validateRepoPath(repoPath);
|
|
16
|
+
if (blocked) return blocked;
|
|
17
|
+
|
|
18
|
+
const git = simpleGit(repoPath);
|
|
19
|
+
|
|
20
|
+
// Check if it's a git repository
|
|
21
|
+
const isRepo = await git.checkIsRepo();
|
|
22
|
+
if (!isRepo) {
|
|
23
|
+
return new Response('Not a valid git repository', { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get commit log (last 100 commits) with custom format for tree graph
|
|
27
|
+
const log = await git.log({
|
|
28
|
+
maxCount: 100,
|
|
29
|
+
format: {
|
|
30
|
+
hash: '%H',
|
|
31
|
+
parents: '%P',
|
|
32
|
+
message: '%s',
|
|
33
|
+
author_name: '%an',
|
|
34
|
+
author_email: '%ae',
|
|
35
|
+
date: '%ai',
|
|
36
|
+
refs: '%D' // e.g. "HEAD -> main, origin/main, origin/HEAD"
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const commits = log.all.map(commit => ({
|
|
41
|
+
hash: commit.hash,
|
|
42
|
+
parents: commit.parents ? commit.parents.trim().split(' ').filter(Boolean) : [],
|
|
43
|
+
message: commit.message.split('\n')[0], // First line only
|
|
44
|
+
author: commit.author_name,
|
|
45
|
+
email: commit.author_email,
|
|
46
|
+
date: commit.date,
|
|
47
|
+
refs: commit.refs ? commit.refs.split(',').map(r => r.trim()).filter(Boolean) : []
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
return Response.json({ commits });
|
|
51
|
+
} catch (error: any) {
|
|
52
|
+
console.error('api:repo:load:error', error);
|
|
53
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/repo/mode
|
|
3
|
+
* Returns the app mode: 'local' (dev) or 'saas' (production).
|
|
4
|
+
* In local mode, users can browse local folders AND clone remote URLs.
|
|
5
|
+
* In saas mode, only remote URL cloning is available.
|
|
6
|
+
*/
|
|
7
|
+
export async function GET() {
|
|
8
|
+
const env = process.env.NODE_ENV || 'development';
|
|
9
|
+
const isLocal = env === 'development' || env === 'local' || env === 'dev';
|
|
10
|
+
return Response.json({
|
|
11
|
+
mode: isLocal ? 'local' : 'saas',
|
|
12
|
+
env,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/repo/search
|
|
7
|
+
* Body: { path, query, commit?, maxResults?, caseSensitive? }
|
|
8
|
+
* Uses `git grep` for fast full-text search across the repo.
|
|
9
|
+
* Returns: { results: [{ file, matches: [{ line, content, lineNumber }] }], totalMatches }
|
|
10
|
+
*/
|
|
11
|
+
export async function POST(req: Request) {
|
|
12
|
+
return measure('api:repo:search', async () => {
|
|
13
|
+
try {
|
|
14
|
+
const { path: repoPath, query, commit, maxResults = 200, caseSensitive = false } = await req.json();
|
|
15
|
+
|
|
16
|
+
if (!repoPath || !query) {
|
|
17
|
+
return new Response('Repository path and query are required', { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (query.length < 2) {
|
|
21
|
+
return new Response('Query must be at least 2 characters', { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const blocked = validateRepoPath(repoPath);
|
|
25
|
+
if (blocked) return blocked;
|
|
26
|
+
|
|
27
|
+
const git = simpleGit(repoPath);
|
|
28
|
+
|
|
29
|
+
// Build git grep args
|
|
30
|
+
const args = ['grep', '-n', '--break', '--heading'];
|
|
31
|
+
if (!caseSensitive) args.push('-i');
|
|
32
|
+
// Limit per-file matches to avoid overwhelming results
|
|
33
|
+
args.push('--max-count=20');
|
|
34
|
+
|
|
35
|
+
if (commit) {
|
|
36
|
+
args.push(commit, '--', query);
|
|
37
|
+
} else {
|
|
38
|
+
// Search working tree
|
|
39
|
+
args.push('--', query);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Actually git grep uses the query as a positional arg, let me restructure
|
|
43
|
+
// git grep [-i] [-n] [--max-count=N] <pattern> [<commit>] [-- <pathspec>]
|
|
44
|
+
const grepArgs: string[] = ['-n'];
|
|
45
|
+
if (!caseSensitive) grepArgs.push('-i');
|
|
46
|
+
grepArgs.push('--max-count=20');
|
|
47
|
+
grepArgs.push('-e', query);
|
|
48
|
+
|
|
49
|
+
if (commit) {
|
|
50
|
+
grepArgs.push(commit);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let rawOutput: string;
|
|
54
|
+
try {
|
|
55
|
+
rawOutput = await git.raw(['grep', ...grepArgs]);
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
// git grep returns exit code 1 when no matches found
|
|
58
|
+
if (err.message?.includes('exit code 1') || err.message?.includes('process exited with code 1')) {
|
|
59
|
+
return Response.json({ results: [], totalMatches: 0 });
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!rawOutput?.trim()) {
|
|
65
|
+
return Response.json({ results: [], totalMatches: 0 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parse git grep output: <file>:<lineNum>:<content>
|
|
69
|
+
// Or with commit: <commit>:<file>:<lineNum>:<content>
|
|
70
|
+
const fileGroups = new Map<string, { line: number; content: string }[]>();
|
|
71
|
+
let totalMatches = 0;
|
|
72
|
+
|
|
73
|
+
for (const line of rawOutput.split('\n')) {
|
|
74
|
+
if (!line.trim()) continue;
|
|
75
|
+
|
|
76
|
+
let filePath: string;
|
|
77
|
+
let lineNum: number;
|
|
78
|
+
let content: string;
|
|
79
|
+
|
|
80
|
+
if (commit) {
|
|
81
|
+
// Format: <commit>:<file>:<lineNum>:<content>
|
|
82
|
+
const commitPrefix = commit + ':';
|
|
83
|
+
if (!line.startsWith(commitPrefix)) continue;
|
|
84
|
+
const rest = line.slice(commitPrefix.length);
|
|
85
|
+
const firstColon = rest.indexOf(':');
|
|
86
|
+
if (firstColon < 0) continue;
|
|
87
|
+
const afterFile = rest.slice(firstColon + 1);
|
|
88
|
+
const secondColon = afterFile.indexOf(':');
|
|
89
|
+
if (secondColon < 0) continue;
|
|
90
|
+
filePath = rest.slice(0, firstColon);
|
|
91
|
+
lineNum = parseInt(afterFile.slice(0, secondColon), 10);
|
|
92
|
+
content = afterFile.slice(secondColon + 1);
|
|
93
|
+
} else {
|
|
94
|
+
// Format: <file>:<lineNum>:<content>
|
|
95
|
+
const firstColon = line.indexOf(':');
|
|
96
|
+
if (firstColon < 0) continue;
|
|
97
|
+
const afterFile = line.slice(firstColon + 1);
|
|
98
|
+
const secondColon = afterFile.indexOf(':');
|
|
99
|
+
if (secondColon < 0) continue;
|
|
100
|
+
filePath = line.slice(0, firstColon);
|
|
101
|
+
lineNum = parseInt(afterFile.slice(0, secondColon), 10);
|
|
102
|
+
content = afterFile.slice(secondColon + 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isNaN(lineNum)) continue;
|
|
106
|
+
|
|
107
|
+
if (!fileGroups.has(filePath)) {
|
|
108
|
+
fileGroups.set(filePath, []);
|
|
109
|
+
}
|
|
110
|
+
fileGroups.get(filePath)!.push({ line: lineNum, content: content.trimEnd() });
|
|
111
|
+
totalMatches++;
|
|
112
|
+
|
|
113
|
+
if (totalMatches >= maxResults) break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const results = Array.from(fileGroups.entries()).map(([file, matches]) => ({
|
|
117
|
+
file,
|
|
118
|
+
matches,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
return Response.json({ results, totalMatches });
|
|
122
|
+
} catch (error: any) {
|
|
123
|
+
console.error('api:repo:search:error', error);
|
|
124
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { measure } from 'measure-fn';
|
|
2
|
+
import simpleGit from 'simple-git';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { validateRepoPath } from '../validate-path';
|
|
6
|
+
|
|
7
|
+
const BINARY_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'svg', 'webp', 'mp3', 'mp4', 'wav', 'ogg', 'avi', 'mov', 'zip', 'tar', 'gz', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'exe', 'dll', 'so', 'dylib', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'lock']);
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
return measure('api:repo:tree', async () => {
|
|
11
|
+
try {
|
|
12
|
+
const { path: repoPath } = await req.json();
|
|
13
|
+
|
|
14
|
+
if (!repoPath) {
|
|
15
|
+
return new Response('Repository path is required', { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const blocked = validateRepoPath(repoPath);
|
|
19
|
+
if (blocked) return blocked;
|
|
20
|
+
|
|
21
|
+
const git = simpleGit(repoPath);
|
|
22
|
+
|
|
23
|
+
// Get tracked files only (respects .gitignore by definition)
|
|
24
|
+
const result = await git.raw(['ls-files']);
|
|
25
|
+
const ignoreDirs = ['node_modules', '.git', 'dist', 'build', '.next', '.cache', 'coverage', '.turbo', '__pycache__', '.tsbuildinfo'];
|
|
26
|
+
|
|
27
|
+
// Also parse .gitignore for extra patterns
|
|
28
|
+
const gitignorePatterns: string[] = [];
|
|
29
|
+
const gitignorePath = path.join(repoPath, '.gitignore');
|
|
30
|
+
if (existsSync(gitignorePath)) {
|
|
31
|
+
try {
|
|
32
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
33
|
+
content.split('\n').forEach(line => {
|
|
34
|
+
line = line.trim();
|
|
35
|
+
if (line && !line.startsWith('#')) {
|
|
36
|
+
// Normalize: remove trailing slashes for dir matching
|
|
37
|
+
const clean = line.replace(/\/+$/, '');
|
|
38
|
+
if (clean) gitignorePatterns.push(clean);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
} catch (e) { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const filePaths = result.trim().split('\n').filter(fp => {
|
|
45
|
+
if (!fp) return false;
|
|
46
|
+
// Filter out known heavy directories
|
|
47
|
+
if (ignoreDirs.some(d => fp.startsWith(d + '/') || fp.startsWith(d + '\\'))) return false;
|
|
48
|
+
// Filter out files matching gitignore patterns (extra safety)
|
|
49
|
+
for (const pattern of gitignorePatterns) {
|
|
50
|
+
if (fp.startsWith(pattern + '/') || fp.startsWith(pattern + '\\')) return false;
|
|
51
|
+
if (fp === pattern) return false;
|
|
52
|
+
// Simple glob: *.ext
|
|
53
|
+
if (pattern.startsWith('*.')) {
|
|
54
|
+
const ext = pattern.substring(1); // .ext
|
|
55
|
+
if (fp.endsWith(ext)) return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const files = filePaths.map(filePath => {
|
|
62
|
+
const parts = filePath.split('/');
|
|
63
|
+
const name = parts[parts.length - 1];
|
|
64
|
+
const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : '';
|
|
65
|
+
|
|
66
|
+
let content = null;
|
|
67
|
+
let lines = 0;
|
|
68
|
+
let isBinary = BINARY_EXTS.has(ext);
|
|
69
|
+
|
|
70
|
+
if (!isBinary) {
|
|
71
|
+
try {
|
|
72
|
+
const fullPath = path.join(repoPath, filePath);
|
|
73
|
+
const raw = readFileSync(fullPath, 'utf-8');
|
|
74
|
+
const allLines = raw.split('\n');
|
|
75
|
+
lines = allLines.length;
|
|
76
|
+
// Send full content for small/medium files, truncate very large ones
|
|
77
|
+
if (allLines.length > 10000) {
|
|
78
|
+
content = allLines.slice(0, 10000).join('\n');
|
|
79
|
+
} else {
|
|
80
|
+
content = raw;
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
content = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
path: filePath,
|
|
89
|
+
name,
|
|
90
|
+
ext,
|
|
91
|
+
type: 'file',
|
|
92
|
+
content,
|
|
93
|
+
lines,
|
|
94
|
+
isBinary
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return Response.json({ files, total: files.length });
|
|
99
|
+
} catch (error: any) {
|
|
100
|
+
console.error('api:repo:tree:error', error);
|
|
101
|
+
return new Response(`Error: ${error.message}`, { status: 500 });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { exec } from "child_process";
|
|
4
|
+
import { promisify } from "util";
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
export async function POST(req: Request) {
|
|
9
|
+
try {
|
|
10
|
+
const formData = await req.formData();
|
|
11
|
+
const files = formData.getAll('files') as File[];
|
|
12
|
+
|
|
13
|
+
if (!files || files.length === 0) {
|
|
14
|
+
return Response.json({ error: 'No files provided' }, { status: 400 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Generate a unique ID for this upload
|
|
18
|
+
const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
19
|
+
const repoPath = path.resolve(`.data/uploads/${uploadId}`);
|
|
20
|
+
|
|
21
|
+
// Ensure directories exist
|
|
22
|
+
await mkdir(repoPath, { recursive: true });
|
|
23
|
+
|
|
24
|
+
// Write all files
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const relativePath = file.name; // We passed the full path in formData.append('files', f, f.fullPath)
|
|
27
|
+
if (relativePath.includes('..') || relativePath.includes('\0')) {
|
|
28
|
+
continue; // Basic security prevention
|
|
29
|
+
}
|
|
30
|
+
const fullPath = path.join(repoPath, relativePath);
|
|
31
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
32
|
+
|
|
33
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
34
|
+
await writeFile(fullPath, buffer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Initialize a Git repository so galaxy-canvas can read it
|
|
38
|
+
await execAsync(`git init`, { cwd: repoPath });
|
|
39
|
+
|
|
40
|
+
// Setup dummy user info, otherwise git commits will fail if not globally set
|
|
41
|
+
await execAsync(`git config user.name "Galaxy Canvas"`, { cwd: repoPath });
|
|
42
|
+
await execAsync(`git config user.email "bot@galaxycanvas.local"`, { cwd: repoPath });
|
|
43
|
+
|
|
44
|
+
// Add all files and commit
|
|
45
|
+
await execAsync(`git add .`, { cwd: repoPath });
|
|
46
|
+
await execAsync(`git commit -m "Initial drop imported by drag-and-drop"`, { cwd: repoPath });
|
|
47
|
+
|
|
48
|
+
return Response.json({ path: repoPath, success: true });
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
console.error("Upload error:", e);
|
|
51
|
+
return Response.json({ error: e.message || 'Failed to upload files' }, { status: 500 });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Allowed repo path directories in production (SaaS) mode.
|
|
5
|
+
* Only these directories can be accessed — prevents arbitrary file system reads.
|
|
6
|
+
*/
|
|
7
|
+
const ALLOWED_ROOTS = [
|
|
8
|
+
path.resolve(process.cwd(), 'git-canvas', 'repos'), // Clone directory
|
|
9
|
+
path.resolve(process.cwd(), '.data', 'uploads'), // Drag-and-drop uploads
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const IS_PRODUCTION = (() => {
|
|
13
|
+
const env = process.env.NODE_ENV || 'development';
|
|
14
|
+
return env !== 'development' && env !== 'local' && env !== 'dev';
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates that a repo path is allowed in the current mode.
|
|
19
|
+
* - In development: any path is allowed (for local folder browsing)
|
|
20
|
+
* - In production/SaaS: only paths under ALLOWED_ROOTS are permitted
|
|
21
|
+
*
|
|
22
|
+
* Returns null if valid, or a Response with a 403 error if blocked.
|
|
23
|
+
*/
|
|
24
|
+
export function validateRepoPath(repoPath: string): Response | null {
|
|
25
|
+
if (!IS_PRODUCTION) return null; // Allow everything in dev
|
|
26
|
+
|
|
27
|
+
const resolved = path.resolve(repoPath);
|
|
28
|
+
const isAllowed = ALLOWED_ROOTS.some(root => resolved.startsWith(root + path.sep) || resolved === root);
|
|
29
|
+
|
|
30
|
+
if (!isAllowed) {
|
|
31
|
+
console.warn(`[security] Blocked access to path outside allowed roots: ${resolved}`);
|
|
32
|
+
return Response.json(
|
|
33
|
+
{ error: 'Access denied: this path is not accessible in production mode.' },
|
|
34
|
+
{ status: 403 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null; // Path is valid
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Quick guard for routes that should be completely disabled in production.
|
|
43
|
+
* Returns a 403 Response if in production, null otherwise.
|
|
44
|
+
*/
|
|
45
|
+
export function blockInProduction(routeName: string): Response | null {
|
|
46
|
+
if (!IS_PRODUCTION) return null;
|
|
47
|
+
|
|
48
|
+
console.warn(`[security] Blocked ${routeName} in production mode`);
|
|
49
|
+
return Response.json(
|
|
50
|
+
{ error: `${routeName} is not available in production mode.` },
|
|
51
|
+
{ status: 403 }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|