feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately

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.
This commit is contained in:
Diego Imbert
2026-06-09 12:41:41 +02:00
parent 41241dd1fd
commit 03c236ea2f
6 changed files with 105 additions and 8 deletions
@@ -120,7 +120,7 @@
{#snippet content()}
<div class="flex flex-col gap-3 text-sm w-72 p-3">
<p class="text-primary text-sm">
<p class="text-primary text-xs">
All changes are saved as a draft on the server. The draft is per-user — your teammates'
editors keep their own.
</p>
+16
View File
@@ -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
+21 -3
View File
@@ -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<void> {
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}
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
@@ -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<void> {
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}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
@@ -35,6 +35,7 @@
import { runScriptAndPollResult } from '../jobs/utils'
import { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import { sendUserToast } from '$lib/utils'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
buildDataTableWhitelist,
parseDataTableRef,
@@ -1327,6 +1328,25 @@
})
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + S forces an immediate flush of the page-level
// autosave queue. Catch this BEFORE the input/Monaco guard
// below so the shortcut fires regardless of focus — saving
// while still in the editor is the common case.
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault()
if (!$workspaceStore || !liveEditorDraftStoragePath) return
void UserDraftDbSyncer.flush({
workspace: $workspaceStore,
itemKind: 'raw_app',
path: liveEditorDraftStoragePath
})
.then(() => 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 (
@@ -391,5 +391,29 @@ export const UserDraftDbSyncer = {
*/
async overwrite(opts: Omit<UserDraftDbSyncerSaveOpts, 'force'>): Promise<void> {
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<void> {
const key = draftKey(query.workspace, query.itemKind, query.path)
const opts = pendingSaveOpts.get(key)
if (!opts) return
await this.save({ ...opts, immediate: true })
}
}