@youtyan/code-viewer 0.13.1 → 0.15.0

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.
@@ -128,6 +128,79 @@ var init_id = __esm({
128
128
  }
129
129
  });
130
130
 
131
+ // web-src/core/history.ts
132
+ function parseHistoryLineRange(value) {
133
+ const match = /^(\d+)-(\d+)$/.exec(value || "");
134
+ if (!match) return void 0;
135
+ const a = Number(match[1]);
136
+ const b = Number(match[2]);
137
+ if (!(a > 0) || !(b > 0)) return void 0;
138
+ return { start: Math.min(a, b), end: Math.max(a, b) };
139
+ }
140
+ function formatHistoryLineRange(range) {
141
+ return `${range.start}-${range.end}`;
142
+ }
143
+ function tokenizeHistoryQuery(raw) {
144
+ const tokens = [];
145
+ const known = new Set(HISTORY_QUERY_PREFIXES);
146
+ for (const match of raw.trim().matchAll(/(?:([A-Za-z-]+):)?("([^"]*)"|(\S+))/g)) {
147
+ const prefix = (match[1] || "").toLowerCase();
148
+ const value = match[3] !== void 0 ? match[3] : match[4] ?? "";
149
+ if (!prefix) {
150
+ if (value === "no-merges") {
151
+ tokens.push({ kind: "merges", value: "no" });
152
+ continue;
153
+ }
154
+ if (value.endsWith(":") && known.has(value.slice(0, -1).toLowerCase()))
155
+ continue;
156
+ if (value) tokens.push({ kind: "text", value });
157
+ continue;
158
+ }
159
+ if (!known.has(prefix)) {
160
+ tokens.push({ kind: "text", value: `${match[1]}:${value}` });
161
+ continue;
162
+ }
163
+ if (!value) continue;
164
+ switch (prefix) {
165
+ case "author":
166
+ case "path":
167
+ case "code":
168
+ tokens.push({ kind: prefix, value });
169
+ break;
170
+ case "since":
171
+ case "after":
172
+ tokens.push({ kind: "since", value });
173
+ break;
174
+ case "until":
175
+ case "before":
176
+ tokens.push({ kind: "until", value });
177
+ break;
178
+ case "merges":
179
+ if (value === "no" || value === "only")
180
+ tokens.push({ kind: "merges", value });
181
+ else tokens.push({ kind: "text", value: `merges:${value}` });
182
+ break;
183
+ }
184
+ }
185
+ return tokens;
186
+ }
187
+ var HISTORY_QUERY_PREFIXES, DAY_MS;
188
+ var init_history = __esm({
189
+ "web-src/core/history.ts"() {
190
+ HISTORY_QUERY_PREFIXES = [
191
+ "author",
192
+ "path",
193
+ "since",
194
+ "after",
195
+ "until",
196
+ "before",
197
+ "code",
198
+ "merges"
199
+ ];
200
+ DAY_MS = 24 * 60 * 60 * 1e3;
201
+ }
202
+ });
203
+
131
204
  // web-src/core/shell.ts
132
205
  function isShellSessionId(value) {
133
206
  return typeof value === "string" && /^shell-[0-9a-z]{6,}$/.test(value);
@@ -201,14 +274,14 @@ function buildRoute(route) {
201
274
  }
202
275
  case "file":
203
276
  if (route.view === "blob") {
204
- return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(route.ref || "worktree") + "&view=blob" + (route.preview ? "&preview=1" : "") + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "") + (route.virtual === "off" ? "&virtual=off" : "");
277
+ return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(route.ref || "worktree") + "&view=blob" + (route.preview ? "&preview=1" : "") + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "") + (route.line && route.hl ? `&hl=${encodeURIComponent(route.hl)}` : "") + (route.virtual === "off" ? "&virtual=off" : "");
205
278
  }
206
279
  if (route.view === "blame") {
207
280
  const ref = route.ref || "worktree";
208
281
  return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(ref) + "&view=blame" + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "");
209
282
  }
210
283
  if (route.view === "history") {
211
- return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(route.ref || "worktree") + "&view=history" + (route.commit ? `&commit=${encodeURIComponent(route.commit)}` : "") + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "");
284
+ return "/file?path=" + encodeURIComponent(route.path) + "&target=" + encodeURIComponent(route.ref || "worktree") + "&view=history" + (route.commit ? `&commit=${encodeURIComponent(route.commit)}` : "") + (route.compare ? `&compare=${encodeURIComponent(route.compare)}` : "") + (route.q ? `&q=${encodeURIComponent(route.q)}` : "") + (route.lines ? `&lines=${encodeURIComponent(formatHistoryLineRange(route.lines))}` : "") + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "");
212
285
  }
213
286
  return "/file?path=" + encodeURIComponent(route.path) + "&ref=" + encodeURIComponent(route.ref || "worktree") + "&from=" + encodeURIComponent(route.range.from || "") + "&to=" + encodeURIComponent(route.range.to || "worktree") + (route.line ? `&line=${encodeURIComponent(formatLineTarget(route.line))}` : "") + (route.virtual === "off" ? "&virtual=off" : "");
214
287
  case "diff":
