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);
|
setContent(originalContent);
|
||||||
}, [originalContent, setContent]);
|
}, [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) => {
|
const handleWorkspaceSelect = useCallback((workspace: string) => {
|
||||||
setCurrentWorkspace(workspace);
|
setCurrentWorkspace(workspace);
|
||||||
setSelectedFile(null);
|
setMobileView(selectedFile ? "editor" : "list");
|
||||||
setMobileView("list");
|
|
||||||
onWorkspaceChange?.(workspace);
|
onWorkspaceChange?.(workspace);
|
||||||
}, [onWorkspaceChange]);
|
}, [onWorkspaceChange, selectedFile]);
|
||||||
|
|
||||||
const persistSidebarWidth = useCallback((width: number) => {
|
const persistSidebarWidth = useCallback((width: number) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -230,6 +230,159 @@ describe("FileBrowserModal", () => {
|
|||||||
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
|
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 () => {
|
it("shows back button in mobile editor view", async () => {
|
||||||
Object.defineProperty(window, "innerWidth", {
|
Object.defineProperty(window, "innerWidth", {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export function useWorkspaceFileBrowser(
|
|||||||
setError(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setCurrentPath(".");
|
setCurrentPath(".");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user