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