From 03c236ea2fda2ef3623e207635abac38aeca7c7e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 9 Jun 2026 12:41:41 +0200 Subject: [PATCH] feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each builder already had a Ctrl/Cmd+S keybinding routed through a saveDraft() no-op left over from the LS-era — the comment said "persistence happens via the page-level UserDraft autosave" but the shortcut was the user's only way to actually force a save without waiting for the 1.5s debounce. Restore the intent. * UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method that re-submits whatever's queued in pendingSaveOpts with immediate: true. No-op when nothing's pending. * Editor.svelte.flushPendingChanges() — exposes a synchronous updateCode() with chain reset, so callers can drain Monaco's own trailing debounce before asking the syncer to flush. Without this step a Ctrl+S within ~500ms of typing would POST the pre-burst content. * ScriptBuilder.saveDraft() — editor?.flushPendingChanges() → await tick() → UserDraftDbSyncer.flush(). Toast on result. * FlowBuilder.saveDraft() — no direct Monaco ref (flows have many per-module editors); just flushes the syncer. Editor.svelte's new 1s max-wait cap means at most the last <1s of typing in a module Monaco won't be in this POST; it follows in the next autosave round. * RawAppEditor.handleKeydown — adds a 's' case that flushes before the focus guard, so the shortcut fires regardless of where focus is in the editor pane. --- .../lib/components/AutosaveIndicator.svelte | 2 +- frontend/src/lib/components/Editor.svelte | 16 +++++++++++ .../src/lib/components/FlowBuilder.svelte | 24 ++++++++++++++--- .../src/lib/components/ScriptBuilder.svelte | 27 ++++++++++++++++--- .../components/raw_apps/RawAppEditor.svelte | 20 ++++++++++++++ frontend/src/lib/userDraftDbSyncer.svelte.ts | 24 +++++++++++++++++ 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 9583075d77..42608781fd 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -120,7 +120,7 @@ {#snippet content()}
-

+

All changes are saved as a draft on the server. The draft is per-user — your teammates' editors keep their own.

diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 6e409b3365..346a85e6a6 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -428,6 +428,22 @@ dispatch('change', ncode) } + /** Force-materialize the latest Monaco content into the bindable + * `code` prop right now, bypassing the trailing debounce. Use for + * explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything + * the user typed within the last `changeTimeout` ms is still sitting + * in Monaco's buffer and downstream consumers (autosave, lint) won't + * see it. Clears the chain state so the next keystroke after this + * flush is a fresh leading fire. */ + export function flushPendingChanges(): void { + if (timeoutModel !== undefined) { + clearTimeout(timeoutModel) + timeoutModel = undefined + } + changeChainStart = undefined + updateCode() + } + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 3e4856f30f..c3be99558c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -23,6 +23,7 @@ type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { Drawer } from '$lib/components/common' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' @@ -253,8 +254,26 @@ let loadingSave = $state(false) - // No-op: persistence happens via the page-level UserDraft autosave. - export function saveDraft(): void {} + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Unlike ScriptBuilder we don't have a direct + // Monaco ref to flush — any focused module Monaco's pending text is + // constrained by the editor's own ~1s max-wait cap, so the flush + // here picks up whatever's already in `pendingSaveOpts`. Worst case + // the user's very last keystroke (<1s ago) isn't in this POST and + // follows in the next autosave round. + export async function saveDraft(): Promise { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + try { + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'flow', + path: liveEditorDraftStoragePath + }) + sendUserToast('Draft saved') + } catch (e: any) { + sendUserToast(`Could not save draft: ${e?.body ?? e?.message ?? e}`, true) + } + } export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( @@ -1103,7 +1122,6 @@ {@render previewButtons()} {/if} - await handleSaveFlow(detail)} {loading} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 1434269c5a..a6c891689a 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -79,7 +79,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { getContext, onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' @@ -102,6 +102,7 @@ import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' let { script = $bindable(), @@ -694,8 +695,27 @@ loadingSave = false } - // No-op: persistence happens via the page-level UserDraft autosave. - function saveDraft(): void {} + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Flush Monaco first so anything typed within + // the last `changeTimeout` ms reaches the bindable before we tell + // the syncer to flush — otherwise we'd POST the pre-burst content. + // `tick()` lets the bind:code → script.content → UserDraft mirror + // chain settle before the flush call sees `pendingSaveOpts`. + async function saveDraft(): Promise { + if (!$workspaceStore || !userDraftPath) return + editor?.flushPendingChanges() + await tick() + try { + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'script', + path: userDraftPath + }) + sendUserToast('Draft saved') + } catch (e: any) { + sendUserToast(`Could not save draft: ${e?.body ?? e?.message ?? e}`, true) + } + } // Inside an AI session pane (which injects an aiChatManager via context) the // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace @@ -1955,7 +1975,6 @@ {@render settingsButton()} {/if} - sendUserToast('Draft saved')) + .catch((err: any) => + sendUserToast(`Could not save draft: ${err?.body ?? err?.message ?? err}`, true) + ) + return + } + // Skip when typing in an input, textarea, or Monaco editor. const classes = (e.target as HTMLElement | null)?.className if ( diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index c9918bc8f7..addf55d9e3 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -391,5 +391,29 @@ export const UserDraftDbSyncer = { */ async overwrite(opts: Omit): Promise { await this.save({ ...opts, immediate: true, force: true }) + }, + + /** + * Flush whatever's queued in the autosave debouncer for this draft + * RIGHT NOW. Use for explicit "save now" shortcuts (Ctrl/Cmd+S). + * + * Re-submits the latest opts captured by `save()` (held in + * `pendingSaveOpts` until the POST lands) with `immediate: true`, + * which cancels the queued debouncer and routes through the + * coalescing runner. The returned promise resolves only after the + * POST lands, so callers can `await flush(...); show "Saved"`. + * + * No-op when nothing is pending (the most recent save already + * landed, or the editor never autosaved this entry). The caller + * should NOT assume "no pending" means "nothing to save" — Monaco + * may still have unmaterialized text in its own buffer; flush the + * editor first (`Editor.flushPendingChanges()`) and await `tick()` + * so the bind:code propagation reaches our save() before this. + */ + async flush(query: UserDraftLastSyncQuery): Promise { + const key = draftKey(query.workspace, query.itemKind, query.path) + const opts = pendingSaveOpts.get(key) + if (!opts) return + await this.save({ ...opts, immediate: true }) } }