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:
7
.changeset/fn-7252-file-browser-worktree-reload.md
Normal file
7
.changeset/fn-7252-file-browser-worktree-reload.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user