@youtyan/code-viewer 0.1.8 → 0.1.9
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 +56 -2
- package/package.json +13 -5
- package/web/app.js +7301 -510
- package/web/index.html +7 -3
- package/web/mermaid.js +156840 -0
- package/web/shiki.js +13182 -0
- package/web/style.css +719 -30
- package/web-src/server/git.ts +114 -7
- package/web-src/server/preview.ts +300 -16
- package/web-src/types.ts +2 -0
package/web-src/server/git.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export type GitFileMeta = {
|
|
@@ -17,6 +17,7 @@ export type GitTreeEntry = {
|
|
|
17
17
|
name: string;
|
|
18
18
|
path: string;
|
|
19
19
|
type: 'tree' | 'blob' | 'commit';
|
|
20
|
+
children_omitted?: true;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
|
|
@@ -28,6 +29,15 @@ function run(args: string[], cwd: string): { code: number; stdout: string; stder
|
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function runBytes(args: string[], cwd: string): { code: number; stdout: Uint8Array; stderr: string } {
|
|
33
|
+
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
34
|
+
return {
|
|
35
|
+
code: proc.exitCode,
|
|
36
|
+
stdout: new Uint8Array(proc.stdout),
|
|
37
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export function repoRoot(cwd: string): string | null {
|
|
32
42
|
const res = run(['git', 'rev-parse', '--show-toplevel'], cwd);
|
|
33
43
|
return res.code === 0 ? res.stdout.trimEnd() : null;
|
|
@@ -42,6 +52,15 @@ export function show(ref: string, path: string, cwd: string): { code: number; st
|
|
|
42
52
|
return run(['git', 'show', `${ref}:${path}`], cwd);
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
export function showBytes(ref: string, path: string, cwd: string): { code: number; stdout: Uint8Array; stderr: string } {
|
|
56
|
+
return runBytes(['git', 'show', `${ref}:${path}`], cwd);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function objectSize(ref: string, path: string, cwd: string): { code: number; size: number; stderr: string } {
|
|
60
|
+
const res = run(['git', 'cat-file', '-s', `${ref}:${path}`], cwd);
|
|
61
|
+
return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
export function verifyTreeRef(ref: string, cwd: string): boolean {
|
|
46
65
|
if (!ref || ref === 'worktree') return false;
|
|
47
66
|
if (ref.startsWith('-')) return false;
|
|
@@ -143,10 +162,13 @@ function worktreeDirectChildren(cwd: string, path: string): GitTreeEntry[] {
|
|
|
143
162
|
try {
|
|
144
163
|
return sortTreeEntries(readdirSync(dir, { withFileTypes: true }).map((entry) => {
|
|
145
164
|
const entryPath = base ? `${base}/${entry.name}` : entry.name;
|
|
165
|
+
const type = entry.isDirectory()
|
|
166
|
+
? hasDotGitEntry(join(dir, entry.name)) ? 'commit' as const : 'tree' as const
|
|
167
|
+
: 'blob' as const;
|
|
146
168
|
return {
|
|
147
169
|
name: entry.name,
|
|
148
170
|
path: entryPath,
|
|
149
|
-
type
|
|
171
|
+
type,
|
|
150
172
|
};
|
|
151
173
|
}));
|
|
152
174
|
} catch {
|
|
@@ -154,6 +176,15 @@ function worktreeDirectChildren(cwd: string, path: string): GitTreeEntry[] {
|
|
|
154
176
|
}
|
|
155
177
|
}
|
|
156
178
|
|
|
179
|
+
function hasDotGitEntry(dir: string): boolean {
|
|
180
|
+
try {
|
|
181
|
+
lstatSync(join(dir, '.git'));
|
|
182
|
+
return true;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return !!err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
157
188
|
function worktreeRecursiveFiles(cwd: string, path: string): GitTreeEntry[] {
|
|
158
189
|
const base = normalizeTreePath(path);
|
|
159
190
|
const trackedArgs = ['git', '-c', 'core.quotepath=false', 'ls-files', '-z'];
|
|
@@ -196,6 +227,31 @@ function combineDirectAndRecursiveFiles(directEntries: GitTreeEntry[], fileEntri
|
|
|
196
227
|
];
|
|
197
228
|
}
|
|
198
229
|
|
|
230
|
+
function ignoredWorktreePaths(cwd: string, path: string): Set<string> {
|
|
231
|
+
const base = normalizeTreePath(path);
|
|
232
|
+
const args = ['git', '-c', 'core.quotepath=false', 'status', '--ignored', '--porcelain=v1', '-z', '--'];
|
|
233
|
+
if (base) args.push(`${base}/`);
|
|
234
|
+
const res = run(args, cwd);
|
|
235
|
+
const ignored = new Set<string>();
|
|
236
|
+
if (res.code !== 0) return ignored;
|
|
237
|
+
const records = res.stdout.split('\0').filter(Boolean);
|
|
238
|
+
for (let i = 0; i < records.length; i++) {
|
|
239
|
+
const rec = records[i];
|
|
240
|
+
if (!rec.startsWith('!! ')) continue;
|
|
241
|
+
const path = rec.slice(3).replace(/\/+$/g, '');
|
|
242
|
+
if (path) ignored.add(path);
|
|
243
|
+
}
|
|
244
|
+
return ignored;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function annotateOmittedWorktreeChildren(entries: GitTreeEntry[], ignoredPaths: Set<string>): GitTreeEntry[] {
|
|
248
|
+
return entries.map((entry) => {
|
|
249
|
+
return entry.type === 'tree' && ignoredPaths.has(entry.path)
|
|
250
|
+
? { ...entry, children_omitted: true }
|
|
251
|
+
: entry;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
199
255
|
export function worktreeEntries(cwd: string, path: string): GitTreeEntry[] {
|
|
200
256
|
return listTree('worktree', path, cwd).entries;
|
|
201
257
|
}
|
|
@@ -221,8 +277,11 @@ export function listTree(
|
|
|
221
277
|
const base = normalizeTreePath(path);
|
|
222
278
|
if (ref === 'worktree') {
|
|
223
279
|
const directEntries = worktreeDirectChildren(cwd, base);
|
|
224
|
-
|
|
225
|
-
|
|
280
|
+
const ignoredPaths = ignoredWorktreePaths(cwd, base);
|
|
281
|
+
const annotatedDirectEntries = annotateOmittedWorktreeChildren(directEntries, ignoredPaths);
|
|
282
|
+
if (!options.recursive) return { code: 0, entries: annotatedDirectEntries, stderr: '' };
|
|
283
|
+
const recursiveEntries = worktreeRecursiveFiles(cwd, base);
|
|
284
|
+
return { code: 0, entries: combineDirectAndRecursiveFiles(annotatedDirectEntries, recursiveEntries), stderr: '' };
|
|
226
285
|
}
|
|
227
286
|
|
|
228
287
|
const direct = gitTreeEntries(ref, base, cwd, false);
|
|
@@ -302,17 +361,65 @@ export function truncateToNHunks(diffText: string, n: number): {
|
|
|
302
361
|
totalHunks: number;
|
|
303
362
|
renderedHunks: number;
|
|
304
363
|
lineCount: number;
|
|
364
|
+
lineTruncated: boolean;
|
|
365
|
+
};
|
|
366
|
+
export function truncateToNHunks(diffText: string, n: number, maxLines: number): {
|
|
367
|
+
text: string;
|
|
368
|
+
totalHunks: number;
|
|
369
|
+
renderedHunks: number;
|
|
370
|
+
lineCount: number;
|
|
371
|
+
lineTruncated: boolean;
|
|
372
|
+
};
|
|
373
|
+
export function truncateToNHunks(diffText: string, n: number, maxLines = Number.POSITIVE_INFINITY): {
|
|
374
|
+
text: string;
|
|
375
|
+
totalHunks: number;
|
|
376
|
+
renderedHunks: number;
|
|
377
|
+
lineCount: number;
|
|
378
|
+
lineTruncated: boolean;
|
|
305
379
|
} {
|
|
306
380
|
const { header, hunks } = splitHunks(diffText);
|
|
307
381
|
if (hunks.length === 0) {
|
|
308
|
-
|
|
382
|
+
const lines = diffText.split('\n');
|
|
383
|
+
const lineTruncated = Number.isFinite(maxLines) && lines.length > maxLines;
|
|
384
|
+
const text = lineTruncated ? lines.slice(0, maxLines).join('\n') : diffText;
|
|
385
|
+
return {
|
|
386
|
+
text,
|
|
387
|
+
totalHunks: 0,
|
|
388
|
+
renderedHunks: 0,
|
|
389
|
+
lineCount: (text.match(/\n/g) || []).length,
|
|
390
|
+
lineTruncated,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const maxHunks = Math.min(n, hunks.length);
|
|
394
|
+
const rendered: string[] = [];
|
|
395
|
+
let renderedHunks = 0;
|
|
396
|
+
let usedLines = (header.match(/\n/g) || []).length;
|
|
397
|
+
let lineTruncated = false;
|
|
398
|
+
for (let index = 0; index < maxHunks; index++) {
|
|
399
|
+
const hunk = hunks[index];
|
|
400
|
+
const lines = hunk.split('\n');
|
|
401
|
+
const separatorLines = rendered.length > 0 ? 1 : 0;
|
|
402
|
+
const remaining = maxLines - usedLines - separatorLines;
|
|
403
|
+
if (remaining <= 0) {
|
|
404
|
+
lineTruncated = true;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (Number.isFinite(maxLines) && lines.length > remaining) {
|
|
408
|
+
rendered.push(lines.slice(0, remaining).join('\n'));
|
|
409
|
+
renderedHunks++;
|
|
410
|
+
lineTruncated = true;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
rendered.push(hunk);
|
|
414
|
+
renderedHunks++;
|
|
415
|
+
usedLines += separatorLines + lines.length;
|
|
309
416
|
}
|
|
310
|
-
const
|
|
311
|
-
const text = header + hunks.slice(0, renderedHunks).join('\n');
|
|
417
|
+
const text = header + rendered.join('\n');
|
|
312
418
|
return {
|
|
313
419
|
text,
|
|
314
420
|
totalHunks: hunks.length,
|
|
315
421
|
renderedHunks,
|
|
316
422
|
lineCount: (text.match(/\n/g) || []).length,
|
|
423
|
+
lineTruncated,
|
|
317
424
|
};
|
|
318
425
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, realpathSync, statSync, watch } from 'node:fs';
|
|
4
|
-
import { basename, extname, join, normalize, relative } from 'node:path';
|
|
3
|
+
import { closeSync, constants, existsSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, extname, join, normalize, relative } from 'node:path';
|
|
5
5
|
import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
|
|
6
6
|
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
|
|
7
7
|
import { cacheFresh, type TimedCacheEntry } from './cache';
|
|
@@ -14,15 +14,27 @@ const WEB_ROOT = join(ROOT, 'web');
|
|
|
14
14
|
const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version as string;
|
|
15
15
|
const DEFAULT_ARGS = ['HEAD'];
|
|
16
16
|
const PREVIEW_HUNKS_DEFAULT = 3;
|
|
17
|
+
const PREVIEW_LINES_DEFAULT = 1200;
|
|
17
18
|
const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
|
|
18
19
|
const SIZE_SMALL = 2000;
|
|
19
20
|
const SIZE_MEDIUM = 8000;
|
|
20
21
|
const SIZE_LARGE = 20000;
|
|
22
|
+
const MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
|
|
23
|
+
const MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
24
|
+
const MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
|
25
|
+
const MAX_UPLOAD_FILES = 50;
|
|
26
|
+
const SAFE_UPLOAD_EXTENSIONS = new Set([
|
|
27
|
+
'.txt', '.md', '.markdown', '.json', '.csv', '.tsv', '.yaml', '.yml', '.toml',
|
|
28
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf',
|
|
29
|
+
'.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.html',
|
|
30
|
+
]);
|
|
21
31
|
|
|
22
32
|
let generation = 1;
|
|
23
33
|
let cwd = git.repoRoot(process.cwd()) || process.cwd();
|
|
24
34
|
let cliArgs = DEFAULT_ARGS;
|
|
25
35
|
let listenPort = 0;
|
|
36
|
+
let allowUpload = false;
|
|
37
|
+
let uploadAllowedByCli = false;
|
|
26
38
|
|
|
27
39
|
const enc = new TextEncoder();
|
|
28
40
|
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
@@ -66,11 +78,15 @@ Examples:
|
|
|
66
78
|
listenPort = parsed;
|
|
67
79
|
} else if (arg === '--open') {
|
|
68
80
|
setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
|
|
81
|
+
} else if (arg === '--allow-upload') {
|
|
82
|
+
allowUpload = true;
|
|
83
|
+
uploadAllowedByCli = true;
|
|
69
84
|
} else {
|
|
70
85
|
rest.push(arg);
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
if (rest.length) cliArgs = rest;
|
|
89
|
+
if (!uploadAllowedByCli) allowUpload = loadProjectConfigUploadEnabled();
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
function json(data: unknown, init: ResponseInit = {}) {
|
|
@@ -99,11 +115,24 @@ function requestAllowed(req: Request) {
|
|
|
99
115
|
return okHost && okOrigin;
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
function sideEffectRequestAllowed(req: Request) {
|
|
119
|
+
const host = req.headers.get('host') || '';
|
|
120
|
+
const origin = req.headers.get('origin');
|
|
121
|
+
const fetchSite = req.headers.get('sec-fetch-site');
|
|
122
|
+
const requestedBy = req.headers.get('x-code-viewer-action');
|
|
123
|
+
return /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host) &&
|
|
124
|
+
origin === `http://${host}` &&
|
|
125
|
+
(!fetchSite || fetchSite === 'same-origin') &&
|
|
126
|
+
requestedBy === '1';
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
function staticFile(pathname: string): Response | null {
|
|
103
130
|
const map: Record<string, [string, string]> = {
|
|
104
131
|
'/favicon.png': ['favicon.png', 'image/png'],
|
|
105
132
|
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
106
133
|
'/app.js': ['app.js', 'application/javascript; charset=utf-8'],
|
|
134
|
+
'/mermaid.js': ['mermaid.js', 'application/javascript; charset=utf-8'],
|
|
135
|
+
'/shiki.js': ['shiki.js', 'application/javascript; charset=utf-8'],
|
|
107
136
|
'/vendor/diff2html/diff2html.min.css': ['vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
|
|
108
137
|
'/vendor/diff2html/diff2html-ui.min.js': ['vendor/diff2html/diff2html-ui.min.js', 'application/javascript; charset=utf-8'],
|
|
109
138
|
'/vendor/highlight.js/highlight.min.js': ['vendor/highlight.js/highlight.min.js', 'application/javascript; charset=utf-8'],
|
|
@@ -172,7 +201,7 @@ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }
|
|
|
172
201
|
const q = { path: file.path, old_path: file.old_path, status: file.status, from: range.from, to: range.to, ...extraQs };
|
|
173
202
|
if (file.untracked) Object.assign(q, { untracked: '1' });
|
|
174
203
|
const previewQ = { ...q, mode: 'preview', max_hunks: PREVIEW_HUNKS_DEFAULT };
|
|
175
|
-
const previewUrl = sizeClass
|
|
204
|
+
const previewUrl = sizeClass !== 'small' ? `/file_diff${buildQuery(previewQ)}` : null;
|
|
176
205
|
return {
|
|
177
206
|
order: file.order,
|
|
178
207
|
key: `${file.status || 'M'}\0${file.old_path || ''}\0${file.path}`,
|
|
@@ -185,7 +214,7 @@ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }
|
|
|
185
214
|
binary: file.binary || false,
|
|
186
215
|
media_kind: guessMediaKind(file.path),
|
|
187
216
|
size_class: sizeClass,
|
|
188
|
-
force_layout: sizeClass === '
|
|
217
|
+
force_layout: sizeClass === 'huge' ? 'line-by-line' : undefined,
|
|
189
218
|
highlight: sizeClass === 'small',
|
|
190
219
|
load_url: `/file_diff${buildQuery(q)}`,
|
|
191
220
|
preview_url: previewUrl,
|
|
@@ -264,8 +293,31 @@ function safeRepoPath(path: string) {
|
|
|
264
293
|
return path === '' || safePath(path);
|
|
265
294
|
}
|
|
266
295
|
|
|
296
|
+
function loadProjectConfigUploadEnabled(): boolean {
|
|
297
|
+
const full = join(cwd, '.code-viewer.json');
|
|
298
|
+
if (!existsSync(full)) return false;
|
|
299
|
+
let realCwd: string;
|
|
300
|
+
let realConfig: string;
|
|
301
|
+
try {
|
|
302
|
+
realCwd = realpathSync(cwd);
|
|
303
|
+
realConfig = realpathSync(full);
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return false;
|
|
308
|
+
try {
|
|
309
|
+
const config = JSON.parse(readFileSync(realConfig, 'utf8')) as {
|
|
310
|
+
upload?: { enabled?: unknown };
|
|
311
|
+
};
|
|
312
|
+
if (!config.upload) return false;
|
|
313
|
+
return config.upload.enabled === true;
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
267
319
|
function isGitInternalPath(path: string): boolean {
|
|
268
|
-
return path
|
|
320
|
+
return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
|
|
269
321
|
}
|
|
270
322
|
|
|
271
323
|
function safeWorktreePath(path: string): string | null {
|
|
@@ -273,13 +325,38 @@ function safeWorktreePath(path: string): string | null {
|
|
|
273
325
|
if (isGitInternalPath(path)) return null;
|
|
274
326
|
const full = join(cwd, path);
|
|
275
327
|
if (!existsSync(full)) return null;
|
|
276
|
-
|
|
277
|
-
|
|
328
|
+
let realCwd: string;
|
|
329
|
+
let realFull: string;
|
|
330
|
+
try {
|
|
331
|
+
realCwd = realpathSync(cwd);
|
|
332
|
+
realFull = realpathSync(full);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
278
336
|
const rel = relative(realCwd, realFull);
|
|
279
337
|
if (rel === '' || rel.startsWith('..') || rel.startsWith('/') || rel.startsWith('\\')) return null;
|
|
338
|
+
if (isGitInternalPath(rel)) return null;
|
|
280
339
|
return realFull;
|
|
281
340
|
}
|
|
282
341
|
|
|
342
|
+
function safeOpenWorktreePath(path: string): string | null {
|
|
343
|
+
if (path === '') {
|
|
344
|
+
try {
|
|
345
|
+
const realCwd = realpathSync(cwd);
|
|
346
|
+
if (isGitInternalPath(realCwd)) return null;
|
|
347
|
+
return realCwd;
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return safeWorktreePath(path);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parentRepoPath(path: string): string {
|
|
356
|
+
const parent = dirname(path);
|
|
357
|
+
return parent === '.' ? '' : parent;
|
|
358
|
+
}
|
|
359
|
+
|
|
283
360
|
function readReadme(target: string, dirPath: string): RepoTreeResponse['readme'] {
|
|
284
361
|
const candidates = ['README.md', 'readme.md', 'README.markdown', 'README'];
|
|
285
362
|
for (const name of candidates) {
|
|
@@ -314,6 +391,7 @@ function handleTree(url: URL) {
|
|
|
314
391
|
branch: git.currentBranch(cwd) || undefined,
|
|
315
392
|
entries,
|
|
316
393
|
readme: readReadme(target, path),
|
|
394
|
+
upload_enabled: allowUpload && (target === 'worktree' || target === ''),
|
|
317
395
|
} satisfies RepoTreeResponse);
|
|
318
396
|
}
|
|
319
397
|
|
|
@@ -362,7 +440,11 @@ function handleFileDiff(url: URL) {
|
|
|
362
440
|
}
|
|
363
441
|
const mode = url.searchParams.get('mode') || 'full';
|
|
364
442
|
const truncated = mode === 'preview'
|
|
365
|
-
? git.truncateToNHunks(
|
|
443
|
+
? git.truncateToNHunks(
|
|
444
|
+
diffText,
|
|
445
|
+
Number(url.searchParams.get('max_hunks')) || PREVIEW_HUNKS_DEFAULT,
|
|
446
|
+
Number(url.searchParams.get('max_lines')) || PREVIEW_LINES_DEFAULT,
|
|
447
|
+
)
|
|
366
448
|
: git.truncateToNHunks(diffText, 1e9);
|
|
367
449
|
const body: FileDiffResponse & { line_count?: number; error?: string } = {
|
|
368
450
|
path,
|
|
@@ -373,7 +455,7 @@ function handleFileDiff(url: URL) {
|
|
|
373
455
|
hunk_count: truncated.totalHunks,
|
|
374
456
|
rendered_hunk_count: truncated.renderedHunks,
|
|
375
457
|
line_count: truncated.lineCount,
|
|
376
|
-
truncated: mode === 'preview' && truncated.totalHunks > truncated.renderedHunks,
|
|
458
|
+
truncated: mode === 'preview' && (truncated.totalHunks > truncated.renderedHunks || truncated.lineTruncated),
|
|
377
459
|
binary: diffText.includes('Binary files'),
|
|
378
460
|
error: errText,
|
|
379
461
|
generation,
|
|
@@ -407,26 +489,225 @@ function handleFileRange(url: URL) {
|
|
|
407
489
|
return json(body);
|
|
408
490
|
}
|
|
409
491
|
|
|
410
|
-
function handleRawFile(url: URL) {
|
|
492
|
+
function handleRawFile(req: Request, url: URL) {
|
|
411
493
|
const path = url.searchParams.get('path') || '';
|
|
412
494
|
if (!safePath(path)) return text('forbidden', 403);
|
|
413
495
|
const ref = url.searchParams.get('ref') || 'worktree';
|
|
414
496
|
let body: BodyInit;
|
|
415
497
|
if (ref !== 'worktree' && ref !== '') {
|
|
416
498
|
if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
|
|
417
|
-
const
|
|
499
|
+
const size = rawFileSize(path, ref);
|
|
500
|
+
if (size == null) return text('not in ref', 404);
|
|
501
|
+
if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
502
|
+
const res = git.showBytes(ref, path, cwd);
|
|
418
503
|
if (res.code !== 0) return text('not in ref', 404);
|
|
419
|
-
body = res.stdout;
|
|
504
|
+
body = res.stdout.buffer.slice(res.stdout.byteOffset, res.stdout.byteOffset + res.stdout.byteLength) as ArrayBuffer;
|
|
505
|
+
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
420
506
|
} else {
|
|
421
507
|
const full = safeWorktreePath(path);
|
|
422
508
|
if (!full) return text('not found', 404);
|
|
423
|
-
|
|
509
|
+
const size = rawFileSize(path, ref);
|
|
510
|
+
if (size == null) return text('not found', 404);
|
|
511
|
+
if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
512
|
+
const bytes = new Uint8Array(readFileSync(full));
|
|
513
|
+
body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
514
|
+
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function rawFileSize(path: string, ref: string): number | null {
|
|
519
|
+
if (ref !== 'worktree' && ref !== '') {
|
|
520
|
+
if (!git.verifyTreeRef(ref, cwd)) return null;
|
|
521
|
+
const res = git.objectSize(ref, path, cwd);
|
|
522
|
+
return res.code === 0 ? res.size : null;
|
|
424
523
|
}
|
|
524
|
+
const full = safeWorktreePath(path);
|
|
525
|
+
if (!full) return null;
|
|
526
|
+
try {
|
|
527
|
+
return (statSync(full) as unknown as { size: number }).size;
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
|
|
425
534
|
const mime: Record<string, string> = {
|
|
426
535
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
427
536
|
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
537
|
+
'.pdf': 'application/pdf',
|
|
428
538
|
};
|
|
429
|
-
|
|
539
|
+
const headers: Record<string, string> = {
|
|
540
|
+
'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream',
|
|
541
|
+
'Cache-Control': 'no-store',
|
|
542
|
+
'X-Content-Type-Options': 'nosniff',
|
|
543
|
+
'Content-Security-Policy': 'sandbox',
|
|
544
|
+
};
|
|
545
|
+
if (size != null) headers['Content-Length'] = String(size);
|
|
546
|
+
return headers;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isForbiddenUploadName(name: string): boolean {
|
|
550
|
+
const lower = name.toLowerCase();
|
|
551
|
+
return lower.startsWith('.') ||
|
|
552
|
+
lower === 'package.json' ||
|
|
553
|
+
lower === 'package-lock.json' ||
|
|
554
|
+
lower === 'bun.lock' ||
|
|
555
|
+
lower === 'bun.lockb' ||
|
|
556
|
+
lower === 'yarn.lock' ||
|
|
557
|
+
lower === 'pnpm-lock.yaml' ||
|
|
558
|
+
lower === 'makefile' ||
|
|
559
|
+
lower === 'dockerfile' ||
|
|
560
|
+
lower.endsWith('.dockerfile') ||
|
|
561
|
+
/^(tsconfig|jsconfig|bunfig|vercel|netlify|wrangler|next|vite|webpack|rollup|esbuild|astro|svelte|tailwind|postcss|babel|prettier|eslint)\./.test(lower) ||
|
|
562
|
+
lower.endsWith('.config.js') ||
|
|
563
|
+
lower.endsWith('.config.jsx') ||
|
|
564
|
+
lower.endsWith('.config.ts') ||
|
|
565
|
+
lower.endsWith('.config.tsx') ||
|
|
566
|
+
lower.endsWith('.config.mjs') ||
|
|
567
|
+
lower.endsWith('.config.cjs') ||
|
|
568
|
+
lower.includes('credential') ||
|
|
569
|
+
lower.includes('secret') ||
|
|
570
|
+
lower.endsWith('.exe') ||
|
|
571
|
+
lower.endsWith('.dll') ||
|
|
572
|
+
lower.endsWith('.dylib') ||
|
|
573
|
+
lower.endsWith('.so') ||
|
|
574
|
+
lower.endsWith('.sh') ||
|
|
575
|
+
lower.endsWith('.bash') ||
|
|
576
|
+
lower.endsWith('.zsh') ||
|
|
577
|
+
lower.endsWith('.fish') ||
|
|
578
|
+
lower.endsWith('.ps1') ||
|
|
579
|
+
lower.endsWith('.bat') ||
|
|
580
|
+
lower.endsWith('.cmd');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function safeUploadFileName(name: string): string | null {
|
|
584
|
+
if (!name || name.includes('\0') || name.includes('/') || name.includes('\\')) return null;
|
|
585
|
+
if (name === '.' || name === '..') return null;
|
|
586
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}$/.test(name)) return null;
|
|
587
|
+
if (isGitInternalPath(name) || isForbiddenUploadName(name)) return null;
|
|
588
|
+
if (!SAFE_UPLOAD_EXTENSIONS.has(extname(name).toLowerCase())) return null;
|
|
589
|
+
return name;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function uploadOpenFlags() {
|
|
593
|
+
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function handleUploadFiles(req: Request) {
|
|
597
|
+
if (!allowUpload) return text('upload disabled', 403);
|
|
598
|
+
if (req.method !== 'POST') return text('method not allowed', 405);
|
|
599
|
+
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
600
|
+
if (req.headers.get('content-encoding')) return text('unsupported media type', 415);
|
|
601
|
+
const lengthHeader = req.headers.get('content-length');
|
|
602
|
+
if (!lengthHeader) return text('content length required', 411);
|
|
603
|
+
const length = Number(lengthHeader);
|
|
604
|
+
if (!Number.isSafeInteger(length) || length < 0) return text('invalid content length', 400);
|
|
605
|
+
if (length > MAX_UPLOAD_BODY_BYTES) return text('upload too large', 413);
|
|
606
|
+
const contentType = req.headers.get('content-type') || '';
|
|
607
|
+
if (!/^multipart\/form-data;\s*boundary=/i.test(contentType)) return text('unsupported media type', 415);
|
|
608
|
+
|
|
609
|
+
let form: FormData;
|
|
610
|
+
try {
|
|
611
|
+
form = await req.formData();
|
|
612
|
+
} catch {
|
|
613
|
+
return text('invalid form data', 400);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const dir = String(form.get('dir') || '').replace(/^\/+|\/+$/g, '');
|
|
617
|
+
if (!safeRepoPath(dir)) return text('invalid dir', 400);
|
|
618
|
+
if (dir && isGitInternalPath(dir)) return text('forbidden', 403);
|
|
619
|
+
const realDir = safeOpenWorktreePath(dir);
|
|
620
|
+
if (!realDir) return text('not found', 404);
|
|
621
|
+
const stats = statSync(realDir) as unknown as { isDirectory(): boolean };
|
|
622
|
+
if (!stats.isDirectory()) return text('not a directory', 400);
|
|
623
|
+
|
|
624
|
+
const files = form.getAll('files').filter((item): item is File => item instanceof File);
|
|
625
|
+
if (!files.length) return text('no files', 400);
|
|
626
|
+
if (files.length > MAX_UPLOAD_FILES) return text('too many files', 413);
|
|
627
|
+
|
|
628
|
+
let total = 0;
|
|
629
|
+
const names = new Set<string>();
|
|
630
|
+
const uploads: Array<{ file: File; name: string; target: string }> = [];
|
|
631
|
+
for (const file of files) {
|
|
632
|
+
const safeName = safeUploadFileName(file.name);
|
|
633
|
+
if (!safeName) return text('invalid filename', 400);
|
|
634
|
+
const lowerName = safeName.toLowerCase();
|
|
635
|
+
if (names.has(lowerName)) return text('duplicate filename', 409);
|
|
636
|
+
names.add(lowerName);
|
|
637
|
+
if (file.size > MAX_UPLOAD_FILE_BYTES) return text('file too large', 413);
|
|
638
|
+
total += file.size;
|
|
639
|
+
if (total > MAX_UPLOAD_TOTAL_BYTES) return text('upload too large', 413);
|
|
640
|
+
const target = join(realDir, safeName);
|
|
641
|
+
if (relative(realDir, dirname(target)) !== '') return text('invalid filename', 400);
|
|
642
|
+
if (existsSync(target)) return text('file exists', 409);
|
|
643
|
+
uploads.push({ file, name: safeName, target });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const written: string[] = [];
|
|
647
|
+
try {
|
|
648
|
+
for (const upload of uploads) {
|
|
649
|
+
const fd = openSync(upload.target, uploadOpenFlags(), 0o644);
|
|
650
|
+
try {
|
|
651
|
+
writeFileSync(fd, new Uint8Array(await upload.file.arrayBuffer()));
|
|
652
|
+
} finally {
|
|
653
|
+
closeSync(fd);
|
|
654
|
+
}
|
|
655
|
+
written.push(upload.target);
|
|
656
|
+
}
|
|
657
|
+
} catch (error) {
|
|
658
|
+
for (const path of written) {
|
|
659
|
+
try { unlinkSync(path); } catch { /* best-effort cleanup */ }
|
|
660
|
+
}
|
|
661
|
+
if ((error as { code?: string }).code === 'EEXIST') return text('file exists', 409);
|
|
662
|
+
return text('upload failed', 500);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
generation++;
|
|
666
|
+
fileCache.clear();
|
|
667
|
+
metaCache.clear();
|
|
668
|
+
sendSse('update');
|
|
669
|
+
return json({ ok: true, files: uploads.map(upload => upload.name), generation });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function openOsPath(path: string) {
|
|
673
|
+
const cmd = process.platform === 'darwin' ? ['open', '--', path]
|
|
674
|
+
: process.platform === 'win32' ? ['explorer.exe', path]
|
|
675
|
+
: ['xdg-open', path];
|
|
676
|
+
Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function handleOpenPath(req: Request) {
|
|
680
|
+
if (req.method !== 'POST') return text('method not allowed', 405);
|
|
681
|
+
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
682
|
+
const contentType = req.headers.get('content-type') || '';
|
|
683
|
+
if (!/^application\/json(?:;|$)/i.test(contentType)) return text('unsupported media type', 415);
|
|
684
|
+
const length = Number(req.headers.get('content-length') || '0');
|
|
685
|
+
if (length > 1024) return text('payload too large', 413);
|
|
686
|
+
|
|
687
|
+
let body: { path?: unknown; kind?: unknown } = {};
|
|
688
|
+
try {
|
|
689
|
+
const raw = await req.text();
|
|
690
|
+
if (raw.length > 1024) return text('payload too large', 413);
|
|
691
|
+
body = JSON.parse(raw);
|
|
692
|
+
} catch {
|
|
693
|
+
return text('invalid json', 400);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const path = typeof body.path === 'string' ? body.path.replace(/^\/+|\/+$/g, '') : '';
|
|
697
|
+
const kind = body.kind;
|
|
698
|
+
if (kind !== 'directory' && kind !== 'file-parent') return text('invalid kind', 400);
|
|
699
|
+
if (kind === 'file-parent' && !path) return text('invalid path', 400);
|
|
700
|
+
if (!safeRepoPath(path)) return text('invalid path', 400);
|
|
701
|
+
if (path && isGitInternalPath(path)) return text('forbidden', 403);
|
|
702
|
+
|
|
703
|
+
const targetPath = kind === 'file-parent' ? parentRepoPath(path) : path;
|
|
704
|
+
const target = safeOpenWorktreePath(targetPath);
|
|
705
|
+
if (!target) return text('not found', 404);
|
|
706
|
+
|
|
707
|
+
const stats = statSync(target) as unknown as { isDirectory(): boolean };
|
|
708
|
+
if (!stats.isDirectory()) return text('not a directory', 400);
|
|
709
|
+
openOsPath(target);
|
|
710
|
+
return json({ ok: true });
|
|
430
711
|
}
|
|
431
712
|
|
|
432
713
|
function sendSse(event: string, data = 'tick') {
|
|
@@ -448,7 +729,7 @@ parseCli();
|
|
|
448
729
|
const server = Bun.serve({
|
|
449
730
|
hostname: '127.0.0.1',
|
|
450
731
|
port: listenPort,
|
|
451
|
-
fetch(req) {
|
|
732
|
+
async fetch(req) {
|
|
452
733
|
if (!requestAllowed(req)) return text('forbidden', 403);
|
|
453
734
|
const url = new URL(req.url);
|
|
454
735
|
const staticResponse = staticFile(url.pathname);
|
|
@@ -457,9 +738,12 @@ const server = Bun.serve({
|
|
|
457
738
|
if (url.pathname === '/_tree') return handleTree(url);
|
|
458
739
|
if (url.pathname === '/file_diff') return handleFileDiff(url);
|
|
459
740
|
if (url.pathname === '/file_range') return handleFileRange(url);
|
|
460
|
-
if (url.pathname === '/_file') return handleRawFile(url);
|
|
741
|
+
if (url.pathname === '/_file') return handleRawFile(req, url);
|
|
742
|
+
if (url.pathname === '/_open_path') return handleOpenPath(req);
|
|
743
|
+
if (url.pathname === '/_upload_files') return handleUploadFiles(req);
|
|
461
744
|
if (url.pathname === '/_refs') return json(git.refs(cwd));
|
|
462
745
|
if (url.pathname === '/refresh' && req.method === 'POST') {
|
|
746
|
+
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
463
747
|
generation++;
|
|
464
748
|
fileCache.clear();
|
|
465
749
|
metaCache.clear();
|
package/web-src/types.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type RepoTreeEntry = {
|
|
|
37
37
|
name: string;
|
|
38
38
|
path: string;
|
|
39
39
|
type: 'tree' | 'blob' | 'commit';
|
|
40
|
+
children_omitted?: true;
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
export type RepoTreeResponse = {
|
|
@@ -44,6 +45,7 @@ export type RepoTreeResponse = {
|
|
|
44
45
|
path: string;
|
|
45
46
|
project: string;
|
|
46
47
|
branch?: string;
|
|
48
|
+
upload_enabled?: boolean;
|
|
47
49
|
entries: RepoTreeEntry[];
|
|
48
50
|
readme?: {
|
|
49
51
|
path: string;
|