@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.
@@ -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?: 'ignored' | 'internal';
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 ignoredWorktreeDirectories(cwd: string, path: string): Set<string> {
163
- const base = normalizeTreePath(path);
164
- const args = ['git', '-c', 'core.quotepath=false', 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'];
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, ignoredDirectories: Set<string>): GitTreeEntry {
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
- return type === 'tree' && (name === '.git' || ignoredDirectories.has(entryPath))
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: name === '.git' ? 'internal' : 'ignored',
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 ignoredDirectories = ignoredWorktreeDirectories(cwd, base);
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(), ignoredDirectories)));
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
- if (entry.name === '.git' || ignoredDirectories.has(entryPath)) {
223
- fileEntries.push({
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: entry.name === '.git' ? 'internal' : 'ignored',
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
- fileEntries.push({ name: entry.name, path: entryPath, type: 'blob' });
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
- 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 = {}) {
@@ -306,29 +326,68 @@ function safeRepoPath(path: string) {
306
326
  return path === '' || safePath(path);
307
327
  }
308
328
 
309
- 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 {
310
349
  const full = join(cwd, '.code-viewer.json');
311
- if (!existsSync(full)) return false;
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 false;
357
+ return null;
319
358
  }
320
- if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return false;
359
+ if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
321
360
  try {
322
- const config = JSON.parse(readFileSync(realConfig, 'utf8')) as {
323
- upload?: { enabled?: unknown };
324
- };
325
- if (!config.upload) return false;
326
- return config.upload.enabled === true;
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 false;
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
- 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;
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
- 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')}`;
415
488
  const cached = fileListCache.get(key);
416
489
  if (cached && cached.generation === generation) return json(cached.body);
417
- const entries = git.listTree(key, '', cwd, { recursive: true }).entries;
418
- const body = buildFileSearchList(key, generation, entries);
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
- 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);
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);
@@ -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,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?: 'ignored' | 'internal';
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;