@@ -232,7 +305,12 @@ function buildRoute(route) {
232
305
  case "history": {
233
306
  const params = new URLSearchParams();
234
307
  if (route.ref && route.ref !== "HEAD") params.set("ref", route.ref);
308
+ if (route.path) params.set("path", route.path);
309
+ if (route.path && route.lines)
310
+ params.set("lines", formatHistoryLineRange(route.lines));
235
311
  if (route.commit) params.set("commit", route.commit);
312
+ if (route.compare) params.set("compare", route.compare);
313
+ if (route.q) params.set("q", route.q);
236
314
  const qs = params.toString();
237
315
  return `/history${qs ? `?${qs}` : ""}`;
238
316
  }
@@ -265,6 +343,7 @@ function buildRoute(route) {
265
343
  var SPA_PATHS, APP_ENTRY_PATHS;
266
344
  var init_routes = __esm({
267
345
  "web-src/core/routes.ts"() {
346
+ init_history();
268
347
  init_shell();
269
348
  init_tools();
270
349
  SPA_PATHS = [
@@ -1667,8 +1746,17 @@ async function readFileTextRange(path, start, endExclusive) {
1667
1746
  }
1668
1747
  function startServer(options) {
1669
1748
  const server2 = createServer(async (req, res) => {
1749
+ const abort = new AbortController();
1750
+ res.on("close", () => {
1751
+ if (!res.writableFinished) abort.abort();
1752
+ });
1670
1753
  try {
1671
- const request = nodeRequestToWeb(req, options.hostname, server2.address());
1754
+ const request = nodeRequestToWeb(
1755
+ req,
1756
+ options.hostname,
1757
+ server2.address(),
1758
+ abort.signal
1759
+ );
1672
1760
  const response = await options.fetch(request);
1673
1761
  await writeWebResponse(res, response);
1674
1762
  } catch (error) {
@@ -1719,7 +1807,7 @@ function startServer(options) {
1719
1807
  });
1720
1808
  });
1721
1809
  }
1722
- function nodeRequestToWeb(req, hostname, address) {
1810
+ function nodeRequestToWeb(req, hostname, address, signal) {
1723
1811
  const port = typeof address === "object" && address ? address.port : 0;
1724
1812
  const host = req.headers.host || `${hostname}:${port}`;
1725
1813
  const url = new URL(req.url || "/", `http://${host}`);
@@ -1737,7 +1825,8 @@ function nodeRequestToWeb(req, hostname, address) {
1737
1825
  method,
1738
1826
  headers,
1739
1827
  body: hasBody ? Readable.toWeb(req) : void 0,
1740
- duplex: hasBody ? "half" : void 0
1828
+ duplex: hasBody ? "half" : void 0,
1829
+ signal
1741
1830
  });
1742
1831
  }
1743
1832
  async function writeWebResponse(res, response) {
@@ -2383,6 +2472,31 @@ async function remoteWebUrlAsync(cwd2) {
2383
2472
  if (res.code !== 0) return null;
2384
2473
  return parseRemoteWebUrl(res.stdout.trim());
2385
2474
  }
2475
+ function parseHistoryDecorations(raw) {
2476
+ const refs = [];
2477
+ for (const part of raw.split(",")) {
2478
+ const item = part.trim();
2479
+ if (!item) continue;
2480
+ if (item === "HEAD") {
2481
+ refs.push({ name: "HEAD", kind: "head" });
2482
+ continue;
2483
+ }
2484
+ if (item.startsWith("HEAD -> ")) {
2485
+ refs.push({
2486
+ name: item.slice("HEAD -> ".length),
2487
+ kind: "branch",
2488
+ head: true
2489
+ });
2490
+ continue;
2491
+ }
2492
+ if (item.startsWith("tag: ")) {
2493
+ refs.push({ name: item.slice("tag: ".length), kind: "tag" });
2494
+ continue;
2495
+ }
2496
+ refs.push({ name: item, kind: "branch" });
2497
+ }
2498
+ return refs;
2499
+ }
2386
2500
  function parseHistoryLog(stdout) {
2387
2501
  const parts = stdout.split("\0");
2388
2502
  const commits = [];
@@ -2396,6 +2510,7 @@ function parseHistoryLog(stdout) {
2396
2510
  const author = parts[index++] || "";
2397
2511
  const when = parts[index++] || "";
2398
2512
  const parentsRaw = (parts[index++] || "").trim();
2513
+ const decorations = (parts[index++] || "").trim();
2399
2514
  const body = (parts[index++] || "").trim();
2400
2515
  if (sha)
2401
2516
  commits.push({
@@ -2404,7 +2519,8 @@ function parseHistoryLog(stdout) {
2404
2519
  author,
2405
2520
  when,
2406
2521
  parents: parentsRaw ? parentsRaw.split(/\s+/) : [],
2407
- body
2522
+ body,
2523
+ refs: parseHistoryDecorations(decorations)
2408
2524
  });
2409
2525
  }
2410
2526
  return commits;
@@ -2412,37 +2528,125 @@ function parseHistoryLog(stdout) {
2412
2528
  function historyQueryArgs(query) {
2413
2529
  const trimmed = query.trim().slice(0, 200).replace(/\0/g, "");
2414
2530
  if (!trimmed) return { filterArgs: [], pathspec: [], shaTerm: "" };
2415
- const prefixed = /^(author|path):(.*)$/.exec(trimmed);
2416
- if (prefixed) {
2417
- const term = prefixed[2].trim();
2418
- if (!term) return { filterArgs: [], pathspec: [], shaTerm: "" };
2419
- if (prefixed[1] === "author") {
2420
- return {
2421
- filterArgs: [
2422
- "--regexp-ignore-case",
2423
- "--fixed-strings",
2424
- `--author=${term}`
2425
- ],
2426
- pathspec: [],
2427
- shaTerm: ""
2428
- };
2531
+ const filterArgs = [];
2532
+ const pathspec = [];
2533
+ const texts = [];
2534
+ let needsTextFlags = false;
2535
+ for (const token of tokenizeHistoryQuery(trimmed)) {
2536
+ switch (token.kind) {
2537
+ case "text":
2538
+ texts.push(token.value);
2539
+ break;
2540
+ case "author":
2541
+ needsTextFlags = true;
2542
+ filterArgs.push(`--author=${token.value}`);
2543
+ break;
2544
+ case "path":
2545
+ pathspec.push(`:(icase)*${token.value}*`);
2546
+ break;
2547
+ case "since":
2548
+ filterArgs.push(`--since=${token.value}`);
2549
+ break;
2550
+ case "until":
2551
+ filterArgs.push(`--until=${token.value}`);
2552
+ break;
2553
+ case "code":
2554
+ filterArgs.push(`-S${token.value}`);
2555
+ break;
2556
+ case "merges":
2557
+ filterArgs.push(token.value === "no" ? "--no-merges" : "--merges");
2558
+ break;
2429
2559
  }
2430
- return {
2431
- filterArgs: [],
2432
- pathspec: ["--", `:(icase)*${term}*`],
2433
- shaTerm: ""
2434
- };
2435
2560
  }
2561
+ const phrase = texts.join(" ");
2562
+ if (phrase) {
2563
+ needsTextFlags = true;
2564
+ filterArgs.push(`--grep=${phrase}`);
2565
+ }
2566
+ if (needsTextFlags)
2567
+ filterArgs.unshift("--regexp-ignore-case", "--fixed-strings");
2436
2568
  return {
2437
- filterArgs: [
2438
- "--regexp-ignore-case",
2439
- "--fixed-strings",
2440
- `--grep=${trimmed}`
2441
- ],
2442
- pathspec: [],
2443
- shaTerm: /^[0-9a-f]{4,40}$/i.test(trimmed) ? trimmed : ""
2569
+ filterArgs,
2570
+ pathspec,
2571
+ shaTerm: texts.length === 1 && /^[0-9a-f]{4,40}$/i.test(texts[0]) ? texts[0] : ""
2444
2572
  };
2445
2573
  }
2574
+ async function fileRevisionNeighborsAsync(cwd2, options) {
2575
+ const path = options.path.trim();
2576
+ if (!path || path.includes("\0") || path.startsWith("-"))
2577
+ return { previous: null, next: null, error: "invalid path" };
2578
+ const rawRef = (options.ref || "HEAD").trim();
2579
+ const ref = rawRef === "worktree" ? "HEAD" : rawRef;
2580
+ if (!ref || ref.startsWith("-") || ref.includes("\0"))
2581
+ return { previous: null, next: null, error: "invalid ref" };
2582
+ const verified = await runGitAsync(
2583
+ ["git", "rev-parse", "--verify", `${ref}^{commit}`],
2584
+ cwd2
2585
+ );
2586
+ if (verified.code !== 0)
2587
+ return {
2588
+ previous: null,
2589
+ next: null,
2590
+ ...gitFailureResult(verified, "unknown ref")
2591
+ };
2592
+ const sha = verified.stdout.trim();
2593
+ const older = await runGitAsync(
2594
+ ["git", "log", "--format=%H", "--max-count=2", sha, "--", path],
2595
+ cwd2
2596
+ );
2597
+ if (older.code !== 0)
2598
+ return {
2599
+ previous: null,
2600
+ next: null,
2601
+ ...gitFailureResult(older, "git log failed")
2602
+ };
2603
+ const olderShas = older.stdout.split("\n").filter(Boolean);
2604
+ const previous = olderShas[0] && olderShas[0] !== sha ? olderShas[0] : olderShas[1] ?? null;
2605
+ const newer = await runGitAsync(
2606
+ ["git", "log", "--format=%H", `${sha}..HEAD`, "--", path],
2607
+ cwd2
2608
+ );
2609
+ if (newer.code !== 0)
2610
+ return {
2611
+ previous: null,
2612
+ next: null,
2613
+ ...gitFailureResult(newer, "git log failed")
2614
+ };
2615
+ const newerShas = newer.stdout.split("\n").filter(Boolean);
2616
+ const next = newerShas.length ? newerShas[newerShas.length - 1] : null;
2617
+ return { previous, next };
2618
+ }
2619
+ async function commitAuthorsAsync(cwd2, ref) {
2620
+ const target = (ref || "HEAD").trim();
2621
+ if (!target || target.startsWith("-") || target.includes("\0"))
2622
+ return { authors: [], error: "invalid ref" };
2623
+ const verified = await runGitAsync(
2624
+ ["git", "rev-parse", "--verify", `${target}^{commit}`],
2625
+ cwd2
2626
+ );
2627
+ if (verified.code !== 0)
2628
+ return { authors: [], ...gitFailureResult(verified, "unknown ref") };
2629
+ const res = await runGitAsync(
2630
+ [
2631
+ "git",
2632
+ "log",
2633
+ "--format=%an",
2634
+ `--max-count=${MAX_HISTORY_AUTHOR_SCAN}`,
2635
+ verified.stdout.trim()
2636
+ ],
2637
+ cwd2
2638
+ );
2639
+ if (res.code !== 0)
2640
+ return { authors: [], ...gitFailureResult(res, "git log failed") };
2641
+ const counts = /* @__PURE__ */ new Map();
2642
+ for (const line of res.stdout.split("\n")) {
2643
+ const name = line.trim();
2644
+ if (!name) continue;
2645
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2646
+ }
2647
+ const authors = [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
2648
+ return { authors };
2649
+ }
2446
2650
  async function commitHistoryAsync(cwd2, options) {
2447
2651
  const ref = (options.ref || "HEAD").trim();
2448
2652
  if (!ref || ref.startsWith("-") || ref.includes("\0"))
@@ -2466,10 +2670,13 @@ async function commitHistoryAsync(cwd2, options) {
2466
2670
  options.query || ""
2467
2671
  );
2468
2672
  const pathFilter = (options.path || "").trim();
2469
- const pathArgs = [];
2470
- if (pathFilter && !pathFilter.includes("\0") && !pathFilter.startsWith("-")) {
2471
- if (!pathFilter.endsWith("/")) pathArgs.push("--follow");
2472
- pathArgs.push("--", pathFilter);
2673
+ const safePathFilter = pathFilter && !pathFilter.includes("\0") && !pathFilter.startsWith("-") ? pathFilter : "";
2674
+ const lineRange = options.lines && safePathFilter && !safePathFilter.endsWith("/") ? options.lines : void 0;
2675
+ const specs = lineRange ? [] : [...pathspec];
2676
+ let follow = false;
2677
+ if (safePathFilter && !lineRange) {
2678
+ specs.push(safePathFilter);
2679
+ follow = !safePathFilter.endsWith("/") && specs.length === 1;
2473
2680
  }
2474
2681
  const res = await runGitAsync(
2475
2682
  [
@@ -2480,9 +2687,12 @@ async function commitHistoryAsync(cwd2, options) {
2480
2687
  `--max-count=${limit + 1}`,
2481
2688
  `--format=${HISTORY_FORMAT}`,
2482
2689
  ...filterArgs,
2690
+ ...follow ? ["--follow"] : [],
2691
+ // -L prints patches by default; -s keeps the output to the headers
2692
+ // our parser expects.
2693
+ ...lineRange ? [`-L${lineRange.start},${lineRange.end}:${safePathFilter}`, "-s"] : [],
2483
2694
  verified.stdout.trim(),
2484
- ...pathspec,
2485
- ...pathArgs
2695
+ ...specs.length ? ["--", ...specs] : []
2486
2696
  ],
2487
2697
  cwd2
2488
2698
  );
@@ -3258,10 +3468,11 @@ function truncateToNHunks(diffText, n, maxLines = Number.POSITIVE_INFINITY) {
3258
3468
  lineTruncated
3259
3469
  };
3260
3470
  }
3261
- var BLAME_ZERO_SHA, WORKTREE_RECURSIVE_DEPTH_LIMIT, WORKTREE_RECURSIVE_ENTRY_LIMIT, DEFAULT_REF_COMMIT_LIMIT, MAX_REF_COMMIT_LIMIT, COMMIT_FORMAT, ALWAYS_WORKTREE_OMIT_DIR_NAMES, DEFAULT_WORKTREE_OMIT_DIR_NAMES, GIT_COMMAND_TIMEOUT_MS, repoStatusMapCache, STATUS_PORCELAIN_ARGS, HISTORY_FORMAT, MAX_HISTORY_LIMIT, LS_TREE_SYMLINK_MODE, untrackedScanCache, UNTRACKED_SCAN_CONCURRENCY;
3471
+ var BLAME_ZERO_SHA, WORKTREE_RECURSIVE_DEPTH_LIMIT, WORKTREE_RECURSIVE_ENTRY_LIMIT, DEFAULT_REF_COMMIT_LIMIT, MAX_REF_COMMIT_LIMIT, COMMIT_FORMAT, ALWAYS_WORKTREE_OMIT_DIR_NAMES, DEFAULT_WORKTREE_OMIT_DIR_NAMES, GIT_COMMAND_TIMEOUT_MS, repoStatusMapCache, STATUS_PORCELAIN_ARGS, HISTORY_FORMAT, MAX_HISTORY_LIMIT, MAX_HISTORY_AUTHOR_SCAN, LS_TREE_SYMLINK_MODE, untrackedScanCache, UNTRACKED_SCAN_CONCURRENCY;
3262
3472
  var init_git = __esm({
3263
3473
  "web-src/server/git.ts"() {
3264
3474
  init_error_detail();
3475
+ init_history();
3265
3476
  init_worktree();
3266
3477
  init_cache();
3267
3478
  init_command_resolver();
@@ -3333,8 +3544,9 @@ var init_git = __esm({
3333
3544
  // would make git enumerate every file under it on each poll.
3334
3545
  "--untracked-files=normal"
3335
3546
  ];
3336
- HISTORY_FORMAT = "%H%x00%s%x00%an%x00%aI%x00%P%x00%b";
3547
+ HISTORY_FORMAT = "%H%x00%s%x00%an%x00%aI%x00%P%x00%D%x00%b";
3337
3548
  MAX_HISTORY_LIMIT = 200;
3549
+ MAX_HISTORY_AUTHOR_SCAN = 5e3;
3338
3550
  LS_TREE_SYMLINK_MODE = "120000";
3339
3551
  untrackedScanCache = /* @__PURE__ */ new Map();
3340
3552
  UNTRACKED_SCAN_CONCURRENCY = 8;
@@ -5305,8 +5517,12 @@ blame options:
5305
5517
  history options:
5306
5518
  --limit <n> 1..${FILE_HISTORY_HARD_CAP} (default: ${FILE_DEFAULT_HISTORY_LIMIT}).
5307
5519
  --skip <n> Non-negative. Used to paginate.
5308
- --query <text> git log filter passed verbatim to git.commitHistory
5309
- (supports the "author:" / "path:" prefixes the browser uses).
5520
+ --query <text> git log filter, same syntax as the browser history filter:
5521
+ free words match the message ("quoted" keeps spaces),
5522
+ author:<name>, path:<part>, since:/after:<date>,
5523
+ until:/before:<date>, code:<text> (git log -S),
5524
+ merges:no (or no-merges), merges:only. Kinds AND together;
5525
+ a lone 4-40 hex word also tries a sha prefix.
5310
5526
 
5311
5527
  show options:
5312
5528
  --start <line> 1-indexed start line (inclusive). Requires --end.
@@ -5370,6 +5586,7 @@ Diff Viewer views.
5370
5586
  code-viewer file blame --path src/sample.ts --base HEAD --json
5371
5587
  code-viewer file history --path src/sample.ts --limit 10 --json
5372
5588
  code-viewer file history --path src/sample.ts --query "author:tester" --json
5589
+ code-viewer file history --path src/sample.ts --query "since:2024-01-01 code:handler merges:no" --json
5373
5590
  code-viewer file show --path src/sample.ts --json
5374
5591
  code-viewer file show --path src/sample.ts --start 100 --end 150 --json
5375
5592
  code-viewer file show --path src/sample.ts --ref main --json
@@ -9289,21 +9506,25 @@ function computeFuzzyMatch(query, path) {
9289
9506
  tier
9290
9507
  };
9291
9508
  }
9292
- function rankFuzzyPaths(query, items, limit) {
9509
+ function rankFuzzyPaths(query, items, limit, stats) {
9293
9510
  const bounded = Number.isInteger(limit) && limit !== void 0 && limit > 0 ? Math.floor(limit) : 0;
9294
9511
  const compare = (a, b) => b.tier - a.tier || b.score - a.score || a.item.path.localeCompare(b.item.path);
9295
9512
  if (!bounded) {
9296
- return items.map((item) => {
9513
+ const ranked = items.map((item) => {
9297
9514
  const match = computeFuzzyMatch(query, item.path);
9298
9515
  return match ? { item, score: match.score, ranges: match.ranges, tier: match.tier } : null;
9299
9516
  }).filter(
9300
9517
  (item) => item !== null
9301
9518
  ).sort(compare).map(({ item, score, ranges }) => ({ item, score, ranges }));
9519
+ if (stats) stats.total = ranked.length;
9520
+ return ranked;
9302
9521
  }
9303
9522
  const top = [];
9523
+ let total = 0;
9304
9524
  for (const item of items) {
9305
9525
  const match = computeFuzzyMatch(query, item.path);
9306
9526
  if (!match) continue;
9527
+ total++;
9307
9528
  const ranked = {
9308
9529
  item,
9309
9530
  score: match.score,
@@ -9312,6 +9533,7 @@ function rankFuzzyPaths(query, items, limit) {
9312
9533
  };
9313
9534
  pushBoundedTop(top, ranked, bounded, compare);
9314
9535
  }
9536
+ if (stats) stats.total = total;
9315
9537
  return top.sort(compare).map(({ item, score, ranges }) => ({ item, score, ranges }));
9316
9538
  }
9317
9539
  function pushBoundedTop(heap, item, limit, compareBestFirst) {
@@ -9346,13 +9568,16 @@ function pushBoundedTop(heap, item, limit, compareBestFirst) {
9346
9568
  heap[0] = item;
9347
9569
  siftDown(0);
9348
9570
  }
9349
- function rankGlobPathMatches(query, items, limit) {
9571
+ function rankGlobPathMatches(query, items, limit, stats) {
9350
9572
  const matchPath = createGlobPathMatcher(query);
9351
- if (!matchPath) return [];
9573
+ if (!matchPath) {
9574
+ if (stats) stats.total = 0;
9575
+ return [];
9576
+ }
9352
9577
  const bounded = Number.isInteger(limit) && limit !== void 0 && limit > 0 ? Math.floor(limit) : 0;
9353
9578
  const compare = (a, b) => b.score - a.score || a.item.path.localeCompare(b.item.path);
9354
9579
  if (!bounded) {
9355
- return items.map((item) => {
9580
+ const ranked = items.map((item) => {
9356
9581
  const match = matchPath(item.path);
9357
9582
  return match ? {
9358
9583
  item,
@@ -9361,11 +9586,15 @@ function rankGlobPathMatches(query, items, limit) {
9361
9586
  mode: "glob"
9362
9587
  } : null;
9363
9588
  }).filter((item) => item !== null).sort(compare);
9589
+ if (stats) stats.total = ranked.length;
9590
+ return ranked;
9364
9591
  }
9365
9592
  const top = [];
9593
+ let total = 0;
9366
9594
  for (const item of items) {
9367
9595
  const match = matchPath(item.path);
9368
9596
  if (!match) continue;
9597
+ total++;
9369
9598
  const ranked = {
9370
9599
  item,
9371
9600
  score: match.score,
@@ -9374,13 +9603,14 @@ function rankGlobPathMatches(query, items, limit) {
9374
9603
  };
9375
9604
  pushBoundedTop(top, ranked, bounded, compare);
9376
9605
  }
9606
+ if (stats) stats.total = total;
9377
9607
  return top.sort(compare);
9378
9608
  }
9379
- function rankPathMatches(query, items, limit) {
9609
+ function rankPathMatches(query, items, limit, stats) {
9380
9610
  if (isGlobPathQuery(query)) {
9381
- return rankGlobPathMatches(query, items, limit);
9611
+ return rankGlobPathMatches(query, items, limit, stats);
9382
9612
  }
9383
- return rankFuzzyPaths(query, items, limit).map((item) => ({
9613
+ return rankFuzzyPaths(query, items, limit, stats).map((item) => ({
9384
9614
  ...item,
9385
9615
  mode: "fuzzy"
9386
9616
  }));
@@ -9399,8 +9629,13 @@ function globToRegExp(query) {
9399
9629
  const ch = pattern[i];
9400
9630
  if (ch === "*") {
9401
9631
  if (pattern[i + 1] === "*") {
9402
- source += ".*";
9403
- i++;
9632
+ if (pattern[i + 2] === "/") {
9633
+ source += "(?:.*/)?";
9634
+ i += 2;
9635
+ } else {
9636
+ source += ".*";
9637
+ i++;
9638
+ }
9404
9639
  } else {
9405
9640
  source += "[^/]*";
9406
9641
  }
@@ -9426,6 +9661,9 @@ function globToRegExp(query) {
9426
9661
  return null;
9427
9662
  }
9428
9663
  }
9664
+ function globMatchPath(query, path) {
9665
+ return createGlobPathMatcher(query)?.(path) ?? null;
9666
+ }
9429
9667
  function createGlobPathMatcher(query) {
9430
9668
  const regex = globToRegExp(query);
9431
9669
  if (!regex) return null;
@@ -9475,14 +9713,31 @@ function isSkippableSearchPath(path, omitDirNames = [], excludeNames = []) {
9475
9713
  return lower === ".git" || lower === ".code-viewer" || omitDirs.matches(part) || excluded.matches(part);
9476
9714
  });
9477
9715
  }
9478
- function fixedStringLineMatches(path, text2, query, max) {
9479
- const needle = query.toLowerCase();
9480
- if (!needle) return [];
9716
+ function isWordBoundary(line, start, end) {
9717
+ const before = start > 0 ? line[start - 1] : "";
9718
+ const after = end < line.length ? line[end] : "";
9719
+ return !WORD_CHAR_RE.test(before) && !WORD_CHAR_RE.test(after);
9720
+ }
9721
+ function fixedStringColumn(line, query, options = {}) {
9722
+ if (!query) return -1;
9723
+ const haystack = options.caseSensitive ? line : line.toLowerCase();
9724
+ const needle = options.caseSensitive ? query : query.toLowerCase();
9725
+ let from = 0;
9726
+ for (; ; ) {
9727
+ const column = haystack.indexOf(needle, from);
9728
+ if (column < 0) return -1;
9729
+ if (!options.wholeWord || isWordBoundary(line, column, column + needle.length))
9730
+ return column;
9731
+ from = column + 1;
9732
+ }
9733
+ }
9734
+ function fixedStringLineMatches(path, text2, query, max, options = {}) {
9735
+ if (!query) return [];
9481
9736
  const matches = [];
9482
9737
  const lines = text2.split("\n");
9483
9738
  for (let i = 0; i < lines.length && matches.length < max; i++) {
9484
9739
  const line = lines[i];
9485
- const column = line.toLowerCase().indexOf(needle);
9740
+ const column = fixedStringColumn(line, query, options);
9486
9741
  if (column < 0) continue;
9487
9742
  matches.push({
9488
9743
  path,
@@ -9505,8 +9760,12 @@ function buildFileSearchList(ref, generation2, entries) {
9505
9760
  truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX
9506
9761
  };
9507
9762
  }
9508
- function buildRgArgs(query, max, paths, regex = false, omitDirNames = [], excludeNames = [], excludeTests = false) {
9763
+ function buildRgArgs(query, max, paths, regex = false, omitDirNames = [], excludeNames = [], excludeTests = false, options = {}) {
9509
9764
  const safePaths = paths.length ? paths : ["."];
9765
+ const includeGlobs = (options.pathGlobs ?? []).flatMap((pattern) => [
9766
+ "--glob",
9767
+ pattern
9768
+ ]);
9510
9769
  const omitGlobs = omitDirNames.flatMap((name) => [
9511
9770
  "--glob",
9512
9771
  `!${name}/**`,
@@ -9556,12 +9815,14 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = [], exclud
9556
9815
  "--with-filename",
9557
9816
  "--color",
9558
9817
  "never",
9559
- "--smart-case",
9818
+ options.caseSensitive ? "--case-sensitive" : "--ignore-case",
9819
+ ...options.wholeWord ? ["--word-regexp"] : [],
9560
9820
  ...matchMode,
9561
9821
  "--max-count",
9562
9822
  String(max),
9563
9823
  "--max-filesize",
9564
9824
  "2M",
9825
+ ...includeGlobs,
9565
9826
  ...omitGlobs,
9566
9827
  ...excludeGlobs,
9567
9828
  ...testGlobs,
@@ -9633,7 +9894,7 @@ function parseGitGrepOutput(stdout, ref, max, omitDirNames = [], excludeNames =
9633
9894
  includePath
9634
9895
  );
9635
9896
  }
9636
- var GREP_DEFAULT_MAX, GREP_ABSOLUTE_MAX, GREP_MAX_FILE_BYTES, FILE_SEARCH_ABSOLUTE_MAX, DEFAULT_EXCLUDE_NAMES;
9897
+ var GREP_DEFAULT_MAX, GREP_ABSOLUTE_MAX, GREP_MAX_FILE_BYTES, FILE_SEARCH_ABSOLUTE_MAX, DEFAULT_EXCLUDE_NAMES, WORD_CHAR_RE;
9637
9898
  var init_search = __esm({
9638
9899
  "web-src/server/search.ts"() {
9639
9900
  init_name_pattern();
@@ -9642,6 +9903,7 @@ var init_search = __esm({
9642
9903
  GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
9643
9904
  FILE_SEARCH_ABSOLUTE_MAX = 5e4;
9644
9905
  DEFAULT_EXCLUDE_NAMES = [".DS_Store"];
9906
+ WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
9645
9907
  }
9646
9908
  });
9647
9909
 
@@ -9735,11 +9997,13 @@ function parseSearchArgs(argv) {
9735
9997
  return { ok: false, error: "--term must be a single line" };
9736
9998
  }
9737
9999
  if (subcommand === "files") {
9738
- if (flags.has("--regex")) {
9739
- return {
9740
- ok: false,
9741
- error: "search files does not accept --regex"
9742
- };
10000
+ for (const codeOnly of ["--regex", "--case-sensitive", "--word"]) {
10001
+ if (flags.has(codeOnly)) {
10002
+ return {
10003
+ ok: false,
10004
+ error: `search files does not accept ${codeOnly}`
10005
+ };
10006
+ }
9743
10007
  }
9744
10008
  if (paths.length > 0) {
9745
10009
  return {
@@ -9780,6 +10044,8 @@ function parseSearchArgs(argv) {
9780
10044
  ref: options.get("--ref"),
9781
10045
  paths,
9782
10046
  regex: flags.has("--regex"),
10047
+ caseSensitive: flags.has("--case-sensitive"),
10048
+ wholeWord: flags.has("--word"),
9783
10049
  max: parsedMax.value,
9784
10050
  json: flags.has("--json")
9785
10051
  },
@@ -9795,6 +10061,8 @@ function buildGrepPath(command) {
9795
10061
  if (command.ref) params.set("ref", command.ref);
9796
10062
  if (command.max !== void 0) params.set("max", String(command.max));
9797
10063
  if (command.regex) params.set("regex", "1");
10064
+ if (command.caseSensitive) params.set("case", "1");
10065
+ if (command.wholeWord) params.set("word", "1");
9798
10066
  for (const path of command.paths) params.append("path", path);
9799
10067
  return `/_grep?${params.toString()}`;
9800
10068
  }
@@ -9925,7 +10193,8 @@ var init_search_cli = __esm({
9925
10193
 
9926
10194
  Usage:
9927
10195
  code-viewer search code --term <text> [--ref <ref>] [--path <path>...]
9928
- [--regex] [--max <n>] [--json]
10196
+ [--regex] [--case-sensitive] [--word]
10197
+ [--max <n>] [--json]
9929
10198
  [--cwd <dir>] [--server <url>] [--bin <name>=<path>]
9930
10199
  code-viewer search files --term <pattern> [--ref <ref>] [--max <n>] [--json]
9931
10200
  [--cwd <dir>] [--server <url>] [--bin <name>=<path>]
@@ -9946,8 +10215,12 @@ Common options:
9946
10215
  --help, -h Show this help.
9947
10216
 
9948
10217
  search code only:
9949
- --path <path> Restrict the search to a sub-path. Repeatable.
10218
+ --path <path> Restrict the search to a file, a directory, or a glob
10219
+ such as "src/**/*.ts". Repeatable.
9950
10220
  --regex Treat --term as an extended regex instead of a fixed string.
10221
+ --case-sensitive Match case. Default: case-insensitive on every engine.
10222
+ --word Match whole words only (rg -w / git grep -w; the
10223
+ fallback scanner applies the same boundary rule).
9951
10224
 
9952
10225
  search files:
9953
10226
  --term auto-switches between fuzzy and glob matching:
@@ -9995,6 +10268,8 @@ environments. Results are NOT persisted on the server; this is a pure read.
9995
10268
  code-viewer search code --term "TODO" --json
9996
10269
  code-viewer search code --term "fn handler" --regex --json
9997
10270
  code-viewer search code --term "config" --path src --path tests --json
10271
+ code-viewer search code --term "Token" --case-sensitive --word --json
10272
+ code-viewer search code --term "config" --path "src/**/*.ts" --json
9998
10273
  code-viewer search code --term "release" --ref main --json
9999
10274
 
10000
10275
  code-viewer search files --term "sample"
@@ -10053,6 +10328,10 @@ Parse failures and unreachable servers exit 1.
10053
10328
  - search code: engine=fallback with regex=true returns zero matches by
10054
10329
  design — the fallback path does not support regex. Install ripgrep or
10055
10330
  use a fixed string instead.
10331
+ - search code: matching is case-insensitive on every engine unless you
10332
+ pass --case-sensitive; --word requires the hit to sit on word
10333
+ boundaries. --path accepts globs ("src/**/*.ts", "*.md") as well as
10334
+ files and directories.
10056
10335
  - search files: --term containing * or ? triggers glob mode (e.g.
10057
10336
  "src/**/*.test.ts"). Bare words use fuzzy ranking (e.g. "auth",
10058
10337
  "userId"). The /_files list is shared with the browser Ctrl+K palette,
@@ -10064,7 +10343,7 @@ Parse failures and unreachable servers exit 1.
10064
10343
  `;
10065
10344
  VALUE_FLAGS3 = /* @__PURE__ */ new Set(["--term", "--ref", "--max"]);
10066
10345
  REPEATABLE_VALUE_FLAGS = /* @__PURE__ */ new Set(["--path"]);
10067
- BOOL_FLAGS3 = /* @__PURE__ */ new Set(["--regex", "--json"]);
10346
+ BOOL_FLAGS3 = /* @__PURE__ */ new Set(["--regex", "--case-sensitive", "--word", "--json"]);
10068
10347
  }
10069
10348
  });
10070
10349
 
@@ -17529,6 +17808,8 @@ var init_keymap = __esm({
17529
17808
  "previous-hunk",
17530
17809
  "goto-diff",
17531
17810
  "goto-history",
17811
+ "history-next-commit",
17812
+ "history-previous-commit",
17532
17813
  "goto-repo",
17533
17814
  "toggle-terminal-panel",
17534
17815
  "toggle-sidebar",
@@ -17719,6 +18000,11 @@ function sanitizeSettings(raw) {
17719
18000
  if (hideTests !== void 0) out.hideTests = hideTests;
17720
18001
  const grepRegex = optionalBoolean(raw.grepRegex);
17721
18002
  if (grepRegex !== void 0) out.grepRegex = grepRegex;
18003
+ const grepCaseSensitive = optionalBoolean(raw.grepCaseSensitive);
18004
+ if (grepCaseSensitive !== void 0)
18005
+ out.grepCaseSensitive = grepCaseSensitive;
18006
+ const grepWholeWord = optionalBoolean(raw.grepWholeWord);
18007
+ if (grepWholeWord !== void 0) out.grepWholeWord = grepWholeWord;
17722
18008
  const grepGroupByFile = optionalBoolean(raw.grepGroupByFile);
17723
18009
  if (grepGroupByFile !== void 0) out.grepGroupByFile = grepGroupByFile;
17724
18010
  const grepPaletteWidth = optionalNumber(
@@ -17748,6 +18034,13 @@ function sanitizeSettings(raw) {
17748
18034
  sort: false
17749
18035
  });
17750
18036
  if (fileSelectionHistory) out.fileSelectionHistory = fileSelectionHistory;
18037
+ const recentRefs = normalizeStringList(raw.recentRefs, {
18038
+ maxItems: MAX_RECENT_REFS,
18039
+ maxLen: MAX_REF_LEN,
18040
+ keepLast: true,
18041
+ sort: false
18042
+ });
18043
+ if (recentRefs) out.recentRefs = recentRefs;
17751
18044
  const scopeOmitDirs = normalizeStringList(raw.scopeOmitDirs, {
17752
18045
  maxItems: 100,
17753
18046
  maxLen: 64,
@@ -18126,7 +18419,7 @@ async function loadToolsState(root) {
18126
18419
  async function patchToolsState(root, patch) {
18127
18420
  return patchJsonState(toolsStore, root, patch, mergeToolsState);
18128
18421
  }
18129
- var CODE_VIEWER_DIR2, SETTINGS_FILE_NAME, VIEW_STATE_FILE_NAME, DB_UI_FILE_NAME, TOOLS_FILE_NAME, MAX_SETTINGS_BYTES, MAX_VIEW_STATE_BYTES, MAX_DB_UI_BYTES, MAX_TOOL_DRAFT_LEN, MAX_TOOLS_BYTES, MAX_REF_LEN, MAX_KEY_LEN, MAX_VIEW_ITEMS, MAX_GREP_SELECTION_HISTORY_ITEMS, MAX_DB_UI_DBS, MAX_DB_UI_TABLES, MAX_DB_UI_COLUMNS, MAX_DB_UI_EXPANDED_SCOPES, DB_UI_BOOL_PREF_KEYS, settingsStore, viewStateStore, dbUiStore, toolsStore;
18422
+ var CODE_VIEWER_DIR2, SETTINGS_FILE_NAME, VIEW_STATE_FILE_NAME, DB_UI_FILE_NAME, TOOLS_FILE_NAME, MAX_SETTINGS_BYTES, MAX_VIEW_STATE_BYTES, MAX_DB_UI_BYTES, MAX_TOOL_DRAFT_LEN, MAX_TOOLS_BYTES, MAX_REF_LEN, MAX_KEY_LEN, MAX_VIEW_ITEMS, MAX_GREP_SELECTION_HISTORY_ITEMS, MAX_RECENT_REFS, MAX_DB_UI_DBS, MAX_DB_UI_TABLES, MAX_DB_UI_COLUMNS, MAX_DB_UI_EXPANDED_SCOPES, DB_UI_BOOL_PREF_KEYS, settingsStore, viewStateStore, dbUiStore, toolsStore;
18130
18423
  var init_state_store = __esm({
18131
18424
  "web-src/server/state-store.ts"() {
18132
18425
  init_keymap();
@@ -18149,6 +18442,7 @@ var init_state_store = __esm({
18149
18442
  MAX_KEY_LEN = 2048;
18150
18443
  MAX_VIEW_ITEMS = 2e4;
18151
18444
  MAX_GREP_SELECTION_HISTORY_ITEMS = 100;
18445
+ MAX_RECENT_REFS = 8;
18152
18446
  MAX_DB_UI_DBS = 200;
18153
18447
  MAX_DB_UI_TABLES = 500;
18154
18448
  MAX_DB_UI_COLUMNS = 1e3;
@@ -27708,6 +28002,7 @@ function isTestFilePath(path) {
27708
28002
  var TEST_FILE_PATH_RE;
27709
28003
  var init_file_filter = __esm({
27710
28004
  "web-src/core/file-filter.ts"() {
28005
+ init_fuzzy_search();
27711
28006
  TEST_FILE_PATH_RE = /(^|[/_.])(test|spec|__tests__)([/_.]|$)/i;
27712
28007
  }
27713
28008
  });
@@ -27763,16 +28058,65 @@ function filterCallerPaths(env, paths, excludeTests) {
27763
28058
  (path) => isSafePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, env.omitDirNames, env.excludeNames) && (!excludeTests || !isTestFilePath(path))
27764
28059
  );
27765
28060
  }
27766
- async function grepWorktreeFallback(env, query, max, paths, excludeTests) {
27767
- const candidates = paths.length ? paths : (await listTreeAsync("worktree", "", env.cwd, {
27768
- recursive: true,
27769
- omitDirNames: env.omitDirNames,
27770
- excludeNames: env.excludeNames
27771
- })).entries.map((entry) => entry.path);
28061
+ function splitCallerPaths(env, paths, excludeTests) {
28062
+ const plain = [];
28063
+ const globs = [];
28064
+ for (const path of paths) {
28065
+ if (!isSafePath(path) || isGitInternalPath(path)) continue;
28066
+ if (isGlobPathQuery(path)) {
28067
+ globs.push(path);
28068
+ continue;
28069
+ }
28070
+ if (isSkippableSearchPath(path, env.omitDirNames, env.excludeNames) || excludeTests && isTestFilePath(path))
28071
+ continue;
28072
+ plain.push(path);
28073
+ }
28074
+ return { plain, globs };
28075
+ }
28076
+ function matchesAnyGlob(path, globs) {
28077
+ return globs.length === 0 || globs.some((glob) => !!globMatchPath(glob, path));
28078
+ }
28079
+ async function fallbackCandidatePaths(env, paths) {
28080
+ if (paths.length === 0) {
28081
+ const tree = await listTreeAsync("worktree", "", env.cwd, {
28082
+ recursive: true,
28083
+ omitDirNames: env.omitDirNames,
28084
+ excludeNames: env.excludeNames
28085
+ });
28086
+ return tree.entries.map((entry) => entry.path);
28087
+ }
28088
+ const candidates = [];
28089
+ for (const path of paths) {
28090
+ const full = safeWorktreePath(env, path);
28091
+ if (!full) continue;
28092
+ let stat4;
28093
+ try {
28094
+ stat4 = await lstat2(full);
28095
+ } catch {
28096
+ continue;
28097
+ }
28098
+ if (!stat4.isDirectory()) {
28099
+ candidates.push(path);
28100
+ continue;
28101
+ }
28102
+ const tree = await listTreeAsync("worktree", path, env.cwd, {
28103
+ recursive: true,
28104
+ omitDirNames: env.omitDirNames,
28105
+ excludeNames: env.excludeNames
28106
+ });
28107
+ for (const entry of tree.entries) {
28108
+ if (entry.type === "blob") candidates.push(entry.path);
28109
+ }
28110
+ }
28111
+ return candidates;
28112
+ }
28113
+ async function grepWorktreeFallback(env, query, max, paths, globs, excludeTests, options, signal) {
28114
+ const candidates = await fallbackCandidatePaths(env, paths);
27772
28115
  const matches = [];
27773
28116
  for (const path of candidates) {
27774
28117
  if (matches.length >= max) break;
27775
- if (!isSafePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, env.omitDirNames, env.excludeNames) || excludeTests && isTestFilePath(path))
28118
+ throwIfAborted(signal, "grep aborted");
28119
+ if (!isSafePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, env.omitDirNames, env.excludeNames) || excludeTests && isTestFilePath(path) || !matchesAnyGlob(path, globs))
27776
28120
  continue;
27777
28121
  const full = safeWorktreePath(env, path);
27778
28122
  if (!full) continue;
@@ -27796,16 +28140,24 @@ async function grepWorktreeFallback(env, query, max, paths, excludeTests) {
27796
28140
  path,
27797
28141
  data.toString("utf8"),
27798
28142
  query,
27799
- max - matches.length
28143
+ max - matches.length,
28144
+ options
27800
28145
  )
27801
28146
  );
27802
28147
  }
27803
28148
  return matches;
27804
28149
  }
28150
+ function matchOptions(req) {
28151
+ return {
28152
+ caseSensitive: req.caseSensitive === true,
28153
+ wholeWord: req.wholeWord === true
28154
+ };
28155
+ }
27805
28156
  async function grepWorktreeAsync(env, req) {
27806
28157
  const excludeTests = req.excludeTests === true;
27807
- const paths = filterCallerPaths(env, req.paths, excludeTests);
27808
- if (req.paths.length > 0 && paths.length === 0) {
28158
+ const { plain, globs } = splitCallerPaths(env, req.paths, excludeTests);
28159
+ const paths = filterCallerPaths(env, plain, excludeTests);
28160
+ if (req.paths.length > 0 && paths.length === 0 && globs.length === 0) {
27809
28161
  return {
27810
28162
  ref: "worktree",
27811
28163
  engine: "fallback",
@@ -27815,7 +28167,7 @@ async function grepWorktreeAsync(env, req) {
27815
28167
  }
27816
28168
  if (await rgAvailableAsync(env.cwd)) {
27817
28169
  const safePaths = paths.filter((path) => safeWorktreePath(env, path));
27818
- if (req.paths.length > 0 && safePaths.length === 0) {
28170
+ if (plain.length > 0 && safePaths.length === 0) {
27819
28171
  return {
27820
28172
  ref: "worktree",
27821
28173
  engine: "rg",
@@ -27830,13 +28182,15 @@ async function grepWorktreeAsync(env, req) {
27830
28182
  req.regex,
27831
28183
  env.omitDirNames,
27832
28184
  env.excludeNames,
27833
- excludeTests
28185
+ excludeTests,
28186
+ { ...matchOptions(req), pathGlobs: globs }
27834
28187
  );
27835
28188
  const proc = await spawnTextAsync({
27836
28189
  command: commandForExternal("rg"),
27837
28190
  args: args.slice(1),
27838
28191
  cwd: env.cwd,
27839
28192
  timeoutMs: 5e3,
28193
+ signal: req.signal,
27840
28194
  abortMessage: "grep aborted",
27841
28195
  timeoutMessage: "grep timed out after 5000ms",
27842
28196
  rejectOnError: false
@@ -27874,7 +28228,10 @@ async function grepWorktreeAsync(env, req) {
27874
28228
  req.query,
27875
28229
  req.max,
27876
28230
  paths,
27877
- excludeTests
28231
+ globs,
28232
+ excludeTests,
28233
+ matchOptions(req),
28234
+ req.signal
27878
28235
  );
27879
28236
  return {
27880
28237
  ref: "worktree",
@@ -27885,8 +28242,9 @@ async function grepWorktreeAsync(env, req) {
27885
28242
  }
27886
28243
  async function grepTreeRefAsync(env, req) {
27887
28244
  const excludeTests = req.excludeTests === true;
27888
- const safePaths = filterCallerPaths(env, req.paths, excludeTests);
27889
- if (req.paths.length > 0 && safePaths.length === 0) {
28245
+ const { plain, globs } = splitCallerPaths(env, req.paths, excludeTests);
28246
+ const safePaths = filterCallerPaths(env, plain, excludeTests);
28247
+ if (req.paths.length > 0 && safePaths.length === 0 && globs.length === 0) {
27890
28248
  return {
27891
28249
  ref: req.ref,
27892
28250
  engine: "git",
@@ -27894,26 +28252,35 @@ async function grepTreeRefAsync(env, req) {
27894
28252
  matches: []
27895
28253
  };
27896
28254
  }
28255
+ const options = matchOptions(req);
28256
+ const pathspecs = [
28257
+ ...safePaths,
28258
+ ...globs.map(
28259
+ (glob) => glob.includes("/") ? `:(glob)${glob}` : `:(glob)**/${glob}`
28260
+ )
28261
+ ];
27897
28262
  const args = [
27898
28263
  "-c",
27899
28264
  "core.quotepath=false",
27900
28265
  "grep",
27901
28266
  "-n",
27902
28267
  "--column",
27903
- "-i",
28268
+ ...options.caseSensitive ? [] : ["-i"],
28269
+ ...options.wholeWord ? ["-w"] : [],
27904
28270
  req.regex ? "-E" : "-F",
27905
28271
  "--no-color",
27906
28272
  "-e",
27907
28273
  req.query,
27908
28274
  req.ref,
27909
28275
  "--",
27910
- ...safePaths
28276
+ ...pathspecs
27911
28277
  ];
27912
28278
  const proc = await spawnTextAsync({
27913
28279
  command: commandForExternal("git"),
27914
28280
  args,
27915
28281
  cwd: env.cwd,
27916
28282
  timeoutMs: 5e3,
28283
+ signal: req.signal,
27917
28284
  abortMessage: "git grep aborted",
27918
28285
  timeoutMessage: "git grep timed out after 5000ms",
27919
28286
  rejectOnError: false
@@ -27993,7 +28360,9 @@ var rgAvailableCache;
27993
28360
  var init_search_service = __esm({
27994
28361
  "web-src/server/search-service.ts"() {
27995
28362
  init_file_filter();
28363
+ init_fuzzy_search();
27996
28364
  init_command_resolver();
28365
+ init_abort();
27997
28366
  init_spawn_runner();
27998
28367
  init_git();
27999
28368
  init_name_pattern();
@@ -29512,7 +29881,7 @@ function defaultMcpTools(options = {}) {
29512
29881
  {
29513
29882
  name: "code_viewer_file_history",
29514
29883
  title: "code-viewer file history",
29515
- description: "Returns the same JSON payload `code-viewer file history --json` emits: path, ref, limit, skip, optional query, and result (commits with sha/author/when/subject, hasMore, optional error). Read-only. ref defaults to 'HEAD'. Follows renames for file paths. Use `query` for the same 'author:' / 'path:' / sha-prefix filters the browser history view accepts.",
29884
+ description: "Returns the same JSON payload `code-viewer file history --json` emits: path, ref, limit, skip, optional query, and result (commits with sha/author/when/subject/refs, hasMore, optional error). Read-only. ref defaults to 'HEAD'. Follows renames for file paths. `query` uses the browser history filter syntax: free words (message), author:<name>, path:<part>, since:/after:<date>, until:/before:<date>, code:<text> (git log -S), merges:no / merges:only; kinds AND together and a lone hex word tries a sha prefix.",
29516
29885
  inputSchema: {
29517
29886
  type: "object",
29518
29887
  properties: {
@@ -29537,7 +29906,7 @@ function defaultMcpTools(options = {}) {
29537
29906
  },
29538
29907
  query: {
29539
29908
  type: "string",
29540
- description: "Optional git log filter, passed verbatim to commitHistory. Supports the 'author:' and 'path:' prefixes the browser history view uses. Single-line, no NUL."
29909
+ description: "Optional git log filter in the browser history syntax: free words, author:<name>, path:<part>, since:/after:<date>, until:/before:<date>, code:<text>, merges:no|only. Single-line, no NUL."
29541
29910
  },
29542
29911
  cwd: {
29543
29912
  type: "string",
@@ -29666,12 +30035,20 @@ function defaultMcpTools(options = {}) {
29666
30035
  paths: {
29667
30036
  type: "array",
29668
30037
  items: { type: "string" },
29669
- description: "Optional list of repo-relative paths to restrict the search to. Unsafe paths are dropped silently."
30038
+ description: "Optional list of repo-relative files, directories, or globs (e.g. 'src/**/*.ts') to restrict the search to. Unsafe paths are dropped silently."
29670
30039
  },
29671
30040
  regex: {
29672
30041
  type: "boolean",
29673
30042
  description: "Treat `term` as an extended regex instead of a fixed string. Defaults to false."
29674
30043
  },
30044
+ caseSensitive: {
30045
+ type: "boolean",
30046
+ description: "Match case. Defaults to false (case-insensitive on every engine)."
30047
+ },
30048
+ wholeWord: {
30049
+ type: "boolean",
30050
+ description: "Match whole words only (rg -w / git grep -w semantics). Defaults to false."
30051
+ },
29675
30052
  max: {
29676
30053
  type: "integer",
29677
30054
  minimum: 1,
@@ -30574,6 +30951,14 @@ async function runSearchCodeTool(input, defaultCwd, generation2) {
30574
30951
  return { text: "regex must be a boolean", isError: true };
30575
30952
  }
30576
30953
  const regex = regexRaw === true;
30954
+ const caseSensitiveRaw = params.caseSensitive;
30955
+ if (caseSensitiveRaw !== void 0 && typeof caseSensitiveRaw !== "boolean") {
30956
+ return { text: "caseSensitive must be a boolean", isError: true };
30957
+ }
30958
+ const wholeWordRaw = params.wholeWord;
30959
+ if (wholeWordRaw !== void 0 && typeof wholeWordRaw !== "boolean") {
30960
+ return { text: "wholeWord must be a boolean", isError: true };
30961
+ }
30577
30962
  const maxParsed = validateMcpIntegerLimit(
30578
30963
  params.max,
30579
30964
  GREP_DEFAULT_MAX,
@@ -30598,6 +30983,8 @@ async function runSearchCodeTool(input, defaultCwd, generation2) {
30598
30983
  ref: refParsed.ref,
30599
30984
  paths,
30600
30985
  regex,
30986
+ caseSensitive: caseSensitiveRaw === true,
30987
+ wholeWord: wholeWordRaw === true,
30601
30988
  max: maxParsed.value
30602
30989
  });
30603
30990
  if (result.ok !== true) {
@@ -33172,7 +33559,9 @@ async function computePayload(extras, range, pathFilter = "", responseGeneration
33172
33559
  files.push(...untracked.files);
33173
33560
  metaError = untracked.error;
33174
33561
  }
33175
- const filteredFiles = pathFilter ? files.filter(
33562
+ const filteredFiles = pathFilter ? pathFilter.endsWith("/") ? files.filter(
33563
+ (file) => file.path.startsWith(pathFilter) || (file.old_path ?? "").startsWith(pathFilter)
33564
+ ) : files.filter(
33176
33565
  (file) => file.path === pathFilter || file.old_path === pathFilter
33177
33566
  ) : files;
33178
33567
  filteredFiles.sort(
@@ -33578,7 +33967,7 @@ async function handleFiles2(url) {
33578
33967
  }
33579
33968
  return json2(result.value);
33580
33969
  }
33581
- async function handleGrep(url) {
33970
+ async function handleGrep(url, signal) {
33582
33971
  const responseGeneration = generation;
33583
33972
  const query = url.searchParams.get("q") || "";
33584
33973
  const ref = url.searchParams.get("ref") || "worktree";
@@ -33591,17 +33980,26 @@ async function handleGrep(url) {
33591
33980
  const paths = url.searchParams.getAll("path");
33592
33981
  const regex = url.searchParams.get("regex") === "1";
33593
33982
  const excludeTests = url.searchParams.get("exclude_tests") === "1";
33594
- const result = await grepRepoAsync(
33595
- currentSearchEnv(omitDirNames, excludeNames),
33596
- {
33983
+ const caseSensitive = url.searchParams.get("case") === "1";
33984
+ const wholeWord = url.searchParams.get("word") === "1";
33985
+ let result;
33986
+ try {
33987
+ result = await grepRepoAsync(currentSearchEnv(omitDirNames, excludeNames), {
33597
33988
  query,
33598
33989
  ref,
33599
33990
  paths,
33600
33991
  regex,
33601
33992
  max,
33602
- excludeTests
33603
- }
33604
- );
33993
+ excludeTests,
33994
+ caseSensitive,
33995
+ wholeWord,
33996
+ signal
33997
+ });
33998
+ } catch (err) {
33999
+ if (isAbortLikeError(err, signal))
34000
+ return text("client closed request", 499);
34001
+ throw err;
34002
+ }
33605
34003
  if (result.ok !== true) return text(result.error, result.status ?? 400);
33606
34004
  return json2({ ...result.value, generation: responseGeneration });
33607
34005
  }
@@ -33615,6 +34013,27 @@ async function handleRefCommits(url) {
33615
34013
  if (result.error) return text(result.error, result.status ?? 500);
33616
34014
  return json2({ commits: result.commits, hasMore: result.hasMore });
33617
34015
  }
34016
+ async function handleFileRevisions(url) {
34017
+ const responseGeneration = generation;
34018
+ const path = url.searchParams.get("path") || "";
34019
+ if (!path || !safePath(path)) return text("invalid path", 400);
34020
+ if (isGitInternalPath(path)) return text("forbidden", 403);
34021
+ const ref = url.searchParams.get("ref") || "HEAD";
34022
+ const result = await fileRevisionNeighborsAsync(cwd, { path, ref });
34023
+ if (result.error) return text(result.error, result.status ?? 400);
34024
+ return json2({
34025
+ previous: result.previous,
34026
+ next: result.next,
34027
+ generation: responseGeneration
34028
+ });
34029
+ }
34030
+ async function handleAuthors(url) {
34031
+ const responseGeneration = generation;
34032
+ const ref = url.searchParams.get("ref") || "HEAD";
34033
+ const result = await commitAuthorsAsync(cwd, ref);
34034
+ if (result.error) return text(result.error, result.status ?? 400);
34035
+ return json2({ authors: result.authors, generation: responseGeneration });
34036
+ }
33618
34037
  async function handleLog(url) {
33619
34038
  const responseGeneration = generation;
33620
34039
  const ref = url.searchParams.get("ref") || "HEAD";
@@ -33622,12 +34041,14 @@ async function handleLog(url) {
33622
34041
  const limit = Number(url.searchParams.get("limit") || "50");
33623
34042
  const path = url.searchParams.get("path") || "";
33624
34043
  if (path && !safePath(path)) return text("invalid path", 400);
34044
+ const lines = parseHistoryLineRange(url.searchParams.get("lines"));
33625
34045
  const result = await commitHistoryAsync(cwd, {
33626
34046
  ref,
33627
34047
  skip: Number.isFinite(skip) ? skip : 0,
33628
34048
  limit: Number.isFinite(limit) ? limit : 50,
33629
34049
  query: url.searchParams.get("q") || "",
33630
- ...path ? { path } : {}
34050
+ ...path ? { path } : {},
34051
+ ...path && lines ? { lines } : {}
33631
34052
  });
33632
34053
  if (result.error) return text(result.error, result.status ?? 400);
33633
34054
  const wantsWorktreeHead = path && skip === 0 && (ref === "worktree" || url.searchParams.get("worktree") === "1");
@@ -33646,7 +34067,8 @@ async function handleLog(url) {
33646
34067
  author: "",
33647
34068
  when: "",
33648
34069
  parents: [],
33649
- body: ""
34070
+ body: "",
34071
+ refs: []
33650
34072
  },
33651
34073
  ...commits
33652
34074
  ];
@@ -34983,11 +35405,13 @@ var init_preview = __esm({
34983
35405
  async "web-src/server/preview.ts"() {
34984
35406
  init_directory_name();
34985
35407
  init_error_detail();
35408
+ init_history();
34986
35409
  init_journal();
34987
35410
  init_routes();
34988
35411
  init_annotations();
34989
35412
  init_cache();
34990
35413
  init_command_resolver();
35414
+ init_abort();
34991
35415
  init_dev_assets();
34992
35416
  init_doctor();
34993
35417
  init_file_upload();
@@ -35124,9 +35548,12 @@ var init_preview = __esm({
35124
35548
  });
35125
35549
  if (url.pathname === "/_tree") return await handleTree(url);
35126
35550
  if (url.pathname === "/_files") return await handleFiles2(url);
35127
- if (url.pathname === "/_grep") return await handleGrep(url);
35551
+ if (url.pathname === "/_grep") return await handleGrep(url, req.signal);
35128
35552
  if (url.pathname === "/_commits") return await handleRefCommits(url);
35129
35553
  if (url.pathname === "/_log") return await handleLog(url);
35554
+ if (url.pathname === "/_authors") return await handleAuthors(url);
35555
+ if (url.pathname === "/_file_revisions")
35556
+ return await handleFileRevisions(url);
35130
35557
  if (url.pathname === "/_file_blame") return await handleFileBlame(url);
35131
35558
  if (url.pathname === "/file_diff") return await handleFileDiff(url);
35132
35559
  if (url.pathname === "/file_range") return handleFileRange(url);