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: () => ( +
+ + +
+ ), +})); +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}