Skip to content

Unsaved changes

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: surface-retention, and ui-primitives for 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):

src/lib/views/editor-view.ts
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 protocol
let 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 reactively

It 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