@youtyan/code-viewer 0.1.14 → 0.1.15
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 +361 -104
- package/web/index.html +45 -8
- package/web/style.css +370 -69
- package/web-src/server/git.ts +67 -29
- package/web-src/server/preview.ts +109 -32
- package/web-src/server/search.ts +10 -7
- package/web-src/types.ts +10 -1
package/web-src/server/git.ts
CHANGED
|
@@ -18,10 +18,39 @@ export type GitTreeEntry = {
|
|
|
18
18
|
path: string;
|
|
19
19
|
type: 'tree' | 'blob' | 'commit';
|
|
20
20
|
children_omitted?: true;
|
|
21
|
-
children_omitted_reason?: '
|
|
21
|
+
children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const WORKTREE_RECURSIVE_DEPTH_LIMIT = 32;
|
|
25
|
+
export const WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000;
|
|
26
|
+
export const DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
|
|
27
|
+
'node_modules',
|
|
28
|
+
'.venv',
|
|
29
|
+
'venv',
|
|
30
|
+
'.next',
|
|
31
|
+
'.nuxt',
|
|
32
|
+
'.svelte-kit',
|
|
33
|
+
'.astro',
|
|
34
|
+
'.vercel',
|
|
35
|
+
'dist',
|
|
36
|
+
'build',
|
|
37
|
+
'out',
|
|
38
|
+
'target',
|
|
39
|
+
'.gradle',
|
|
40
|
+
'__pycache__',
|
|
41
|
+
'.pytest_cache',
|
|
42
|
+
'.tox',
|
|
43
|
+
'.terraform',
|
|
44
|
+
'.idea',
|
|
45
|
+
'.vscode',
|
|
46
|
+
'vendor',
|
|
47
|
+
'.cache',
|
|
48
|
+
'coverage',
|
|
49
|
+
'DerivedData',
|
|
50
|
+
'Pods',
|
|
51
|
+
'bin',
|
|
52
|
+
'obj',
|
|
53
|
+
];
|
|
25
54
|
|
|
26
55
|
function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
|
|
27
56
|
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
@@ -159,55 +188,63 @@ function sortTreeEntries(entries: GitTreeEntry[]): GitTreeEntry[] {
|
|
|
159
188
|
});
|
|
160
189
|
}
|
|
161
190
|
|
|
162
|
-
function
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (base) args.push('--', `${base}/`);
|
|
166
|
-
const proc = Bun.spawnSync(args, {
|
|
167
|
-
cwd,
|
|
168
|
-
stdout: 'pipe',
|
|
169
|
-
stderr: 'ignore',
|
|
170
|
-
});
|
|
171
|
-
if (proc.exitCode !== 0) return new Set();
|
|
172
|
-
return new Set(new TextDecoder().decode(proc.stdout)
|
|
173
|
-
.split('\0')
|
|
174
|
-
.filter(entry => entry.endsWith('/'))
|
|
175
|
-
.map(entry => entry.replace(/\/+$/g, ''))
|
|
176
|
-
.filter(entry => entry && entry !== base));
|
|
191
|
+
function omittedWorktreeDirectoryReason(name: string, omitDirNames: Set<string>): GitTreeEntry['children_omitted_reason'] | undefined {
|
|
192
|
+
if (name === '.git') return 'internal';
|
|
193
|
+
return omitDirNames.has(name) ? 'heavy' : undefined;
|
|
177
194
|
}
|
|
178
195
|
|
|
179
|
-
function worktreeEntryFromDirent(base: string, dir: string, name: string, isDirectory: boolean,
|
|
196
|
+
function worktreeEntryFromDirent(base: string, dir: string, name: string, isDirectory: boolean, omitDirNames: Set<string>): GitTreeEntry {
|
|
180
197
|
const entryPath = base ? `${base}/${name}` : name;
|
|
181
198
|
const type = isDirectory
|
|
182
199
|
? hasDotGitEntry(join(dir, name)) ? 'commit' as const : 'tree' as const
|
|
183
200
|
: 'blob' as const;
|
|
184
|
-
|
|
201
|
+
const omittedReason = type === 'tree' ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
|
|
202
|
+
return omittedReason
|
|
185
203
|
? {
|
|
186
204
|
name,
|
|
187
205
|
path: entryPath,
|
|
188
206
|
type,
|
|
189
207
|
children_omitted: true,
|
|
190
|
-
children_omitted_reason:
|
|
208
|
+
children_omitted_reason: omittedReason,
|
|
191
209
|
}
|
|
192
210
|
: { name, path: entryPath, type };
|
|
193
211
|
}
|
|
194
212
|
|
|
195
|
-
function worktreeFilesystemEntries(cwd: string, path: string, recursive: boolean): GitTreeEntry[] {
|
|
213
|
+
function worktreeFilesystemEntries(cwd: string, path: string, recursive: boolean, omitDirNames: string[] = DEFAULT_WORKTREE_OMIT_DIR_NAMES): GitTreeEntry[] {
|
|
196
214
|
const base = normalizeTreePath(path);
|
|
197
215
|
const root = join(cwd, base);
|
|
198
|
-
const
|
|
216
|
+
const omitDirNameSet = new Set(omitDirNames);
|
|
199
217
|
let directEntries: GitTreeEntry[];
|
|
200
218
|
try {
|
|
201
219
|
const dirents = readdirSync(root, { withFileTypes: true });
|
|
202
220
|
directEntries = sortTreeEntries(dirents
|
|
203
|
-
.map(entry => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(),
|
|
221
|
+
.map(entry => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet)));
|
|
204
222
|
} catch {
|
|
205
223
|
return [];
|
|
206
224
|
}
|
|
207
225
|
if (!recursive) return directEntries;
|
|
208
226
|
|
|
209
227
|
const fileEntries: GitTreeEntry[] = [];
|
|
228
|
+
let truncated = false;
|
|
229
|
+
const pushRecursiveEntry = (entry: GitTreeEntry): boolean => {
|
|
230
|
+
if (fileEntries.length >= WORKTREE_RECURSIVE_ENTRY_LIMIT) {
|
|
231
|
+
if (!truncated) {
|
|
232
|
+
fileEntries.push({
|
|
233
|
+
name: 'more...',
|
|
234
|
+
path: '__code_viewer_truncated__',
|
|
235
|
+
type: 'tree',
|
|
236
|
+
children_omitted: true,
|
|
237
|
+
children_omitted_reason: 'truncated',
|
|
238
|
+
});
|
|
239
|
+
truncated = true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
fileEntries.push(entry);
|
|
244
|
+
return true;
|
|
245
|
+
};
|
|
210
246
|
const walk = (dir: string, prefix: string, depth: number) => {
|
|
247
|
+
if (truncated) return;
|
|
211
248
|
if (depth >= WORKTREE_RECURSIVE_DEPTH_LIMIT) return;
|
|
212
249
|
let entries;
|
|
213
250
|
try {
|
|
@@ -219,20 +256,21 @@ function worktreeFilesystemEntries(cwd: string, path: string, recursive: boolean
|
|
|
219
256
|
const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
220
257
|
const full = join(dir, entry.name);
|
|
221
258
|
if (entry.isDirectory()) {
|
|
222
|
-
|
|
223
|
-
|
|
259
|
+
const omittedReason = omittedWorktreeDirectoryReason(entry.name, omitDirNameSet);
|
|
260
|
+
if (omittedReason) {
|
|
261
|
+
if (!pushRecursiveEntry({
|
|
224
262
|
name: entry.name,
|
|
225
263
|
path: entryPath,
|
|
226
264
|
type: 'tree',
|
|
227
265
|
children_omitted: true,
|
|
228
|
-
children_omitted_reason:
|
|
229
|
-
});
|
|
266
|
+
children_omitted_reason: omittedReason,
|
|
267
|
+
})) return;
|
|
230
268
|
continue;
|
|
231
269
|
}
|
|
232
270
|
if (hasDotGitEntry(full)) continue;
|
|
233
271
|
walk(full, entryPath, depth + 1);
|
|
234
272
|
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
235
|
-
|
|
273
|
+
if (!pushRecursiveEntry({ name: entry.name, path: entryPath, type: 'blob' })) return;
|
|
236
274
|
}
|
|
237
275
|
}
|
|
238
276
|
};
|
|
@@ -297,11 +335,11 @@ export function listTree(
|
|
|
297
335
|
ref: string,
|
|
298
336
|
path: string,
|
|
299
337
|
cwd: string,
|
|
300
|
-
options: { recursive?: boolean } = {},
|
|
338
|
+
options: { recursive?: boolean; omitDirNames?: string[] } = {},
|
|
301
339
|
): { code: number; entries: GitTreeEntry[]; stderr: string } {
|
|
302
340
|
const base = normalizeTreePath(path);
|
|
303
341
|
if (ref === 'worktree') {
|
|
304
|
-
return { code: 0, entries: worktreeFilesystemEntries(cwd, base, !!options.recursive), stderr: '' };
|
|
342
|
+
return { code: 0, entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames), stderr: '' };
|
|
305
343
|
}
|
|
306
344
|
|
|
307
345
|
const direct = gitTreeEntries(ref, base, cwd, false);
|
|
@@ -3,7 +3,7 @@
|
|
|
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';
|
|
@@ -45,6 +45,8 @@ let cliArgs = DEFAULT_ARGS;
|
|
|
45
45
|
let listenPort = 0;
|
|
46
46
|
let allowUpload = false;
|
|
47
47
|
let uploadAllowedByCli = false;
|
|
48
|
+
let scopeOmitDirNames = git.DEFAULT_WORKTREE_OMIT_DIR_NAMES;
|
|
49
|
+
let scopeOmitDirCliOverride: string[] | null = null;
|
|
48
50
|
let rgAvailableCache: boolean | null = null;
|
|
49
51
|
|
|
50
52
|
const enc = new TextEncoder();
|
|
@@ -79,7 +81,12 @@ Examples:
|
|
|
79
81
|
console.error('--cwd requires a value');
|
|
80
82
|
process.exit(1);
|
|
81
83
|
}
|
|
82
|
-
|
|
84
|
+
try {
|
|
85
|
+
cwd = git.repoRoot(next) || realpathSync(next);
|
|
86
|
+
} catch {
|
|
87
|
+
console.error('--cwd must point to an existing directory');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
83
90
|
} else if (arg === '--port') {
|
|
84
91
|
const next = process.argv[++i];
|
|
85
92
|
const parsed = Number(next);
|
|
@@ -93,12 +100,25 @@ Examples:
|
|
|
93
100
|
} else if (arg === '--allow-upload') {
|
|
94
101
|
allowUpload = true;
|
|
95
102
|
uploadAllowedByCli = true;
|
|
103
|
+
} else if (arg === '--scope-omit-dir') {
|
|
104
|
+
const next = process.argv[++i];
|
|
105
|
+
if (!next) {
|
|
106
|
+
console.error('--scope-omit-dir requires a directory name');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
scopeOmitDirCliOverride = normalizeScopeOmitDirNames([...(scopeOmitDirCliOverride || []), next]);
|
|
96
110
|
} else {
|
|
97
111
|
rest.push(arg);
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
if (rest.length) cliArgs = rest;
|
|
101
115
|
if (!uploadAllowedByCli) allowUpload = loadProjectConfigUploadEnabled();
|
|
116
|
+
const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
|
|
117
|
+
if (scopeOmitDirCliOverride) {
|
|
118
|
+
scopeOmitDirNames = scopeOmitDirCliOverride;
|
|
119
|
+
} else if (configScopeOmitDirs) {
|
|
120
|
+
scopeOmitDirNames = configScopeOmitDirs;
|
|
121
|
+
}
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
function json(data: unknown, init: ResponseInit = {}) {
|
|
@@ -306,29 +326,68 @@ function safeRepoPath(path: string) {
|
|
|
306
326
|
return path === '' || safePath(path);
|
|
307
327
|
}
|
|
308
328
|
|
|
309
|
-
function
|
|
329
|
+
function normalizeScopeOmitDirNames(names: unknown): string[] {
|
|
330
|
+
if (!Array.isArray(names)) return [];
|
|
331
|
+
return [...new Set(names
|
|
332
|
+
.filter((name): name is string => typeof name === 'string')
|
|
333
|
+
.map(name => name.trim())
|
|
334
|
+
.filter(name => name && name.length <= 64 && !name.includes('/') && !name.includes('\\') && !name.includes('\0') && name !== '.' && name !== '..' && name !== '.git'))]
|
|
335
|
+
.sort((a, b) => a.localeCompare(b));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseScopeOmitDirNamesQuery(value: string): string[] | null {
|
|
339
|
+
const names = value ? value.split(',') : [];
|
|
340
|
+
if (names.length > 100) return null;
|
|
341
|
+
for (const raw of names) {
|
|
342
|
+
const name = raw.trim();
|
|
343
|
+
if (!name || name.length > 64 || name.includes('/') || name.includes('\\') || name.includes('\0') || name === '.' || name === '..' || name === '.git') return null;
|
|
344
|
+
}
|
|
345
|
+
return normalizeScopeOmitDirNames(names);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function loadProjectConfig(): Record<string, unknown> | null {
|
|
310
349
|
const full = join(cwd, '.code-viewer.json');
|
|
311
|
-
if (!existsSync(full)) return
|
|
350
|
+
if (!existsSync(full)) return null;
|
|
312
351
|
let realCwd: string;
|
|
313
352
|
let realConfig: string;
|
|
314
353
|
try {
|
|
315
354
|
realCwd = realpathSync(cwd);
|
|
316
355
|
realConfig = realpathSync(full);
|
|
317
356
|
} catch {
|
|
318
|
-
return
|
|
357
|
+
return null;
|
|
319
358
|
}
|
|
320
|
-
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return
|
|
359
|
+
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
|
|
321
360
|
try {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
361
|
+
const parsed = JSON.parse(readFileSync(realConfig, 'utf8'));
|
|
362
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'version' in parsed && (parsed as { version?: unknown }).version !== 1) return null;
|
|
363
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
364
|
+
? parsed as Record<string, unknown>
|
|
365
|
+
: null;
|
|
327
366
|
} catch {
|
|
328
|
-
return
|
|
367
|
+
return null;
|
|
329
368
|
}
|
|
330
369
|
}
|
|
331
370
|
|
|
371
|
+
function loadProjectConfigUploadEnabled(): boolean {
|
|
372
|
+
const config = loadProjectConfig() as { upload?: { enabled?: unknown } } | null;
|
|
373
|
+
return config?.upload?.enabled === true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function loadProjectConfigScopeOmitDirs(): string[] | null {
|
|
377
|
+
const config = loadProjectConfig() as { scope?: { omitDirs?: unknown } } | null;
|
|
378
|
+
if (!config?.scope || !Array.isArray(config.scope.omitDirs)) return null;
|
|
379
|
+
return normalizeScopeOmitDirNames(config.scope.omitDirs);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function scopeOmitDirNamesFromQuery(url: URL): string[] {
|
|
383
|
+
if (!url.searchParams.has('omit_dirs')) return scopeOmitDirNames;
|
|
384
|
+
return parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '') || scopeOmitDirNames;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function invalidScopeOmitDirNamesQuery(url: URL): boolean {
|
|
388
|
+
return url.searchParams.has('omit_dirs') && !parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '');
|
|
389
|
+
}
|
|
390
|
+
|
|
332
391
|
function isGitInternalPath(path: string): boolean {
|
|
333
392
|
return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
|
|
334
393
|
}
|
|
@@ -396,7 +455,8 @@ function handleTree(url: URL) {
|
|
|
396
455
|
if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
|
|
397
456
|
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
398
457
|
const recursive = url.searchParams.get('recursive') === '1';
|
|
399
|
-
|
|
458
|
+
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
459
|
+
const entries = git.listTree(target, path, cwd, { recursive, omitDirNames: scopeOmitDirNamesFromQuery(url) }).entries;
|
|
400
460
|
return json({
|
|
401
461
|
ref: target,
|
|
402
462
|
path,
|
|
@@ -408,20 +468,34 @@ function handleTree(url: URL) {
|
|
|
408
468
|
} satisfies RepoTreeResponse);
|
|
409
469
|
}
|
|
410
470
|
|
|
471
|
+
function handleSettings() {
|
|
472
|
+
return json({
|
|
473
|
+
project: basename(cwd),
|
|
474
|
+
scope: {
|
|
475
|
+
omit_dirs_effective: scopeOmitDirNames,
|
|
476
|
+
omit_dirs_built_in: git.DEFAULT_WORKTREE_OMIT_DIR_NAMES,
|
|
477
|
+
max_entries: git.WORKTREE_RECURSIVE_ENTRY_LIMIT,
|
|
478
|
+
},
|
|
479
|
+
} satisfies SettingsResponse);
|
|
480
|
+
}
|
|
481
|
+
|
|
411
482
|
function handleFiles(url: URL) {
|
|
412
483
|
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
413
484
|
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
414
|
-
|
|
485
|
+
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
486
|
+
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
487
|
+
const key = `${target || 'worktree'}\0${omitDirNames.join('\0')}`;
|
|
415
488
|
const cached = fileListCache.get(key);
|
|
416
489
|
if (cached && cached.generation === generation) return json(cached.body);
|
|
417
|
-
const
|
|
418
|
-
const
|
|
490
|
+
const ref = target || 'worktree';
|
|
491
|
+
const entries = git.listTree(ref, '', cwd, { recursive: true, omitDirNames }).entries;
|
|
492
|
+
const body = buildFileSearchList(ref, generation, entries);
|
|
419
493
|
fileListCache.set(key, { generation, body });
|
|
420
494
|
return json(body);
|
|
421
495
|
}
|
|
422
496
|
|
|
423
|
-
function parseGrepPaths(url: URL): string[] {
|
|
424
|
-
return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path));
|
|
497
|
+
function parseGrepPaths(url: URL, omitDirNames: string[]): string[] {
|
|
498
|
+
return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
425
499
|
}
|
|
426
500
|
|
|
427
501
|
function rgAvailable(): boolean {
|
|
@@ -431,12 +505,12 @@ function rgAvailable(): boolean {
|
|
|
431
505
|
return rgAvailableCache;
|
|
432
506
|
}
|
|
433
507
|
|
|
434
|
-
function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
|
|
508
|
+
function grepWorktreeFallback(query: string, max: number, paths: string[], omitDirNames: string[]): GrepMatch[] {
|
|
435
509
|
const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
|
|
436
510
|
const matches: GrepMatch[] = [];
|
|
437
511
|
for (const path of candidates) {
|
|
438
512
|
if (matches.length >= max) break;
|
|
439
|
-
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
|
|
513
|
+
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames)) continue;
|
|
440
514
|
const full = safeWorktreePath(path);
|
|
441
515
|
if (!full) continue;
|
|
442
516
|
let stat;
|
|
@@ -458,23 +532,23 @@ function grepWorktreeFallback(query: string, max: number, paths: string[]): Grep
|
|
|
458
532
|
return matches;
|
|
459
533
|
}
|
|
460
534
|
|
|
461
|
-
function grepWorktree(query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
535
|
+
function grepWorktree(query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
|
|
462
536
|
if (rgAvailable()) {
|
|
463
|
-
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
|
|
464
|
-
const args = buildRgArgs(query, max, safePaths, regex);
|
|
537
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
|
|
538
|
+
const args = buildRgArgs(query, max, safePaths, regex, omitDirNames);
|
|
465
539
|
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
466
540
|
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));
|
|
541
|
+
const matches = parseRgOutput(stdout, max, omitDirNames)
|
|
542
|
+
.filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
|
|
469
543
|
return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
|
|
470
544
|
}
|
|
471
545
|
if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
|
|
472
|
-
const matches = grepWorktreeFallback(query, max, paths);
|
|
546
|
+
const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
|
|
473
547
|
return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
|
|
474
548
|
}
|
|
475
549
|
|
|
476
|
-
function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
|
|
477
|
-
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path));
|
|
550
|
+
function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
|
|
551
|
+
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
478
552
|
const args = [
|
|
479
553
|
'git', '-c', 'core.quotepath=false', 'grep',
|
|
480
554
|
'-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
|
|
@@ -484,7 +558,7 @@ function grepTreeRef(ref: string, query: string, max: number, paths: string[], r
|
|
|
484
558
|
];
|
|
485
559
|
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
486
560
|
const stdout = new TextDecoder().decode(proc.stdout);
|
|
487
|
-
const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
|
|
561
|
+
const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
|
|
488
562
|
return { ref, engine: 'git', truncated: matches.length >= max, matches };
|
|
489
563
|
}
|
|
490
564
|
|
|
@@ -492,12 +566,14 @@ function handleGrep(url: URL) {
|
|
|
492
566
|
const query = url.searchParams.get('q') || '';
|
|
493
567
|
const ref = url.searchParams.get('ref') || 'worktree';
|
|
494
568
|
const max = normalizeGrepMax(url.searchParams.get('max'));
|
|
495
|
-
|
|
569
|
+
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
570
|
+
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
571
|
+
const paths = parseGrepPaths(url, omitDirNames);
|
|
496
572
|
const regex = url.searchParams.get('regex') === '1';
|
|
497
573
|
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));
|
|
574
|
+
if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex, omitDirNames));
|
|
499
575
|
if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
|
|
500
|
-
return json(grepTreeRef(ref, query, max, paths, regex));
|
|
576
|
+
return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
|
|
501
577
|
}
|
|
502
578
|
|
|
503
579
|
function handleFileDiff(url: URL) {
|
|
@@ -845,6 +921,7 @@ const server = Bun.serve({
|
|
|
845
921
|
const staticResponse = staticFile(url.pathname);
|
|
846
922
|
if (staticResponse) return staticResponse;
|
|
847
923
|
if (url.pathname === '/diff.json') return handleDiffJson(url);
|
|
924
|
+
if (url.pathname === '/_settings') return handleSettings();
|
|
848
925
|
if (url.pathname === '/_tree') return handleTree(url);
|
|
849
926
|
if (url.pathname === '/_files') return handleFiles(url);
|
|
850
927
|
if (url.pathname === '/_grep') return handleGrep(url);
|
package/web-src/server/search.ts
CHANGED
|
@@ -12,10 +12,11 @@ export function normalizeGrepMax(value: string | null): number {
|
|
|
12
12
|
return Math.min(parsed, GREP_ABSOLUTE_MAX);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function isSkippableSearchPath(path: string): boolean {
|
|
15
|
+
export function isSkippableSearchPath(path: string, omitDirNames: string[] = []): boolean {
|
|
16
|
+
const omitDirs = new Set(omitDirNames.map(name => name.toLowerCase()));
|
|
16
17
|
return path.split(/[\\/]+/).some(part => {
|
|
17
18
|
const lower = part.toLowerCase();
|
|
18
|
-
return lower === '.git' || lower
|
|
19
|
+
return lower === '.git' || omitDirs.has(lower);
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -51,8 +52,9 @@ export function buildFileSearchList(ref: string, generation: number, entries: Gi
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
export function buildRgArgs(query: string, max: number, paths: string[], regex = false): string[] {
|
|
55
|
+
export function buildRgArgs(query: string, max: number, paths: string[], regex = false, omitDirNames: string[] = []): string[] {
|
|
55
56
|
const safePaths = paths.length ? paths : ['.'];
|
|
57
|
+
const omitGlobs = omitDirNames.flatMap(name => ['--glob', `!${name}/**`, '--glob', `!**/${name}/**`]);
|
|
56
58
|
const args = [
|
|
57
59
|
'rg',
|
|
58
60
|
'--no-config',
|
|
@@ -66,6 +68,7 @@ export function buildRgArgs(query: string, max: number, paths: string[], regex =
|
|
|
66
68
|
String(max),
|
|
67
69
|
'--max-filesize',
|
|
68
70
|
'2M',
|
|
71
|
+
...omitGlobs,
|
|
69
72
|
'-e',
|
|
70
73
|
query,
|
|
71
74
|
'--',
|
|
@@ -75,7 +78,7 @@ export function buildRgArgs(query: string, max: number, paths: string[], regex =
|
|
|
75
78
|
return args;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
81
|
+
export function parseRgOutput(stdout: string, max: number, omitDirNames: string[] = []): GrepMatch[] {
|
|
79
82
|
const matches: GrepMatch[] = [];
|
|
80
83
|
for (const line of stdout.split('\n')) {
|
|
81
84
|
if (!line || matches.length >= max) continue;
|
|
@@ -85,17 +88,17 @@ export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
|
|
|
85
88
|
const lineNo = Number(parsed[2]);
|
|
86
89
|
const column = Number(parsed[3]);
|
|
87
90
|
const preview = parsed[4];
|
|
88
|
-
if (!path || !lineNo || !column || isSkippableSearchPath(path)) continue;
|
|
91
|
+
if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames)) continue;
|
|
89
92
|
matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
|
|
90
93
|
}
|
|
91
94
|
return matches;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
export function parseGitGrepOutput(stdout: string, ref: string, max: number): GrepMatch[] {
|
|
97
|
+
export function parseGitGrepOutput(stdout: string, ref: string, max: number, omitDirNames: string[] = []): GrepMatch[] {
|
|
95
98
|
const prefix = ref + ':';
|
|
96
99
|
const normalized = stdout
|
|
97
100
|
.split('\n')
|
|
98
101
|
.map(line => line.startsWith(prefix) ? line.slice(prefix.length) : line)
|
|
99
102
|
.join('\n');
|
|
100
|
-
return parseRgOutput(normalized, max);
|
|
103
|
+
return parseRgOutput(normalized, max, omitDirNames);
|
|
101
104
|
}
|
package/web-src/types.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type RepoTreeEntry = {
|
|
|
38
38
|
path: string;
|
|
39
39
|
type: 'tree' | 'blob' | 'commit';
|
|
40
40
|
children_omitted?: true;
|
|
41
|
-
children_omitted_reason?: '
|
|
41
|
+
children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
export type RepoTreeResponse = {
|
|
@@ -54,6 +54,15 @@ export type RepoTreeResponse = {
|
|
|
54
54
|
} | null;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
export type SettingsResponse = {
|
|
58
|
+
project: string;
|
|
59
|
+
scope: {
|
|
60
|
+
omit_dirs_effective: string[];
|
|
61
|
+
omit_dirs_built_in: string[];
|
|
62
|
+
max_entries: number;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
57
66
|
export type FileSearchListResponse = {
|
|
58
67
|
ref: string;
|
|
59
68
|
generation: number;
|