@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.
- package/README.md +9 -0
- package/package.json +1 -1
- package/web/app.js +451 -106
- package/web/index.html +45 -8
- package/web/style.css +405 -78
- package/web-src/server/git.ts +117 -59
- package/web-src/server/preview.ts +113 -33
- package/web-src/server/search.ts +10 -7
- package/web-src/types.ts +10 -0
package/web-src/server/git.ts
CHANGED
|
@@ -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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
357
|
+
return null;
|
|
318
358
|
}
|
|
319
|
-
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return
|
|
359
|
+
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
|
|
320
360
|
try {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
417
|
-
const
|
|
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
|
-
|
|
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);
|
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,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;
|