@youtyan/code-viewer 0.1.13 → 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.
@@ -18,8 +18,40 @@ export type GitTreeEntry = {
18
18
  path: string;
19
19
  type: 'tree' | 'blob' | 'commit';
20
20
  children_omitted?: true;
21
+ children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
21
22
  };
22
23
 
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
+ ];
54
+
23
55
  function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
24
56
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
25
57
  return {
@@ -156,24 +188,94 @@ function sortTreeEntries(entries: GitTreeEntry[]): GitTreeEntry[] {
156
188
  });
157
189
  }
158
190
 
159
- function worktreeDirectChildren(cwd: string, path: string): GitTreeEntry[] {
160
- const base = normalizeTreePath(path);
161
- const dir = join(cwd, base);
162
- try {
163
- return sortTreeEntries(readdirSync(dir, { withFileTypes: true }).map((entry) => {
164
- const entryPath = base ? `${base}/${entry.name}` : entry.name;
165
- const type = entry.isDirectory()
166
- ? hasDotGitEntry(join(dir, entry.name)) ? 'commit' as const : 'tree' as const
167
- : 'blob' as const;
168
- return {
169
- name: entry.name,
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;
194
+ }
195
+
196
+ function worktreeEntryFromDirent(base: string, dir: string, name: string, isDirectory: boolean, omitDirNames: Set<string>): GitTreeEntry {
197
+ const entryPath = base ? `${base}/${name}` : name;
198
+ const type = isDirectory
199
+ ? hasDotGitEntry(join(dir, name)) ? 'commit' as const : 'tree' as const
200
+ : 'blob' as const;
201
+ const omittedReason = type === 'tree' ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
202
+ return omittedReason
203
+ ? {
204
+ name,
170
205
  path: entryPath,
171
206
  type,
172
- };
173
- }));
207
+ children_omitted: true,
208
+ children_omitted_reason: omittedReason,
209
+ }
210
+ : { name, path: entryPath, type };
211
+ }
212
+
213
+ function worktreeFilesystemEntries(cwd: string, path: string, recursive: boolean, omitDirNames: string[] = DEFAULT_WORKTREE_OMIT_DIR_NAMES): GitTreeEntry[] {
214
+ const base = normalizeTreePath(path);
215
+ const root = join(cwd, base);
216
+ const omitDirNameSet = new Set(omitDirNames);
217
+ let directEntries: GitTreeEntry[];
218
+ try {
219
+ const dirents = readdirSync(root, { withFileTypes: true });
220
+ directEntries = sortTreeEntries(dirents
221
+ .map(entry => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet)));
174
222
  } catch {
175
223
  return [];
176
224
  }
225
+ if (!recursive) return directEntries;
226
+
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
+ };
246
+ const walk = (dir: string, prefix: string, depth: number) => {
247
+ if (truncated) return;
248
+ if (depth >= WORKTREE_RECURSIVE_DEPTH_LIMIT) return;
249
+ let entries;
250
+ try {
251
+ entries = readdirSync(dir, { withFileTypes: true });
252
+ } catch {
253
+ return;
254
+ }
255
+ for (const entry of entries) {
256
+ const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
257
+ const full = join(dir, entry.name);
258
+ if (entry.isDirectory()) {
259
+ const omittedReason = omittedWorktreeDirectoryReason(entry.name, omitDirNameSet);
260
+ if (omittedReason) {
261
+ if (!pushRecursiveEntry({
262
+ name: entry.name,
263
+ path: entryPath,
264
+ type: 'tree',
265
+ children_omitted: true,
266
+ children_omitted_reason: omittedReason,
267
+ })) return;
268
+ continue;
269
+ }
270
+ if (hasDotGitEntry(full)) continue;
271
+ walk(full, entryPath, depth + 1);
272
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
273
+ if (!pushRecursiveEntry({ name: entry.name, path: entryPath, type: 'blob' })) return;
274
+ }
275
+ }
276
+ };
277
+ walk(root, base, 0);
278
+ return combineDirectAndRecursiveFiles(directEntries, fileEntries.sort((a, b) => a.path.localeCompare(b.path)));
177
279
  }
178
280
 
179
281
  function hasDotGitEntry(dir: string): boolean {
@@ -185,20 +287,6 @@ function hasDotGitEntry(dir: string): boolean {
185
287
  }
186
288
  }
187
289
 
188
- function worktreeRecursiveFiles(cwd: string, path: string): GitTreeEntry[] {
189
- const base = normalizeTreePath(path);
190
- const trackedArgs = ['git', '-c', 'core.quotepath=false', 'ls-files', '-z'];
191
- if (base) trackedArgs.push('--', `${base}/`);
192
- const tracked = run(trackedArgs, cwd);
193
- const paths = tracked.code === 0 ? tracked.stdout.split('\0').filter(Boolean) : [];
194
- paths.push(...untracked(cwd, base));
195
- return [...new Set(paths)].filter(Boolean).sort().map((entryPath) => ({
196
- name: entryPath.split('/').pop() || entryPath,
197
- path: entryPath,
198
- type: 'blob' as const,
199
- }));
200
- }
201
-
202
290
  function gitTreeEntries(ref: string, path: string, cwd: string, recursive: boolean): { code: number; entries: GitTreeEntry[]; stderr: string } {
203
291
  const base = normalizeTreePath(path);
204
292
  const args = ['git', '-c', 'core.quotepath=false', 'ls-tree'];
@@ -227,31 +315,6 @@ function combineDirectAndRecursiveFiles(directEntries: GitTreeEntry[], fileEntri
227
315
  ];
228
316
  }
