Unsaved changes
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:surface-retention, andui-primitivesfor dialogs. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
This page keeps unsaved work safe when a surface is hidden or closed: implement DirtySurface, and
the workbench asks before anything is lost. Where a surface only needs its live instance kept, retain
on View state that survives is the lighter tool.
DirtySurface
For an editor-like surface the guard is not retain but dirty state. Implement the DirtySurface
interface on your component (per instance: one doc/:id declaration backs many open tabs, and
“this tab has unsaved changes” is a question about one of them):
import { DirtySurface } from '@loomweaver/plugin-sdk';
@Component({ /* ... */ })export class EditorView implements DirtySurface { protected readonly draft = signal(this.savedBody());
surfaceDirty(): boolean { return this.draft() !== this.savedBody(); // read signals — the host reads this reactively }
surfaceSave(): Promise<void> { return this.api.save(this.draft()); // optional — enables "Save" in the host's close dialog }}While surfaceDirty() is true the instance is never destroyed on hide, so no gesture that
merely hides is ever blocked or prompted; which gestures hide, and which actions ask, is on
Retention and unsaved work.
Closing asks: the host shows its own localised dialog with Save (only when surfaceSave exists),
Discard and Cancel, with the same wording and keyboard behaviour across every plugin. A failed or
in-flight save keeps the instance dirty and therefore alive; failures surface as an error toast, never
as silent loss. Declare saveOn: 'hide' on the surface registration and the host calls surfaceSave
fire-and-forget the moment a dirty instance becomes hidden, which is the auto-save pattern of a notes
pad.
Two limits. First, a sandboxed surface pushes its dirty flag over the channel
(setDirty(true|false)). It is then treated like any other dirty surface: it survives hiding and is
guarded at close and unload. But saveOn: 'hide' is inert for it, because no save call crosses the RPC
boundary. Save inside the surface instead, and push setDirty(false). Second, a routable surface
has no VIEW_STATE handle (View state that survives says why). For it, DirtySurface
or retain is the way to keep unsaved work across hides.
surfaceBeforeClose — veto a close with your own flow
When the standard dialog is the wrong shape for your surface, implement the optional
surfaceBeforeClose(): boolean | Promise<boolean> member of DirtySurface. It runs on every
user-initiated close of that instance: tab ×, Delete, close pane, close others/all/to the right,
ctx.closeContentTab. It runs before the host’s unsaved-changes dialog. Return false to cancel the
close, true to let it continue. Typically you draw your own dialog first and resolve the promise with
the user’s answer. An approved close does not bypass the safety net. If the instance is still dirty
afterwards, the standard Save · Discard · Cancel ask still runs. So approve-and-stay-dirty never
discards silently. Resolve your own save/discard first, and the surface closes without a second dialog.
The host enforces a timeout with a guaranteed “Close anyway” escape, and a hook that throws or
rejects counts as approval. A broken or hung veto can never make a tab unclosable. Programmatic
destruction (your plugin being disabled or uninstalled, a workspace reset) does not consult the
hook; only the unsaved-changes dialog guards those, because a plugin must not be able to veto its own
removal.
A sandboxed surface takes part through its surface channel: push setDirty(true|false) to report
unsaved work, and optionally expose beforeClose() next to the render receiver. The host calls it
over RPC and applies the same timeout. Both wires in one place:
// view.js — the surface document's side of the dirty/close protocollet draft = '';
const connection = Penpal.connect({ messenger, methods: { render(state) { /* locale, tab, theme tokens … — see the sandbox bootstrap */ }, beforeClose() { // optional veto — draw your own dialog and resolve with the answer; // absent, throwing or hanging all count as consent (host timeout). return draft === '' || confirm('Discard your draft?'); }, },});
input.addEventListener('input', (event) => { draft = event.target.value; connection.promise.then((host) => host.setDirty(draft.length > 0));});The runtime channel may also expose contentTabClosed(path), the sandbox counterpart of the in-process
onClose tab hook: when a tab your plugin opened via ctx.openContentTab closes, the host calls it
with the path you opened. The trusted editor as one file, with its save flow, saveOn: 'hide' and
veto, is recipe 8 in Samples; the sandbox variant
lives only here.
In a dialog
A component you open with ctx.ui.open takes part the same way. Implement DirtySurface on it, and
while surfaceDirty() returns true every way the user closes the dialog asks Save · Discard ·
Cancel first, with the same veto and the same timeout. Closing it from your own code, through
DialogRef.close or a footer button that carries a value, never asks.
A form with its own Save and Cancel needs none of this. Open it with dismiss: 'explicit', so a
stray click beside it does nothing while Escape and the close control read as cancel:
const name = await ctx.ui.open<string>(RenameNoteForm, { title: 'Rename', dismiss: 'explicit' }).closed;A body with its own cancel beside its other buttons, a wizard’s “Abbrechen” beside “Weiter”, wants
the question the close control asks, not the silent close. Call requestClose() on the DialogRef
it injects: the veto runs, the question is asked while there is unsaved work, and the promise tells
you whether the dialog closed. It works whatever dismiss says, because the control is yours:
private readonly ref = inject(DialogRef);
protected async cancel(): Promise<void> { if (!(await this.ref.requestClose())) { this.focusFirstField(); }}A dialog whose changes apply as they are made, such as a settings panel, holds nothing unsaved and needs neither. Dialogs and toasts sets the three side by side.
Reading it back
The workbench marks the tab for you. Where you want to show it somewhere the workbench cannot reach, the row in the list the document was opened from is the usual place, read it:
ctx.hasUnsavedWork('doc/42'); // boolean, read reactivelyIt is a reactive read: call it in a template or a computed and your view follows the work being
saved without anything else wired up. An arrangement answers for what is inside it, so a document
whose panel is dirty reads true at the document’s own address. An address with nothing open reads
false, which is sound because a surface holding unsaved work is never destroyed while it does.
Two bounds. It answers about your own surfaces only, and needs no granted capability for the same
reason running your own command needs none; a surface another plugin registered reads false,
whatever is happening in it. And it is trusted rung only: a sandboxed surface reports its own state
over its surface channel and is told about its own there.
Where next
- View state that survives:
VIEW_STATEandretain. - Retention and unsaved work: who asks, and why hiding never does.
- Samples: recipe 8, an editor with unsaved changes.