@youtyan/code-viewer 0.1.14 → 0.1.16

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.
@@ -3,11 +3,22 @@
3
3
  import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
4
4
  import { basename, dirname, extname, join, normalize, relative } from 'node:path';
5
5
  import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
6
- import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } from '../types';
6
+ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse, SettingsResponse } from '../types';
7
7
  import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
8
8
  import { startDevAssetReload } from './dev-assets';
9
9
  import * as git from './git';
10
- import { isSameWorktreeRange } from './range';
10
+ import {
11
+ buildLineOffsetIndexFromStream,
12
+ collectByteRangeFromStream,
13
+ collectBytesWithLineOffsetIndexFromStream,
14
+ collectLineRangeFromIndexedText,
15
+ collectLineRangeFromStream,
16
+ isSameWorktreeRange,
17
+ lineByteRangeForIndex,
18
+ parseHttpByteRange,
19
+ type LineOffsetIndex,
20
+ type LineRangeResult,
21
+ } from './range';
11
22
  import {
12
23
  GREP_MAX_FILE_BYTES,
13
24
  buildFileSearchList,
@@ -29,6 +40,9 @@ const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
29
40
  const SIZE_SMALL = 2000;
30
41
  const SIZE_MEDIUM = 8000;
31
42
  const SIZE_LARGE = 20000;
43
+ const LINE_INDEX_MIN_START = 10000;
44
+ const LINE_INDEX_MAX_FILE_BYTES = 256 * 1024 * 1024;
45
+ const BLOB_LINE_CACHE_MAX_BYTES = 128 * 1024 * 1024;
32
46
  const MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
33
47
  const MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
34
48
  const MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
@@ -45,6 +59,8 @@ let cliArgs = DEFAULT_ARGS;
45
59
  let listenPort = 0;
46
60
  let allowUpload = false;
47
61
  let uploadAllowedByCli = false;
62
+ let scopeOmitDirNames = git.DEFAULT_WORKTREE_OMIT_DIR_NAMES;
63
+ let scopeOmitDirCliOverride: string[] | null = null;
48
64
  let rgAvailableCache: boolean | null = null;
49
65
 
50
66
  const enc = new TextEncoder();
@@ -52,6 +68,10 @@ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
52
68
  const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
53
69
  const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
54
70
  const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
71
+ const lineIndexCache = new Map<string, { signature: string; index: LineOffsetIndex }>();
72
+ const blobLineIndexCache = new Map<string, LineOffsetIndex>();
73
+ const blobBytesCache = new Map<string, Uint8Array>();
74
+ let blobLineCacheBytes = 0;
55
75
 
56
76
  function parseCli() {
57
77
  const rest: string[] = [];
@@ -79,7 +99,12 @@ Examples:
79
99
  console.error('--cwd requires a value');
80
100
  process.exit(1);
81
101
  }
82
- cwd = git.repoRoot(next) || cwd;
102
+ try {
103
+ cwd = git.repoRoot(next) || realpathSync(next);
104
+ } catch {
105
+ console.error('--cwd must point to an existing directory');
106
+ process.exit(1);
107
+ }
83
108
  } else if (arg === '--port') {
84
109
  const next = process.argv[++i];
85
110
  const parsed = Number(next);
@@ -93,12 +118,25 @@ Examples:
93
118
  } else if (arg === '--allow-upload') {
94
119
  allowUpload = true;
95
120
  uploadAllowedByCli = true;
121
+ } else if (arg === '--scope-omit-dir') {
122
+ const next = process.argv[++i];
123
+ if (!next) {
124
+ console.error('--scope-omit-dir requires a directory name');
125
+ process.exit(1);
126
+ }
127
+ scopeOmitDirCliOverride = normalizeScopeOmitDirNames([...(scopeOmitDirCliOverride || []), next]);
96
128
  } else {
97
129
  rest.push(arg);
98
130
  }
99
131
  }
100
132
  if (rest.length) cliArgs = rest;
101
133
  if (!uploadAllowedByCli) allowUpload = loadProjectConfigUploadEnabled();
134
+ const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
135
+ if (scopeOmitDirCliOverride) {
136
+ scopeOmitDirNames = scopeOmitDirCliOverride;
137
+ } else if (configScopeOmitDirs) {
138
+ scopeOmitDirNames = configScopeOmitDirs;
139
+ }
102
140
  }
103
141
 
