gitmaps 1.1.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.
- package/README.md +267 -118
- package/app/[...slug]/page.client.tsx +1 -0
- package/app/[...slug]/page.tsx +6 -0
- package/app/analytics.db +0 -0
- package/app/api/analytics/route.ts +64 -0
- package/app/api/auth/positions/route.ts +95 -33
- package/app/api/build-info/route.ts +19 -0
- package/app/api/chat/route.ts +13 -2
- package/app/api/og-image/route.ts +14 -0
- package/app/api/repo/file-content/route.ts +73 -20
- package/app/api/repo/load/route.test.ts +62 -0
- package/app/api/repo/load/route.ts +41 -1
- package/app/api/repo/pdf-thumb/route.ts +127 -0
- package/app/api/repo/resolve-slug/route.ts +51 -0
- package/app/api/repo/tree/route.ts +188 -104
- package/app/api/version/route.ts +26 -0
- package/app/globals.css +5706 -4938
- package/app/layout.tsx +1279 -490
- package/app/lib/auto-arrange.test.ts +158 -0
- package/app/lib/auto-arrange.ts +147 -0
- package/app/lib/canvas-export.ts +358 -358
- package/app/lib/canvas.ts +625 -564
- package/app/lib/cards.tsx +1361 -916
- package/app/lib/chat.tsx +65 -9
- package/app/lib/code-editor.ts +86 -2
- package/app/lib/context.test.ts +32 -0
- package/app/lib/context.ts +19 -3
- package/app/lib/cursor-sharing.ts +34 -0
- package/app/lib/events.tsx +71 -93
- package/app/lib/export-canvas.ts +287 -0
- package/app/lib/file-card-plugin.ts +148 -148
- package/app/lib/file-modal.tsx +49 -0
- package/app/lib/file-preview.ts +486 -427
- package/app/lib/github-import.test.ts +424 -0
- package/app/lib/initial-route-hydration.test.ts +283 -0
- package/app/lib/initial-route-hydration.ts +202 -0
- package/app/lib/landing-reset.test.ts +99 -0
- package/app/lib/landing-reset.ts +106 -0
- package/app/lib/landing-shell.test.ts +75 -0
- package/app/lib/large-repo-optimization.ts +37 -0
- package/app/lib/layout-snapshots.ts +320 -0
- package/app/lib/loading.test.ts +69 -0
- package/app/lib/loading.tsx +160 -45
- package/app/lib/mount-cleanup.test.ts +52 -0
- package/app/lib/mount-cleanup.ts +34 -0
- package/app/lib/mount-init.test.ts +123 -0
- package/app/lib/mount-init.ts +107 -0
- package/app/lib/mount-lifecycle.test.ts +39 -0
- package/app/lib/mount-lifecycle.ts +12 -0
- package/app/lib/mount-route-wiring.test.ts +87 -0
- package/app/lib/mount-route-wiring.ts +84 -0
- package/app/lib/multi-repo.ts +14 -0
- package/app/lib/onboarding-tutorial.ts +278 -0
- package/app/lib/positions.ts +190 -121
- package/app/lib/recent-commits.test.ts +869 -0
- package/app/lib/recent-commits.ts +227 -0
- package/app/lib/repo-handoff.test.ts +23 -0
- package/app/lib/repo-handoff.ts +16 -0
- package/app/lib/repo-progressive.ts +119 -0
- package/app/lib/repo-select.test.ts +61 -0
- package/app/lib/repo-select.ts +74 -0
- package/app/lib/repo.tsx +1383 -987
- package/app/lib/role.ts +228 -0
- package/app/lib/route-catchall.test.ts +27 -0
- package/app/lib/route-repo-entry.test.ts +95 -0
- package/app/lib/route-repo-entry.ts +36 -0
- package/app/lib/router-contract.test.ts +22 -0
- package/app/lib/router-contract.ts +19 -0
- package/app/lib/shared-layout.test.ts +86 -0
- package/app/lib/shared-layout.ts +82 -0
- package/app/lib/status-bar.test.ts +118 -0
- package/app/lib/status-bar.ts +365 -128
- package/app/lib/sync-controls.test.ts +43 -0
- package/app/lib/sync-controls.tsx +303 -0
- package/app/lib/test-dom.ts +145 -0
- package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
- package/app/lib/transclusion-smoke.test.ts +163 -0
- package/app/lib/tutorial.ts +301 -0
- package/app/lib/version.ts +93 -0
- package/app/lib/viewport-culling.ts +740 -735
- package/app/lib/virtual-files.ts +456 -0
- package/app/lib/webgl-text.ts +189 -0
- package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
- package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
- package/app/og-image.png +0 -0
- package/app/page.client.tsx +70 -269
- package/app/page.tsx +15 -16
- package/app/state/machine.js +13 -0
- package/package.json +16 -7
- package/server.ts +10 -0
- package/app/[owner]/[repo]/page.tsx +0 -6
- package/app/[slug]/page.tsx +0 -6
- package/packages/galaxydraw/README.md +0 -296
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +0 -100
- package/packages/galaxydraw/demo/client.ts +0 -154
- package/packages/galaxydraw/demo/dist/client.js +0 -8
- package/packages/galaxydraw/demo/index.html +0 -256
- package/packages/galaxydraw/demo/server.ts +0 -96
- package/packages/galaxydraw/dist/index.js +0 -984
- package/packages/galaxydraw/dist/index.js.map +0 -16
- 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 +0 -49
- package/packages/galaxydraw/perf.test.ts +0 -284
- package/packages/galaxydraw/src/core/cards.ts +0 -435
- package/packages/galaxydraw/src/core/engine.ts +0 -339
- package/packages/galaxydraw/src/core/events.ts +0 -81
- package/packages/galaxydraw/src/core/layout.ts +0 -136
- package/packages/galaxydraw/src/core/minimap.ts +0 -216
- package/packages/galaxydraw/src/core/state.ts +0 -177
- package/packages/galaxydraw/src/core/viewport.ts +0 -106
- package/packages/galaxydraw/src/galaxydraw.css +0 -166
- package/packages/galaxydraw/src/index.ts +0 -40
- package/packages/galaxydraw/tsconfig.json +0 -30
|
@@ -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
|
+
}
|
package/app/api/chat/route.ts
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -1,28 +1,81 @@
|
|
|
1
|
-
import { measure } from
|
|
2
|
-
import simpleGit from
|
|
3
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
57
|
+
const blocked = validateRepoPath(repoPath);
|
|
58
|
+
if (blocked) return blocked;
|
|
13
59
|
|
|
14
|
-
|
|
15
|
-
|
|
60
|
+
const ext = path.extname(file).toLowerCase();
|
|
61
|
+
const mimeType = MIME_TYPES[ext];
|
|
16
62
|
|
|
17
|
-
|
|
63
|
+
if (!mimeType) {
|
|
64
|
+
return new Response("Not an image file", { status: 400 });
|
|
65
|
+
}
|
|
18
66
|
|
|
19
|
-
|
|
20
|
-
|
|
67
|
+
const fullPath = path.join(repoPath, file);
|
|
68
|
+
const buffer = readFileSync(fullPath);
|
|
21
69
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 });
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { validateRepoPath } from '../validate-path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/repo/pdf-thumb?path=REPO&file=FILE&page=0&width=600
|
|
7
|
+
*
|
|
8
|
+
* Converts a PDF page to a PNG thumbnail using Bun's native sharp or
|
|
9
|
+
* falls back to pdftoppm (poppler-utils) if available.
|
|
10
|
+
* Returns the image directly as image/png.
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(req: Request) {
|
|
13
|
+
const url = new URL(req.url);
|
|
14
|
+
const repoPath = url.searchParams.get('path') || '';
|
|
15
|
+
const filePath = url.searchParams.get('file') || '';
|
|
16
|
+
const page = parseInt(url.searchParams.get('page') || '0', 10);
|
|
17
|
+
const width = parseInt(url.searchParams.get('width') || '800', 10);
|
|
18
|
+
|
|
19
|
+
if (!repoPath || !filePath) {
|
|
20
|
+
return new Response('path and file params required', { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const blocked = validateRepoPath(repoPath);
|
|
24
|
+
if (blocked) return blocked;
|
|
25
|
+
|
|
26
|
+
// Prevent path traversal
|
|
27
|
+
if (filePath.includes('..') || filePath.startsWith('/')) {
|
|
28
|
+
return new Response('Invalid file path', { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fullPath = path.join(repoPath, filePath);
|
|
32
|
+
if (!existsSync(fullPath)) {
|
|
33
|
+
return new Response('File not found', { status: 404 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Cache key based on file path + modification time
|
|
37
|
+
const file = Bun.file(fullPath);
|
|
38
|
+
const cacheKey = `pdf-thumb:${fullPath}:${file.size}:p${page}:w${width}`;
|
|
39
|
+
|
|
40
|
+
// Try pdftoppm (poppler-utils) — most reliable cross-platform PDF renderer
|
|
41
|
+
try {
|
|
42
|
+
const proc = Bun.spawnSync([
|
|
43
|
+
'pdftoppm',
|
|
44
|
+
'-png',
|
|
45
|
+
'-f', String(page + 1),
|
|
46
|
+
'-l', String(page + 1),
|
|
47
|
+
'-scale-to-x', String(width),
|
|
48
|
+
'-scale-to-y', '-1',
|
|
49
|
+
'-singlefile',
|
|
50
|
+
fullPath,
|
|
51
|
+
'-',
|
|
52
|
+
], {
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'pipe',
|
|
55
|
+
timeout: 15000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (proc.exitCode === 0 && proc.stdout.length > 0) {
|
|
59
|
+
return new Response(proc.stdout, {
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'image/png',
|
|
62
|
+
'Cache-Control': 'public, max-age=86400',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// pdftoppm outputs to stdout with `-` but some versions need a prefix
|
|
68
|
+
// Try alternative invocation
|
|
69
|
+
const proc2 = Bun.spawnSync([
|
|
70
|
+
'pdftoppm',
|
|
71
|
+
'-png',
|
|
72
|
+
'-f', String(page + 1),
|
|
73
|
+
'-l', String(page + 1),
|
|
74
|
+
'-scale-to-x', String(width),
|
|
75
|
+
'-scale-to-y', '-1',
|
|
76
|
+
'-singlefile',
|
|
77
|
+
fullPath,
|
|
78
|
+
], {
|
|
79
|
+
stdout: 'pipe',
|
|
80
|
+
stderr: 'pipe',
|
|
81
|
+
timeout: 15000,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (proc2.exitCode === 0 && proc2.stdout.length > 0) {
|
|
85
|
+
return new Response(proc2.stdout, {
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'image/png',
|
|
88
|
+
'Cache-Control': 'public, max-age=86400',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
} catch (_) {
|
|
93
|
+
// pdftoppm not available
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fallback: try magick (ImageMagick)
|
|
97
|
+
try {
|
|
98
|
+
const proc = Bun.spawnSync([
|
|
99
|
+
'magick',
|
|
100
|
+
'-density', '150',
|
|
101
|
+
`${fullPath}[${page}]`,
|
|
102
|
+
'-resize', `${width}x`,
|
|
103
|
+
'png:-',
|
|
104
|
+
], {
|
|
105
|
+
stdout: 'pipe',
|
|
106
|
+
stderr: 'pipe',
|
|
107
|
+
timeout: 30000,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (proc.exitCode === 0 && proc.stdout.length > 0) {
|
|
111
|
+
return new Response(proc.stdout, {
|
|
112
|
+
headers: {
|
|
113
|
+
'Content-Type': 'image/png',
|
|
114
|
+
'Cache-Control': 'public, max-age=86400',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch (_) {
|
|
119
|
+
// magick not available
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No PDF renderer available — return a helpful placeholder
|
|
123
|
+
return new Response('PDF preview requires poppler-utils (pdftoppm) or ImageMagick (magick). Install one to enable PDF thumbnails.', {
|
|
124
|
+
status: 501,
|
|
125
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
4
|
+
import { extractCanonicalForgeSlugInfo } from '../load/route';
|
|
5
|
+
|
|
6
|
+
const CLONES_DIR = path.join(process.cwd(), 'git-canvas', 'repos');
|
|
7
|
+
|
|
8
|
+
async function findRepoByCanonicalSlug(slug: string): Promise<string | null> {
|
|
9
|
+
if (!slug || !existsSync(CLONES_DIR)) return null;
|
|
10
|
+
|
|
11
|
+
const entries = readdirSync(CLONES_DIR);
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const fullPath = path.join(CLONES_DIR, entry);
|
|
14
|
+
try {
|
|
15
|
+
const stat = statSync(fullPath);
|
|
16
|
+
if (!stat.isDirectory()) continue;
|
|
17
|
+
if (!existsSync(path.join(fullPath, '.git'))) continue;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const git = simpleGit(fullPath);
|
|
21
|
+
const remotes = await git.getRemotes(true);
|
|
22
|
+
const origin = remotes.find((r) => r.name === 'origin') || remotes[0];
|
|
23
|
+
const info = extractCanonicalForgeSlugInfo(origin?.refs?.fetch || origin?.refs?.push || null);
|
|
24
|
+
if (info.slug === slug) {
|
|
25
|
+
return fullPath.replace(/\\/g, '/');
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore invalid repos
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore bad entries
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function POST(req: Request) {
|
|
39
|
+
try {
|
|
40
|
+
const { slug } = await req.json() as { slug?: string };
|
|
41
|
+
const normalizedSlug = (slug || '').trim().replace(/^\/+|\/+$/g, '');
|
|
42
|
+
if (!normalizedSlug) {
|
|
43
|
+
return Response.json({ error: 'slug is required' }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const resolvedPath = await findRepoByCanonicalSlug(normalizedSlug);
|
|
47
|
+
return Response.json({ path: resolvedPath });
|
|
48
|
+
} catch (error: any) {
|
|
49
|
+
return Response.json({ error: error?.message || 'Failed to resolve slug' }, { status: 500 });
|
|
50
|
+
}
|
|
51
|
+
}
|