diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 54a12c77dd..419a549f9c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +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 diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx index 06af73cd93..5909bb0115 100644 --- a/packages/dashboard/app/components/DockFilesView.tsx +++ b/packages/dashboard/app/components/DockFilesView.tsx @@ -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(() => 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 /> diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx index e59200733a..6a76ffc65d 100644 --- a/packages/dashboard/app/components/FileBrowserModal.tsx +++ b/packages/dashboard/app/components/FileBrowserModal.tsx @@ -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({ {t("actions.back", "Back")} )} - {!isBinaryFile(selectedFile) && !isNarrowEditorView && ( + {!selectedIsBinaryFile && !isNarrowEditorView && ( ) : null} + {shouldShowAutoSaveToggle && ( + + )} {shouldShowLineNumbersToggle && ( + ) : null} + + ); }, })); @@ -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(); + 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, diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx index adb433b69c..5ff8443738 100644 --- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 () => { diff --git a/packages/dashboard/app/components/__tests__/FileEditor.test.tsx b/packages/dashboard/app/components/__tests__/FileEditor.test.tsx index fc68cb03e9..2bfbb8a543 100644 --- a/packages/dashboard/app/components/__tests__/FileEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/FileEditor.test.tsx @@ -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( + , + ); + + 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(); + + 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(); + + expandEditorOptions(); + expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument(); + }); + + it("is absent for readOnly files", () => { + render(); + + expect(screen.queryByTestId("file-editor-auto-save-toggle")).not.toBeInTheDocument(); + }); + + it("is absent in markdown Preview mode", () => { + render(); + + 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(); diff --git a/packages/dashboard/app/hooks/__tests__/useAutoSavePreference.test.ts b/packages/dashboard/app/hooks/__tests__/useAutoSavePreference.test.ts new file mode 100644 index 0000000000..6c362b999a --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useAutoSavePreference.test.ts @@ -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); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useWorkspaceFileEditor.test.ts b/packages/dashboard/app/hooks/__tests__/useWorkspaceFileEditor.test.ts index 9df4d0d0d0..c26e8047f8 100644 --- a/packages/dashboard/app/hooks/__tests__/useWorkspaceFileEditor.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useWorkspaceFileEditor.test.ts @@ -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", diff --git a/packages/dashboard/app/hooks/useAutoSavePreference.ts b/packages/dashboard/app/hooks/useAutoSavePreference.ts new file mode 100644 index 0000000000..05d3db19c4 --- /dev/null +++ b/packages/dashboard/app/hooks/useAutoSavePreference.ts @@ -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).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 }; +} diff --git a/packages/dashboard/app/hooks/useWorkspaceFileEditor.ts b/packages/dashboard/app/hooks/useWorkspaceFileEditor.ts index f1bb1d244d..7577ce11f0 100644 --- a/packages/dashboard/app/hooks/useWorkspaceFileEditor.ts +++ b/packages/dashboard/app/hooks/useWorkspaceFileEditor.ts @@ -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(""); @@ -37,6 +40,8 @@ export function useWorkspaceFileEditor( const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const autoSaveTimeoutRef = useRef | null>(null); + const lastAutoSaveAttemptRef = useRef(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, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 2909fe420f..7ed9fb966a 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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",