104
142
  function json(data: unknown, init: ResponseInit = {}) {
@@ -306,29 +344,68 @@ function safeRepoPath(path: string) {
306
344
  return path === '' || safePath(path);
307
345
  }
308
346
 
309
- function loadProjectConfigUploadEnabled(): boolean {
347
+ function normalizeScopeOmitDirNames(names: unknown): string[] {
348
+ if (!Array.isArray(names)) return [];
349
+ return [...new Set(names
350
+ .filter((name): name is string => typeof name === 'string')
351
+ .map(name => name.trim())
352
+ .filter(name => name && name.length <= 64 && !name.includes('/') && !name.includes('\\') && !name.includes('\0') && name !== '.' && name !== '..' && name !== '.git'))]
353
+ .sort((a, b) => a.localeCompare(b));
354
+ }
355
+
356
+ function parseScopeOmitDirNamesQuery(value: string): string[] | null {
357
+ const names = value ? value.split(',') : [];
358
+ if (names.length > 100) return null;
359
+ for (const raw of names) {
360
+ const name = raw.trim();
361
+ if (!name || name.length > 64 || name.includes('/') || name.includes('\\') || name.includes('\0') || name === '.' || name === '..' || name === '.git') return null;
362
+ }
363
+ return normalizeScopeOmitDirNames(names);
364
+ }
365
+
366
+ function loadProjectConfig(): Record<string, unknown> | null {
310
367
  const full = join(cwd, '.code-viewer.json');
311
- if (!existsSync(full)) return false;
368
+ if (!existsSync(full)) return null;
312
369
  let realCwd: string;
313
370
  let realConfig: string;
314
371
  try {
315
372
  realCwd = realpathSync(cwd);
316
373
  realConfig = realpathSync(full);
317
374
  } catch {
318
- return false;
375
+ return null;
319
376
  }
320
- if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return false;
377
+ if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
321
378
  try {
322
- const config = JSON.parse(readFileSync(realConfig, 'utf8')) as {
323
- upload?: { enabled?: unknown };
324
- };
325
- if (!config.upload) return false;
326
- return config.upload.enabled === true;
379
+ const parsed = JSON.parse(readFileSync(realConfig, 'utf8'));
380
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'version' in parsed && (parsed as { version?: unknown }).version !== 1) return null;
381
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
382
+ ? parsed as Record<string, unknown>
383
+ : null;
327
384
  } catch {
328
- return false;
385
+ return null;
329
386
  }
330
387
  }
331
388
 
389
+ function loadProjectConfigUploadEnabled(): boolean {
390
+ const config = loadProjectConfig() as { upload?: { enabled?: unknown } } | null;
391
+ return config?.upload?.enabled === true;
392
+ }
393
+
394
+ function loadProjectConfigScopeOmitDirs(): string[] | null {
395
+ const config = loadProjectConfig() as { scope?: { omitDirs?: unknown } } | null;
396
+ if (!config?.scope || !Array.isArray(config.scope.omitDirs)) return null;
397
+ return normalizeScopeOmitDirNames(config.scope.omitDirs);
398
+ }
399
+
400
+ function scopeOmitDirNamesFromQuery(url: URL): string[] {
401
+ if (!url.searchParams.has('omit_dirs')) return scopeOmitDirNames;
402
+ return parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '') || scopeOmitDirNames;
403
+ }
404
+
405
+ function invalidScopeOmitDirNamesQuery(url: URL): boolean {
406
+ return url.searchParams.has('omit_dirs') && !parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '');
407
+ }
408
+
332
409
  function isGitInternalPath(path: string): boolean {
333
410
  return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
334
411
  }
