diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914bed..7da02e86eac1d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -4,8 +4,10 @@ import { page, userEvent } from "vite-plus/test/browser"; import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag"; import { dismissContextMenu } from "~/contextMenuFallback"; +import { isMacPlatform } from "~/lib/utils"; import { render } from "../../../test/render"; import FileBrowserPanel from "./FileBrowserPanel"; +import { dispatchPreviewAction } from "../preview/previewActionBus"; const entries: readonly ProjectEntry[] = [ { path: "src", kind: "directory" }, @@ -115,4 +117,31 @@ describe("FileBrowserPanel", () => { row.click(); await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); + + it("mod+f from a tree row, or the panel find command, focuses the file search", async () => { + await using _screen = await render( + {}} + workspaceMutationId={null} + answersPanelFind + />, + ); + await vi.waitFor(() => expect(findTreeRow("README.md")).not.toBeNull()); + findTreeRow("README.md")!.focus(); + + const modifier = isMacPlatform(navigator.platform) ? "Meta" : "Control"; + await userEvent.keyboard(`{${modifier}>}f{/${modifier}}`); + + const search = page.getByRole("searchbox", { name: "Search repo files" }); + await expect.element(search).toHaveFocus(); + + (search.element() as HTMLElement).blur(); + dispatchPreviewAction("find"); + await expect.element(search).toHaveFocus(); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 6b9c912be0a06..292535e7bb1d0 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -10,7 +10,7 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { type Ref, useEffect, useEffectEvent, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -33,6 +33,8 @@ import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; import { useProjectPathSearch } from "~/state/queries"; +import { isMacPlatform } from "~/lib/utils"; +import { type PreviewAction, subscribePreviewAction } from "../preview/previewActionBus"; interface FileBrowserPanelProps { environmentId: EnvironmentId; @@ -46,6 +48,7 @@ interface FileBrowserPanelProps { hostFolder?: boolean; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; + answersPanelFind?: boolean; } function treePath(entry: ProjectEntry): string { @@ -73,16 +76,21 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } ); } -function FileSearchField(props: { +function FileSearchField({ + ref, + ...props +}: { ariaLabel: string; name: string; onClose: () => void; onValueChange: (value: string) => void; + ref: Ref; value: string; }) { return ( hostFolder ? joinHostPath(cwd, relativePath) : relativePath; @@ -320,6 +329,20 @@ export default function FileBrowserPanel({ setQuery(""); search.close(); }; + const searchInputRef = useRef(null); + const focusSearch = () => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }; + const onPanelAction = useEffectEvent((action: PreviewAction) => { + // Hidden panel tabs stay mounted; only the visible Files tab answers. + if (action === "find" && searchInputRef.current?.getClientRects().length) focusSearch(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the panel find command, which fires while focus is anywhere in the right panel + useEffect(() => { + if (!answersPanelFind) return; + return subscribePreviewAction(onPanelAction); + }, [answersPanelFind]); const expandedPathsRef = useRef(new Set()); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded useEffect(() => { @@ -482,6 +505,13 @@ export default function FileBrowserPanel({ // Capture dragstart before the tree selects the row so selection sees the drag flag. onDragStartCapture={(event) => dragMention.handleDragStart(event.nativeEvent)} onDragEnd={() => dragMention.handleDragEnd((path) => model.getItem(path)?.deselect())} + onKeyDown={(event) => { + const isModPressed = isMacPlatform(navigator.platform) ? event.metaKey : event.ctrlKey; + if (!isModPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== "f") + return; + event.preventDefault(); + focusSearch(); + }} >