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..9e655c1502 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,8 @@ export class PreviewModel implements ViewModel { hideViewName: Atom; previewTextRef: React.RefObject; editMode: Atom; + autoSaveMode: Atom; + saveQueue: Promise = Promise.resolve(); canPreview: PrimitiveAtom; specializedView: Atom>; loadableSpecializedView: Atom>; @@ -222,6 +232,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) => { @@ -645,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; @@ -663,7 +686,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 +822,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"`