@youtyan/code-viewer 0.1.10 → 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/shiki.js CHANGED
@@ -13178,5 +13178,6 @@ var createHighlighter = /* @__PURE__ */ createBundledHighlighter({
13178
13178
  engine: () => (0, engine_oniguruma_exports.createOnigurumaEngine)(Promise.resolve().then(() => (init_wasm2(), exports_wasm2)))
13179
13179
  });
13180
13180
  export {
13181
- createHighlighter
13181
+ createHighlighter,
13182
+ bundledLanguages
13182
13183
  };
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,19 @@ 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
+ }
1844
+ .gdp-source-line-code.shiki,
1845
+ .gdp-source-line-code.shiki span {
1846
+ color: var(--shiki-light) !important;
1847
+ }
1848
+ [data-theme="dark"] .gdp-source-line-code.shiki,
1849
+ [data-theme="dark"] .gdp-source-line-code.shiki span {
1850
+ color: var(--shiki-dark) !important;
1851
+ }
1663
1852
  .gdp-source-virtual {
1664
1853
  display: grid;
1665
1854
  grid-template-rows: auto minmax(0, 1fr);
@@ -1725,6 +1914,8 @@ table.d2h-diff-table .d2h-code-line-prefix {
1725
1914
  font-family: "Monaspace Neon", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
1726
1915
  font-size: 12px;
1727
1916
  line-height: 20px;
1917
+ cursor: pointer;
1918
+ user-select: none;
1728
1919
  }
1729
1920
  .gdp-source-virtual-spacer {
1730
1921
  position: relative;
@@ -1752,6 +1943,7 @@ table.d2h-diff-table .d2h-code-line-prefix {
1752
1943
  background: var(--bg);
1753
1944
  box-shadow: inset -1px 0 0 var(--border-muted);
1754
1945
  font-variant-numeric: tabular-nums;
1946
+ cursor: pointer;
1755
1947
  user-select: none;
1756
1948
  }
1757
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,6 +1,10 @@
1
+ import { lstatSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
1
4
  // Short enough that a browser reload self-heals stale git data, while still
2
5
  // coalescing bursts from one render pass.
3
6
  export const CACHE_TTL_MS = 1500;
7
+ export const MAX_TIMED_CACHE_ENTRIES = 200;
4
8
 
5
9
  export type TimedCacheEntry<T> = T & { storedAt: number };
6
10
 
@@ -11,3 +15,50 @@ export function cacheFresh<T>(
11
15
  ): cached is TimedCacheEntry<T> {
12
16
  return !!cached && now - cached.storedAt <= ttlMs;
13
17
  }
18
+
19
+ export function setTimedCacheEntry<T>(
20
+ cache: Map<string, TimedCacheEntry<T>>,
21
+ key: string,
22
+ value: T,
23
+ now = Date.now(),
24
+ maxEntries = MAX_TIMED_CACHE_ENTRIES,
25
+ ): void {
26
+ cache.set(key, { ...value, storedAt: now });
27
+ while (cache.size > maxEntries) {
28
+ const oldest = cache.keys().next().value;
29
+ if (oldest === undefined) break;
30
+ cache.delete(oldest);
31
+ }
32
+ }
33
+
34
+ export function worktreeFileSignature(path: string, cwd: string): string {
35
+ try {
36
+ const stats = lstatSync(join(cwd, path));
37
+ const inode = 'ino' in stats ? stats.ino : 0;
38
+ return `state:file|size:${stats.size}|mtime:${stats.mtimeMs}|ctime:${stats.ctimeMs}|ino:${inode}`;
39
+ } catch {
40
+ return 'state:missing';
41
+ }
42
+ }
43
+
44
+ export function fileDiffCacheKey(options: {
45
+ path: string;
46
+ oldPath?: string | null;
47
+ isUntracked: boolean;
48
+ range: { from?: string; to?: string };
49
+ extras: string[];
50
+ args: string[];
51
+ cwd: string;
52
+ }): string {
53
+ const worktreeTarget = options.range.from === 'worktree' || !options.range.to || options.range.to === 'worktree';
54
+ if (options.isUntracked && !worktreeTarget) {
55
+ throw new Error('untracked file diffs require a worktree range');
56
+ }
57
+ const signature = worktreeTarget
58
+ ? `\0${worktreeFileSignature(options.path, options.cwd)}`
59
+ : '';
60
+ if (options.isUntracked) {
61
+ return `u\0${options.path}${signature}\0${options.extras.join('\0')}`;
62
+ }
63
+ return `t\0${options.path}\0${options.oldPath || ''}${signature}\0${[...options.extras, ...options.args].join('\0')}`;
64
+ }
@@ -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';
7
- import { cacheFresh, type TimedCacheEntry } from './cache';
6
+ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse } from '../types';
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[] = [];
@@ -273,14 +285,14 @@ function handleDiffJson(url: URL) {
273
285
  fileCache.clear();
274
286
  }
275
287
  const body = JSON.stringify(payload);
276
- metaCache.set(key, { body, sig, storedAt: Date.now() });
288
+ setTimedCacheEntry(metaCache, key, { body, sig });
277
289
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
278
290
  }
279
291
  const cached = metaCache.get(key);
280
292
  if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
281
293
  const payload = computePayload(extras, range);
282
294
  const body = JSON.stringify(payload);
283
- metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }), storedAt: Date.now() });
295
+ setTimedCacheEntry(metaCache, key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
284
296
  return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
285
297
  }
