diff --git a/.changeset/fn-8755-file-editor-history.md b/.changeset/fn-8755-file-editor-history.md new file mode 100644 index 0000000000..37653d379f --- /dev/null +++ b/.changeset/fn-8755-file-editor-history.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add undo and redo controls to editable file editors. +category: feature +dev: Uses CodeMirror native history with controlled external-content baselines. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c8fae6e564..810c36a62b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1155,7 +1155,7 @@ The Files modal provides a workspace-aware file browser and editor. ## Memory View -Memory view provides a multi-file editor for project and daily memory files. Its file editors share the same highlighted-text **Add comment** affordance as the Files modal, so memory snippets can seed a New Task with file path, snippet, and comment context. +Memory view provides a multi-file editor for project and daily memory files. Its file editors share the same highlighted-text **Add comment** affordance as the Files modal, so memory snippets can seed a New Task with file path, snippet, and comment context. Editable file editors also provide localized **Undo** and **Redo** toolbar controls and the standard platform shortcuts: Ctrl/Cmd+Z to undo, plus Ctrl+Y or Cmd+Shift+Z to redo. > Available when the `experimentalFeatures.memoryView` toggle is enabled. diff --git a/packages/dashboard/app/components/FileEditor.tsx b/packages/dashboard/app/components/FileEditor.tsx index 4f2f764f54..76620c4c04 100644 --- a/packages/dashboard/app/components/FileEditor.tsx +++ b/packages/dashboard/app/components/FileEditor.tsx @@ -2,9 +2,10 @@ 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, Save } from "lucide-react"; -import { EditorView, lineNumbers } from "@codemirror/view"; +import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp, Save, Undo2, Redo2 } from "lucide-react"; +import { EditorView, lineNumbers, keymap } from "@codemirror/view"; import { EditorState, Compartment, type Extension } from "@codemirror/state"; +import { history, historyKeymap, redo, redoDepth, undo, undoDepth } from "@codemirror/commands"; import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language"; import { oneDark } from "@codemirror/theme-one-dark"; import { useSelectionComment } from "../hooks/useSelectionComment"; @@ -99,6 +100,7 @@ export function FileEditor({ const editorHostRef = useRef(null); const previewRef = useRef(null); const editorViewRef = useRef(null); + const createEditorStateRef = useRef<((doc: string, selection?: { anchor: number; head: number }) => EditorState) | null>(null); const syncingFromPropsRef = useRef(false); const localEditVersionRef = useRef(0); const contentEditVersionsRef = useRef>(new Map([[content, 0]])); @@ -111,6 +113,7 @@ export function FileEditor({ const readOnlyCompartmentRef = useRef(new Compartment()); const languageCompartmentRef = useRef(new Compartment()); const themeCompartmentRef = useRef(new Compartment()); + const [historyAvailability, setHistoryAvailability] = useState({ undo: false, redo: false }); const isMarkdown = isMarkdownFile(filePath); const generatedToolbarActionsId = useId(); @@ -121,8 +124,11 @@ export function FileEditor({ const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview; const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview; const shouldShowAutoSaveToggle = Boolean(onToggleAutoSave) && canToggleAutoSave && !readOnly && !effectiveShowPreview; + const shouldShowHistoryControls = !readOnly && !effectiveShowPreview; const hasToolbarActions = isMarkdown || !readOnly || shouldShowLineNumbersToggle || shouldShowAutoSaveToggle; const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]); + const editorStateConfigRef = useRef({ shouldRenderLineNumbers, wordWrap, readOnly, languageExtension, darkThemeActive }); + editorStateConfigRef.current = { shouldRenderLineNumbers, wordWrap, readOnly, languageExtension, darkThemeActive }; const handleEditClick = useCallback(() => setShowPreview(false), []); const handlePreviewClick = useCallback(() => setShowPreview(true), []); @@ -132,6 +138,11 @@ export function FileEditor({ setInternalExpanded((prev) => !prev); } }, [isControlled]); + const handleHistoryCommand = useCallback((command: typeof undo | typeof redo) => { + const view = editorViewRef.current; + if (!view || !command(view)) return; + view.focus(); + }, []); const [selectionCommentOpen, setSelectionCommentOpen] = useState(false); const getCodeMirrorLineRange = useCallback(() => { @@ -175,28 +186,39 @@ export function FileEditor({ "&.cm-focused": { outline: "none" }, }); - const state = EditorState.create({ - doc: content, - extensions: [ - lineNumbersCompartmentRef.current.of(shouldRenderLineNumbers ? lineNumbers() : []), - wordWrapCompartmentRef.current.of(wordWrap ? EditorView.lineWrapping : []), - readOnlyCompartmentRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []), - languageCompartmentRef.current.of(languageExtension ?? []), - themeCompartmentRef.current.of(buildThemeExtension(darkThemeActive)), - themeOverlay, - EditorView.updateListener.of((update) => { - if (!update.docChanged || syncingFromPropsRef.current) return; - const nextContent = update.state.doc.toString(); - localEditVersionRef.current += 1; - contentEditVersionsRef.current.set(nextContent, localEditVersionRef.current); - onChangeRef.current(nextContent); - }), - ], - }); + const createEditorState = (doc: string, selection?: { anchor: number; head: number }) => { + const { shouldRenderLineNumbers, wordWrap, readOnly, languageExtension, darkThemeActive } = editorStateConfigRef.current; + return EditorState.create({ + doc, + selection, + extensions: [ + history(), + keymap.of(historyKeymap), + lineNumbersCompartmentRef.current.of(shouldRenderLineNumbers ? lineNumbers() : []), + wordWrapCompartmentRef.current.of(wordWrap ? EditorView.lineWrapping : []), + readOnlyCompartmentRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []), + languageCompartmentRef.current.of(languageExtension ?? []), + themeCompartmentRef.current.of(buildThemeExtension(darkThemeActive)), + themeOverlay, + EditorView.updateListener.of((update) => { + if (update.docChanged || update.transactions.some((transaction) => transaction.effects.length > 0)) { + setHistoryAvailability({ undo: undoDepth(update.state) > 0, redo: redoDepth(update.state) > 0 }); + } + if (!update.docChanged || syncingFromPropsRef.current) return; + const nextContent = update.state.doc.toString(); + localEditVersionRef.current += 1; + contentEditVersionsRef.current.set(nextContent, localEditVersionRef.current); + onChangeRef.current(nextContent); + }), + ], + }); + }; - const view = new EditorView({ state, parent: editorHostRef.current }); + createEditorStateRef.current = createEditorState; + const view = new EditorView({ state: createEditorState(content), parent: editorHostRef.current }); editorViewRef.current = view; return () => { + createEditorStateRef.current = null; editorViewRef.current = null; view.destroy(); }; @@ -255,6 +277,14 @@ export function FileEditor({ }); }, [darkThemeActive]); + /* + * FNXC:FileEditor 2026-08-03-06:15: + * Editable editors expose native CodeMirror undo/redo through localized controls and platform shortcuts, while read-only and markdown preview presentations expose no editing affordances. A stale controlled self-echo preserves its local history, but an accepted external replacement resets the native editor state so it becomes a non-undoable baseline and cannot restore stale content. + * + * FNXC:FileEditor 2026-08-03-06:41: + * External content and read-only mode may change in the same render. Build replacement states from the current render configuration rather than the initial editor mount so the reset never reverses the caller's editability choice. + */ + /* * FNXC:FileViewer 2026-07-10-22:52: * FN-7810 found that the bounded self-echo Set could evict a stale value during long sessions, and it could miss end-of-file Enter flows where a trailing header newline raced an older prop. Use monotonic edit versions for every local CodeMirror emission instead: any prop whose known version is older than the live editor or last accepted prop is a stale self-echo at any session length, while never-emitted external reload/save-normalization content still replaces the document with the caret clamped into range. @@ -280,13 +310,13 @@ export function FileEditor({ const clampPosition = (position: number) => Math.max(0, Math.min(position, nextLength)); syncingFromPropsRef.current = true; try { - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: content }, - selection: { - anchor: clampPosition(previousSelection.anchor), - head: clampPosition(previousSelection.head), - }, - }); + const createEditorState = createEditorStateRef.current; + if (!createEditorState) return; + view.setState(createEditorState(content, { + anchor: clampPosition(previousSelection.anchor), + head: clampPosition(previousSelection.head), + })); + setHistoryAvailability({ undo: false, redo: false }); const externalContentVersion = localEditVersionRef.current; contentEditVersionsRef.current.set(content, externalContentVersion); lastPropEditVersionRef.current = Math.max(lastPropEditVersionRef.current, externalContentVersion); @@ -319,6 +349,16 @@ export function FileEditor({ ) : null} + {shouldShowHistoryControls && ( + <> + + + + )} {shouldShowAutoSaveToggle && (