Files
fusion/packages/dashboard/app/hooks/useWorkspaceFileEditor.ts
gsxdsm 06ec0e606e 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>
2026-07-12 12:49:37 -07:00

169 lines
5.1 KiB
TypeScript

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;
originalContent: string;
loading: boolean;
saving: boolean;
error: string | null;
save: () => Promise<void>;
hasChanges: boolean;
mtime: string | null;
}
/**
* Hook for editing a file in a selected workspace.
*
* @param workspace - The workspace identifier ("project" or task ID)
* @param filePath - The selected file path
* @param enabled - Whether loading is enabled
* @param projectId - Optional project ID for multi-project scoping
*/
export function useWorkspaceFileEditor(
workspace: string,
filePath: string | null,
enabled: boolean,
projectId?: string,
autoSave = false,
): UseWorkspaceFileEditorReturn {
const { t } = useTranslation("app");
const [content, setContentState] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [mtime, setMtime] = useState<string | null>(null);
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);
setError(null);
}, []);
useEffect(() => {
if (!enabled || !workspace || !filePath) {
setContentState("");
setOriginalContent("");
setMtime(null);
setError(null);
return;
}
let cancelled = false;
async function loadFile() {
setLoading(true);
setError(null);
try {
const response: FileContentResponse = await fetchWorkspaceFileContent(workspace, filePath!, projectId);
if (!cancelled) {
setContentState(response.content);
setOriginalContent(response.content);
setMtime(response.mtime);
lastAutoSaveAttemptRef.current = null;
}
} catch (err) {
if (!cancelled) {
setError(getErrorMessage(err) || t("editor.failedLoadFile", "Failed to load file"));
setContentState("");
setOriginalContent("");
setMtime(null);
lastAutoSaveAttemptRef.current = null;
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadFile();
return () => {
cancelled = true;
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
lastAutoSaveAttemptRef.current = null;
};
}, [workspace, filePath, enabled, projectId]);
const hasChanges = content !== originalContent;
const save = useCallback(async () => {
if (!workspace || !filePath || !hasChanges) {
return;
}
setSaving(true);
setError(null);
try {
const response: SaveFileResponse = await saveWorkspaceFileContent(workspace, filePath, content, projectId);
setOriginalContent(content);
setMtime(response.mtime);
} catch (err) {
setError(getErrorMessage(err) || t("editor.failedSaveFile", "Failed to save file"));
throw err;
} finally {
setSaving(false);
}
}, [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,
originalContent,
loading,
saving,
error,
save,
hasChanges,
mtime,
};
}