229
317
 
230
- function ignoredWorktreePaths(cwd: string, path: string): Set<string> {
231
- const base = normalizeTreePath(path);
232
- const args = ['git', '-c', 'core.quotepath=false', 'status', '--ignored', '--porcelain=v1', '-z', '--'];
233
- if (base) args.push(`${base}/`);
234
- const res = run(args, cwd);
235
- const ignored = new Set<string>();
236
- if (res.code !== 0) return ignored;
237
- const records = res.stdout.split('\0').filter(Boolean);
238
- for (let i = 0; i < records.length; i++) {
239
- const rec = records[i];
240
- if (!rec.startsWith('!! ')) continue;
241
- const path = rec.slice(3).replace(/\/+$/g, '');
242
- if (path) ignored.add(path);
243
- }
244
- return ignored;
245
- }
246
-
247
- function annotateOmittedWorktreeChildren(entries: GitTreeEntry[], ignoredPaths: Set<string>): GitTreeEntry[] {
248
- return entries.map((entry) => {
249
- return entry.type === 'tree' && ignoredPaths.has(entry.path)
250
- ? { ...entry, children_omitted: true }
251
- : entry;
252
- });
253
- }
254
-
255
318
  export function worktreeEntries(cwd: string, path: string): GitTreeEntry[] {
256
319
  return listTree('worktree', path, cwd).entries;
257
320
  }
@@ -272,16 +335,11 @@ export function listTree(
272
335
  ref: string,
273
336
  path: string,
274
337
  cwd: string,
275
- options: { recursive?: boolean } = {},
338
+ options: { recursive?: boolean; omitDirNames?: string[] } = {},
276
339
  ): { code: number; entries: GitTreeEntry[]; stderr: string } {
277
340
  const base = normalizeTreePath(path);
278
341
  if (ref === 'worktree') {
279
- const directEntries = worktreeDirectChildren(cwd, base);
280
- const ignoredPaths = ignoredWorktreePaths(cwd, base);
281
- const annotatedDirectEntries = annotateOmittedWorktreeChildren(directEntries, ignoredPaths);
282
- if (!options.recursive) return { code: 0, entries: annotatedDirectEntries, stderr: '' };
283
- const recursiveEntries = worktreeRecursiveFiles(cwd, base);
284
- return { code: 0, entries: combineDirectAndRecursiveFiles(annotatedDirectEntries, recursiveEntries), stderr: '' };
342
+ return { code: 0, entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames), stderr: '' };
285
343
  }
286
344
 
287
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
- cwd = git.repoRoot(next) || cwd;
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 = {}) {
@@ -180,6 +200,7 @@ function guessMediaKind(path: string) {
180
200
  const ext = extname(path).slice(1).toLowerCase();
181
201
  if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) return 'image';
182
202
  if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
203
+ if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) return 'audio';
183
204
  return null;
184
205
  }
185
206
 
@@ -305,29 +326,68 @@ function safeRepoPath(path: string) {
305
326
  return path === '' || safePath(path);
306
327
  }
307
328
 
308
- function loadProjectConfigUploadEnabled(): boolean {
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 {
309
349
  const full = join(cwd, '.code-viewer.json');
310
- if (!existsSync(full)) return false;
350
+ if (!existsSync(full)) return null;
311
351
  let realCwd: string;
312
352
  let realConfig: string;
313
353
  try {
314
354
  realCwd = realpathSync(cwd);
315
355
  realConfig = realpathSync(full);
316
356
  } catch {
317
- return false;
357
+ return null;
318
358
  }
319
- if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return false;
359
+ if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
320
360
  try {
321
- const config = JSON.parse(readFileSync(realConfig, 'utf8')) as {
322
- upload?: { enabled?: unknown };
323
- };
324
- if (!config.upload) return false;
325
- return config.upload.enabled === true;
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;
326
366
  } catch {
327
- return false;
367
+ return null;
328
368
  }
329
369
  }
330
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
+
331
391
  function isGitInternalPath(path: string): boolean {
332
392
  return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
333
393
  }
@@ -395,7 +455,8 @@ function handleTree(url: URL) {
395
455
  if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
396
456
  if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
397
457
  const recursive = url.searchParams.get('recursive') === '1';
398
- const entries = git.listTree(target, path, cwd, { recursive }).entries;
458
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
459
+ const entries = git.listTree(target, path, cwd, { recursive, omitDirNames: scopeOmitDirNamesFromQuery(url) }).entries;
399
460
  return json({
400
461
  ref: target,
401
462
  path,
@@ -407,20 +468,34 @@ function handleTree(url: URL) {
407
468
  } satisfies RepoTreeResponse);
408
469
  }
409
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
+
410
482
  function handleFiles(url: URL) {
411
483
  const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
412
484
  if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
413
- const key = target || 'worktree';
485
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
486
+ const omitDirNames = scopeOmitDirNamesFromQuery(url);
487
+ const key = `${target || 'worktree'}\0${omitDirNames.join('\0')}`;
414
488
  const cached = fileListCache.get(key);
415
489
  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);
490
+ const ref = target || 'worktree';
491
+ const entries = git.listTree(ref, '', cwd, { recursive: true, omitDirNames }).entries;
492
+ const body = buildFileSearchList(ref, generation, entries);
418
493
  fileListCache.set(key, { generation, body });
419
494
  return json(body);
420
495
  }
421
496
 
422
- function parseGrepPaths(url: URL): string[] {
423
- 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));
424
499
  }
