@youtyan/code-viewer 0.1.11 → 0.1.12

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/web/style.css CHANGED
@@ -29,6 +29,8 @@
29
29
  --danger: #cf222e;
30
30
  --danger-emph: #cf222e;
31
31
  --attn: #9a6700;
32
+ --line-hit-bg: #fff8c5;
33
+ --line-hit-border:var(--accent);
32
34
  --done: #8250df;
33
35
  /* diff colors (GitHub exact) */
34
36
  --diff-add-bg: #dafbe1;
@@ -74,6 +76,8 @@
74
76
  --danger: #f85149;
75
77
  --danger-emph: #da3633;
76
78
  --attn: #d29922;
79
+ --line-hit-bg: rgba(187,128,9,0.18);
80
+ --line-hit-border:var(--accent);
77
81
  --done: #a371f7;
78
82
  --diff-add-bg: rgba(46,160,67,0.15);
79
83
  --diff-add-num-bg: rgba(46,160,67,0.30);
@@ -106,6 +110,140 @@ html, body {
106
110
  /* Window scrolls; global header + topbar + sidebar are fixed */
107
111
  #app { display: block; }
108
112
 
113
+ .gdp-palette-backdrop {
114
+ position: fixed;
115
+ inset: 0;
116
+ z-index: 500;
117
+ background: rgba(31, 35, 40, 0.18);
118
+ display: flex;
119
+ align-items: flex-start;
120
+ justify-content: center;
121
+ padding-top: min(12vh, 96px);
122
+ }
123
+
124
+ [data-theme="dark"] .gdp-palette-backdrop {
125
+ background: rgba(1, 4, 9, 0.45);
126
+ }
127
+
128
+ .gdp-palette {
129
+ width: min(760px, calc(100vw - 32px));
130
+ max-height: min(620px, calc(100vh - 48px));
131
+ display: grid;
132
+ grid-template-rows: auto auto auto auto 1fr;
133
+ overflow: hidden;
134
+ background: var(--bg);
135
+ border: 1px solid var(--border);
136
+ border-radius: 8px;
137
+ box-shadow: 0 16px 48px rgba(31, 35, 40, 0.28);
138
+ }
139
+
140
+ .gdp-palette-label {
141
+ padding: 12px 16px 0;
142
+ color: var(--fg-muted);
143
+ font-size: 12px;
144
+ font-weight: 600;
145
+ text-transform: uppercase;
146
+ }
147
+
148
+ .gdp-palette-input {
149
+ width: 100%;
150
+ border: 0;
151
+ border-bottom: 1px solid var(--border);
152
+ padding: 10px 16px 14px;
153
+ background: transparent;
154
+ color: var(--fg);
155
+ font-size: 24px;
156
+ line-height: 1.25;
157
+ outline: none;
158
+ }
159
+
160
+ .gdp-palette-status {
161
+ min-height: 28px;
162
+ padding: 6px 16px;
163
+ color: var(--fg-muted);
164
+ font-size: 12px;
165
+ border-bottom: 1px solid var(--border-muted);
166
+ }
167
+
168
+ .gdp-palette-controls {
169
+ min-height: 34px;
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 6px;
173
+ padding: 6px 16px 0;
174
+ }
175
+
176
+ .gdp-palette-mode-button {
177
+ height: 24px;
178
+ padding: 0 8px;
179
+ border: 1px solid var(--border);
180
+ border-radius: 6px;
181
+ background: var(--bg);
182
+ color: var(--fg-muted);
183
+ font-size: 12px;
184
+ font-weight: 600;
185
+ cursor: pointer;
186
+ }
187
+
188
+ .gdp-palette-mode-button[aria-pressed="true"] {
189
+ border-color: var(--accent);
190
+ background: var(--accent-subtle);
191
+ color: var(--accent);
192
+ }
193
+
194
+ .gdp-palette-mode-hint {
195
+ color: var(--fg-muted);
196
+ font-size: 12px;
197
+ }
198
+
199
+ .gdp-palette-list {
200
+ overflow: auto;
201
+ padding: 6px;
202
+ }
203
+
204
+ .gdp-palette-row {
205
+ width: 100%;
206
+ min-height: 52px;
207
+ display: grid;
208
+ grid-template-rows: auto auto;
209
+ gap: 2px;
210
+ padding: 8px 10px;
211
+ border: 0;
212
+ border-radius: 6px;
213
+ background: transparent;
214
+ color: var(--fg);
215
+ text-align: left;
216
+ cursor: pointer;
217
+ }
218
+
219
+ .gdp-palette-row[aria-selected="true"] {
220
+ background: var(--accent-subtle);
221
+ }
222
+
223
+ .gdp-palette-row-title {
224
+ overflow: hidden;
225
+ text-overflow: ellipsis;
226
+ white-space: nowrap;
227
+ font-size: 14px;
228
+ font-weight: 600;
229
+ }
230
+
231
+ .gdp-palette-row-detail {
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ white-space: nowrap;
235
+ color: var(--fg-muted);
236
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
237
+ font-size: 12px;
238
+ }
239
+
240
+ .gdp-palette-row-detail mark {
241
+ background: var(--attn);
242
+ color: var(--fg-onemphasis);
243
+ border-radius: 2px;
244
+ padding: 0 1px;
245
+ }
246
+
109
247
  /* ===== Global header ===== */
110
248
  #global-header {
111
249
  position: fixed;
@@ -1286,6 +1424,17 @@ table.d2h-diff-table .d2h-code-line-prefix {
1286
1424
  width: 1.5em;
1287
1425
  padding-left: 4px;
1288
1426
  }
1427
+
1428
+ table.d2h-diff-table tr.gdp-diff-line-target > td,
1429
+ table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-line,
1430
+ table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-side-line,
1431
+ table.d2h-diff-table tr.gdp-diff-line-target .d2h-code-line-ctn {
1432
+ background: var(--line-hit-bg) !important;
1433
+ background-color: var(--line-hit-bg) !important;
1434
+ }
1435
+ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
1436
+ box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
1437
+ }
1289
1438
  /* Stack height = number of buttons * 20px. With 1 button = 20px row,
1290
1439
  * with 2 (↑+↓) = 40px row, matching GitHub. */
