From 777268d3d0f9f3099cde05786e25b0e965396f9f Mon Sep 17 00:00:00 2001 From: arman Date: Wed, 11 Nov 2026 13:11:41 +0000 Subject: [PATCH 1/6] fix(web): opening a file focuses its editor so Cmd+F searches it Co-Authored-By: Claude Opus 5.5 --- .../files/FilePreviewPanel.browser.test.tsx | 60 ++++++++++++++++++- .../src/components/files/FilePreviewPanel.tsx | 48 ++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index 49857b5db36a4..d5c941663ad5d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -1,7 +1,13 @@ +import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs"; import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../../test/render"; +import appCss from "~/index.css?inline"; +import { isMacPlatform } from "~/lib/utils"; +import { createWorkspaceFocusController, workspaceAreaProps } from "~/lib/workspaceFocus"; const state = vi.hoisted(() => ({ file: null as ProjectReadFileResult | null, @@ -49,6 +55,9 @@ vi.mock(import("~/hooks/useWorkspaceMutationRefresh"), () => ({ useWorkspaceMutationRefresh: vi.fn(), })); vi.mock(import("./FileBreadcrumbs"), () => ({ FileBreadcrumbs: () => <> })); +vi.mock(import("../DiffWorkerPoolProvider"), () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); vi.mock(import("./projectFilesQueryState"), async (original) => ({ ...(await original()), useProjectFileQuery: () => ({ @@ -82,6 +91,7 @@ function setup() { const previous = keys.map((key) => localStorage.getItem(key)); localStorage.setItem(keys[0]!, "false"); localStorage.setItem(keys[1]!, "true"); + state.file = null; state.optimistic = null; state.writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); return { @@ -102,12 +112,16 @@ function markdownPreview(truncated: boolean) { byteLength: truncated ? 1_200_000 : 32, truncated, }; + return filePreview("tasks.md"); +} + +function filePreview(relativePath: string) { return ( { + it("hands panel focus to the file's editor, which owns mod+f", async () => { + using _settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const panel = () => ( +
+
{filePreview("server.ts")}
+
+ ); + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + + await using screen = await render(panel()); + const focus = createWorkspaceFocusController(document); + using _focus = { [Symbol.dispose]: focus.dispose }; + expect(focus.focus("panel")).toBe(true); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await screen.rerender(panel()); + await vi.waitFor(editorHasFocus); + + (deepActiveElement() as HTMLElement).blur(); + expect(focus.focus("panel", false)).toBe(true); + await vi.waitFor(editorHasFocus); + await userEvent.keyboard( + isMacPlatform(navigator.platform) ? "{Meta>}f{/Meta}" : "{Control>}f{/Control}", + ); + await vi.waitFor(() => expect(deepActiveElement()).toBeInstanceOf(HTMLInputElement)); + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0311ce254ff4f..e5e5ebabd7906 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -598,6 +598,7 @@ const createFileEditor: EditorFactory = ( options, editStateKey, ) => new Editor(editorType, options, editStateKey); +type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>; function editableFileContents( environmentId: EnvironmentId, @@ -661,6 +662,7 @@ interface EditableFileSurfaceProps { wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; + onEditorChange: (editor: FileEditor | null) => void; } interface FileSelectionOverride { @@ -680,6 +682,7 @@ function EditableFileSurface({ wordWrap, onPostRender, onPendingChange, + onEditorChange, }: EditableFileSurfaceProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -705,13 +708,15 @@ function EditableFileSurface({ } const { ready: editable, onPostRender: onEditablePostRender } = useEditableAfterHighlight(externalFile); - const editorRef = useRef | null>(null); + const editorRef = useRef(null); const editorOptions: EditorOptions<"file", FileCommentAnnotationGroup, undefined> = { onAttach: (editor) => { editorRef.current = editor; + onEditorChange(editor); }, onComplete: () => { editorRef.current = null; + onEditorChange(null); }, }; @@ -943,6 +948,7 @@ function RenderedMarkdownSurface({ | "revealRequestId" | "wordWrap" | "onPostRender" + | "onEditorChange" > & { threadRef: ScopedThreadRef; readOnly: boolean; @@ -1142,6 +1148,32 @@ export default function FilePreviewPanel({ !(tableDelimiter && renderTable) && !renderBrowserFile && !isDocument; + const mayOpenEditor = + previewPath !== null && + !isHostFile && + canWriteFiles && + file.error === null && + !file.data?.truncated && + !isMedia && + !isDocument && + !renderBrowserFile && + !renderMermaid && + !(isMarkdown && renderMarkdown) && + !(tableDelimiter && renderTable); + const contentFocusRef = useRef(null); + const fileEditorRef = useRef(null); + const focusEditorFromContent = () => { + const editor = fileEditorRef.current; + if (!editor || document.activeElement !== contentFocusRef.current) return; + const atRevealLine = revealLine !== null && !editor.getViewState().selections?.length; + editor.focus( + atRevealLine ? { preventScroll: true, lineNumber: revealLine } : { preventScroll: true }, + ); + }; + const handleEditorChange = (editor: FileEditor | null) => { + fileEditorRef.current = editor; + focusEditorFromContent(); + }; const rendered = isMermaid ? renderMermaid : isMarkdown @@ -1364,6 +1396,19 @@ export default function FilePreviewPanel({
+ {mayOpenEditor ? ( + // Panel focus lands here and passes to the editor once it attaches. sr-only keeps the + // 1px box workspace focus needs, and clips it so clicks in the file never reach it. + // The hand-off waits a microtask: workspace focus checks that focus stayed here. +
queueMicrotask(focusEditorFromContent)} + /> + ) : null} {isFolder ? null : relativePath && attachment ? ( ) From c7771221f0e6af2061d2a7bfd981cd94a44b251e Mon Sep 17 00:00:00 2001 From: arman Date: Wed, 11 Nov 2026 13:24:57 +0000 Subject: [PATCH 2/6] refactor(web): pass a stable editor-change callback to the file editor Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/files/FilePreviewPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e5e5ebabd7906..30a483050717e 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,6 +57,7 @@ import { MediaActions, type MediaActionSource } from "~/components/media/MediaAc import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; @@ -1174,6 +1175,7 @@ export default function FilePreviewPanel({ fileEditorRef.current = editor; focusEditorFromContent(); }; + const handleEditorChangeStable = useStableCallback(handleEditorChange); const rendered = isMermaid ? renderMermaid : isMarkdown @@ -1584,7 +1586,7 @@ export default function FilePreviewPanel({ wordWrap={wordWrap} onPostRender={onFilePostRender} onPendingChange={onPendingChange} - onEditorChange={handleEditorChange} + onEditorChange={handleEditorChangeStable} /> ) From bae4e768f51c0761e37e489c1a8f5a3d25f23d8b Mon Sep 17 00:00:00 2001 From: arman Date: Wed, 11 Nov 2026 16:22:51 +0000 Subject: [PATCH 3/6] fix(web): clicking or pressing Enter on a file in the tree focuses its editor Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FilePreviewPanel.tsx | 78 +++++++++++++------ 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 30a483050717e..76afbb039964a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -62,6 +62,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hoo import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; +import { requestWorkspaceFocus } from "~/lib/workspaceFocus"; import type { ChatFileAttachment } from "~/types"; import { isHostPath } from "@t3tools/shared/path"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; @@ -1176,6 +1177,24 @@ export default function FilePreviewPanel({ focusEditorFromContent(); }; const handleEditorChangeStable = useStableCallback(handleEditorChange); + const focusEditorOnTreeRowClickOrEnter = (event: { + nativeEvent: Event; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const row = event.nativeEvent + .composedPath() + .find( + (node): node is Element => + node instanceof Element && node.matches('[role="treeitem"][data-item-path]'), + ); + const path = row?.getAttribute("data-item-path"); + if (!path || path.endsWith("/")) return; + requestWorkspaceFocus({ area: "panel", selector: "[data-file-content-focus]" }); + }; const rendered = isMermaid ? renderMermaid : isMarkdown @@ -1406,6 +1425,7 @@ export default function FilePreviewPanel({ ref={contentFocusRef} tabIndex={-1} data-workspace-focus-default + data-file-content-focus aria-label="File contents" className="sr-only" onFocus={() => queueMicrotask(focusEditorFromContent)} @@ -1593,31 +1613,39 @@ export default function FilePreviewPanel({ ) : null}
{showExplorer ? ( - - { - if (!isDocument) file.refresh(); - setFileRefreshRevision((value) => value + 1); - }, - } - : {})} - /> - +
{ + if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event); + }} + > + + { + if (!isDocument) file.refresh(); + setFileRefreshRevision((value) => value + 1); + }, + } + : {})} + /> + +
) : null}
From 23b720709f244e591ae2217e42ac6bccb706accc Mon Sep 17 00:00:00 2001 From: arman Date: Wed, 11 Nov 2026 16:24:13 +0000 Subject: [PATCH 4/6] test(web): cover editor focus after clicking or pressing Enter on a tree file Co-Authored-By: Claude Opus 5.5 --- .../files/FilePreviewPanel.browser.test.tsx | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index d5c941663ad5d..e7801ae2a5016 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -7,7 +7,11 @@ import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../../test/render"; import appCss from "~/index.css?inline"; import { isMacPlatform } from "~/lib/utils"; -import { createWorkspaceFocusController, workspaceAreaProps } from "~/lib/workspaceFocus"; +import { + createWorkspaceFocusController, + startWorkspaceFocus, + workspaceAreaProps, +} from "~/lib/workspaceFocus"; const state = vi.hoisted(() => ({ file: null as ProjectReadFileResult | null, @@ -55,6 +59,18 @@ vi.mock(import("~/hooks/useWorkspaceMutationRefresh"), () => ({ useWorkspaceMutationRefresh: vi.fn(), })); vi.mock(import("./FileBreadcrumbs"), () => ({ FileBreadcrumbs: () => <> })); +vi.mock(import("./FileBrowserPanel"), () => ({ + default: () => ( +
+ + +
+ ), +})); vi.mock(import("../DiffWorkerPoolProvider"), () => ({ DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, })); @@ -181,6 +197,44 @@ describe("source file focus", () => { ); await vi.waitFor(() => expect(deepActiveElement()).toBeInstanceOf(HTMLInputElement)); }); + + it("focuses the editor when a file row is clicked or activated with Enter", async () => { + using _settings = setup(); + localStorage.setItem("t3code.fileExplorerOpen", "true"); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await using screen = await render( +
+
{filePreview("server.ts")}
+
, + ); + using _focus = { + [Symbol.dispose]: startWorkspaceFocus({ subscribe: () => () => {} } as never), + }; + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + const folderRow = screen.getByRole("treeitem", { name: "src" }); + const fileRow = screen.getByRole("treeitem", { name: "server.ts" }); + + (folderRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + expect(document.activeElement).toBe(folderRow.element()); + + await userEvent.click(fileRow); + await vi.waitFor(editorHasFocus); + + (fileRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(editorHasFocus); + }); }); describe("rendered Markdown task saves", () => { From 0b13bb116a09b1ef28b101fc3bc2996651006d75 Mon Sep 17 00:00:00 2001 From: arman Date: Wed, 11 Nov 2026 17:19:56 +0000 Subject: [PATCH 5/6] fix(web): place a caret when focus moves into a file with none Co-Authored-By: Claude Opus 5.5 --- .../components/files/FilePreviewPanel.browser.test.tsx | 2 ++ apps/web/src/components/files/FilePreviewPanel.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index e7801ae2a5016..cf8dc6a0e646d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -188,6 +188,8 @@ describe("source file focus", () => { }; await screen.rerender(panel()); await vi.waitFor(editorHasFocus); + await userEvent.keyboard("x"); + await vi.waitFor(() => expect(deepActiveElement()?.textContent).toContain("xconst server")); (deepActiveElement() as HTMLElement).blur(); expect(focus.focus("panel", false)).toBe(true); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 76afbb039964a..a430469847b81 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1167,9 +1167,13 @@ export default function FilePreviewPanel({ const focusEditorFromContent = () => { const editor = fileEditorRef.current; if (!editor || document.activeElement !== contentFocusRef.current) return; - const atRevealLine = revealLine !== null && !editor.getViewState().selections?.length; + // Without a caret the editor focuses with no selection, so no caret shows and typing + // lands on whatever the browser selection held. + const hasCaret = Boolean(editor.getViewState().selections?.length); editor.focus( - atRevealLine ? { preventScroll: true, lineNumber: revealLine } : { preventScroll: true }, + hasCaret + ? { preventScroll: true } + : { preventScroll: true, lineNumber: revealLine ?? "first-visible" }, ); }; const handleEditorChange = (editor: FileEditor | null) => { From 87dba64012abf7e63cd3e04415f1f5926fdc5036 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 19:33:08 +0000 Subject: [PATCH 6/6] fix(web): keep the file explorer resizable with the tree focus handler The click/Enter wrapper sat between the explorer pane and its row, and as a display:contents box it reported zero width, so the pane capped itself at its 160px minimum. Move the wrapper inside the pane so the pane measures the row. AI-assisted; verified with the tests and tools listed above. --- .../files/FilePreviewPanel.browser.test.tsx | 6 +++++- .../src/components/files/FilePreviewPanel.tsx | 20 +++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index cf8dc6a0e646d..a0e292d41afa8 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -215,7 +215,7 @@ describe("source file focus", () => { truncated: false, }; await using screen = await render( -
+
{filePreview("server.ts")}
, ); @@ -223,6 +223,10 @@ describe("source file focus", () => { [Symbol.dispose]: startWorkspaceFocus({ subscribe: () => () => {} } as never), }; const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + // The explorer measures its parent row; a wrapper without a box would cap it at the minimum. + await expect + .element(screen.getByRole("separator", { name: "Resize file explorer" })) + .toHaveAttribute("aria-valuemax", "560"); const folderRow = screen.getByRole("treeitem", { name: "src" }); const fileRow = screen.getByRole("treeitem", { name: "server.ts" }); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a430469847b81..4904211de72df 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1617,14 +1617,14 @@ export default function FilePreviewPanel({ ) : null}
{showExplorer ? ( -
{ - if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event); - }} - > - + +
{ + if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event); + }} + > - -
+
+ ) : null}