FN-7866: add auto-save toggle for the workspace file editor (default on)

Adds a shared, persisted auto-save preference for workspace text-file editing, defaulted to on, surfaced as a toolbar toggle in both the Files modal and right-dock Files view.

- Add useAutoSavePreference hook: persists the fn-file-editor-auto-save localStorage preference, broadcasts same-window changes via a custom event (storage events only reach other documents), and defaults to true.
- Extend useWorkspaceFileEditor with an autoSave flag that debounces (800ms) and triggers save() for a loaded, editable file with real pending changes, keyed by workspace+file+content to avoid re-firing on failed writes.
- Add an Auto-save toggle button to FileEditor's toolbar (autoSaveEnabled/onToggleAutoSave/canToggleAutoSave props), hidden for read-only/preview/binary files.
- Wire the shared preference into FileBrowserModal and DockFilesView, disabling auto-save for binary files in the modal.
- Add fileEditor.autoSave / fileEditor.toggleAutoSave i18n strings and document the new default behavior in docs/dashboard-guide.md.
- Add/extend tests covering the new hook, debounced auto-save behavior, and toolbar toggle wiring across FileEditor, FileBrowserModal, and DockFilesView.

Files changed:
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/DockFilesView.tsx     |   6 +-
 .../dashboard/app/components/FileBrowserModal.tsx  |  20 ++--
 packages/dashboard/app/components/FileEditor.tsx   |  17 +++-
 .../components/__tests__/DockFilesView.test.tsx    |  37 ++++++-
 .../components/__tests__/FileBrowserModal.test.tsx |  86 +++++++++++++---
 .../app/components/__tests__/FileEditor.test.tsx   |  56 +++++++++++
 .../hooks/__tests__/useAutoSavePreference.test.ts  |  66 +++++++++++++
 .../hooks/__tests__/useWorkspaceFileEditor.test.ts | 108 +++++++++++++++++++++
 .../dashboard/app/hooks/useAutoSavePreference.ts   |  79 +++++++++++++++
 .../dashboard/app/hooks/useWorkspaceFileEditor.ts  |  47 ++++++++-
 packages/i18n/locales/en/app.json                  |   2 +
 12 files changed, 500 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-7866

Fusion-Task-Lineage: 0604de18-666d-4872-abce-2a3886c9ea55

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 12:49:37 -07:00
parent bc30ce8aa1
commit 06ec0e606e
12 changed files with 500 additions and 27 deletions

View File

@@ -171,6 +171,9 @@ Use deep links to open a specific task directly from notifications, chat, or ext
File paths in dashboard text are automatically rendered as inline links. Clicking a linked path opens the Files browser modal at that path (including line/column targets when available) so you can inspect the file and use editor actions where supported.
<!-- FNXC:FileEditor 2026-07-12-00:00: Workspace file editing now auto-saves by default in the Files modal and right-dock Files view, with a shared persisted toolbar toggle so operators can return to manual Save/Discard behavior when needed. -->
Editable workspace text files auto-save after a short pause by default in both the Files modal and the right-dock Files view. Use the editor toolbar's **Auto-save** toggle to turn that shared preference off or on; when it is off, the existing **Save**, **Discard**, and Cmd/Ctrl+S manual flow applies.
Current surfaces include:
- Task detail modal content (description markdown, **Review** tab, and **Workflow Results** tab output plus workflow overview/graph/model settings)
- Chat view messages/tool output

View File

