mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user