@youtyan/code-viewer 0.1.7 → 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.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { existsSync, readFileSync, realpathSync, statSync } 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';
8
+ import { startDevAssetReload } from './dev-assets';
8
9
  import * as git from './git';
9
10
  import { isSameWorktreeRange } from './range';
10
11
 
@@ -13,15 +14,27 @@ const WEB_ROOT = join(ROOT, 'web');
13
14
  const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version as string;
14
15
  const DEFAULT_ARGS = ['HEAD'];
15
16
  const PREVIEW_HUNKS_DEFAULT = 3;
17
+ const PREVIEW_LINES_DEFAULT = 1200;
16
18
  const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
17
19
  const SIZE_SMALL = 2000;
18
20
  const SIZE_MEDIUM = 8000;
19
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
+ ]);
20
31
 
21
32
  let generation = 1;
22
33
  let cwd = git.repoRoot(process.cwd()) || process.cwd();
23
34
  let cliArgs = DEFAULT_ARGS;
24
35
  let listenPort = 0;
36
+ let allowUpload = false;
37
+ let uploadAllowedByCli = false;
25
38
 
26
39
  const enc = new TextEncoder();
27
40
  const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
@@ -65,11 +78,15 @@ Examples:
65
78
  listenPort = parsed;
66
79
  } else if (arg === '--open') {
67
80
  setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
81
+ } else if (arg === '--allow-upload') {
82
+ allowUpload = true;
83
+ uploadAllowedByCli = true;
68
84
  } else {
69
85
  rest.push(arg);
70
86
  }
71
87
  }
72
88
  if (rest.length) cliArgs = rest;
89
+ if (!uploadAllowedByCli) allowUpload = loadProjectConfigUploadEnabled();
73
90
  }
74
91
 
75
92
  function json(data: unknown, init: ResponseInit = {}) {
@@ -98,11 +115,24 @@ function requestAllowed(req: Request) {
98
115
  return okHost && okOrigin;
99
116
  }
100
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
+
101
129
  function staticFile(pathname: string): Response | null {
102
130
  const map: Record<string, [string, string]> = {
103
131
  '/favicon.png': ['favicon.png', 'image/png'],
104
132
  '/style.css': ['style.css', 'text/css; charset=utf-8'],
105
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'],
106
136
  '/vendor/diff2html/diff2html.min.css': ['vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
107
137
  '/vendor/diff2html/diff2html-ui.min.js': ['vendor/diff2html/diff2html-ui.min.js', 'application/javascript; charset=utf-8'],
108
138
  '/vendor/highlight.js/highlight.min.js': ['vendor/highlight.js/highlight.min.js', 'application/javascript; charset=utf-8'],
@@ -171,7 +201,7 @@ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }
171
201
  const q = { path: file.path, old_path: file.old_path, status: file.status, from: range.from, to: range.to, ...extraQs };
172
202
  if (file.untracked) Object.assign(q, { untracked: '1' });
173
203
  const previewQ = { ...q, mode: 'preview', max_hunks: PREVIEW_HUNKS_DEFAULT };
174
- const previewUrl = sizeClass === 'large' || sizeClass === 'huge' ? `/file_diff${buildQuery(previewQ)}` : null;
204
+ const previewUrl = sizeClass !== 'small' ? `/file_diff${buildQuery(previewQ)}` : null;
175
205
  return {
176
206
  order: file.order,
177
207
  key: `${file.status || 'M'}\0${file.old_path || ''}\0${file.path}`,
@@ -184,7 +214,7 @@ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }
184
214
  binary: file.binary || false,
185
215
  media_kind: guessMediaKind(file.path),
186
216
  size_class: sizeClass,
187
- force_layout: sizeClass === 'large' || sizeClass === 'huge' ? 'line-by-line' : undefined,
217
+ force_layout: sizeClass === 'huge' ? 'line-by-line' : undefined,
188
218
  highlight: sizeClass === 'small',
189
219
  load_url: `/file_diff${buildQuery(q)}`,
190
220
  preview_url: previewUrl,
@@ -263,8 +293,31 @@ function safeRepoPath(path: string) {
263
293
  return path === '' || safePath(path);
264
294
  }
265
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
+
266
319
  function isGitInternalPath(path: string): boolean {
267
- return path === '.git' || path.startsWith('.git/');
320
+ return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
268
321
  }
269
322
 
270
323
  function safeWorktreePath(path: string): string | null {
@@ -272,13 +325,38 @@ function safeWorktreePath(path: string): string | null {
272
325
  if (isGitInternalPath(path)) return null;
273
326
  const full = join(cwd, path);
274
327
  if (!existsSync(full)) return null;
275
- const realCwd = realpathSync(cwd);
276
- 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
+ }
277
336
  const rel = relative(realCwd, realFull);
278
337
  if (rel === '' || rel.startsWith('..') || rel.startsWith('/') || rel.startsWith('\\')) return null;
338
+ if (isGitInternalPath(rel)) return null;
279
339
  return realFull;
280
340
  }
281
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
+
282
360
  function readReadme(target: string, dirPath: string): RepoTreeResponse['readme'] {
283
361
  const candidates = ['README.md', 'readme.md', 'README.markdown', 'README'];
284
362
  for (const name of candidates) {
@@ -313,6 +391,7 @@ function handleTree(url: URL) {
313
391
  branch: git.currentBranch(cwd) || undefined,
314
392
  entries,
315
393
  readme: readReadme(target, path),
394
+ upload_enabled: allowUpload && (target === 'worktree' || target === ''),
316
395
  } satisfies RepoTreeResponse);
317
396
  }
318
397
 
@@ -361,7 +440,11 @@ function handleFileDiff(url: URL) {
361
440
  }
362
441
  const mode = url.searchParams.get('mode') || 'full';
363
442
  const truncated = mode === 'preview'
364
- ? 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
+ )
365
448
  : git.truncateToNHunks(diffText, 1e9);
366
449
  const body: FileDiffResponse & { line_count?: number; error?: string } = {
367
450
  path,
@@ -372,7 +455,7 @@ function handleFileDiff(url: URL) {
372
455
  hunk_count: truncated.totalHunks,
373
456
  rendered_hunk_count: truncated.renderedHunks,
374
457
  line_count: truncated.lineCount,
375
- truncated: mode === 'preview' && truncated.totalHunks > truncated.renderedHunks,
458
+ truncated: mode === 'preview' && (truncated.totalHunks > truncated.renderedHunks || truncated.lineTruncated),
376
459
  binary: diffText.includes('Binary files'),
377
460
  error: errText,
378
461
  generation,
@@ -406,26 +489,225 @@ function handleFileRange(url: URL) {
406
489
  return json(body);
407
490
  }
408
491
 
409
- function handleRawFile(url: URL) {
492
+ function handleRawFile(req: Request, url: URL) {
410
493
  const path = url.searchParams.get('path') || '';
411
494
  if (!safePath(path)) return text('forbidden', 403);
412
495
  const ref = url.searchParams.get('ref') || 'worktree';
413
496
  let body: BodyInit;
414
497
  if (ref !== 'worktree' && ref !== '') {
415
498
  if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
416
- 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);
417
503
  if (res.code !== 0) return text('not in ref', 404);
418
- 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) });
419
506
  } else {
420
507
  const full = safeWorktreePath(path);
421
508
  if (!full) return text('not found', 404);
422
- 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;
423
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 {
424
534
  const mime: Record<string, string> = {
425
535
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
426
536
  '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
537
+ '.pdf': 'application/pdf',
538
+ };
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',
427
544
  };
428
- return new Response(body, { headers: { 'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' } });
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 });
429
711
  }
