From 6a16c6c2936cbd3ad79d1affae2144732324f26e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 12:36:17 -0400 Subject: [PATCH 01/10] Add project file picker (mod+p) and project content search (mod+shift+f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines and supersedes #3985 and #4007 with a unified global search overlay: the command palette host now owns three mutually exclusive modes — command (mod+k), file picker (mod+p), and content search (mod+shift+f) — behind a single reducer and keydown dispatcher, so the surfaces never stack and shortcuts switch modes in place. Server: WorkspaceSearchIndex routes kind-filtered entry search to fff fileSearch/directorySearch and adds grep-backed searchContents with case/whole-word/regex options, punctuation-safe whole-word boundaries, and byte-to-string match range mapping; content indexing is enabled for workspace indexes. New projects.searchContents RPC with read-scope auth and structured errors that never leak query text. Web: shared useActiveProjectTarget hook resolves the active thread/draft workspace for both overlays; results open through the right-panel openFile path (with line reveal for content matches), and the file explorer reveals and selects the opened file. Shared Shiki highlighter promise cache and RenderErrorBoundary are extracted from ChatMarkdown and reused for syntax-highlighted search result lines. Co-Authored-By: Claude Fable 5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/keybindings.test.ts | 2 + .../src/workspace/WorkspaceEntries.test.ts | 188 ++++++++++- apps/server/src/workspace/WorkspaceEntries.ts | 27 +- .../workspace/WorkspaceSearchIndex.test.ts | 23 ++ .../src/workspace/WorkspaceSearchIndex.ts | 182 ++++++++++- apps/server/src/ws.ts | 18 ++ apps/web/src/components/ChatMarkdown.tsx | 52 +-- .../components/CommandPalette.logic.test.ts | 72 +++++ .../src/components/CommandPalette.logic.ts | 51 ++- apps/web/src/components/CommandPalette.tsx | 78 +++-- .../src/components/RenderErrorBoundary.tsx | 16 + .../src/components/files/FileBrowserPanel.tsx | 34 ++ .../src/components/files/FilePreviewPanel.tsx | 2 + .../files/ProjectFilePicker.logic.test.ts | 83 +++++ .../files/ProjectFilePicker.logic.ts | 73 +++++ .../components/files/ProjectFilePicker.tsx | 177 +++++++++++ .../files/projectFilesQueryState.ts | 37 +++ .../search/HighlightedSearchLine.tsx | 183 +++++++++++ .../search/ProjectContentSearchDialog.tsx | 296 ++++++++++++++++++ apps/web/src/hooks/useActiveProjectTarget.ts | 41 +++ apps/web/src/hooks/useHandleNewThread.ts | 2 +- apps/web/src/keybindings.test.ts | 52 +++ apps/web/src/lib/syntaxHighlighting.ts | 30 ++ apps/web/src/state/queries.test.ts | 25 ++ apps/web/src/state/queries.ts | 82 ++++- docs/user/keybindings.md | 4 + .../src/state/projectCommands.ts | 6 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/keybindings.test.ts | 12 + packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/project.test.ts | 13 + packages/contracts/src/project.ts | 69 +++- packages/contracts/src/rpc.ts | 11 + packages/shared/src/keybindings.ts | 2 + 35 files changed, 1835 insertions(+), 114 deletions(-) create mode 100644 apps/web/src/components/RenderErrorBoundary.tsx create mode 100644 apps/web/src/components/files/ProjectFilePicker.logic.test.ts create mode 100644 apps/web/src/components/files/ProjectFilePicker.logic.ts create mode 100644 apps/web/src/components/files/ProjectFilePicker.tsx create mode 100644 apps/web/src/components/search/HighlightedSearchLine.tsx create mode 100644 apps/web/src/components/search/ProjectContentSearchDialog.tsx create mode 100644 apps/web/src/hooks/useActiveProjectTarget.ts create mode 100644 apps/web/src/lib/syntaxHighlighting.ts create mode 100644 apps/web/src/state/queries.test.ts 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/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..05ca4cb889c 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,41 @@ 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("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 +332,152 @@ 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("matches punctuation-ended literal queries as whole words", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-bar 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).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 4 }, + { start: 13, end: 17 }, + ], + }); + }), + ); + + it.effect("matches punctuation-ended 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-bar foo-\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-" + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + }); + expect(result.matches[0]?.matchRanges).toHaveLength(2); + }), + ); + + 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..e9454fb376f 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") {} @@ -230,17 +236,26 @@ 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); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); + 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(normalizedCwd))); + }); + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); @@ -251,7 +266,7 @@ export const make = Effect.gen(function* () { }, ); - 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..885c4f58982 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -85,12 +85,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), mixedSearch: vi.fn(() => { throw searchCause; }), + grep: vi.fn(() => { + throw contentSearchCause; + }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -100,6 +104,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 +125,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", diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..5d1515d8662 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,4 +1,13 @@ -import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import { + type DirItem, + type DirSearchResult, + type FileItem, + FileFinder, + 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"; @@ -8,7 +17,10 @@ import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -17,6 +29,7 @@ const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -96,7 +109,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 +146,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 +209,68 @@ function mapMixedSearchResult( }; } +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; + +function escapeRegexLiteral(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * `\b` only matches at word-character edges, so a pattern edge that is + * punctuation (e.g. the query "foo-") would never match inside + * `\b(?:foo-)\b`. Fall back to explicit non-word boundaries for those edges. + */ +function wrapWholeWord(pattern: string, query: string): string { + const firstCharacter = Array.from(query).at(0) ?? ""; + const lastCharacter = Array.from(query).at(-1) ?? ""; + const leading = WORD_CHARACTER.test(firstCharacter) ? "\\b" : "(?:^|\\W)"; + const trailing = WORD_CHARACTER.test(lastCharacter) ? "\\b" : "(?:$|\\W)"; + return `${leading}(?:${pattern})${trailing}`; +} + +function buildContentSearchQuery(input: Omit): { + readonly searchQuery: string; + readonly regexMode: boolean; +} { + const pattern = input.wholeWord + ? wrapWholeWord(input.useRegex ? input.query : escapeRegexLiteral(input.query), input.query) + : input.query; + const regexMode = input.useRegex || input.wholeWord; + if (input.caseSensitive) { + return { searchQuery: pattern, regexMode }; + } + // Plain mode relies on smart case: an all-lowercase needle matches + // case-insensitively. Regex mode needs an explicit inline flag instead. + return regexMode + ? { searchQuery: `(?i:${pattern})`, regexMode } + : { searchQuery: pattern.toLowerCase(), regexMode }; +} + +function mapContentMatchRanges( + input: Omit, + 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]) => { + const start = toStringIndex(startByte); + const end = toStringIndex(endByte); + if (!input.wholeWord || input.useRegex) return { start, end }; + + // Non-word whole-word edges consume the boundary character, so the raw + // range can include punctuation around the literal query. Re-anchor the + // range on the query itself for exact highlighting. + const matchedText = line.slice(start, end); + const queryIndex = input.caseSensitive + ? matchedText.indexOf(input.query) + : matchedText.toLocaleLowerCase().indexOf(input.query.toLocaleLowerCase()); + return queryIndex === -1 + ? { start, end } + : { start: start + queryIndex, end: start + queryIndex + input.query.length }; + }); +} + function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -175,7 +291,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c FileFinder.create({ basePath: cwd, disableMmapCache: true, - disableContentIndexing: true, + disableContentIndexing: false, aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -229,18 +345,20 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin }), ); - 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, }), }); @@ -287,7 +405,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,12 +422,54 @@ 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 result = yield* runSearch(input.query, input.limit, "grep", () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + maxMatchesPerFile: input.limit, + pageSize: input.limit, + timeBudgetMs: CONTENT_SEARCH_TIME_BUDGET_MS, + }), + ); + return { + matches: result.items.map((match) => ({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges: mapContentMatchRanges(input, match.lineContent, match.matchRanges), + })), + truncated: result.nextCursor !== null, + ...(result.regexFallbackError !== undefined + ? { regexFallbackError: result.regexFallbackError } + : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); /** 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 b4e874017d4..3b0b50d4bd8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -96,6 +96,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, + type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -103,12 +104,16 @@ 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 { 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"; @@ -340,50 +345,30 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; -interface CommandPaletteUiState { - readonly open: boolean; - readonly openIntent: CommandPaletteOpenIntent | null; -} - -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" }), []); @@ -409,16 +394,17 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - 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, terminalOpen, toggleMode]); useEffect( () => @@ -434,23 +420,31 @@ export function CommandPalette({ children }: { children: ReactNode }) { [openAddProject, openNewThreadIn, setOpen], ); + const commandDialogOpen = state.open && state.mode !== "content"; + return ( - + {children} + ); } function CommandPaletteDialog(props: { readonly open: boolean; + readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; readonly clearOpenIntent: () => void; @@ -459,6 +453,10 @@ function CommandPaletteDialog(props: { return null; } + if (props.mode === "files") { + return ; + } + return ( { + 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..517175fecee 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,7 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + const syncingSelectionRef = useRef(false); // 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,6 +223,9 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (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; dragMention.handleSelectionChange(selectedPaths); // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. @@ -247,6 +257,30 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); + useEffect(() => { + if (!selectedPath || entryKinds.get(selectedPath) !== "file") return; + + syncingSelectionRef.current = true; + model.closeSearch(); + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + + const segments = selectedPath.split("/"); + let ancestorPath = ""; + for (const segment of segments.slice(0, -1)) { + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; + const item = model.getItem(ancestorPath); + if (item && "expand" in item) item.expand(); + } + + model.getItem(selectedPath)?.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 6ddd38e9d25..57a79f33576 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -941,6 +941,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..f8a33866d56 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -0,0 +1,177 @@ +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 { type CommandPaletteActionItem } from "../CommandPalette.logic"; +import { CommandPaletteResults } from "../CommandPaletteResults"; +import { Command, CommandDialogPopup, CommandInput, CommandPanel } from "../ui/command"; +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 ProjectFilePickerPopup(props: { + readonly children: ReactNode; + readonly setOpen: (open: boolean) => void; +}) { + return ( + props.setOpen(false)} + > + {props.children} + + ); +} + +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(props: ProjectFilePickerProps) { + 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); + }} + 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/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0d3fb8dd941..6a084b4564b 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,42 @@ export function useProjectEntriesQuery( }; } +/** + * Backing query for the project file picker: the workspace index listing when + * the query is blank, and a debounced file-only server search otherwise. + * `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 listing = useProjectEntriesQuery(environmentId, cwd); + const hasQuery = /\S/.test(query); + const search = useProjectPathSearch( + { environmentId, cwd, query: hasQuery ? query : null, kind: "file" }, + limit, + ); + + if (hasQuery) { + return { + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, + }; + } + + return { + entries: listing.data?.entries ?? [], + error: listing.data === null ? listing.error : null, + isPending: listing.data === null && listing.isPending, + matchedQuery: "", + }; +} + 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..9b9607d9924 --- /dev/null +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -0,0 +1,296 @@ +import type { ProjectContentMatch } from "@t3tools/contracts"; +import { LoaderCircle, Search } from "lucide-react"; +import { 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 { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { HighlightedSearchLine } from "./HighlightedSearchLine"; + +interface ProjectContentSearchDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +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 ContentSearchPopup(props: { readonly children: ReactNode }) { + return ( + + {props.children} + + ); +} + +function EmptyContentSearchDialog() { + return ( + + Search project contents +
+ 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 search = useProjectContentSearch({ + environmentId: target.environmentId, + cwd: target.cwd, + query, + caseSensitive, + wholeWord, + useRegex, + }); + const matches = search.matches; + const groups = useMemo(() => groupMatches(matches), [matches]); + + useEffect(() => { + setSelectedIndex(0); + }, [matches]); + + useEffect(() => { + document + .querySelector(`[data-content-search-result="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const openMatch = (match: ProjectContentMatch) => { + props.onOpenChange(false); + useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); + }; + const fileCount = groups.length; + + return ( + + Search {target.projectName} +
+ + setQuery(event.currentTarget.value)} + 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") { + const match = matches[selectedIndex]; + if (match) { + event.preventDefault(); + openMatch(match); + } + } + }} + className="h-9 min-w-0 flex-1 px-2 font-mono text-sm" + placeholder={`Search in ${target.projectName}`} + aria-label={`Search file contents in ${target.projectName}`} + /> +
+ setCaseSensitive((current) => !current)} + > + Aa + + setWholeWord((current) => !current)} + > + ab + + setUseRegex((current) => !current)} + > + .* + +
+
+ +
+
+ {search.isPending ? ( + + Searching… + + ) : search.error ? ( + {search.error} + ) : search.invalidRegex ? ( + Invalid regular expression + ) : !search.hasQuery ? ( + `Search every file in ${target.projectName}` + ) : ( + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` + )} +
+ + {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) => ( + + ))} +
+ ); + })} +
+
+ )} +
+
+ ↑↓ Navigate + ↵ Open file + esc Close +
+
+ ); +} + +export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { + const target = useActiveProjectTarget(); + + return ( + + {props.open ? ( + target ? ( + + ) : ( + + ) + ) : null} + + ); +} 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 2479d6ba02f..6583e89e34c 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -191,7 +191,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.ts b/apps/web/src/lib/syntaxHighlighting.ts new file mode 100644 index 00000000000..c8daf97e0ff --- /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) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + // "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/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..6eb411aeb20 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, @@ -24,10 +26,13 @@ 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,16 +189,33 @@ 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) { const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, query: 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 && @@ -203,7 +225,8 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, @@ -212,11 +235,58 @@ 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) { + const query = target.query.trim(); + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + target.environmentId !== null && + target.cwd !== null && + query.length > 0 && + debouncedQuery.length > 0 + ? projectEnvironment.searchContents({ + 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: query.length > 0, + 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/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..e68d69998fa 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -60,6 +60,12 @@ export function createProjectEnvironmentAtoms( tag: WS_METHODS.projectsSearchEntries, staleTimeMs: 15_000, }), + searchContents: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:search-contents", + tag: WS_METHODS.projectsSearchContents, + staleTimeMs: 5_000, + idleTtlMs: 60_000, + }), listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d67..ea51689a52f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -26,6 +26,8 @@ import type { ProjectListEntriesResult, ProjectReadFileInput, ProjectReadFileResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -1158,6 +1160,7 @@ export interface EnvironmentApi { projects: { listEntries: (input: ProjectListEntriesInput) => Promise; readFile: (input: ProjectReadFileInput) => Promise; + searchContents: (input: ProjectSearchContentsInput) => Promise; searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; 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..ca2158a56b8 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, } from "./project.ts"; @@ -39,6 +40,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..36610e024fc 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -2,18 +2,21 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } 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)), 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 +29,37 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectSearchContentsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + query: TrimmedNonEmptyString.check(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 +128,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" }, From 0f7cceec1106cda891ed37ec1eda6c46b3298951 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 15:45:29 -0400 Subject: [PATCH 02/10] Address review feedback for the project search overlays Server - Whole-word matching now greps the bare pattern and filters match ranges by VS Code-style word boundaries, fixing widened highlight spans and dropped adjacent occurrences that consuming (?:^|\W) boundaries caused - Cap grep matches per file (100) so one dense file cannot fill the page - Split the workspace index into path-only and content variants; content indexing now initializes only when content search is used - Entries search accepts an empty query (bounded frecency-ordered listing); content search queries preserve whitespace on the wire - Startup keybinding backfill appends only the defaults that fit instead of evicting the user's oldest custom rules Web - File line reveal retries until contents and line metrics exist and briefly guards the target scroll position against programmatic resets, fixing content-search results opening at the top of the file - Explorer reveal expands slash-keyed directory ancestors, keeps the drag controller's selection cache fresh, and no longer closes an active tree search when the selection originated inside the tree - Content search gates Enter while results are stale and renders result rows in growing windows instead of mounting all 500 at once - File picker's empty state uses the bounded file-only server search (recent files first) instead of transferring the full listing - Command palette exposes "Go to file" and "Search project contents" actions and resolves shortcuts with the complete when-clause context - Content search atoms moved out of the shared client-runtime surface (web-only); dropped the unconsumed EnvironmentApi.searchContents Co-Authored-By: Claude Fable 5 --- apps/server/src/keybindings.ts | 21 ++- .../src/workspace/WorkspaceEntries.test.ts | 104 ++++++++++- apps/server/src/workspace/WorkspaceEntries.ts | 80 ++++++--- .../src/workspace/WorkspaceSearchIndex.ts | 147 +++++++++------ apps/web/src/components/CommandPalette.tsx | 47 ++++- .../src/components/files/FileBrowserPanel.tsx | 15 +- .../src/components/files/FilePreviewPanel.tsx | 167 ++++++++++++------ .../files/projectFilesQueryState.ts | 36 ++-- .../search/ProjectContentSearchDialog.tsx | 45 ++++- apps/web/src/state/projects.ts | 13 ++ apps/web/src/state/queries.ts | 29 +-- .../src/state/projectCommands.ts | 6 - packages/contracts/src/ipc.ts | 3 - packages/contracts/src/project.test.ts | 29 +++ packages/contracts/src/project.ts | 15 +- 15 files changed, 551 insertions(+), 206 deletions(-) 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 05ca4cb889c..f62870fbce3 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -223,6 +223,27 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + 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-" }); @@ -384,37 +405,72 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("matches punctuation-ended literal queries as whole words", () => + 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("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-bar foo-\n"); + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const result = yield* workspaceEntries.searchContents({ cwd, - query: "foo-", + 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: 4 }, - { start: 13, end: 17 }, + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, ], }); }), ); - it.effect("matches punctuation-ended regex queries as whole words", () => + 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-bar foo-\n"); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const result = yield* workspaceEntries.searchContents({ @@ -426,13 +482,43 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { useRegex: true, }); - // wholeWord + useRegex must not silently drop non-word-edged patterns like "foo-" + // 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, }); - expect(result.matches[0]?.matchRanges).toHaveLength(2); + + 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); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index e9454fb376f..bb2113dac37 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -154,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, + }), + ); + } }, ); @@ -242,7 +246,13 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); @@ -253,7 +263,13 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.searchContents(input); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), + ), + ), + ); }); const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( @@ -262,7 +278,13 @@ export const make = Effect.gen(function* () { 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"), + ), + ), + ); }, ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 5d1515d8662..12cd4f732bc 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -30,6 +30,7 @@ const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; 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", @@ -211,64 +212,53 @@ function mapMixedSearchResult( const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; -function escapeRegexLiteral(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * `\b` only matches at word-character edges, so a pattern edge that is - * punctuation (e.g. the query "foo-") would never match inside - * `\b(?:foo-)\b`. Fall back to explicit non-word boundaries for those edges. - */ -function wrapWholeWord(pattern: string, query: string): string { - const firstCharacter = Array.from(query).at(0) ?? ""; - const lastCharacter = Array.from(query).at(-1) ?? ""; - const leading = WORD_CHARACTER.test(firstCharacter) ? "\\b" : "(?:^|\\W)"; - const trailing = WORD_CHARACTER.test(lastCharacter) ? "\\b" : "(?:$|\\W)"; - return `${leading}(?:${pattern})${trailing}`; -} - function buildContentSearchQuery(input: Omit): { readonly searchQuery: string; readonly regexMode: boolean; } { - const pattern = input.wholeWord - ? wrapWholeWord(input.useRegex ? input.query : escapeRegexLiteral(input.query), input.query) - : input.query; - const regexMode = input.useRegex || input.wholeWord; if (input.caseSensitive) { - return { searchQuery: pattern, regexMode }; + 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 regexMode - ? { searchQuery: `(?i:${pattern})`, regexMode } - : { searchQuery: pattern.toLowerCase(), regexMode }; + return input.useRegex + ? { searchQuery: `(?i:${input.query})`, regexMode: true } + : { searchQuery: input.query.toLowerCase(), regexMode: false }; } function mapContentMatchRanges( - input: Omit, 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]) => { - const start = toStringIndex(startByte); - const end = toStringIndex(endByte); - if (!input.wholeWord || input.useRegex) return { start, end }; - - // Non-word whole-word edges consume the boundary character, so the raw - // range can include punctuation around the literal query. Re-anchor the - // range on the query itself for exact highlighting. - const matchedText = line.slice(start, end); - const queryIndex = input.caseSensitive - ? matchedText.indexOf(input.query) - : matchedText.toLocaleLowerCase().indexOf(input.query.toLocaleLowerCase()); - return queryIndex === -1 - ? { start, end } - : { start: start + queryIndex, end: start + queryIndex + input.query.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(line[range.start - 1]) || !isWord(line[range.start]); + const rightIsBoundary = + range.end >= line.length || !isWord(line[range.end]) || !isWord(line[range.end - 1]); + return leftIsBoundary && rightIsBoundary; } function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { @@ -285,13 +275,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: false, + // 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, @@ -327,8 +323,11 @@ const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unkn Effect.withSpan("WorkspaceSearchIndex.waitForScan"), ); -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 }), @@ -450,18 +449,28 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin finder.grep(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, - maxMatchesPerFile: input.limit, + // A single dense file must not consume the whole result page. + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, input.limit), pageSize: input.limit, timeBudgetMs: CONTENT_SEARCH_TIME_BUDGET_MS, }), ); + const matches = result.items.flatMap((match) => { + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + ); + if (matchRanges.length === 0) return []; + return [ + { + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges, + }, + ]; + }); return { - matches: result.items.map((match) => ({ - path: toPosixPath(match.relativePath), - lineNumber: match.lineNumber, - lineContent: match.lineContent, - matchRanges: mapContentMatchRanges(input, match.lineContent, match.matchRanges), - })), + matches, truncated: result.nextCursor !== null, ...(result.regexFallbackError !== undefined ? { regexFallbackError: result.regexFallbackError } @@ -472,12 +481,38 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin 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/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3b0b50d4bd8..3b985aa72dd 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -29,12 +29,14 @@ import { ArrowLeftIcon, ArrowUpIcon, CornerLeftUpIcon, + FileSearchIcon, FolderIcon, FolderPlusIcon, LinkIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, + TextSearchIcon, } from "lucide-react"; import { useCallback, @@ -77,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"; @@ -384,14 +388,23 @@ 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, }, }); const mode = overlayModeForCommand(command); @@ -404,7 +417,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, terminalOpen, toggleMode]); useEffect( () => @@ -431,6 +444,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} openIntent={state.openIntent} setOpen={setOpen} + openOverlayMode={toggleMode} clearOpenIntent={clearOpenIntent} /> @@ -447,6 +461,7 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; + readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { if (!props.open) { @@ -461,6 +476,7 @@ function CommandPaletteDialog(props: { ); @@ -469,10 +485,11 @@ function CommandPaletteDialog(props: { 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 { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -1270,6 +1287,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", diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 517175fecee..042636260f1 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -223,10 +223,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; - dragMention.handleSelectionChange(selectedPaths); // 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()) { @@ -259,6 +261,13 @@ export default function FileBrowserPanel({ useEffect(() => { if (!selectedPath || entryKinds.get(selectedPath) !== "file") 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). + if (model.getSelectedPaths().some((path) => path.replace(/\/$/, "") === selectedPath)) { + return; + } syncingSelectionRef.current = true; model.closeSearch(); @@ -266,11 +275,13 @@ export default function FileBrowserPanel({ 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); + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); if (item && "expand" in item) item.expand(); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ff40ceb33cd..a941f55db26 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -181,25 +181,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 = 80; + +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") { @@ -207,18 +235,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; } @@ -229,54 +259,89 @@ 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 guardScrollTarget = (targetTop: 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); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + scrollContainer.addEventListener("pointerdown", cancelGuard, { passive: true }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + if (Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX) { + scrollContainer.scrollTop = targetTop; + } + guardFrameId = requestAnimationFrame(holdTarget); + }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; - const linePosition = instance.getLinePosition(targetLine); - if (!linePosition) return; - - 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, - ); + 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 linePosition = line === null ? null : instance.getLinePosition(line); + if (line === null || !linePosition) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); - scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); - handledRequestIdsByPath.set(relativePath, revealRequestId); + 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, + ); + const targetTop = Math.min(centeredTop, maxScrollTop); + + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(targetTop); + }); }; - pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); + scheduleReveal(0); }, - [ - handledRequestIdsByPath, - latestRequestIdsByPath, - pendingFramesByPath, - relativePath, - revealLine, - revealRequestId, - ], + [revealStatesByPath, relativePath, revealLine, revealRequestId], ); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 6a084b4564b..d165c1d1a7a 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -138,10 +138,12 @@ export function useProjectEntriesQuery( } /** - * Backing query for the project file picker: the workspace index listing when - * the query is blank, and a debounced file-only server search otherwise. - * `matchedQuery` is the query the returned entries were computed for, so the - * caller can highlight against results instead of half-typed input. + * 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, @@ -149,27 +151,15 @@ export function useProjectFilePickerQuery( query: string, limit: number, ) { - const listing = useProjectEntriesQuery(environmentId, cwd); - const hasQuery = /\S/.test(query); - const search = useProjectPathSearch( - { environmentId, cwd, query: hasQuery ? query : null, kind: "file" }, - limit, - ); - - if (hasQuery) { - return { - entries: search.isPending ? [] : search.entries, - error: search.error, - isPending: search.isPending, - matchedQuery: search.searchedQuery, - }; - } + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { + allowEmptyQuery: true, + }); return { - entries: listing.data?.entries ?? [], - error: listing.data === null ? listing.error : null, - isPending: listing.data === null && listing.isPending, - matchedQuery: "", + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, }; } diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 9b9607d9924..64367d88ed5 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,6 +1,6 @@ import type { ProjectContentMatch } from "@t3tools/contracts"; import { LoaderCircle, Search } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useTheme } from "~/hooks/useTheme"; @@ -19,6 +19,14 @@ 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; @@ -105,6 +113,8 @@ function OpenContentSearchDialog(props: { 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, @@ -114,23 +124,40 @@ function OpenContentSearchDialog(props: { useRegex, }); const matches = search.matches; - const groups = useMemo(() => groupMatches(matches), [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]); + }, [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) => { props.onOpenChange(false); useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); }; - const fileCount = groups.length; + const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); return ( @@ -151,6 +178,13 @@ function OpenContentSearchDialog(props: { 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 (search.isPending) { + event.preventDefault(); + return; + } const match = matches[selectedIndex]; if (match) { event.preventDefault(); @@ -261,6 +295,9 @@ function OpenContentSearchDialog(props: { ); })} + {matches.length > visibleCount ? ( + )} 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.ts b/apps/web/src/state/queries.ts index 6eb411aeb20..5cd006a4f37 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -21,7 +21,7 @@ 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"; @@ -205,12 +205,17 @@ export function areProjectPathSearchTargetsEqual( ); } -export function useProjectPathSearch(target: ProjectPathSearchTarget, limit: number) { +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.kind, target.query], @@ -219,7 +224,8 @@ export function useProjectPathSearch(target: ProjectPathSearchTarget, limit: num 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: { @@ -237,7 +243,7 @@ export function useProjectPathSearch(target: ProjectPathSearchTarget, limit: num error: result.error, isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, - searchedQuery: debouncedTarget.query, + searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; } @@ -256,14 +262,17 @@ interface ProjectContentSearchTarget { } export function useProjectContentSearch(target: ProjectContentSearchTarget) { - const query = target.query.trim(); + // 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 && - query.length > 0 && - debouncedQuery.length > 0 - ? projectEnvironment.searchContents({ + hasQuery && + debouncedQuery.trim().length > 0 + ? projectContentSearch({ environmentId: target.environmentId, input: { cwd: target.cwd, @@ -281,7 +290,7 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, error: result.error, isPending: query !== debouncedQuery || result.isPending, - hasQuery: query.length > 0, + hasQuery, truncated: result.data?.truncated ?? false, invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, }; diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index e68d69998fa..3defcc32154 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -60,12 +60,6 @@ export function createProjectEnvironmentAtoms( tag: WS_METHODS.projectsSearchEntries, staleTimeMs: 15_000, }), - searchContents: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:projects:search-contents", - tag: WS_METHODS.projectsSearchContents, - staleTimeMs: 5_000, - idleTtlMs: 60_000, - }), listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea51689a52f..a92eb972d67 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -26,8 +26,6 @@ import type { ProjectListEntriesResult, ProjectReadFileInput, ProjectReadFileResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -1160,7 +1158,6 @@ export interface EnvironmentApi { projects: { listEntries: (input: ProjectListEntriesInput) => Promise; readFile: (input: ProjectReadFileInput) => Promise; - searchContents: (input: ProjectSearchContentsInput) => Promise; searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ca2158a56b8..8e6771cba88 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -4,10 +4,39 @@ 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"); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 36610e024fc..a1b11df73b2 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,10 @@ 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; @@ -11,7 +16,9 @@ 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), }); @@ -31,7 +38,9 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; export const ProjectSearchContentsInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // 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, From d36d8130abe6eb3758b9114fdfe5e0226401f265 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 16:21:27 -0400 Subject: [PATCH 03/10] fix(search): address remaining review feedback --- .../src/workspace/WorkspaceEntries.test.ts | 25 ++++++ .../workspace/WorkspaceSearchIndex.test.ts | 79 ++++++++++++++++++- .../src/workspace/WorkspaceSearchIndex.ts | 79 +++++++++++++------ .../src/components/files/FileBrowserPanel.tsx | 9 ++- 4 files changed, 164 insertions(+), 28 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index f62870fbce3..027cb26bffe 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -437,6 +437,31 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + 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-" }); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 885c4f58982..5338c267800 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"; @@ -180,3 +180,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(), + isScanning: vi.fn(() => false), + 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 12cd4f732bc..0cfed2e279e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -3,6 +3,7 @@ import { type DirSearchResult, type FileItem, FileFinder, + type GrepCursor, type MixedItem, type MixedSearchResult, type Result, @@ -212,6 +213,19 @@ 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; @@ -255,9 +269,13 @@ function isWholeWordRange( const isWord = (character: string | undefined) => character !== undefined && WORD_CHARACTER.test(character); const leftIsBoundary = - range.start === 0 || !isWord(line[range.start - 1]) || !isWord(line[range.start]); + range.start === 0 || + !isWord(codePointBefore(line, range.start)) || + !isWord(codePointAt(line, range.start)); const rightIsBoundary = - range.end >= line.length || !isWord(line[range.end]) || !isWord(line[range.end - 1]); + range.end >= line.length || + !isWord(codePointAt(line, range.end)) || + !isWord(codePointBefore(line, range.end)); return leftIsBoundary && rightIsBoundary; } @@ -445,36 +463,45 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( "WorkspaceSearchIndex.searchContents", )(function* (input) { const { searchQuery, regexMode } = buildContentSearchQuery(input); - 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, input.limit), - pageSize: input.limit, - timeBudgetMs: CONTENT_SEARCH_TIME_BUDGET_MS, - }), - ); - const matches = result.items.flatMap((match) => { - const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( - (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; + 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, input.limit), + pageSize: input.limit, + cursor: nextCursor, + timeBudgetMs: remainingTimeBudgetMs, + }), ); - if (matchRanges.length === 0) return []; - return [ - { + + 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, - truncated: result.nextCursor !== null, - ...(result.regexFallbackError !== undefined - ? { regexFallbackError: result.regexFallbackError } - : {}), + matches: matches.slice(0, input.limit), + truncated: matches.length > input.limit || nextCursor !== null, + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), }; }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 042636260f1..ffd41fafaba 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -118,6 +118,7 @@ export default function FileBrowserPanel({ const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); const syncingSelectionRef = useRef(false); + const treeSelectionPathRef = useRef(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 @@ -236,6 +237,7 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { + treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -265,9 +267,14 @@ export default function FileBrowserPanel({ // 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). - if (model.getSelectedPaths().some((path) => path.replace(/\/$/, "") === selectedPath)) { + const selectedInTree = model + .getSelectedPaths() + .some((path) => path.replace(/\/$/, "") === selectedPath); + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { + treeSelectionPathRef.current = null; return; } + treeSelectionPathRef.current = null; syncingSelectionRef.current = true; model.closeSearch(); From 2a1487d61a24ad245f05335873a07a304363e814 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 16:28:46 -0400 Subject: [PATCH 04/10] fix(search): overscan whole-word candidates --- .../src/workspace/WorkspaceEntries.test.ts | 25 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 9 +++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 027cb26bffe..3d3a05f027f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -437,6 +437,31 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + 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-" }); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 0cfed2e279e..ca0ad0c0285 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -464,6 +464,11 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( )(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; @@ -475,8 +480,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( 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, input.limit), - pageSize: input.limit, + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), + pageSize: rawPageSize, cursor: nextCursor, timeBudgetMs: remainingTimeBudgetMs, }), From 2d45736a13255956c496feb33b560fac1bc29f00 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 16:34:55 -0400 Subject: [PATCH 05/10] fix(web): block stale content search clicks --- .../src/components/search/ProjectContentSearchDialog.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 64367d88ed5..3cc2732c405 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -123,6 +123,7 @@ function OpenContentSearchDialog(props: { 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]); @@ -154,6 +155,7 @@ function OpenContentSearchDialog(props: { }, []); const openMatch = (match: ProjectContentMatch) => { + if (!canOpenMatches) return; props.onOpenChange(false); useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); }; @@ -181,7 +183,7 @@ function OpenContentSearchDialog(props: { // 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 (search.isPending) { + if (!canOpenMatches) { event.preventDefault(); return; } @@ -274,9 +276,10 @@ function OpenContentSearchDialog(props: { key={`${match.path}:${match.lineNumber}:${match.resultIndex}`} data-content-search-result={match.resultIndex} className={cn( - "flex h-7 w-full min-w-0 items-center gap-3 px-3 text-left font-mono text-xs hover:bg-accent/60", + "flex h-7 w-full min-w-0 items-center gap-3 px-3 text-left font-mono text-xs hover:bg-accent/60 disabled:pointer-events-none", match.resultIndex === selectedIndex && "bg-accent text-accent-foreground", )} + disabled={!canOpenMatches} onMouseEnter={() => setSelectedIndex(match.resultIndex)} onClick={() => openMatch(match)} > From b76364e8cd570e8ad71585f72bf1696b654a42a0 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 16:46:06 -0400 Subject: [PATCH 06/10] fix(web): center high-line file reveals --- .../src/components/files/FilePreviewPanel.tsx | 64 ++++++++++++------- .../components/files/fileLineReveal.test.ts | 32 ++++++++++ .../src/components/files/fileLineReveal.ts | 24 +++++++ 3 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/files/fileLineReveal.test.ts create mode 100644 apps/web/src/components/files/fileLineReveal.ts diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e8e05f25026..81c086e3724 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"; @@ -194,7 +195,7 @@ const REVEAL_MAX_ATTEMPTS = 30; * guard immediately. */ const REVEAL_GUARD_FRAMES = 20; -const REVEAL_GUARD_TOLERANCE_PX = 80; +const REVEAL_GUARD_TOLERANCE_PX = 2; interface FileRevealState { frameId: number | null; @@ -264,7 +265,38 @@ function useFileLineReveal( return; } - const guardScrollTarget = (targetTop: number) => { + 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 - + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); + + 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, + }, + } + : {}), + }); + }; + + const guardScrollTarget = (line: number) => { let framesLeft = REVEAL_GUARD_FRAMES; let guardFrameId: number | null = null; const cancelGuard = () => { @@ -289,7 +321,11 @@ function useFileLineReveal( cancelGuard(); return; } - if (Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX) { + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { scrollContainer.scrollTop = targetTop; } guardFrameId = requestAnimationFrame(holdTarget); @@ -311,32 +347,16 @@ function useFileLineReveal( const currentContents = instance.file?.contents; const line = currentContents === undefined ? null : clampFileLine(currentContents, revealLine); - const linePosition = line === null ? null : instance.getLinePosition(line); - if (line === null || !linePosition) { + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); return; } updateFileLinkReveal(fileContainer, line); - 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, - ); - const targetTop = Math.min(centeredTop, maxScrollTop); - scrollContainer.scrollTop = targetTop; state.handledRequestId = revealRequestId; - guardScrollTarget(targetTop); + guardScrollTarget(line); }); }; 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)); +} From bca349b076070363549306c835d9c2be939fb460 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 16:53:48 -0400 Subject: [PATCH 07/10] fix(web): prevent comment gutter scroll jump --- apps/web/src/components/files/FilePreviewPanel.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 81c086e3724..a736cf96cd3 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -306,13 +306,18 @@ function useFileLineReveal( } scrollContainer.removeEventListener("wheel", cancelGuard); scrollContainer.removeEventListener("touchstart", cancelGuard); - scrollContainer.removeEventListener("pointerdown", 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 }); - scrollContainer.addEventListener("pointerdown", 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; From 65d45a29873cff665bb1edffd6e88a2ce649a2c5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 18:06:37 -0400 Subject: [PATCH 08/10] Combine project search overlays into command dialog - Share command palette behavior across file and content search - Add consistent keyboard shortcut footers --- apps/web/src/components/CommandPalette.tsx | 72 ++-- .../components/files/ProjectFilePicker.tsx | 53 ++- .../search/ProjectContentSearchDialog.tsx | 358 +++++++++--------- 3 files changed, 253 insertions(+), 230 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3b985aa72dd..a4869f47914 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -433,14 +433,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { [openAddProject, openNewThreadIn, setOpen], ); - const commandDialogOpen = state.open && state.mode !== "content"; - return ( - + {children} - ); } @@ -464,21 +458,46 @@ function CommandPaletteDialog(props: { readonly openOverlayMode: (mode: SearchOverlayMode) => void; readonly clearOpenIntent: () => void; }) { + const composerHandleRef = useComposerHandleContext(); + if (!props.open) { return null; } - if (props.mode === "files") { - return ; - } - return ( - + { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + onBackdropPointerDown={() => { + props.setOpen(false); + }} + > + {props.mode === "files" ? ( + + ) : props.mode === "content" ? ( + + ) : ( + + )} + ); } @@ -490,7 +509,6 @@ function OpenCommandPaletteDialog(props: { }) { const navigate = useNavigate(); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; - const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -2002,19 +2020,7 @@ function OpenCommandPaletteDialog(props: { ]); return ( - { - composerHandleRef?.current?.focusAtEnd(); - return false; - }} - onBackdropPointerDown={() => { - setOpen(false); - }} - > +
- +
); } diff --git a/apps/web/src/components/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx index f8a33866d56..046ee500de5 100644 --- a/apps/web/src/components/files/ProjectFilePicker.tsx +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -9,7 +10,8 @@ import { primaryServerKeybindingsAtom } from "~/state/server"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { type CommandPaletteActionItem } from "../CommandPalette.logic"; import { CommandPaletteResults } from "../CommandPaletteResults"; -import { Command, CommandDialogPopup, CommandInput, CommandPanel } from "../ui/command"; +import { Command, CommandFooter, CommandInput, CommandPanel } from "../ui/command"; +import { Kbd, KbdGroup } from "../ui/kbd"; import { getProjectFilePickerMatches, PROJECT_FILE_PICKER_RESULT_LIMIT, @@ -43,20 +45,29 @@ function HighlightedFuzzyText(props: { return {parts}; } -function ProjectFilePickerPopup(props: { - readonly children: ReactNode; - readonly setOpen: (open: boolean) => void; -}) { +function FilePickerFooter() { return ( - props.setOpen(false)} - > - {props.children} - + +
+ + + + + + + + Navigate + + + Enter + Open file + + + Esc + Close + +
+
); } @@ -67,9 +78,9 @@ function getEmptyStateMessage(query: string, error: string | null, isPending: bo return isSearching ? "No matching files." : "No files found."; } -function EmptyProjectFilePicker(props: ProjectFilePickerProps) { +function EmptyProjectFilePicker() { return ( - +
@@ -77,8 +88,9 @@ function EmptyProjectFilePicker(props: ProjectFilePickerProps) { Open a project to search its files.
+ -
+ ); } @@ -130,7 +142,7 @@ function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveP const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending); return ( - +
+ - +
); } @@ -170,7 +183,7 @@ export function ProjectFilePicker(props: ProjectFilePickerProps) { const target = useActiveProjectTarget(); if (!target) { - return ; + return ; } return ; diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 3cc2732c405..7d5372214fd 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,5 @@ import type { ProjectContentMatch } from "@t3tools/contracts"; -import { LoaderCircle, Search } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, LoaderCircle } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -9,13 +9,12 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; -import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; -import { Input } from "../ui/input"; +import { Command, CommandFooter, CommandInput, CommandPanel } from "../ui/command"; +import { Kbd, KbdGroup } from "../ui/kbd"; import { ScrollArea } from "../ui/scroll-area"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { - readonly open: boolean; readonly onOpenChange: (open: boolean) => void; } @@ -76,28 +75,43 @@ function SearchOptionButton(props: { ); } -function ContentSearchPopup(props: { readonly children: ReactNode }) { +function ContentSearchFooter() { return ( - - {props.children} - + +
+ + + + + + + + Navigate + + + Enter + Open file + + + Esc + Close + +
+
); } function EmptyContentSearchDialog() { return ( - - Search project contents -
- Open a project to search its files. -
-
+
+ + + + Open a project to search its files. + + + +
); } @@ -162,175 +176,165 @@ function OpenContentSearchDialog(props: { const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); return ( - - Search {target.projectName} -
- - setQuery(event.currentTarget.value)} - 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) { +
+ +
+ { + if (event.key === "ArrowDown" && matches.length > 0) { event.preventDefault(); - return; - } - const match = matches[selectedIndex]; - if (match) { + setSelectedIndex((current) => (current + 1) % matches.length); + } else if (event.key === "ArrowUp" && matches.length > 0) { event.preventDefault(); - openMatch(match); + 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); + } } - } - }} - className="h-9 min-w-0 flex-1 px-2 font-mono text-sm" - placeholder={`Search in ${target.projectName}`} - aria-label={`Search file contents in ${target.projectName}`} - /> -
- setCaseSensitive((current) => !current)} - > - Aa - - setWholeWord((current) => !current)} - > - ab - - setUseRegex((current) => !current)} - > - .* - -
-
- -
-
- {search.isPending ? ( - - Searching… - - ) : search.error ? ( - {search.error} - ) : search.invalidRegex ? ( - Invalid regular expression - ) : !search.hasQuery ? ( - `Search every file in ${target.projectName}` - ) : ( - `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` - )} + }} + /> +
+ setCaseSensitive((current) => !current)} + > + Aa + + setWholeWord((current) => !current)} + > + ab + + setUseRegex((current) => !current)} + > + .* + +
- {matches.length === 0 ? ( -
- {search.hasQuery && !search.isPending && !search.error - ? "No results found." - : "Type to search across your project."} + +
+ {search.isPending ? ( + + Searching… + + ) : search.error ? ( + {search.error} + ) : search.invalidRegex ? ( + Invalid regular expression + ) : !search.hasQuery ? ( + `Search every file in ${target.projectName}` + ) : ( + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` + )}
- ) : ( - -
- {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 ? ( - -
- ↑↓ Navigate - ↵ Open file - esc Close -
- + ) : ( + +
+ {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 ( - - {props.open ? ( - target ? ( - - ) : ( - - ) - ) : null} - + return target ? ( + + ) : ( + ); } From a253ca4429a414327f49df632ca4af5912bf1646 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 18:29:23 -0400 Subject: [PATCH 09/10] Share command palette chrome across search overlays - Extract shared input, panel, and keyboard-hint layout - Reuse it for project file and content search dialogs --- apps/web/src/components/CommandPalette.tsx | 410 ++++++++---------- .../src/components/CommandPaletteContent.tsx | 77 ++++ .../components/files/ProjectFilePicker.tsx | 119 ++--- .../search/ProjectContentSearchDialog.tsx | 331 +++++++------- 4 files changed, 463 insertions(+), 474 deletions(-) create mode 100644 apps/web/src/components/CommandPaletteContent.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a4869f47914..fbfd3822ca1 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,9 +25,7 @@ import { import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { - ArrowDownIcon, ArrowLeftIcon, - ArrowUpIcon, CornerLeftUpIcon, FileSearchIcon, FolderIcon, @@ -113,6 +111,7 @@ import { } 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"; @@ -122,14 +121,7 @@ import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusI 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"; @@ -435,7 +427,17 @@ export function CommandPalette({ children }: { children: ReactNode }) { return ( - + { + if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { + eventDetails.cancel(); + toggleMode("command"); + return; + } + setOpen(open); + }} + > {children} + { + 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 ( -
- { - setHighlightedItemValue(typeof value === "string" ? value : null); - }} - onValueChange={handleQueryChange} - value={query} - > -
- + + + ), } - placeholder={inputPlaceholder} - wrapperClassName={ - isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined + : isBrowsing + ? { startAddon: } + : {}), + onKeyDown: handleKeyDown, + }} + mode="none" + onItemHighlighted={(value) => { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={handleQueryChange} + panelClassName="max-h-[min(28rem,70vh)]" + showBackHint={isSubmenu} + value={query} + > + {remoteProjectContext ? ( +
+
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/files/ProjectFilePicker.tsx b/apps/web/src/components/files/ProjectFilePicker.tsx index 046ee500de5..a59e8dd77e4 100644 --- a/apps/web/src/components/files/ProjectFilePicker.tsx +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -8,10 +7,9 @@ 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 { Command, CommandFooter, CommandInput, CommandPanel } from "../ui/command"; -import { Kbd, KbdGroup } from "../ui/kbd"; import { getProjectFilePickerMatches, PROJECT_FILE_PICKER_RESULT_LIMIT, @@ -45,32 +43,6 @@ function HighlightedFuzzyText(props: { return {parts}; } -function FilePickerFooter() { - return ( - -
- - - - - - - - Navigate - - - Enter - Open file - - - Esc - Close - -
-
- ); -} - function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string { if (error) return error; const isSearching = query.trim().length > 0; @@ -80,17 +52,19 @@ function getEmptyStateMessage(query: string, error: string | null, isPending: bo function EmptyProjectFilePicker() { return ( -
- - - -
- Open a project to search its files. -
-
- -
-
+ +
+ Open a project to search its files. +
+
); } @@ -142,40 +116,39 @@ function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveP const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending); return ( -
- { - setHighlightedItemValue(typeof value === "string" ? value : null); + { + 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(); }} - onValueChange={(value) => { - setHighlightedItemValue(null); - setQuery(value); - }} - 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} - /> - - - -
+ emptyStateMessage={emptyStateMessage} + /> + ); } diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 7d5372214fd..1d00d1ed3dc 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,5 @@ import type { ProjectContentMatch } from "@t3tools/contracts"; -import { ArrowDownIcon, ArrowUpIcon, LoaderCircle } from "lucide-react"; +import { LoaderCircle } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -9,8 +9,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; -import { Command, CommandFooter, CommandInput, CommandPanel } from "../ui/command"; -import { Kbd, KbdGroup } from "../ui/kbd"; +import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; @@ -75,43 +74,20 @@ function SearchOptionButton(props: { ); } -function ContentSearchFooter() { - return ( - -
- - - - - - - - Navigate - - - Enter - Open file - - - Esc - Close - -
-
- ); -} - function EmptyContentSearchDialog() { return ( -
- - - - Open a project to search its files. - - - -
+ + Open a project to search its files. + ); } @@ -176,151 +152,148 @@ function OpenContentSearchDialog(props: { const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); return ( -
- -
- { - 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); - } - } - }} - /> -
- setCaseSensitive((current) => !current)} - > - Aa - - setWholeWord((current) => !current)} - > - ab - - setUseRegex((current) => !current)} - > - .* - -
+ + 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} + > +
+ {search.isPending ? ( + + Searching… + + ) : search.error ? ( + {search.error} + ) : search.invalidRegex ? ( + Invalid regular expression + ) : !search.hasQuery ? ( + `Search every file in ${target.projectName}` + ) : ( + `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` + )} +
- -
- {search.isPending ? ( - - Searching… - - ) : search.error ? ( - {search.error} - ) : search.invalidRegex ? ( - Invalid regular expression - ) : !search.hasQuery ? ( - `Search every file in ${target.projectName}` - ) : ( - `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` - )} -
- - {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 ( -
-
- + {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) => ( +
- {group.matches.map((match) => ( - - ))} -
- ); - })} - {matches.length > visibleCount ? ( - - - )} - - - -
+ + + ))} + + ); + })} + {matches.length > visibleCount ? ( + +
+ )} + ); } From 205e88598de0fb579a072b733ae3cc405ed94d51 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 18:31:48 -0400 Subject: [PATCH 10/10] Hide content-search status until needed Avoid reserving a status row before a search query is entered --- .../search/ProjectContentSearchDialog.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 1d00d1ed3dc..1890aab7fa7 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -150,6 +150,8 @@ function OpenContentSearchDialog(props: { 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 ( -
- {search.isPending ? ( - - Searching… - - ) : search.error ? ( - {search.error} - ) : search.invalidRegex ? ( - Invalid regular expression - ) : !search.hasQuery ? ( - `Search every file in ${target.projectName}` - ) : ( - `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` - )} -
+ {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 ? (