@@ -5,6 +5,7 @@ import type { PluginDashboardViewContext } from "../plugins/types";
import { downloadFileUrl } from "../api";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
import { useAutoSavePreference } from "../hooks/useAutoSavePreference";
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
import { getFilePreviewKind, IMAGE_PREVIEW_EXTENSIONS, VIDEO_PREVIEW_EXTENSIONS, AUDIO_PREVIEW_EXTENSIONS, PDF_PREVIEW_EXTENSIONS } from "../utils/file-preview-kind";
import { FileBrowser } from "./FileBrowser";
@@ -76,6 +77,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
// FNXC:RightDockFiles 2026-06-22-23:30: initialize from the shared scoped-storage key so the expand pop-out opens the same file the dock is showing.
const [selectedFile, setSelectedFile] = useState<string | null>(() => getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const { autoSaveEnabled, toggleAutoSave } = useAutoSavePreference();
/*
FNXC:RightDockFiles 2026-06-22-23:30:
@@ -129,7 +131,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
error: contentError,
save,
hasChanges,
} = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile) && !isPreviewOnlyFile && !isReadOnlyBinaryFile, projectId);
} = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile) && !isPreviewOnlyFile && !isReadOnlyBinaryFile, projectId, autoSaveEnabled);
const handleBack = useCallback(() => selectFile(null), [selectFile]);
const handlePopOut = useCallback(() => {
@@ -264,6 +266,8 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
filePath={selectedFile}
showLineNumbers={showLineNumbers}
onToggleLineNumbers={handleToggleLineNumbers}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
toolbarExpanded
forceToolbarActionsVisible
/>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor";
import { useAutoSavePreference } from "../hooks/useAutoSavePreference";
import { useWorkspaces } from "../hooks/useWorkspaces";
import { downloadFileUrl } from "../api";
import { FileBrowser } from "./FileBrowser";
@@ -88,6 +89,7 @@ export function FileBrowserModal({
const [showLineNumbers, setShowLineNumbers] = useState(false);
const [toolbarActionsExpanded, setToolbarActionsExpanded] = useState(false);
const toolbarActionsId = useId();
const { autoSaveEnabled, toggleAutoSave } = useAutoSavePreference();
const {
entries,
@@ -100,6 +102,7 @@ export function FileBrowserModal({
const selectedPreviewKind = useMemo(() => getFilePreviewKind(selectedFile), [selectedFile]);
const isPreviewOnlyFile = selectedPreviewKind !== null;
const selectedIsBinaryFile = selectedFile ? isBinaryFile(selectedFile) : false;
const {
content,
@@ -111,7 +114,7 @@ export function FileBrowserModal({
save,
hasChanges,
mtime,
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, !isPreviewOnlyFile, projectId);
} = useWorkspaceFileEditor(currentWorkspace, selectedFile, !isPreviewOnlyFile, projectId, autoSaveEnabled && !selectedIsBinaryFile);
useEffect(() => {
setCurrentWorkspace(initialWorkspace);
@@ -341,7 +344,7 @@ export function FileBrowserModal({
}, [currentWorkspace, workspaces, t]);
const modalTitle = t("fileBrowser.modalTitle", "Files — {{workspace}}", { workspace: workspaceLabel });
const isNarrowEditorView = Boolean(isMobile && selectedFile && mobileView === "editor" && !isBinaryFile(selectedFile));
const isNarrowEditorView = Boolean(isMobile && selectedFile && mobileView === "editor" && !selectedIsBinaryFile);
/*
FNXC:FileBrowser 2026-06-25-00:00:
@@ -455,7 +458,7 @@ export function FileBrowserModal({
<span>{t("actions.back", "Back")}</span>
</button>
)}
{!isBinaryFile(selectedFile) && !isNarrowEditorView && (
{!selectedIsBinaryFile && !isNarrowEditorView && (
<button
className="btn btn-sm btn-icon file-editor-toolbar-button"
onClick={() => setToolbarActionsExpanded((prev) => !prev)}
@@ -473,7 +476,7 @@ export function FileBrowserModal({
<FileType size={12} />
{selectedPreviewLabel}
</span>
) : isBinaryFile(selectedFile) ? (
) : selectedIsBinaryFile ? (
<span className="file-browser-binary-indicator">
<FileType size={12} />
{t("fileBrowser.binaryReadOnly", "Binary file — read only")}
@@ -555,10 +558,13 @@ export function FileBrowserModal({
content={content}
onChange={setContent}
filePath={selectedFile}
readOnly={isBinaryFile(selectedFile)}
showLineNumbers={showLineNumbers && !isBinaryFile(selectedFile)}
readOnly={selectedIsBinaryFile}
showLineNumbers={showLineNumbers && !selectedIsBinaryFile}
onToggleLineNumbers={handleToggleLineNumbers}
canToggleLineNumbers={!isBinaryFile(selectedFile)}
canToggleLineNumbers={!selectedIsBinaryFile}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
canToggleAutoSave={!selectedIsBinaryFile}
toolbarExpanded={isNarrowEditorView ? true : toolbarActionsExpanded}
forceToolbarActionsVisible={isNarrowEditorView}
toolbarActionsId={toolbarActionsId}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef, useId, useEffect } from "react"
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react";
import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp, Save } from "lucide-react";
import { EditorView, lineNumbers } from "@codemirror/view";
import { EditorState, Compartment, type Extension } from "@codemirror/state";
import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
@@ -19,6 +19,9 @@ interface FileEditorProps {
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
canToggleLineNumbers?: boolean;
autoSaveEnabled?: boolean;
onToggleAutoSave?: () => void;
canToggleAutoSave?: boolean;
toolbarExpanded?: boolean;
forceToolbarActionsVisible?: boolean;
toolbarActionsId?: string;
@@ -69,6 +72,9 @@ export function FileEditor({
showLineNumbers = false,
onToggleLineNumbers,
canToggleLineNumbers = true,
autoSaveEnabled = false,
onToggleAutoSave,
canToggleAutoSave = true,
toolbarExpanded,
forceToolbarActionsVisible = false,
toolbarActionsId: externalToolbarActionsId,
@@ -114,7 +120,8 @@ export function FileEditor({
const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview);
const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview;
const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview;
const hasToolbarActions = isMarkdown || !readOnly || shouldShowLineNumbersToggle;
const shouldShowAutoSaveToggle = Boolean(onToggleAutoSave) && canToggleAutoSave && !readOnly && !effectiveShowPreview;
const hasToolbarActions = isMarkdown || !readOnly || shouldShowLineNumbersToggle || shouldShowAutoSaveToggle;
const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]);
const handleEditClick = useCallback(() => setShowPreview(false), []);
@@ -312,6 +319,12 @@ export function FileEditor({
</button>
</>
) : null}
{shouldShowAutoSaveToggle && (
<button className={`btn btn-sm file-editor-toolbar-button ${autoSaveEnabled ? "btn-primary" : ""}`} onClick={onToggleAutoSave} aria-label={t("fileEditor.toggleAutoSave", "Toggle auto-save")} aria-pressed={autoSaveEnabled} title={t("fileEditor.toggleAutoSave", "Toggle auto-save")} data-testid="file-editor-auto-save-toggle">
<Save size={14} />
<span>{t("fileEditor.autoSave", "Auto-save")}</span>
</button>
)}
{shouldShowLineNumbersToggle && (
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label={t("fileEditor.toggleLineNumbers", "Toggle line numbers")} aria-pressed={showLineNumbers} title={t("fileEditor.toggleLineNumbers", "Toggle line numbers")}>
<ListOrdered size={14} />

View File

@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { DockFilesView } from "../DockFilesView";
import { FILE_EDITOR_AUTO_SAVE_STORAGE_KEY } from "../../hooks/useAutoSavePreference";
import { getScopedItem, scopedKey } from "../../utils/projectStorage";
import type { FileNode } from "../../api";
@@ -64,13 +65,14 @@ const capturedEditorHookCalls: Array<{
filePath: string | null;
enabled: boolean;
projectId?: string;
autoSave?: boolean;
}> = [];
const mockSetContent = vi.fn();
const mockSave = vi.fn(() => Promise.resolve());
vi.mock("../../hooks/useWorkspaceFileEditor", () => ({
useWorkspaceFileEditor: (workspace: string, filePath: string | null, enabled: boolean, projectId?: string) => {
capturedEditorHookCalls.push({ workspace, filePath, enabled, projectId });
useWorkspaceFileEditor: (workspace: string, filePath: string | null, enabled: boolean, projectId?: string, autoSave?: boolean) => {
capturedEditorHookCalls.push({ workspace, filePath, enabled, projectId, autoSave });
const hasChanges = filePath === "changed.txt";
return {
content: filePath ? `content for ${filePath}` : "",
@@ -93,6 +95,8 @@ const capturedFileEditorProps: Array<{
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
readOnly?: boolean;
autoSaveEnabled?: boolean;
onToggleAutoSave?: () => void;
}> = [];
// Keep the viewer simple: surface the file path it was asked to render and capture toolbar props.
@@ -104,9 +108,19 @@ vi.mock("../FileEditor", () => ({
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
readOnly?: boolean;
autoSaveEnabled?: boolean;
onToggleAutoSave?: () => void;
}) => {
capturedFileEditorProps.push(props);
return <div data-testid="mock-file-editor" data-file-path={props.filePath} />;
return (
<div data-testid="mock-file-editor" data-file-path={props.filePath}>
{props.onToggleAutoSave ? (
<button type="button" data-testid="file-editor-auto-save-toggle" aria-pressed={props.autoSaveEnabled ? "true" : "false"} onClick={props.onToggleAutoSave}>
Auto-save
</button>
) : null}
</div>
);
},
}));
@@ -234,6 +248,23 @@ describe("DockFilesView shared current-file state", () => {
expect(screen.getByTestId("right-dock-files-save")).toBeDisabled();
});
it.each(["auto", "two-pane"] as const)("wires the auto-save toggle in %s layout and persists the preference", async (layout) => {
render(<DockFilesView projectId={PROJECT_ID} layout={layout} />);
fireEvent.click(screen.getByText("readme.md"));
await waitFor(() => expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md"));
const toggle = screen.getByTestId("file-editor-auto-save-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(capturedFileEditorProps.at(-1)).toMatchObject({ autoSaveEnabled: true, onToggleAutoSave: expect.any(Function) });
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "readme.md", enabled: true, projectId: PROJECT_ID, autoSave: true });
fireEvent.click(toggle);
await waitFor(() => expect(window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY)).toBe("false"));
await waitFor(() => expect(screen.getByTestId("file-editor-auto-save-toggle")).toHaveAttribute("aria-pressed", "false"));
expect(capturedEditorHookCalls.at(-1)).toMatchObject({ workspace: "project", filePath: "readme.md", enabled: true, projectId: PROJECT_ID, autoSave: false });
});
it.each([
{
layout: "auto" as const,

View File

@@ -186,7 +186,67 @@ describe("FileBrowserModal", () => {
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
});
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined, true);
});
it("shows the auto-save toggle for desktop text editor files", async () => {
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent.click(screen.getByText("file1.ts"));
await waitFor(() => expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
expect(screen.getByTestId("file-editor-auto-save-toggle")).toHaveAttribute("aria-pressed", "true");
});
it("shows the auto-save toggle in the narrow mobile editor view", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 375,
});
window.dispatchEvent(new Event("resize"));
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent.click(screen.getByText("file1.ts"));
await waitFor(() => {
expect(document.querySelector(".file-browser-modal--narrow")).toBeInTheDocument();
expect(screen.getByTestId("file-editor-auto-save-toggle")).toHaveAttribute("aria-pressed", "true");
});
});
it("does not show the auto-save toggle for preview-only files", async () => {
mockUseWorkspaceFileBrowser.mockReturnValue({
...defaultBrowserState,
entries: [{ name: "preview.png", type: "file", size: 1024, mtime: "2024-01-01" }],
});
render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
fireEvent.click(screen.getByText("preview.png"));
await waitFor(() => expect(document.querySelector("img.file-browser-preview-media--image")).toBeInTheDocument());
expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "preview.png", false, undefined, false);
});
it("sends selected code text from the embedded editor to a new task description", async () => {
@@ -262,7 +322,7 @@ describe("FileBrowserModal", () => {
});
expect(mockSetPath).toHaveBeenCalledWith("packages/dashboard/app");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "packages/dashboard/app/App.tsx", true, undefined, true);
});
it("opens root-level absolute initial files at filesystem root", async () => {
@@ -280,7 +340,7 @@ describe("FileBrowserModal", () => {
});
expect(mockSetPath).toHaveBeenCalledWith("/");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "/README.md", true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "/README.md", true, undefined, true);
});
it("switches workspace and notifies parent", async () => {
@@ -326,7 +386,7 @@ describe("FileBrowserModal", () => {
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined, true);
});
expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument();
expect(screen.getByText("Could not load App.tsx")).toBeInTheDocument();
@@ -377,7 +437,7 @@ describe("FileBrowserModal", () => {
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined, true);
});
expect(screen.queryByText("Could not load App.tsx")).not.toBeInTheDocument();
expect(document.querySelector(".cm-content")?.textContent).toContain("console.log('loaded from task worktree');");
@@ -416,7 +476,7 @@ describe("FileBrowserModal", () => {
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", nestedFile, true, undefined, true);
});
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
expect(screen.getByLabelText(`Editor for ${nestedFile}`)).toBeInTheDocument();
@@ -445,7 +505,7 @@ describe("FileBrowserModal", () => {
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
await waitFor(() => {
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", null, true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-002", null, true, undefined, true);
});
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
expect(screen.queryByLabelText(/Editor for /)).not.toBeInTheDocument();
@@ -1065,7 +1125,7 @@ describe("FileBrowserModal", () => {
expect(screen.queryByRole("button", { name: /Save/ })).not.toBeInTheDocument();
expect(document.querySelector(".file-editor-wrapper")).not.toBeInTheDocument();
expect(document.querySelector(".file-browser-footer")).not.toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", name, false, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", name, false, undefined, false);
});
it("renders task-workspace preview URLs with project scoping", async () => {
@@ -1084,7 +1144,7 @@ describe("FileBrowserModal", () => {
expect(video).toHaveAttribute("src", expect.stringContaining("projectId=proj-1"));
expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(video).toHaveAttribute("src", expect.stringContaining("movie.mov"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("FN-001", "movie.mov", false, "proj-1", false);
});
it("previews uppercase and nested PDF paths without loading editor content", async () => {
@@ -1103,7 +1163,7 @@ describe("FileBrowserModal", () => {
expect(pdf).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(pdf).toHaveAttribute("title", "Preview for docs/MANUAL.PDF");
expect(mockSetPath).toHaveBeenCalledWith("docs");
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "docs/MANUAL.PDF", false, undefined, false);
});
it("keeps text files editable with save and discard controls", async () => {
@@ -1121,7 +1181,7 @@ describe("FileBrowserModal", () => {
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Discard/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Save/ })).toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined, true);
});
it("keeps unknown binary files in the read-only editor fallback", async () => {
@@ -1134,7 +1194,7 @@ describe("FileBrowserModal", () => {
expect(screen.getByText(/Binary file — read only/)).toBeInTheDocument();
expect(screen.getByLabelText("Editor for archive.zip")).toBeInTheDocument();
expect(document.querySelector(".file-browser-preview")).not.toBeInTheDocument();
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "archive.zip", true, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "archive.zip", true, undefined, false);
});
it("keeps the no-selected-file placeholder until a previewable file is selected", async () => {
@@ -1166,7 +1226,7 @@ describe("FileBrowserModal", () => {
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute("src", expect.stringContaining("clip.mp4"));
expect(video).toHaveAttribute("src", expect.stringContaining("inline=1"));
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined);
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "clip.mp4", false, undefined, false);
});
it("renders preview-only files in the mobile editor pane with back navigation", async () => {

View File

@@ -666,6 +666,62 @@ describe("FileEditor", () => {
});
});
describe("auto-save toggle", () => {
it.each([
["markdown", "notes.md", "# Heading\n\nBody"],
["non-markdown", "src/app.ts", "const value = 1;"],
])("renders for editable %s files when toggle support is provided", (_label, filePath, content) => {
const onToggleAutoSave = vi.fn();
render(
<FileEditor
content={content}
onChange={vi.fn()}
filePath={filePath}
autoSaveEnabled
onToggleAutoSave={onToggleAutoSave}
/>,
);
expandEditorOptions();
const toggle = screen.getByTestId("file-editor-auto-save-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(toggle).toHaveAttribute("title", "Toggle auto-save");
fireEvent.click(toggle);
expect(onToggleAutoSave).toHaveBeenCalledTimes(1);
});
it("reflects the disabled state", () => {
render(<FileEditor content="text" onChange={vi.fn()} filePath="notes.txt" autoSaveEnabled={false} onToggleAutoSave={vi.fn()} />);
expandEditorOptions();
const toggle = screen.getByTestId("file-editor-auto-save-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(toggle.classList.contains("btn-primary")).toBe(false);
});
it("is absent when the host does not wire auto-save", () => {
render(<FileEditor content="text" onChange={vi.fn()} filePath="notes.txt" />);
expandEditorOptions();
expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument();
});
it("is absent for readOnly files", () => {
render(<FileEditor content="text" onChange={vi.fn()} filePath="notes.txt" readOnly autoSaveEnabled onToggleAutoSave={vi.fn()} />);
expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument();
});
it("is absent in markdown Preview mode", () => {
render(<FileEditor content="# Heading" onChange={vi.fn()} filePath="notes.md" autoSaveEnabled onToggleAutoSave={vi.fn()} />);
expandEditorOptions();
expect(screen.getByTestId("file-editor-auto-save-toggle")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /preview mode/i }));
expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument();
});
});
describe("editor toolbar options collapse", () => {
it("hides edit, preview, line numbers, and wrap while collapsed", () => {
render(<FileEditor content="# Hello" onChange={vi.fn()} filePath="readme.md" onToggleLineNumbers={vi.fn()} />);

View File

@@ -0,0 +1,66 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, useAutoSavePreference } from "../useAutoSavePreference";
describe("useAutoSavePreference", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
});
it("defaults to enabled when no preference is stored", () => {
const { result } = renderHook(() => useAutoSavePreference());
expect(result.current.autoSaveEnabled).toBe(true);
});
it("reads a stored false preference", () => {
window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, "false");
const { result } = renderHook(() => useAutoSavePreference());
expect(result.current.autoSaveEnabled).toBe(false);
});
it("toggles and persists the preference", () => {
const { result } = renderHook(() => useAutoSavePreference());
act(() => result.current.toggleAutoSave());
expect(result.current.autoSaveEnabled).toBe(false);
expect(window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY)).toBe("false");
});
it("sets and persists an explicit preference", () => {
const { result } = renderHook(() => useAutoSavePreference());
act(() => result.current.setAutoSaveEnabled(false));
expect(result.current.autoSaveEnabled).toBe(false);
expect(window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY)).toBe("false");
act(() => result.current.setAutoSaveEnabled(true));
expect(result.current.autoSaveEnabled).toBe(true);
expect(window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY)).toBe("true");
});
it("updates when another document changes storage", () => {
const { result } = renderHook(() => useAutoSavePreference());
act(() => {
window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, "false");
window.dispatchEvent(new StorageEvent("storage", { key: FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, newValue: "false" }));
});
expect(result.current.autoSaveEnabled).toBe(false);
});
it("syncs multiple same-window editor instances", () => {
const first = renderHook(() => useAutoSavePreference());
const second = renderHook(() => useAutoSavePreference());
act(() => first.result.current.setAutoSaveEnabled(false));
expect(first.result.current.autoSaveEnabled).toBe(false);
expect(second.result.current.autoSaveEnabled).toBe(false);
});
});

View File

@@ -19,6 +19,7 @@ describe("useWorkspaceFileEditor", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -71,6 +72,113 @@ describe("useWorkspaceFileEditor", () => {
expect(result.current.mtime).toBe("2024-01-02T00:00:00Z");
});
it("auto-saves changed content once after the debounce", async () => {
mockFetchWorkspaceFileContent.mockResolvedValueOnce({ content: "original", mtime: "2024-01-01T00:00:00Z", size: 8 });
mockSaveWorkspaceFileContent.mockResolvedValueOnce({ success: true, mtime: "2024-01-02T00:00:00Z", size: 7 });
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true, undefined, true));
await waitFor(() => expect(result.current.loading).toBe(false));
vi.useFakeTimers();
act(() => result.current.setContent("changed"));
act(() => vi.advanceTimersByTime(799));
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledTimes(1);
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledWith("project", "README.md", "changed", undefined);
vi.useRealTimers();
await waitFor(() => expect(result.current.hasChanges).toBe(false));
});
it("does not auto-save when auto-save is disabled", async () => {
mockFetchWorkspaceFileContent.mockResolvedValueOnce({ content: "original", mtime: "2024-01-01T00:00:00Z", size: 8 });
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true, undefined, false));
await waitFor(() => expect(result.current.loading).toBe(false));
vi.useFakeTimers();
act(() => result.current.setContent("changed"));
act(() => vi.advanceTimersByTime(800));
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
});
it("does not auto-save during or immediately after load", async () => {
mockFetchWorkspaceFileContent.mockResolvedValueOnce({ content: "loaded", mtime: "2024-01-01T00:00:00Z", size: 6 });
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true, undefined, true));
await waitFor(() => expect(result.current.loading).toBe(false));
vi.useFakeTimers();
act(() => vi.advanceTimersByTime(800));
expect(result.current.content).toBe("loaded");
expect(result.current.hasChanges).toBe(false);
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
});
it("cancels a pending auto-save when the selected file changes", async () => {
mockFetchWorkspaceFileContent
.mockResolvedValueOnce({ content: "first", mtime: "2024-01-01T00:00:00Z", size: 5 })
.mockResolvedValueOnce({ content: "second", mtime: "2024-01-01T00:00:00Z", size: 6 });
const { result, rerender } = renderHook(
({ filePath }) => useWorkspaceFileEditor("project", filePath, true, undefined, true),
{ initialProps: { filePath: "first.md" as string | null } },
);
await waitFor(() => expect(result.current.content).toBe("first"));
vi.useFakeTimers();
act(() => result.current.setContent("first changed"));
rerender({ filePath: "second.md" });
act(() => vi.advanceTimersByTime(800));
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
vi.useRealTimers();
await waitFor(() => expect(result.current.content).toBe("second"));
});
it("cancels a pending auto-save on unmount", async () => {
mockFetchWorkspaceFileContent.mockResolvedValueOnce({ content: "original", mtime: "2024-01-01T00:00:00Z", size: 8 });
const { result, unmount } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true, undefined, true));
await waitFor(() => expect(result.current.loading).toBe(false));
vi.useFakeTimers();
act(() => result.current.setContent("changed"));
unmount();
act(() => vi.advanceTimersByTime(800));
expect(mockSaveWorkspaceFileContent).not.toHaveBeenCalled();
});
it("surfaces an auto-save error without retrying the same content", async () => {
mockFetchWorkspaceFileContent.mockResolvedValueOnce({ content: "original", mtime: "2024-01-01T00:00:00Z", size: 8 });
mockSaveWorkspaceFileContent.mockRejectedValueOnce(new Error("disk full"));
const { result } = renderHook(() => useWorkspaceFileEditor("project", "README.md", true, undefined, true));
await waitFor(() => expect(result.current.loading).toBe(false));
vi.useFakeTimers();
act(() => result.current.setContent("changed"));
await act(async () => {
vi.advanceTimersByTime(800);
await Promise.resolve();
});
vi.useRealTimers();
await waitFor(() => expect(result.current.error).toBe("disk full"));
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledTimes(1);
vi.useFakeTimers();
act(() => vi.advanceTimersByTime(1600));
expect(mockSaveWorkspaceFileContent).toHaveBeenCalledTimes(1);
});
it("resets state when disabled or file is cleared", async () => {
const response: FileContentResponse = {
content: "hello",

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from "react";
export const FILE_EDITOR_AUTO_SAVE_STORAGE_KEY = "fn-file-editor-auto-save";
const FILE_EDITOR_AUTO_SAVE_CHANGED_EVENT = "fn:file-editor-auto-save-changed";
function readBooleanPref(key: string, defaultValue: boolean): boolean {
if (typeof window === "undefined") return defaultValue;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "true";
} catch {
return defaultValue;
}
}
function writeBooleanPref(key: string, value: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// Ignore storage failures (quota, private mode, etc.).
}
}
export interface UseAutoSavePreferenceReturn {
autoSaveEnabled: boolean;
toggleAutoSave: () => void;
setAutoSaveEnabled: (value: boolean) => void;
}
/*
FNXC:FileEditor 2026-07-12-00:00:
Workspace file-editor auto-save defaults ON and is toggled from the shared toolbar. Persist one preference key for every workspace editor surface, and broadcast same-window changes because the native storage event only reaches other documents.
*/
export function useAutoSavePreference(): UseAutoSavePreferenceReturn {
const [autoSaveEnabled, setAutoSaveEnabledState] = useState(() => readBooleanPref(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, true));
const setAutoSaveEnabled = useCallback((value: boolean) => {
setAutoSaveEnabledState(value);
writeBooleanPref(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, value);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_EDITOR_AUTO_SAVE_CHANGED_EVENT, { detail: value }));
}
}, []);
const toggleAutoSave = useCallback(() => {
setAutoSaveEnabledState((current) => {
const next = !current;
writeBooleanPref(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, next);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(FILE_EDITOR_AUTO_SAVE_CHANGED_EVENT, { detail: next }));
}
return next;
});
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const syncFromStorage = (event: StorageEvent) => {
if (event.key !== FILE_EDITOR_AUTO_SAVE_STORAGE_KEY) return;
setAutoSaveEnabledState(readBooleanPref(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, true));
};
const syncFromLocalEvent = (event: Event) => {
const nextValue = (event as CustomEvent<boolean>).detail;
setAutoSaveEnabledState(typeof nextValue === "boolean" ? nextValue : readBooleanPref(FILE_EDITOR_AUTO_SAVE_STORAGE_KEY, true));
};
window.addEventListener("storage", syncFromStorage);
window.addEventListener(FILE_EDITOR_AUTO_SAVE_CHANGED_EVENT, syncFromLocalEvent);
return () => {
window.removeEventListener("storage", syncFromStorage);
window.removeEventListener(FILE_EDITOR_AUTO_SAVE_CHANGED_EVENT, syncFromLocalEvent);
};
}, []);
return { autoSaveEnabled, toggleAutoSave, setAutoSaveEnabled };
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { getErrorMessage } from "@fusion/core";
import type { FileContentResponse, SaveFileResponse } from "../api";
import { fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
export const AUTO_SAVE_DEBOUNCE_MS = 800;
interface UseWorkspaceFileEditorReturn {
content: string;
setContent: (content: string) => void;
@@ -29,6 +31,7 @@ export function useWorkspaceFileEditor(
filePath: string | null,
enabled: boolean,
projectId?: string,
autoSave = false,
): UseWorkspaceFileEditorReturn {
const { t } = useTranslation("app");
const [content, setContentState] = useState<string>("");
@@ -37,6 +40,8 @@ export function useWorkspaceFileEditor(
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastAutoSaveAttemptRef = useRef<string | null>(null);
const setContent = useCallback((newContent: string) => {
setContentState(newContent);
@@ -65,6 +70,7 @@ export function useWorkspaceFileEditor(
setContentState(response.content);
setOriginalContent(response.content);
setMtime(response.mtime);
lastAutoSaveAttemptRef.current = null;
}
} catch (err) {
if (!cancelled) {
@@ -72,6 +78,7 @@ export function useWorkspaceFileEditor(
setContentState("");
setOriginalContent("");
setMtime(null);
lastAutoSaveAttemptRef.current = null;
}
} finally {
if (!cancelled) {
@@ -84,6 +91,11 @@ export function useWorkspaceFileEditor(
return () => {
cancelled = true;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
lastAutoSaveAttemptRef.current = null;
};
}, [workspace, filePath, enabled, projectId]);
@@ -109,6 +121,39 @@ export function useWorkspaceFileEditor(
}
}, [workspace, filePath, content, hasChanges, projectId]);
/*
FNXC:FileEditor 2026-07-12-00:00:
Debounced workspace auto-save may only run for a loaded editable file with real user changes. Clear the pending timer whenever the workspace/file/effective content changes, key attempts by workspace+file+content so a failed write surfaces once without spinning, and rely on hasChanges becoming false after save to prevent originalContent/mtime updates from scheduling a loop.
*/
useEffect(() => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
if (!autoSave || !enabled || !workspace || !filePath || !hasChanges || saving || loading) {
return;
}
const attemptKey = JSON.stringify([workspace, filePath, projectId ?? "", content]);
if (lastAutoSaveAttemptRef.current === attemptKey) {
return;
}
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveTimeoutRef.current = null;
lastAutoSaveAttemptRef.current = attemptKey;
void save().catch(() => undefined);
}, AUTO_SAVE_DEBOUNCE_MS);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
};
}, [autoSave, enabled, workspace, filePath, projectId, content, hasChanges, saving, loading, save]);
return {
content,
setContent,

View File

@@ -2456,6 +2456,7 @@
"previewTitle": "Preview for {{file}}"
},
"fileEditor": {
"autoSave": "Auto-save",
"edit": "Edit",
"editMode": "Edit mode",
"editorFor": "Editor for {{filePath}}",
@@ -2463,6 +2464,7 @@
"lineNumber": "Line #",
"preview": "Preview",
"previewMode": "Preview mode",
"toggleAutoSave": "Toggle auto-save",
"toggleLineNumbers": "Toggle line numbers",
"toggleOptions": "Toggle editor options",
"toggleWordWrap": "Toggle word wrap",