FN-7252: Preserve selected file across worktree switches

Keep the Files modal focused on the selected file when users switch worktrees.

- Preserve selected file state during workspace changes and restore the sidebar to the file parent directory.\n- Keep mobile users in the editor pane when switching worktrees with an active file.\n- Cover desktop and mobile worktree-switch reload behavior in FileBrowserModal tests.\n- Add a patch changeset for the Files modal fix.\n\nFiles changed:\n .changeset/fn-7252-file-browser-worktree-reload.md |   7 +\n .../dashboard/app/components/FileBrowserModal.tsx  |  21 ++-\n .../components/__tests__/FileBrowserModal.test.tsx | 153 +++++++++++++++++++++\n .../dashboard/app/hooks/useWorkspaceFileBrowser.ts |   4 +\n 4 files changed, 182 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7252

Fusion-Task-Lineage: b791e3d7-6a68-48aa-aeb4-c826cec7f760

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 19:51:38 -07:00
parent f01e5c9651
commit 224e8b4065
4 changed files with 182 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Reload the selected file when switching Files modal worktrees.
category: fix
dev: Keeps Files modal selected-path state while changing workspace/worktree.

View File

@@ -228,12 +228,27 @@ export function FileBrowserModal({
setContent(originalContent);
}, [originalContent, setContent]);
/*
FNXC:FileBrowser 2026-06-29-00:00:
Worktree switches must keep the last selected file path so users can recover from a load error caused by viewing the right path in the wrong workspace. The raw browser hook still resets each new workspace to root, so the modal re-points the sidebar to the selected file's parent directory after that reset while mobile keeps the editor pane active.
*/
const previousWorkspaceRef = useRef(currentWorkspace);
useEffect(() => {
const workspaceChanged = previousWorkspaceRef.current !== currentWorkspace;
if (workspaceChanged && selectedFile) {
setPath(getParentDirectory(selectedFile));
if (isMobile) {
setMobileView("editor");
}
}
previousWorkspaceRef.current = currentWorkspace;
}, [currentWorkspace, isMobile, selectedFile, setPath]);
const handleWorkspaceSelect = useCallback((workspace: string) => {
setCurrentWorkspace(workspace);
setSelectedFile(null);
setMobileView("list");
setMobileView(selectedFile ? "editor" : "list");
onWorkspaceChange?.(workspace);
}, [onWorkspaceChange]);
}, [onWorkspaceChange, selectedFile]);
const persistSidebarWidth = useCallback((width: number) => {
try {

View File

@@ -230,6 +230,159 @@ describe("FileBrowserModal", () => {
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
});
it("keeps an errored nested selected file open on desktop after switching workspaces", async () => {
const user = userEvent.setup();
const nestedFile = "packages/dashboard/app/App.tsx";
mockUseWorkspaceFileEditor.mockReturnValue({
...defaultEditorState,
error: "Could not load App.tsx",
});
render(
<FileBrowserModal
initialWorkspace="project"
initialFile={nestedFile}
isOpen={true}
onClose={mockOnClose}
onWorkspaceChange={mockOnWorkspaceChange}
/>,
);
await waitFor(() => expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument());
expect(screen.getByText("Could not load App.tsx")).toBeInTheDocument();
mockSetPath.mockClear();
await user.click(screen.getByRole("button", { name: /kb/i }));
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
});
expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument();
expect(screen.getByText("Could not load App.tsx")).toBeInTheDocument();
expect(document.querySelector(".file-browser-content.mobile")).toBeNull();
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
});
it("replaces an errored selected file with newly loaded content after switching workspaces", async () => {
const user = userEvent.setup();
const nestedFile = "packages/dashboard/app/App.tsx";
mockUseWorkspaceFileEditor.mockImplementation((workspace, filePath) => {
if (workspace === "FN-002" && filePath === nestedFile) {
return {
...defaultEditorState,
content: "console.log('loaded from task worktree');",
originalContent: "console.log('loaded from task worktree');",
error: null,
};
}
if (filePath === nestedFile) {
return {
...defaultEditorState,
content: "",
originalContent: "",
error: "Could not load App.tsx",
};
}
return defaultEditorState;
});
render(
<FileBrowserModal
initialWorkspace="project"
initialFile={nestedFile}
isOpen={true}
onClose={mockOnClose}
onWorkspaceChange={mockOnWorkspaceChange}
/>,
);
await waitFor(() => expect(screen.getByText("Could not load App.tsx")).toBeInTheDocument());
mockSetPath.mockClear();
await user.click(screen.getByRole("button", { name: /kb/i }));
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
});
expect(screen.queryByText("Could not load App.tsx")).not.toBeInTheDocument();
expect(document.querySelector(".cm-content")?.textContent).toContain("console.log('loaded from task worktree');");
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
});
it("keeps mobile on the errored nested selected file after switching workspaces", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 375,
});
const user = userEvent.setup();
const nestedFile = "packages/dashboard/app/App.tsx";
mockUseWorkspaceFileEditor.mockReturnValue({
...defaultEditorState,
error: "Could not load App.tsx",
});
render(
<FileBrowserModal
initialWorkspace="project"
initialFile={nestedFile}
isOpen={true}
onClose={mockOnClose}
onWorkspaceChange={mockOnWorkspaceChange}
/>,
);
fireEvent(window, new Event("resize"));
await waitFor(() => expect(screen.getByLabelText("Back to file list")).toBeInTheDocument());
expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument();
mockSetPath.mockClear();
await user.click(screen.getByRole("button", { name: /kb/i }));
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
});
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument();
expect(document.querySelector(".file-browser-content.mobile.active")).not.toBeNull();
expect(document.querySelector(".file-browser-sidebar.mobile.active")).toBeNull();
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
});
it("keeps the placeholder after switching workspaces with no selected file", async () => {
const user = userEvent.setup();
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
onWorkspaceChange={mockOnWorkspaceChange}
/>,
);
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
mockUseWorkspaceFileEditor.mockClear();
await user.click(screen.getByRole("button", { name: /kb/i }));
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", null, true, undefined);
});
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
expect(screen.queryByLabelText(/Editor for /)).not.toBeInTheDocument();
expect(mockSetPath).not.toHaveBeenCalledWith(expect.stringContaining("packages/dashboard/app"));
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
});
it("shows back button in mobile editor view", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,

View File

@@ -39,6 +39,10 @@ export function useWorkspaceFileBrowser(
setError(null);
}, []);
/*
FNXC:FileBrowser 2026-06-29-19:35:
Workspace file pickers must start each workspace at root so SettingsModal directory/file pickers do not inherit editor selection state. FileBrowserModal restores its selected file path at the modal layer when it needs editor persistence across worktree switches.
*/
useEffect(() => {
setCurrentPath(".");
setError(null);