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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
79
packages/dashboard/app/hooks/useAutoSavePreference.ts
Normal file
79
packages/dashboard/app/hooks/useAutoSavePreference.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user