History

Add project file picker (⌘P) and project content search (⇧⌘F) (#4855)

Jake Leventhal committed 3 months ago

Commit abc409c1 parent 187723739 files · +2928 −476Browse files

Unified · Split

39 files
  1. apps/server/src/auth/RpcAuthorization.ts +1 −0
  2. apps/server/src/keybindings.test.ts +2 −0
  3. apps/server/src/keybindings.ts +13 −8
  4. apps/server/src/workspace/WorkspaceEntries.test.ts +343 −1
  5. apps/server/src/workspace/WorkspaceEntries.ts +71 −34
  6. apps/server/src/workspace/WorkspaceSearchIndex.test.ts +139 −4
  7. apps/server/src/workspace/WorkspaceSearchIndex.ts +275 −40
  8. apps/server/src/ws.ts +18 −0
  9. apps/web/src/components/ChatMarkdown.tsx +5 −47
  10. apps/web/src/components/CommandPalette.logic.test.ts +72 −0
  11. apps/web/src/components/CommandPalette.logic.ts +50 −1
  12. apps/web/src/components/CommandPalette.tsx +305 −279
  13. apps/web/src/components/CommandPaletteContent.tsx +77 −0
  14. apps/web/src/components/RenderErrorBoundary.tsx +16 −0
  15. apps/web/src/components/files/FileBrowserPanel.tsx +72 −0
  16. apps/web/src/components/files/FilePreviewPanel.tsx +140 −48
  17. apps/web/src/components/files/ProjectFilePicker.logic.test.ts +83 −0
  18. apps/web/src/components/files/ProjectFilePicker.logic.ts +73 −0
  19. apps/web/src/components/files/ProjectFilePicker.tsx +163 −0
  20. apps/web/src/components/files/fileLineReveal.test.ts +32 −0
  21. apps/web/src/components/files/fileLineReveal.ts +24 −0
  22. apps/web/src/components/files/projectFilesQueryState.ts +27 −0
  23. apps/web/src/components/search/HighlightedSearchLine.tsx +183 −0
  24. apps/web/src/components/search/ProjectContentSearchDialog.tsx +315 −0
  25. apps/web/src/hooks/useActiveProjectTarget.ts +41 −0
  26. apps/web/src/hooks/useHandleNewThread.ts +1 −1
  27. apps/web/src/keybindings.test.ts +52 −0
  28. apps/web/src/lib/syntaxHighlighting.test.ts +28 −0
  29. apps/web/src/lib/syntaxHighlighting.ts +30 −0
  30. apps/web/src/state/projects.ts +13 −0
  31. apps/web/src/state/queries.test.ts +25 −0
  32. apps/web/src/state/queries.ts +88 −9
  33. docs/user/keybindings.md +4 −0
  34. packages/contracts/src/keybindings.test.ts +12 −0
  35. packages/contracts/src/keybindings.ts +2 −0
  36. packages/contracts/src/project.test.ts +42 −0
  37. packages/contracts/src/project.ts +78 −4
  38. packages/contracts/src/rpc.ts +11 −0
  39. packages/shared/src/keybindings.ts +2 −0
apps/server/src/auth/RpcAuthorization.ts+1−0
@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { · 54 unmodified lines
5555 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
5656 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
5757 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
58+ [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
5859 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
5960 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
6061 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
apps/server/src/keybindings.test.ts+2−0
@@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { · 197 unmodified lines
198198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
201+ assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
202+ assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
201203 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
202204 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
203205 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
apps/server/src/keybindings.ts+13−8
@@ -547,19 +547,24 @@ const make = Effect.gen(function* () { · 546 unmodified lines
547547 });
548548 }
549549
550− const nextConfig = [...customConfig, ...missingDefaults];
551− const cappedConfig =
552− nextConfig.length > MAX_KEYBINDINGS_COUNT
553− ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)
554− : nextConfig;
555− if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {
556− yield* Effect.logWarning("truncating keybindings config to max entries", {
550+ // Startup backfill must never evict persisted user rules: append only
551+ // the defaults that fit and skip the rest.
552+ const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length);
553+ const defaultsToAppend = missingDefaults.slice(0, availableSlots);
554+ const skippedDefaults = missingDefaults.slice(availableSlots);
555+ if (skippedDefaults.length > 0) {
556+ yield* Effect.logWarning("skipping default keybinding backfill at max entries", {
557557 path: keybindingsConfigPath,
558558 maxEntries: MAX_KEYBINDINGS_COUNT,
559+ commands: skippedDefaults.map((rule) => rule.command),
559560 });
560561 }
562+ if (defaultsToAppend.length === 0) {
563+ yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
564+ return;
565+ }
561566
562− yield* writeConfigAtomically(cappedConfig);
567+ yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]);
563568 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
564569 }),
565570 );
apps/server/src/workspace/WorkspaceEntries.test.ts+343−1
@@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) · 71 unmodified lines
7272 return result.stdout.trim();
7373 });
7474
75−const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) =>
75+const searchWorkspaceEntries = (input: {
76+ cwd: string;
77+ query: string;
78+ limit: number;
79+ kind?: "file" | "directory";
80+}) =>
7681 Effect.gen(function* () {
7782 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
7883 return yield* workspaceEntries.search(input);
@@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { · 121 unmodified lines
200205 }),
201206 );
202207
208+ it.effect("applies the file filter before limiting search results", () =>
209+ Effect.gen(function* () {
210+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" });
211+ yield* writeTextFile(cwd, "src/index.ts");
212+ yield* writeTextFile(cwd, "src/internal.ts");
213+
214+ const result = yield* searchWorkspaceEntries({
215+ cwd,
216+ query: "src",
217+ limit: 1,
218+ kind: "file",
219+ });
220+
221+ expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]);
222+ expect(result.truncated).toBe(true);
223+ }),
224+ );
225+
226+ it.effect("answers an empty file-filtered query with a bounded file listing", () =>
227+ Effect.gen(function* () {
228+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" });
229+ yield* writeTextFile(cwd, "src/index.ts");
230+ yield* writeTextFile(cwd, "README.md");
231+
232+ const result = yield* searchWorkspaceEntries({
233+ cwd,
234+ query: "",
235+ limit: 10,
236+ kind: "file",
237+ });
238+
239+ const paths = result.entries.map((entry) => entry.path);
240+ expect(paths).toHaveLength(2);
241+ expect(paths).toContain("src/index.ts");
242+ expect(paths).toContain("README.md");
243+ expect(result.entries.every((entry) => entry.kind === "file")).toBe(true);
244+ }),
245+ );
246+
247+ it.effect("returns only directories for the directory filter", () =>
248+ Effect.gen(function* () {
249+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" });
250+ yield* writeTextFile(cwd, "src/index.ts");
251+
252+ const result = yield* searchWorkspaceEntries({
253+ cwd,
254+ query: "src",
255+ limit: 10,
256+ kind: "directory",
257+ });
258+
259+ expect(result.entries).toEqual([{ path: "src", kind: "directory" }]);
260+ expect(result.truncated).toBe(false);
261+ }),
262+ );
263+
203264 it.effect("excludes gitignored paths for git repositories", () =>
204265 Effect.gen(function* () {
205266 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
@@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { · 86 unmodified lines
292353 );
293354 });
294355
356+ describe("searchContents", () => {
357+ it.effect("returns content matches with file paths, line numbers, and ranges", () =>
358+ Effect.gen(function* () {
359+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" });
360+ yield* writeTextFile(
361+ cwd,
362+ "src/shapes.ts",
363+ "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n",
364+ );
365+ yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n");
366+
367+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
368+ const result = yield* workspaceEntries.searchContents({
369+ cwd,
370+ query: "Square",
371+ limit: 100,
372+ caseSensitive: false,
373+ wholeWord: true,
374+ useRegex: false,
375+ });
376+
377+ expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([
378+ ["src/shapes.ts", 1],
379+ ["src/shapes.ts", 2],
380+ ]);
381+ expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]);
382+ expect(result.truncated).toBe(false);
383+ }),
384+ );
385+
386+ it.effect("honors case sensitivity and gitignore rules", () =>
387+ Effect.gen(function* () {
388+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true });
389+ yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n");
390+ yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n");
391+ yield* writeTextFile(cwd, "ignored.txt", "Square\n");
392+
393+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
394+ const result = yield* workspaceEntries.searchContents({
395+ cwd,
396+ query: "Square",
397+ limit: 100,
398+ caseSensitive: true,
399+ wholeWord: false,
400+ useRegex: false,
401+ });
402+
403+ expect(result.matches).toHaveLength(1);
404+ expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 });
405+ }),
406+ );
407+
408+ it.effect("filters whole-word matches by word boundaries without widening ranges", () =>
409+ Effect.gen(function* () {
410+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" });
411+ yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n");
412+
413+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
414+ const result = yield* workspaceEntries.searchContents({
415+ cwd,
416+ query: "note",
417+ limit: 100,
418+ caseSensitive: true,
419+ wholeWord: true,
420+ useRegex: false,
421+ });
422+
423+ // "notes", "denote", and "footnote" are word-adjacent and excluded;
424+ // ranges cover exactly the query, never boundary characters.
425+ expect(result.matches).toEqual([
426+ expect.objectContaining({
427+ path: "src/words.ts",
428+ lineNumber: 1,
429+ matchRanges: [{ start: 0, end: 4 }],
430+ }),
431+ expect.objectContaining({
432+ path: "src/words.ts",
433+ lineNumber: 2,
434+ matchRanges: [{ start: 9, end: 13 }],
435+ }),
436+ ]);
437+ }),
438+ );
439+
440+ it.effect("finds later whole-word matches in a file after rejected raw matches", () =>
441+ Effect.gen(function* () {
442+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" });
443+ yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`);
444+
445+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
446+ const result = yield* workspaceEntries.searchContents({
447+ cwd,
448+ query: "foo",
449+ limit: 1,
450+ caseSensitive: true,
451+ wholeWord: true,
452+ useRegex: false,
453+ });
454+
455+ expect(result.matches).toEqual([
456+ expect.objectContaining({
457+ path: "src/words.ts",
458+ lineNumber: 11,
459+ matchRanges: [{ start: 0, end: 3 }],
460+ }),
461+ ]);
462+ }),
463+ );
464+
465+ it.effect("treats astral-plane letters as whole word characters", () =>
466+ Effect.gen(function* () {
467+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" });
468+ yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n");
469+
470+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
471+ const result = yield* workspaceEntries.searchContents({
472+ cwd,
473+ query: "foo",
474+ limit: 100,
475+ caseSensitive: true,
476+ wholeWord: true,
477+ useRegex: false,
478+ });
479+
480+ expect(result.matches).toEqual([
481+ expect.objectContaining({
482+ path: "src/words.ts",
483+ lineNumber: 1,
484+ matchRanges: [{ start: 6, end: 9 }],
485+ }),
486+ ]);
487+ }),
488+ );
489+
490+ it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () =>
491+ Effect.gen(function* () {
492+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" });
493+ yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n");
494+
495+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
496+ const result = yield* workspaceEntries.searchContents({
497+ cwd,
498+ query: "-foo-",
499+ limit: 100,
500+ caseSensitive: true,
501+ wholeWord: true,
502+ useRegex: false,
503+ });
504+
505+ // Consuming-boundary regex would swallow the separating spaces and
506+ // drop the middle occurrence; boundary post-filtering keeps all three.
507+ expect(result.matches).toHaveLength(1);
508+ expect(result.matches[0]).toMatchObject({
509+ path: "src/words.ts",
510+ lineNumber: 1,
511+ matchRanges: [
512+ { start: 0, end: 5 },
513+ { start: 6, end: 11 },
514+ { start: 12, end: 17 },
515+ ],
516+ });
517+ }),
518+ );
519+
520+ it.effect("matches punctuation-edged regex queries as whole words", () =>
521+ Effect.gen(function* () {
522+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" });
523+ yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n");
524+
525+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
526+ const result = yield* workspaceEntries.searchContents({
527+ cwd,
528+ query: "foo-",
529+ limit: 100,
530+ caseSensitive: true,
531+ wholeWord: true,
532+ useRegex: true,
533+ });
534+
535+ // wholeWord + useRegex must not silently drop non-word-edged patterns
536+ // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word
537+ // characters at the match's left edge.
538+ expect(result.matches).toHaveLength(1);
539+ expect(result.matches[0]).toMatchObject({
540+ path: "src/words.ts",
541+ lineNumber: 1,
542+ matchRanges: [
543+ { start: 0, end: 4 },
544+ { start: 5, end: 9 },
545+ ],
546+ });
547+ }),
548+ );
549+
550+ it.effect("caps matches per file so one dense file cannot fill the page", () =>
551+ Effect.gen(function* () {
552+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" });
553+ yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300));
554+ yield* writeTextFile(cwd, "src/other.ts", "needle\n");
555+
556+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
557+ const result = yield* workspaceEntries.searchContents({
558+ cwd,
559+ query: "needle",
560+ limit: 500,
561+ caseSensitive: true,
562+ wholeWord: false,
563+ useRegex: false,
564+ });
565+
566+ const byPath = new Map<string, number>();
567+ for (const match of result.matches) {
568+ byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1);
569+ }
570+ expect(byPath.get("src/dense.ts")).toBe(100);
571+ expect(byPath.get("src/other.ts")).toBe(1);
572+ }),
573+ );
574+
575+ it.effect("preserves regex escapes during case-insensitive searches", () =>
576+ Effect.gen(function* () {
577+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" });
578+ yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n");
579+
580+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
581+ const result = yield* workspaceEntries.searchContents({
582+ cwd,
583+ query: "\\SQUARE",
584+ limit: 100,
585+ caseSensitive: false,
586+ wholeWord: false,
587+ useRegex: true,
588+ });
589+
590+ expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]);
591+ }),
592+ );
593+
594+ it.effect("preserves invalid regex errors during case-insensitive searches", () =>
595+ Effect.gen(function* () {
596+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" });
597+ yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n");
598+
599+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
600+ const result = yield* workspaceEntries.searchContents({
601+ cwd,
602+ query: "foo)bar(",
603+ limit: 100,
604+ caseSensitive: false,
605+ wholeWord: false,
606+ useRegex: true,
607+ });
608+
609+ expect(result.regexFallbackError).toBeDefined();
610+ expect(result.matches).toEqual([]);
611+ }),
612+ );
613+
614+ it.effect("maps multi-byte lines to string-indexed ranges", () =>
615+ Effect.gen(function* () {
616+ const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" });
617+ yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n');
618+
619+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
620+ const result = yield* workspaceEntries.searchContents({
621+ cwd,
622+ query: "wörld",
623+ limit: 100,
624+ caseSensitive: true,
625+ wholeWord: false,
626+ useRegex: false,
627+ });
628+
629+ expect(result.matches).toHaveLength(1);
630+ const match = result.matches[0]!;
631+ const range = match.matchRanges[0]!;
632+ expect(match.lineContent.slice(range.start, range.end)).toBe("wörld");
633+ }),
634+ );
635+ });
636+
295637 describe("browse", () => {
296638 it.effect("returns matching directories and excludes files", () =>
297639 Effect.gen(function* () {
apps/server/src/workspace/WorkspaceEntries.ts+71−34
@@ -14,11 +14,14 @@ import type { · 13 unmodified lines
1414 FilesystemBrowseResult,
1515 ProjectListEntriesInput,
1616 ProjectListEntriesResult,
17+ ProjectSearchContentsInput,
18+ ProjectSearchContentsResult,
1719 ProjectSearchEntriesInput,
1820 ProjectSearchEntriesResult,
1921 } from "@t3tools/contracts";
2022 import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2123 import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
24+import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
2225
2326 import * as WorkspacePaths from "./WorkspacePaths.ts";
2427 import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
@@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< · 68 unmodified lines
9396 readonly search: (
9497 input: ProjectSearchEntriesInput,
9598 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
99+ readonly searchContents: (
100+ input: ProjectSearchContentsInput,
101+ ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;
96102 readonly refresh: (cwd: string) => Effect.Effect<void>;
97103 }
98104 >()("t3/workspace/WorkspaceEntries") {}
@@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { · 49 unmodified lines
148154 const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
149155 Effect.orElseSucceed(() => cwd),
150156 );
151− if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) {
152− return;
153− }
154− const recoverRefreshFailure = (
155− cause:
156− | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed
157− | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut
158− | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,
159− ) =>
160− Effect.gen(function* () {
161− yield* Effect.logWarning("Failed to refresh workspace search index", {
162− cwd,
163− cause,
157+ for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) {
158+ const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant);
159+ if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) {
160+ continue;
161+ }
162+ const recoverRefreshFailure = (
163+ cause:
164+ | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed
165+ | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut
166+ | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,
167+ ) =>
168+ Effect.gen(function* () {
169+ yield* Effect.logWarning("Failed to refresh workspace search index", {
170+ cwd,
171+ variant,
172+ cause,
173+ });
174+ yield* workspaceSearchIndexes.invalidate(indexKey);
164175 });
165− yield* workspaceSearchIndexes.invalidate(normalizedCwd);
166− });
167− yield* Effect.gen(function* () {
168− const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
169− yield* searchIndex.refresh();
170− }).pipe(
171− Effect.provide(workspaceSearchIndexes.get(normalizedCwd)),
172− Effect.catchTags({
173− WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,
174− WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,
175− WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,
176− }),
177− );
176+ yield* Effect.gen(function* () {
177+ const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
178+ yield* searchIndex.refresh();
179+ }).pipe(
180+ Effect.provide(workspaceSearchIndexes.get(indexKey)),
181+ Effect.catchTags({
182+ WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,
183+ WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,
184+ WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,
185+ }),
186+ );
187+ }
178188 },
179189 );
180190
@@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { · 49 unmodified lines
230240 const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(
231241 function* (input) {
232242 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
233− const normalizedQuery = input.query
234− .trim()
235− .toLowerCase()
236− .replace(/^[@./]+/, "");
243+ const normalizedQuery = normalizeSearchQuery(input.query, {
244+ trimLeadingPattern: /^[@./]+/,
245+ });
237246 return yield* Effect.gen(function* () {
238247 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
239− return yield* searchIndex.search(normalizedQuery, input.limit);
240− }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
248+ return yield* searchIndex.search(normalizedQuery, input.limit, input.kind);
249+ }).pipe(
250+ Effect.provide(
251+ workspaceSearchIndexes.get(
252+ WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
253+ ),
254+ ),
255+ );
241256 },
242257 );
243258
259+ const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(
260+ "WorkspaceEntries.searchContents",
261+ )(function* (input) {
262+ const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
263+ return yield* Effect.gen(function* () {
264+ const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
265+ return yield* searchIndex.searchContents(input);
266+ }).pipe(
267+ Effect.provide(
268+ workspaceSearchIndexes.get(
269+ WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"),
270+ ),
271+ ),
272+ );
273+ });
274+
244275 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
245276 function* (input) {
246277 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
247278 return yield* Effect.gen(function* () {
248279 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
249280 return yield* searchIndex.list();
250− }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));
281+ }).pipe(
282+ Effect.provide(
283+ workspaceSearchIndexes.get(
284+ WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
285+ ),
286+ ),
287+ );
251288 },
252289 );
253290
254− return WorkspaceEntries.of({ browse, list, refresh, search });
291+ return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });
255292 });
256293
257294 export const layer = Layer.effect(WorkspaceEntries, make).pipe(
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+139−4
@@ -1,4 +1,4 @@
1−import { FileFinder } from "@ff-labs/fff-node";
1+import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node";
22 import { afterEach, expect, it } from "@effect/vitest";
33 import * as Cause from "effect/Cause";
44 import * as Effect from "effect/Effect";
@@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain · 46 unmodified lines
5151 }),
5252 );
5353
54+it.effect("waits for the full content index warmup before returning", () =>
55+ Effect.gen(function* () {
56+ const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true }));
57+ const finder = {
58+ destroy: vi.fn(),
59+ waitForIndexReady,
60+ } as unknown as FileFinder;
61+ vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
62+
63+ yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content"));
64+
65+ expect(waitForIndexReady).toHaveBeenCalledWith(15_000);
66+ }),
67+);
68+
69+it.effect("preserves a full-index warmup timeout as a structured error", () =>
70+ Effect.gen(function* () {
71+ const finder = {
72+ destroy: vi.fn(),
73+ waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })),
74+ } as unknown as FileFinder;
75+ vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
76+
77+ const error = yield* Effect.flip(
78+ Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")),
79+ );
80+
81+ expect(error).toMatchObject({
82+ _tag: "WorkspaceSearchIndexScanTimedOut",
83+ cwd: "/workspace/project",
84+ timeout: "15 seconds",
85+ });
86+ }),
87+);
88+
5489 it.effect("preserves FileFinder destroy failures as structured defects", () =>
5590 Effect.gen(function* () {
5691 const cause = new Error("native destroy failed");
@@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => · 1 unmodified line
5893 destroy: vi.fn(() => {
5994 throw cause;
6095 }),
61− isScanning: vi.fn(() => false),
96+ waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
6297 } as unknown as FileFinder;
6398 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
6499
@@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => · 20 unmodified lines
85120 Effect.gen(function* () {
86121 const searchCause = new Error("native search failed");
87122 const refreshCause = new Error("native scan failed");
123+ const contentSearchCause = new Error("native grep failed");
88124 const finder = {
89125 destroy: vi.fn(),
90− isScanning: vi.fn(() => false),
126+ waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
91127 mixedSearch: vi.fn(() => {
92128 throw searchCause;
93129 }),
130+ grep: vi.fn(() => {
131+ throw contentSearchCause;
132+ }),
94133 scanFiles: vi.fn(() => {
95134 throw refreshCause;
96135 }),
@@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => · 3 unmodified lines
100139 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
101140 const query = "authorization: Bearer secret-token";
102141 const searchError = yield* Effect.flip(searchIndex.search(query, 3));
142+ const contentSearchError = yield* Effect.flip(
143+ searchIndex.searchContents({
144+ query,
145+ limit: 3,
146+ caseSensitive: false,
147+ wholeWord: false,
148+ useRegex: false,
149+ }),
150+ );
103151 const refreshError = yield* Effect.flip(searchIndex.refresh());
104152
105153 expect(searchError).toMatchObject({
@@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => · 6 unmodified lines
112160 });
113161 expect(searchError).not.toHaveProperty("query");
114162 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
163+ expect(contentSearchError).toMatchObject({
164+ _tag: "WorkspaceSearchIndexSearchFailed",
165+ cwd: "/workspace/project",
166+ queryLength: query.length,
167+ pageSize: 3,
168+ reason: "FileFinder.grep threw unexpectedly.",
169+ cause: contentSearchCause,
170+ });
171+ expect(contentSearchError).not.toHaveProperty("query");
172+ expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/);
115173 expect(refreshError).toMatchObject({
116174 _tag: "WorkspaceSearchIndexRefreshFailed",
117175 cwd: "/workspace/project",
@@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => · 9 unmodified lines
127185 Effect.gen(function* () {
128186 const finder = {
129187 destroy: vi.fn(),
130− isScanning: vi.fn(() => false),
188+ waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
131189 mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })),
132190 scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })),
133191 } as unknown as FileFinder;
@@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => · 23 unmodified lines
157215 }),
158216 ),
159217 );
218+
219+it.effect("continues whole-word searches after a filtered grep page", () =>
220+ Effect.scoped(
221+ Effect.gen(function* () {
222+ const nextCursor = {
223+ __brand: "GrepCursor",
224+ _offset: 1,
225+ } as GrepCursor;
226+ const grepResult = (
227+ lineContent: string,
228+ matchRanges: Array<[number, number]>,
229+ cursor: GrepCursor | null,
230+ ): GrepResult => ({
231+ items: [
232+ {
233+ relativePath: "src/words.ts",
234+ fileName: "words.ts",
235+ gitStatus: "unmodified",
236+ size: lineContent.length,
237+ modified: 0,
238+ isBinary: false,
239+ totalFrecencyScore: 0,
240+ accessFrecencyScore: 0,
241+ modificationFrecencyScore: 0,
242+ lineNumber: 1,
243+ col: 0,
244+ byteOffset: 0,
245+ lineContent,
246+ matchRanges,
247+ },
248+ ],
249+ totalMatched: 1,
250+ totalFilesSearched: 1,
251+ totalFiles: 1,
252+ filteredFileCount: 1,
253+ nextCursor: cursor,
254+ });
255+ const grep = vi.fn((_query: string, options?: GrepOptions) =>
256+ options?.cursor
257+ ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) }
258+ : {
259+ ok: true as const,
260+ value: grepResult("needleSuffix", [[0, 6]], nextCursor),
261+ },
262+ );
263+ const finder = {
264+ destroy: vi.fn(),
265+ waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
266+ grep,
267+ } as unknown as FileFinder;
268+ vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
269+
270+ const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content");
271+ const result = yield* searchIndex.searchContents({
272+ query: "needle",
273+ limit: 1,
274+ caseSensitive: true,
275+ wholeWord: true,
276+ useRegex: false,
277+ });
278+
279+ expect(result).toEqual({
280+ matches: [
281+ {
282+ path: "src/words.ts",
283+ lineNumber: 1,
284+ lineContent: "needle",
285+ matchRanges: [{ start: 0, end: 6 }],
286+ },
287+ ],
288+ truncated: false,
289+ });
290+ expect(grep).toHaveBeenCalledTimes(2);
291+ expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor);
292+ }),
293+ ),
294+);
apps/server/src/workspace/WorkspaceSearchIndex.ts+275−40
@@ -1,22 +1,36 @@
1−import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node";
1+import {
2+ type DirItem,
3+ type DirSearchResult,
4+ type FileItem,
5+ FileFinder,
6+ type GrepCursor,
7+ type MixedItem,
8+ type MixedSearchResult,
9+ type Result,
10+ type SearchResult,
11+} from "@ff-labs/fff-node";
212 import * as Context from "effect/Context";
313 import * as Effect from "effect/Effect";
414 import * as Layer from "effect/Layer";
515 import * as LayerMap from "effect/LayerMap";
6−import * as Schedule from "effect/Schedule";
716 import * as Schema from "effect/Schema";
817
918 import type {
1019 ProjectEntry,
20+ ProjectEntryKind,
1121 ProjectListEntriesResult,
22+ ProjectSearchContentsInput,
23+ ProjectSearchContentsResult,
1224 ProjectSearchEntriesResult,
1325 } from "@t3tools/contracts";
1426
1527 const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
1628 const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
1729 const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
30+const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000;
1831 const WORKSPACE_INDEX_IDLE_TTL = "15 minutes";
19−const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis";
32+const CONTENT_SEARCH_TIME_BUDGET_MS = 250;
33+const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100;
2034
2135 export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()(
2236 "WorkspaceSearchIndexCreateFailed",
@@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< · 73 unmodified lines
96110 readonly search: (
97111 query: string,
98112 limit: number,
113+ kind?: ProjectEntryKind,
99114 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
115+ readonly searchContents: (
116+ input: Omit<ProjectSearchContentsInput, "cwd">,
117+ ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceSearchIndexSearchFailed>;
100118 readonly refresh: () => Effect.Effect<
101119 void,
102120 WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut
@@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { · 26 unmodified lines
129147 };
130148 }
131149
150+function toFileEntry(item: FileItem): ProjectEntry | null {
151+ const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
152+ return normalizedPath ? { path: normalizedPath, kind: "file" } : null;
153+}
154+
155+function toDirectoryEntry(item: DirItem): ProjectEntry | null {
156+ const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
157+ return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;
158+}
159+
160+function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult {
161+ return {
162+ entries: result.items
163+ .flatMap((item) => {
164+ const entry = toFileEntry(item);
165+ return entry ? [entry] : [];
166+ })
167+ .slice(0, limit),
168+ truncated: result.totalMatched > limit,
169+ };
170+}
171+
172+function mapDirectorySearchResult(
173+ result: DirSearchResult,
174+ limit: number,
175+): ProjectSearchEntriesResult {
176+ const entries = result.items.flatMap((item) => {
177+ const entry = toDirectoryEntry(item);
178+ return entry ? [entry] : [];
179+ });
180+ const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0;
181+ return {
182+ entries: entries.slice(0, limit),
183+ truncated: result.totalMatched - rootDirectoryCount > limit,
184+ };
185+}
186+
132187 function mapMixedSearchResult(
133188 result: MixedSearchResult,
134189 limit: number,
@@ -155,6 +210,74 @@ function mapMixedSearchResult( · 20 unmodified lines
155210 };
156211 }
157212
213+const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u;
214+
215+function codePointAt(line: string, index: number): string | undefined {
216+ const codePoint = line.codePointAt(index);
217+ return codePoint === undefined ? undefined : String.fromCodePoint(codePoint);
218+}
219+
220+function codePointBefore(line: string, index: number): string | undefined {
221+ if (index <= 0) return undefined;
222+ const previousCodeUnit = line.charCodeAt(index - 1);
223+ const previousIndex =
224+ previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1;
225+ return codePointAt(line, previousIndex);
226+}
227+
228+function buildContentSearchQuery(input: Omit<ProjectSearchContentsInput, "cwd">): {
229+ readonly searchQuery: string;
230+ readonly regexMode: boolean;
231+} {
232+ if (input.caseSensitive) {
233+ return { searchQuery: input.query, regexMode: input.useRegex };
234+ }
235+ // Plain mode relies on smart case: an all-lowercase needle matches
236+ // case-insensitively. Regex mode needs an explicit inline flag instead.
237+ return input.useRegex
238+ ? { searchQuery: `(?i)${input.query}`, regexMode: true }
239+ : { searchQuery: input.query.toLowerCase(), regexMode: false };
240+}
241+
242+function mapContentMatchRanges(
243+ line: string,
244+ byteRanges: ReadonlyArray<readonly [number, number]>,
245+): Array<{ readonly start: number; readonly end: number }> {
246+ const lineBytes = Buffer.from(line);
247+ const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length;
248+ return byteRanges.map(([startByte, endByte]) => ({
249+ start: toStringIndex(startByte),
250+ end: toStringIndex(endByte),
251+ }));
252+}
253+
254+/**
255+ * Whole-word filtering happens after the grep rather than by wrapping the
256+ * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow
257+ * the separator between adjacent matches and widen the reported ranges, and
258+ * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a
259+ * match edge is a word boundary when it touches the line edge, the
260+ * neighbouring character is not a word character, or the match's own edge
261+ * character is not a word character.
262+ */
263+function isWholeWordRange(
264+ line: string,
265+ range: { readonly start: number; readonly end: number },
266+): boolean {
267+ if (range.end <= range.start) return false;
268+ const isWord = (character: string | undefined) =>
269+ character !== undefined && WORD_CHARACTER.test(character);
270+ const leftIsBoundary =
271+ range.start === 0 ||
272+ !isWord(codePointBefore(line, range.start)) ||
273+ !isWord(codePointAt(line, range.start));
274+ const rightIsBoundary =
275+ range.end >= line.length ||
276+ !isWord(codePointAt(line, range.end)) ||
277+ !isWord(codePointBefore(line, range.end));
278+ return leftIsBoundary && rightIsBoundary;
279+}
280+
158281 function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] {
159282 const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
160283 for (const entry of entries) {
@@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn · 8 unmodified lines
169292 return [...entryByPath.values()];
170293 }
171294
172−const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {
295+const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (
296+ cwd: string,
297+ variant: WorkspaceSearchIndexVariant,
298+) {
173299 const result = yield* Effect.try({
174300 try: () =>
175301 FileFinder.create({
176302 basePath: cwd,
177303 disableMmapCache: true,
178− disableContentIndexing: true,
304+ // Content indexing costs scan CPU and memory, so only the on-demand
305+ // content-search index pays for it; path-only consumers (file tree,
306+ // composer path search, file picker) keep the lightweight index.
307+ disableContentIndexing: variant !== "content",
179308 aiMode: false,
180309 enableFsRootScanning: true,
181310 enableHomeDirScanning: true,
@@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c · 12 unmodified lines
194323 });
195324 });
196325
197−const waitForScan = <E>(cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) =>
198− Effect.try({
199− try: () => finder.isScanning(),
200− catch: onFailure,
201− }).pipe(
202− Effect.repeat({
203− while: (scanning) => scanning,
204− schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL),
205− }),
206− Effect.timeoutOrElse({
207− duration: WORKSPACE_INDEX_SCAN_TIMEOUT,
208− orElse: () =>
209− new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }),
210− }),
211− Effect.withSpan("WorkspaceSearchIndex.waitForScan"),
212− );
326+const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>(
327+ cwd: string,
328+ finder: FileFinder,
329+ onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E,
330+): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> {
331+ const result = yield* Effect.tryPromise({
332+ try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS),
333+ catch: (cause) =>
334+ onFailure({
335+ reason: "FileFinder.waitForIndexReady rejected unexpectedly.",
336+ cause,
337+ }),
338+ });
339+ if (!result.ok) {
340+ return yield* Effect.fail(onFailure({ reason: result.error }));
341+ }
342+ if (!result.value) {
343+ return yield* new WorkspaceSearchIndexScanTimedOut({
344+ cwd,
345+ timeout: WORKSPACE_INDEX_SCAN_TIMEOUT,
346+ });
347+ }
348+});
213349
214−export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {
215− const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>
350+export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
351+ cwd: string,
352+ variant: WorkspaceSearchIndexVariant = "paths",
353+) {
354+ const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) =>
216355 Effect.try({
217356 try: () => finder.destroy(),
218357 catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }),
219358 }).pipe(Effect.orDie),
220359 );
221− yield* waitForScan(
360+ yield* waitForIndexReady(
222361 cwd,
223362 finder,
224− (cause) =>
363+ ({ reason, cause }) =>
225364 new WorkspaceSearchIndexCreateFailed({
226365 cwd,
227− reason: "FileFinder.isScanning threw while creating the index.",
366+ reason,
228367 cause,
229368 }),
230369 );
231370
232− const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (
371+ const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(
233372 query: string,
234373 pageSize: number,
235− ) {
374+ operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch",
375+ execute: () => Result<A>,
376+ ): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> {
236377 const result = yield* Effect.try({
237− try: () => finder.mixedSearch(query, { pageSize }),
378+ try: execute,
238379 catch: (cause) =>
239380 new WorkspaceSearchIndexSearchFailed({
240381 cwd,
241382 queryLength: query.length,
242383 pageSize,
243− reason: "FileFinder.mixedSearch threw unexpectedly.",
384+ reason: `FileFinder.${operation} threw unexpectedly.`,
244385 cause,
245386 }),
246387 });
@@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 26 unmodified lines
273414 reason: result.error,
274415 });
275416 }
276− yield* waitForScan(
417+ yield* waitForIndexReady(
277418 cwd,
278419 finder,
279− (cause) =>
420+ ({ reason, cause }) =>
280421 new WorkspaceSearchIndexRefreshFailed({
281422 cwd,
282− reason: "FileFinder.isScanning threw while refreshing the index.",
423+ reason,
283424 cause,
284425 }),
285426 );
@@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 1 unmodified line
287428
288429 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
289430 function* () {
290− const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE);
431+ const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () =>
432+ finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }),
433+ );
291434 const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
292435 const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
293436 left.path.localeCompare(right.path),
@@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin · 8 unmodified lines
302445
303446 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
304447 "WorkspaceSearchIndex.search",
305− )(function* (query, limit) {
306− const result = yield* runMixedSearch(query, Math.max(1, limit + 1));
448+ )(function* (query, limit, kind) {
449+ const pageSize = Math.max(1, limit + 1);
450+ if (kind === "file") {
451+ const result = yield* runSearch(query, pageSize, "fileSearch", () =>
452+ finder.fileSearch(query, { pageSize }),
453+ );
454+ return mapFileSearchResult(result, limit);
455+ }
456+ if (kind === "directory") {
457+ const result = yield* runSearch(query, pageSize, "directorySearch", () =>
458+ finder.directorySearch(query, { pageSize }),
459+ );
460+ return mapDirectorySearchResult(result, limit);
461+ }
462+ const result = yield* runSearch(query, pageSize, "mixedSearch", () =>
463+ finder.mixedSearch(query, { pageSize }),
464+ );
307465 return mapMixedSearchResult(result, limit);
308466 });
309467
310− return WorkspaceSearchIndex.of({ list, refresh, search });
468+ const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn(
469+ "WorkspaceSearchIndex.searchContents",
470+ )(function* (input) {
471+ const { searchQuery, regexMode } = buildContentSearchQuery(input);
472+ const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS;
473+ // Grep cursors advance by file, so whole-word post-filtering needs enough
474+ // raw candidates from the current file before moving to the next one.
475+ const rawPageSize = input.wholeWord
476+ ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE)
477+ : input.limit;
478+ const matches: Array<ProjectSearchContentsResult["matches"][number]> = [];
479+ let nextCursor: GrepCursor | null = null;
480+ let regexFallbackError: string | undefined;
481+
482+ do {
483+ const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now()));
484+ const result = yield* runSearch(input.query, input.limit, "grep", () =>
485+ finder.grep(searchQuery, {
486+ mode: regexMode ? "regex" : "plain",
487+ smartCase: !input.caseSensitive && !regexMode,
488+ // A single dense file must not consume the whole result page.
489+ maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize),
490+ pageSize: rawPageSize,
491+ cursor: nextCursor,
492+ timeBudgetMs: remainingTimeBudgetMs,
493+ }),
494+ );
495+
496+ for (const match of result.items) {
497+ const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter(
498+ (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range),
499+ );
500+ if (matchRanges.length === 0) continue;
501+ matches.push({
502+ path: toPosixPath(match.relativePath),
503+ lineNumber: match.lineNumber,
504+ lineContent: match.lineContent,
505+ matchRanges,
506+ });
507+ }
508+ nextCursor = result.nextCursor;
509+ regexFallbackError ??= result.regexFallbackError;
510+ } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline);
511+
512+ return {
513+ matches: matches.slice(0, input.limit),
514+ truncated: matches.length > input.limit || nextCursor !== null,
515+ ...(regexFallbackError !== undefined ? { regexFallbackError } : {}),
516+ };
517+ });
518+
519+ return WorkspaceSearchIndex.of({ list, refresh, search, searchContents });
311520 });
312521
522+export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const;
523+export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number];
524+
525+/**
526+ * Composite LayerMap key so the lightweight path index and the on-demand
527+ * content-search index of the same workspace are separate resources with
528+ * independent lifecycles. "\n" cannot appear in a filesystem path.
529+ */
530+export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) =>
531+ `${variant}\n${cwd}`;
532+
533+function parseWorkspaceSearchIndexKey(key: string): {
534+ readonly cwd: string;
535+ readonly variant: WorkspaceSearchIndexVariant;
536+} {
537+ const separatorIndex = key.indexOf("\n");
538+ return {
539+ variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant,
540+ cwd: key.slice(separatorIndex + 1),
541+ };
542+}
543+
313544 /**
314545 * A layer factory is required because every index is scoped to a concrete
315− * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup;
316− * using a default cwd here would mix resources from different workspaces.
546+ * workspace root and variant. WorkspaceSearchIndexMap owns memoization and
547+ * idle cleanup; using a default cwd here would mix resources from different
548+ * workspaces.
317549 */
318−export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd));
550+export const layer = (key: string) => {
551+ const { cwd, variant } = parseWorkspaceSearchIndexKey(key);
552+ return Layer.effect(WorkspaceSearchIndex, make(cwd, variant));
553+};
319554
320555 export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()(
321556 "t3/workspace/WorkspaceSearchIndexMap",
apps/server/src/ws.ts+18−0
@@ -37,6 +37,7 @@ import { · 36 unmodified lines
3737 type ProjectFileOperation,
3838 ProjectListEntriesError,
3939 ProjectReadFileError,
40+ ProjectSearchContentsError,
4041 ProjectSearchEntriesError,
4142 ProjectWriteFileError,
4243 RelayClientInstallFailedError,
@@ -1597,6 +1598,23 @@ const makeWsRpcLayer = ( · 1554 unmodified lines
15971598 ),
15981599 { "rpc.aggregate": "workspace" },
15991600 ),
1601+ [WS_METHODS.projectsSearchContents]: (input) =>
1602+ observeRpcEffect(
1603+ WS_METHODS.projectsSearchContents,
1604+ workspaceEntries.searchContents(input).pipe(
1605+ Effect.mapError(
1606+ (cause) =>
1607+ new ProjectSearchContentsError({
1608+ cwd: input.cwd,
1609+ queryLength: input.query.length,
1610+ limit: input.limit,
1611+ ...projectEntriesFailureContext(cause),
1612+ cause,
1613+ }),
1614+ ),
1615+ ),
1616+ { "rpc.aggregate": "workspace" },
1617+ ),
16001618 [WS_METHODS.projectsListEntries]: (input) =>
16011619 observeRpcEffect(
16021620 WS_METHODS.projectsListEntries,
apps/web/src/components/ChatMarkdown.tsx+5−47
@@ -1,5 +1,4 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
32 import {
43 CheckIcon,
54 ChevronRightIcon,
@@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; · 51 unmodified lines
5756 import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
5857 import { fnv1a32 } from "../lib/diffRendering";
5958 import { LRUCache } from "../lib/lruCache";
59+import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
60+import { RenderErrorBoundary } from "./RenderErrorBoundary";
6061 import { useTheme } from "../hooks/useTheme";
6162 import { getClientSettings } from "../hooks/useSettings";
6263 import {
@@ -91,27 +92,6 @@ import { · 28 unmodified lines
9192 BrowserPreviewUnavailableError,
9293 } from "../browser/openFileInPreview";
9394
94−class CodeHighlightErrorBoundary extends React.Component<
95− { fallback: ReactNode; children: ReactNode },
96− { hasError: boolean }
97−> {
98− constructor(props: { fallback: ReactNode; children: ReactNode }) {
99− super(props);
100− this.state = { hasError: false };
101− }
102−
103− static getDerivedStateFromError() {
104− return { hasError: true };
105− }
106−
107− override render() {
108− if (this.state.hasError) {
109− return this.props.fallback;
110− }
111− return this.props.children;
112− }
113−}
114−
11595 interface ChatMarkdownProps {
11696 text: string;
11797 cwd: string | undefined;
@@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>( · 29 unmodified lines
147127 MAX_HIGHLIGHT_CACHE_ENTRIES,
148128 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
149129 );
150−const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
151130
152131 function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {
153132 const firstLineEnd = markdown.indexOf("\n", listItemStart);
@@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { · 179 unmodified lines
333312 return Math.max(html.length * 2, code.length * 3);
334313 }
335314
336−function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
337− const cached = highlighterPromiseCache.get(language);
338− if (cached) return cached;
339−
340− const promise = getSharedHighlighter({
341− themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
342− langs: [language as SupportedLanguages],
343− preferredHighlighter: "shiki-js",
344− }).catch((err) => {
345− highlighterPromiseCache.delete(language);
346− if (language === "text") {
347− // "text" itself failed — Shiki cannot initialize at all, surface the error
348− throw err;
349− }
350− // Language not supported by Shiki — fall back to "text"
351− return getHighlighterPromise("text");
352− });
353− highlighterPromiseCache.set(language, promise);
354− return promise;
355−}
356−
357315 function readInitialWordWrapSetting(): boolean {
358316 return getClientSettings().wordWrap;
359317 }
@@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ · 383 unmodified lines
743701 cacheKey,
744702 isStreaming,
745703 }: UncachedShikiCodeBlockProps) {
746− const highlighter = use(getHighlighterPromise(language));
704+ const highlighter = use(getSyntaxHighlighterPromise(language));
747705 const highlightedHtml = useMemo(() => {
748706 try {
749707 return highlighter.codeToHtml(code, { lang: language, theme: themeName });
@@ -1605,7 +1563,7 @@ function ChatMarkdown({ · 855 unmodified lines
16051563 fenceTitle={fenceTitle}
16061564 theme={resolvedTheme}
16071565 >
1608− <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
1566+ <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
16091567 <Suspense fallback={<pre {...props}>{children}</pre>}>
16101568 <SuspenseShikiCodeBlock
16111569 className={codeBlock.className}
@@ -1614,7 +1572,7 @@ function ChatMarkdown({ · 2 unmodified lines
16141572 isStreaming={isStreaming}
16151573 />
16161574 </Suspense>
1617− </CodeHighlightErrorBoundary>
1575+ </RenderErrorBoundary>
16181576 </MarkdownCodeBlock>
16191577 );
16201578 },
apps/web/src/components/CommandPalette.logic.test.ts+72−0
@@ -6,9 +6,81 @@ import { · 5 unmodified lines
66 buildThreadActionItems,
77 enumerateCommandPaletteItems,
88 filterCommandPaletteGroups,
9+ reduceCommandPaletteUiState,
910 type CommandPaletteGroup,
1011 } from "./CommandPalette.logic";
1112
13+describe("reduceCommandPaletteUiState", () => {
14+ const closedState = { open: false, mode: "command", openIntent: null } as const;
15+
16+ it("toggles each overlay mode open and closed", () => {
17+ const filesOpen = reduceCommandPaletteUiState(closedState, {
18+ _tag: "ToggleMode",
19+ mode: "files",
20+ });
21+ expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null });
22+
23+ const contentOpen = reduceCommandPaletteUiState(filesOpen, {
24+ _tag: "ToggleMode",
25+ mode: "content",
26+ });
27+ expect(contentOpen).toEqual({ open: true, mode: "content", openIntent: null });
28+
29+ expect(
30+ reduceCommandPaletteUiState(contentOpen, { _tag: "ToggleMode", mode: "content" }),
31+ ).toEqual({ open: false, mode: "command", openIntent: null });
32+ });
33+
34+ it("switches between open modes without closing", () => {
35+ const filesOpen = reduceCommandPaletteUiState(closedState, {
36+ _tag: "ToggleMode",
37+ mode: "files",
38+ });
39+ expect(reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleMode", mode: "command" })).toEqual(
40+ {
41+ open: true,
42+ mode: "command",
43+ openIntent: null,
44+ },
45+ );
46+ });
47+
48+ it("routes open intents to command mode", () => {
49+ const filesOpen = reduceCommandPaletteUiState(closedState, {
50+ _tag: "ToggleMode",
51+ mode: "files",
52+ });
53+ expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({
54+ open: true,
55+ mode: "command",
56+ openIntent: { kind: "add-project" },
57+ });
58+ expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({
59+ open: true,
60+ mode: "command",
61+ openIntent: { kind: "new-thread-in" },
62+ });
63+ });
64+
65+ it("resets to command mode for dialog-driven opens and closes", () => {
66+ const filesOpen = reduceCommandPaletteUiState(closedState, {
67+ _tag: "ToggleMode",
68+ mode: "files",
69+ });
70+
71+ expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({
72+ open: false,
73+ mode: "command",
74+ openIntent: null,
75+ });
76+ expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({
77+ open: true,
78+ mode: "command",
79+ openIntent: null,
80+ });
81+ });
82+});
83+
1284 describe("enumerateCommandPaletteItems", () => {
1385 it("assigns positional jump shortcuts to the first nine displayed items", () => {
1486 const items = Array.from({ length: 10 }, (_, index) => ({
apps/web/src/components/CommandPalette.logic.ts+50−1
@@ -15,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12; · 14 unmodified lines
1515 export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
1616 export const ADDON_ICON_CLASS = "size-4";
1717
18+/**
19+ * The global search overlay hosts three mutually exclusive surfaces: the
20+ * command palette (⌘K), the project file picker (⌘P), and project content
21+ * search (⇧⌘F). One reducer owns open/mode state so the surfaces can never
22+ * stack and re-triggering a mode's shortcut toggles it closed.
23+ */
24+export type SearchOverlayMode = "command" | "files" | "content";
25+
26+export interface CommandPaletteOpenIntent {
27+ readonly kind: "add-project" | "new-thread-in";
28+}
29+
30+export interface CommandPaletteUiState {
31+ readonly open: boolean;
32+ readonly mode: SearchOverlayMode;
33+ readonly openIntent: CommandPaletteOpenIntent | null;
34+}
35+
36+export type CommandPaletteUiAction =
37+ | { readonly _tag: "SetOpen"; readonly open: boolean }
38+ | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
39+ | { readonly _tag: "OpenAddProject" }
40+ | { readonly _tag: "OpenNewThreadIn" }
41+ | { readonly _tag: "ClearOpenIntent" };
42+
43+export function reduceCommandPaletteUiState(
44+ state: CommandPaletteUiState,
45+ action: CommandPaletteUiAction,
46+): CommandPaletteUiState {
47+ switch (action._tag) {
48+ case "SetOpen":
49+ return {
50+ open: action.open,
51+ mode: "command",
52+ openIntent: action.open ? state.openIntent : null,
53+ };
54+ case "ToggleMode":
55+ return state.open && state.mode === action.mode
56+ ? { open: false, mode: "command", openIntent: null }
57+ : { open: true, mode: action.mode, openIntent: null };
58+ case "OpenAddProject":
59+ return { open: true, mode: "command", openIntent: { kind: "add-project" } };
60+ case "OpenNewThreadIn":
61+ return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
62+ case "ClearOpenIntent":
63+ return state.openIntent ? { ...state, openIntent: null } : state;
64+ }
65+}
66+
1867 export interface CommandPaletteThreadContentMatch {
1968 readonly source: "user" | "assistant";
2069 readonly snippet: string;
@@ -26,7 +75,7 @@ export interface CommandPaletteItem { · 5 unmodified lines
2675 readonly value: string;
2776 readonly searchTerms: ReadonlyArray<string>;
2877 readonly title: ReactNode;
29− readonly description?: string;
78+ readonly description?: ReactNode;
3079 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
3180 readonly timestamp?: string;
3281 readonly icon: ReactNode;
apps/web/src/components/CommandPalette.tsx+305−279
@@ -28,16 +28,16 @@ import { · 27 unmodified lines
2828 import { useNavigate, useParams } from "@tanstack/react-router";
2929 import * as Option from "effect/Option";
3030 import {
31− ArrowDownIcon,
3231 ArrowLeftIcon,
33− ArrowUpIcon,
3432 CornerLeftUpIcon,
33+ FileSearchIcon,
3534 FolderIcon,
3635 FolderPlusIcon,
3736 LinkIcon,
3837 MessageSquareIcon,
3938 SettingsIcon,
4039 SquarePenIcon,
40+ TextSearchIcon,
4141 } from "lucide-react";
4242 import {
4343 useCallback,
@@ -81,7 +81,9 @@ import { · 37 unmodified lines
8181 resolveProjectPathForDispatch,
8282 } from "../lib/projectPaths";
8383 import { onOpenCommandPalette } from "../commandPaletteBus";
84+import { isPreviewFocused } from "../lib/previewFocus";
8485 import { isTerminalFocused } from "../lib/terminalFocus";
86+import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
8587 import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
8688 import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
8789 import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
@@ -100,6 +102,7 @@ import { · 12 unmodified lines
100102 buildThreadActionItems,
101103 enumerateCommandPaletteItems,
102104 type CommandPaletteActionItem,
105+ type CommandPaletteOpenIntent,
103106 type CommandPaletteSubmenuItem,
104107 type CommandPaletteView,
105108 filterCommandPaletteGroups,
@@ -107,24 +110,22 @@ import { · 1 unmodified line
107110 getCommandPaletteMode,
108111 ITEM_ICON_CLASS,
109112 RECENT_THREAD_LIMIT,
113+ reduceCommandPaletteUiState,
114+ type SearchOverlayMode,
110115 } from "./CommandPalette.logic";
111116 import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
112117 import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
118+import { CommandPaletteContent } from "./CommandPaletteContent";
113119 import { CommandPaletteResults } from "./CommandPaletteResults";
114120 import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
115121 import { ProjectFavicon } from "./ProjectFavicon";
122+import { ProjectFilePicker } from "./files/ProjectFilePicker";
123+import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
116124 import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
117125 import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
118126 import { resolveDefaultProviderModelSelection } from "../providerInstances";
119127 import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
120−import {
121− Command,
122− CommandDialog,
123− CommandDialogPopup,
124− CommandFooter,
125− CommandInput,
126− CommandPanel,
127−} from "./ui/command";
128+import { CommandDialog, CommandDialogPopup } from "./ui/command";
128129 import { Button } from "./ui/button";
129130 import { Kbd, KbdGroup } from "./ui/kbd";
130131 import { stackedThreadToast, toastManager } from "./ui/toast";
@@ -346,50 +347,30 @@ function errorMessage(error: unknown): string { · 215 unmodified lines
346347 return "An error occurred.";
347348 }
348349
349−interface CommandPaletteOpenIntent {
350− readonly kind: "add-project" | "new-thread-in";
351−}
352−
353−interface CommandPaletteUiState {
354− readonly open: boolean;
355− readonly openIntent: CommandPaletteOpenIntent | null;
356−}
350+const OVERLAY_MODE_BY_COMMAND = {
351+ "commandPalette.toggle": "command",
352+ "filePicker.toggle": "files",
353+ "projectSearch.toggle": "content",
354+} as const satisfies Partial<Record<string, SearchOverlayMode>>;
357355
358−type CommandPaletteUiAction =
359− | { readonly _tag: "SetOpen"; readonly open: boolean }
360− | { readonly _tag: "Toggle" }
361− | { readonly _tag: "OpenAddProject" }
362− | { readonly _tag: "OpenNewThreadIn" }
363− | { readonly _tag: "ClearOpenIntent" };
364−
365−function reduceCommandPaletteUiState(
366− state: CommandPaletteUiState,
367− action: CommandPaletteUiAction,
368−): CommandPaletteUiState {
369− switch (action._tag) {
370− case "SetOpen":
371− return {
372− open: action.open,
373− openIntent: action.open ? state.openIntent : null,
374− };
375− case "Toggle":
376− return { open: !state.open, openIntent: null };
377− case "OpenAddProject":
378− return { open: true, openIntent: { kind: "add-project" } };
379− case "OpenNewThreadIn":
380− return { open: true, openIntent: { kind: "new-thread-in" } };
381− case "ClearOpenIntent":
382− return state.openIntent ? { ...state, openIntent: null } : state;
383− }
356+function overlayModeForCommand(command: string | null): SearchOverlayMode | null {
357+ if (command === null) return null;
358+ return command in OVERLAY_MODE_BY_COMMAND
359+ ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND]
360+ : null;
384361 }
385362
386363 export function CommandPalette({ children }: { children: ReactNode }) {
387364 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
388365 open: false,
366+ mode: "command",
389367 openIntent: null,
390368 });
391369 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
392− const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);
370+ const toggleMode = useCallback(
371+ (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }),
372+ [],
373+ );
393374 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
394375 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
395376 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
@@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 9 unmodified lines
405386 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
406387 : false,
407388 );
389+ const previewOpen = useRightPanelStore((state) =>
390+ routeThreadRef
391+ ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview"
392+ : false,
393+ );
394+
395+ useEffect(() => {
396+ if (!state.open || state.mode === "command") return;
397+ const onEscapeKeyDown = (event: globalThis.KeyboardEvent) => {
398+ if (event.isComposing || event.key !== "Escape") return;
399+ event.preventDefault();
400+ event.stopPropagation();
401+ toggleMode("command");
402+ };
403+ window.addEventListener("keydown", onEscapeKeyDown, true);
404+ return () => window.removeEventListener("keydown", onEscapeKeyDown, true);
405+ }, [state.mode, state.open, toggleMode]);
408406
409407 useEffect(() => {
410408 const onKeyDown = (event: globalThis.KeyboardEvent) => {
411409 if (event.defaultPrevented) return;
410+ // Resolve with the complete shortcut context so customized bindings
411+ // using any documented `when` condition (e.g. previewFocus) work.
412412 const command = resolveShortcutCommand(event, keybindings, {
413413 context: {
414414 terminalFocus: isTerminalFocused(),
415415 terminalOpen,
416+ previewFocus: isPreviewFocused(),
417+ previewOpen,
416418 },
417419 });
418− if (command !== "commandPalette.toggle") {
420+ const mode = overlayModeForCommand(command);
421+ if (mode === null) {
419422 return;
420423 }
421424 event.preventDefault();
422425 event.stopPropagation();
423− toggleOpen();
426+ toggleMode(mode);
424427 };
425428 window.addEventListener("keydown", onKeyDown);
426429 return () => window.removeEventListener("keydown", onKeyDown);
427− }, [keybindings, terminalOpen, toggleOpen]);
430+ }, [keybindings, previewOpen, terminalOpen, toggleMode]);
428431
429432 useEffect(
430433 () =>
@@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 11 unmodified lines
442445
443446 return (
444447 <ComposerHandleContext value={composerHandleRef}>
445− <CommandDialog open={state.open} onOpenChange={setOpen}>
448+ <CommandDialog
449+ open={state.open}
450+ onOpenChange={(open, eventDetails) => {
451+ if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") {
452+ eventDetails.cancel();
453+ toggleMode("command");
454+ return;
455+ }
456+ setOpen(open);
457+ }}
458+ >
446459 {children}
447460 <CommandPaletteDialog
448461 open={state.open}
462+ mode={state.mode}
449463 openIntent={state.openIntent}
450464 setOpen={setOpen}
465+ openOverlayMode={toggleMode}
451466 clearOpenIntent={clearOpenIntent}
452467 />
453468 </CommandDialog>
@@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 3 unmodified lines
457472
458473 function CommandPaletteDialog(props: {
459474 readonly open: boolean;
475+ readonly mode: SearchOverlayMode;
460476 readonly openIntent: CommandPaletteOpenIntent | null;
461477 readonly setOpen: (open: boolean) => void;
478+ readonly openOverlayMode: (mode: SearchOverlayMode) => void;
462479 readonly clearOpenIntent: () => void;
463480 }) {
481+ const composerHandleRef = useComposerHandleContext();
482+
464483 if (!props.open) {
465484 return null;
466485 }
467486
468487 return (
469− <OpenCommandPaletteDialog
470− openIntent={props.openIntent}
471− setOpen={props.setOpen}
472− clearOpenIntent={props.clearOpenIntent}
473− />
488+ <CommandDialogPopup
489+ aria-label={
490+ props.mode === "files"
491+ ? "File picker"
492+ : props.mode === "content"
493+ ? "Search project contents"
494+ : "Command palette"
495+ }
496+ className={cn("overflow-hidden p-0", props.mode === "content" && "h-105")}
497+ data-command-palette="true"
498+ data-palette-mode={props.mode}
499+ data-testid="command-palette"
500+ finalFocus={() => {
501+ composerHandleRef?.current?.focusAtEnd();
502+ return false;
503+ }}
504+ onBackdropPointerDown={() => {
505+ props.setOpen(false);
506+ }}
507+ >
508+ {props.mode === "files" ? (
509+ <ProjectFilePicker setOpen={props.setOpen} />
510+ ) : props.mode === "content" ? (
511+ <ProjectContentSearchDialog onOpenChange={props.setOpen} />
512+ ) : (
513+ <OpenCommandPaletteDialog
514+ openIntent={props.openIntent}
515+ setOpen={props.setOpen}
516+ openOverlayMode={props.openOverlayMode}
517+ clearOpenIntent={props.clearOpenIntent}
518+ />
519+ )}
520+ </CommandDialogPopup>
474521 );
475522 }
476523
477524 function OpenCommandPaletteDialog(props: {
478525 readonly openIntent: CommandPaletteOpenIntent | null;
479526 readonly setOpen: (open: boolean) => void;
527+ readonly openOverlayMode: (mode: SearchOverlayMode) => void;
480528 readonly clearOpenIntent: () => void;
481529 }) {
482530 const navigate = useNavigate();
483− const { clearOpenIntent, openIntent, setOpen } = props;
484− const composerHandleRef = useComposerHandleContext();
531+ const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
485532 const [query, setQuery] = useState("");
486533 const deferredQuery = useDeferredValue(query);
487534 const isActionsOnly = deferredQuery.startsWith(">");
@@ -1346,6 +1393,32 @@ function OpenCommandPaletteDialog(props: { · 858 unmodified lines
13461393 });
13471394 }
13481395
1396+ actionItems.push({
1397+ kind: "action",
1398+ value: "action:open-file-picker",
1399+ searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"],
1400+ title: "Go to file",
1401+ icon: <FileSearchIcon className={ITEM_ICON_CLASS} />,
1402+ keepOpen: true,
1403+ shortcutCommand: "filePicker.toggle",
1404+ run: async () => {
1405+ openOverlayMode("files");
1406+ },
1407+ });
1408+
1409+ actionItems.push({
1410+ kind: "action",
1411+ value: "action:search-project-contents",
1412+ searchTerms: ["search project", "find in files", "grep", "content search", "text search"],
1413+ title: "Search project contents",
1414+ icon: <TextSearchIcon className={ITEM_ICON_CLASS} />,
1415+ keepOpen: true,
1416+ shortcutCommand: "projectSearch.toggle",
1417+ run: async () => {
1418+ openOverlayMode("content");
1419+ },
1420+ });
1421+
13491422 actionItems.push({
13501423 kind: "action",
13511424 value: "action:add-project",
@@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: { · 710 unmodified lines
20622135 primaryEnvironmentId,
20632136 ]);
20642137
2138+ const inputAccessory =
2139+ addProjectCloneFlow?.step === "repository" ? (
2140+ <Tooltip>
2141+ <TooltipTrigger
2142+ render={
2143+ <Button
2144+ variant="outline"
2145+ size="xs"
2146+ tabIndex={-1}
2147+ className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"
2148+ aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
2149+ disabled={!canSubmitRemoteProjectFlow}
2150+ onMouseDown={(event) => {
2151+ event.preventDefault();
2152+ }}
2153+ onClick={() => {
2154+ void submitAddProjectCloneFlow();
2155+ }}
2156+ />
2157+ }
2158+ >
2159+ <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
2160+ <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
2161+ <Kbd>Enter</Kbd>
2162+ </KbdGroup>
2163+ </TooltipTrigger>
2164+ <TooltipPopup side="top">{remoteProjectButtonLabel ?? "Continue"} (Enter)</TooltipPopup>
2165+ </Tooltip>
2166+ ) : isBrowsing ? (
2167+ <Tooltip>
2168+ <TooltipTrigger
2169+ render={
2170+ <Button
2171+ variant="outline"
2172+ size="xs"
2173+ tabIndex={-1}
2174+ className={cn(
2175+ "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2",
2176+ hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
2177+ )}
2178+ aria-label={`${submitActionLabel} (${addShortcutLabel})`}
2179+ disabled={
2180+ !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
2181+ relativePathNeedsActiveProject ||
2182+ (isCloneDestinationStep && isRemoteProjectPending)
2183+ }
2184+ onMouseDown={(event) => {
2185+ event.preventDefault();
2186+ }}
2187+ onClick={() => {
2188+ if (relativePathNeedsActiveProject) {
2189+ return;
2190+ }
2191+ if (isCloneDestinationStep) {
2192+ void submitAddProjectCloneFlow(resolvedAddProjectPath);
2193+ } else {
2194+ void handleAddProject(resolvedAddProjectPath);
2195+ }
2196+ }}
2197+ />
2198+ }
2199+ >
2200+ <span>
2201+ {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
2202+ </span>
2203+ <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
2204+ <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
2205+ </KbdGroup>
2206+ </TooltipTrigger>
2207+ <TooltipPopup side="top">
2208+ {submitActionLabel} ({addShortcutLabel})
2209+ </TooltipPopup>
2210+ </Tooltip>
2211+ ) : null;
2212+
2213+ const footerActionLabel =
2214+ addProjectCloneFlow?.step === "repository"
2215+ ? (remoteProjectButtonLabel ?? "Continue")
2216+ : !canSubmitBrowsePath || hasHighlightedBrowseItem
2217+ ? "Select"
2218+ : undefined;
2219+
2220+ const footerTrailing = canOpenProjectFromFileManager ? (
2221+ <Button
2222+ variant="ghost"
2223+ size="xs"
2224+ className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
2225+ disabled={isPickingProjectFolder}
2226+ onClick={() => {
2227+ void handleOpenProjectFromFileManager();
2228+ }}
2229+ >
2230+ {`Open in ${fileManagerName}`}
2231+ </Button>
2232+ ) : null;
2233+
20652234 return (
2066− <CommandDialogPopup
2235+ <CommandPaletteContent
2236+ key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}
20672237 aria-label="Command palette"
2068− className="overflow-hidden p-0"
2069− data-command-palette="true"
2070− data-testid="command-palette"
2071− finalFocus={() => {
2072− composerHandleRef?.current?.focusAtEnd();
2073− return false;
2238+ autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}
2239+ footerActionLabel={footerActionLabel}
2240+ footerTrailing={footerTrailing}
2241+ inputAccessory={inputAccessory}
2242+ inputProps={{
2243+ className:
2244+ addProjectCloneFlow?.step === "repository"
2245+ ? "pe-32"
2246+ : isBrowsing
2247+ ? willCreateProjectPath
2248+ ? "pe-36"
2249+ : "pe-16"
2250+ : undefined,
2251+ placeholder: inputPlaceholder,
2252+ wrapperClassName: isSubmenu
2253+ ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto"
2254+ : undefined,
2255+ ...(isSubmenu
2256+ ? {
2257+ startAddon: (
2258+ <button
2259+ type="button"
2260+ className="flex cursor-pointer items-center"
2261+ aria-label="Back"
2262+ onClick={popView}
2263+ >
2264+ <ArrowLeftIcon />
2265+ </button>
2266+ ),
2267+ }
2268+ : isBrowsing
2269+ ? { startAddon: <FolderPlusIcon /> }
2270+ : {}),
2271+ onKeyDown: handleKeyDown,
20742272 }}
2075− onBackdropPointerDown={() => {
2076− setOpen(false);
2273+ mode="none"
2274+ onItemHighlighted={(value) => {
2275+ setHighlightedItemValue(typeof value === "string" ? value : null);
20772276 }}
2277+ onValueChange={handleQueryChange}
2278+ panelClassName="max-h-[min(28rem,70vh)]"
2279+ showBackHint={isSubmenu}
2280+ value={query}
20782281 >
2079− <Command
2080− key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}
2081− aria-label="Command palette"
2082− autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}
2083− mode="none"
2084− onItemHighlighted={(value) => {
2085− setHighlightedItemValue(typeof value === "string" ? value : null);
2086− }}
2087− onValueChange={handleQueryChange}
2088− value={query}
2089− >
2090− <div className="relative">
2091− <CommandInput
2092− className={
2093− addProjectCloneFlow?.step === "repository"
2094− ? "pe-32"
2095− : isBrowsing
2096− ? willCreateProjectPath
2097− ? "pe-36"
2098− : "pe-16"
2099− : undefined
2100− }
2101− placeholder={inputPlaceholder}
2102− wrapperClassName={
2103− isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined
2282+ {remoteProjectContext ? (
2283+ <div className="p-2 pb-0">
2284+ <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div>
2285+ <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
2286+ {remoteProjectContext.icon}
2287+ <span className="flex min-w-0 flex-1 flex-col">
2288+ <span className="truncate text-foreground text-sm">{remoteProjectContext.title}</span>
2289+ <span className="truncate text-muted-foreground/85 text-xs">
2290+ {remoteProjectContext.description}
2291+ </span>
2292+ </span>
2293+ </div>
2294+ </div>
2295+ ) : null}
2296+ <CommandPaletteResults
2297+ groups={displayedGroups}
2298+ highlightedItemValue={highlightedItemValue}
2299+ isActionsOnly={isActionsOnly}
2300+ keybindings={keybindings}
2301+ onExecuteItem={executeItem}
2302+ {...(addProjectCloneFlow?.step === "repository"
2303+ ? {
2304+ emptyStateMessage:
2305+ addProjectCloneFlow.source === "url"
2306+ ? "Enter a Git clone URL and press Enter to continue."
2307+ : "Enter a repository path and press Enter to look it up.",
21042308 }
2105− {...(isSubmenu
2106− ? {
2107− startAddon: (
2108− <button
2109− type="button"
2110− className="flex cursor-pointer items-center"
2111− aria-label="Back"
2112− onClick={popView}
2113− >
2114− <ArrowLeftIcon />
2115− </button>
2116− ),
2117− }
2118− : isBrowsing && !isSubmenu
2309+ : addProjectCloneFlow?.step === "confirm"
2310+ ? { emptyStateMessage: "Choose a destination path and press Enter to clone." }
2311+ : relativePathNeedsActiveProject
2312+ ? { emptyStateMessage: "Relative paths require an active project." }
2313+ : willCreateProjectPath
21192314 ? {
2120− startAddon: <FolderPlusIcon />,
2315+ emptyStateMessage: "Press Enter to create this folder and add it as a project.",
21212316 }
2122− : {})}
2123− onKeyDown={handleKeyDown}
2124− />
2125− {addProjectCloneFlow?.step === "repository" ? (
2126− <Tooltip>
2127− <TooltipTrigger
2128− render={
2129− <Button
2130− variant="outline"
2131− size="xs"
2132− tabIndex={-1}
2133− className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2"
2134− aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`}
2135− disabled={!canSubmitRemoteProjectFlow}
2136− onMouseDown={(event) => {
2137− event.preventDefault();
2138− }}
2139− onClick={() => {
2140− void submitAddProjectCloneFlow();
2141− }}
2142− />
2143− }
2144− >
2145− <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span>
2146− <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
2147− <Kbd>Enter</Kbd>
2148− </KbdGroup>
2149− </TooltipTrigger>
2150− <TooltipPopup side="top">
2151− {remoteProjectButtonLabel ?? "Continue"} (Enter)
2152− </TooltipPopup>
2153− </Tooltip>
2154− ) : isBrowsing ? (
2155− <Tooltip>
2156− <TooltipTrigger
2157− render={
2158− <Button
2159− variant="outline"
2160− size="xs"
2161− tabIndex={-1}
2162− className={cn(
2163− "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2",
2164− hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
2165− )}
2166− aria-label={`${submitActionLabel} (${addShortcutLabel})`}
2167− disabled={
2168− !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) ||
2169− relativePathNeedsActiveProject ||
2170− (isCloneDestinationStep && isRemoteProjectPending)
2171− }
2172− onMouseDown={(event) => {
2173− event.preventDefault();
2174− }}
2175− onClick={() => {
2176− if (relativePathNeedsActiveProject) {
2177− return;
2178− }
2179− if (isCloneDestinationStep) {
2180− void submitAddProjectCloneFlow(resolvedAddProjectPath);
2181− } else {
2182− void handleAddProject(resolvedAddProjectPath);
2183− }
2184− }}
2185− />
2186− }
2187− >
2188− <span>
2189− {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
2190− </span>
2191− <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
2192− <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
2193− </KbdGroup>
2194− </TooltipTrigger>
2195− <TooltipPopup side="top">
2196− {submitActionLabel} ({addShortcutLabel})
2197− </TooltipPopup>
2198− </Tooltip>
2199− ) : null}
2200− </div>
2201− <CommandPanel className="max-h-[min(28rem,70vh)]">
2202− {remoteProjectContext ? (
2203− <div className="p-2 pb-0">
2204− <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
2205− Repository
2206− </div>
2207− <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
2208− {remoteProjectContext.icon}
2209− <span className="flex min-w-0 flex-1 flex-col">
2210− <span className="truncate text-foreground text-sm">
2211− {remoteProjectContext.title}
2212− </span>
2213− <span className="truncate text-muted-foreground/85 text-xs">
2214− {remoteProjectContext.description}
2215− </span>
2216− </span>
2217− </div>
2218− </div>
2219− ) : null}
2220− <CommandPaletteResults
2221− groups={displayedGroups}
2222− highlightedItemValue={highlightedItemValue}
2223− isActionsOnly={isActionsOnly}
2224− keybindings={keybindings}
2225− onExecuteItem={executeItem}
2226− {...(addProjectCloneFlow?.step === "repository"
2227− ? {
2228− emptyStateMessage:
2229− addProjectCloneFlow.source === "url"
2230− ? "Enter a Git clone URL and press Enter to continue."
2231− : "Enter a repository path and press Enter to look it up.",
2232− }
2233− : addProjectCloneFlow?.step === "confirm"
2234− ? { emptyStateMessage: "Choose a destination path and press Enter to clone." }
2235− : relativePathNeedsActiveProject
2236− ? { emptyStateMessage: "Relative paths require an active project." }
2237− : willCreateProjectPath
2238− ? {
2239− emptyStateMessage:
2240− "Press Enter to create this folder and add it as a project.",
2241− }
2242− : threadSearch.isPending
2243− ? { emptyStateMessage: "Searching thread messages…" }
2244− : {})}
2245− />
2246− </CommandPanel>
2247− <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
2248− <div className="flex items-center gap-3">
2249− <KbdGroup className="items-center gap-1.5">
2250− <Kbd>
2251− <ArrowUpIcon />
2252− </Kbd>
2253− <Kbd>
2254− <ArrowDownIcon />
2255− </Kbd>
2256− <span>Navigate</span>
2257− </KbdGroup>
2258− {addProjectCloneFlow?.step === "repository" ? (
2259− <KbdGroup className="items-center gap-1.5">
2260− <Kbd>Enter</Kbd>
2261− <span>{remoteProjectButtonLabel ?? "Continue"}</span>
2262− </KbdGroup>
2263− ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? (
2264− <KbdGroup className="items-center gap-1.5">
2265− <Kbd>Enter</Kbd>
2266− <span>Select</span>
2267− </KbdGroup>
2268− ) : null}
2269− {isSubmenu ? (
2270− <KbdGroup className="items-center gap-1.5">
2271− <Kbd>Backspace</Kbd>
2272− <span>Back</span>
2273− </KbdGroup>
2274− ) : null}
2275− <KbdGroup className="items-center gap-1.5">
2276− <Kbd>Esc</Kbd>
2277− <span>Close</span>
2278− </KbdGroup>
2279− </div>
2280− {canOpenProjectFromFileManager ? (
2281− <Button
2282− variant="ghost"
2283− size="xs"
2284− className="h-auto px-2 text-muted-foreground text-xs hover:bg-transparent hover:text-foreground"
2285− disabled={isPickingProjectFolder}
2286− onClick={() => {
2287− void handleOpenProjectFromFileManager();
2288− }}
2289− >
2290− {`Open in ${fileManagerName}`}
2291− </Button>
2292− ) : null}
2293− </CommandFooter>
2294− </Command>
2295− </CommandDialogPopup>
2317+ : threadSearch.isPending
2318+ ? { emptyStateMessage: "Searching thread messages…" }
2319+ : {})}
2320+ />
2321+ </CommandPaletteContent>
22962322 );
22972323 }
apps/web/src/components/CommandPaletteContent.tsx+77−0
@@ -0,0 +1,77 @@
1+import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2+import type { ComponentProps, ReactNode } from "react";
3+
4+import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
5+import { Kbd, KbdGroup } from "./ui/kbd";
6+
7+type CommandPaletteContentProps = Omit<ComponentProps<typeof Command>, "children"> & {
8+ readonly children: ReactNode;
9+ readonly escapeLabel?: ReactNode;
10+ readonly footerActionLabel?: ReactNode;
11+ readonly footerTrailing?: ReactNode;
12+ readonly inputAccessory?: ReactNode;
13+ readonly inputProps: ComponentProps<typeof CommandInput>;
14+ readonly panelClassName?: string;
15+ readonly showBackHint?: boolean;
16+ readonly testId?: string;
17+};
18+
19+/**
20+ * Shared command palette chrome. Palette modes provide their query behavior,
21+ * results, and optional input accessory while retaining one input, panel, and
22+ * keyboard-hint gutter.
23+ */
24+export function CommandPaletteContent({
25+ children,
26+ escapeLabel = "Close",
27+ footerActionLabel,
28+ footerTrailing,
29+ inputAccessory,
30+ inputProps,
31+ panelClassName,
32+ showBackHint,
33+ testId,
34+ ...commandProps
35+}: CommandPaletteContentProps) {
36+ return (
37+ <div className="contents" data-testid={testId}>
38+ <Command {...commandProps}>
39+ <div className="relative">
40+ <CommandInput {...inputProps} />
41+ {inputAccessory}
42+ </div>
43+ <CommandPanel className={panelClassName}>{children}</CommandPanel>
44+ <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
45+ <div className="flex items-center gap-3">
46+ <KbdGroup className="items-center gap-1.5">
47+ <Kbd>
48+ <ArrowUpIcon />
49+ </Kbd>
50+ <Kbd>
51+ <ArrowDownIcon />
52+ </Kbd>
53+ <span>Navigate</span>
54+ </KbdGroup>
55+ {footerActionLabel !== undefined ? (
56+ <KbdGroup className="items-center gap-1.5">
57+ <Kbd>Enter</Kbd>
58+ <span>{footerActionLabel}</span>
59+ </KbdGroup>
60+ ) : null}
61+ {showBackHint ? (
62+ <KbdGroup className="items-center gap-1.5">
63+ <Kbd>Backspace</Kbd>
64+ <span>Back</span>
65+ </KbdGroup>
66+ ) : null}
67+ <KbdGroup className="items-center gap-1.5">
68+ <Kbd>Esc</Kbd>
69+ <span>{escapeLabel}</span>
70+ </KbdGroup>
71+ </div>
72+ {footerTrailing}
73+ </CommandFooter>
74+ </Command>
75+ </div>
76+ );
77+}
apps/web/src/components/RenderErrorBoundary.tsx+16−0
@@ -0,0 +1,16 @@
1+import { Component, type ReactNode } from "react";
2+
3+export class RenderErrorBoundary extends Component<
4+ { readonly children: ReactNode; readonly fallback: ReactNode },
5+ { readonly failed: boolean }
6+> {
7+ override state = { failed: false };
8+
9+ static getDerivedStateFromError() {
10+ return { failed: true };
11+ }
12+
13+ override render() {
14+ return this.state.failed ? this.props.fallback : this.props.children;
15+ }
16+}
apps/web/src/components/files/FileBrowserPanel.tsx+72−0
@@ -26,6 +26,10 @@ interface FileBrowserPanelProps { · 25 unmodified lines
2626 environmentId: EnvironmentId;
2727 cwd: string;
2828 projectName: string;
29+ /** File currently open in the preview pane; revealed and selected in the tree. */
30+ selectedPath: string | null;
31+ /** Bumped when the same path should be revealed again (e.g. re-opened from search). */
32+ selectedPathRevealId: number;
2933 onOpenFile: (relativePath: string) => void;
3034 }
3135
@@ -98,6 +102,8 @@ export default function FileBrowserPanel({ · 66 unmodified lines
98102 environmentId,
99103 cwd,
100104 projectName,
105+ selectedPath,
106+ selectedPathRevealId,
101107 onOpenFile,
102108 }: FileBrowserPanelProps) {
103109 const { resolvedTheme } = useTheme();
@@ -111,6 +117,9 @@ export default function FileBrowserPanel({ · 7 unmodified lines
111117 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
112118 const treePaths = useMemo(() => entries.map(treePath), [entries]);
113119 const previousTreePathsRef = useRef<readonly string[]>([]);
120+ const syncingSelectionRef = useRef(false);
121+ const treeSelectionPathRef = useRef<string | null>(null);
122+ const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null);
114123
115124 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
116125 // capture the right-click position ourselves; contextmenu is a composed
@@ -216,7 +225,12 @@ export default function FileBrowserPanel({ · 99 unmodified lines
216225 initialExpansion: 1,
217226 icons: T3_PIERRE_ICONS,
218227 onSelectionChange: (selectedPaths) => {
228+ // The drag controller's selection cache must track every change,
229+ // including reveal-driven ones, or drags act on a stale selection.
219230 dragMention.handleSelectionChange(selectedPaths);
231+ // Selection changes driven by the reveal sync below are echoes of an
232+ // already-open file, not a request to open it again.
233+ if (syncingSelectionRef.current) return;
220234 // Starting a drag selects the dragged row; that selection is a side
221235 // effect of the gesture, not a request to open the file.
222236 if (dragMention.isDragInProgress()) {
@@ -224,6 +238,7 @@ export default function FileBrowserPanel({ · 1 unmodified line
224238 }
225239 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
226240 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
241+ treeSelectionPathRef.current = selectedPath;
227242 onOpenFile(selectedPath);
228243 }
229244 },
@@ -247,6 +262,63 @@ export default function FileBrowserPanel({ · 17 unmodified lines
247262 model.resetPaths(treePaths);
248263 }, [entryKinds, model, treePaths]);
249264
265+ useEffect(() => {
266+ if (!selectedPath) {
267+ handledRevealRef.current = null;
268+ return;
269+ }
270+ const revealRequest = { path: selectedPath, revealId: selectedPathRevealId };
271+ const handledReveal = handledRevealRef.current;
272+ // Entry refreshes rebuild treePaths while the same preview stays open.
273+ // Replaying a handled reveal would close an active tree search and steal focus.
274+ if (
275+ handledReveal?.path === revealRequest.path &&
276+ handledReveal.revealId === revealRequest.revealId
277+ ) {
278+ return;
279+ }
280+ if (entryKinds.get(selectedPath) !== "file") return;
281+ const selectedItem = model.getItem(selectedPath);
282+ if (!selectedItem) return;
283+
284+ // A selection that originated inside the tree (clicking a row, possibly
285+ // in an active tree search) is already visible; re-revealing it would
286+ // close the search and clobber the user's context. Only sync external
287+ // opens (file picker, content search, chat links).
288+ const selectedInTree = model
289+ .getSelectedPaths()
290+ .some((path) => path.replace(/\/$/, "") === selectedPath);
291+ if (selectedInTree && treeSelectionPathRef.current === selectedPath) {
292+ treeSelectionPathRef.current = null;
293+ handledRevealRef.current = revealRequest;
294+ return;
295+ }
296+ treeSelectionPathRef.current = null;
297+ handledRevealRef.current = revealRequest;
298+
299+ syncingSelectionRef.current = true;
300+ model.closeSearch();
301+ for (const path of model.getSelectedPaths()) {
302+ model.getItem(path)?.deselect();
303+ }
304+
305+ // Directory rows are registered with a trailing slash (see treePath), so
306+ // ancestor lookups must use the same form to expand them.
307+ const segments = selectedPath.split("/");
308+ let ancestorPath = "";
309+ for (const segment of segments.slice(0, -1)) {
310+ ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment;
311+ const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath);
312+ if (item && "expand" in item) item.expand();
313+ }
314+
315+ selectedItem.select();
316+ model.scrollToPath(selectedPath, { focus: true, offset: "center" });
317+ queueMicrotask(() => {
318+ syncingSelectionRef.current = false;
319+ });
320+ }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]);
321+
250322 // Tag tree drags with the composer mention payload. The row is read from
251323 // the composed event path (the tree's shadow root is open), so this does
252324 // not depend on running after the tree's own dragstart handler; the drag
apps/web/src/components/files/FilePreviewPanel.tsx+140−48
@@ -51,6 +51,7 @@ import { · 50 unmodified lines
5151 remapFileCommentAnnotations,
5252 } from "./fileCommentAnnotations";
5353 import { installFileEditorDismissal } from "./fileEditorDismissal";
54+import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
5455 import { LocalCommentAnnotation } from "./LocalCommentAnnotation";
5556 import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
5657 import { fileBreadcrumbs } from "./filePath";
@@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): · 125 unmodified lines
182183 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
183184 }
184185
186+/**
187+ * Frames to keep retrying while the file contents or line metrics are not
188+ * available yet (fresh mounts hydrate asynchronously).
189+ */
190+const REVEAL_MAX_ATTEMPTS = 30;
191+/**
192+ * After scrolling to the target, hold it for a short window so late
193+ * programmatic scroll resets (editable-editor focus and state restoration)
194+ * cannot silently snap the file back to the top. Real user input cancels the
195+ * guard immediately.
196+ */
197+const REVEAL_GUARD_FRAMES = 20;
198+const REVEAL_GUARD_TOLERANCE_PX = 2;
199+
200+interface FileRevealState {
201+ frameId: number | null;
202+ cancelGuard: (() => void) | null;
203+ handledRequestId: number | null;
204+ latestRequestId: number | null;
205+}
206+
185207 function useFileLineReveal(
186208 relativePath: string | null,
187209 revealLine: number | null,
188210 revealRequestId: number,
189211 ): FilePostRender {
190− const [handledRequestIdsByPath] = useState(() => new Map<string, number>());
191− const [latestRequestIdsByPath] = useState(() => new Map<string, number>());
192− const [pendingFramesByPath] = useState(() => new Map<string, number>());
212+ const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>());
193213
194214 return useCallback<FilePostRender>(
195215 (fileContainer, instance, phase) => {
196216 if (relativePath === null) return;
197217
218+ const existingState = revealStatesByPath.get(relativePath);
219+ const state: FileRevealState = existingState ?? {
220+ frameId: null,
221+ cancelGuard: null,
222+ handledRequestId: null,
223+ latestRequestId: null,
224+ };
225+ if (!existingState) revealStatesByPath.set(relativePath, state);
226+
198227 const cancelPendingReveal = () => {
199− const frameId = pendingFramesByPath.get(relativePath);
200− if (frameId !== undefined) {
201− cancelAnimationFrame(frameId);
202− pendingFramesByPath.delete(relativePath);
228+ if (state.frameId !== null) {
229+ cancelAnimationFrame(state.frameId);
230+ state.frameId = null;
203231 }
232+ state.cancelGuard?.();
204233 };
205234
206235 if (phase === "unmount") {
@@ -208,18 +237,20 @@ function useFileLineReveal( · 1 unmodified line
208237 return;
209238 }
210239
240+ const contents = instance.file?.contents;
211241 const targetLine =
212− revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine);
242+ revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine);
213243 updateFileLinkReveal(fileContainer, targetLine);
214244
215245 if (!(instance instanceof VirtualizedFile)) return;
216246
217− if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) {
247+ if (state.latestRequestId !== revealRequestId) {
218248 cancelPendingReveal();
219− latestRequestIdsByPath.set(relativePath, revealRequestId);
249+ state.latestRequestId = revealRequestId;
250+ state.handledRequestId = null;
220251 }
221252
222− if (targetLine === null) {
253+ if (revealLine === null) {
223254 fileContainer.style.minHeight = "";
224255 return;
225256 }
@@ -230,54 +261,113 @@ function useFileLineReveal( · 4 unmodified lines
230261 Math.max(instance.height, scrollContainer.clientHeight),
231262 )}px`;
232263
233− if (
234− handledRequestIdsByPath.get(relativePath) === revealRequestId ||
235− pendingFramesByPath.has(relativePath)
236− ) {
264+ if (state.handledRequestId === revealRequestId || state.frameId !== null) {
237265 return;
238266 }
239267
240− const reveal = () => {
241− pendingFramesByPath.delete(relativePath);
242− if (
243− latestRequestIdsByPath.get(relativePath) !== revealRequestId ||
244− !fileContainer.isConnected
245− ) {
246− return;
247− }
248−
249− const linePosition = instance.getLinePosition(targetLine);
250− if (!linePosition) return;
268+ const resolveScrollTarget = (line: number): number | null => {
269+ const linePosition = instance.getLinePosition(line);
270+ if (!linePosition) return null;
251271
272+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
252273 const fileTop =
253274 scrollContainer.scrollTop +
254275 fileContainer.getBoundingClientRect().top -
255− scrollContainer.getBoundingClientRect().top;
256− const centeredTop = Math.max(
257− 0,
258− fileTop +
259− linePosition.top -
260− Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2),
261− );
262− const maxScrollTop = Math.max(
263− 0,
264− scrollContainer.scrollHeight - scrollContainer.clientHeight,
265− );
276+ scrollContainerRect.top;
277+ const root = fileContainer.shadowRoot ?? fileContainer;
278+ const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`);
279+ const renderedLineRect = renderedLineElement?.getBoundingClientRect();
266280
267− scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop);
268− handledRequestIdsByPath.set(relativePath, revealRequestId);
281+ return resolveCenteredFileLineScrollTop({
282+ scrollTop: scrollContainer.scrollTop,
283+ scrollHeight: scrollContainer.scrollHeight,
284+ viewportTop: scrollContainerRect.top,
285+ viewportHeight: scrollContainer.clientHeight,
286+ fileTop,
287+ estimatedLine: linePosition,
288+ ...(renderedLineRect && renderedLineRect.height > 0
289+ ? {
290+ renderedLine: {
291+ top: renderedLineRect.top,
292+ height: renderedLineRect.height,
293+ },
294+ }
295+ : {}),
296+ });
269297 };
270298
271− pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal));
299+ const guardScrollTarget = (line: number) => {
300+ let framesLeft = REVEAL_GUARD_FRAMES;
301+ let guardFrameId: number | null = null;
302+ const cancelGuard = () => {
303+ if (guardFrameId !== null) {
304+ cancelAnimationFrame(guardFrameId);
305+ guardFrameId = null;
306+ }
307+ scrollContainer.removeEventListener("wheel", cancelGuard);
308+ scrollContainer.removeEventListener("touchstart", cancelGuard);
309+ scrollContainer.removeEventListener("pointerdown", cancelGuard, true);
310+ window.removeEventListener("keydown", cancelGuard, true);
311+ if (state.cancelGuard === cancelGuard) state.cancelGuard = null;
312+ };
313+ scrollContainer.addEventListener("wheel", cancelGuard, { passive: true });
314+ scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true });
315+ // Pierre stops gutter pointer events from bubbling. Listen in capture
316+ // so starting a comment cancels the reveal guard before the row expands.
317+ scrollContainer.addEventListener("pointerdown", cancelGuard, {
318+ passive: true,
319+ capture: true,
320+ });
321+ window.addEventListener("keydown", cancelGuard, true);
322+ const holdTarget = () => {
323+ guardFrameId = null;
324+ framesLeft -= 1;
325+ if (framesLeft <= 0 || !scrollContainer.isConnected) {
326+ cancelGuard();
327+ return;
328+ }
329+ const targetTop = resolveScrollTarget(line);
330+ if (
331+ targetTop !== null &&
332+ Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX
333+ ) {
334+ scrollContainer.scrollTop = targetTop;
335+ }
336+ guardFrameId = requestAnimationFrame(holdTarget);
337+ };
338+ guardFrameId = requestAnimationFrame(holdTarget);
339+ state.cancelGuard = cancelGuard;
340+ };
341+
342+ const scheduleReveal = (attempt: number) => {
343+ state.frameId = requestAnimationFrame(() => {
344+ state.frameId = null;
345+ if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) {
346+ return;
347+ }
348+
349+ // Contents and line metrics can lag the first post-render on fresh
350+ // mounts; clamping against missing contents would scroll to line 1
351+ // and wrongly mark the request handled.
352+ const currentContents = instance.file?.contents;
353+ const line =
354+ currentContents === undefined ? null : clampFileLine(currentContents, revealLine);
355+ const targetTop = line === null ? null : resolveScrollTarget(line);
356+ if (line === null || targetTop === null) {
357+ if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1);
358+ return;
359+ }
360+ updateFileLinkReveal(fileContainer, line);
361+
362+ scrollContainer.scrollTop = targetTop;
363+ state.handledRequestId = revealRequestId;
364+ guardScrollTarget(line);
365+ });
366+ };
367+
368+ scheduleReveal(0);
272369 },
273− [
274− handledRequestIdsByPath,
275− latestRequestIdsByPath,
276− pendingFramesByPath,
277− relativePath,
278− revealLine,
279− revealRequestId,
280− ],
370+ [revealStatesByPath, relativePath, revealLine, revealRequestId],
281371 );
282372 }
283373
@@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ · 679 unmodified lines
9631053 environmentId={environmentId}
9641054 cwd={cwd}
9651055 projectName={projectName}
1056+ selectedPath={relativePath}
1057+ selectedPathRevealId={revealRequestId}
9661058 onOpenFile={onOpenFile}
9671059 />
9681060 </aside>
apps/web/src/components/files/ProjectFilePicker.logic.test.ts+83−0
@@ -0,0 +1,83 @@
1+import { assert, describe, it } from "vite-plus/test";
2+
3+import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic";
4+
5+function pathsForQuery(entries: Parameters<typeof getProjectFilePickerMatches>[0], query: string) {
6+ return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path }));
7+}
8+
9+const entries = [
10+ { kind: "directory", path: "apps/web/src" },
11+ { kind: "file", path: "apps/web/src/index.ts" },
12+ { kind: "file", path: "packages/shared/src/index.ts" },
13+ { kind: "file", path: "README.md" },
14+ { kind: "file", path: ".gitignore" },
15+] as const;
16+
17+describe("getProjectFilePickerMatches", () => {
18+ it("returns files only and preserves index order for an empty query", () => {
19+ assert.deepEqual(pathsForQuery(entries, ""), [
20+ { name: "index.ts", path: "apps/web/src/index.ts" },
21+ { name: "index.ts", path: "packages/shared/src/index.ts" },
22+ { name: "README.md", path: "README.md" },
23+ { name: ".gitignore", path: ".gitignore" },
24+ ]);
25+ });
26+
27+ it("preserves the server result order", () => {
28+ assert.deepEqual(pathsForQuery(entries, "index"), [
29+ { name: "index.ts", path: "apps/web/src/index.ts" },
30+ { name: "index.ts", path: "packages/shared/src/index.ts" },
31+ { name: "README.md", path: "README.md" },
32+ { name: ".gitignore", path: ".gitignore" },
33+ ]);
34+ });
35+
36+ it("supports space-separated path tokens and a result limit", () => {
37+ assert.deepEqual(
38+ getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({
39+ name,
40+ path,
41+ })),
42+ [{ name: "index.ts", path: "apps/web/src/index.ts" }],
43+ );
44+ });
45+
46+ it("matches ordered characters while allowing skipped characters", () => {
47+ const fuzzyEntries = [
48+ { kind: "file", path: "src/TestFlags.tsx" },
49+ { kind: "file", path: "src/SubtestFlow.tsx" },
50+ { kind: "file", path: "src/useSubtestFlags.ts" },
51+ {
52+ kind: "file",
53+ path: "src/useSubtestFlags/useTabActivity.ts",
54+ },
55+ ] as const;
56+
57+ assert.deepEqual(
58+ pathsForQuery(fuzzyEntries, "testf").map(({ name }) => name),
59+ ["TestFlags.tsx", "SubtestFlow.tsx", "useSubtestFlags.ts", "useTabActivity.ts"],
60+ );
61+ assert.deepEqual(getProjectFilePickerMatches(fuzzyEntries, "tsfl")[0], {
62+ name: "TestFlags.tsx",
63+ nameMatchIndices: [0, 2, 4, 5],
64+ path: "src/TestFlags.tsx",
65+ pathMatchIndices: [4, 6, 8, 9],
66+ });
67+ });
68+
69+ it("uses the first ordered subsequence for highlighting", () => {
70+ assert.deepEqual(
71+ getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices,
72+ [0, 2, 4],
73+ );
74+ });
75+
76+ it("normalizes path prefixes consistently with server search", () => {
77+ assert.deepEqual(
78+ getProjectFilePickerMatches([{ kind: "file", path: "src/index.ts" }], "@/src")[0]
79+ ?.pathMatchIndices,
80+ [0, 1, 2],
81+ );
82+ });
83+});
apps/web/src/components/files/ProjectFilePicker.logic.ts+73−0
@@ -0,0 +1,73 @@
1+import type { ProjectEntry } from "@t3tools/contracts";
2+import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
3+
4+export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200;
5+
6+export interface ProjectFilePickerMatch {
7+ readonly name: string;
8+ readonly nameMatchIndices: ReadonlyArray<number>;
9+ readonly path: string;
10+ readonly pathMatchIndices: ReadonlyArray<number>;
11+}
12+
13+function fileName(path: string): string {
14+ return path.slice(path.lastIndexOf("/") + 1);
15+}
16+
17+/**
18+ * First ordered subsequence of `query` inside `value`, as highlight indices.
19+ * Returns null when `value` does not contain the subsequence at all — the
20+ * entry still renders (the server matched it), just without highlights.
21+ */
22+function findMatchIndices(value: string, query: string): number[] | null {
23+ if (!query) return [];
24+
25+ const normalizedValue = value.toLowerCase();
26+ const indices: number[] = [];
27+ let queryIndex = 0;
28+
29+ for (let valueIndex = 0; valueIndex < normalizedValue.length; valueIndex += 1) {
30+ if (normalizedValue[valueIndex] !== query[queryIndex]) continue;
31+ indices.push(valueIndex);
32+ queryIndex += 1;
33+ if (queryIndex === query.length) return indices;
34+ }
35+
36+ return null;
37+}
38+
39+/**
40+ * Maps server search results to picker rows. Server ordering is preserved —
41+ * ranking already happened there; this pass only filters to files and
42+ * computes highlight positions with the same query normalization the server
43+ * applied.
44+ */
45+export function getProjectFilePickerMatches(
46+ entries: ReadonlyArray<ProjectEntry>,
47+ rawQuery: string,
48+ limit = PROJECT_FILE_PICKER_RESULT_LIMIT,
49+): ProjectFilePickerMatch[] {
50+ if (limit <= 0) return [];
51+
52+ const query = normalizeSearchQuery(rawQuery, {
53+ trimLeadingPattern: /^[@./]+/,
54+ }).replaceAll(/\s/g, "");
55+ const matches: ProjectFilePickerMatch[] = [];
56+
57+ for (const entry of entries) {
58+ if (entry.kind !== "file") continue;
59+
60+ const name = fileName(entry.path);
61+ const nameMatchIndices = findMatchIndices(name, query);
62+ const pathMatchIndices = findMatchIndices(entry.path, query);
63+ matches.push({
64+ name,
65+ nameMatchIndices: nameMatchIndices ?? [],
66+ path: entry.path,
67+ pathMatchIndices: pathMatchIndices ?? [],
68+ });
69+ if (matches.length >= limit) break;
70+ }
71+
72+ return matches;
73+}
apps/web/src/components/files/ProjectFilePicker.tsx+163−0
@@ -0,0 +1,163 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { useMemo, useState, type ReactNode } from "react";
3+
4+import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
5+import { useTheme } from "~/hooks/useTheme";
6+import { useRightPanelStore } from "~/rightPanelStore";
7+import { primaryServerKeybindingsAtom } from "~/state/server";
8+
9+import { PierreEntryIcon } from "../chat/PierreEntryIcon";
10+import { CommandPaletteContent } from "../CommandPaletteContent";
11+import { type CommandPaletteActionItem } from "../CommandPalette.logic";
12+import { CommandPaletteResults } from "../CommandPaletteResults";
13+import {
14+ getProjectFilePickerMatches,
15+ PROJECT_FILE_PICKER_RESULT_LIMIT,
16+} from "./ProjectFilePicker.logic";
17+import { useProjectFilePickerQuery } from "./projectFilesQueryState";
18+
19+interface ProjectFilePickerProps {
20+ readonly setOpen: (open: boolean) => void;
21+}
22+
23+function HighlightedFuzzyText(props: {
24+ readonly active: boolean;
25+ readonly indices: ReadonlyArray<number>;
26+ readonly value: string;
27+}) {
28+ if (!props.active) return props.value;
29+
30+ const parts: ReactNode[] = [];
31+ let start = 0;
32+ for (const index of props.indices) {
33+ if (start < index) parts.push(props.value.slice(start, index));
34+ parts.push(
35+ <strong className="font-semibold text-foreground" key={index}>
36+ {props.value[index]}
37+ </strong>,
38+ );
39+ start = index + 1;
40+ }
41+ if (start < props.value.length) parts.push(props.value.slice(start));
42+
43+ return <span className="text-muted-foreground">{parts}</span>;
44+}
45+
46+function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string {
47+ if (error) return error;
48+ const isSearching = query.trim().length > 0;
49+ if (isPending) return isSearching ? "Searching workspace files…" : "Indexing workspace files…";
50+ return isSearching ? "No matching files." : "No files found.";
51+}
52+
53+function EmptyProjectFilePicker() {
54+ return (
55+ <CommandPaletteContent
56+ aria-label="File picker"
57+ escapeLabel="Back"
58+ footerActionLabel="Open file"
59+ inputProps={{ disabled: true, placeholder: "Search files…" }}
60+ mode="none"
61+ testId="project-file-picker"
62+ value=""
63+ >
64+ <div className="py-10 text-center text-sm text-muted-foreground">
65+ Open a project to search its files.
66+ </div>
67+ </CommandPaletteContent>
68+ );
69+}
70+
71+function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) {
72+ const { target } = props;
73+ const [query, setQuery] = useState("");
74+ const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
75+ const result = useProjectFilePickerQuery(
76+ target.environmentId,
77+ target.cwd,
78+ query,
79+ PROJECT_FILE_PICKER_RESULT_LIMIT,
80+ );
81+ const { resolvedTheme } = useTheme();
82+ const keybindings = useAtomValue(primaryServerKeybindingsAtom);
83+ const matches = useMemo(
84+ () => getProjectFilePickerMatches(result.entries, result.matchedQuery),
85+ [result.entries, result.matchedQuery],
86+ );
87+ const hasMatchedQuery = /\S/.test(result.matchedQuery);
88+ const items = useMemo<CommandPaletteActionItem[]>(
89+ () =>
90+ matches.map((match) => ({
91+ kind: "action",
92+ value: `file:${match.path}`,
93+ searchTerms: [match.name, match.path],
94+ title: (
95+ <HighlightedFuzzyText
96+ active={hasMatchedQuery}
97+ value={match.name}
98+ indices={match.nameMatchIndices}
99+ />
100+ ),
101+ description: (
102+ <HighlightedFuzzyText
103+ active={hasMatchedQuery}
104+ value={match.path}
105+ indices={match.pathMatchIndices}
106+ />
107+ ),
108+ icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />,
109+ run: async () => {
110+ useRightPanelStore.getState().openFile(target.threadRef, match.path);
111+ },
112+ })),
113+ [hasMatchedQuery, matches, resolvedTheme, target.threadRef],
114+ );
115+
116+ const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending);
117+
118+ return (
119+ <CommandPaletteContent
120+ aria-label="File picker"
121+ autoHighlight="always"
122+ escapeLabel="Back"
123+ footerActionLabel="Open file"
124+ inputProps={{ placeholder: "Search files…" }}
125+ mode="none"
126+ onItemHighlighted={(value) => {
127+ setHighlightedItemValue(typeof value === "string" ? value : null);
128+ }}
129+ onValueChange={(value) => {
130+ setHighlightedItemValue(null);
131+ setQuery(value);
132+ }}
133+ panelClassName="max-h-[min(34rem,76vh)]"
134+ testId="project-file-picker"
135+ value={query}
136+ >
137+ <CommandPaletteResults
138+ groups={
139+ items.length > 0 ? [{ value: "project-files", label: target.projectName, items }] : []
140+ }
141+ highlightedItemValue={highlightedItemValue}
142+ isActionsOnly={false}
143+ keybindings={keybindings}
144+ onExecuteItem={(item) => {
145+ if (item.kind !== "action") return;
146+ props.setOpen(false);
147+ void item.run();
148+ }}
149+ emptyStateMessage={emptyStateMessage}
150+ />
151+ </CommandPaletteContent>
152+ );
153+}
154+
155+export function ProjectFilePicker(props: ProjectFilePickerProps) {
156+ const target = useActiveProjectTarget();
157+
158+ if (!target) {
159+ return <EmptyProjectFilePicker />;
160+ }
161+
162+ return <OpenProjectFilePicker setOpen={props.setOpen} target={target} />;
163+}
apps/web/src/components/files/fileLineReveal.test.ts+32−0
@@ -0,0 +1,32 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
4+
5+describe("resolveCenteredFileLineScrollTop", () => {
6+ it("centers an estimated virtualized line position", () => {
7+ expect(
8+ resolveCenteredFileLineScrollTop({
9+ scrollTop: 0,
10+ scrollHeight: 2_000,
11+ viewportTop: 100,
12+ viewportHeight: 400,
13+ fileTop: 20,
14+ estimatedLine: { top: 1_000, height: 20 },
15+ }),
16+ ).toBe(830);
17+ });
18+
19+ it("corrects a stale estimate from the rendered line geometry", () => {
20+ expect(
21+ resolveCenteredFileLineScrollTop({
22+ scrollTop: 830,
23+ scrollHeight: 2_000,
24+ viewportTop: 100,
25+ viewportHeight: 400,
26+ fileTop: 20,
27+ estimatedLine: { top: 1_000, height: 20 },
28+ renderedLine: { top: 620, height: 20 },
29+ }),
30+ ).toBe(1_160);
31+ });
32+});
apps/web/src/components/files/fileLineReveal.ts+24−0
@@ -0,0 +1,24 @@
1+interface LineGeometry {
2+ readonly top: number;
3+ readonly height: number;
4+}
5+
6+interface CenteredFileLineScrollInput {
7+ readonly scrollTop: number;
8+ readonly scrollHeight: number;
9+ readonly viewportTop: number;
10+ readonly viewportHeight: number;
11+ readonly fileTop: number;
12+ readonly estimatedLine: LineGeometry;
13+ readonly renderedLine?: LineGeometry;
14+}
15+
16+export function resolveCenteredFileLineScrollTop(input: CenteredFileLineScrollInput): number {
17+ const lineTop =
18+ input.renderedLine === undefined
19+ ? input.fileTop + input.estimatedLine.top
20+ : input.scrollTop + input.renderedLine.top - input.viewportTop;
21+ const lineHeight = input.renderedLine?.height ?? input.estimatedLine.height;
22+ const centeredTop = Math.max(0, lineTop - Math.max(0, (input.viewportHeight - lineHeight) / 2));
23+ return Math.min(centeredTop, Math.max(0, input.scrollHeight - input.viewportHeight));
24+}
apps/web/src/components/files/projectFilesQueryState.ts+27−0
@@ -11,6 +11,7 @@ import { useCallback } from "react"; · 10 unmodified lines
1111
1212 import { appAtomRegistry } from "~/rpc/atomRegistry";
1313 import { projectEnvironment } from "~/state/projects";
14+import { useProjectPathSearch } from "~/state/queries";
1415 import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1516
1617 const EMPTY_PROJECT_FILE_PATH = "";
@@ -136,6 +137,32 @@ export function useProjectEntriesQuery( · 119 unmodified lines
136137 };
137138 }
138139
140+/**
141+ * Backing query for the project file picker: a debounced, bounded, file-only
142+ * server search. An empty query is a valid request — the index answers it
143+ * with frecency-ordered files, so the picker's initial view is recent files
144+ * without transferring the full workspace listing. `matchedQuery` is the
145+ * query the returned entries were computed for, so the caller can highlight
146+ * against results instead of half-typed input.
147+ */
148+export function useProjectFilePickerQuery(
149+ environmentId: EnvironmentId,
150+ cwd: string,
151+ query: string,
152+ limit: number,
153+) {
154+ const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, {
155+ allowEmptyQuery: true,
156+ });
157+
158+ return {
159+ entries: search.isPending ? [] : search.entries,
160+ error: search.error,
161+ isPending: search.isPending,
162+ matchedQuery: search.searchedQuery,
163+ };
164+}
165+
139166 export function useProjectFileQuery(
140167 environmentId: EnvironmentId,
141168 cwd: string,
apps/web/src/components/search/HighlightedSearchLine.tsx+183−0
@@ -0,0 +1,183 @@
1+import { getFiletypeFromFileName } from "@pierre/diffs";
2+import type { ProjectContentMatch } from "@t3tools/contracts";
3+import { memo, Suspense, use, useMemo, type CSSProperties } from "react";
4+
5+import { resolveDiffThemeName } from "~/lib/diffRendering";
6+import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
7+
8+import { RenderErrorBoundary } from "../RenderErrorBoundary";
9+
10+interface Range {
11+ readonly start: number;
12+ readonly end: number;
13+}
14+
15+interface CodeToken {
16+ readonly content: string;
17+ readonly offset: number;
18+ readonly color?: string;
19+ readonly fontStyle?: number;
20+}
21+
22+interface Segment {
23+ readonly content: string;
24+ readonly isMatch: boolean;
25+ readonly start: number;
26+ readonly end: number;
27+ readonly token: CodeToken;
28+}
29+
30+function normalizeRanges(match: ProjectContentMatch): Range[] {
31+ const ranges = match.matchRanges
32+ .map((range) => ({
33+ start: Math.max(0, Math.min(match.lineContent.length, range.start)),
34+ end: Math.max(0, Math.min(match.lineContent.length, range.end)),
35+ }))
36+ .filter((range) => range.end > range.start)
37+ .toSorted((left, right) => left.start - right.start);
38+
39+ const merged: Array<{ start: number; end: number }> = [];
40+ for (const range of ranges) {
41+ const previous = merged.at(-1);
42+ if (previous && range.start <= previous.end) {
43+ previous.end = Math.max(previous.end, range.end);
44+ } else {
45+ merged.push({ ...range });
46+ }
47+ }
48+ return merged;
49+}
50+
51+function splitToken(line: string, token: CodeToken, ranges: ReadonlyArray<Range>): Segment[] {
52+ const segments: Segment[] = [];
53+ const tokenEnd = token.offset + token.content.length;
54+ let cursor = token.offset;
55+
56+ for (const range of ranges) {
57+ if (range.end <= cursor) continue;
58+ if (range.start >= tokenEnd) break;
59+
60+ const matchStart = Math.max(cursor, range.start);
61+ if (matchStart > cursor) {
62+ segments.push({
63+ content: line.slice(cursor, matchStart),
64+ isMatch: false,
65+ start: cursor,
66+ end: matchStart,
67+ token,
68+ });
69+ }
70+
71+ const matchEnd = Math.min(tokenEnd, range.end);
72+ segments.push({
73+ content: line.slice(matchStart, matchEnd),
74+ isMatch: true,
75+ start: matchStart,
76+ end: matchEnd,
77+ token,
78+ });
79+ cursor = matchEnd;
80+ }
81+
82+ if (cursor < tokenEnd) {
83+ segments.push({
84+ content: line.slice(cursor, tokenEnd),
85+ isMatch: false,
86+ start: cursor,
87+ end: tokenEnd,
88+ token,
89+ });
90+ }
91+ return segments;
92+}
93+
94+function tokenStyle(token: CodeToken): CSSProperties {
95+ const fontStyle = token.fontStyle ?? 0;
96+ return {
97+ ...(token.color ? { color: token.color } : {}),
98+ ...(fontStyle & 1 ? { fontStyle: "italic" } : {}),
99+ ...(fontStyle & 2 ? { fontWeight: 700 } : {}),
100+ ...(fontStyle & 4 ? { textDecoration: "underline" } : {}),
101+ };
102+}
103+
104+function HighlightedTokens(props: {
105+ readonly line: string;
106+ readonly ranges: ReadonlyArray<Range>;
107+ readonly tokens: ReadonlyArray<CodeToken>;
108+}) {
109+ return props.tokens
110+ .flatMap((token) => splitToken(props.line, token, props.ranges))
111+ .map((segment) =>
112+ segment.isMatch ? (
113+ <mark
114+ className="rounded-[2px] bg-primary/25 text-inherit"
115+ key={`${segment.start}:${segment.end}:match`}
116+ style={tokenStyle(segment.token)}
117+ >
118+ {segment.content}
119+ </mark>
120+ ) : (
121+ <span key={`${segment.start}:${segment.end}:code`} style={tokenStyle(segment.token)}>
122+ {segment.content}
123+ </span>
124+ ),
125+ );
126+}
127+
128+function SyntaxHighlightedTokens(props: {
129+ readonly line: string;
130+ readonly language: string;
131+ readonly ranges: ReadonlyArray<Range>;
132+ readonly theme: "light" | "dark";
133+}) {
134+ const highlighter = use(getSyntaxHighlighterPromise(props.language));
135+ const tokens = useMemo(() => {
136+ try {
137+ return highlighter.codeToTokens(props.line, {
138+ lang: props.language,
139+ theme: resolveDiffThemeName(props.theme),
140+ }).tokens[0];
141+ } catch {
142+ return undefined;
143+ }
144+ }, [highlighter, props.language, props.line, props.theme]);
145+
146+ return tokens ? (
147+ <HighlightedTokens line={props.line} ranges={props.ranges} tokens={tokens} />
148+ ) : (
149+ <HighlightedTokens
150+ line={props.line}
151+ ranges={props.ranges}
152+ tokens={[{ content: props.line, offset: 0 }]}
153+ />
154+ );
155+}
156+
157+export const HighlightedSearchLine = memo(function HighlightedSearchLine(props: {
158+ readonly match: ProjectContentMatch;
159+ readonly path: string;
160+ readonly theme: "light" | "dark";
161+}) {
162+ const ranges = useMemo(() => normalizeRanges(props.match), [props.match]);
163+ const fallback = (
164+ <HighlightedTokens
165+ line={props.match.lineContent}
166+ ranges={ranges}
167+ tokens={[{ content: props.match.lineContent, offset: 0 }]}
168+ />
169+ );
170+
171+ return (
172+ <RenderErrorBoundary fallback={fallback}>
173+ <Suspense fallback={fallback}>
174+ <SyntaxHighlightedTokens
175+ line={props.match.lineContent}
176+ language={getFiletypeFromFileName(props.path)}
177+ ranges={ranges}
178+ theme={props.theme}
179+ />
180+ </Suspense>
181+ </RenderErrorBoundary>
182+ );
183+});
apps/web/src/components/search/ProjectContentSearchDialog.tsx+315−0
@@ -0,0 +1,315 @@
1+import type { ProjectContentMatch } from "@t3tools/contracts";
2+import { LoaderCircle } from "lucide-react";
3+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
4+
5+import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
6+import { useTheme } from "~/hooks/useTheme";
7+import { cn } from "~/lib/utils";
8+import { useRightPanelStore } from "~/rightPanelStore";
9+import { useProjectContentSearch } from "~/state/queries";
10+
11+import { PierreEntryIcon } from "../chat/PierreEntryIcon";
12+import { CommandPaletteContent } from "../CommandPaletteContent";
13+import { ScrollArea } from "../ui/scroll-area";
14+import { HighlightedSearchLine } from "./HighlightedSearchLine";
15+
16+interface ProjectContentSearchDialogProps {
17+ readonly onOpenChange: (open: boolean) => void;
18+}
19+
20+/**
21+ * Result rows are syntax highlighted, so mounting all 500 possible rows at
22+ * once stalls the UI. Rows render in windows that grow as the sentinel at the
23+ * bottom of the list scrolls into view (or keyboard navigation moves past
24+ * the rendered window).
25+ */
26+const VISIBLE_MATCH_WINDOW = 100;
27+
28+interface MatchGroup {
29+ readonly path: string;
30+ readonly matches: ReadonlyArray<ProjectContentMatch & { readonly resultIndex: number }>;
31+}
32+
33+function splitPath(path: string): { readonly name: string; readonly directory: string } {
34+ const separator = path.lastIndexOf("/");
35+ return separator === -1
36+ ? { name: path, directory: "" }
37+ : { name: path.slice(separator + 1), directory: path.slice(0, separator) };
38+}
39+
40+function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] {
41+ const groups = new Map<string, Array<ProjectContentMatch & { readonly resultIndex: number }>>();
42+ matches.forEach((match, resultIndex) => {
43+ const group = groups.get(match.path);
44+ const indexedMatch = { ...match, resultIndex };
45+ if (group) {
46+ group.push(indexedMatch);
47+ } else {
48+ groups.set(match.path, [indexedMatch]);
49+ }
50+ });
51+ return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches }));
52+}
53+
54+function SearchOptionButton(props: {
55+ readonly active: boolean;
56+ readonly label: string;
57+ readonly onClick: () => void;
58+ readonly children: ReactNode;
59+}) {
60+ return (
61+ <button
62+ type="button"
63+ aria-label={props.label}
64+ aria-pressed={props.active}
65+ title={props.label}
66+ className={cn(
67+ "flex size-8 shrink-0 items-center justify-center rounded-[5px] font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
68+ props.active && "bg-accent text-foreground shadow-sm",
69+ )}
70+ onClick={props.onClick}
71+ >
72+ {props.children}
73+ </button>
74+ );
75+}
76+
77+function EmptyContentSearchDialog() {
78+ return (
79+ <CommandPaletteContent
80+ aria-label="Search project contents"
81+ escapeLabel="Back"
82+ footerActionLabel="Open file"
83+ inputProps={{ disabled: true, placeholder: "Search project contents…" }}
84+ mode="none"
85+ panelClassName="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground"
86+ testId="project-content-search"
87+ value=""
88+ >
89+ Open a project to search its files.
90+ </CommandPaletteContent>
91+ );
92+}
93+
94+function OpenContentSearchDialog(props: {
95+ readonly onOpenChange: (open: boolean) => void;
96+ readonly target: ActiveProjectTarget;
97+}) {
98+ const { target } = props;
99+ const { resolvedTheme } = useTheme();
100+ const [query, setQuery] = useState("");
101+ const [caseSensitive, setCaseSensitive] = useState(false);
102+ const [wholeWord, setWholeWord] = useState(false);
103+ const [useRegex, setUseRegex] = useState(false);
104+ const [selectedIndex, setSelectedIndex] = useState(0);
105+
106+ const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW);
107+
108+ const search = useProjectContentSearch({
109+ environmentId: target.environmentId,
110+ cwd: target.cwd,
111+ query,
112+ caseSensitive,
113+ wholeWord,
114+ useRegex,
115+ });
116+ const canOpenMatches = !search.isPending;
117+ const matches = search.matches;
118+ const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]);
119+ const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]);
120+
121+ useEffect(() => {
122+ setSelectedIndex(0);
123+ setVisibleCount(VISIBLE_MATCH_WINDOW);
124+ }, [matches]);
125+
126+ useEffect(() => {
127+ if (selectedIndex >= visibleCount) {
128+ setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW);
129+ return;
130+ }
131+ document
132+ .querySelector<HTMLElement>(`[data-content-search-result="${selectedIndex}"]`)
133+ ?.scrollIntoView({ block: "nearest" });
134+ }, [selectedIndex, visibleCount]);
135+
136+ const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => {
137+ if (!sentinel) return;
138+ const observer = new IntersectionObserver((entries) => {
139+ if (entries.some((entry) => entry.isIntersecting)) {
140+ setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW);
141+ }
142+ });
143+ observer.observe(sentinel);
144+ return () => observer.disconnect();
145+ }, []);
146+
147+ const openMatch = (match: ProjectContentMatch) => {
148+ if (!canOpenMatches) return;
149+ props.onOpenChange(false);
150+ useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber);
151+ };
152+ const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]);
153+ const showSearchStatus =
154+ search.hasQuery || search.isPending || search.error !== null || search.invalidRegex;
155+
156+ return (
157+ <CommandPaletteContent
158+ aria-label={`Search file contents in ${target.projectName}`}
159+ escapeLabel="Back"
160+ footerActionLabel="Open file"
161+ inputAccessory={
162+ <div className="absolute inset-e-2.5 top-1/2 flex shrink-0 -translate-y-1/2 items-center gap-0.5 rounded-md border bg-muted/30 p-0.5">
163+ <SearchOptionButton
164+ active={caseSensitive}
165+ label="Match case"
166+ onClick={() => setCaseSensitive((current) => !current)}
167+ >
168+ Aa
169+ </SearchOptionButton>
170+ <SearchOptionButton
171+ active={wholeWord}
172+ label="Match whole word"
173+ onClick={() => setWholeWord((current) => !current)}
174+ >
175+ <span className="underline decoration-2 underline-offset-2">ab</span>
176+ </SearchOptionButton>
177+ <SearchOptionButton
178+ active={useRegex}
179+ label="Use regular expression"
180+ onClick={() => setUseRegex((current) => !current)}
181+ >
182+ .*
183+ </SearchOptionButton>
184+ </div>
185+ }
186+ inputProps={{
187+ className: "pe-30",
188+ placeholder: `Search in ${target.projectName}`,
189+ onKeyDown: (event) => {
190+ if (event.key === "ArrowDown" && matches.length > 0) {
191+ event.preventDefault();
192+ setSelectedIndex((current) => (current + 1) % matches.length);
193+ } else if (event.key === "ArrowUp" && matches.length > 0) {
194+ event.preventDefault();
195+ setSelectedIndex((current) => (current - 1 + matches.length) % matches.length);
196+ } else if (event.key === "Enter") {
197+ // While a newer query is debouncing or in flight, the visible
198+ // matches belong to the previous query; opening one would jump
199+ // to a result the user did not ask for.
200+ if (!canOpenMatches) {
201+ event.preventDefault();
202+ return;
203+ }
204+ const match = matches[selectedIndex];
205+ if (match) {
206+ event.preventDefault();
207+ openMatch(match);
208+ }
209+ }
210+ },
211+ }}
212+ mode="none"
213+ onValueChange={setQuery}
214+ panelClassName="flex min-h-0 flex-1 flex-col"
215+ testId="project-content-search"
216+ value={query}
217+ >
218+ {showSearchStatus ? (
219+ <div className="flex h-9 shrink-0 items-center border-b px-3 text-xs text-muted-foreground">
220+ {search.isPending ? (
221+ <span className="flex items-center gap-2">
222+ <LoaderCircle className="size-3.5 animate-spin" /> Searching…
223+ </span>
224+ ) : search.error ? (
225+ <span className="text-destructive">{search.error}</span>
226+ ) : search.invalidRegex ? (
227+ <span className="text-destructive">Invalid regular expression</span>
228+ ) : (
229+ `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files`
230+ )}
231+ </div>
232+ ) : null}
233+
234+ {matches.length === 0 ? (
235+ <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground">
236+ {search.hasQuery && !search.isPending && !search.error
237+ ? "No results found."
238+ : "Type to search across your project."}
239+ </div>
240+ ) : (
241+ <ScrollArea className="min-h-0 flex-1" scrollFade>
242+ <div className="py-2">
243+ {groups.map((group) => {
244+ const path = splitPath(group.path);
245+ return (
246+ <section className="pb-2" key={group.path}>
247+ <div className="sticky top-0 z-10 flex h-8 items-center gap-2 bg-popover/95 px-3 text-xs backdrop-blur-sm">
248+ <PierreEntryIcon
249+ pathValue={group.path}
250+ kind="file"
251+ theme={resolvedTheme}
252+ className="size-3.5"
253+ />
254+ <span className="font-medium text-foreground">{path.name}</span>
255+ {path.directory ? (
256+ <span className="min-w-0 truncate text-muted-foreground">
257+ {path.directory}
258+ </span>
259+ ) : null}
260+ <span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 tabular-nums text-[10px] text-muted-foreground">
261+ {group.matches.length}
262+ </span>
263+ </div>
264+ {group.matches.map((match) => (
265+ <button
266+ type="button"
267+ key={`${match.path}:${match.lineNumber}:${match.resultIndex}`}
268+ data-content-search-result={match.resultIndex}
269+ className={cn(
270+ "flex h-7 w-full min-w-0 items-center gap-3 px-3 text-left font-mono text-xs hover:bg-accent/60 disabled:pointer-events-none",
271+ match.resultIndex === selectedIndex && "bg-accent text-accent-foreground",
272+ )}
273+ disabled={!canOpenMatches}
274+ onMouseEnter={() => setSelectedIndex(match.resultIndex)}
275+ onClick={() => openMatch(match)}
276+ >
277+ <span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground/70">
278+ {match.lineNumber}
279+ </span>
280+ <span className="min-w-0 flex-1 truncate whitespace-pre">
281+ <HighlightedSearchLine
282+ match={match}
283+ path={group.path}
284+ theme={resolvedTheme}
285+ />
286+ </span>
287+ </button>
288+ ))}
289+ </section>
290+ );
291+ })}
292+ {matches.length > visibleCount ? (
293+ <div ref={observeLoadMoreSentinel} className="h-8" aria-hidden="true" />
294+ ) : null}
295+ </div>
296+ </ScrollArea>
297+ )}
298+ </CommandPaletteContent>
299+ );
300+}
301+
302+export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) {
303+ const target = useActiveProjectTarget();
304+
305+ return target ? (
306+ <OpenContentSearchDialog
307+ // Reset the query and options whenever the workspace changes.
308+ key={`${target.environmentId}:${target.cwd}`}
309+ onOpenChange={props.onOpenChange}
310+ target={target}
311+ />
312+ ) : (
313+ <EmptyContentSearchDialog />
314+ );
315+}
apps/web/src/hooks/useActiveProjectTarget.ts+41−0
@@ -0,0 +1,41 @@
1+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2+import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3+
4+import { useProjects } from "~/state/entities";
5+
6+import { useHandleNewThread } from "./useHandleNewThread";
7+
8+export interface ActiveProjectTarget {
9+ readonly environmentId: EnvironmentId;
10+ readonly cwd: string;
11+ readonly projectName: string;
12+ readonly threadRef: ScopedThreadRef;
13+}
14+
15+/**
16+ * Resolves the project workspace behind the active thread (or draft) so
17+ * project-scoped surfaces like the file picker and content search know which
18+ * workspace to query and which thread's right panel opens their results.
19+ */
20+export function useActiveProjectTarget(): ActiveProjectTarget | null {
21+ const { activeDraftThread, activeThread } = useHandleNewThread();
22+ const projects = useProjects();
23+ const thread = activeThread ?? activeDraftThread;
24+ const threadId = activeThread?.id ?? activeDraftThread?.threadId;
25+ const project = thread
26+ ? projects.find(
27+ (candidate) =>
28+ candidate.environmentId === thread.environmentId && candidate.id === thread.projectId,
29+ )
30+ : null;
31+ const cwd = thread?.worktreePath ?? project?.workspaceRoot;
32+
33+ if (!thread || !threadId || !project || !cwd) return null;
34+
35+ return {
36+ environmentId: project.environmentId,
37+ cwd,
38+ projectName: project.title,
39+ threadRef: scopeThreadRef(thread.environmentId, threadId),
40+ };
41+}
apps/web/src/hooks/useHandleNewThread.ts+1−1
@@ -190,7 +190,7 @@ export function useNewThreadHandler() { · 189 unmodified lines
190190 reusableStoredDraftThread.draftId,
191191 {
192192 threadId: reusableStoredDraftThread.threadId,
193− ...(workspaceContext ?? {}),
193+ ...workspaceContext,
194194 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
195195 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
196196 },
apps/web/src/keybindings.test.ts+52−0
@@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ · 117 unmodified lines
118118 command: "commandPalette.toggle",
119119 whenAst: whenNot(whenIdentifier("terminalFocus")),
120120 },
121+ {
122+ shortcut: modShortcut("p"),
123+ command: "filePicker.toggle",
124+ whenAst: whenNot(whenIdentifier("terminalFocus")),
125+ },
126+ {
127+ shortcut: modShortcut("f", { shiftKey: true }),
128+ command: "projectSearch.toggle",
129+ whenAst: whenNot(whenIdentifier("terminalFocus")),
130+ },
121131 {
122132 shortcut: modShortcut("m", { shiftKey: true }),
123133 command: "modelPicker.toggle",
@@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => { · 203 unmodified lines
327337 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
328338 "⌘K",
329339 );
340+ assert.strictEqual(
341+ shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"),
342+ "⌘P",
343+ );
344+ assert.strictEqual(
345+ shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"),
346+ "⇧⌘F",
347+ );
330348 assert.strictEqual(
331349 shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"),
332350 "Ctrl+Shift+M",
@@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => { · 181 unmodified lines
514532 );
515533 });
516534
535+ it("matches filePicker.toggle shortcut outside terminal focus", () => {
536+ assert.strictEqual(
537+ resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, {
538+ platform: "MacIntel",
539+ context: { terminalFocus: false },
540+ }),
541+ "filePicker.toggle",
542+ );
543+ assert.notStrictEqual(
544+ resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, {
545+ platform: "MacIntel",
546+ context: { terminalFocus: true },
547+ }),
548+ "filePicker.toggle",
549+ );
550+ });
551+
552+ it("matches projectSearch.toggle shortcut outside terminal focus", () => {
553+ assert.strictEqual(
554+ resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
555+ platform: "MacIntel",
556+ context: { terminalFocus: false },
557+ }),
558+ "projectSearch.toggle",
559+ );
560+ assert.notStrictEqual(
561+ resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
562+ platform: "MacIntel",
563+ context: { terminalFocus: true },
564+ }),
565+ "projectSearch.toggle",
566+ );
567+ });
568+
517569 it("matches diff.toggle shortcut outside terminal focus", () => {
518570 assert.isTrue(
519571 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/lib/syntaxHighlighting.test.ts+28−0
@@ -0,0 +1,28 @@
1+import type { DiffsHighlighter } from "@pierre/diffs";
2+import { expect, it, vi } from "vite-plus/test";
3+
4+const { getSharedHighlighter } = vi.hoisted(() => ({
5+ getSharedHighlighter: vi.fn(),
6+}));
7+
8+vi.mock("@pierre/diffs", () => ({
9+ getSharedHighlighter,
10+}));
11+
12+import { getSyntaxHighlighterPromise } from "./syntaxHighlighting";
13+
14+it("caches the recovered text highlighter for unsupported languages", async () => {
15+ const textHighlighter = {} as DiffsHighlighter;
16+ getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) =>
17+ langs[0] === "text"
18+ ? Promise.resolve(textHighlighter)
19+ : Promise.reject(new Error("unsupported language")),
20+ );
21+
22+ const first = getSyntaxHighlighterPromise("unsupported-test-language");
23+ await expect(first).resolves.toBe(textHighlighter);
24+ const second = getSyntaxHighlighterPromise("unsupported-test-language");
25+
26+ expect(second).toBe(first);
27+ expect(getSharedHighlighter).toHaveBeenCalledTimes(2);
28+});
apps/web/src/lib/syntaxHighlighting.ts+30−0
@@ -0,0 +1,30 @@
1+import {
2+ getSharedHighlighter,
3+ type DiffsHighlighter,
4+ type SupportedLanguages,
5+} from "@pierre/diffs";
6+
7+import { resolveDiffThemeName } from "./diffRendering";
8+
9+const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
10+
11+export function getSyntaxHighlighterPromise(language: string): Promise<DiffsHighlighter> {
12+ const cached = highlighterPromiseCache.get(language);
13+ if (cached) return cached;
14+
15+ const promise = getSharedHighlighter({
16+ themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
17+ langs: [language as SupportedLanguages],
18+ preferredHighlighter: "shiki-js",
19+ }).catch((error) => {
20+ if (language === "text") {
21+ highlighterPromiseCache.delete(language);
22+ // "text" itself failed — Shiki cannot initialize at all, surface the error
23+ throw error;
24+ }
25+ // Language not supported by Shiki — fall back to "text"
26+ return getSyntaxHighlighterPromise("text");
27+ });
28+ highlighterPromiseCache.set(language, promise);
29+ return promise;
30+}
apps/web/src/state/projects.ts+13−0
@@ -1,11 +1,24 @@
11 import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";
22 import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";
3+import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime";
4+import { WS_METHODS } from "@t3tools/contracts";
35
46 import { environmentCatalog } from "../connection/catalog";
57 import { connectionAtomRuntime } from "../connection/runtime";
68 import { environmentSnapshotAtom } from "./shell";
79
810 export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);
11+/**
12+ * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile
13+ * surface, so the atom family lives here instead of the shared client-runtime
14+ * project atoms consumed by the mobile app.
15+ */
16+export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, {
17+ label: "environment-data:projects:search-contents",
18+ tag: WS_METHODS.projectsSearchContents,
19+ staleTimeMs: 5_000,
20+ idleTtlMs: 60_000,
21+});
922 export const environmentProjects = createEnvironmentProjectAtoms({
1023 catalogValueAtom: environmentCatalog.catalogValueAtom,
1124 snapshotAtom: environmentSnapshotAtom,
apps/web/src/state/queries.test.ts+25−0
@@ -0,0 +1,25 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { areProjectPathSearchTargetsEqual } from "./queries";
5+
6+describe("areProjectPathSearchTargetsEqual", () => {
7+ const target = {
8+ environmentId: EnvironmentId.make("environment-a"),
9+ cwd: "/project-a",
10+ query: "index",
11+ };
12+
13+ it("requires the environment, workspace, query, and entry kind to match", () => {
14+ expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true);
15+ expect(
16+ areProjectPathSearchTargetsEqual(target, {
17+ ...target,
18+ environmentId: EnvironmentId.make("environment-b"),
19+ }),
20+ ).toBe(false);
21+ expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false);
22+ expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false);
23+ expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false);
24+ });
25+});
apps/web/src/state/queries.ts+88−9
@@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; · 11 unmodified lines
1212 import type {
1313 EnvironmentId,
1414 OrchestrationThread,
15+ ProjectContentMatch,
16+ ProjectEntryKind,
1517 ThreadId,
1618 VcsListRefsResult,
1719 VcsRef,
@@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; · 6 unmodified lines
2426 import { appAtomRegistry } from "../rpc/atomRegistry";
2527 import { orchestrationEnvironment } from "./orchestration";
2628 import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
27−import { projectEnvironment } from "./projects";
29+import { projectContentSearch, projectEnvironment } from "./projects";
2830 import { useEnvironmentQuery } from "./query";
2931 import { useEnvironmentThread } from "./threads";
3032 import { vcsEnvironment } from "./vcs";
3133
32−const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
34+const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120;
3335 const COMPOSER_PATH_SEARCH_LIMIT = 80;
36+const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120;
37+const PROJECT_CONTENT_SEARCH_LIMIT = 500;
3438 const THREAD_SEARCH_DEBOUNCE_MS = 200;
3539 const VCS_REF_LIST_LIMIT = 100;
3640 const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
41+const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = [];
3742 const INITIAL_BRANCH_CURSORS = [undefined] as const;
3843 const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
3944 const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
@@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { · 189 unmodified lines
229234 };
230235 }
231236
232−export function useComposerPathSearch(target: ComposerPathSearchTarget) {
237+type ProjectPathSearchTarget = ComposerPathSearchTarget & {
238+ readonly kind?: ProjectEntryKind | undefined;
239+};
240+
241+export function areProjectPathSearchTargetsEqual(
242+ left: ProjectPathSearchTarget,
243+ right: ProjectPathSearchTarget,
244+): boolean {
245+ return (
246+ left.environmentId === right.environmentId &&
247+ left.cwd === right.cwd &&
248+ left.query === right.query &&
249+ left.kind === right.kind
250+ );
251+}
252+
253+export function useProjectPathSearch(
254+ target: ProjectPathSearchTarget,
255+ limit: number,
256+ options?: { readonly allowEmptyQuery?: boolean },
257+) {
258+ const allowEmptyQuery = options?.allowEmptyQuery === true;
233259 const normalizedTarget = useMemo(
234260 () => ({
235261 environmentId: target.environmentId,
236262 cwd: target.cwd,
237− query: target.query?.trim() ?? "",
263+ query: target.query == null ? null : target.query.trim(),
264+ kind: target.kind,
238265 }),
239− [target.cwd, target.environmentId, target.query],
266+ [target.cwd, target.environmentId, target.kind, target.query],
240267 );
241− const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS);
268+ const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
242269 const result = useEnvironmentQuery(
243270 debouncedTarget.environmentId !== null &&
244271 debouncedTarget.cwd !== null &&
245− debouncedTarget.query.length > 0
272+ debouncedTarget.query !== null &&
273+ (allowEmptyQuery || debouncedTarget.query.length > 0)
246274 ? projectEnvironment.searchEntries({
247275 environmentId: debouncedTarget.environmentId,
248276 input: {
249277 cwd: debouncedTarget.cwd,
250278 query: debouncedTarget.query,
251− limit: COMPOSER_PATH_SEARCH_LIMIT,
279+ limit,
280+ ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
252281 },
253282 })
254283 : null,
@@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { · 2 unmodified lines
257286 return {
258287 entries: result.data?.entries ?? [],
259288 error: result.error,
260− isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending,
289+ isPending:
290+ !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,
291+ searchedQuery: debouncedTarget.query ?? "",
261292 refresh: result.refresh,
262293 };
263294 }
264295
296+export function useComposerPathSearch(target: ComposerPathSearchTarget) {
297+ return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT);
298+}
299+
300+interface ProjectContentSearchTarget {
301+ readonly environmentId: EnvironmentId | null;
302+ readonly cwd: string | null;
303+ readonly query: string;
304+ readonly caseSensitive: boolean;
305+ readonly wholeWord: boolean;
306+ readonly useRegex: boolean;
307+}
308+
309+export function useProjectContentSearch(target: ProjectContentSearchTarget) {
310+ // Whitespace is significant in content queries; trimming is only used to
311+ // decide whether the input is blank.
312+ const query = target.query;
313+ const hasQuery = query.trim().length > 0;
314+ const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);
315+ const result = useEnvironmentQuery(
316+ target.environmentId !== null &&
317+ target.cwd !== null &&
318+ hasQuery &&
319+ debouncedQuery.trim().length > 0
320+ ? projectContentSearch({
321+ environmentId: target.environmentId,
322+ input: {
323+ cwd: target.cwd,
324+ query: debouncedQuery,
325+ limit: PROJECT_CONTENT_SEARCH_LIMIT,
326+ caseSensitive: target.caseSensitive,
327+ wholeWord: target.wholeWord,
328+ useRegex: target.useRegex,
329+ },
330+ })
331+ : null,
332+ );
333+
334+ return {
335+ matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,
336+ error: result.error,
337+ isPending: hasQuery && (query !== debouncedQuery || result.isPending),
338+ hasQuery,
339+ truncated: result.data?.truncated ?? false,
340+ invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,
341+ };
342+}
343+
265344 export function useCheckpointDiff(
266345 target: CheckpointDiffTarget,
267346 options?: { readonly enabled?: boolean },
docs/user/keybindings.md+4−0
@@ -37,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`. · 36 unmodified lines
3737 Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and
3838 `chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`.
3939
40+`filePicker.toggle` opens file search for the active project and defaults to `mod+p`.
41+`projectSearch.toggle` searches inside the active project's files and defaults to `mod+shift+f`.
42+Repeating either shortcut closes that search, and switching shortcuts replaces the open search.
43+
4044 The command palette searches active thread titles, projects, branches, user messages, and final
4145 agent responses across connected environments. Message matches show one labeled excerpt while
4246 keeping the thread's project, branch, and machine context visible. Message search begins after two
packages/contracts/src/keybindings.test.ts+12−0
@@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () => · 58 unmodified lines
5959 });
6060 assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle");
6161
62+ const parsedFilePicker = yield* decode(KeybindingRule, {
63+ key: "mod+p",
64+ command: "filePicker.toggle",
65+ });
66+ assert.strictEqual(parsedFilePicker.command, "filePicker.toggle");
67+
68+ const parsedProjectSearch = yield* decode(KeybindingRule, {
69+ key: "mod+shift+f",
70+ command: "projectSearch.toggle",
71+ });
72+ assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle");
73+
6274 const parsedLocal = yield* decode(KeybindingRule, {
6375 key: "mod+shift+n",
6476 command: "chat.newLocal",
packages/contracts/src/keybindings.ts+2−0
@@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ · 62 unmodified lines
6363 "preview.zoomOut",
6464 "preview.resetZoom",
6565 "commandPalette.toggle",
66+ "filePicker.toggle",
67+ "projectSearch.toggle",
6668 "composer.stash",
6769 "chat.new",
6870 "chat.newLocal",
packages/contracts/src/project.test.ts+42−0
@@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
33
44 import {
55 ProjectReadFileError,
6+ ProjectSearchContentsError,
7+ ProjectSearchContentsInput,
68 ProjectSearchEntriesError,
9+ ProjectSearchEntriesInput,
710 ProjectWriteFileError,
811 } from "./project.ts";
912
13+const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput);
14+const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput);
15+
16+describe("project search inputs", () => {
17+ it("allows an empty entries query for bounded frecency browsing", () => {
18+ const decoded = decodeSearchEntriesInput({
19+ cwd: "/workspace",
20+ query: " ",
21+ limit: 10,
22+ kind: "file",
23+ });
24+ expect(decoded.query).toBe("");
25+ });
26+
27+ it("preserves whitespace in content search queries", () => {
28+ const decoded = decodeSearchContentsInput({
29+ cwd: "/workspace",
30+ query: " foo ",
31+ limit: 10,
32+ caseSensitive: false,
33+ wholeWord: false,
34+ useRegex: false,
35+ });
36+ expect(decoded.query).toBe(" foo ");
37+ });
38+});
39+
1040 describe("project RPC errors", () => {
1141 it("derives stable messages from structured request context while retaining causes", () => {
1242 const cause = new Error("sensitive platform detail");
@@ -39,6 +69,18 @@ describe("project RPC errors", () => { · 26 unmodified lines
3969 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");
4070 expect(readError.message).not.toContain(cause.message);
4171 expect(readError.cause).toBe(cause);
72+
73+ const contentSearchError = new ProjectSearchContentsError({
74+ cwd: "/workspace",
75+ queryLength: "authorization: Bearer secret-token".length,
76+ limit: 100,
77+ failure: "search_index_search_failed",
78+ cause,
79+ });
80+ expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'.");
81+ expect(contentSearchError.message).not.toContain(cause.message);
82+ expect(contentSearchError).not.toHaveProperty("query");
83+ expect(contentSearchError.cause).toBe(cause);
4284 });
4385
4486 it("decodes legacy message-only errors during rolling upgrades", () => {
packages/contracts/src/project.ts+78−4
@@ -1,19 +1,29 @@
11 import * as Schema from "effect/Schema";
2−import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
2+import {
3+ NonNegativeInt,
4+ PositiveInt,
5+ TrimmedNonEmptyString,
6+ TrimmedString,
7+} from "./baseSchemas.ts";
38
49 const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
10+const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500;
511 const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
612 const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
713
14+export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
15+export type ProjectEntryKind = typeof ProjectEntryKind.Type;
16+
817 export const ProjectSearchEntriesInput = Schema.Struct({
918 cwd: TrimmedNonEmptyString,
10− query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
19+ // An empty query is a bounded browse: the index returns frecency-ordered
20+ // entries, which the file picker uses for its initial results.
21+ query: TrimmedString.check(Schema.isMaxLength(256)),
1122 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
23+ kind: Schema.optional(ProjectEntryKind),
1224 });
1325 export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1426
15−const ProjectEntryKind = Schema.Literals(["file", "directory"]);
16−
1727 export const ProjectEntry = Schema.Struct({
1828 path: TrimmedNonEmptyString,
1929 kind: ProjectEntryKind,
@@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ · 6 unmodified lines
2636 });
2737 export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2838
39+export const ProjectSearchContentsInput = Schema.Struct({
40+ cwd: TrimmedNonEmptyString,
41+ // Whitespace is significant in content queries (" foo", regex trailing
42+ // spaces), so the query is deliberately not trimmed on the wire.
43+ query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)),
44+ limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)),
45+ caseSensitive: Schema.Boolean,
46+ wholeWord: Schema.Boolean,
47+ useRegex: Schema.Boolean,
48+});
49+export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type;
50+
51+export const ProjectContentMatchRange = Schema.Struct({
52+ start: NonNegativeInt,
53+ end: NonNegativeInt,
54+});
55+export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type;
56+
57+export const ProjectContentMatch = Schema.Struct({
58+ path: TrimmedNonEmptyString,
59+ lineNumber: PositiveInt,
60+ lineContent: Schema.String,
61+ matchRanges: Schema.Array(ProjectContentMatchRange),
62+});
63+export type ProjectContentMatch = typeof ProjectContentMatch.Type;
64+
65+export const ProjectSearchContentsResult = Schema.Struct({
66+ matches: Schema.Array(ProjectContentMatch),
67+ truncated: Schema.Boolean,
68+ regexFallbackError: Schema.optional(Schema.String),
69+});
70+export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type;
71+
2972 export const ProjectListEntriesInput = Schema.Struct({
3073 cwd: TrimmedNonEmptyString,
3174 });
@@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe · 62 unmodified lines
94137 }
95138 }
96139
140+export class ProjectSearchContentsError extends Schema.TaggedErrorClass<ProjectSearchContentsError>()(
141+ "ProjectSearchContentsError",
142+ {
143+ cwd: Schema.optional(TrimmedNonEmptyString),
144+ queryLength: Schema.optional(NonNegativeInt),
145+ limit: Schema.optional(PositiveInt),
146+ failure: Schema.optional(ProjectEntriesFailure),
147+ normalizedCwd: Schema.optional(TrimmedNonEmptyString),
148+ timeout: Schema.optional(TrimmedNonEmptyString),
149+ detail: Schema.optional(TrimmedNonEmptyString),
150+ message: TrimmedNonEmptyString,
151+ cause: Schema.optional(Schema.Defect()),
152+ },
153+) {
154+ // @effect-diagnostics-next-line overriddenSchemaConstructor:off
155+ constructor(
156+ props: ProjectEntriesFailureContext & {
157+ readonly cwd: string;
158+ readonly queryLength: number;
159+ readonly limit: number;
160+ },
161+ ) {
162+ super({
163+ ...props,
164+ message:
165+ decodedProjectErrorMessage(props) ??
166+ `Failed to search workspace contents in '${props.cwd}'.`,
167+ } as any);
168+ }
169+}
170+
97171 export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()(
98172 "ProjectListEntriesError",
99173 {
packages/contracts/src/rpc.ts+11−0
@@ -76,6 +76,9 @@ import { · 75 unmodified lines
7676 ProjectReadFileError,
7777 ProjectReadFileInput,
7878 ProjectReadFileResult,
79+ ProjectSearchContentsError,
80+ ProjectSearchContentsInput,
81+ ProjectSearchContentsResult,
7982 ProjectSearchEntriesError,
8083 ProjectSearchEntriesInput,
8184 ProjectSearchEntriesResult,
@@ -166,6 +169,7 @@ export const WS_METHODS = { · 84 unmodified lines
166169 projectsRemove: "projects.remove",
167170 projectsListEntries: "projects.listEntries",
168171 projectsReadFile: "projects.readFile",
172+ projectsSearchContents: "projects.searchContents",
169173 projectsSearchEntries: "projects.searchEntries",
170174 projectsWriteFile: "projects.writeFile",
171175
@@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr · 266 unmodified lines
438442 error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]),
439443 });
440444
445+export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, {
446+ payload: ProjectSearchContentsInput,
447+ success: ProjectSearchContentsResult,
448+ error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]),
449+});
450+
441451 export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {
442452 payload: ProjectListEntriesInput,
443453 success: ProjectListEntriesResult,
@@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make( · 357 unmodified lines
801811 WsSourceControlPublishRepositoryRpc,
802812 WsProjectsListEntriesRpc,
803813 WsProjectsReadFileRpc,
814+ WsProjectsSearchContentsRpc,
804815 WsProjectsSearchEntriesRpc,
805816 WsProjectsWriteFileRpc,
806817 WsShellOpenInEditorRpc,
packages/shared/src/keybindings.ts+2−0
@@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 34 unmodified lines
3535 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
3636 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
3737 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
38+ { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
39+ { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
3840 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
3941 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
4042 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },