authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-07-30 10:39:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 16:39:15+02:00
logabc409c2d4a072c2de46c9015f5cffff00dcc46b
tree4a537759cab418f898c282bcefcd095d2c2daa38
parent1877237c7ae99ecf2163b4c5bb7d3e14bebcca4d
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

39 files changed, 2928 insertions(+), 476 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
...@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = {
55 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,55 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
56 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,56 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
57 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,57 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
58 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
58 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,59 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
59 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,60 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
60 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,61 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
apps/server/src/keybindings.test.ts+2
...@@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");198 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");199 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
200 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");200 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");
201 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");203 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
202 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");204 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
203 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");205 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* () {...@@ -547,19 +547,24 @@ const make = Effect.gen(function* () {
547 });547 });
548 }548 }
549549
550 const nextConfig = [...customConfig, ...missingDefaults];550 // Startup backfill must never evict persisted user rules: append only
551 const cappedConfig =551 // the defaults that fit and skip the rest.
552 nextConfig.length > MAX_KEYBINDINGS_COUNT552 const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length);
553 ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT)553 const defaultsToAppend = missingDefaults.slice(0, availableSlots);
554 : nextConfig;554 const skippedDefaults = missingDefaults.slice(availableSlots);
555 if (nextConfig.length > MAX_KEYBINDINGS_COUNT) {555 if (skippedDefaults.length > 0) {
556 yield* Effect.logWarning("truncating keybindings config to max entries", {556 yield* Effect.logWarning("skipping default keybinding backfill at max entries", {
557 path: keybindingsConfigPath,557 path: keybindingsConfigPath,
558 maxEntries: MAX_KEYBINDINGS_COUNT,558 maxEntries: MAX_KEYBINDINGS_COUNT,
559 commands: skippedDefaults.map((rule) => rule.command),
559 });560 });
560 }561 }
562 if (defaultsToAppend.length === 0) {
563 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
564 return;
565 }
561566
562 yield* writeConfigAtomically(cappedConfig);567 yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]);
563 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);568 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
564 }),569 }),
565 );570 );
apps/server/src/workspace/WorkspaceEntries.test.ts+343-1
...@@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv)...@@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv)
72 return result.stdout.trim();72 return result.stdout.trim();
73 });73 });
7474
75const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) =>75const searchWorkspaceEntries = (input: {
76 cwd: string;
77 query: string;
78 limit: number;
79 kind?: "file" | "directory";
80}) =>
76 Effect.gen(function* () {81 Effect.gen(function* () {
77 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;82 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
78 return yield* workspaceEntries.search(input);83 return yield* workspaceEntries.search(input);
...@@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {...@@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
200 }),205 }),
201 );206 );
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
203 it.effect("excludes gitignored paths for git repositories", () =>264 it.effect("excludes gitignored paths for git repositories", () =>
204 Effect.gen(function* () {265 Effect.gen(function* () {
205 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });266 const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true });
...@@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {...@@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
292 );353 );
293 });354 });
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
295 describe("browse", () => {637 describe("browse", () => {
296 it.effect("returns matching directories and excludes files", () =>638 it.effect("returns matching directories and excludes files", () =>
297 Effect.gen(function* () {639 Effect.gen(function* () {
apps/server/src/workspace/WorkspaceEntries.ts+71-34
...@@ -14,11 +14,14 @@ import type {...@@ -14,11 +14,14 @@ import type {
14 FilesystemBrowseResult,14 FilesystemBrowseResult,
15 ProjectListEntriesInput,15 ProjectListEntriesInput,
16 ProjectListEntriesResult,16 ProjectListEntriesResult,
17 ProjectSearchContentsInput,
18 ProjectSearchContentsResult,
17 ProjectSearchEntriesInput,19 ProjectSearchEntriesInput,
18 ProjectSearchEntriesResult,20 ProjectSearchEntriesResult,
19} from "@t3tools/contracts";21} from "@t3tools/contracts";
20import { HostProcessPlatform } from "@t3tools/shared/hostProcess";22import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
21import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";23import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
24import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
2225
23import * as WorkspacePaths from "./WorkspacePaths.ts";26import * as WorkspacePaths from "./WorkspacePaths.ts";
24import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";27import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
...@@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service<...@@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service<
93 readonly search: (96 readonly search: (
94 input: ProjectSearchEntriesInput,97 input: ProjectSearchEntriesInput,
95 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;98 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
99 readonly searchContents: (
100 input: ProjectSearchContentsInput,
101 ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceEntriesError>;
96 readonly refresh: (cwd: string) => Effect.Effect<void>;102 readonly refresh: (cwd: string) => Effect.Effect<void>;
97 }103 }
98>()("t3/workspace/WorkspaceEntries") {}104>()("t3/workspace/WorkspaceEntries") {}
...@@ -148,33 +154,37 @@ export const make = Effect.gen(function* () {...@@ -148,33 +154,37 @@ export const make = Effect.gen(function* () {
148 const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(154 const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
149 Effect.orElseSucceed(() => cwd),155 Effect.orElseSucceed(() => cwd),
150 );156 );
151 if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) {157 for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) {
152 return;158 const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant);
153 }159 if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) {
154 const recoverRefreshFailure = (160 continue;
155 cause:161 }
156 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed162 const recoverRefreshFailure = (
157 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut163 cause:
158 | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,164 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed
159 ) =>165 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut
160 Effect.gen(function* () {166 | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed,
161 yield* Effect.logWarning("Failed to refresh workspace search index", {167 ) =>
162 cwd,168 Effect.gen(function* () {
163 cause,169 yield* Effect.logWarning("Failed to refresh workspace search index", {
170 cwd,
171 variant,
172 cause,
173 });
174 yield* workspaceSearchIndexes.invalidate(indexKey);
164 });175 });
165 yield* workspaceSearchIndexes.invalidate(normalizedCwd);176 yield* Effect.gen(function* () {
166 });177 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
167 yield* Effect.gen(function* () {178 yield* searchIndex.refresh();
168 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;179 }).pipe(
169 yield* searchIndex.refresh();180 Effect.provide(workspaceSearchIndexes.get(indexKey)),
170 }).pipe(181 Effect.catchTags({
171 Effect.provide(workspaceSearchIndexes.get(normalizedCwd)),182 WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,
172 Effect.catchTags({183 WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,
173 WorkspaceSearchIndexCreateFailed: recoverRefreshFailure,184 WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,
174 WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure,185 }),
175 WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure,186 );
176 }),187 }
177 );
178 },188 },
179 );189 );
180190
...@@ -230,28 +240,55 @@ export const make = Effect.gen(function* () {...@@ -230,28 +240,55 @@ export const make = Effect.gen(function* () {
230 const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(240 const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")(
231 function* (input) {241 function* (input) {
232 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);242 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
233 const normalizedQuery = input.query243 const normalizedQuery = normalizeSearchQuery(input.query, {
234 .trim()244 trimLeadingPattern: /^[@./]+/,
235 .toLowerCase()245 });
236 .replace(/^[@./]+/, "");
237 return yield* Effect.gen(function* () {246 return yield* Effect.gen(function* () {
238 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;247 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
239 return yield* searchIndex.search(normalizedQuery, input.limit);248 return yield* searchIndex.search(normalizedQuery, input.limit, input.kind);
240 }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd)));249 }).pipe(
250 Effect.provide(
251 workspaceSearchIndexes.get(
252 WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"),
253 ),
254 ),
255 );
241 },256 },
242 );257 );
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
244 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(275 const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
245 function* (input) {276 function* (input) {
246 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);277 const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
247 return yield* Effect.gen(function* () {278 return yield* Effect.gen(function* () {
248 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;279 const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
249 return yield* searchIndex.list();280 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 );
251 },288 },
252 );289 );
253290
254 return WorkspaceEntries.of({ browse, list, refresh, search });291 return WorkspaceEntries.of({ browse, list, refresh, search, searchContents });
255});292});
256293
257export const layer = Layer.effect(WorkspaceEntries, make).pipe(294export const layer = Layer.effect(WorkspaceEntries, make).pipe(
apps/server/src/workspace/WorkspaceSearchIndex.test.ts+139-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { FileFinder } from "@ff-labs/fff-node";1import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node";
2import { afterEach, expect, it } from "@effect/vitest";2import { afterEach, expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain...@@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain
51 }),51 }),
52);52);
5353
54it.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
69it.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
54it.effect("preserves FileFinder destroy failures as structured defects", () =>89it.effect("preserves FileFinder destroy failures as structured defects", () =>
55 Effect.gen(function* () {90 Effect.gen(function* () {
56 const cause = new Error("native destroy failed");91 const cause = new Error("native destroy failed");
...@@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () =>...@@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () =>
58 destroy: vi.fn(() => {93 destroy: vi.fn(() => {
59 throw cause;94 throw cause;
60 }),95 }),
61 isScanning: vi.fn(() => false),96 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
62 } as unknown as FileFinder;97 } as unknown as FileFinder;
63 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });98 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
6499
...@@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () =>...@@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () =>
85 Effect.gen(function* () {120 Effect.gen(function* () {
86 const searchCause = new Error("native search failed");121 const searchCause = new Error("native search failed");
87 const refreshCause = new Error("native scan failed");122 const refreshCause = new Error("native scan failed");
123 const contentSearchCause = new Error("native grep failed");
88 const finder = {124 const finder = {
89 destroy: vi.fn(),125 destroy: vi.fn(),
90 isScanning: vi.fn(() => false),126 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
91 mixedSearch: vi.fn(() => {127 mixedSearch: vi.fn(() => {
92 throw searchCause;128 throw searchCause;
93 }),129 }),
130 grep: vi.fn(() => {
131 throw contentSearchCause;
132 }),
94 scanFiles: vi.fn(() => {133 scanFiles: vi.fn(() => {
95 throw refreshCause;134 throw refreshCause;
96 }),135 }),
...@@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () =>...@@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () =>
100 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");139 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
101 const query = "authorization: Bearer secret-token";140 const query = "authorization: Bearer secret-token";
102 const searchError = yield* Effect.flip(searchIndex.search(query, 3));141 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 );
103 const refreshError = yield* Effect.flip(searchIndex.refresh());151 const refreshError = yield* Effect.flip(searchIndex.refresh());
104152
105 expect(searchError).toMatchObject({153 expect(searchError).toMatchObject({
...@@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () =>...@@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () =>
112 });160 });
113 expect(searchError).not.toHaveProperty("query");161 expect(searchError).not.toHaveProperty("query");
114 expect(searchError.message).not.toMatch(/Bearer|secret-token/);162 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/);
115 expect(refreshError).toMatchObject({173 expect(refreshError).toMatchObject({
116 _tag: "WorkspaceSearchIndexRefreshFailed",174 _tag: "WorkspaceSearchIndexRefreshFailed",
117 cwd: "/workspace/project",175 cwd: "/workspace/project",
...@@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () =>...@@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () =>
127 Effect.gen(function* () {185 Effect.gen(function* () {
128 const finder = {186 const finder = {
129 destroy: vi.fn(),187 destroy: vi.fn(),
130 isScanning: vi.fn(() => false),188 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
131 mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })),189 mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })),
132 scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })),190 scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })),
133 } as unknown as FileFinder;191 } as unknown as FileFinder;
...@@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () =>...@@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () =>
157 }),215 }),
158 ),216 ),
159);217);
218
219it.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,22 +1,36 @@
1import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node";1import {
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";
2import * as Context from "effect/Context";12import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
5import * as LayerMap from "effect/LayerMap";15import * as LayerMap from "effect/LayerMap";
6import * as Schedule from "effect/Schedule";
7import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
817
9import type {18import type {
10 ProjectEntry,19 ProjectEntry,
20 ProjectEntryKind,
11 ProjectListEntriesResult,21 ProjectListEntriesResult,
22 ProjectSearchContentsInput,
23 ProjectSearchContentsResult,
12 ProjectSearchEntriesResult,24 ProjectSearchEntriesResult,
13} from "@t3tools/contracts";25} from "@t3tools/contracts";
1426
15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;27const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;28const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2;
17const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";29const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds";
30const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000;
18const WORKSPACE_INDEX_IDLE_TTL = "15 minutes";31const WORKSPACE_INDEX_IDLE_TTL = "15 minutes";
19const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis";32const CONTENT_SEARCH_TIME_BUDGET_MS = 250;
33const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100;
2034
21export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()(35export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()(
22 "WorkspaceSearchIndexCreateFailed",36 "WorkspaceSearchIndexCreateFailed",
...@@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service<...@@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service<
96 readonly search: (110 readonly search: (
97 query: string,111 query: string,
98 limit: number,112 limit: number,
113 kind?: ProjectEntryKind,
99 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;114 ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>;
115 readonly searchContents: (
116 input: Omit<ProjectSearchContentsInput, "cwd">,
117 ) => Effect.Effect<ProjectSearchContentsResult, WorkspaceSearchIndexSearchFailed>;
100 readonly refresh: () => Effect.Effect<118 readonly refresh: () => Effect.Effect<
101 void,119 void,
102 WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut120 WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut
...@@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null {...@@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null {
129 };147 };
130}148}
131149
150function toFileEntry(item: FileItem): ProjectEntry | null {
151 const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
152 return normalizedPath ? { path: normalizedPath, kind: "file" } : null;
153}
154
155function toDirectoryEntry(item: DirItem): ProjectEntry | null {
156 const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath));
157 return normalizedPath ? { path: normalizedPath, kind: "directory" } : null;
158}
159
160function 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
172function 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
132function mapMixedSearchResult(187function mapMixedSearchResult(
133 result: MixedSearchResult,188 result: MixedSearchResult,
134 limit: number,189 limit: number,
...@@ -155,6 +210,74 @@ function mapMixedSearchResult(...@@ -155,6 +210,74 @@ function mapMixedSearchResult(
155 };210 };
156}211}
157212
213const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u;
214
215function codePointAt(line: string, index: number): string | undefined {
216 const codePoint = line.codePointAt(index);
217 return codePoint === undefined ? undefined : String.fromCodePoint(codePoint);
218}
219
220function 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
228function 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
242function 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 */
263function 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
158function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] {281function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] {
159 const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));282 const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
160 for (const entry of entries) {283 for (const entry of entries) {
...@@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn...@@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEn
169 return [...entryByPath.values()];292 return [...entryByPath.values()];
170}293}
171294
172const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) {295const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (
296 cwd: string,
297 variant: WorkspaceSearchIndexVariant,
298) {
173 const result = yield* Effect.try({299 const result = yield* Effect.try({
174 try: () =>300 try: () =>
175 FileFinder.create({301 FileFinder.create({
176 basePath: cwd,302 basePath: cwd,
177 disableMmapCache: true,303 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",
179 aiMode: false,308 aiMode: false,
180 enableFsRootScanning: true,309 enableFsRootScanning: true,
181 enableHomeDirScanning: true,310 enableHomeDirScanning: true,
...@@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c...@@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c
194 });323 });
195});324});
196325
197const waitForScan = <E>(cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) =>326const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* <E>(
198 Effect.try({327 cwd: string,
199 try: () => finder.isScanning(),328 finder: FileFinder,
200 catch: onFailure,329 onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E,
201 }).pipe(330): Effect.fn.Return<void, E | WorkspaceSearchIndexScanTimedOut> {
202 Effect.repeat({331 const result = yield* Effect.tryPromise({
203 while: (scanning) => scanning,332 try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS),
204 schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL),333 catch: (cause) =>
205 }),334 onFailure({
206 Effect.timeoutOrElse({335 reason: "FileFinder.waitForIndexReady rejected unexpectedly.",
207 duration: WORKSPACE_INDEX_SCAN_TIMEOUT,336 cause,
208 orElse: () =>337 }),
209 new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }),338 });
210 }),339 if (!result.ok) {
211 Effect.withSpan("WorkspaceSearchIndex.waitForScan"),340 return yield* Effect.fail(onFailure({ reason: result.error }));
212 );341 }
342 if (!result.value) {
343 return yield* new WorkspaceSearchIndexScanTimedOut({
344 cwd,
345 timeout: WORKSPACE_INDEX_SCAN_TIMEOUT,
346 });
347 }
348});
213349
214export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) {350export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
215 const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) =>351 cwd: string,
352 variant: WorkspaceSearchIndexVariant = "paths",
353) {
354 const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) =>
216 Effect.try({355 Effect.try({
217 try: () => finder.destroy(),356 try: () => finder.destroy(),
218 catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }),357 catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }),
219 }).pipe(Effect.orDie),358 }).pipe(Effect.orDie),
220 );359 );
221 yield* waitForScan(360 yield* waitForIndexReady(
222 cwd,361 cwd,
223 finder,362 finder,
224 (cause) =>363 ({ reason, cause }) =>
225 new WorkspaceSearchIndexCreateFailed({364 new WorkspaceSearchIndexCreateFailed({
226 cwd,365 cwd,
227 reason: "FileFinder.isScanning threw while creating the index.",366 reason,
228 cause,367 cause,
229 }),368 }),
230 );369 );
231370
232 const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* (371 const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(
233 query: string,372 query: string,
234 pageSize: number,373 pageSize: number,
235 ) {374 operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch",
375 execute: () => Result<A>,
376 ): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> {
236 const result = yield* Effect.try({377 const result = yield* Effect.try({
237 try: () => finder.mixedSearch(query, { pageSize }),378 try: execute,
238 catch: (cause) =>379 catch: (cause) =>
239 new WorkspaceSearchIndexSearchFailed({380 new WorkspaceSearchIndexSearchFailed({
240 cwd,381 cwd,
241 queryLength: query.length,382 queryLength: query.length,
242 pageSize,383 pageSize,
243 reason: "FileFinder.mixedSearch threw unexpectedly.",384 reason: `FileFinder.${operation} threw unexpectedly.`,
244 cause,385 cause,
245 }),386 }),
246 });387 });
...@@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin...@@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
273 reason: result.error,414 reason: result.error,
274 });415 });
275 }416 }
276 yield* waitForScan(417 yield* waitForIndexReady(
277 cwd,418 cwd,
278 finder,419 finder,
279 (cause) =>420 ({ reason, cause }) =>
280 new WorkspaceSearchIndexRefreshFailed({421 new WorkspaceSearchIndexRefreshFailed({
281 cwd,422 cwd,
282 reason: "FileFinder.isScanning threw while refreshing the index.",423 reason,
283 cause,424 cause,
284 }),425 }),
285 );426 );
...@@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin...@@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
287428
288 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(429 const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
289 function* () {430 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 );
291 const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);434 const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES);
292 const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>435 const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) =>
293 left.path.localeCompare(right.path),436 left.path.localeCompare(right.path),
...@@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin...@@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin
302445
303 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(446 const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn(
304 "WorkspaceSearchIndex.search",447 "WorkspaceSearchIndex.search",
305 )(function* (query, limit) {448 )(function* (query, limit, kind) {
306 const result = yield* runMixedSearch(query, Math.max(1, limit + 1));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 );
307 return mapMixedSearchResult(result, limit);465 return mapMixedSearchResult(result, limit);
308 });466 });
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 });
311});520});
312521
522export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const;
523export 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 */
530export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) =>
531 `${variant}\n${cwd}`;
532
533function 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
313/**544/**
314 * A layer factory is required because every index is scoped to a concrete545 * A layer factory is required because every index is scoped to a concrete
315 * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup;546 * workspace root and variant. WorkspaceSearchIndexMap owns memoization and
316 * using a default cwd here would mix resources from different workspaces.547 * idle cleanup; using a default cwd here would mix resources from different
548 * workspaces.
317 */549 */
318export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd));550export const layer = (key: string) => {
551 const { cwd, variant } = parseWorkspaceSearchIndexKey(key);
552 return Layer.effect(WorkspaceSearchIndex, make(cwd, variant));
553};
319554
320export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()(555export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()(
321 "t3/workspace/WorkspaceSearchIndexMap",556 "t3/workspace/WorkspaceSearchIndexMap",
apps/server/src/ws.ts+18
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 type ProjectFileOperation,37 type ProjectFileOperation,
38 ProjectListEntriesError,38 ProjectListEntriesError,
39 ProjectReadFileError,39 ProjectReadFileError,
40 ProjectSearchContentsError,
40 ProjectSearchEntriesError,41 ProjectSearchEntriesError,
41 ProjectWriteFileError,42 ProjectWriteFileError,
42 RelayClientInstallFailedError,43 RelayClientInstallFailedError,
...@@ -1597,6 +1598,23 @@ const makeWsRpcLayer = (...@@ -1597,6 +1598,23 @@ const makeWsRpcLayer = (
1597 ),1598 ),
1598 { "rpc.aggregate": "workspace" },1599 { "rpc.aggregate": "workspace" },
1599 ),1600 ),
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 ),
1600 [WS_METHODS.projectsListEntries]: (input) =>1618 [WS_METHODS.projectsListEntries]: (input) =>
1601 observeRpcEffect(1619 observeRpcEffect(
1602 WS_METHODS.projectsListEntries,1620 WS_METHODS.projectsListEntries,
apps/web/src/components/ChatMarkdown.tsx+5-47
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs";
3import {2import {
4 CheckIcon,3 CheckIcon,
5 ChevronRightIcon,4 ChevronRightIcon,
...@@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences";...@@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences";
57import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";56import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
58import { fnv1a32 } from "../lib/diffRendering";57import { fnv1a32 } from "../lib/diffRendering";
59import { LRUCache } from "../lib/lruCache";58import { LRUCache } from "../lib/lruCache";
59import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
60import { RenderErrorBoundary } from "./RenderErrorBoundary";
60import { useTheme } from "../hooks/useTheme";61import { useTheme } from "../hooks/useTheme";
61import { getClientSettings } from "../hooks/useSettings";62import { getClientSettings } from "../hooks/useSettings";
62import {63import {
...@@ -91,27 +92,6 @@ import {...@@ -91,27 +92,6 @@ import {
91 BrowserPreviewUnavailableError,92 BrowserPreviewUnavailableError,
92} from "../browser/openFileInPreview";93} from "../browser/openFileInPreview";
9394
94class 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
115interface ChatMarkdownProps {95interface ChatMarkdownProps {
116 text: string;96 text: string;
117 cwd: string | undefined;97 cwd: string | undefined;
...@@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>(...@@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache<string>(
147 MAX_HIGHLIGHT_CACHE_ENTRIES,127 MAX_HIGHLIGHT_CACHE_ENTRIES,
148 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,128 MAX_HIGHLIGHT_CACHE_MEMORY_BYTES,
149);129);
150const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
151130
152function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {131function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null {
153 const firstLineEnd = markdown.indexOf("\n", listItemStart);132 const firstLineEnd = markdown.indexOf("\n", listItemStart);
...@@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number {...@@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number {
333 return Math.max(html.length * 2, code.length * 3);312 return Math.max(html.length * 2, code.length * 3);
334}313}
335314
336function 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
357function readInitialWordWrapSetting(): boolean {315function readInitialWordWrapSetting(): boolean {
358 return getClientSettings().wordWrap;316 return getClientSettings().wordWrap;
359}317}
...@@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({...@@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({
743 cacheKey,701 cacheKey,
744 isStreaming,702 isStreaming,
745}: UncachedShikiCodeBlockProps) {703}: UncachedShikiCodeBlockProps) {
746 const highlighter = use(getHighlighterPromise(language));704 const highlighter = use(getSyntaxHighlighterPromise(language));
747 const highlightedHtml = useMemo(() => {705 const highlightedHtml = useMemo(() => {
748 try {706 try {
749 return highlighter.codeToHtml(code, { lang: language, theme: themeName });707 return highlighter.codeToHtml(code, { lang: language, theme: themeName });
...@@ -1605,7 +1563,7 @@ function ChatMarkdown({...@@ -1605,7 +1563,7 @@ function ChatMarkdown({
1605 fenceTitle={fenceTitle}1563 fenceTitle={fenceTitle}
1606 theme={resolvedTheme}1564 theme={resolvedTheme}
1607 >1565 >
1608 <CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>1566 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
1609 <Suspense fallback={<pre {...props}>{children}</pre>}>1567 <Suspense fallback={<pre {...props}>{children}</pre>}>
1610 <SuspenseShikiCodeBlock1568 <SuspenseShikiCodeBlock
1611 className={codeBlock.className}1569 className={codeBlock.className}
...@@ -1614,7 +1572,7 @@ function ChatMarkdown({...@@ -1614,7 +1572,7 @@ function ChatMarkdown({
1614 isStreaming={isStreaming}1572 isStreaming={isStreaming}
1615 />1573 />
1616 </Suspense>1574 </Suspense>
1617 </CodeHighlightErrorBoundary>1575 </RenderErrorBoundary>
1618 </MarkdownCodeBlock>1576 </MarkdownCodeBlock>
1619 );1577 );
1620 },1578 },
apps/web/src/components/CommandPalette.logic.test.ts+72
...@@ -6,9 +6,81 @@ import {...@@ -6,9 +6,81 @@ import {
6 buildThreadActionItems,6 buildThreadActionItems,
7 enumerateCommandPaletteItems,7 enumerateCommandPaletteItems,
8 filterCommandPaletteGroups,8 filterCommandPaletteGroups,
9 reduceCommandPaletteUiState,
9 type CommandPaletteGroup,10 type CommandPaletteGroup,
10} from "./CommandPalette.logic";11} from "./CommandPalette.logic";
1112
13describe("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
12describe("enumerateCommandPaletteItems", () => {84describe("enumerateCommandPaletteItems", () => {
13 it("assigns positional jump shortcuts to the first nine displayed items", () => {85 it("assigns positional jump shortcuts to the first nine displayed items", () => {
14 const items = Array.from({ length: 10 }, (_, index) => ({86 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;...@@ -15,6 +15,55 @@ export const RECENT_THREAD_LIMIT = 12;
15export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";15export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
16export const ADDON_ICON_CLASS = "size-4";16export 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 */
24export type SearchOverlayMode = "command" | "files" | "content";
25
26export interface CommandPaletteOpenIntent {
27 readonly kind: "add-project" | "new-thread-in";
28}
29
30export interface CommandPaletteUiState {
31 readonly open: boolean;
32 readonly mode: SearchOverlayMode;
33 readonly openIntent: CommandPaletteOpenIntent | null;
34}
35
36export 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
43export 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
18export interface CommandPaletteThreadContentMatch {67export interface CommandPaletteThreadContentMatch {
19 readonly source: "user" | "assistant";68 readonly source: "user" | "assistant";
20 readonly snippet: string;69 readonly snippet: string;
...@@ -26,7 +75,7 @@ export interface CommandPaletteItem {...@@ -26,7 +75,7 @@ export interface CommandPaletteItem {
26 readonly value: string;75 readonly value: string;
27 readonly searchTerms: ReadonlyArray<string>;76 readonly searchTerms: ReadonlyArray<string>;
28 readonly title: ReactNode;77 readonly title: ReactNode;
29 readonly description?: string;78 readonly description?: ReactNode;
30 readonly threadContentMatch?: CommandPaletteThreadContentMatch;79 readonly threadContentMatch?: CommandPaletteThreadContentMatch;
31 readonly timestamp?: string;80 readonly timestamp?: string;
32 readonly icon: ReactNode;81 readonly icon: ReactNode;
apps/web/src/components/CommandPalette.tsx+305-279
...@@ -28,16 +28,16 @@ import {...@@ -28,16 +28,16 @@ import {
28import { useNavigate, useParams } from "@tanstack/react-router";28import { useNavigate, useParams } from "@tanstack/react-router";
29import * as Option from "effect/Option";29import * as Option from "effect/Option";
30import {30import {
31 ArrowDownIcon,
32 ArrowLeftIcon,31 ArrowLeftIcon,
33 ArrowUpIcon,
34 CornerLeftUpIcon,32 CornerLeftUpIcon,
33 FileSearchIcon,
35 FolderIcon,34 FolderIcon,
36 FolderPlusIcon,35 FolderPlusIcon,
37 LinkIcon,36 LinkIcon,
38 MessageSquareIcon,37 MessageSquareIcon,
39 SettingsIcon,38 SettingsIcon,
40 SquarePenIcon,39 SquarePenIcon,
40 TextSearchIcon,
41} from "lucide-react";41} from "lucide-react";
42import {42import {
43 useCallback,43 useCallback,
...@@ -81,7 +81,9 @@ import {...@@ -81,7 +81,9 @@ import {
81 resolveProjectPathForDispatch,81 resolveProjectPathForDispatch,
82} from "../lib/projectPaths";82} from "../lib/projectPaths";
83import { onOpenCommandPalette } from "../commandPaletteBus";83import { onOpenCommandPalette } from "../commandPaletteBus";
84import { isPreviewFocused } from "../lib/previewFocus";
84import { isTerminalFocused } from "../lib/terminalFocus";85import { isTerminalFocused } from "../lib/terminalFocus";
86import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
85import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";87import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
86import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";88import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
87import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";89import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
...@@ -100,6 +102,7 @@ import {...@@ -100,6 +102,7 @@ import {
100 buildThreadActionItems,102 buildThreadActionItems,
101 enumerateCommandPaletteItems,103 enumerateCommandPaletteItems,
102 type CommandPaletteActionItem,104 type CommandPaletteActionItem,
105 type CommandPaletteOpenIntent,
103 type CommandPaletteSubmenuItem,106 type CommandPaletteSubmenuItem,
104 type CommandPaletteView,107 type CommandPaletteView,
105 filterCommandPaletteGroups,108 filterCommandPaletteGroups,
...@@ -107,24 +110,22 @@ import {...@@ -107,24 +110,22 @@ import {
107 getCommandPaletteMode,110 getCommandPaletteMode,
108 ITEM_ICON_CLASS,111 ITEM_ICON_CLASS,
109 RECENT_THREAD_LIMIT,112 RECENT_THREAD_LIMIT,
113 reduceCommandPaletteUiState,
114 type SearchOverlayMode,
110} from "./CommandPalette.logic";115} from "./CommandPalette.logic";
111import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";116import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
112import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";117import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
118import { CommandPaletteContent } from "./CommandPaletteContent";
113import { CommandPaletteResults } from "./CommandPaletteResults";119import { CommandPaletteResults } from "./CommandPaletteResults";
114import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";120import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
115import { ProjectFavicon } from "./ProjectFavicon";121import { ProjectFavicon } from "./ProjectFavicon";
122import { ProjectFilePicker } from "./files/ProjectFilePicker";
123import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
116import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";124import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
117import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";125import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
118import { resolveDefaultProviderModelSelection } from "../providerInstances";126import { resolveDefaultProviderModelSelection } from "../providerInstances";
119import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";127import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings";
120import {128import { CommandDialog, CommandDialogPopup } from "./ui/command";
121 Command,
122 CommandDialog,
123 CommandDialogPopup,
124 CommandFooter,
125 CommandInput,
126 CommandPanel,
127} from "./ui/command";
128import { Button } from "./ui/button";129import { Button } from "./ui/button";
129import { Kbd, KbdGroup } from "./ui/kbd";130import { Kbd, KbdGroup } from "./ui/kbd";
130import { stackedThreadToast, toastManager } from "./ui/toast";131import { stackedThreadToast, toastManager } from "./ui/toast";
...@@ -346,50 +347,30 @@ function errorMessage(error: unknown): string {...@@ -346,50 +347,30 @@ function errorMessage(error: unknown): string {
346 return "An error occurred.";347 return "An error occurred.";
347}348}
348349
349interface CommandPaletteOpenIntent {350const OVERLAY_MODE_BY_COMMAND = {
350 readonly kind: "add-project" | "new-thread-in";351 "commandPalette.toggle": "command",
351}352 "filePicker.toggle": "files",
352353 "projectSearch.toggle": "content",
353interface CommandPaletteUiState {354} as const satisfies Partial<Record<string, SearchOverlayMode>>;
354 readonly open: boolean;
355 readonly openIntent: CommandPaletteOpenIntent | null;
356}
357355
358type CommandPaletteUiAction =356function overlayModeForCommand(command: string | null): SearchOverlayMode | null {
359 | { readonly _tag: "SetOpen"; readonly open: boolean }357 if (command === null) return null;
360 | { readonly _tag: "Toggle" }358 return command in OVERLAY_MODE_BY_COMMAND
361 | { readonly _tag: "OpenAddProject" }359 ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND]
362 | { readonly _tag: "OpenNewThreadIn" }360 : null;
363 | { readonly _tag: "ClearOpenIntent" };
364
365function 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 }
384}361}
385362
386export function CommandPalette({ children }: { children: ReactNode }) {363export function CommandPalette({ children }: { children: ReactNode }) {
387 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {364 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
388 open: false,365 open: false,
366 mode: "command",
389 openIntent: null,367 openIntent: null,
390 });368 });
391 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);369 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 );
393 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);374 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
394 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);375 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
395 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);376 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
...@@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -405,26 +386,48 @@ export function CommandPalette({ children }: { children: ReactNode }) {
405 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen386 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
406 : false,387 : false,
407 );388 );
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
409 useEffect(() => {407 useEffect(() => {
410 const onKeyDown = (event: globalThis.KeyboardEvent) => {408 const onKeyDown = (event: globalThis.KeyboardEvent) => {
411 if (event.defaultPrevented) return;409 if (event.defaultPrevented) return;
410 // Resolve with the complete shortcut context so customized bindings
411 // using any documented `when` condition (e.g. previewFocus) work.
412 const command = resolveShortcutCommand(event, keybindings, {412 const command = resolveShortcutCommand(event, keybindings, {
413 context: {413 context: {
414 terminalFocus: isTerminalFocused(),414 terminalFocus: isTerminalFocused(),
415 terminalOpen,415 terminalOpen,
416 previewFocus: isPreviewFocused(),
417 previewOpen,
416 },418 },
417 });419 });
418 if (command !== "commandPalette.toggle") {420 const mode = overlayModeForCommand(command);
421 if (mode === null) {
419 return;422 return;
420 }423 }
421 event.preventDefault();424 event.preventDefault();
422 event.stopPropagation();425 event.stopPropagation();
423 toggleOpen();426 toggleMode(mode);
424 };427 };
425 window.addEventListener("keydown", onKeyDown);428 window.addEventListener("keydown", onKeyDown);
426 return () => window.removeEventListener("keydown", onKeyDown);429 return () => window.removeEventListener("keydown", onKeyDown);
427 }, [keybindings, terminalOpen, toggleOpen]);430 }, [keybindings, previewOpen, terminalOpen, toggleMode]);
428431
429 useEffect(432 useEffect(
430 () =>433 () =>
...@@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -442,12 +445,24 @@ export function CommandPalette({ children }: { children: ReactNode }) {
442445
443 return (446 return (
444 <ComposerHandleContext value={composerHandleRef}>447 <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 >
446 {children}459 {children}
447 <CommandPaletteDialog460 <CommandPaletteDialog
448 open={state.open}461 open={state.open}
462 mode={state.mode}
449 openIntent={state.openIntent}463 openIntent={state.openIntent}
450 setOpen={setOpen}464 setOpen={setOpen}
465 openOverlayMode={toggleMode}
451 clearOpenIntent={clearOpenIntent}466 clearOpenIntent={clearOpenIntent}
452 />467 />
453 </CommandDialog>468 </CommandDialog>
...@@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -457,31 +472,63 @@ export function CommandPalette({ children }: { children: ReactNode }) {
457472
458function CommandPaletteDialog(props: {473function CommandPaletteDialog(props: {
459 readonly open: boolean;474 readonly open: boolean;
475 readonly mode: SearchOverlayMode;
460 readonly openIntent: CommandPaletteOpenIntent | null;476 readonly openIntent: CommandPaletteOpenIntent | null;
461 readonly setOpen: (open: boolean) => void;477 readonly setOpen: (open: boolean) => void;
478 readonly openOverlayMode: (mode: SearchOverlayMode) => void;
462 readonly clearOpenIntent: () => void;479 readonly clearOpenIntent: () => void;
463}) {480}) {
481 const composerHandleRef = useComposerHandleContext();
482
464 if (!props.open) {483 if (!props.open) {
465 return null;484 return null;
466 }485 }
467486
468 return (487 return (
469 <OpenCommandPaletteDialog488 <CommandDialogPopup
470 openIntent={props.openIntent}489 aria-label={
471 setOpen={props.setOpen}490 props.mode === "files"
472 clearOpenIntent={props.clearOpenIntent}491 ? "File picker"
473 />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>
474 );521 );
475}522}
476523
477function OpenCommandPaletteDialog(props: {524function OpenCommandPaletteDialog(props: {
478 readonly openIntent: CommandPaletteOpenIntent | null;525 readonly openIntent: CommandPaletteOpenIntent | null;
479 readonly setOpen: (open: boolean) => void;526 readonly setOpen: (open: boolean) => void;
527 readonly openOverlayMode: (mode: SearchOverlayMode) => void;
480 readonly clearOpenIntent: () => void;528 readonly clearOpenIntent: () => void;
481}) {529}) {
482 const navigate = useNavigate();530 const navigate = useNavigate();
483 const { clearOpenIntent, openIntent, setOpen } = props;531 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
484 const composerHandleRef = useComposerHandleContext();
485 const [query, setQuery] = useState("");532 const [query, setQuery] = useState("");
486 const deferredQuery = useDeferredValue(query);533 const deferredQuery = useDeferredValue(query);
487 const isActionsOnly = deferredQuery.startsWith(">");534 const isActionsOnly = deferredQuery.startsWith(">");
...@@ -1346,6 +1393,32 @@ function OpenCommandPaletteDialog(props: {...@@ -1346,6 +1393,32 @@ function OpenCommandPaletteDialog(props: {
1346 });1393 });
1347 }1394 }
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
1349 actionItems.push({1422 actionItems.push({
1350 kind: "action",1423 kind: "action",
1351 value: "action:add-project",1424 value: "action:add-project",
...@@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: {...@@ -2062,236 +2135,189 @@ function OpenCommandPaletteDialog(props: {
2062 primaryEnvironmentId,2135 primaryEnvironmentId,
2063 ]);2136 ]);
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
2065 return (2234 return (
2066 <CommandDialogPopup2235 <CommandPaletteContent
2236 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}
2067 aria-label="Command palette"2237 aria-label="Command palette"
2068 className="overflow-hidden p-0"2238 autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}
2069 data-command-palette="true"2239 footerActionLabel={footerActionLabel}
2070 data-testid="command-palette"2240 footerTrailing={footerTrailing}
2071 finalFocus={() => {2241 inputAccessory={inputAccessory}
2072 composerHandleRef?.current?.focusAtEnd();2242 inputProps={{
2073 return false;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,
2074 }}2272 }}
2075 onBackdropPointerDown={() => {2273 mode="none"
2076 setOpen(false);2274 onItemHighlighted={(value) => {
2275 setHighlightedItemValue(typeof value === "string" ? value : null);
2077 }}2276 }}
2277 onValueChange={handleQueryChange}
2278 panelClassName="max-h-[min(28rem,70vh)]"
2279 showBackHint={isSubmenu}
2280 value={query}
2078 >2281 >
2079 <Command2282 {remoteProjectContext ? (
2080 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}2283 <div className="p-2 pb-0">
2081 aria-label="Command palette"2284 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Repository</div>
2082 autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}2285 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
2083 mode="none"2286 {remoteProjectContext.icon}
2084 onItemHighlighted={(value) => {2287 <span className="flex min-w-0 flex-1 flex-col">
2085 setHighlightedItemValue(typeof value === "string" ? value : null);2288 <span className="truncate text-foreground text-sm">{remoteProjectContext.title}</span>
2086 }}2289 <span className="truncate text-muted-foreground/85 text-xs">
2087 onValueChange={handleQueryChange}2290 {remoteProjectContext.description}
2088 value={query}2291 </span>
2089 >2292 </span>
2090 <div className="relative">2293 </div>
2091 <CommandInput2294 </div>
2092 className={2295 ) : null}
2093 addProjectCloneFlow?.step === "repository"2296 <CommandPaletteResults
2094 ? "pe-32"2297 groups={displayedGroups}
2095 : isBrowsing2298 highlightedItemValue={highlightedItemValue}
2096 ? willCreateProjectPath2299 isActionsOnly={isActionsOnly}
2097 ? "pe-36"2300 keybindings={keybindings}
2098 : "pe-16"2301 onExecuteItem={executeItem}
2099 : undefined2302 {...(addProjectCloneFlow?.step === "repository"
2100 }2303 ? {
2101 placeholder={inputPlaceholder}2304 emptyStateMessage:
2102 wrapperClassName={2305 addProjectCloneFlow.source === "url"
2103 isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined2306 ? "Enter a Git clone URL and press Enter to continue."
2307 : "Enter a repository path and press Enter to look it up.",
2104 }2308 }
2105 {...(isSubmenu2309 : addProjectCloneFlow?.step === "confirm"
2106 ? {2310 ? { emptyStateMessage: "Choose a destination path and press Enter to clone." }
2107 startAddon: (2311 : relativePathNeedsActiveProject
2108 <button2312 ? { emptyStateMessage: "Relative paths require an active project." }
2109 type="button"2313 : willCreateProjectPath
2110 className="flex cursor-pointer items-center"
2111 aria-label="Back"
2112 onClick={popView}
2113 >
2114 <ArrowLeftIcon />
2115 </button>
2116 ),
2117 }
2118 : isBrowsing && !isSubmenu
2119 ? {2314 ? {
2120 startAddon: <FolderPlusIcon />,2315 emptyStateMessage: "Press Enter to create this folder and add it as a project.",
2121 }2316 }
2122 : {})}2317 : threadSearch.isPending
2123 onKeyDown={handleKeyDown}2318 ? { emptyStateMessage: "Searching thread messages…" }
2124 />2319 : {})}
2125 {addProjectCloneFlow?.step === "repository" ? (2320 />
2126 <Tooltip>2321 </CommandPaletteContent>
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>
2296 );2322 );
2297}2323}
apps/web/src/components/CommandPaletteContent.tsx created+77
...@@ -0,0 +1,77 @@
1import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";
3
4import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
5import { Kbd, KbdGroup } from "./ui/kbd";
6
7type 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 */
24export 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 created+16
...@@ -0,0 +1,16 @@
1import { Component, type ReactNode } from "react";
2
3export 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
...@@ -26,6 +26,10 @@ interface FileBrowserPanelProps {...@@ -26,6 +26,10 @@ interface FileBrowserPanelProps {
26 environmentId: EnvironmentId;26 environmentId: EnvironmentId;
27 cwd: string;27 cwd: string;
28 projectName: string;28 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;
29 onOpenFile: (relativePath: string) => void;33 onOpenFile: (relativePath: string) => void;
30}34}
3135
...@@ -98,6 +102,8 @@ export default function FileBrowserPanel({...@@ -98,6 +102,8 @@ export default function FileBrowserPanel({
98 environmentId,102 environmentId,
99 cwd,103 cwd,
100 projectName,104 projectName,
105 selectedPath,
106 selectedPathRevealId,
101 onOpenFile,107 onOpenFile,
102}: FileBrowserPanelProps) {108}: FileBrowserPanelProps) {
103 const { resolvedTheme } = useTheme();109 const { resolvedTheme } = useTheme();
...@@ -111,6 +117,9 @@ export default function FileBrowserPanel({...@@ -111,6 +117,9 @@ export default function FileBrowserPanel({
111 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);117 const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
112 const treePaths = useMemo(() => entries.map(treePath), [entries]);118 const treePaths = useMemo(() => entries.map(treePath), [entries]);
113 const previousTreePathsRef = useRef<readonly string[]>([]);119 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
115 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so124 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
116 // capture the right-click position ourselves; contextmenu is a composed125 // capture the right-click position ourselves; contextmenu is a composed
...@@ -216,7 +225,12 @@ export default function FileBrowserPanel({...@@ -216,7 +225,12 @@ export default function FileBrowserPanel({
216 initialExpansion: 1,225 initialExpansion: 1,
217 icons: T3_PIERRE_ICONS,226 icons: T3_PIERRE_ICONS,
218 onSelectionChange: (selectedPaths) => {227 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.
219 dragMention.handleSelectionChange(selectedPaths);230 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;
220 // Starting a drag selects the dragged row; that selection is a side234 // Starting a drag selects the dragged row; that selection is a side
221 // effect of the gesture, not a request to open the file.235 // effect of the gesture, not a request to open the file.
222 if (dragMention.isDragInProgress()) {236 if (dragMention.isDragInProgress()) {
...@@ -224,6 +238,7 @@ export default function FileBrowserPanel({...@@ -224,6 +238,7 @@ export default function FileBrowserPanel({
224 }238 }
225 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");239 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
226 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {240 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
241 treeSelectionPathRef.current = selectedPath;
227 onOpenFile(selectedPath);242 onOpenFile(selectedPath);
228 }243 }
229 },244 },
...@@ -247,6 +262,63 @@ export default function FileBrowserPanel({...@@ -247,6 +262,63 @@ export default function FileBrowserPanel({
247 model.resetPaths(treePaths);262 model.resetPaths(treePaths);
248 }, [entryKinds, model, treePaths]);263 }, [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
250 // Tag tree drags with the composer mention payload. The row is read from322 // Tag tree drags with the composer mention payload. The row is read from
251 // the composed event path (the tree's shadow root is open), so this does323 // the composed event path (the tree's shadow root is open), so this does
252 // not depend on running after the tree's own dragstart handler; the drag324 // 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 {...@@ -51,6 +51,7 @@ import {
51 remapFileCommentAnnotations,51 remapFileCommentAnnotations,
52} from "./fileCommentAnnotations";52} from "./fileCommentAnnotations";
53import { installFileEditorDismissal } from "./fileEditorDismissal";53import { installFileEditorDismissal } from "./fileEditorDismissal";
54import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
54import { LocalCommentAnnotation } from "./LocalCommentAnnotation";55import { LocalCommentAnnotation } from "./LocalCommentAnnotation";
55import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";56import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
56import { fileBreadcrumbs } from "./filePath";57import { fileBreadcrumbs } from "./filePath";
...@@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null):...@@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null):
182 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");183 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
183}184}
184185
186/**
187 * Frames to keep retrying while the file contents or line metrics are not
188 * available yet (fresh mounts hydrate asynchronously).
189 */
190const 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 */
197const REVEAL_GUARD_FRAMES = 20;
198const REVEAL_GUARD_TOLERANCE_PX = 2;
199
200interface FileRevealState {
201 frameId: number | null;
202 cancelGuard: (() => void) | null;
203 handledRequestId: number | null;
204 latestRequestId: number | null;
205}
206
185function useFileLineReveal(207function useFileLineReveal(
186 relativePath: string | null,208 relativePath: string | null,
187 revealLine: number | null,209 revealLine: number | null,
188 revealRequestId: number,210 revealRequestId: number,
189): FilePostRender {211): FilePostRender {
190 const [handledRequestIdsByPath] = useState(() => new Map<string, number>());212 const [revealStatesByPath] = useState(() => new Map<string, FileRevealState>());
191 const [latestRequestIdsByPath] = useState(() => new Map<string, number>());
192 const [pendingFramesByPath] = useState(() => new Map<string, number>());
193213
194 return useCallback<FilePostRender>(214 return useCallback<FilePostRender>(
195 (fileContainer, instance, phase) => {215 (fileContainer, instance, phase) => {
196 if (relativePath === null) return;216 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
198 const cancelPendingReveal = () => {227 const cancelPendingReveal = () => {
199 const frameId = pendingFramesByPath.get(relativePath);228 if (state.frameId !== null) {
200 if (frameId !== undefined) {229 cancelAnimationFrame(state.frameId);
201 cancelAnimationFrame(frameId);230 state.frameId = null;
202 pendingFramesByPath.delete(relativePath);
203 }231 }
232 state.cancelGuard?.();
204 };233 };
205234
206 if (phase === "unmount") {235 if (phase === "unmount") {
...@@ -208,18 +237,20 @@ function useFileLineReveal(...@@ -208,18 +237,20 @@ function useFileLineReveal(
208 return;237 return;
209 }238 }
210239
240 const contents = instance.file?.contents;
211 const targetLine =241 const targetLine =
212 revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine);242 revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine);
213 updateFileLinkReveal(fileContainer, targetLine);243 updateFileLinkReveal(fileContainer, targetLine);
214244
215 if (!(instance instanceof VirtualizedFile)) return;245 if (!(instance instanceof VirtualizedFile)) return;
216246
217 if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) {247 if (state.latestRequestId !== revealRequestId) {
218 cancelPendingReveal();248 cancelPendingReveal();
219 latestRequestIdsByPath.set(relativePath, revealRequestId);249 state.latestRequestId = revealRequestId;
250 state.handledRequestId = null;
220 }251 }
221252
222 if (targetLine === null) {253 if (revealLine === null) {
223 fileContainer.style.minHeight = "";254 fileContainer.style.minHeight = "";
224 return;255 return;
225 }256 }
...@@ -230,54 +261,113 @@ function useFileLineReveal(...@@ -230,54 +261,113 @@ function useFileLineReveal(
230 Math.max(instance.height, scrollContainer.clientHeight),261 Math.max(instance.height, scrollContainer.clientHeight),
231 )}px`;262 )}px`;
232263
233 if (264 if (state.handledRequestId === revealRequestId || state.frameId !== null) {
234 handledRequestIdsByPath.get(relativePath) === revealRequestId ||
235 pendingFramesByPath.has(relativePath)
236 ) {
237 return;265 return;
238 }266 }
239267
240 const reveal = () => {268 const resolveScrollTarget = (line: number): number | null => {
241 pendingFramesByPath.delete(relativePath);269 const linePosition = instance.getLinePosition(line);
242 if (270 if (!linePosition) return null;
243 latestRequestIdsByPath.get(relativePath) !== revealRequestId ||
244 !fileContainer.isConnected
245 ) {
246 return;
247 }
248
249 const linePosition = instance.getLinePosition(targetLine);
250 if (!linePosition) return;
251271
272 const scrollContainerRect = scrollContainer.getBoundingClientRect();
252 const fileTop =273 const fileTop =
253 scrollContainer.scrollTop +274 scrollContainer.scrollTop +
254 fileContainer.getBoundingClientRect().top -275 fileContainer.getBoundingClientRect().top -
255 scrollContainer.getBoundingClientRect().top;276 scrollContainerRect.top;
256 const centeredTop = Math.max(277 const root = fileContainer.shadowRoot ?? fileContainer;
257 0,278 const renderedLineElement = root.querySelector<HTMLElement>(`[data-line="${line}"]`);
258 fileTop +279 const renderedLineRect = renderedLineElement?.getBoundingClientRect();
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 );
266280
267 scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop);281 return resolveCenteredFileLineScrollTop({
268 handledRequestIdsByPath.set(relativePath, revealRequestId);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 });
269 };297 };
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);
272 },369 },
273 [370 [revealStatesByPath, relativePath, revealLine, revealRequestId],
274 handledRequestIdsByPath,
275 latestRequestIdsByPath,
276 pendingFramesByPath,
277 relativePath,
278 revealLine,
279 revealRequestId,
280 ],
281 );371 );
282}372}
283373
...@@ -963,6 +1053,8 @@ export default function FilePreviewPanel({...@@ -963,6 +1053,8 @@ export default function FilePreviewPanel({
963 environmentId={environmentId}1053 environmentId={environmentId}
964 cwd={cwd}1054 cwd={cwd}
965 projectName={projectName}1055 projectName={projectName}
1056 selectedPath={relativePath}
1057 selectedPathRevealId={revealRequestId}
966 onOpenFile={onOpenFile}1058 onOpenFile={onOpenFile}
967 />1059 />
968 </aside>1060 </aside>
apps/web/src/components/files/ProjectFilePicker.logic.test.ts created+83
...@@ -0,0 +1,83 @@
1import { assert, describe, it } from "vite-plus/test";
2
3import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic";
4
5function pathsForQuery(entries: Parameters<typeof getProjectFilePickerMatches>[0], query: string) {
6 return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path }));
7}
8
9const 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
17describe("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 created+73
...@@ -0,0 +1,73 @@
1import type { ProjectEntry } from "@t3tools/contracts";
2import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
3
4export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200;
5
6export interface ProjectFilePickerMatch {
7 readonly name: string;
8 readonly nameMatchIndices: ReadonlyArray<number>;
9 readonly path: string;
10 readonly pathMatchIndices: ReadonlyArray<number>;
11}
12
13function 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 */
22function 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 */
45export 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 created+163
...@@ -0,0 +1,163 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState, type ReactNode } from "react";
3
4import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
5import { useTheme } from "~/hooks/useTheme";
6import { useRightPanelStore } from "~/rightPanelStore";
7import { primaryServerKeybindingsAtom } from "~/state/server";
8
9import { PierreEntryIcon } from "../chat/PierreEntryIcon";
10import { CommandPaletteContent } from "../CommandPaletteContent";
11import { type CommandPaletteActionItem } from "../CommandPalette.logic";
12import { CommandPaletteResults } from "../CommandPaletteResults";
13import {
14 getProjectFilePickerMatches,
15 PROJECT_FILE_PICKER_RESULT_LIMIT,
16} from "./ProjectFilePicker.logic";
17import { useProjectFilePickerQuery } from "./projectFilesQueryState";
18
19interface ProjectFilePickerProps {
20 readonly setOpen: (open: boolean) => void;
21}
22
23function 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
46function 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
53function 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
71function 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
155export 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 created+32
...@@ -0,0 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
4
5describe("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 created+24
...@@ -0,0 +1,24 @@
1interface LineGeometry {
2 readonly top: number;
3 readonly height: number;
4}
5
6interface 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
16export 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
...@@ -11,6 +11,7 @@ import { useCallback } from "react";...@@ -11,6 +11,7 @@ import { useCallback } from "react";
1111
12import { appAtomRegistry } from "~/rpc/atomRegistry";12import { appAtomRegistry } from "~/rpc/atomRegistry";
13import { projectEnvironment } from "~/state/projects";13import { projectEnvironment } from "~/state/projects";
14import { useProjectPathSearch } from "~/state/queries";
14import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";15import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1516
16const EMPTY_PROJECT_FILE_PATH = "";17const EMPTY_PROJECT_FILE_PATH = "";
...@@ -136,6 +137,32 @@ export function useProjectEntriesQuery(...@@ -136,6 +137,32 @@ export function useProjectEntriesQuery(
136 };137 };
137}138}
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 */
148export 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
139export function useProjectFileQuery(166export function useProjectFileQuery(
140 environmentId: EnvironmentId,167 environmentId: EnvironmentId,
141 cwd: string,168 cwd: string,
apps/web/src/components/search/HighlightedSearchLine.tsx created+183
...@@ -0,0 +1,183 @@
1import { getFiletypeFromFileName } from "@pierre/diffs";
2import type { ProjectContentMatch } from "@t3tools/contracts";
3import { memo, Suspense, use, useMemo, type CSSProperties } from "react";
4
5import { resolveDiffThemeName } from "~/lib/diffRendering";
6import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
7
8import { RenderErrorBoundary } from "../RenderErrorBoundary";
9
10interface Range {
11 readonly start: number;
12 readonly end: number;
13}
14
15interface CodeToken {
16 readonly content: string;
17 readonly offset: number;
18 readonly color?: string;
19 readonly fontStyle?: number;
20}
21
22interface Segment {
23 readonly content: string;
24 readonly isMatch: boolean;
25 readonly start: number;
26 readonly end: number;
27 readonly token: CodeToken;
28}
29
30function 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
51function 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
94function 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
104function 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
128function 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
157export 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 created+315
...@@ -0,0 +1,315 @@
1import type { ProjectContentMatch } from "@t3tools/contracts";
2import { LoaderCircle } from "lucide-react";
3import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
4
5import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
6import { useTheme } from "~/hooks/useTheme";
7import { cn } from "~/lib/utils";
8import { useRightPanelStore } from "~/rightPanelStore";
9import { useProjectContentSearch } from "~/state/queries";
10
11import { PierreEntryIcon } from "../chat/PierreEntryIcon";
12import { CommandPaletteContent } from "../CommandPaletteContent";
13import { ScrollArea } from "../ui/scroll-area";
14import { HighlightedSearchLine } from "./HighlightedSearchLine";
15
16interface 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 */
26const VISIBLE_MATCH_WINDOW = 100;
27
28interface MatchGroup {
29 readonly path: string;
30 readonly matches: ReadonlyArray<ProjectContentMatch & { readonly resultIndex: number }>;
31}
32
33function 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
40function 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
54function 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
77function 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
94function 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
302export 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 created+41
...@@ -0,0 +1,41 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3
4import { useProjects } from "~/state/entities";
5
6import { useHandleNewThread } from "./useHandleNewThread";
7
8export 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 */
20export 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() {...@@ -190,7 +190,7 @@ export function useNewThreadHandler() {
190 reusableStoredDraftThread.draftId,190 reusableStoredDraftThread.draftId,
191 {191 {
192 threadId: reusableStoredDraftThread.threadId,192 threadId: reusableStoredDraftThread.threadId,
193 ...(workspaceContext ?? {}),193 ...workspaceContext,
194 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),194 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
195 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),195 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
196 },196 },
apps/web/src/keybindings.test.ts+52
...@@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([...@@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([
118 command: "commandPalette.toggle",118 command: "commandPalette.toggle",
119 whenAst: whenNot(whenIdentifier("terminalFocus")),119 whenAst: whenNot(whenIdentifier("terminalFocus")),
120 },120 },
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 },
121 {131 {
122 shortcut: modShortcut("m", { shiftKey: true }),132 shortcut: modShortcut("m", { shiftKey: true }),
123 command: "modelPicker.toggle",133 command: "modelPicker.toggle",
...@@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => {...@@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => {
327 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),337 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
328 "⌘K",338 "⌘K",
329 );339 );
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 );
330 assert.strictEqual(348 assert.strictEqual(
331 shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"),349 shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"),
332 "Ctrl+Shift+M",350 "Ctrl+Shift+M",
...@@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => {...@@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => {
514 );532 );
515 });533 });
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
517 it("matches diff.toggle shortcut outside terminal focus", () => {569 it("matches diff.toggle shortcut outside terminal focus", () => {
518 assert.isTrue(570 assert.isTrue(
519 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {571 isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/lib/syntaxHighlighting.test.ts created+28
...@@ -0,0 +1,28 @@
1import type { DiffsHighlighter } from "@pierre/diffs";
2import { expect, it, vi } from "vite-plus/test";
3
4const { getSharedHighlighter } = vi.hoisted(() => ({
5 getSharedHighlighter: vi.fn(),
6}));
7
8vi.mock("@pierre/diffs", () => ({
9 getSharedHighlighter,
10}));
11
12import { getSyntaxHighlighterPromise } from "./syntaxHighlighting";
13
14it("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 created+30
...@@ -0,0 +1,30 @@
1import {
2 getSharedHighlighter,
3 type DiffsHighlighter,
4 type SupportedLanguages,
5} from "@pierre/diffs";
6
7import { resolveDiffThemeName } from "./diffRendering";
8
9const highlighterPromiseCache = new Map<string, Promise<DiffsHighlighter>>();
10
11export 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
...@@ -1,11 +1,24 @@...@@ -1,11 +1,24 @@
1import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";1import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects";
2import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";2import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects";
3import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime";
4import { WS_METHODS } from "@t3tools/contracts";
35
4import { environmentCatalog } from "../connection/catalog";6import { environmentCatalog } from "../connection/catalog";
5import { connectionAtomRuntime } from "../connection/runtime";7import { connectionAtomRuntime } from "../connection/runtime";
6import { environmentSnapshotAtom } from "./shell";8import { environmentSnapshotAtom } from "./shell";
79
8export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime);10export 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 */
16export 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});
9export const environmentProjects = createEnvironmentProjectAtoms({22export const environmentProjects = createEnvironmentProjectAtoms({
10 catalogValueAtom: environmentCatalog.catalogValueAtom,23 catalogValueAtom: environmentCatalog.catalogValueAtom,
11 snapshotAtom: environmentSnapshotAtom,24 snapshotAtom: environmentSnapshotAtom,
apps/web/src/state/queries.test.ts created+25
...@@ -0,0 +1,25 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { areProjectPathSearchTargetsEqual } from "./queries";
5
6describe("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";...@@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
12import type {12import type {
13 EnvironmentId,13 EnvironmentId,
14 OrchestrationThread,14 OrchestrationThread,
15 ProjectContentMatch,
16 ProjectEntryKind,
15 ThreadId,17 ThreadId,
16 VcsListRefsResult,18 VcsListRefsResult,
17 VcsRef,19 VcsRef,
...@@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react";...@@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react";
24import { appAtomRegistry } from "../rpc/atomRegistry";26import { appAtomRegistry } from "../rpc/atomRegistry";
25import { orchestrationEnvironment } from "./orchestration";27import { orchestrationEnvironment } from "./orchestration";
26import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";28import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
27import { projectEnvironment } from "./projects";29import { projectContentSearch, projectEnvironment } from "./projects";
28import { useEnvironmentQuery } from "./query";30import { useEnvironmentQuery } from "./query";
29import { useEnvironmentThread } from "./threads";31import { useEnvironmentThread } from "./threads";
30import { vcsEnvironment } from "./vcs";32import { vcsEnvironment } from "./vcs";
3133
32const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;34const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120;
33const COMPOSER_PATH_SEARCH_LIMIT = 80;35const COMPOSER_PATH_SEARCH_LIMIT = 80;
36const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120;
37const PROJECT_CONTENT_SEARCH_LIMIT = 500;
34const THREAD_SEARCH_DEBOUNCE_MS = 200;38const THREAD_SEARCH_DEBOUNCE_MS = 200;
35const VCS_REF_LIST_LIMIT = 100;39const VCS_REF_LIST_LIMIT = 100;
36const EMPTY_REFS: ReadonlyArray<VcsRef> = [];40const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
41const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = [];
37const INITIAL_BRANCH_CURSORS = [undefined] as const;42const INITIAL_BRANCH_CURSORS = [undefined] as const;
38const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);43const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
39const EMPTY_THREAD_SEARCH_ATOM = Atom.make({44const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
...@@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) {...@@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) {
229 };234 };
230}235}
231236
232export function useComposerPathSearch(target: ComposerPathSearchTarget) {237type ProjectPathSearchTarget = ComposerPathSearchTarget & {
238 readonly kind?: ProjectEntryKind | undefined;
239};
240
241export 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
253export function useProjectPathSearch(
254 target: ProjectPathSearchTarget,
255 limit: number,
256 options?: { readonly allowEmptyQuery?: boolean },
257) {
258 const allowEmptyQuery = options?.allowEmptyQuery === true;
233 const normalizedTarget = useMemo(259 const normalizedTarget = useMemo(
234 () => ({260 () => ({
235 environmentId: target.environmentId,261 environmentId: target.environmentId,
236 cwd: target.cwd,262 cwd: target.cwd,
237 query: target.query?.trim() ?? "",263 query: target.query == null ? null : target.query.trim(),
264 kind: target.kind,
238 }),265 }),
239 [target.cwd, target.environmentId, target.query],266 [target.cwd, target.environmentId, target.kind, target.query],
240 );267 );
241 const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS);268 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
242 const result = useEnvironmentQuery(269 const result = useEnvironmentQuery(
243 debouncedTarget.environmentId !== null &&270 debouncedTarget.environmentId !== null &&
244 debouncedTarget.cwd !== null &&271 debouncedTarget.cwd !== null &&
245 debouncedTarget.query.length > 0272 debouncedTarget.query !== null &&
273 (allowEmptyQuery || debouncedTarget.query.length > 0)
246 ? projectEnvironment.searchEntries({274 ? projectEnvironment.searchEntries({
247 environmentId: debouncedTarget.environmentId,275 environmentId: debouncedTarget.environmentId,
248 input: {276 input: {
249 cwd: debouncedTarget.cwd,277 cwd: debouncedTarget.cwd,
250 query: debouncedTarget.query,278 query: debouncedTarget.query,
251 limit: COMPOSER_PATH_SEARCH_LIMIT,279 limit,
280 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
252 },281 },
253 })282 })
254 : null,283 : null,
...@@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {...@@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) {
257 return {286 return {
258 entries: result.data?.entries ?? [],287 entries: result.data?.entries ?? [],
259 error: result.error,288 error: result.error,
260 isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending,289 isPending:
290 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,
291 searchedQuery: debouncedTarget.query ?? "",
261 refresh: result.refresh,292 refresh: result.refresh,
262 };293 };
263}294}
264295
296export function useComposerPathSearch(target: ComposerPathSearchTarget) {
297 return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT);
298}
299
300interface 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
309export 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
265export function useCheckpointDiff(344export function useCheckpointDiff(
266 target: CheckpointDiffTarget,345 target: CheckpointDiffTarget,
267 options?: { readonly enabled?: boolean },346 options?: { readonly enabled?: boolean },
docs/user/keybindings.md+4
...@@ -37,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`....@@ -37,6 +37,10 @@ Examples: `mod+j`, `mod+shift+d`, `ctrl+l`, `cmd+k`.
37Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and37Commands are IDs like `terminal.toggle`, `commandPalette.toggle`, `preview.refresh`, and
38`chat.new`. Project scripts are addressable as `script.{id}.run`, for example `script.test.run`.38`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`.
42Repeating either shortcut closes that search, and switching shortcuts replaces the open search.
43
40The command palette searches active thread titles, projects, branches, user messages, and final44The command palette searches active thread titles, projects, branches, user messages, and final
41agent responses across connected environments. Message matches show one labeled excerpt while45agent responses across connected environments. Message matches show one labeled excerpt while
42keeping the thread's project, branch, and machine context visible. Message search begins after two46keeping the thread's project, branch, and machine context visible. Message search begins after two
packages/contracts/src/keybindings.test.ts+12
...@@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () =>...@@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () =>
59 });59 });
60 assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle");60 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
62 const parsedLocal = yield* decode(KeybindingRule, {74 const parsedLocal = yield* decode(KeybindingRule, {
63 key: "mod+shift+n",75 key: "mod+shift+n",
64 command: "chat.newLocal",76 command: "chat.newLocal",
packages/contracts/src/keybindings.ts+2
...@@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [...@@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [
63 "preview.zoomOut",63 "preview.zoomOut",
64 "preview.resetZoom",64 "preview.resetZoom",
65 "commandPalette.toggle",65 "commandPalette.toggle",
66 "filePicker.toggle",
67 "projectSearch.toggle",
66 "composer.stash",68 "composer.stash",
67 "chat.new",69 "chat.new",
68 "chat.newLocal",70 "chat.newLocal",
packages/contracts/src/project.test.ts+42
...@@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 ProjectReadFileError,5 ProjectReadFileError,
6 ProjectSearchContentsError,
7 ProjectSearchContentsInput,
6 ProjectSearchEntriesError,8 ProjectSearchEntriesError,
9 ProjectSearchEntriesInput,
7 ProjectWriteFileError,10 ProjectWriteFileError,
8} from "./project.ts";11} from "./project.ts";
912
13const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput);
14const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput);
15
16describe("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
10describe("project RPC errors", () => {40describe("project RPC errors", () => {
11 it("derives stable messages from structured request context while retaining causes", () => {41 it("derives stable messages from structured request context while retaining causes", () => {
12 const cause = new Error("sensitive platform detail");42 const cause = new Error("sensitive platform detail");
...@@ -39,6 +69,18 @@ describe("project RPC errors", () => {...@@ -39,6 +69,18 @@ describe("project RPC errors", () => {
39 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");69 expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'.");
40 expect(readError.message).not.toContain(cause.message);70 expect(readError.message).not.toContain(cause.message);
41 expect(readError.cause).toBe(cause);71 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);
42 });84 });
4385
44 it("decodes legacy message-only errors during rolling upgrades", () => {86 it("decodes legacy message-only errors during rolling upgrades", () => {
packages/contracts/src/project.ts+78-4
...@@ -1,19 +1,29 @@...@@ -1,19 +1,29 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";2import {
3 NonNegativeInt,
4 PositiveInt,
5 TrimmedNonEmptyString,
6 TrimmedString,
7} from "./baseSchemas.ts";
38
4const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;9const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
10const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500;
5const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;11const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
6const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;12const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
713
14export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
15export type ProjectEntryKind = typeof ProjectEntryKind.Type;
16
8export const ProjectSearchEntriesInput = Schema.Struct({17export const ProjectSearchEntriesInput = Schema.Struct({
9 cwd: TrimmedNonEmptyString,18 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)),
11 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),22 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
23 kind: Schema.optional(ProjectEntryKind),
12});24});
13export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;25export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1426
15const ProjectEntryKind = Schema.Literals(["file", "directory"]);
16
17export const ProjectEntry = Schema.Struct({27export const ProjectEntry = Schema.Struct({
18 path: TrimmedNonEmptyString,28 path: TrimmedNonEmptyString,
19 kind: ProjectEntryKind,29 kind: ProjectEntryKind,
...@@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({...@@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({
26});36});
27export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;37export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
2838
39export 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});
49export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type;
50
51export const ProjectContentMatchRange = Schema.Struct({
52 start: NonNegativeInt,
53 end: NonNegativeInt,
54});
55export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type;
56
57export const ProjectContentMatch = Schema.Struct({
58 path: TrimmedNonEmptyString,
59 lineNumber: PositiveInt,
60 lineContent: Schema.String,
61 matchRanges: Schema.Array(ProjectContentMatchRange),
62});
63export type ProjectContentMatch = typeof ProjectContentMatch.Type;
64
65export const ProjectSearchContentsResult = Schema.Struct({
66 matches: Schema.Array(ProjectContentMatch),
67 truncated: Schema.Boolean,
68 regexFallbackError: Schema.optional(Schema.String),
69});
70export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type;
71
29export const ProjectListEntriesInput = Schema.Struct({72export const ProjectListEntriesInput = Schema.Struct({
30 cwd: TrimmedNonEmptyString,73 cwd: TrimmedNonEmptyString,
31});74});
...@@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe...@@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe
94 }137 }
95}138}
96139
140export 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
97export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()(171export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()(
98 "ProjectListEntriesError",172 "ProjectListEntriesError",
99 {173 {
packages/contracts/src/rpc.ts+11
...@@ -76,6 +76,9 @@ import {...@@ -76,6 +76,9 @@ import {
76 ProjectReadFileError,76 ProjectReadFileError,
77 ProjectReadFileInput,77 ProjectReadFileInput,
78 ProjectReadFileResult,78 ProjectReadFileResult,
79 ProjectSearchContentsError,
80 ProjectSearchContentsInput,
81 ProjectSearchContentsResult,
79 ProjectSearchEntriesError,82 ProjectSearchEntriesError,
80 ProjectSearchEntriesInput,83 ProjectSearchEntriesInput,
81 ProjectSearchEntriesResult,84 ProjectSearchEntriesResult,
...@@ -166,6 +169,7 @@ export const WS_METHODS = {...@@ -166,6 +169,7 @@ export const WS_METHODS = {
166 projectsRemove: "projects.remove",169 projectsRemove: "projects.remove",
167 projectsListEntries: "projects.listEntries",170 projectsListEntries: "projects.listEntries",
168 projectsReadFile: "projects.readFile",171 projectsReadFile: "projects.readFile",
172 projectsSearchContents: "projects.searchContents",
169 projectsSearchEntries: "projects.searchEntries",173 projectsSearchEntries: "projects.searchEntries",
170 projectsWriteFile: "projects.writeFile",174 projectsWriteFile: "projects.writeFile",
171175
...@@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr...@@ -438,6 +442,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr
438 error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]),442 error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]),
439});443});
440444
445export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, {
446 payload: ProjectSearchContentsInput,
447 success: ProjectSearchContentsResult,
448 error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]),
449});
450
441export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {451export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {
442 payload: ProjectListEntriesInput,452 payload: ProjectListEntriesInput,
443 success: ProjectListEntriesResult,453 success: ProjectListEntriesResult,
...@@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -801,6 +811,7 @@ export const WsRpcGroup = RpcGroup.make(
801 WsSourceControlPublishRepositoryRpc,811 WsSourceControlPublishRepositoryRpc,
802 WsProjectsListEntriesRpc,812 WsProjectsListEntriesRpc,
803 WsProjectsReadFileRpc,813 WsProjectsReadFileRpc,
814 WsProjectsSearchContentsRpc,
804 WsProjectsSearchEntriesRpc,815 WsProjectsSearchEntriesRpc,
805 WsProjectsWriteFileRpc,816 WsProjectsWriteFileRpc,
806 WsShellOpenInEditorRpc,817 WsShellOpenInEditorRpc,
packages/shared/src/keybindings.ts+2
...@@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
35 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },35 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
36 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },36 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
37 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },37 { 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" },
38 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },40 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
39 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },41 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
40 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },42 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },