@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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/web/app.js +875 -109
- package/web/index.html +45 -8
- package/web/style.css +414 -72
- package/web-src/server/git.ts +90 -29
- package/web-src/server/preview.ts +301 -49
- package/web-src/server/range.ts +228 -0
- package/web-src/server/runtime.d.ts +10 -0
- package/web-src/server/search.ts +10 -7
- package/web-src/types.ts +16 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
375
|
+
return null;
|
|
319
376
|
}
|
|
320
|
-
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return
|
|
377
|
+
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
|
|
321
378
|
try {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
418
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
590
|
-
if (
|
|
591
|
-
|
|
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
|
-
|
|
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)
|
|
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);
|