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>
169 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|