430
712
 
431
713
  function sendSse(event: string, data = 'tick') {
@@ -447,7 +729,7 @@ parseCli();
447
729
  const server = Bun.serve({
448
730
  hostname: '127.0.0.1',
449
731
  port: listenPort,
450
- fetch(req) {
732
+ async fetch(req) {
451
733
  if (!requestAllowed(req)) return text('forbidden', 403);
452
734
  const url = new URL(req.url);
453
735
  const staticResponse = staticFile(url.pathname);
@@ -456,13 +738,12 @@ const server = Bun.serve({
456
738
  if (url.pathname === '/_tree') return handleTree(url);
457
739
  if (url.pathname === '/file_diff') return handleFileDiff(url);
458
740
  if (url.pathname === '/file_range') return handleFileRange(url);
459
- 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);
460
744
  if (url.pathname === '/_refs') return json(git.refs(cwd));
461
- if (url.pathname === '/_asset_version') {
462
- const version = Math.max(...WATCHED_ASSET_FILES.map((name) => statSync(join(WEB_ROOT, name)).mtimeMs));
463
- return json({ version });
464
- }
465
745
  if (url.pathname === '/refresh' && req.method === 'POST') {
746
+ if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
466
747
  generation++;
467
748
  fileCache.clear();
468
749
  metaCache.clear();
@@ -502,5 +783,13 @@ const server = Bun.serve({
502
783
  },
503
784
  });
504
785
 
786
+ startDevAssetReload({
787
+ enabled: process.env.CODE_VIEWER_DEV === '1',
788
+ webRoot: WEB_ROOT,
789
+ watchedFiles: WATCHED_ASSET_FILES,
790
+ watch,
791
+ sendReload: () => sendSse('reload'),
792
+ });
793
+
505
794
  console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
506
795
  console.log(`git-diff-preview serving ${cwd}`);
@@ -17,6 +17,7 @@ declare const Bun: {
17
17
 
18
18
  declare const process: {
19
19
  argv: string[];
20
+ env: Record<string, string | undefined>;
20
21
  cwd(): string;
21
22
  platform: 'darwin' | 'win32' | string;
22
23
  on(event: 'SIGINT' | 'SIGTERM', listener: () => void): void;
@@ -34,6 +35,11 @@ declare module 'node:fs' {
34
35
  export function readFileSync(path: string, encoding: BufferEncoding): string;
35
36
  export function realpathSync(path: string): string;
36
37
  export function statSync(path: string): { mtimeMs: number };
38
+ export function watch(
39
+ path: string,
40
+ options: { persistent?: boolean },
41
+ listener: (eventType: string, filename: string | Buffer | null) => void,
42
+ ): unknown;
37
43
  }
38
44
 
39
45
  declare module 'node:path' {
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;
@@ -86,10 +88,6 @@ export type RefResponse = {
86
88
  current?: string;
87
89
  };
88
90
 
89
- export type AssetVersionResponse = {
90
- version?: number;
91
- };
92
-
93
91
  declare global {
94
92
  interface Window {
95
93
  Diff2HtmlUI: any;