diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
index 49857b5db36a4..a0e292d41afa8 100644
--- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
@@ -1,7 +1,17 @@
+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,
+ startWorkspaceFocus,
+ workspaceAreaProps,
+} from "~/lib/workspaceFocus";
const state = vi.hoisted(() => ({
file: null as ProjectReadFileResult | null,
@@ -49,6 +59,21 @@ vi.mock(import("~/hooks/useWorkspaceMutationRefresh"), () => ({
useWorkspaceMutationRefresh: vi.fn(),
}));
vi.mock(import("./FileBreadcrumbs"), () => ({ FileBreadcrumbs: () => <>> }));
+vi.mock(import("./FileBrowserPanel"), () => ({
+ default: () => (
+
+
+ src
+
+
+ server.ts
+
+
+ ),
+}));
+vi.mock(import("../DiffWorkerPoolProvider"), () => ({
+ DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
vi.mock(import("./projectFilesQueryState"), async (original) => ({
...(await original()),
useProjectFileQuery: () => ({
@@ -82,6 +107,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 +128,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);
+ await userEvent.keyboard("x");
+ await vi.waitFor(() => expect(deepActiveElement()?.textContent).toContain("xconst server"));
+
+ (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));
+ });
+
+ 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);
+ // 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" });
+
+ (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", () => {
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..4904211de72df 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -57,10 +57,12 @@ 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";
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";
@@ -598,6 +600,7 @@ const createFileEditor: EditorFactory = (
options,
editStateKey,
) => new Editor(editorType, options, editStateKey);
+type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>;
function editableFileContents(
environmentId: EnvironmentId,
@@ -661,6 +664,7 @@ interface EditableFileSurfaceProps {
wordWrap: boolean;
onPostRender: FilePostRender;
onPendingChange: (relativePath: string, pending: boolean) => void;
+ onEditorChange: (editor: FileEditor | null) => void;
}
interface FileSelectionOverride {
@@ -680,6 +684,7 @@ function EditableFileSurface({
wordWrap,
onPostRender,
onPendingChange,
+ onEditorChange,
}: EditableFileSurfaceProps) {
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
@@ -705,13 +710,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 +950,7 @@ function RenderedMarkdownSurface({
| "revealRequestId"
| "wordWrap"
| "onPostRender"
+ | "onEditorChange"
> & {
threadRef: ScopedThreadRef;
readOnly: boolean;
@@ -1142,6 +1150,55 @@ 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;
+ // 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(
+ hasCaret
+ ? { preventScroll: true }
+ : { preventScroll: true, lineNumber: revealLine ?? "first-visible" },
+ );
+ };
+ const handleEditorChange = (editor: FileEditor | null) => {
+ fileEditorRef.current = editor;
+ 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
@@ -1364,6 +1421,20 @@ 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 ? (
)
@@ -1546,29 +1618,37 @@ export default function FilePreviewPanel({
{showExplorer ? (
- {
- if (!isDocument) file.refresh();
- setFileRefreshRevision((value) => value + 1);
- },
- }
- : {})}
- />
+ {
+ if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event);
+ }}
+ >
+ {
+ if (!isDocument) file.refresh();
+ setFileRefreshRevision((value) => value + 1);
+ },
+ }
+ : {})}
+ />
+
) : null}