From e7da2bac113ac0ee29e5e57b3b825e41f533845a Mon Sep 17 00:00:00 2001 From: NGdev2 Date: Thu, 8 Oct 2026 02:15:21 +0200 Subject: [PATCH 1/2] Add per-block auto save option to the editor Adds an "Auto Save" submenu to the editor's settings menu (next to Word Wrap), stored as the block meta key editor:autosave: - Off (default) - After Delay: saves 1s after typing pauses; each edit restarts the timer - On Focus Change: saves when the block loses focus Read-only files are never auto-saved. Also makes handleFileSave only clear the unsaved state if nothing was typed while the write was in flight, so edits made during a save are not dropped. Refs #1080 Co-Authored-By: Claude Opus 5.5 --- frontend/app/view/preview/preview-edit.tsx | 26 ++++++++++++++++ frontend/app/view/preview/preview-model.tsx | 33 ++++++++++++++++++++- frontend/types/gotypes.d.ts | 1 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index 2961771fa3..b8b4c4ef7d 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -12,6 +12,8 @@ import * as monaco from "monaco-editor"; import { useEffect } from "react"; import type { SpecializedViewProps } from "./preview"; +const AutoSaveDelayMs = 1000; + export const shellFileMap: Record = { ".bashrc": "shell", ".bash_profile": "shell", @@ -44,6 +46,30 @@ function CodeEditPreview({ model }: SpecializedViewProps) { const baseName = fileName ? fileName.split("/").pop() : null; const language = baseName && shellFileMap[baseName] ? shellFileMap[baseName] : undefined; + const newFileContent = useAtomValue(model.newFileContent); + const autoSaveMode = useAtomValue(model.autoSaveMode); + const isFocused = useAtomValue(model.nodeModel.isFocused); + const readonly = fileInfo?.readonly ?? false; + + // "afterdelay": save once typing pauses; every edit changes newFileContent and restarts the timer + useEffect(() => { + if (autoSaveMode != "afterdelay" || newFileContent == null || readonly) { + return; + } + const timeoutId = setTimeout(() => fireAndForget(model.handleFileSave.bind(model)), AutoSaveDelayMs); + return () => clearTimeout(timeoutId); + }, [autoSaveMode, newFileContent, readonly]); + + // "onfocuschange": save when the block loses focus + useEffect(() => { + if (autoSaveMode != "onfocuschange" || isFocused || readonly) { + return; + } + if (globalStore.get(model.newFileContent) == null) { + return; + } + fireAndForget(model.handleFileSave.bind(model)); + }, [autoSaveMode, isFocused, readonly]); function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { if (checkKeyPressed(e, "Cmd:e")) { diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 8315e48b2a..a46107e5b2 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -34,6 +34,14 @@ const BOOKMARKS: { label: string; path: string }[] = [ const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB +export type AutoSaveMode = "off" | "afterdelay" | "onfocuschange"; + +const AutoSaveMenuOptions: { label: string; mode: AutoSaveMode }[] = [ + { label: "Off", mode: "off" }, + { label: "After Delay", mode: "afterdelay" }, + { label: "On Focus Change", mode: "onfocuschange" }, +]; + const textApplicationMimetypes = [ "application/sql", "application/x-php", @@ -131,6 +139,7 @@ export class PreviewModel implements ViewModel { hideViewName: Atom; previewTextRef: React.RefObject; editMode: Atom; + autoSaveMode: Atom; canPreview: PrimitiveAtom; specializedView: Atom>; loadableSpecializedView: Atom>; @@ -222,6 +231,10 @@ export class PreviewModel implements ViewModel { const blockData = get(this.blockAtom); return blockData?.meta?.edit ?? false; }); + this.autoSaveMode = atom((get) => { + const mode = get(this.blockAtom)?.meta?.["editor:autosave"]; + return AutoSaveMenuOptions.some((opt) => opt.mode == mode) ? (mode as AutoSaveMode) : "off"; + }); this.viewName = atom("Preview"); this.hideViewName = atom(true); this.viewText = atom((get) => { @@ -663,7 +676,10 @@ export class PreviewModel implements ViewModel { data64: stringToBase64(newFileContent), }); globalStore.set(this.fileContent, newFileContent); - globalStore.set(this.newFileContent, null); + // only clear if nothing was typed while the write was in flight (autosave can save mid-typing) + if (globalStore.get(this.newFileContent) === newFileContent) { + globalStore.set(this.newFileContent, null); + } console.log("saved file", filePath); } catch (e) { const errorStatus: ErrorMsg = { @@ -796,6 +812,21 @@ export class PreviewModel implements ViewModel { }); }), }); + const autoSaveMode = globalStore.get(this.autoSaveMode); + menuItems.push({ + label: "Auto Save", + submenu: AutoSaveMenuOptions.map((opt) => ({ + label: opt.label, + type: "checkbox", + checked: autoSaveMode == opt.mode, + click: () => { + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "editor:autosave": opt.mode == "off" ? null : opt.mode }, + }); + }, + })), + }); } if (loadableSV.state == "hasData" && loadableSV.data.specializedView == "directory") { menuItems.push({ type: "separator" }); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..85e295f072 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1149,6 +1149,7 @@ declare global { "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; + "editor:autosave"?: string; "editor:fontsize"?: number; "graph:*"?: boolean; "graph:numpoints"?: number; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0ce08099d8..fecee0aeb1 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -81,6 +81,7 @@ const ( MetaKey_EditorMinimapEnabled = "editor:minimapenabled" MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" MetaKey_EditorWordWrap = "editor:wordwrap" + MetaKey_EditorAutoSave = "editor:autosave" MetaKey_EditorFontSize = "editor:fontsize" MetaKey_GraphClear = "graph:*" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2280b55d2d..c21979f9ec 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -83,6 +83,7 @@ type MetaTSType struct { EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` EditorWordWrap bool `json:"editor:wordwrap,omitempty"` + EditorAutoSave string `json:"editor:autosave,omitempty"` EditorFontSize float64 `json:"editor:fontsize,omitempty"` GraphClear bool `json:"graph:*,omitempty"` From 030194faefb94ffd116107f7fb5cda8fa3576e81 Mon Sep 17 00:00:00 2001 From: NGdev2 Date: Thu, 8 Oct 2026 15:18:56 +0200 Subject: [PATCH 2/2] Serialize file saves so an older write can't overwrite a newer one Addresses review feedback: with autosave, a second save could start while the first was still writing. If the older write finished last it overwrote the newer content on disk with nothing left marked unsaved. handleFileSave now queues saves and runs them one at a time; each queued save reads the current content when it starts. A failed save doesn't block later ones. Co-Authored-By: Claude Opus 5.5 --- frontend/app/view/preview/preview-model.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index a46107e5b2..9e655c1502 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -140,6 +140,7 @@ export class PreviewModel implements ViewModel { previewTextRef: React.RefObject; editMode: Atom; autoSaveMode: Atom; + saveQueue: Promise = Promise.resolve(); canPreview: PrimitiveAtom; specializedView: Atom>; loadableSpecializedView: Atom>; @@ -658,7 +659,16 @@ export class PreviewModel implements ViewModel { await this.env.services.object.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); } - async handleFileSave() { + handleFileSave(): Promise { + // run saves one at a time; a queued save reads newFileContent when it starts, so an + // older write can never finish after (and overwrite) a newer one + this.saveQueue = this.saveQueue + .then(() => this.writeFileContent()) + .catch((e) => console.log("error saving file", e)); + return this.saveQueue; + } + + async writeFileContent() { const filePath = await globalStore.get(this.statFilePath); if (filePath == null) { return;