diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5655f260bc9..5942b8ee652 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..252819adacf 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); + assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe3623..53f329e8512 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -547,19 +547,24 @@ const make = Effect.gen(function* () { }); } - const nextConfig = [...customConfig, ...missingDefaults]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS_COUNT - ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) - : nextConfig; - if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { - yield* Effect.logWarning("truncating keybindings config to max entries", { + // Startup backfill must never evict persisted user rules: append only + // the defaults that fit and skip the rest. + const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); + const defaultsToAppend = missingDefaults.slice(0, availableSlots); + const skippedDefaults = missingDefaults.slice(availableSlots); + if (skippedDefaults.length > 0) { + yield* Effect.logWarning("skipping default keybinding backfill at max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, + commands: skippedDefaults.map((rule) => rule.command), }); } + if (defaultsToAppend.length === 0) { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + return; + } - yield* writeConfigAtomically(cappedConfig); + yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..3d3a05f027f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => +const searchWorkspaceEntries = (input: { + cwd: string; + query: string; + limit: number; + kind?: "file" | "directory"; +}) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("applies the file filter before limiting search results", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "src/internal.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 1, + kind: "file", + }); + + expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); + expect(result.truncated).toBe(true); + }), + ); + + it.effect("answers an empty file-filtered query with a bounded file listing", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "README.md"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "", + limit: 10, + kind: "file", + }); + + const paths = result.entries.map((entry) => entry.path); + expect(paths).toHaveLength(2); + expect(paths).toContain("src/index.ts"); + expect(paths).toContain("README.md"); + expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); + }), + ); + + it.effect("returns only directories for the directory filter", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); + yield* writeTextFile(cwd, "src/index.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 10, + kind: "directory", + }); + + expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); + expect(result.truncated).toBe(false); + }), + ); + it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -292,6 +353,267 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("searchContents", () => { + it.effect("returns content matches with file paths, line numbers, and ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); + yield* writeTextFile( + cwd, + "src/shapes.ts", + "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", + ); + yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: false, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ + ["src/shapes.ts", 1], + ["src/shapes.ts", 2], + ]); + expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("honors case sensitivity and gitignore rules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); + yield* writeTextFile(cwd, "ignored.txt", "Square\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); + }), + ); + + it.effect("filters whole-word matches by word boundaries without widening ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "note", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // "notes", "denote", and "footnote" are word-adjacent and excluded; + // ranges cover exactly the query, never boundary characters. + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 0, end: 4 }], + }), + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 2, + matchRanges: [{ start: 9, end: 13 }], + }), + ]); + }), + ); + + it.effect("finds later whole-word matches in a file after rejected raw matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 11, + matchRanges: [{ start: 0, end: 3 }], + }), + ]); + }), + ); + + it.effect("treats astral-plane letters as whole word characters", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 6, end: 9 }], + }), + ]); + }), + ); + + it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "-foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // Consuming-boundary regex would swallow the separating spaces and + // drop the middle occurrence; boundary post-filtering keeps all three. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ], + }); + }), + ); + + it.effect("matches punctuation-edged regex queries as whole words", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: true, + }); + + // wholeWord + useRegex must not silently drop non-word-edged patterns + // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word + // characters at the match's left edge. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 4 }, + { start: 5, end: 9 }, + ], + }); + }), + ); + + it.effect("caps matches per file so one dense file cannot fill the page", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); + yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); + yield* writeTextFile(cwd, "src/other.ts", "needle\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 500, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + const byPath = new Map(); + for (const match of result.matches) { + byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); + } + expect(byPath.get("src/dense.ts")).toBe(100); + expect(byPath.get("src/other.ts")).toBe(1); + }), + ); + + it.effect("preserves regex escapes during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "\\SQUARE", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); + }), + ); + + it.effect("maps multi-byte lines to string-indexed ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); + yield* writeTextFile(cwd, "src/notes.ts", 'const label = "hĆ©llo wƶrld";\n'); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "wƶrld", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]!; + const range = match.matchRanges[0]!; + expect(match.lineContent.slice(range.start, range.end)).toBe("wƶrld"); + }), + ); + }); + describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..bb2113dac37 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,11 +14,14 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; + readonly searchContents: ( + input: ProjectSearchContentsInput, + ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { - return; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, + for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { + const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { + continue; + } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + variant, + cause, + }); + yield* workspaceSearchIndexes.invalidate(indexKey); }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(indexKey)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); + } }, ); @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = input.query - .trim() - .toLowerCase() - .replace(/^[@./]+/, ""); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); + const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( + "WorkspaceEntries.searchContents", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.searchContents(input); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), + ), + ), + ); + }); + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453..15572837030 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder } from "@ff-labs/fff-node"; +import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("waits for the full content index warmup before returning", () => + Effect.gen(function* () { + const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); + const finder = { + destroy: vi.fn(), + waitForIndexReady, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); + + expect(waitForIndexReady).toHaveBeenCalledWith(15_000); + }), +); + +it.effect("preserves a full-index warmup timeout as a structured error", () => + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexScanTimedOut", + cwd: "/workspace/project", + timeout: "15 seconds", + }); + }), +); + it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); + const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => { throw searchCause; }), + grep: vi.fn(() => { + throw contentSearchCause; + }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const contentSearchError = yield* Effect.flip( + searchIndex.searchContents({ + query, + limit: 3, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }), + ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(contentSearchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 3, + reason: "FileFinder.grep threw unexpectedly.", + cause: contentSearchCause, + }); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); + +it.effect("continues whole-word searches after a filtered grep page", () => + Effect.scoped( + Effect.gen(function* () { + const nextCursor = { + __brand: "GrepCursor", + _offset: 1, + } as GrepCursor; + const grepResult = ( + lineContent: string, + matchRanges: Array<[number, number]>, + cursor: GrepCursor | null, + ): GrepResult => ({ + items: [ + { + relativePath: "src/words.ts", + fileName: "words.ts", + gitStatus: "unmodified", + size: lineContent.length, + modified: 0, + isBinary: false, + totalFrecencyScore: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + lineNumber: 1, + col: 0, + byteOffset: 0, + lineContent, + matchRanges, + }, + ], + totalMatched: 1, + totalFilesSearched: 1, + totalFiles: 1, + filteredFileCount: 1, + nextCursor: cursor, + }); + const grep = vi.fn((_query: string, options?: GrepOptions) => + options?.cursor + ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } + : { + ok: true as const, + value: grepResult("needleSuffix", [[0, 6]], nextCursor), + }, + ); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + grep, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + const result = yield* searchIndex.searchContents({ + query: "needle", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result).toEqual({ + matches: [ + { + path: "src/words.ts", + lineNumber: 1, + lineContent: "needle", + matchRanges: [{ start: 0, end: 6 }], + }, + ], + truncated: false, + }); + expect(grep).toHaveBeenCalledTimes(2); + expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..09654eb6612 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,22 +1,36 @@ -import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import { + type DirItem, + type DirSearchResult, + type FileItem, + FileFinder, + type GrepCursor, + type MixedItem, + type MixedSearchResult, + type Result, + type SearchResult, +} from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; +const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, + kind?: ProjectEntryKind, ) => Effect.Effect; + readonly searchContents: ( + input: Omit, + ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } +function toFileEntry(item: FileItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "file" } : null; +} + +function toDirectoryEntry(item: DirItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; +} + +function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { + return { + entries: result.items + .flatMap((item) => { + const entry = toFileEntry(item); + return entry ? [entry] : []; + }) + .slice(0, limit), + truncated: result.totalMatched > limit, + }; +} + +function mapDirectorySearchResult( + result: DirSearchResult, + limit: number, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toDirectoryEntry(item); + return entry ? [entry] : []; + }); + const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; + return { + entries: entries.slice(0, limit), + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -155,6 +210,74 @@ function mapMixedSearchResult( }; } +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; + +function codePointAt(line: string, index: number): string | undefined { + const codePoint = line.codePointAt(index); + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); +} + +function codePointBefore(line: string, index: number): string | undefined { + if (index <= 0) return undefined; + const previousCodeUnit = line.charCodeAt(index - 1); + const previousIndex = + previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; + return codePointAt(line, previousIndex); +} + +function buildContentSearchQuery(input: Omit): { + readonly searchQuery: string; + readonly regexMode: boolean; +} { + if (input.caseSensitive) { + return { searchQuery: input.query, regexMode: input.useRegex }; + } + // Plain mode relies on smart case: an all-lowercase needle matches + // case-insensitively. Regex mode needs an explicit inline flag instead. + return input.useRegex + ? { searchQuery: `(?i:${input.query})`, regexMode: true } + : { searchQuery: input.query.toLowerCase(), regexMode: false }; +} + +function mapContentMatchRanges( + line: string, + byteRanges: ReadonlyArray, +): Array<{ readonly start: number; readonly end: number }> { + const lineBytes = Buffer.from(line); + const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; + return byteRanges.map(([startByte, endByte]) => ({ + start: toStringIndex(startByte), + end: toStringIndex(endByte), + })); +} + +/** + * Whole-word filtering happens after the grep rather than by wrapping the + * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow + * the separator between adjacent matches and widen the reported ranges, and + * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a + * match edge is a word boundary when it touches the line edge, the + * neighbouring character is not a word character, or the match's own edge + * character is not a word character. + */ +function isWholeWordRange( + line: string, + range: { readonly start: number; readonly end: number }, +): boolean { + if (range.end <= range.start) return false; + const isWord = (character: string | undefined) => + character !== undefined && WORD_CHARACTER.test(character); + const leftIsBoundary = + range.start === 0 || + !isWord(codePointBefore(line, range.start)) || + !isWord(codePointAt(line, range.start)); + const rightIsBoundary = + range.end >= line.length || + !isWord(codePointAt(line, range.end)) || + !isWord(codePointBefore(line, range.end)); + return leftIsBoundary && rightIsBoundary; +} + function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant, +) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - disableContentIndexing: true, + // Content indexing costs scan CPU and memory, so only the on-demand + // content-search index pays for it; path-only consumers (file tree, + // composer path search, file picker) keep the lightweight index. + disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c }); }); -const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => - Effect.try({ - try: () => finder.isScanning(), - catch: onFailure, - }).pipe( - Effect.repeat({ - while: (scanning) => scanning, - schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), - }), - Effect.timeoutOrElse({ - duration: WORKSPACE_INDEX_SCAN_TIMEOUT, - orElse: () => - new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), - }), - Effect.withSpan("WorkspaceSearchIndex.waitForScan"), - ); +const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( + cwd: string, + finder: FileFinder, + onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, +): Effect.fn.Return { + const result = yield* Effect.tryPromise({ + try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), + catch: (cause) => + onFailure({ + reason: "FileFinder.waitForIndexReady rejected unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* Effect.fail(onFailure({ reason: result.error })); + } + if (!result.value) { + return yield* new WorkspaceSearchIndexScanTimedOut({ + cwd, + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, + }); + } +}); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { - const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant = "paths", +) { + const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason: "FileFinder.isScanning threw while creating the index.", + reason, cause, }), ); - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, - ) { + operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", + execute: () => Result, + ): Effect.fn.Return { const result = yield* Effect.try({ - try: () => finder.mixedSearch(query, { pageSize }), + try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: "FileFinder.mixedSearch threw unexpectedly.", + reason: `FileFinder.${operation} threw unexpectedly.`, cause, }), }); @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin reason: result.error, }); } - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason: "FileFinder.isScanning threw while refreshing the index.", + reason, cause, }), ); @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => + finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), + ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + )(function* (query, limit, kind) { + const pageSize = Math.max(1, limit + 1); + if (kind === "file") { + const result = yield* runSearch(query, pageSize, "fileSearch", () => + finder.fileSearch(query, { pageSize }), + ); + return mapFileSearchResult(result, limit); + } + if (kind === "directory") { + const result = yield* runSearch(query, pageSize, "directorySearch", () => + finder.directorySearch(query, { pageSize }), + ); + return mapDirectorySearchResult(result, limit); + } + const result = yield* runSearch(query, pageSize, "mixedSearch", () => + finder.mixedSearch(query, { pageSize }), + ); return mapMixedSearchResult(result, limit); }); - return WorkspaceSearchIndex.of({ list, refresh, search }); + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( + "WorkspaceSearchIndex.searchContents", + )(function* (input) { + const { searchQuery, regexMode } = buildContentSearchQuery(input); + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; + // Grep cursors advance by file, so whole-word post-filtering needs enough + // raw candidates from the current file before moving to the next one. + const rawPageSize = input.wholeWord + ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) + : input.limit; + const matches: Array = []; + let nextCursor: GrepCursor | null = null; + let regexFallbackError: string | undefined; + + do { + const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); + const result = yield* runSearch(input.query, input.limit, "grep", () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + // A single dense file must not consume the whole result page. + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), + pageSize: rawPageSize, + cursor: nextCursor, + timeBudgetMs: remainingTimeBudgetMs, + }), + ); + + for (const match of result.items) { + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + ); + if (matchRanges.length === 0) continue; + matches.push({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges, + }); + } + nextCursor = result.nextCursor; + regexFallbackError ??= result.regexFallbackError; + } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); + + return { + matches: matches.slice(0, input.limit), + truncated: matches.length > input.limit || nextCursor !== null, + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); +export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; +export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; + +/** + * Composite LayerMap key so the lightweight path index and the on-demand + * content-search index of the same workspace are separate resources with + * independent lifecycles. "\n" cannot appear in a filesystem path. + */ +export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => + `${variant}\n${cwd}`; + +function parseWorkspaceSearchIndexKey(key: string): { + readonly cwd: string; + readonly variant: WorkspaceSearchIndexVariant; +} { + const separatorIndex = key.indexOf("\n"); + return { + variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, + cwd: key.slice(separatorIndex + 1), + }; +} + /** * A layer factory is required because every index is scoped to a concrete - * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; - * using a default cwd here would mix resources from different workspaces. + * workspace root and variant. WorkspaceSearchIndexMap owns memoization and + * idle cleanup; using a default cwd here would mix resources from different + * workspaces. */ -export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); +export const layer = (key: string) => { + const { cwd, variant } = parseWorkspaceSearchIndexKey(key); + return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); +}; export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6c021c9af80..665ad3e18da 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -36,6 +36,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1553,6 +1554,23 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsSearchContents]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchContents, + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f..985e943cb39 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -91,27 +92,6 @@ import { BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; -class CodeHighlightErrorBoundary extends React.Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -const highlighterPromiseCache = new Map>(); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getHighlighterPromise(language: string): Promise { - const cached = highlighterPromiseCache.get(language); - if (cached) return cached; - - const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], - langs: [language as SupportedLanguages], - preferredHighlighter: "shiki-js", - }).catch((err) => { - highlighterPromiseCache.delete(language); - if (language === "text") { - // "text" itself failed — Shiki cannot initialize at all, surface the error - throw err; - } - // Language not supported by Shiki — fall back to "text" - return getHighlighterPromise("text"); - }); - highlighterPromiseCache.set(language, promise); - return promise; -} - function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getHighlighterPromise(language)); + const highlighter = use(getSyntaxHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -1605,7 +1563,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 04de1784715..eac2d74440a 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -6,9 +6,81 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("reduceCommandPaletteUiState", () => { + const closedState = { open: false, mode: "command", openIntent: null } as const; + + it("toggles each overlay mode open and closed", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null }); + + const contentOpen = reduceCommandPaletteUiState(filesOpen, { + _tag: "ToggleMode", + mode: "content", + }); + expect(contentOpen).toEqual({ open: true, mode: "content", openIntent: null }); + + expect( + reduceCommandPaletteUiState(contentOpen, { _tag: "ToggleMode", mode: "content" }), + ).toEqual({ open: false, mode: "command", openIntent: null }); + }); + + it("switches between open modes without closing", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleMode", mode: "command" })).toEqual( + { + open: true, + mode: "command", + openIntent: null, + }, + ); + }); + + it("routes open intents to command mode", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "add-project" }, + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "new-thread-in" }, + }); + }); + + it("resets to command mode for dialog-driven opens and closes", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { + _tag: "ToggleMode", + mode: "files", + }); + + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({ + open: false, + mode: "command", + openIntent: null, + }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({ + open: true, + mode: "command", + openIntent: null, + }); + }); +}); + describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { const items = Array.from({ length: 10 }, (_, index) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 058322744bb..c5138cd2cb0 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,12 +15,61 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +/** + * The global search overlay hosts three mutually exclusive surfaces: the + * command palette (⌘K), the project file picker (⌘P), and project content + * search (ā‡§āŒ˜F). One reducer owns open/mode state so the surfaces can never + * stack and re-triggering a mode's shortcut toggles it closed. + */ +export type SearchOverlayMode = "command" | "files" | "content"; + +export interface CommandPaletteOpenIntent { + readonly kind: "add-project" | "new-thread-in"; +} + +export interface CommandPaletteUiState { + readonly open: boolean; + readonly mode: SearchOverlayMode; + readonly openIntent: CommandPaletteOpenIntent | null; +} + +export type CommandPaletteUiAction = + | { readonly _tag: "SetOpen"; readonly open: boolean } + | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } + | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "ClearOpenIntent" }; + +export function reduceCommandPaletteUiState( + state: CommandPaletteUiState, + action: CommandPaletteUiAction, +): CommandPaletteUiState { + switch (action._tag) { + case "SetOpen": + return { + open: action.open, + mode: "command", + openIntent: action.open ? state.openIntent : null, + }; + case "ToggleMode": + return state.open && state.mode === action.mode + ? { open: false, mode: "command", openIntent: null } + : { open: true, mode: action.mode, openIntent: null }; + case "OpenAddProject": + return { open: true, mode: "command", openIntent: { kind: "add-project" } }; + case "OpenNewThreadIn": + return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "ClearOpenIntent": + return state.openIntent ? { ...state, openIntent: null } : state; + } +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; - readonly description?: string; + readonly description?: ReactNode; readonly timestamp?: string; readonly icon: ReactNode; readonly disabled?: boolean; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 45b5e117600..828f429ccd6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -27,16 +27,16 @@ import { import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { - ArrowDownIcon, ArrowLeftIcon, - ArrowUpIcon, CornerLeftUpIcon, + FileSearchIcon, FolderIcon, FolderPlusIcon, LinkIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, + TextSearchIcon, } from "lucide-react"; import { useCallback, @@ -79,7 +79,9 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -98,6 +100,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -105,24 +108,22 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + reduceCommandPaletteUiState, + type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFilePicker } from "./files/ProjectFilePicker"; +import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { - Command, - CommandDialog, - CommandDialogPopup, - CommandFooter, - CommandInput, - CommandPanel, -} from "./ui/command"; +import { CommandDialog, CommandDialogPopup } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -344,50 +345,30 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} - -interface CommandPaletteUiState { - readonly open: boolean; - readonly openIntent: CommandPaletteOpenIntent | null; -} +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; -type CommandPaletteUiAction = - | { readonly _tag: "SetOpen"; readonly open: boolean } - | { readonly _tag: "Toggle" } - | { readonly _tag: "OpenAddProject" } - | { readonly _tag: "OpenNewThreadIn" } - | { readonly _tag: "ClearOpenIntent" }; - -function reduceCommandPaletteUiState( - state: CommandPaletteUiState, - action: CommandPaletteUiAction, -): CommandPaletteUiState { - switch (action._tag) { - case "SetOpen": - return { - open: action.open, - openIntent: action.open ? state.openIntent : null, - }; - case "Toggle": - return { open: !state.open, openIntent: null }; - case "OpenAddProject": - return { open: true, openIntent: { kind: "add-project" } }; - case "OpenNewThreadIn": - return { open: true, openIntent: { kind: "new-thread-in" } }; - case "ClearOpenIntent": - return state.openIntent ? { ...state, openIntent: null } : state; - } +function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; } export function CommandPalette({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, + mode: "command", openIntent: null, }); const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []); - const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); + const toggleMode = useCallback( + (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), + [], + ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); @@ -403,26 +384,36 @@ export function CommandPalette({ children }: { children: ReactNode }) { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented) return; + // Resolve with the complete shortcut context so customized bindings + // using any documented `when` condition (e.g. previewFocus) work. const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, }, }); - if (command !== "commandPalette.toggle") { + const mode = overlayModeForCommand(command); + if (mode === null) { return; } event.preventDefault(); event.stopPropagation(); - toggleOpen(); + toggleMode(mode); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, previewOpen, terminalOpen, toggleMode]); useEffect( () => @@ -440,12 +431,24 @@ export function CommandPalette({ children }: { children: ReactNode }) { return ( - + { + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { + eventDetails.cancel(); + toggleMode("command"); + return; + } + setOpen(open); + }} + > {children} @@ -455,31 +458,63 @@ export function CommandPalette({ children }: { children: ReactNode }) { function CommandPaletteDialog(props: { readonly open: boolean; + readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; + readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { + const composerHandleRef = useComposerHandleContext(); + if (!props.open) { return null; } return ( - + { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + onBackdropPointerDown={() => { + props.setOpen(false); + }} + > + {props.mode === "files" ? ( + + ) : props.mode === "content" ? ( + + ) : ( + + )} + ); } function OpenCommandPaletteDialog(props: { readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; + readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); - const { clearOpenIntent, openIntent, setOpen } = props; - const composerHandleRef = useComposerHandleContext(); + const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -1301,6 +1336,32 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:open-file-picker", + searchTerms: ["go to file", "open file", "file picker", "find file", "quick open"], + title: "Go to file", + icon: , + keepOpen: true, + shortcutCommand: "filePicker.toggle", + run: async () => { + openOverlayMode("files"); + }, + }); + + actionItems.push({ + kind: "action", + value: "action:search-project-contents", + searchTerms: ["search project", "find in files", "grep", "content search", "text search"], + title: "Search project contents", + icon: , + keepOpen: true, + shortcutCommand: "projectSearch.toggle", + run: async () => { + openOverlayMode("content"); + }, + }); + actionItems.push({ kind: "action", value: "action:add-project", @@ -2017,234 +2078,187 @@ function OpenCommandPaletteDialog(props: { primaryEnvironmentId, ]); + const inputAccessory = + addProjectCloneFlow?.step === "repository" ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitAddProjectCloneFlow(); + }} + /> + } + > + {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} + + Enter + + + {remoteProjectButtonLabel ?? "Continue"} (Enter) + + ) : isBrowsing ? ( + + { + event.preventDefault(); + }} + onClick={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> + } + > + + {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} + + + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} + + + + {submitActionLabel} ({addShortcutLabel}) + + + ) : null; + + const footerActionLabel = + addProjectCloneFlow?.step === "repository" + ? (remoteProjectButtonLabel ?? "Continue") + : !canSubmitBrowsePath || hasHighlightedBrowseItem + ? "Select" + : undefined; + + const footerTrailing = canOpenProjectFromFileManager ? ( + + ) : null; + return ( - { - composerHandleRef?.current?.focusAtEnd(); - return false; + autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} + footerActionLabel={footerActionLabel} + footerTrailing={footerTrailing} + inputAccessory={inputAccessory} + inputProps={{ + className: + addProjectCloneFlow?.step === "repository" + ? "pe-32" + : isBrowsing + ? willCreateProjectPath + ? "pe-36" + : "pe-16" + : undefined, + placeholder: inputPlaceholder, + wrapperClassName: isSubmenu + ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" + : undefined, + ...(isSubmenu + ? { + startAddon: ( + + ), + } + : isBrowsing + ? { startAddon: } + : {}), + onKeyDown: handleKeyDown, }} - onBackdropPointerDown={() => { - setOpen(false); + mode="none" + onItemHighlighted={(value) => { + setHighlightedItemValue(typeof value === "string" ? value : null); }} + onValueChange={handleQueryChange} + panelClassName="max-h-[min(28rem,70vh)]" + showBackHint={isSubmenu} + value={query} > - { - setHighlightedItemValue(typeof value === "string" ? value : null); - }} - onValueChange={handleQueryChange} - value={query} - > -
- +
Repository
+
+ {remoteProjectContext.icon} + + {remoteProjectContext.title} + + {remoteProjectContext.description} + + +
+
+ ) : null} + - - - ), - } - : isBrowsing && !isSubmenu + : addProjectCloneFlow?.step === "confirm" + ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } + : relativePathNeedsActiveProject + ? { emptyStateMessage: "Relative paths require an active project." } + : willCreateProjectPath ? { - startAddon: , + emptyStateMessage: "Press Enter to create this folder and add it as a project.", } : {})} - onKeyDown={handleKeyDown} - /> - {addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - - - ) : isBrowsing ? ( - - { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); - } - }} - /> - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {submitActionLabel} ({addShortcutLabel}) - - - ) : null} - - - {remoteProjectContext ? ( -
-
- Repository -
-
- {remoteProjectContext.icon} - - - {remoteProjectContext.title} - - - {remoteProjectContext.description} - - -
-
- ) : null} - -
- -
- - - - - - - - Navigate - - {addProjectCloneFlow?.step === "repository" ? ( - - Enter - {remoteProjectButtonLabel ?? "Continue"} - - ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( - - Enter - Select - - ) : null} - {isSubmenu ? ( - - Backspace - Back - - ) : null} - - Esc - Close - -
- {canOpenProjectFromFileManager ? ( - - ) : null} -
-
-
+ /> + ); } diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx new file mode 100644 index 00000000000..af3c1b67170 --- /dev/null +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -0,0 +1,77 @@ +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; +import { Kbd, KbdGroup } from "./ui/kbd"; + +type CommandPaletteContentProps = Omit, "children"> & { + readonly children: ReactNode; + readonly escapeLabel?: ReactNode; + readonly footerActionLabel?: ReactNode; + readonly footerTrailing?: ReactNode; + readonly inputAccessory?: ReactNode; + readonly inputProps: ComponentProps; + readonly panelClassName?: string; + readonly showBackHint?: boolean; + readonly testId?: string; +}; + +/** + * Shared command palette chrome. Palette modes provide their query behavior, + * results, and optional input accessory while retaining one input, panel, and + * keyboard-hint gutter. + */ +export function CommandPaletteContent({ + children, + escapeLabel = "Close", + footerActionLabel, + footerTrailing, + inputAccessory, + inputProps, + panelClassName, + showBackHint, + testId, + ...commandProps +}: CommandPaletteContentProps) { + return ( +
+ +
+ + {inputAccessory} +
+ {children} + +
+ + + + + + + + Navigate + + {footerActionLabel !== undefined ? ( + + Enter + {footerActionLabel} + + ) : null} + {showBackHint ? ( + + Backspace + Back + + ) : null} + + Esc + {escapeLabel} + +
+ {footerTrailing} +
+
+
+ ); +} diff --git a/apps/web/src/components/RenderErrorBoundary.tsx b/apps/web/src/components/RenderErrorBoundary.tsx new file mode 100644 index 00000000000..29f7d612d94 --- /dev/null +++ b/apps/web/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,16 @@ +import { Component, type ReactNode } from "react"; + +export class RenderErrorBoundary extends Component< + { readonly children: ReactNode; readonly fallback: ReactNode }, + { readonly failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 307d4413751..ff658693a70 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + /** File currently open in the preview pane; revealed and selected in the tree. */ + selectedPath: string | null; + /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ + selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + selectedPath, + selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + const syncingSelectionRef = useRef(false); + const treeSelectionPathRef = useRef(null); + const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + // The drag controller's selection cache must track every change, + // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); + // Selection changes driven by the reveal sync below are echoes of an + // already-open file, not a request to open it again. + if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { + treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); + useEffect(() => { + if (!selectedPath) { + handledRevealRef.current = null; + return; + } + const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; + const handledReveal = handledRevealRef.current; + // Entry refreshes rebuild treePaths while the same preview stays open. + // Replaying a handled reveal would close an active tree search and steal focus. + if ( + handledReveal?.path === revealRequest.path && + handledReveal.revealId === revealRequest.revealId + ) { + return; + } + if (entryKinds.get(selectedPath) !== "file") return; + const selectedItem = model.getItem(selectedPath); + if (!selectedItem) return; + + // A selection that originated inside the tree (clicking a row, possibly + // in an active tree search) is already visible; re-revealing it would + // close the search and clobber the user's context. Only sync external + // opens (file picker, content search, chat links). + const selectedInTree = model + .getSelectedPaths() + .some((path) => path.replace(/\/$/, "") === selectedPath); + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + return; + } + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + + syncingSelectionRef.current = true; + model.closeSearch(); + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + + // Directory rows are registered with a trailing slash (see treePath), so + // ancestor lookups must use the same form to expand them. + const segments = selectedPath.split("/"); + let ancestorPath = ""; + for (const segment of segments.slice(0, -1)) { + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); + if (item && "expand" in item) item.expand(); + } + + selectedItem.select(); + model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 24e63a6d8ea..a736cf96cd3 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,6 +51,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } +/** + * Frames to keep retrying while the file contents or line metrics are not + * available yet (fresh mounts hydrate asynchronously). + */ +const REVEAL_MAX_ATTEMPTS = 30; +/** + * After scrolling to the target, hold it for a short window so late + * programmatic scroll resets (editable-editor focus and state restoration) + * cannot silently snap the file back to the top. Real user input cancels the + * guard immediately. + */ +const REVEAL_GUARD_FRAMES = 20; +const REVEAL_GUARD_TOLERANCE_PX = 2; + +interface FileRevealState { + frameId: number | null; + cancelGuard: (() => void) | null; + handledRequestId: number | null; + latestRequestId: number | null; +} + function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [handledRequestIdsByPath] = useState(() => new Map()); - const [latestRequestIdsByPath] = useState(() => new Map()); - const [pendingFramesByPath] = useState(() => new Map()); + const [revealStatesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; + const existingState = revealStatesByPath.get(relativePath); + const state: FileRevealState = existingState ?? { + frameId: null, + cancelGuard: null, + handledRequestId: null, + latestRequestId: null, + }; + if (!existingState) revealStatesByPath.set(relativePath, state); + const cancelPendingReveal = () => { - const frameId = pendingFramesByPath.get(relativePath); - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - pendingFramesByPath.delete(relativePath); + if (state.frameId !== null) { + cancelAnimationFrame(state.frameId); + state.frameId = null; } + state.cancelGuard?.(); }; if (phase === "unmount") { @@ -208,18 +237,20 @@ function useFileLineReveal( return; } + const contents = instance.file?.contents; const targetLine = - revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { + if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); - latestRequestIdsByPath.set(relativePath, revealRequestId); + state.latestRequestId = revealRequestId; + state.handledRequestId = null; } - if (targetLine === null) { + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } @@ -230,54 +261,113 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if ( - handledRequestIdsByPath.get(relativePath) === revealRequestId || - pendingFramesByPath.has(relativePath) - ) { + if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } - const reveal = () => { - pendingFramesByPath.delete(relativePath); - if ( - latestRequestIdsByPath.get(relativePath) !== revealRequestId || - !fileContainer.isConnected - ) { - return; - } - - const linePosition = instance.getLinePosition(targetLine); - if (!linePosition) return; + const resolveScrollTarget = (line: number): number | null => { + const linePosition = instance.getLinePosition(line); + if (!linePosition) return null; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainer.getBoundingClientRect().top; - const centeredTop = Math.max( - 0, - fileTop + - linePosition.top - - Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), - ); - const maxScrollTop = Math.max( - 0, - scrollContainer.scrollHeight - scrollContainer.clientHeight, - ); + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); - handledRequestIdsByPath.set(relativePath, revealRequestId); + return resolveCenteredFileLineScrollTop({ + scrollTop: scrollContainer.scrollTop, + scrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollContainerRect.top, + viewportHeight: scrollContainer.clientHeight, + fileTop, + estimatedLine: linePosition, + ...(renderedLineRect && renderedLineRect.height > 0 + ? { + renderedLine: { + top: renderedLineRect.top, + height: renderedLineRect.height, + }, + } + : {}), + }); }; - pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); + const guardScrollTarget = (line: number) => { + let framesLeft = REVEAL_GUARD_FRAMES; + let guardFrameId: number | null = null; + const cancelGuard = () => { + if (guardFrameId !== null) { + cancelAnimationFrame(guardFrameId); + guardFrameId = null; + } + scrollContainer.removeEventListener("wheel", cancelGuard); + scrollContainer.removeEventListener("touchstart", cancelGuard); + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + // Pierre stops gutter pointer events from bubbling. Listen in capture + // so starting a comment cancels the reveal guard before the row expands. + scrollContainer.addEventListener("pointerdown", cancelGuard, { + passive: true, + capture: true, + }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { + scrollContainer.scrollTop = targetTop; + } + guardFrameId = requestAnimationFrame(holdTarget); + }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; + + const scheduleReveal = (attempt: number) => { + state.frameId = requestAnimationFrame(() => { + state.frameId = null; + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { + return; + } + + // Contents and line metrics can lag the first post-render on fresh + // mounts; clamping against missing contents would scroll to line 1 + // and wrongly mark the request handled. + const currentContents = instance.file?.contents; + const line = + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); + + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(line); + }); + }; + + scheduleReveal(0); }, - [ - handledRequestIdsByPath, - latestRequestIdsByPath, - pendingFramesByPath, - relativePath, - revealLine, - revealRequestId, - ], + [revealStatesByPath, relativePath, revealLine, revealRequestId], ); } @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} + selectedPath={relativePath} + selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts new file mode 100644 index 00000000000..2693ac9a072 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { getProjectFilePickerMatches } from "./ProjectFilePicker.logic"; + +function pathsForQuery(entries: Parameters[0], query: string) { + return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path })); +} + +const entries = [ + { kind: "directory", path: "apps/web/src" }, + { kind: "file", path: "apps/web/src/index.ts" }, + { kind: "file", path: "packages/shared/src/index.ts" }, + { kind: "file", path: "README.md" }, + { kind: "file", path: ".gitignore" }, +] as const; + +describe("getProjectFilePickerMatches", () => { + it("returns files only and preserves index order for an empty query", () => { + assert.deepEqual(pathsForQuery(entries, ""), [ + { name: "index.ts", path: "apps/web/src/index.ts" }, + { name: "index.ts", path: "packages/shared/src/index.ts" }, + { name: "README.md", path: "README.md" }, + { name: ".gitignore", path: ".gitignore" }, + ]); + }); + + it("preserves the server result order", () => { + assert.deepEqual(pathsForQuery(entries, "index"), [ + { name: "index.ts", path: "apps/web/src/index.ts" }, + { name: "index.ts", path: "packages/shared/src/index.ts" }, + { name: "README.md", path: "README.md" }, + { name: ".gitignore", path: ".gitignore" }, + ]); + }); + + it("supports space-separated path tokens and a result limit", () => { + assert.deepEqual( + getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({ + name, + path, + })), + [{ name: "index.ts", path: "apps/web/src/index.ts" }], + ); + }); + + it("matches ordered characters while allowing skipped characters", () => { + const fuzzyEntries = [ + { kind: "file", path: "src/TestFlags.tsx" }, + { kind: "file", path: "src/SubtestFlow.tsx" }, + { kind: "file", path: "src/useSubtestFlags.ts" }, + { + kind: "file", + path: "src/useSubtestFlags/useTabActivity.ts", + }, + ] as const; + + assert.deepEqual( + pathsForQuery(fuzzyEntries, "testf").map(({ name }) => name), + ["TestFlags.tsx", "SubtestFlow.tsx", "useSubtestFlags.ts", "useTabActivity.ts"], + ); + assert.deepEqual(getProjectFilePickerMatches(fuzzyEntries, "tsfl")[0], { + name: "TestFlags.tsx", + nameMatchIndices: [0, 2, 4, 5], + path: "src/TestFlags.tsx", + pathMatchIndices: [4, 6, 8, 9], + }); + }); + + it("uses the first ordered subsequence for highlighting", () => { + assert.deepEqual( + getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices, + [0, 2, 4], + ); + }); + + it("normalizes path prefixes consistently with server search", () => { + assert.deepEqual( + getProjectFilePickerMatches([{ kind: "file", path: "src/index.ts" }], "@/src")[0] + ?.pathMatchIndices, + [0, 1, 2], + ); + }); +}); diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.ts b/apps/web/src/components/files/ProjectFilePicker.logic.ts new file mode 100644 index 00000000000..048c5373425 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.logic.ts @@ -0,0 +1,73 @@ +import type { ProjectEntry } from "@t3tools/contracts"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; + +export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200; + +export interface ProjectFilePickerMatch { + readonly name: string; + readonly nameMatchIndices: ReadonlyArray; + readonly path: string; + readonly pathMatchIndices: ReadonlyArray; +} + +function fileName(path: string): string { + return path.slice(path.lastIndexOf("/") + 1); +} + +/** + * First ordered subsequence of `query` inside `value`, as highlight indices. + * Returns null when `value` does not contain the subsequence at all — the + * entry still renders (the server matched it), just without highlights. + */ +function findMatchIndices(value: string, query: string): number[] | null { + if (!query) return []; + + const normalizedValue = value.toLowerCase(); + const indices: number[] = []; + let queryIndex = 0; + + for (let valueIndex = 0; valueIndex < normalizedValue.length; valueIndex += 1) { + if (normalizedValue[valueIndex] !== query[queryIndex]) continue; + indices.push(valueIndex); + queryIndex += 1; + if (queryIndex === query.length) return indices; + } + + return null; +} + +/** + * Maps server search results to picker rows. Server ordering is preserved — + * ranking already happened there; this pass only filters to files and + * computes highlight positions with the same query normalization the server + * applied. + */ +export function getProjectFilePickerMatches( + entries: ReadonlyArray, + rawQuery: string, + limit = PROJECT_FILE_PICKER_RESULT_LIMIT, +): ProjectFilePickerMatch[] { + if (limit <= 0) return []; + + const query = normalizeSearchQuery(rawQuery, { + trimLeadingPattern: /^[@./]+/, + }).replaceAll(/\s/g, ""); + const matches: ProjectFilePickerMatch[] = []; + + for (const entry of entries) { + if (entry.kind !== "file") continue; + + const name = fileName(entry.path); + const nameMatchIndices = findMatchIndices(name, query); + const pathMatchIndices = findMatchIndices(entry.path, query); + matches.push({ + name, + nameMatchIndices: nameMatchIndices ?? [], + path: entry.path, + pathMatchIndices: pathMatchIndices ?? [], + }); + if (matches.length >= limit) break; + } + + return matches; +} diff --git a/apps/web/src/components/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx new file mode 100644 index 00000000000..a59e8dd77e4 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -0,0 +1,163 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo, useState, type ReactNode } from "react"; + +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; +import { useTheme } from "~/hooks/useTheme"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { primaryServerKeybindingsAtom } from "~/state/server"; + +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import { type CommandPaletteActionItem } from "../CommandPalette.logic"; +import { CommandPaletteResults } from "../CommandPaletteResults"; +import { + getProjectFilePickerMatches, + PROJECT_FILE_PICKER_RESULT_LIMIT, +} from "./ProjectFilePicker.logic"; +import { useProjectFilePickerQuery } from "./projectFilesQueryState"; + +interface ProjectFilePickerProps { + readonly setOpen: (open: boolean) => void; +} + +function HighlightedFuzzyText(props: { + readonly active: boolean; + readonly indices: ReadonlyArray; + readonly value: string; +}) { + if (!props.active) return props.value; + + const parts: ReactNode[] = []; + let start = 0; + for (const index of props.indices) { + if (start < index) parts.push(props.value.slice(start, index)); + parts.push( + + {props.value[index]} + , + ); + start = index + 1; + } + if (start < props.value.length) parts.push(props.value.slice(start)); + + return {parts}; +} + +function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string { + if (error) return error; + const isSearching = query.trim().length > 0; + if (isPending) return isSearching ? "Searching workspace files…" : "Indexing workspace files…"; + return isSearching ? "No matching files." : "No files found."; +} + +function EmptyProjectFilePicker() { + return ( + +
+ Open a project to search its files. +
+
+ ); +} + +function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) { + const { target } = props; + const [query, setQuery] = useState(""); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const result = useProjectFilePickerQuery( + target.environmentId, + target.cwd, + query, + PROJECT_FILE_PICKER_RESULT_LIMIT, + ); + const { resolvedTheme } = useTheme(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const matches = useMemo( + () => getProjectFilePickerMatches(result.entries, result.matchedQuery), + [result.entries, result.matchedQuery], + ); + const hasMatchedQuery = /\S/.test(result.matchedQuery); + const items = useMemo( + () => + matches.map((match) => ({ + kind: "action", + value: `file:${match.path}`, + searchTerms: [match.name, match.path], + title: ( + + ), + description: ( + + ), + icon: , + run: async () => { + useRightPanelStore.getState().openFile(target.threadRef, match.path); + }, + })), + [hasMatchedQuery, matches, resolvedTheme, target.threadRef], + ); + + const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending); + + return ( + { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={(value) => { + setHighlightedItemValue(null); + setQuery(value); + }} + panelClassName="max-h-[min(34rem,76vh)]" + testId="project-file-picker" + value={query} + > + 0 ? [{ value: "project-files", label: target.projectName, items }] : [] + } + highlightedItemValue={highlightedItemValue} + isActionsOnly={false} + keybindings={keybindings} + onExecuteItem={(item) => { + if (item.kind !== "action") return; + props.setOpen(false); + void item.run(); + }} + emptyStateMessage={emptyStateMessage} + /> + + ); +} + +export function ProjectFilePicker(props: ProjectFilePickerProps) { + const target = useActiveProjectTarget(); + + if (!target) { + return ; + } + + return ; +} diff --git a/apps/web/src/components/files/fileLineReveal.test.ts b/apps/web/src/components/files/fileLineReveal.test.ts new file mode 100644 index 00000000000..3a63168d5d3 --- /dev/null +++ b/apps/web/src/components/files/fileLineReveal.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; + +describe("resolveCenteredFileLineScrollTop", () => { + it("centers an estimated virtualized line position", () => { + expect( + resolveCenteredFileLineScrollTop({ + scrollTop: 0, + scrollHeight: 2_000, + viewportTop: 100, + viewportHeight: 400, + fileTop: 20, + estimatedLine: { top: 1_000, height: 20 }, + }), + ).toBe(830); + }); + + it("corrects a stale estimate from the rendered line geometry", () => { + expect( + resolveCenteredFileLineScrollTop({ + scrollTop: 830, + scrollHeight: 2_000, + viewportTop: 100, + viewportHeight: 400, + fileTop: 20, + estimatedLine: { top: 1_000, height: 20 }, + renderedLine: { top: 620, height: 20 }, + }), + ).toBe(1_160); + }); +}); diff --git a/apps/web/src/components/files/fileLineReveal.ts b/apps/web/src/components/files/fileLineReveal.ts new file mode 100644 index 00000000000..fd066ae205b --- /dev/null +++ b/apps/web/src/components/files/fileLineReveal.ts @@ -0,0 +1,24 @@ +interface LineGeometry { + readonly top: number; + readonly height: number; +} + +interface CenteredFileLineScrollInput { + readonly scrollTop: number; + readonly scrollHeight: number; + readonly viewportTop: number; + readonly viewportHeight: number; + readonly fileTop: number; + readonly estimatedLine: LineGeometry; + readonly renderedLine?: LineGeometry; +} + +export function resolveCenteredFileLineScrollTop(input: CenteredFileLineScrollInput): number { + const lineTop = + input.renderedLine === undefined + ? input.fileTop + input.estimatedLine.top + : input.scrollTop + input.renderedLine.top - input.viewportTop; + const lineHeight = input.renderedLine?.height ?? input.estimatedLine.height; + const centeredTop = Math.max(0, lineTop - Math.max(0, (input.viewportHeight - lineHeight) / 2)); + return Math.min(centeredTop, Math.max(0, input.scrollHeight - input.viewportHeight)); +} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0d3fb8dd941..d165c1d1a7a 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,6 +11,7 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; +import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( }; } +/** + * Backing query for the project file picker: a debounced, bounded, file-only + * server search. An empty query is a valid request — the index answers it + * with frecency-ordered files, so the picker's initial view is recent files + * without transferring the full workspace listing. `matchedQuery` is the + * query the returned entries were computed for, so the caller can highlight + * against results instead of half-typed input. + */ +export function useProjectFilePickerQuery( + environmentId: EnvironmentId, + cwd: string, + query: string, + limit: number, +) { + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { + allowEmptyQuery: true, + }); + + return { + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, + }; +} + export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/components/search/HighlightedSearchLine.tsx b/apps/web/src/components/search/HighlightedSearchLine.tsx new file mode 100644 index 00000000000..9d529622776 --- /dev/null +++ b/apps/web/src/components/search/HighlightedSearchLine.tsx @@ -0,0 +1,183 @@ +import { getFiletypeFromFileName } from "@pierre/diffs"; +import type { ProjectContentMatch } from "@t3tools/contracts"; +import { memo, Suspense, use, useMemo, type CSSProperties } from "react"; + +import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +import { RenderErrorBoundary } from "../RenderErrorBoundary"; + +interface Range { + readonly start: number; + readonly end: number; +} + +interface CodeToken { + readonly content: string; + readonly offset: number; + readonly color?: string; + readonly fontStyle?: number; +} + +interface Segment { + readonly content: string; + readonly isMatch: boolean; + readonly start: number; + readonly end: number; + readonly token: CodeToken; +} + +function normalizeRanges(match: ProjectContentMatch): Range[] { + const ranges = match.matchRanges + .map((range) => ({ + start: Math.max(0, Math.min(match.lineContent.length, range.start)), + end: Math.max(0, Math.min(match.lineContent.length, range.end)), + })) + .filter((range) => range.end > range.start) + .toSorted((left, right) => left.start - right.start); + + const merged: Array<{ start: number; end: number }> = []; + for (const range of ranges) { + const previous = merged.at(-1); + if (previous && range.start <= previous.end) { + previous.end = Math.max(previous.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +function splitToken(line: string, token: CodeToken, ranges: ReadonlyArray): Segment[] { + const segments: Segment[] = []; + const tokenEnd = token.offset + token.content.length; + let cursor = token.offset; + + for (const range of ranges) { + if (range.end <= cursor) continue; + if (range.start >= tokenEnd) break; + + const matchStart = Math.max(cursor, range.start); + if (matchStart > cursor) { + segments.push({ + content: line.slice(cursor, matchStart), + isMatch: false, + start: cursor, + end: matchStart, + token, + }); + } + + const matchEnd = Math.min(tokenEnd, range.end); + segments.push({ + content: line.slice(matchStart, matchEnd), + isMatch: true, + start: matchStart, + end: matchEnd, + token, + }); + cursor = matchEnd; + } + + if (cursor < tokenEnd) { + segments.push({ + content: line.slice(cursor, tokenEnd), + isMatch: false, + start: cursor, + end: tokenEnd, + token, + }); + } + return segments; +} + +function tokenStyle(token: CodeToken): CSSProperties { + const fontStyle = token.fontStyle ?? 0; + return { + ...(token.color ? { color: token.color } : {}), + ...(fontStyle & 1 ? { fontStyle: "italic" } : {}), + ...(fontStyle & 2 ? { fontWeight: 700 } : {}), + ...(fontStyle & 4 ? { textDecoration: "underline" } : {}), + }; +} + +function HighlightedTokens(props: { + readonly line: string; + readonly ranges: ReadonlyArray; + readonly tokens: ReadonlyArray; +}) { + return props.tokens + .flatMap((token) => splitToken(props.line, token, props.ranges)) + .map((segment) => + segment.isMatch ? ( + + {segment.content} + + ) : ( + + {segment.content} + + ), + ); +} + +function SyntaxHighlightedTokens(props: { + readonly line: string; + readonly language: string; + readonly ranges: ReadonlyArray; + readonly theme: "light" | "dark"; +}) { + const highlighter = use(getSyntaxHighlighterPromise(props.language)); + const tokens = useMemo(() => { + try { + return highlighter.codeToTokens(props.line, { + lang: props.language, + theme: resolveDiffThemeName(props.theme), + }).tokens[0]; + } catch { + return undefined; + } + }, [highlighter, props.language, props.line, props.theme]); + + return tokens ? ( + + ) : ( + + ); +} + +export const HighlightedSearchLine = memo(function HighlightedSearchLine(props: { + readonly match: ProjectContentMatch; + readonly path: string; + readonly theme: "light" | "dark"; +}) { + const ranges = useMemo(() => normalizeRanges(props.match), [props.match]); + const fallback = ( + + ); + + return ( + + + + + + ); +}); diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx new file mode 100644 index 00000000000..1890aab7fa7 --- /dev/null +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -0,0 +1,315 @@ +import type { ProjectContentMatch } from "@t3tools/contracts"; +import { LoaderCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; + +import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; +import { useTheme } from "~/hooks/useTheme"; +import { cn } from "~/lib/utils"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useProjectContentSearch } from "~/state/queries"; + +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import { ScrollArea } from "../ui/scroll-area"; +import { HighlightedSearchLine } from "./HighlightedSearchLine"; + +interface ProjectContentSearchDialogProps { + readonly onOpenChange: (open: boolean) => void; +} + +/** + * Result rows are syntax highlighted, so mounting all 500 possible rows at + * once stalls the UI. Rows render in windows that grow as the sentinel at the + * bottom of the list scrolls into view (or keyboard navigation moves past + * the rendered window). + */ +const VISIBLE_MATCH_WINDOW = 100; + +interface MatchGroup { + readonly path: string; + readonly matches: ReadonlyArray; +} + +function splitPath(path: string): { readonly name: string; readonly directory: string } { + const separator = path.lastIndexOf("/"); + return separator === -1 + ? { name: path, directory: "" } + : { name: path.slice(separator + 1), directory: path.slice(0, separator) }; +} + +function groupMatches(matches: ReadonlyArray): MatchGroup[] { + const groups = new Map>(); + matches.forEach((match, resultIndex) => { + const group = groups.get(match.path); + const indexedMatch = { ...match, resultIndex }; + if (group) { + group.push(indexedMatch); + } else { + groups.set(match.path, [indexedMatch]); + } + }); + return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches })); +} + +function SearchOptionButton(props: { + readonly active: boolean; + readonly label: string; + readonly onClick: () => void; + readonly children: ReactNode; +}) { + return ( + + ); +} + +function EmptyContentSearchDialog() { + return ( + + Open a project to search its files. + + ); +} + +function OpenContentSearchDialog(props: { + readonly onOpenChange: (open: boolean) => void; + readonly target: ActiveProjectTarget; +}) { + const { target } = props; + const { resolvedTheme } = useTheme(); + const [query, setQuery] = useState(""); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [useRegex, setUseRegex] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW); + + const search = useProjectContentSearch({ + environmentId: target.environmentId, + cwd: target.cwd, + query, + caseSensitive, + wholeWord, + useRegex, + }); + const canOpenMatches = !search.isPending; + const matches = search.matches; + const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); + const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); + + useEffect(() => { + setSelectedIndex(0); + setVisibleCount(VISIBLE_MATCH_WINDOW); + }, [matches]); + + useEffect(() => { + if (selectedIndex >= visibleCount) { + setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW); + return; + } + document + .querySelector(`[data-content-search-result="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex, visibleCount]); + + const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => { + if (!sentinel) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW); + } + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, []); + + const openMatch = (match: ProjectContentMatch) => { + if (!canOpenMatches) return; + props.onOpenChange(false); + useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); + }; + const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); + const showSearchStatus = + search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; + + return ( + + setCaseSensitive((current) => !current)} + > + Aa + + setWholeWord((current) => !current)} + > + ab + + setUseRegex((current) => !current)} + > + .* + + + } + inputProps={{ + className: "pe-30", + placeholder: `Search in ${target.projectName}`, + onKeyDown: (event) => { + if (event.key === "ArrowDown" && matches.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => (current + 1) % matches.length); + } else if (event.key === "ArrowUp" && matches.length > 0) { + event.preventDefault(); + setSelectedIndex((current) => (current - 1 + matches.length) % matches.length); + } else if (event.key === "Enter") { + // While a newer query is debouncing or in flight, the visible + // matches belong to the previous query; opening one would jump + // to a result the user did not ask for. + if (!canOpenMatches) { + event.preventDefault(); + return; + } + const match = matches[selectedIndex]; + if (match) { + event.preventDefault(); + openMatch(match); + } + } + }, + }} + mode="none" + onValueChange={setQuery} + panelClassName="flex min-h-0 flex-1 flex-col" + testId="project-content-search" + value={query} + > + {showSearchStatus ? ( +
+ {search.isPending ? ( + + Searching… + + ) : search.error ? ( + {search.error} + ) : search.invalidRegex ? ( + Invalid regular expression + ) : ( + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` + )} +
+ ) : null} + + {matches.length === 0 ? ( +
+ {search.hasQuery && !search.isPending && !search.error + ? "No results found." + : "Type to search across your project."} +
+ ) : ( + +
+ {groups.map((group) => { + const path = splitPath(group.path); + return ( +
+
+ + {path.name} + {path.directory ? ( + + {path.directory} + + ) : null} + + {group.matches.length} + +
+ {group.matches.map((match) => ( + + ))} +
+ ); + })} + {matches.length > visibleCount ? ( + + + )} + + ); +} + +export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { + const target = useActiveProjectTarget(); + + return target ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/hooks/useActiveProjectTarget.ts b/apps/web/src/hooks/useActiveProjectTarget.ts new file mode 100644 index 00000000000..560bba54f73 --- /dev/null +++ b/apps/web/src/hooks/useActiveProjectTarget.ts @@ -0,0 +1,41 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; + +import { useProjects } from "~/state/entities"; + +import { useHandleNewThread } from "./useHandleNewThread"; + +export interface ActiveProjectTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly projectName: string; + readonly threadRef: ScopedThreadRef; +} + +/** + * Resolves the project workspace behind the active thread (or draft) so + * project-scoped surfaces like the file picker and content search know which + * workspace to query and which thread's right panel opens their results. + */ +export function useActiveProjectTarget(): ActiveProjectTarget | null { + const { activeDraftThread, activeThread } = useHandleNewThread(); + const projects = useProjects(); + const thread = activeThread ?? activeDraftThread; + const threadId = activeThread?.id ?? activeDraftThread?.threadId; + const project = thread + ? projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ) + : null; + const cwd = thread?.worktreePath ?? project?.workspaceRoot; + + if (!thread || !threadId || !project || !cwd) return null; + + return { + environmentId: project.environmentId, + cwd, + projectName: project.title, + threadRef: scopeThreadRef(thread.environmentId, threadId), + }; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 74ad952e5a2..547d8287012 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -190,7 +190,7 @@ export function useNewThreadHandler() { reusableStoredDraftThread.draftId, { threadId: reusableStoredDraftThread.threadId, - ...(workspaceContext ?? {}), + ...workspaceContext, ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..703077e3ef3 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -118,6 +118,16 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("p"), + command: "filePicker.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("f", { shiftKey: true }), + command: "projectSearch.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -327,6 +337,14 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), + "⌘P", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"), + "ā‡§āŒ˜F", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -514,6 +532,40 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches filePicker.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "filePicker.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "filePicker.toggle", + ); + }); + + it("matches projectSearch.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "projectSearch.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "projectSearch.toggle", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/lib/syntaxHighlighting.test.ts b/apps/web/src/lib/syntaxHighlighting.test.ts new file mode 100644 index 00000000000..1f7ce756b60 --- /dev/null +++ b/apps/web/src/lib/syntaxHighlighting.test.ts @@ -0,0 +1,28 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; +import { expect, it, vi } from "vite-plus/test"; + +const { getSharedHighlighter } = vi.hoisted(() => ({ + getSharedHighlighter: vi.fn(), +})); + +vi.mock("@pierre/diffs", () => ({ + getSharedHighlighter, +})); + +import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; + +it("caches the recovered text highlighter for unsupported languages", async () => { + const textHighlighter = {} as DiffsHighlighter; + getSharedHighlighter.mockImplementation(({ langs }: { langs: string[] }) => + langs[0] === "text" + ? Promise.resolve(textHighlighter) + : Promise.reject(new Error("unsupported language")), + ); + + const first = getSyntaxHighlighterPromise("unsupported-test-language"); + await expect(first).resolves.toBe(textHighlighter); + const second = getSyntaxHighlighterPromise("unsupported-test-language"); + + expect(second).toBe(first); + expect(getSharedHighlighter).toHaveBeenCalledTimes(2); +}); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts new file mode 100644 index 00000000000..171725617e8 --- /dev/null +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -0,0 +1,30 @@ +import { + getSharedHighlighter, + type DiffsHighlighter, + type SupportedLanguages, +} from "@pierre/diffs"; + +import { resolveDiffThemeName } from "./diffRendering"; + +const highlighterPromiseCache = new Map>(); + +export function getSyntaxHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((error) => { + if (language === "text") { + highlighterPromiseCache.delete(language); + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw error; + } + // Language not supported by Shiki — fall back to "text" + return getSyntaxHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 7a879988328..d4e1098a364 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,11 +1,24 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +/** + * Web-only: project content search backs the ā‡§āŒ˜F dialog, which has no mobile + * surface, so the atom family lives here instead of the shared client-runtime + * project atoms consumed by the mobile app. + */ +export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:projects:search-contents", + tag: WS_METHODS.projectsSearchContents, + staleTimeMs: 5_000, + idleTtlMs: 60_000, +}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.test.ts b/apps/web/src/state/queries.test.ts new file mode 100644 index 00000000000..f8887dfb8ef --- /dev/null +++ b/apps/web/src/state/queries.test.ts @@ -0,0 +1,25 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { areProjectPathSearchTargetsEqual } from "./queries"; + +describe("areProjectPathSearchTargetsEqual", () => { + const target = { + environmentId: EnvironmentId.make("environment-a"), + cwd: "/project-a", + query: "index", + }; + + it("requires the environment, workspace, query, and entry kind to match", () => { + expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true); + expect( + areProjectPathSearchTargetsEqual(target, { + ...target, + environmentId: EnvironmentId.make("environment-b"), + }), + ).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, cwd: "/project-b" })).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, query: "readme" })).toBe(false); + expect(areProjectPathSearchTargetsEqual(target, { ...target, kind: "file" })).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 745c9e700b3..5cd006a4f37 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -7,6 +7,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProjectContentMatch, + ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -19,15 +21,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectEnvironment } from "./projects"; +import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_CONTENT_SEARCH_LIMIT = 500; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; export interface ThreadDetailView { @@ -184,26 +189,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { +type ProjectPathSearchTarget = ComposerPathSearchTarget & { + readonly kind?: ProjectEntryKind | undefined; +}; + +export function areProjectPathSearchTargetsEqual( + left: ProjectPathSearchTarget, + right: ProjectPathSearchTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query && + left.kind === right.kind + ); +} + +export function useProjectPathSearch( + target: ProjectPathSearchTarget, + limit: number, + options?: { readonly allowEmptyQuery?: boolean }, +) { + const allowEmptyQuery = options?.allowEmptyQuery === true; const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query?.trim() ?? "", + query: target.query == null ? null : target.query.trim(), + kind: target.kind, }), - [target.cwd, target.environmentId, target.query], + [target.cwd, target.environmentId, target.kind, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, @@ -212,11 +241,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: result.error, - isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + isPending: + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, + searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; } +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); +} + +interface ProjectContentSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string; + readonly caseSensitive: boolean; + readonly wholeWord: boolean; + readonly useRegex: boolean; +} + +export function useProjectContentSearch(target: ProjectContentSearchTarget) { + // Whitespace is significant in content queries; trimming is only used to + // decide whether the input is blank. + const query = target.query; + const hasQuery = query.trim().length > 0; + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + target.environmentId !== null && + target.cwd !== null && + hasQuery && + debouncedQuery.trim().length > 0 + ? projectContentSearch({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + query: debouncedQuery, + limit: PROJECT_CONTENT_SEARCH_LIMIT, + caseSensitive: target.caseSensitive, + wholeWord: target.wholeWord, + useRegex: target.useRegex, + }, + }) + : null, + ); + + return { + matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, + error: result.error, + isPending: query !== debouncedQuery || result.isPending, + hasQuery, + truncated: result.data?.truncated ?? false, + invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, + }; +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a0..7d7b2a52b12 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -30,6 +30,8 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, + { "key": "mod+p", "command": "filePicker.toggle", "when": "!terminalFocus" }, + { "key": "mod+shift+f", "command": "projectSearch.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, @@ -64,6 +66,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) - `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) - `commandPalette.toggle`: open or close the global command palette +- `filePicker.toggle`: open or close the active project's file picker +- `projectSearch.toggle`: open or close content search across the active project's files - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..4e8ea953770 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedFilePicker = yield* decode(KeybindingRule, { + key: "mod+p", + command: "filePicker.toggle", + }); + assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); + + const parsedProjectSearch = yield* decode(KeybindingRule, { + key: "mod+shift+f", + command: "projectSearch.toggle", + }); + assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d966c1e7e61..14a2a9a4b30 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "filePicker.toggle", + "projectSearch.toggle", "composer.stash", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c..8e6771cba88 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectSearchContentsError, + ProjectSearchContentsInput, ProjectSearchEntriesError, + ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; +const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); +const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); + +describe("project search inputs", () => { + it("allows an empty entries query for bounded frecency browsing", () => { + const decoded = decodeSearchEntriesInput({ + cwd: "/workspace", + query: " ", + limit: 10, + kind: "file", + }); + expect(decoded.query).toBe(""); + }); + + it("preserves whitespace in content search queries", () => { + const decoded = decodeSearchContentsInput({ + cwd: "/workspace", + query: " foo ", + limit: 10, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }); + expect(decoded.query).toBe(" foo "); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -39,6 +69,18 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); + + const contentSearchError = new ProjectSearchContentsError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 100, + failure: "search_index_search_failed", + cause, + }); + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); + expect(contentSearchError.message).not.toContain(cause.message); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad3..a1b11df73b2 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,19 +1,29 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); +export type ProjectEntryKind = typeof ProjectEntryKind.Type; + export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // An empty query is a bounded browse: the index returns frecency-ordered + // entries, which the file picker uses for its initial results. + query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), + kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; -const ProjectEntryKind = Schema.Literals(["file", "directory"]); - export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectSearchContentsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + // Whitespace is significant in content queries (" foo", regex trailing + // spaces), so the query is deliberately not trimmed on the wire. + query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), + caseSensitive: Schema.Boolean, + wholeWord: Schema.Boolean, + useRegex: Schema.Boolean, +}); +export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; + +export const ProjectContentMatchRange = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, +}); +export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; + +export const ProjectContentMatch = Schema.Struct({ + path: TrimmedNonEmptyString, + lineNumber: PositiveInt, + lineContent: Schema.String, + matchRanges: Schema.Array(ProjectContentMatchRange), +}); +export type ProjectContentMatch = typeof ProjectContentMatch.Type; + +export const ProjectSearchContentsResult = Schema.Struct({ + matches: Schema.Array(ProjectContentMatch), + truncated: Schema.Boolean, + regexFallbackError: Schema.optional(Schema.String), +}); +export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; + export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchContentsError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace contents in '${props.cwd}'.`, + } as any); + } +} + export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0701e15a668..71dbf3f377e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -74,6 +74,9 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, + ProjectSearchContentsError, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -163,6 +166,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -424,6 +428,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { + payload: ProjectSearchContentsInput, + success: ProjectSearchContentsResult, + error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), +}); + export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, @@ -780,6 +790,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0688cf07254..6add9f47844 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, + { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },