diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index 49857b5db36a4..16a7b74c4ac71 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -1,7 +1,16 @@ +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 { + createWorkspaceFocusController, + startWorkspaceFocus, + workspaceAreaProps, +} from "~/lib/workspaceFocus"; const state = vi.hoisted(() => ({ file: null as ProjectReadFileResult | null, @@ -49,6 +58,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 +106,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 +127,16 @@ function markdownPreview(truncated: boolean) { byteLength: truncated ? 1_200_000 : 32, truncated, }; + return filePreview("tasks.md"); +} + +function filePreview(relativePath: string) { return ( { + it("moves panel focus into the source editor once the file loads", 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); + }); + + 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", () => { 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..1461c3c3e4236 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -61,6 +61,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"; @@ -598,6 +599,7 @@ const createFileEditor: EditorFactory = ( options, editStateKey, ) => new Editor(editorType, options, editStateKey); +type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>; function editableFileContents( environmentId: EnvironmentId, @@ -661,6 +663,7 @@ interface EditableFileSurfaceProps { wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; + onEditorChange: (editor: FileEditor | null) => void; } interface FileSelectionOverride { @@ -680,6 +683,7 @@ function EditableFileSurface({ wordWrap, onPostRender, onPendingChange, + onEditorChange, }: EditableFileSurfaceProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -705,13 +709,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 +949,7 @@ function RenderedMarkdownSurface({ | "revealRequestId" | "wordWrap" | "onPostRender" + | "onEditorChange" > & { threadRef: ScopedThreadRef; readOnly: boolean; @@ -1142,6 +1149,53 @@ export default function FilePreviewPanel({ !(tableDelimiter && renderTable) && !renderBrowserFile && !isDocument; + const opensEditor = + previewPath !== null && + !isHostFile && + canWriteFiles && + file.error === null && + !file.data?.truncated && + !isMedia && + !isDocument && + !renderBrowserFile && + !renderMermaid && + !(isMarkdown && renderMarkdown) && + !(tableDelimiter && renderTable); + const editorFocusRef = useRef(null); + const fileEditorRef = useRef(null); + const focusEditorFromPlaceholder = () => { + const editor = fileEditorRef.current; + if (!editor || document.activeElement !== editorFocusRef.current) return; + // Without a selection the editor focuses with no caret, and typing goes nowhere visible. + const hasCaret = Boolean(editor.getViewState().selections?.length); + editor.focus({ + preventScroll: true, + ...(hasCaret ? {} : { lineNumber: revealLine ?? "first-visible" }), + }); + }; + const handleEditorChange = (editor: FileEditor | null) => { + fileEditorRef.current = editor; + focusEditorFromPlaceholder(); + }; + // Click and Enter open a file row; arrow keys also open files but keep browsing the tree. + const focusEditorFromTreeRow = (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 @@ -1360,10 +1414,30 @@ export default function FilePreviewPanel({ Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. ) : null} -
+
{ + if (event.key === "Enter") focusEditorFromTreeRow(event); + }} + >
+ {opensEditor ? ( + // The editor lives in a shadow root and attaches after the file loads, so panel focus + // lands here and passes on once it can. sr-only keeps the box focus needs while clipping + // it from clicks. The hand-off waits a microtask: workspace focus checks focus stayed here. +
queueMicrotask(focusEditorFromPlaceholder)} + /> + ) : null} {isFolder ? null : relativePath && attachment ? ( )