425
500
 
426
501
  function rgAvailable(): boolean {
@@ -430,12 +505,12 @@ function rgAvailable(): boolean {
430
505
  return rgAvailableCache;
431
506
  }
432
507
 
433
- function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
508
+ function grepWorktreeFallback(query: string, max: number, paths: string[], omitDirNames: string[]): GrepMatch[] {
434
509
  const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
435
510
  const matches: GrepMatch[] = [];
436
511
  for (const path of candidates) {
437
512
  if (matches.length >= max) break;
438
- if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
513
+ if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames)) continue;
439
514
  const full = safeWorktreePath(path);
440
515
  if (!full) continue;
441
516
  let stat;
@@ -457,23 +532,23 @@ function grepWorktreeFallback(query: string, max: number, paths: string[]): Grep
457
532
  return matches;
458
533
  }
459
534
 
460
- 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 {
461
536
  if (rgAvailable()) {
462
- const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
463
- 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);
464
539
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
465
540
  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));
541
+ const matches = parseRgOutput(stdout, max, omitDirNames)
542
+ .filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
468
543
  return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
469
544
  }
470
545
  if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
471
- const matches = grepWorktreeFallback(query, max, paths);
546
+ const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
472
547
  return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
473
548
  }
474
549
 
475
- function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
476
- 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));
477
552
  const args = [
478
553
  'git', '-c', 'core.quotepath=false', 'grep',
479
554
  '-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
@@ -483,7 +558,7 @@ function grepTreeRef(ref: string, query: string, max: number, paths: string[], r
483
558
  ];
484
559
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
485
560
  const stdout = new TextDecoder().decode(proc.stdout);
486
- const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
561
+ const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
487
562
  return { ref, engine: 'git', truncated: matches.length >= max, matches };
488
563
  }
489
564
 
@@ -491,12 +566,14 @@ function handleGrep(url: URL) {
491
566
  const query = url.searchParams.get('q') || '';
492
567
  const ref = url.searchParams.get('ref') || 'worktree';
493
568
  const max = normalizeGrepMax(url.searchParams.get('max'));
494
- const paths = parseGrepPaths(url);
569
+ if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
570
+ const omitDirNames = scopeOmitDirNamesFromQuery(url);
571
+ const paths = parseGrepPaths(url, omitDirNames);
495
572
  const regex = url.searchParams.get('regex') === '1';
496
573
  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));
574
+ if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex, omitDirNames));
498
575
  if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
499
- return json(grepTreeRef(ref, query, max, paths, regex));
576
+ return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
500
577
  }
501
578
 
502
579
  function handleFileDiff(url: URL) {
@@ -641,7 +718,9 @@ function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
641
718
  const mime: Record<string, string> = {
642
719
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
643
720
  '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
644
- '.pdf': 'application/pdf',
721
+ '.mov': 'video/quicktime', '.pdf': 'application/pdf',
722
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
723
+ '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.opus': 'audio/ogg',
645
724
  };
646
725
  const headers: Record<string, string> = {
647
726
  'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream',
@@ -842,6 +921,7 @@ const server = Bun.serve({
842
921
  const staticResponse = staticFile(url.pathname);
843
922
  if (staticResponse) return staticResponse;
844
923
  if (url.pathname === '/diff.json') return handleDiffJson(url);
924
+ if (url.pathname === '/_settings') return handleSettings();
845
925
  if (url.pathname === '/_tree') return handleTree(url);
846
926
  if (url.pathname === '/_files') return handleFiles(url);
847
927
  if (url.pathname === '/_grep') return handleGrep(url);
@@ -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 === 'node_modules';
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,6 +38,7 @@ export type RepoTreeEntry = {
38
38
  path: string;
39
39
  type: 'tree' | 'blob' | 'commit';
40
40
  children_omitted?: true;
41
+ children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
41
42
  };
42
43
 
43
44
  export type RepoTreeResponse = {
@@ -53,6 +54,15 @@ export type RepoTreeResponse = {
53
54
  } | null;
54
55
  };
55
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
+
56
66
  export type FileSearchListResponse = {
57
67
  ref: string;
58
68
  generation: number;