@youtyan/code-viewer 0.1.8 → 0.1.10

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.
@@ -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: entry.isDirectory() ? 'tree' as const : 'blob' as const,
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
- if (!options.recursive) return { code: 0, entries: directEntries, stderr: '' };
225
- return { code: 0, entries: combineDirectAndRecursiveFiles(directEntries, worktreeRecursiveFiles(cwd, base)), stderr: '' };
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
- return { text: diffText, totalHunks: 0, renderedHunks: 0, lineCount: (diffText.match(/\n/g) || []).length };
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 renderedHunks = Math.min(n, hunks.length);
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 === 'large' || sizeClass === 'huge' ? `/file_diff${buildQuery(previewQ)}` : null;
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 === 'large' || sizeClass === 'huge' ? 'line-by-line' : undefined,
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 === '.git' || path.startsWith('.git/');
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
- const realCwd = realpathSync(cwd);
277
- const realFull = realpathSync(full);
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(diffText, Number(url.searchParams.get('max_hunks')) || PREVIEW_HUNKS_DEFAULT)
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 res = git.show(ref, path, cwd);
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
- body = new Uint8Array(readFileSync(full));
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
- return new Response(body, { headers: { 'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' } });
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;