Skip to content
120 changes: 119 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,6 +59,21 @@ vi.mock(import("~/hooks/useWorkspaceMutationRefresh"), () => ({
useWorkspaceMutationRefresh: vi.fn(),
}));
vi.mock(import("./FileBreadcrumbs"), () => ({ FileBreadcrumbs: () => <></> }));
vi.mock(import("./FileBrowserPanel"), () => ({
default: () => (
<div role="tree">
<button type="button" role="treeitem" data-item-path="src/">
src
</button>
<button type="button" role="treeitem" data-item-path="server.ts">
server.ts
</button>
</div>
),
}));
vi.mock(import("../DiffWorkerPoolProvider"), () => ({
DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
vi.mock(import("./projectFilesQueryState"), async (original) => ({
...(await original()),
useProjectFileQuery: () => ({
Expand Down Expand Up @@ -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 {
Expand All @@ -102,12 +128,16 @@ function markdownPreview(truncated: boolean) {
byteLength: truncated ? 1_200_000 : 32,
truncated,
};
return filePreview("tasks.md");
}

function filePreview(relativePath: string) {
return (
<FilePreviewPanel
environmentId={environmentId}
cwd="/workspace"
projectName="Project"
relativePath="tasks.md"
relativePath={relativePath}
threadRef={threadRef}
composerDraftTarget={threadRef}
keybindings={[]}
Expand All @@ -125,6 +155,94 @@ function markdownPreview(truncated: boolean) {
);
}

function deepActiveElement() {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
}

describe("source file focus", () => {
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 = () => (
<div {...workspaceAreaProps("panel", 1, "Workspace panel")}>
<div data-right-panel-surface-content>{filePreview("server.ts")}</div>
</div>
);
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(
<div {...workspaceAreaProps("panel", 1, "Workspace panel")} style={{ width: 900 }}>
<div data-right-panel-surface-content>{filePreview("server.ts")}</div>
</div>,
);
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();
Expand Down
128 changes: 104 additions & 24 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -598,6 +600,7 @@ const createFileEditor: EditorFactory<FileCommentAnnotationGroup, undefined> = (
options,
editStateKey,
) => new Editor(editorType, options, editStateKey);
type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>;

function editableFileContents(
environmentId: EnvironmentId,
Expand Down Expand Up @@ -661,6 +664,7 @@ interface EditableFileSurfaceProps {
wordWrap: boolean;
onPostRender: FilePostRender;
onPendingChange: (relativePath: string, pending: boolean) => void;
onEditorChange: (editor: FileEditor | null) => void;
}

interface FileSelectionOverride {
Expand All @@ -680,6 +684,7 @@ function EditableFileSurface({
wordWrap,
onPostRender,
onPendingChange,
onEditorChange,
}: EditableFileSurfaceProps) {
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
Expand All @@ -705,13 +710,15 @@ function EditableFileSurface({
}
const { ready: editable, onPostRender: onEditablePostRender } =
useEditableAfterHighlight(externalFile);
const editorRef = useRef<Editor<"file", FileCommentAnnotationGroup, undefined> | null>(null);
const editorRef = useRef<FileEditor | null>(null);
const editorOptions: EditorOptions<"file", FileCommentAnnotationGroup, undefined> = {
onAttach: (editor) => {
editorRef.current = editor;
onEditorChange(editor);
},
onComplete: () => {
editorRef.current = null;
onEditorChange(null);
},
};

Expand Down Expand Up @@ -943,6 +950,7 @@ function RenderedMarkdownSurface({
| "revealRequestId"
| "wordWrap"
| "onPostRender"
| "onEditorChange"
> & {
threadRef: ScopedThreadRef;
readOnly: boolean;
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const fileEditorRef = useRef<FileEditor | null>(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
Expand Down Expand Up @@ -1364,6 +1421,20 @@ export default function FilePreviewPanel({
<div
className={cn("min-w-0 flex-1 flex-col overflow-hidden", previewPath ? "flex" : "hidden")}
>
{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.
<div
ref={contentFocusRef}
tabIndex={-1}
data-workspace-focus-default
data-file-content-focus
aria-label="File contents"
className="sr-only"
onFocus={() => queueMicrotask(focusEditorFromContent)}
/>
) : null}
{isFolder ? null : relativePath && attachment ? (
<AttachmentFilePreview
key={`${environmentId}:${attachment.id}`}
Expand Down Expand Up @@ -1539,36 +1610,45 @@ export default function FilePreviewPanel({
wordWrap={wordWrap}
onPostRender={onFilePostRender}
onPendingChange={onPendingChange}
onEditorChange={handleEditorChangeStable}
/>
</DiffWorkerPoolProvider>
)
) : null}
</div>
{showExplorer ? (
<FileExplorerPane resizable={Boolean(previewPath)}>
<FileBrowserPanel
key={`${environmentId}:${hostFolderPath ?? cwd}`}
environmentId={environmentId}
cwd={hostFolderPath ?? cwd}
projectName={hostFolderPath ?? projectName}
hostFolder={hostFolderPath !== null}
selectedPath={
hostFolderPath === null
? relativePath
: trailChildOf(hostFolderPath, breadcrumbTrail ?? hostFolderPath)
}
selectedPathRevealId={revealRequestId}
onOpenFile={onOpenFile}
workspaceMutationId={workspaceMutationId}
{...(previewPath && !isMedia && !isPdf
? {
onRefreshSelectedFile: () => {
if (!isDocument) file.refresh();
setFileRefreshRevision((value) => value + 1);
},
}
: {})}
/>
<div
className="contents"
onClick={focusEditorOnTreeRowClickOrEnter}
onKeyDown={(event) => {
if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event);
}}
>
<FileBrowserPanel
key={`${environmentId}:${hostFolderPath ?? cwd}`}
environmentId={environmentId}
cwd={hostFolderPath ?? cwd}
projectName={hostFolderPath ?? projectName}
hostFolder={hostFolderPath !== null}
selectedPath={
hostFolderPath === null
? relativePath
: trailChildOf(hostFolderPath, breadcrumbTrail ?? hostFolderPath)
}
selectedPathRevealId={revealRequestId}
onOpenFile={onOpenFile}
workspaceMutationId={workspaceMutationId}
{...(previewPath && !isMedia && !isPdf
? {
onRefreshSelectedFile: () => {
if (!isDocument) file.refresh();
setFileRefreshRevision((value) => value + 1);
},
}
: {})}
/>
</div>
</FileExplorerPane>
) : null}
</div>
Expand Down
Loading