1291
1440
  .gdp-expand-stack {
@@ -1602,6 +1751,33 @@ table.d2h-diff-table .d2h-code-line-prefix {
1602
1751
  font-size: 12px;
1603
1752
  line-height: 20px;
1604
1753
  }
1754
+ .gdp-source-table tr.gdp-source-line-target {
1755
+ background: var(--line-hit-bg);
1756
+ }
1757
+ .gdp-source-table tr.gdp-source-line-target .gdp-source-line-number,
1758
+ .gdp-source-table tr.gdp-source-line-target .gdp-source-line-code,
1759
+ .gdp-source-virtual-row.gdp-source-line-target {
1760
+ background: var(--line-hit-bg) !important;
1761
+ background-color: var(--line-hit-bg) !important;
1762
+ }
1763
+ .gdp-source-table tr.gdp-source-line-target > td.gdp-source-line-number,
1764
+ .gdp-source-virtual-row.gdp-source-line-target > .gdp-source-virtual-line-number {
1765
+ background: var(--line-hit-bg) !important;
1766
+ background-color: var(--line-hit-bg) !important;
1767
+ }
1768
+ .gdp-source-table tr.gdp-source-line-target .gdp-source-line-number,
1769
+ .gdp-source-virtual-row.gdp-source-line-target .gdp-source-virtual-line-number {
1770
+ box-shadow: inset 3px 0 0 var(--line-hit-border), inset -1px 0 0 var(--border-muted);
1771
+ }
1772
+ .gdp-source-line-target .gdp-source-line-code,
1773
+ .gdp-source-line-target .gdp-source-virtual-line-code {
1774
+ background: var(--line-hit-bg) !important;
1775
+ background-color: var(--line-hit-bg) !important;
1776
+ }
1777
+ .gdp-source-line-target .gdp-source-line-code.shiki span,
1778
+ .gdp-source-line-target .gdp-source-virtual-line-code.hljs span {
1779
+ background: transparent !important;
1780
+ }
1605
1781
  .gdp-standalone-source .gdp-source-table {
1606
1782
  display: block;
1607
1783
  overflow: auto;
@@ -1660,6 +1836,11 @@ table.d2h-diff-table .d2h-code-line-prefix {
1660
1836
  .gdp-source-line-code.hljs {
1661
1837
  background: var(--bg);
1662
1838
  }
1839
+ .gdp-source-line-target .gdp-source-line-code.hljs,
1840
+ .gdp-source-line-target .gdp-source-line-code.shiki,
1841
+ .gdp-source-line-target .gdp-source-virtual-line-code.hljs {
1842
+ background: var(--line-hit-bg);
1843
+ }
1663
1844
  .gdp-source-line-code.shiki,
1664
1845
  .gdp-source-line-code.shiki span {
1665
1846
  color: var(--shiki-light) !important;
@@ -1733,6 +1914,8 @@ table.d2h-diff-table .d2h-code-line-prefix {
1733
1914
  font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1734
1915
  font-size: 12px;
1735
1916
  line-height: 20px;
1917
+ cursor: pointer;
1918
+ user-select: none;
1736
1919
  }
1737
1920
  .gdp-source-virtual-spacer {
1738
1921
  position: relative;
@@ -1760,6 +1943,7 @@ table.d2h-diff-table .d2h-code-line-prefix {
1760
1943
  background: var(--bg);
1761
1944
  box-shadow: inset -1px 0 0 var(--border-muted);
1762
1945
  font-variant-numeric: tabular-nums;
1946
+ cursor: pointer;
1763
1947
  user-select: none;
1764
1948
  }
1765
1949
  .gdp-source-virtual-line-code {
package/web-src/routes.ts CHANGED
@@ -3,6 +3,13 @@ export type DiffRange = {
3
3
  to: string;
4
4
  };
5
5
 
6
+ export type SourceLineRange = {
7
+ start: number;
8
+ end: number;
9
+ };
10
+
11
+ export type SourceLineTarget = number | SourceLineRange;
12
+
6
13
  export type SourceFileTarget = {
7
14
  path: string;
8
15
  ref: string;
@@ -10,8 +17,8 @@ export type SourceFileTarget = {
10
17
 
11
18
  export type AppRoute =
12
19
  | { screen: 'repo'; ref: string; path: string; range: DiffRange }
13
- | { screen: 'diff'; range: DiffRange }
14
- | { screen: 'file'; path: string; ref: string; range: DiffRange; view?: 'blob' | 'detail' }
20
+ | { screen: 'diff'; range: DiffRange; path?: string; line?: SourceLineTarget }
21
+ | { screen: 'file'; path: string; ref: string; range: DiffRange; view?: 'blob' | 'detail'; line?: SourceLineTarget }
15
22
  | { screen: 'unknown'; reason: 'unknown-pathname' | 'missing-path'; rawPathname: string; rawSearch: string; range: DiffRange };
16
23
 
17
24
  export const SPA_PATHS = ['/todif', '/todiff', '/file'] as const;
@@ -31,6 +38,25 @@ function parseLegacyRange(value: string | null | undefined, fallback: DiffRange)
31
38
  };
32
39
  }
33
40
 
41
+ function parseLineTarget(value: string | null | undefined): SourceLineTarget | undefined {
42
+ const raw = value || '';
43
+ const range = /^(\d+)-(\d+)$/.exec(raw);
44
+ if (range) {
45
+ const a = Number(range[1]);
46
+ const b = Number(range[2]);
47
+ const start = Math.min(a, b);
48
+ const end = Math.max(a, b);
49
+ if (start > 0) return { start, end };
50
+ return undefined;
51
+ }
52
+ const line = Number(raw);
53
+ return Number.isInteger(line) && line > 0 ? line : undefined;
54
+ }
55
+
56
+ function formatLineTarget(line: SourceLineTarget): string {
57
+ return typeof line === 'number' ? String(line) : line.start + '-' + line.end;
58
+ }
59
+
34
60
  export function parseRoute(pathname: string, search: string, fallbackRange: DiffRange): AppRoute {
35
61
  const params = new URLSearchParams(search);
36
62
  const legacyRange = parseLegacyRange(params.get('range'), fallbackRange);
@@ -49,13 +75,19 @@ export function parseRoute(pathname: string, search: string, fallbackRange: Diff
49
75
  };
50
76
  case '/todif':
51
77
  case '/todiff':
52
- return { screen: 'diff', range };
78
+ return {
79
+ screen: 'diff',
80
+ range,
81
+ ...(params.get('path') ? { path: params.get('path') || '' } : {}),
82
+ ...(parseLineTarget(params.get('line')) ? { line: parseLineTarget(params.get('line')) } : {}),
83
+ };
53
84
  case '/file': {
54
85
  const path = params.get('path') || '';
55
86
  const target = params.get('target') || '';
56
87
  const ref = target || params.get('ref') || 'worktree';
88
+ const line = parseLineTarget(params.get('line'));
57
89
  if (!path) return { screen: 'unknown', reason: 'missing-path', rawPathname: pathname, rawSearch: search, range };
58
- return { screen: 'file', path, ref, range, view: target ? 'blob' : 'detail' };
90
+ return { screen: 'file', path, ref, range, view: target ? 'blob' : 'detail', ...(line ? { line } : {}) };
59
91
  }
60
92
  default:
61
93
  return { screen: 'unknown', reason: 'unknown-pathname', rawPathname: pathname, rawSearch: search, range };
@@ -74,15 +106,19 @@ export function buildRoute(route: AppRoute): string {
74
106
  case 'file':
75
107
  if (route.view === 'blob') {
76
108
  return '/file?path=' + encodeURIComponent(route.path) +
77
- '&target=' + encodeURIComponent(route.ref || 'worktree');
109
+ '&target=' + encodeURIComponent(route.ref || 'worktree') +
110
+ (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
78
111
  }
79
112
  return '/file?path=' + encodeURIComponent(route.path) +
80
113
  '&ref=' + encodeURIComponent(route.ref || 'worktree') +
81
114
  '&from=' + encodeURIComponent(route.range.from || '') +
82
- '&to=' + encodeURIComponent(route.range.to || 'worktree');
115
+ '&to=' + encodeURIComponent(route.range.to || 'worktree') +
116
+ (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
83
117
  case 'diff':
84
118
  return '/todif?from=' + encodeURIComponent(route.range.from || '') +
85
- '&to=' + encodeURIComponent(route.range.to || 'worktree');
119
+ '&to=' + encodeURIComponent(route.range.to || 'worktree') +
120
+ (route.path ? '&path=' + encodeURIComponent(route.path) : '') +
121
+ (route.line ? '&line=' + encodeURIComponent(formatLineTarget(route.line)) : '');
86
122
  case 'unknown':
87
123
  return '/todif?from=' + encodeURIComponent(route.range.from || '') +
88
124
  '&to=' + encodeURIComponent(route.range.to || 'worktree');
@@ -1,13 +1,23 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { closeSync, constants, existsSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
3
+ import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
4
4
  import { basename, dirname, extname, join, normalize, relative } from 'node:path';
5
5
  import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
6
- import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
6
+ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } from '../types';
7
7
  import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
8
8
  import { startDevAssetReload } from './dev-assets';
9
9
  import * as git from './git';
10
10
  import { isSameWorktreeRange } from './range';
11
+ import {
12
+ GREP_MAX_FILE_BYTES,
13
+ buildFileSearchList,
14
+ buildRgArgs,
15
+ fixedStringLineMatches,
16
+ isSkippableSearchPath,
17
+ normalizeGrepMax,
18
+ parseGitGrepOutput,
19
+ parseRgOutput,
20
+ } from './search';
11
21
 
12
22
  const ROOT = normalize(join(import.meta.dir, '..', '..'));
13
23
  const WEB_ROOT = join(ROOT, 'web');
@@ -35,11 +45,13 @@ let cliArgs = DEFAULT_ARGS;
35
45
  let listenPort = 0;
36
46
  let allowUpload = false;
37
47
  let uploadAllowedByCli = false;
48
+ let rgAvailableCache: boolean | null = null;
38
49
 
39
50
  const enc = new TextEncoder();
40
51
  const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
41
52
  const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
42
53
  const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
54
+ const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
43
55
 
44
56
  function parseCli() {
45
57
  const rest: string[] = [];
@@ -395,6 +407,98 @@ function handleTree(url: URL) {
395
407
  } satisfies RepoTreeResponse);
396
408
  }
397
409
 
410
+ function handleFiles(url: URL) {
411
+ const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
412
+ if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
413
+ const key = target || 'worktree';
414
+ const cached = fileListCache.get(key);
415
+ if (cached && cached.generation === generation) return json(cached.body);
416
+ const entries = git.listTree(key, '', cwd, { recursive: true }).entries;
417
+ const body = buildFileSearchList(key, generation, entries);
418
+ fileListCache.set(key, { generation, body });
419
+ return json(body);
420
+ }
421
+
422
+ function parseGrepPaths(url: URL): string[] {
423
+ return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path));
424
+ }
425
+
426
+ function rgAvailable(): boolean {
427
+ if (rgAvailableCache !== null) return rgAvailableCache;
428
+ const proc = Bun.spawnSync(['rg', '--version'], { cwd, stdout: 'pipe', stderr: 'pipe' });
429
+ rgAvailableCache = proc.exitCode === 0;
430
+ return rgAvailableCache;
431
+ }
432
+
433
+ function grepWorktreeFallback(query: string, max: number, paths: string[]): GrepMatch[] {
434
+ const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
435
+ const matches: GrepMatch[] = [];
436
+ for (const path of candidates) {
437
+ if (matches.length >= max) break;
438
+ if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path)) continue;
439
+ const full = safeWorktreePath(path);
440
+ if (!full) continue;
441
+ let stat;
442
+ try {
443
+ stat = lstatSync(full);
444
+ } catch {
445
+ continue;
446
+ }
447
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > GREP_MAX_FILE_BYTES) continue;
448
+ let data: Buffer;
449
+ try {
450
+ data = readFileSync(full);
451
+ } catch {
452
+ continue;
453
+ }
454
+ if (data.subarray(0, 8192).includes(0)) continue;
455
+ matches.push(...fixedStringLineMatches(path, data.toString('utf8'), query, max - matches.length));
456
+ }
457
+ return matches;
458
+ }
459
+
460
+ function grepWorktree(query: string, max: number, paths: string[], regex: boolean): GrepResponse {
461
+ if (rgAvailable()) {
462
+ const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && safeWorktreePath(path));
463
+ const args = buildRgArgs(query, max, safePaths, regex);
464
+ const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
465
+ const stdout = new TextDecoder().decode(proc.stdout);
466
+ const matches = parseRgOutput(stdout, max)
467
+ .filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !!safeWorktreePath(match.path));
468
+ return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
469
+ }
470
+ if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
471
+ const matches = grepWorktreeFallback(query, max, paths);
472
+ return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
473
+ }
474
+
475
+ function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean): GrepResponse {
476
+ const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path));
477
+ const args = [
478
+ 'git', '-c', 'core.quotepath=false', 'grep',
479
+ '-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
480
+ '-e', query,
481
+ ref, '--',
482
+ ...safePaths,
483
+ ];
484
+ const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
485
+ const stdout = new TextDecoder().decode(proc.stdout);
486
+ const matches = parseGitGrepOutput(stdout, ref, max).slice(0, max);
487
+ return { ref, engine: 'git', truncated: matches.length >= max, matches };
488
+ }
489
+
490
+ function handleGrep(url: URL) {
491
+ const query = url.searchParams.get('q') || '';
492
+ const ref = url.searchParams.get('ref') || 'worktree';
493
+ const max = normalizeGrepMax(url.searchParams.get('max'));
494
+ const paths = parseGrepPaths(url);
495
+ const regex = url.searchParams.get('regex') === '1';
496
+ if (!query.trim()) return json({ ref, engine: ref === 'worktree' ? 'fallback' : 'git', truncated: false, matches: [] } satisfies GrepResponse);
497
+ if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex));
498
+ if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
499
+ return json(grepTreeRef(ref, query, max, paths, regex));
500
+ }
501
+
398
502
  function handleFileDiff(url: URL) {
399
503
  const path = url.searchParams.get('path') || '';
400
504
  if (!safePath(path)) return text('invalid path', 400);
@@ -739,6 +843,8 @@ const server = Bun.serve({
739
843
  if (staticResponse) return staticResponse;
740
844
  if (url.pathname === '/diff.json') return handleDiffJson(url);
741
845
  if (url.pathname === '/_tree') return handleTree(url);
846
+ if (url.pathname === '/_files') return handleFiles(url);
847
+ if (url.pathname === '/_grep') return handleGrep(url);
742
848
  if (url.pathname === '/file_diff') return handleFileDiff(url);
743
849
  if (url.pathname === '/file_range') return handleFileRange(url);
744
850
  if (url.pathname === '/_file') return handleRawFile(req, url);
@@ -750,6 +856,7 @@ const server = Bun.serve({
750
856
  generation++;
751
857
  fileCache.clear();
752
858
  metaCache.clear();
859
+ fileListCache.clear();
753
860
  sendSse('update');
754
861
  return json({ ok: true, generation });
755
862
  }
@@ -0,0 +1,101 @@
1
+ import type { FileSearchListResponse, GrepMatch } from '../types';
2
+ import type { GitTreeEntry } from './git';
3
+
4
+ export const GREP_DEFAULT_MAX = 200;
5
+ export const GREP_ABSOLUTE_MAX = 500;
6
+ export const GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
7
+ export const FILE_SEARCH_ABSOLUTE_MAX = 50000;
8
+
9
+ export function normalizeGrepMax(value: string | null): number {
10
+ const parsed = Number(value || '');
11
+ if (!Number.isInteger(parsed) || parsed <= 0) return GREP_DEFAULT_MAX;
12
+ return Math.min(parsed, GREP_ABSOLUTE_MAX);
13
+ }
14
+
15
+ export function isSkippableSearchPath(path: string): boolean {
16
+ return path.split(/[\\/]+/).some(part => {
17
+ const lower = part.toLowerCase();
18
+ return lower === '.git' || lower === 'node_modules';
19
+ });
20
+ }
21
+
22
+ export function fixedStringLineMatches(path: string, text: string, query: string, max: number): GrepMatch[] {
23
+ const needle = query.toLowerCase();
24
+ if (!needle) return [];
25
+ const matches: GrepMatch[] = [];
26
+ const lines = text.split('\n');
27
+ for (let i = 0; i < lines.length && matches.length < max; i++) {
28
+ const line = lines[i];
29
+ const column = line.toLowerCase().indexOf(needle);
30
+ if (column < 0) continue;
31
+ matches.push({
32
+ path,
33
+ line: i + 1,
34
+ column: column + 1,
35
+ preview: line.slice(0, 500),
36
+ });
37
+ }
38
+ return matches;
39
+ }
40
+
41
+ export function buildFileSearchList(ref: string, generation: number, entries: GitTreeEntry[]): FileSearchListResponse {
42
+ const files = entries
43
+ .filter((entry): entry is GitTreeEntry & { type: 'blob' | 'commit' } => entry.type === 'blob' || entry.type === 'commit')
44
+ .slice(0, FILE_SEARCH_ABSOLUTE_MAX)
45
+ .map(entry => ({ path: entry.path, type: entry.type }));
46
+ return {
47
+ ref,
48
+ generation,
49
+ files,
50
+ truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX,
51
+ };
52
+ }
53
+
54
+ export function buildRgArgs(query: string, max: number, paths: string[], regex = false): string[] {
55
+ const safePaths = paths.length ? paths : ['.'];
56
+ const args = [
57
+ 'rg',
58
+ '--no-config',
59
+ '--line-number',
60
+ '--column',
61
+ '--no-heading',
62
+ '--color',
63
+ 'never',
64
+ '--smart-case',
65
+ '--max-count',
66
+ String(max),
67
+ '--max-filesize',
68
+ '2M',
69
+ '-e',
70
+ query,
71
+ '--',
72
+ ...safePaths,
73
+ ];
74
+ if (!regex) args.splice(8, 0, '--fixed-strings');
75
+ return args;
76
+ }
77
+
78
+ export function parseRgOutput(stdout: string, max: number): GrepMatch[] {
79
+ const matches: GrepMatch[] = [];
80
+ for (const line of stdout.split('\n')) {
81
+ if (!line || matches.length >= max) continue;
82
+ const parsed = /^(.*):(\d+):(\d+):(.*)$/.exec(line);
83
+ if (!parsed) continue;
84
+ const path = parsed[1];
85
+ const lineNo = Number(parsed[2]);
86
+ const column = Number(parsed[3]);
87
+ const preview = parsed[4];
88
+ if (!path || !lineNo || !column || isSkippableSearchPath(path)) continue;
89
+ matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
90
+ }
91
+ return matches;
92
+ }
93
+
94
+ export function parseGitGrepOutput(stdout: string, ref: string, max: number): GrepMatch[] {
95
+ const prefix = ref + ':';
96
+ const normalized = stdout
97
+ .split('\n')
98
+ .map(line => line.startsWith(prefix) ? line.slice(prefix.length) : line)
99
+ .join('\n');
100
+ return parseRgOutput(normalized, max);
101
+ }
package/web-src/types.ts CHANGED
@@ -53,6 +53,30 @@ export type RepoTreeResponse = {
53
53
  } | null;
54
54
  };
55
55
 
56
+ export type FileSearchListResponse = {
57
+ ref: string;
58
+ generation: number;
59
+ files: {
60
+ path: string;
61
+ type: 'blob' | 'commit';
62
+ }[];
63
+ truncated: boolean;
64
+ };
65
+
66
+ export type GrepMatch = {
67
+ path: string;
68
+ line: number;
69
+ column: number;
70
+ preview: string;
71
+ };
72
+
73
+ export type GrepResponse = {
74
+ ref: string;
75
+ engine: 'rg' | 'git' | 'fallback';
76
+ truncated: boolean;
77
+ matches: GrepMatch[];
78
+ };
79
+
56
80
  export type FileDiffResponse = {
57
81
  path: string;
58
82
  old_path?: string;