@@ -396,7 +473,8 @@ function handleTree(url: URL) {
396
473
  if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
397
474
  if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
398
475
  const recursive = url.searchParams.get('recursive') === '1';
399
- const entries = git.listTree(target, path, cwd, { recursive }).entries;
476
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
477
+ const entries = git.listTree(target, path, cwd, { recursive, omitDirNames: scopeOmitDirNamesFromQuery(url) }).entries;
400
478
  return json({
401
479
  ref: target,
402
480
  path,
@@ -408,20 +486,34 @@ function handleTree(url: URL) {
408
486
  } satisfies RepoTreeResponse);
409
487
  }
410
488
 
489
+ function handleSettings() {
490
+ return json({
491
+ project: basename(cwd),
492
+ scope: {
493
+ omit_dirs_effective: scopeOmitDirNames,
494
+ omit_dirs_built_in: git.DEFAULT_WORKTREE_OMIT_DIR_NAMES,
495
+ max_entries: git.WORKTREE_RECURSIVE_ENTRY_LIMIT,
496
+ },
497
+ } satisfies SettingsResponse);
498
+ }
499
+
411
500
  function handleFiles(url: URL) {
412
501
  const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
413
502
  if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
414
- const key = target || 'worktree';
503
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
504
+ const omitDirNames = scopeOmitDirNamesFromQuery(url);
505
+ const key = `${target || 'worktree'}\0${omitDirNames.join('\0')}`;
415
506
  const cached = fileListCache.get(key);
416
507
  if (cached && cached.generation === generation) return json(cached.body);
417
- const entries = git.listTree(key, '', cwd, { recursive: true }).entries;
418
- const body = buildFileSearchList(key, generation, entries);
508
+ const ref = target || 'worktree';
509
+ const entries = git.listTree(ref, '', cwd, { recursive: true, omitDirNames }).entries;
510
+ const body = buildFileSearchList(ref, generation, entries);
419
511
  fileListCache.set(key, { generation, body });
420
512
  return json(body);
421
513
  }
422
514
 
423
- function parseGrepPaths(url: URL): string[] {
424
- return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path));
515
+ function parseGrepPaths(url: URL, omitDirNames: string[]): string[] {
516
+ return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
425
517
  }
426
518
 
427
519
  function rgAvailable(): boolean {
@@ -431,12 +523,12 @@ function rgAvailable(): boolean {
431
523
  return rgAvailableCache;
432
524
  }
433
525
 
434
- function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
526
+ function grepWorktreeFallback(query: string, max: number, paths: string[], omitDirNames: string[]): GrepMatch[] {
435
527
  const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
436
528
  const matches: GrepMatch[] = [];
437
529
  for (const path of candidates) {
438
530
  if (matches.length >= max) break;
439
- if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
531
+ if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames)) continue;
440
532
  const full = safeWorktreePath(path);
441
533
  if (!full) continue;
442
534
  let stat;
@@ -458,23 +550,23 @@ function grepWorktreeFallback(query: string, max: number, paths: string[]): Grep
458
550
  return matches;
459
551
  }
460
552
 
461
- function grepWorktree(query: string, max: number, paths: string[], regex: boolean): GrepResponse {
553
+ function grepWorktree(query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
462
554
  if (rgAvailable()) {
463
- const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
464
- const args = buildRgArgs(query, max, safePaths, regex);
555
+ const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
556
+ const args = buildRgArgs(query, max, safePaths, regex, omitDirNames);
465
557
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
466
558
  const stdout = new TextDecoder().decode(proc.stdout);
467
- const matches = parseRgOutput(stdout, max)
468
- .filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !!safeWorktreePath(match.path));
559
+ const matches = parseRgOutput(stdout, max, omitDirNames)
560
+ .filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
469
561
  return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
470
562
  }
471
563
  if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
472
- const matches = grepWorktreeFallback(query, max, paths);
564
+ const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
473
565
  return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
474
566
  }
475
567
 
476
- function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
477
- const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path));
568
+ function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
569
+ const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
478
570
  const args = [
479
571
  'git', '-c', 'core.quotepath=false', 'grep',
480
572
  '-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
@@ -484,7 +576,7 @@ function grepTreeRef(ref: string, query: string, max: number, paths: string[], r
484
576
  ];
485
577
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
486
578
  const stdout = new TextDecoder().decode(proc.stdout);
487
- const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
579
+ const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
488
580
  return { ref, engine: 'git', truncated: matches.length >= max, matches };
489
581
  }
490
582
 
@@ -492,12 +584,14 @@ function handleGrep(url: URL) {
492
584
  const query = url.searchParams.get('q') || '';
493
585
  const ref = url.searchParams.get('ref') || 'worktree';
494
586
  const max = normalizeGrepMax(url.searchParams.get('max'));
495
- const paths = parseGrepPaths(url);
587
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
588
+ const omitDirNames = scopeOmitDirNamesFromQuery(url);
589
+ const paths = parseGrepPaths(url, omitDirNames);
496
590
  const regex = url.searchParams.get('regex') === '1';
497
591
  if (!query.trim()) return json({ ref, engine: ref === 'worktree' ? 'fallback' : 'git', truncated: false, matches: [] } satisfies GrepResponse);
498
- if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex));
592
+ if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex, omitDirNames));
499
593
  if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
