From 72f5b0e988e9f8c450aca975fd7944d264bd228e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 00:16:40 -0400 Subject: [PATCH 1/7] Add project file picker --- apps/server/src/keybindings.test.ts | 1 + .../components/CommandPalette.logic.test.ts | 28 +++ .../src/components/CommandPalette.logic.ts | 45 +++- apps/web/src/components/CommandPalette.tsx | 56 ++--- .../files/ProjectFilePicker.logic.test.ts | 74 +++++++ .../files/ProjectFilePicker.logic.ts | 61 ++++++ .../components/files/ProjectFilePicker.tsx | 203 ++++++++++++++++++ .../files/projectFilesQueryState.ts | 31 +++ apps/web/src/keybindings.test.ts | 26 +++ apps/web/src/state/queries.ts | 13 +- docs/user/keybindings.md | 2 + packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 14 files changed, 503 insertions(+), 45 deletions(-) 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 diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index a51ad20afbe..de7b66a86b2 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,7 @@ 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("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/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..786fd208642 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,12 +4,40 @@ import type { Thread } from "../types"; import { buildThreadActionItems, filterCommandPaletteGroups, + reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("reduceCommandPaletteUiState", () => { + const closedState = { open: false, mode: "command", openIntent: null } as const; + + it("opens, switches, and closes command and file-picker modes", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" }); + expect(filesOpen).toEqual({ open: true, mode: "files", openIntent: null }); + + const commandOpen = reduceCommandPaletteUiState(filesOpen, { _tag: "ToggleCommand" }); + expect(commandOpen).toEqual({ open: true, mode: "command", openIntent: null }); + + expect(reduceCommandPaletteUiState(commandOpen, { _tag: "ToggleCommand" })).toEqual({ + open: false, + mode: "command", + openIntent: null, + }); + }); + + it("routes add-project requests back to command mode", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "add-project" }, + }); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..48a552efb9a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -11,12 +11,55 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export interface CommandPaletteOpenIntent { + readonly kind: "add-project"; +} + +export interface CommandPaletteUiState { + readonly open: boolean; + readonly mode: "command" | "files"; + readonly openIntent: CommandPaletteOpenIntent | null; +} + +export type CommandPaletteUiAction = + | { readonly _tag: "SetOpen"; readonly open: boolean } + | { readonly _tag: "ToggleCommand" } + | { readonly _tag: "ToggleFiles" } + | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "ClearOpenIntent" }; + +export function reduceCommandPaletteUiState( + state: CommandPaletteUiState, + action: CommandPaletteUiAction, +): CommandPaletteUiState { + switch (action._tag) { + case "SetOpen": + return { + ...state, + open: action.open, + openIntent: action.open ? state.openIntent : null, + }; + case "ToggleCommand": + return state.open && state.mode === "command" + ? { ...state, open: false, openIntent: null } + : { open: true, mode: "command", openIntent: null }; + case "ToggleFiles": + return state.open && state.mode === "files" + ? { ...state, open: false, openIntent: null } + : { open: true, mode: "files", openIntent: null }; + case "OpenAddProject": + return { open: true, mode: "command", openIntent: { kind: "add-project" } }; + 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 8dccf984457..0813af90e41 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -97,6 +97,7 @@ import { buildRootGroups, buildThreadActionItems, type CommandPaletteActionItem, + type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, type CommandPaletteView, filterBrowseEntries, @@ -105,11 +106,13 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + reduceCommandPaletteUiState, } from "./CommandPalette.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 { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand } from "../keybindings"; @@ -334,47 +337,15 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -interface CommandPaletteOpenIntent { - readonly kind: "add-project"; -} - -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: "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 "ClearOpenIntent": - return state.openIntent ? { ...state, openIntent: null } : state; - } -} - 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 toggleCommand = useCallback(() => dispatch({ _tag: "ToggleCommand" }), []); + const toggleFiles = useCallback(() => dispatch({ _tag: "ToggleFiles" }), []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -399,16 +370,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - if (command !== "commandPalette.toggle") { - return; - } + if (command !== "commandPalette.toggle" && command !== "filePicker.toggle") return; event.preventDefault(); event.stopPropagation(); - toggleOpen(); + if (command === "filePicker.toggle") toggleFiles(); + else toggleCommand(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, terminalOpen, toggleCommand, toggleFiles]); return ( @@ -416,6 +386,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { {children} void; readonly clearOpenIntent: () => void; @@ -437,6 +409,10 @@ function CommandPaletteDialog(props: { return null; } + if (props.mode === "files") { + return ; + } + return ( [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("matches against both file names and paths", () => { + assert.deepEqual(pathsForQuery(entries, "shared"), [ + { name: "index.ts", path: "packages/shared/src/index.ts" }, + ]); + assert.deepEqual(pathsForQuery(entries, "read"), [{ name: "README.md", path: "README.md" }]); + }); + + 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", + }, + { kind: "file", path: "src/TestResults.tsx" }, + ] 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], + ); + }); +}); 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..5d5ff1797d3 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.logic.ts @@ -0,0 +1,61 @@ +import type { ProjectEntry } from "@t3tools/contracts"; + +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); +} + +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; +} + +export function getProjectFilePickerMatches( + entries: ReadonlyArray, + rawQuery: string, + limit = PROJECT_FILE_PICKER_RESULT_LIMIT, +): ProjectFilePickerMatch[] { + if (limit <= 0) return []; + + const query = rawQuery.toLowerCase().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); + if (nameMatchIndices === null && pathMatchIndices === null) continue; + + 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..e1c06796958 --- /dev/null +++ b/apps/web/src/components/files/ProjectFilePicker.tsx @@ -0,0 +1,203 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useAtomValue } from "@effect/atom-react"; +import { useMemo, useState, type ReactNode } from "react"; + +import { useHandleNewThread } from "~/hooks/useHandleNewThread"; +import { useTheme } from "~/hooks/useTheme"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useProjects } from "~/state/entities"; +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; +} + +interface ProjectFilePickerTarget { + readonly environmentId: Parameters[0]; + readonly cwd: string; + readonly projectName: string; + readonly threadRef: ReturnType; +} + +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 & ProjectFilePickerTarget) { + const [query, setQuery] = useState(""); + const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const result = useProjectFilePickerQuery( + props.environmentId, + props.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(props.threadRef, match.path); + }, + })), + [hasMatchedQuery, matches, props.threadRef, resolvedTheme], + ); + + 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: props.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 { 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 ; + } + + return ( + + ); +} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 191b97d6a96..6b41ae4b4c1 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 = ""; @@ -133,6 +134,36 @@ export function useProjectEntriesQuery( }; } +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 }, + limit, + ); + + if (hasQuery) { + return { + entries: 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/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..7abb8f2153c 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("p"), + command: "filePicker.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("m", { shiftKey: true }), command: "modelPicker.toggle", @@ -327,6 +332,10 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), + "⌘P", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -514,6 +523,23 @@ 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 diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..a780fd158c5 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -23,7 +23,7 @@ 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 VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; @@ -181,7 +181,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { +export function useProjectPathSearch(target: ComposerPathSearchTarget, limit: number) { const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, @@ -190,7 +190,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }), [target.cwd, target.environmentId, 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 && @@ -200,7 +200,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, }, }) : null, @@ -210,10 +210,15 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { entries: result.data?.entries ?? [], error: result.error, isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + searchedQuery: debouncedTarget.query, refresh: result.refresh, }; } +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a0..190cf1a9379 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+p", "command": "filePicker.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 +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 +- `filePicker.toggle`: open or close the active project's file picker - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..f2c0ef38ecf 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 parsedFilePicker = yield* decode(KeybindingRule, { + key: "mod+p", + command: "filePicker.toggle", + }); + assert.strictEqual(parsedFilePicker.command, "filePicker.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 c7cff9943cd..a28319849f7 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", + "filePicker.toggle", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..7bb26bbe499 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+p", command: "filePicker.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" }, From 5db796698232c4860b0a330b7b8746c140a4c77d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 00:41:42 -0400 Subject: [PATCH 2/7] Preserve workspace search results --- apps/server/src/workspace/WorkspaceEntries.ts | 8 ++++---- .../files/ProjectFilePicker.logic.test.ts | 17 +++++++++++++---- .../components/files/ProjectFilePicker.logic.ts | 7 ++++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..0927df54999 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -19,6 +19,7 @@ import type { } 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"; @@ -230,10 +231,9 @@ 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); diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts index 66064d8e737..2693ac9a072 100644 --- a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts +++ b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts @@ -24,11 +24,13 @@ describe("getProjectFilePickerMatches", () => { ]); }); - it("matches against both file names and paths", () => { - assert.deepEqual(pathsForQuery(entries, "shared"), [ + 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" }, ]); - assert.deepEqual(pathsForQuery(entries, "read"), [{ name: "README.md", path: "README.md" }]); }); it("supports space-separated path tokens and a result limit", () => { @@ -50,7 +52,6 @@ describe("getProjectFilePickerMatches", () => { kind: "file", path: "src/useSubtestFlags/useTabActivity.ts", }, - { kind: "file", path: "src/TestResults.tsx" }, ] as const; assert.deepEqual( @@ -71,4 +72,12 @@ describe("getProjectFilePickerMatches", () => { [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 index 5d5ff1797d3..e4eff268efa 100644 --- a/apps/web/src/components/files/ProjectFilePicker.logic.ts +++ b/apps/web/src/components/files/ProjectFilePicker.logic.ts @@ -1,4 +1,5 @@ import type { ProjectEntry } from "@t3tools/contracts"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; export const PROJECT_FILE_PICKER_RESULT_LIMIT = 200; @@ -37,7 +38,9 @@ export function getProjectFilePickerMatches( ): ProjectFilePickerMatch[] { if (limit <= 0) return []; - const query = rawQuery.toLowerCase().replaceAll(/\s/g, ""); + const query = normalizeSearchQuery(rawQuery, { + trimLeadingPattern: /^[@./]+/, + }).replaceAll(/\s/g, ""); const matches: ProjectFilePickerMatch[] = []; for (const entry of entries) { @@ -46,8 +49,6 @@ export function getProjectFilePickerMatches( const name = fileName(entry.path); const nameMatchIndices = findMatchIndices(name, query); const pathMatchIndices = findMatchIndices(entry.path, query); - if (nameMatchIndices === null && pathMatchIndices === null) continue; - matches.push({ name, nameMatchIndices: nameMatchIndices ?? [], From 7d64e8d4df49bca4f2c917545392469d1d34a9d7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 00:42:28 -0400 Subject: [PATCH 3/7] Hide stale file search results --- apps/web/src/components/files/projectFilesQueryState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 6b41ae4b4c1..3b347dbeecb 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -149,7 +149,7 @@ export function useProjectFilePickerQuery( if (hasQuery) { return { - entries: search.entries, + entries: search.isPending ? [] : search.entries, error: search.error, isPending: search.isPending, matchedQuery: search.searchedQuery, From d0730ad51cc9eb2b177db87334f92dfc2e199a01 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 00:43:02 -0400 Subject: [PATCH 4/7] Reset command palette mode on dialog opens --- .../src/components/CommandPalette.logic.test.ts | 15 +++++++++++++++ apps/web/src/components/CommandPalette.logic.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 786fd208642..ef45195ffd3 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -36,6 +36,21 @@ describe("reduceCommandPaletteUiState", () => { openIntent: { kind: "add-project" }, }); }); + + it("resets file-picker mode for dialog trigger opens and closes", () => { + const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleFiles" }); + + 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, + }); + }); }); function makeThread(overrides: Partial = {}): Thread { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 48a552efb9a..0cba83dee50 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -35,8 +35,8 @@ export function reduceCommandPaletteUiState( switch (action._tag) { case "SetOpen": return { - ...state, open: action.open, + mode: "command", openIntent: action.open ? state.openIntent : null, }; case "ToggleCommand": From 46a52f43a08ea104d70e4918ba03cf7beea6aad3 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 00:47:30 -0400 Subject: [PATCH 5/7] Invalidate file search across projects --- apps/web/src/state/queries.test.ts | 24 ++++++++++++++++++++++++ apps/web/src/state/queries.ts | 14 +++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/state/queries.test.ts diff --git a/apps/web/src/state/queries.test.ts b/apps/web/src/state/queries.test.ts new file mode 100644 index 00000000000..dabb700f79c --- /dev/null +++ b/apps/web/src/state/queries.test.ts @@ -0,0 +1,24 @@ +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, and query 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); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index a780fd158c5..4be957d5d5a 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -51,6 +51,17 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +export function areProjectPathSearchTargetsEqual( + left: ComposerPathSearchTarget, + right: ComposerPathSearchTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query + ); +} + export function useThreadDetail( environmentId: EnvironmentId | null, threadId: ThreadId | null, @@ -209,7 +220,8 @@ export function useProjectPathSearch(target: ComposerPathSearchTarget, limit: nu 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, }; From 7fe3135e63bd18ccc1152634bb0b851488c328ad Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 15 Jul 2026 09:14:55 -0400 Subject: [PATCH 6/7] Filter file picker search before limiting --- .../src/workspace/WorkspaceEntries.test.ts | 25 +++++- apps/server/src/workspace/WorkspaceEntries.ts | 2 +- .../src/workspace/WorkspaceSearchIndex.ts | 83 +++++++++++++++++-- .../files/projectFilesQueryState.ts | 2 +- apps/web/src/state/queries.test.ts | 3 +- apps/web/src/state/queries.ts | 18 ++-- packages/contracts/src/project.ts | 6 +- 7 files changed, 120 insertions(+), 19 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..8cc4b80e8d2 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,24 @@ 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("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 0927df54999..cd671ab28bb 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -236,7 +236,7 @@ export const make = Effect.gen(function* () { }); 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))); }, ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..4e600ae501f 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,6 +17,7 @@ import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -96,6 +106,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, + kind?: ProjectEntryKind, ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, @@ -129,6 +140,16 @@ 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 mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -155,6 +176,33 @@ function mapMixedSearchResult( }; } +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 withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -229,18 +277,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" | "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 +337,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,8 +354,23 @@ 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); }); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 3b347dbeecb..632cbe5fe45 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -143,7 +143,7 @@ export function useProjectFilePickerQuery( const listing = useProjectEntriesQuery(environmentId, cwd); const hasQuery = /\S/.test(query); const search = useProjectPathSearch( - { environmentId, cwd, query: hasQuery ? query : null }, + { environmentId, cwd, query: hasQuery ? query : null, kind: "file" }, limit, ); diff --git a/apps/web/src/state/queries.test.ts b/apps/web/src/state/queries.test.ts index dabb700f79c..f8887dfb8ef 100644 --- a/apps/web/src/state/queries.test.ts +++ b/apps/web/src/state/queries.test.ts @@ -10,7 +10,7 @@ describe("areProjectPathSearchTargetsEqual", () => { query: "index", }; - it("requires the environment, workspace, and query to match", () => { + it("requires the environment, workspace, query, and entry kind to match", () => { expect(areProjectPathSearchTargetsEqual(target, target)).toBe(true); expect( areProjectPathSearchTargetsEqual(target, { @@ -20,5 +20,6 @@ describe("areProjectPathSearchTargetsEqual", () => { ).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 4be957d5d5a..64e9baae331 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, + ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -51,14 +52,19 @@ function useDebouncedValue(value: A, delayMs: number): A { return debounced; } +type ProjectPathSearchTarget = ComposerPathSearchTarget & { + readonly kind?: ProjectEntryKind | undefined; +}; + export function areProjectPathSearchTargetsEqual( - left: ComposerPathSearchTarget, - right: ComposerPathSearchTarget, + left: ProjectPathSearchTarget, + right: ProjectPathSearchTarget, ): boolean { return ( left.environmentId === right.environmentId && left.cwd === right.cwd && - left.query === right.query + left.query === right.query && + left.kind === right.kind ); } @@ -192,14 +198,15 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useProjectPathSearch(target: ComposerPathSearchTarget, limit: number) { +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, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( @@ -212,6 +219,7 @@ export function useProjectPathSearch(target: ComposerPathSearchTarget, limit: nu cwd: debouncedTarget.cwd, query: debouncedTarget.query, limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad3..b537e81bd03 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -5,15 +5,17 @@ const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; 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, From 276a18ddfe9f8fc170f2ba0b780db40bfdbf53c1 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 11:10:40 -0400 Subject: [PATCH 7/7] Remove stale command palette context wrapper --- apps/web/src/components/CommandPalette.tsx | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0813af90e41..4f6099ec3bf 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -45,7 +45,6 @@ import { type ReactNode, } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -381,20 +380,18 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [keybindings, terminalOpen, toggleCommand, toggleFiles]); return ( - - - - {children} - - - - + + + {children} + + + ); }