@youtyan/code-viewer 0.1.11 → 0.1.13
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/package.json +1 -1
- package/web/app.js +1416 -56
- package/web/index.html +1 -0
- package/web/style.css +373 -0
- package/web-src/routes.ts +59 -8
- package/web-src/server/preview.ts +109 -2
- package/web-src/server/search.ts +101 -0
- package/web-src/types.ts +24 -0
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { closeSync, constants, existsSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
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, RepoTreeResponse } from '../types';
|
|
6
|
+
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } 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
10
|
import { isSameWorktreeRange } from './range';
|
|
11
|
+
import {
|
|
12
|
+
GREP_MAX_FILE_BYTES,
|
|
13
|
+
buildFileSearchList,
|
|
14
|
+
buildRgArgs,
|
|
15
|
+
fixedStringLineMatches,
|
|
16
|
+
isSkippableSearchPath,
|
|
17
|
+
normalizeGrepMax,
|
|
18
|
+
parseGitGrepOutput,
|
|
19
|
+
parseRgOutput,
|
|
20
|
+
} from './search';
|
|
11
21
|
|
|
12
22
|
const ROOT = normalize(join(import.meta.dir, '..', '..'));
|
|
13
23
|
const WEB_ROOT = join(ROOT, 'web');
|
|
@@ -35,11 +45,13 @@ let cliArgs = DEFAULT_ARGS;
|
|
|
35
45
|
let listenPort = 0;
|
|
36
46
|
let allowUpload = false;
|
|
37
47
|
let uploadAllowedByCli = false;
|
|
48
|
+
let rgAvailableCache: boolean | null = null;
|
|
38
49
|
|
|
39
50
|
const enc = new TextEncoder();
|
|
40
51
|
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
41
52
|
const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
|
|
42
53
|
const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
|
|
54
|
+
const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
|
|
43
55
|
|
|
44
56
|
function parseCli() {
|
|
45
57
|
const rest: string[] = [];
|
|
@@ -395,6 +407,98 @@ function handleTree(url: URL) {
|
|
|
395
407
|
} satisfies RepoTreeResponse);
|
|
396
408
|
}
|
|
397
409
|
|
|
410
|
+
function handleFiles(url: URL) {
|
|
411
|
+
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
412
|
+
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
413
|
+
const key = target || 'worktree';
|
|
414
|
+
const cached = fileListCache.get(key);
|
|
415
|
+
if (cached && cached.generation === generation) return json(cached.body);
|
|
416
|
+
const entries = git.listTree(key, '', cwd, { recursive: true }).entries;
|
|
417
|
+
const body = buildFileSearchList(key, generation, entries);
|
|
418
|
+
fileListCache.set(key, { generation, body });
|
|
419
|
+
return json(body);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseGrepPaths(url: URL): string[] {
|
|
423
|
+
return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function rgAvailable(): boolean {
|
|
427
|
+
if (rgAvailableCache !== null) return rgAvailableCache;
|
|
428
|
+
const proc = Bun.spawnSync(['rg', '--version'], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
429
|
+
rgAvailableCache = proc.exitCode === 0;
|
|
430
|
+
return rgAvailableCache;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
|
|
434
|
+
const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
|
|
435
|
+
const matches: GrepMatch[] = [];
|
|
436
|
+
for (const path of candidates) {
|
|
437
|
+
if (matches.length >= max) break;
|
|
438
|
+
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
|
|
439
|
+
const full = safeWorktreePath(path);
|
|
440
|
+
if (!full) continue;
|
|
441
|
+
let stat;
|
|
442
|
+
try {
|
|
443
|
+
stat = lstatSync(full);
|
|
444
|
+
} catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > GREP_MAX_FILE_BYTES) continue;
|
|
448
|
+
let data: Buffer;
|
|
449
|
+
try {
|
|
450
|
+
data = readFileSync(full);
|
|
451
|
+
} catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (data.subarray(0, 8192).includes(0)) continue;
|
|
455
|
+
matches.push(...fixedStringLineMatches(path, data.toString('utf8'), query, max - matches.length));
|
|
456
|
+
}
|
|
457
|
+
return matches;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function grepWorktree(query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
461
|
+
if (rgAvailable()) {
|
|
462
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
|
|
463
|
+
const args = buildRgArgs(query, max, safePaths, regex);
|
|
464
|
+
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
465
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
466
|
+
const matches = parseRgOutput(stdout, max)
|
|
467
|
+
.filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !!safeWorktreePath(match.path));
|
|
468
|
+
return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
|
|
469
|
+
}
|
|
470
|
+
if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
|
|
471
|
+
const matches = grepWorktreeFallback(query, max, paths);
|
|
472
|
+
return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
476
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path));
|
|
477
|
+
const args = [
|
|
478
|
+
'git', '-c', 'core.quotepath=false', 'grep',
|
|
479
|
+
'-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
|
|
480
|
+
'-e', query,
|
|
481
|
+
ref, '--',
|
|
482
|
+
...safePaths,
|
|
483
|
+
];
|
|
484
|
+
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
485
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
486
|
+
const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
|
|
487
|
+
return { ref, engine: 'git', truncated: matches.length >= max, matches };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function handleGrep(url: URL) {
|
|
491
|
+
const query = url.searchParams.get('q') || '';
|
|
492
|
+
const ref = url.searchParams.get('ref') || 'worktree';
|
|
493
|
+
const max = normalizeGrepMax(url.searchParams.get('max'));
|
|
494
|
+
const paths = parseGrepPaths(url);
|
|
495
|
+
const regex = url.searchParams.get('regex') === '1';
|
|
496
|
+
if (!query.trim()) return json({ ref, engine: ref === 'worktree' ? 'fallback' : 'git', truncated: false, matches: [] } satisfies GrepResponse);
|
|
497
|
+
if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex));
|
|
498
|
+
if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
|
|
499
|
+
return json(grepTreeRef(ref, query, max, paths, regex));
|
|
500
|
+
}
|
|
501
|
+
|
|
398
502
|
function handleFileDiff(url: URL) {
|
|
399
503
|
const path = url.searchParams.get('path') || '';
|
|
400
504
|
if (!safePath(path)) return text('invalid path', 400);
|
|
@@ -739,6 +843,8 @@ const server = Bun.serve({
|
|
|
739
843
|
if (staticResponse) return staticResponse;
|
|
740
844
|
if (url.pathname === '/diff.json') return handleDiffJson(url);
|
|
741
845
|
if (url.pathname === '/_tree') return handleTree(url);
|
|
846
|
+
if (url.pathname === '/_files') return handleFiles(url);
|
|
847
|
+
if (url.pathname === '/_grep') return handleGrep(url);
|
|
742
848
|
if (url.pathname === '/file_diff') return handleFileDiff(url);
|
|
743
849
|
if (url.pathname === '/file_range') return handleFileRange(url);
|
|
744
850
|
if (url.pathname === '/_file') return handleRawFile(req, url);
|
|
@@ -750,6 +856,7 @@ const server = Bun.serve({
|
|
|
750
856
|
generation++;
|
|
751
857
|
fileCache.clear();
|
|
752
858
|
metaCache.clear();
|
|
859
|
+
fileListCache.clear();
|
|
753
860
|
sendSse('update');
|
|
754
861
|
return json({ ok: true, generation });
|
|
755
862
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { FileSearchListResponse, GrepMatch } from '../types';
|
|
2
|
+
import type { GitTreeEntry } from './git';
|
|
3
|
+
|
|
4
|
+
export const GREP_DEFAULT_MAX = 200;
|
|
5
|
+
export const GREP_ABSOLUTE_MAX = 500;
|
|
6
|
+
export const GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
export const FILE_SEARCH_ABSOLUTE_MAX = 50000;
|
|
8
|
+
|
|
9
|
+
export function normalizeGrepMax(value: string | null): number {
|
|
10
|
+
const parsed = Number(value || '');
|
|
11
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return GREP_DEFAULT_MAX;
|
|
12
|
+
return Math.min(parsed, GREP_ABSOLUTE_MAX);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSkippableSearchPath(path: string): boolean {
|
|
16
|
+
return path.split(/[\\/]+/).some(part => {
|
|
17
|
+
const lower = part.toLowerCase();
|
|
18
|
+
return lower === '.git' || lower === 'node_modules';
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function fixedStringLineMatches(path: string, text: string, query: string, max: number): GrepMatch[] {
|
|
23
|
+
const needle = query.toLowerCase();
|
|
24
|
+
if (!needle) return [];
|
|
25
|
+
const matches: GrepMatch[] = [];
|
|
26
|
+
const lines = text.split('\n');
|
|
27
|
+
for (let i = 0; i < lines.length && matches.length < max; i++) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
const column = line.toLowerCase().indexOf(needle);
|
|
30
|
+
if (column < 0) continue;
|
|
31
|
+
matches.push({
|
|
32
|
+
path,
|
|
33
|
+
line: i + 1,
|
|
34
|
+
column: column + 1,
|
|
35
|
+
preview: line.slice(0, 500),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return matches;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildFileSearchList(ref: string, generation: number, entries: GitTreeEntry[]): FileSearchListResponse {
|
|
42
|
+
const files = entries
|
|
43
|
+
.filter((entry): entry is GitTreeEntry & { type: 'blob' | 'commit' } => entry.type === 'blob' || entry.type === 'commit')
|
|
44
|
+
.slice(0, FILE_SEARCH_ABSOLUTE_MAX)
|
|
45
|
+
.map(entry => ({ path: entry.path, type: entry.type }));
|
|
46
|
+
return {
|
|
47
|
+
ref,
|
|
48
|
+
generation,
|
|
49
|
+
files,
|
|
50
|
+
truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildRgArgs(query: string, max: number, paths: string[], regex = false): string[] {
|
|
55
|
+
const safePaths = paths.length ? paths : ['.'];
|
|
56
|
+
const args = [
|
|
57
|
+
'rg',
|
|
58
|
+
'--no-config',
|
|
59
|
+
'--line-number',
|
|
60
|
+
'--column',
|
|
61
|
+
'--no-heading',
|
|
62
|
+
'--color',
|
|
63
|
+
'never',
|
|
64
|
+
'--smart-case',
|
|
65
|
+
'--max-count',
|
|
66
|
+
String(max),
|
|
67
|
+
'--max-filesize',
|
|
68
|
+
'2M',
|
|
69
|
+
'-e',
|
|
70
|
+
query,
|
|
71
|
+
'--',
|
|
72
|
+
...safePaths,
|
|
73
|
+
];
|
|
74
|
+
if (!regex) args.splice(8, 0, '--fixed-strings');
|
|
75
|
+
return args;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
79
|
+
const matches: GrepMatch[] = [];
|
|
80
|
+
for (const line of stdout.split('\n')) {
|
|
81
|
+
if (!line || matches.length >= max) continue;
|
|
82
|
+
const parsed = /^(.*):(\d+):(\d+):(.*)$/.exec(line);
|
|
83
|
+
if (!parsed) continue;
|
|
84
|
+
const path = parsed[1];
|
|
85
|
+
const lineNo = Number(parsed[2]);
|
|
86
|
+
const column = Number(parsed[3]);
|
|
87
|
+
const preview = parsed[4];
|
|
88
|
+
if (!path || !lineNo || !column || isSkippableSearchPath(path)) continue;
|
|
89
|
+
matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
|
|
90
|
+
}
|
|
91
|
+
return matches;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseGitGrepOutput(stdout: string, ref: string, max: number): GrepMatch[] {
|
|
95
|
+
const prefix = ref + ':';
|
|
96
|
+
const normalized = stdout
|
|
97
|
+
.split('\n')
|
|
98
|
+
.map(line => line.startsWith(prefix) ? line.slice(prefix.length) : line)
|
|
99
|
+
.join('\n');
|
|
100
|
+
return parseRgOutput(normalized, max);
|
|
101
|
+
}
|
package/web-src/types.ts
CHANGED
|
@@ -53,6 +53,30 @@ export type RepoTreeResponse = {
|
|
|
53
53
|
} | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
export type FileSearchListResponse = {
|
|
57
|
+
ref: string;
|
|
58
|
+
generation: number;
|
|
59
|
+
files: {
|
|
60
|
+
path: string;
|
|
61
|
+
type: 'blob' | 'commit';
|
|
62
|
+
}[];
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type GrepMatch = {
|
|
67
|
+
path: string;
|
|
68
|
+
line: number;
|
|
69
|
+
column: number;
|
|
70
|
+
preview: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type GrepResponse = {
|
|
74
|
+
ref: string;
|
|
75
|
+
engine: 'rg' | 'git' | 'fallback';
|
|
76
|
+
truncated: boolean;
|
|
77
|
+
matches: GrepMatch[];
|
|
78
|
+
};
|
|
79
|
+
|
|
56
80
|
export type FileDiffResponse = {
|
|
57
81
|
path: string;
|
|
58
82
|
old_path?: string;
|