500
- return json(grepTreeRef(ref, query, max, paths, regex));
594
+ return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
501
595
  }
502
596
 
503
597
  function handleFileDiff(url: URL) {
@@ -571,7 +665,138 @@ function handleFileDiff(url: URL) {
571
665
  return json(body);
572
666
  }
573
667
 
574
- function handleFileRange(url: URL) {
668
+ function worktreeLineIndexSignature(full: string): string | null {
669
+ try {
670
+ const stat = statSync(full) as unknown as { size: number; mtimeMs: number; ctimeMs: number; ino?: number };
671
+ return `size:${stat.size}|mtime:${stat.mtimeMs}|ctime:${stat.ctimeMs}|ino:${stat.ino || 0}`;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
677
+ async function getWorktreeLineIndex(full: string): Promise<LineOffsetIndex | null> {
678
+ const signature = worktreeLineIndexSignature(full);
679
+ if (!signature) return null;
680
+ const cached = lineIndexCache.get(full);
681
+ if (cached?.signature === signature) {
682
+ lineIndexCache.delete(full);
683
+ lineIndexCache.set(full, cached);
684
+ return cached.index;
685
+ }
686
+ const stat = statSync(full) as unknown as { size: number };
687
+ if (stat.size > LINE_INDEX_MAX_FILE_BYTES) return null;
688
+ const index = await buildLineOffsetIndexFromStream(Bun.file(full).stream(), stat.size);
689
+ lineIndexCache.delete(full);
690
+ lineIndexCache.set(full, { signature, index });
691
+ while (lineIndexCache.size > 32) {
692
+ const oldest = lineIndexCache.keys().next().value;
693
+ if (oldest === undefined) break;
694
+ lineIndexCache.delete(oldest);
695
+ }
696
+ return index;
697
+ }
698
+
699
+ function cachedBlobLineRange(cacheKey: string, start: number, end: number): LineRangeResult | null {
700
+ const bytes = blobBytesCache.get(cacheKey);
701
+ const index = blobLineIndexCache.get(cacheKey);
702
+ if (!bytes || !index) return null;
703
+ blobBytesCache.delete(cacheKey);
704
+ blobBytesCache.set(cacheKey, bytes);
705
+ blobLineIndexCache.delete(cacheKey);
706
+ blobLineIndexCache.set(cacheKey, index);
707
+ const range = lineByteRangeForIndex(index, start, end);
708
+ const textValue = range
709
+ ? new TextDecoder().decode(bytes.subarray(range.start, range.endExclusive))
710
+ : '';
711
+ return collectLineRangeFromIndexedText(textValue, index, start, end);
712
+ }
713
+
714
+ function setBlobLineCache(cacheKey: string, bytes: Uint8Array, index: LineOffsetIndex): void {
715
+ setBlobLineIndexCache(cacheKey, index);
716
+ const existing = blobBytesCache.get(cacheKey);
717
+ if (existing) blobLineCacheBytes -= existing.byteLength;
718
+ blobBytesCache.delete(cacheKey);
719
+ if (bytes.byteLength > BLOB_LINE_CACHE_MAX_BYTES) return;
720
+ blobBytesCache.set(cacheKey, bytes);
721
+ blobLineCacheBytes += bytes.byteLength;
722
+ while (blobBytesCache.size > 16 || blobLineCacheBytes > BLOB_LINE_CACHE_MAX_BYTES) {
723
+ const oldest = blobBytesCache.keys().next().value;
724
+ if (oldest === undefined) break;
725
+ const evicted = blobBytesCache.get(oldest);
726
+ if (evicted) blobLineCacheBytes -= evicted.byteLength;
727
+ blobBytesCache.delete(oldest);
728
+ }
729
+ }
730
+
731
+ function setBlobLineIndexCache(cacheKey: string, index: LineOffsetIndex): void {
732
+ blobLineIndexCache.delete(cacheKey);
733
+ blobLineIndexCache.set(cacheKey, index);
734
+ while (blobLineIndexCache.size > 128) {
735
+ const oldest = blobLineIndexCache.keys().next().value;
736
+ if (oldest === undefined) break;
737
+ blobLineIndexCache.delete(oldest);
738
+ }
739
+ }
740
+
741
+ async function collectGitBlobLineRangeWithIndex(cacheKey: string, oid: string, index: LineOffsetIndex, start: number, end: number): Promise<LineRangeResult | null> {
742
+ blobLineIndexCache.delete(cacheKey);
743
+ blobLineIndexCache.set(cacheKey, index);
744
+ const range = lineByteRangeForIndex(index, start, end);
745
+ if (!range) return collectLineRangeFromIndexedText('', index, start, end);
746
+ const shown = git.catFileBlobStream(oid, cwd);
747
+ const bytes = await collectByteRangeFromStream(shown.stream, range.start, range.endExclusive);
748
+ await shown.exited;
749
+ if (bytes.byteLength !== range.endExclusive - range.start) return null;
750
+ const textValue = new TextDecoder().decode(bytes);
751
+ return collectLineRangeFromIndexedText(textValue, index, start, end);
752
+ }
753
+
754
+ async function readGitBlobBytesWithIndex(oid: string, sizeHint: number): Promise<{ bytes: Uint8Array; index: LineOffsetIndex } | null> {
755
+ const shown = git.catFileBlobStream(oid, cwd);
756
+ const result = await collectBytesWithLineOffsetIndexFromStream(shown.stream, sizeHint);
757
+ const code = await shown.exited;
758
+ if (code !== 0) return null;
759
+ return result;
760
+ }
761
+
762
+ async function collectGitBlobLineRangeFromStream(oid: string, start: number, end: number): Promise<LineRangeResult | null> {
763
+ const shown = git.catFileBlobStream(oid, cwd);
764
+ const result = await collectLineRangeFromStream(shown.stream, start, end);
765
+ const code = await shown.exited;
766
+ if (code !== 0 && result.complete) return null;
767
+ return result;
768
+ }
769
+
770
+ async function collectIndexedGitBlobLineRange(path: string, oid: string, size: number, start: number, end: number): Promise<LineRangeResult | null> {
771
+ const cacheKey = `${oid}\0${path}`;
772
+ const cached = cachedBlobLineRange(cacheKey, start, end);
773
+ if (cached) return cached;
774
+ const cachedIndex = blobLineIndexCache.get(cacheKey);
775
+ if (cachedIndex) return collectGitBlobLineRangeWithIndex(cacheKey, oid, cachedIndex, start, end);
776
+ if (start < LINE_INDEX_MIN_START) {
777
+ return collectGitBlobLineRangeFromStream(oid, start, end);
778
+ }
779
+ if (size > LINE_INDEX_MAX_FILE_BYTES) return collectGitBlobLineRangeFromStream(oid, start, end);
780
+ const indexedBlob = await readGitBlobBytesWithIndex(oid, size);
781
+ if (!indexedBlob) return null;
782
+ setBlobLineCache(cacheKey, indexedBlob.bytes, indexedBlob.index);
783
+ return cachedBlobLineRange(cacheKey, start, end) || collectGitBlobLineRangeWithIndex(cacheKey, oid, indexedBlob.index, start, end);
784
+ }
785
+
786
+ async function collectIndexedWorktreeLineRange(full: string, start: number, end: number): Promise<LineRangeResult> {
787
+ if (start < LINE_INDEX_MIN_START && !lineIndexCache.has(full)) {
788
+ return collectLineRangeFromStream(Bun.file(full).stream(), start, end);
789
+ }
790
+ const index = await getWorktreeLineIndex(full);
791
+ if (!index) return collectLineRangeFromStream(Bun.file(full).stream(), start, end);
792
+ const range = lineByteRangeForIndex(index, start, end);
793
+ const textValue = range
794
+ ? await Bun.file(full).slice(range.start, range.endExclusive).text()
795
+ : '';
796
+ return collectLineRangeFromIndexedText(textValue, index, start, end);
797
+ }
798
+
799
+ async function handleFileRange(url: URL) {
575
800
  const path = url.searchParams.get('path') || '';
576
801
  if (!safePath(path)) return text('invalid path', 400);
577
802
  let start = Number(url.searchParams.get('start') || '1') || 1;
@@ -579,22 +804,23 @@ function handleFileRange(url: URL) {
579
804
  if (start < 1) start = 1;
580
805
  if (end < start) end = start;
581
806
  const ref = url.searchParams.get('ref') || 'worktree';
582
- let content = '';
583
807
  if (ref === 'worktree' || ref === '') {
584
808
  const full = safeWorktreePath(path);
585
809
  if (!full) return text('no file', 404);
586
- content = readFileSync(full, 'utf8');
810
+ const result = await collectIndexedWorktreeLineRange(full, start, end);
811
+ const body: FileRangeResponse = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
812
+ return json(body);
587
813
  } else {
588
814
  if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
589
- const res = git.show(ref, path, cwd);
590
- if (res.code !== 0) return text('not in ref', 404);
591
- content = res.stdout;
815
+ const oid = git.objectId(ref, path, cwd);
816
+ if (oid.code !== 0 || !oid.oid) return text('not in ref', 404);
817
+ const size = git.objectByteSize(oid.oid, cwd);
818
+ if (size.code !== 0) return text('cannot read ref', 500);
819
+ const result = await collectIndexedGitBlobLineRange(path, oid.oid, size.size, start, end);
820
+ if (!result) return text('cannot read ref', 500);
821
+ const body: FileRangeResponse = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
822
+ return json(body);
592
823
  }
593
- const lines: string[] = [];
594
- const all = `${content}\n`.split('\n');
595
- for (let i = start; i <= end && i <= all.length; i++) lines.push(all[i - 1]);
596
- const body: FileRangeResponse = { path, ref, start, end, lines, total: Math.min(all.length, end + 1), generation };
597
- return json(body);
598
824
  }
599
825
 
600
826
  function handleRawFile(req: Request, url: URL) {
@@ -616,10 +842,29 @@ function handleRawFile(req: Request, url: URL) {
616
842
  if (!full) return text('not found', 404);
617
843
  const size = rawFileSize(path, ref);
618
844
  if (size == null) return text('not found', 404);
845
+ const rangeResult = req.headers.get('range') ? parseHttpByteRange(req.headers.get('range'), size) : null;
846
+ if (rangeResult?.kind === 'unsatisfiable') {
847
+ return new Response(null, {
848
+ status: 416,
849
+ headers: { ...rawFileHeaders(path, size), 'Content-Range': `bytes */${size}`, 'Content-Length': '0' },
850
+ });
851
+ }
852
+ if (rangeResult?.kind === 'range') {
853
+ const range = rangeResult.range;
854
+ if (req.method === 'HEAD') {
855
+ return new Response(null, {
856
+ status: 206,
857
+ headers: rawFileHeaders(path, size, range),
858
+ });
859
+ }
860
+ const file = Bun.file(full).slice(range.start, range.end + 1);
861
+ return new Response(file, {
862
+ status: 206,
863
+ headers: rawFileHeaders(path, size, range),
864
+ });
865
+ }
619
866
  if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
620
- const bytes = new Uint8Array(readFileSync(full));
621
- body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
622
- return new Response(body, { headers: rawFileHeaders(path, size) });
867
+ return new Response(Bun.file(full).stream(), { headers: rawFileHeaders(path, size) });
623
868
  }
624
869
  }
625
870
 
@@ -638,7 +883,7 @@ function rawFileSize(path: string, ref: string): number | null {
638
883
  }
639
884
  }
640
885
 
641
- function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
886
+ function rawFileHeaders(path: string, size: number | null = null, range?: { start: number; end: number }): HeadersInit {
642
887
  const mime: Record<string, string> = {
643
888
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
644
889
  '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
@@ -651,8 +896,14 @@ function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
651
896
  'Cache-Control': 'no-store',
652
897
  'X-Content-Type-Options': 'nosniff',
653
898
  'Content-Security-Policy': 'sandbox',
899
+ 'Accept-Ranges': 'bytes',
654
900
  };
655
- if (size != null) headers['Content-Length'] = String(size);
901
+ if (range && size != null) {
902
+ headers['Content-Length'] = String(range.end - range.start + 1);
903
+ headers['Content-Range'] = `bytes ${range.start}-${range.end}/${size}`;
904
+ } else if (size != null) {
905
+ headers['Content-Length'] = String(size);
906
+ }
656
907
  return headers;
657
908
  }
658
909
 
@@ -845,6 +1096,7 @@ const server = Bun.serve({
845
1096
  const staticResponse = staticFile(url.pathname);
846
1097
  if (staticResponse) return staticResponse;
847
1098
  if (url.pathname === '/diff.json') return handleDiffJson(url);
1099
+ if (url.pathname === '/_settings') return handleSettings();
848
1100
  if (url.pathname === '/_tree') return handleTree(url);
849
1101
  if (url.pathname === '/_files') return handleFiles(url);
850
1102
  if (url.pathname === '/_grep') return handleGrep(url);