diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..c163bb7be39 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -292,6 +292,130 @@ 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]); + }), + ); + }); + 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..e8b98fb9a0f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -16,6 +16,8 @@ import type { ProjectListEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -93,6 +95,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") {} @@ -251,7 +256,17 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + 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))); + }); + + 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..981f771b9ee 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,15 @@ 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(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..f5f21612ca0 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -9,6 +9,8 @@ import * as Schema from "effect/Schema"; import type { ProjectEntry, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -97,6 +99,9 @@ export class WorkspaceSearchIndex extends Context.Service< query: string, limit: number, ) => Effect.Effect; + readonly searchContents: ( + input: Omit, + ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -175,7 +180,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, @@ -307,7 +312,86 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin return mapMixedSearchResult(result, limit); }); - return WorkspaceSearchIndex.of({ list, refresh, search }); + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( + "WorkspaceSearchIndex.searchContents", + )(function* (input) { + const escapedQuery = input.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const wordCharacter = /[\p{Letter}\p{Mark}\p{Number}_]/u; + const firstCharacter = Array.from(input.query).at(0) ?? ""; + const lastCharacter = Array.from(input.query).at(-1) ?? ""; + // Use \b only when the pattern edge is a word character; otherwise \b cannot + // match (e.g. query "foo-" → \b(?:foo-)\b never matches "foo- "). Fall back to + // (?:^|\W)/(?:$|\W) for non-word edges, same as the literal whole-word path. + const wrapWholeWord = (pattern: string) => + `${wordCharacter.test(firstCharacter) ? "\\b" : "(?:^|\\W)"}(?:${pattern})${wordCharacter.test(lastCharacter) ? "\\b" : "(?:$|\\W)"}`; + const query = input.wholeWord + ? wrapWholeWord(input.useRegex ? input.query : escapedQuery) + : input.query; + const regexMode = input.useRegex || input.wholeWord; + const searchQuery = input.caseSensitive + ? query + : regexMode + ? `(?i:${query})` + : query.toLowerCase(); + const result = yield* Effect.try({ + try: () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + maxMatchesPerFile: input.limit, + pageSize: input.limit, + timeBudgetMs: 250, + }), + catch: (cause) => + new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: input.query.length, + pageSize: input.limit, + reason: "FileFinder.grep threw unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* new WorkspaceSearchIndexSearchFailed({ + cwd, + queryLength: input.query.length, + pageSize: input.limit, + reason: result.error, + }); + } + + const byteOffsetToStringIndex = (line: string, byteOffset: number): number => + Buffer.from(line).subarray(0, byteOffset).toString().length; + const mapMatchRange = (line: string, startByte: number, endByte: number) => { + const start = byteOffsetToStringIndex(line, startByte); + const end = byteOffsetToStringIndex(line, endByte); + if (!input.wholeWord || input.useRegex) return { start, end }; + + 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 { + matches: result.value.items.map((match) => ({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges: match.matchRanges.map(([start, end]) => + mapMatchRange(match.lineContent, start, end), + ), + })), + truncated: result.value.nextCursor !== null, + ...(result.value.regexFallbackError + ? { regexFallbackError: result.value.regexFallbackError } + : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..18902fc9c85 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -314,6 +315,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [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], @@ -1632,6 +1634,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/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992..a5a53e087fb 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -25,6 +25,7 @@ export function onOpenCommandPalette( /** Read at event time so consumers do not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( - typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null + typeof document !== "undefined" && + document.querySelector("[data-command-palette], [data-project-search]") !== null ); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..45d8612bfb8 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,7 @@ 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 { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -88,27 +88,7 @@ import { openUrlInPreview, 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; - } -} +import { RenderErrorBoundary } from "./RenderErrorBoundary"; interface ChatMarkdownProps { text: string; @@ -145,7 +125,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); @@ -296,27 +275,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; } @@ -706,7 +664,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 }); @@ -1511,7 +1469,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..48bd5e2072d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -218,6 +218,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; +import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; @@ -1252,6 +1253,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [projectSearchOpen, setProjectSearchOpen] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -3008,9 +3010,9 @@ function ChatViewContent(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); const openFileSurface = useCallback( - (relativePath: string) => { + (relativePath: string, line?: number) => { if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + useRightPanelStore.getState().openFile(activeThreadRef, relativePath, line); }, [activeProject, activeThreadRef], ); @@ -4262,6 +4264,14 @@ function ChatViewContent(props: ChatViewProps) { }); if (!command) return; + if (command === "project.searchContents") { + if (!activeProject || !activeWorkspaceRoot) return; + event.preventDefault(); + event.stopPropagation(); + setProjectSearchOpen(true); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -4373,6 +4383,7 @@ function ChatViewContent(props: ChatViewProps) { toggleRightPanel, toggleTerminalVisibility, composerRef, + activeWorkspaceRoot, ]); const onRevertToTurnCount = useCallback( @@ -5607,6 +5618,16 @@ function ChatViewContent(props: ChatViewProps) { return (
+ {activeProject && activeWorkspaceRoot ? ( + + ) : null} {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
void; + readonly onOpenMatch: (relativePath: string, lineNumber: number) => 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 ( + + ); +} + +export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { + 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: props.open ? props.environmentId : null, + cwd: props.open ? props.cwd : null, + query, + caseSensitive, + wholeWord, + useRegex, + }); + const matches = search.matches; + const groups = useMemo(() => groupMatches(matches), [matches]); + + useEffect(() => { + if (!props.open) setQuery(""); + }, [props.open]); + + useEffect(() => { + setSelectedIndex(0); + }, [matches]); + + useEffect(() => { + document + .querySelector(`[data-project-search-result="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const openMatch = (match: ProjectContentMatch) => { + props.onOpenChange(false); + props.onOpenMatch(match.path, match.lineNumber); + }; + const fileCount = groups.length; + + return ( + + + Search {props.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 ${props.projectName}`} + aria-label={`Search file contents in ${props.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 ${props.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 +
+
+
+ ); +} diff --git a/apps/web/src/components/RenderErrorBoundary.tsx b/apps/web/src/components/RenderErrorBoundary.tsx new file mode 100644 index 00000000000..29f7d612d94 --- /dev/null +++ b/apps/web/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,16 @@ +import { Component, type ReactNode } from "react"; + +export class RenderErrorBoundary extends Component< + { readonly children: ReactNode; readonly fallback: ReactNode }, + { readonly failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 3f53d65533d..e64250fdfc8 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -23,6 +23,8 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + selectedPath: string | null; + selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -46,6 +48,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + selectedPath, + selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -59,6 +63,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 @@ -164,6 +169,7 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + 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. @@ -187,6 +193,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]); + const fileCount = useMemo( () => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0), [entries], 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/project-search/HighlightedSearchLine.tsx b/apps/web/src/components/project-search/HighlightedSearchLine.tsx new file mode 100644 index 00000000000..9d529622776 --- /dev/null +++ b/apps/web/src/components/project-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/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..0590b4df6f0 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -118,6 +118,11 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("f", { shiftKey: true }), + command: "project.searchContents", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -514,6 +519,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches project.searchContents shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "project.searchContents", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "project.searchContents", + ); + }); + 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..f94006c54e6 --- /dev/null +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -0,0 +1,26 @@ +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") throw error; + return getSyntaxHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..6297a6c5560 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -7,6 +7,7 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProjectContentMatch, ThreadId, VcsListRefsResult, VcsRef, @@ -25,8 +26,11 @@ import { vcsEnvironment } from "./vcs"; const COMPOSER_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 { @@ -214,6 +218,47 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +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: 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..9e9747023e0 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -30,6 +30,7 @@ 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+shift+f", "command": "project.searchContents", "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 +65,7 @@ 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 +- `project.searchContents`: search file contents across the active project - `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 4b1676d7926..cf5bd3fb21e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -28,6 +28,8 @@ import type { ProjectReadFileResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectWriteFileInput, ProjectWriteFileResult, } from "./project.ts"; @@ -1192,6 +1194,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..d67fe10a13c 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -59,6 +59,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedProjectSearch = yield* decode(KeybindingRule, { + key: "mod+shift+f", + command: "project.searchContents", + }); + assert.strictEqual(parsedProjectSearch.command, "project.searchContents"); + 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 c7cff9943cd..e8aebab5729 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "project.searchContents", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c..566ea9b934a 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"; @@ -28,6 +29,13 @@ describe("project RPC errors", () => { resolvedPath: "/workspace/src/index.ts", cause, }); + const contentSearchError = new ProjectSearchContentsError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 100, + failure: "search_index_search_failed", + cause, + }); expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); expect(searchError.message).not.toContain(cause.message); @@ -39,6 +47,8 @@ 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); + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); + expect(contentSearchError).not.toHaveProperty("query"); }); 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..c9727143621 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -2,6 +2,7 @@ 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; @@ -26,6 +27,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, + useRegex: Schema.Boolean, + wholeWord: 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 +126,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 fa2d23b8ef2..ad56a91b189 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -74,6 +74,9 @@ import { ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectSearchContentsError, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, @@ -154,6 +157,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -377,6 +381,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, @@ -720,6 +730,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 b6bdd7b4783..33d447d6408 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,7 @@ 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+shift+f", command: "project.searchContents", 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" },