286
298
 
@@ -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);
@@ -420,9 +524,12 @@ function handleFileDiff(url: URL) {
420
524
  }
421
525
  const { args } = buildRangeArgs(range);
422
526
  const oldPath = url.searchParams.get('old_path');
423
- const cacheKey = isUntracked
424
- ? `u\0${path}\0${extras.join('\0')}`
425
- : `t\0${path}\0${oldPath || ''}\0${[...extras, ...args].join('\0')}`;
527
+ let cacheKey: string;
528
+ try {
529
+ cacheKey = fileDiffCacheKey({ path, oldPath, isUntracked, range, extras, args, cwd });
530
+ } catch {
531
+ return text('invalid diff range', 400);
532
+ }
426
533
  const cached = fileCache.get(cacheKey);
427
534
  let diffText: string;
428
535
  let errText = '';
@@ -436,7 +543,7 @@ function handleFileDiff(url: URL) {
436
543
  diffText = res.stdout || '';
437
544
  if (res.code !== 0) errText = res.stderr;
438
545
  }
439
- fileCache.set(cacheKey, { diffText, storedAt: Date.now() });
546
+ setTimedCacheEntry(fileCache, cacheKey, { diffText });
440
547
  }
441
548
  const mode = url.searchParams.get('mode') || 'full';
442
549
  const truncated = mode === 'preview'
@@ -736,6 +843,8 @@ const server = Bun.serve({
736
843
  if (staticResponse) return staticResponse;
737
844
  if (url.pathname === '/diff.json') return handleDiffJson(url);
738
845
  if (url.pathname === '/_tree') return handleTree(url);
846
+ if (url.pathname === '/_files') return handleFiles(url);
847
+ if (url.pathname === '/_grep') return handleGrep(url);
739
848
  if (url.pathname === '/file_diff') return handleFileDiff(url);
740
849
  if (url.pathname === '/file_range') return handleFileRange(url);
741
850
  if (url.pathname === '/_file') return handleRawFile(req, url);
@@ -747,6 +856,7 @@ const server = Bun.serve({
747
856
  generation++;
748
857
  fileCache.clear();
749
858
  metaCache.clear();
859
+ fileListCache.clear();
750
860
  sendSse('update');
751
861
  return json({ ok: true, generation });
752
862
  }