Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Documents and undo

The session

A command runs on a Session<D>, and a view reaches the same session through Cx. The session holds the document store, the file it came from and view state.

CallWhat it does
s.doc()The current document, &D
s.snapshot()The current document as an Arc<D>, to keep or send to a job
s.revision()The Revision of the current state; key caches by it
s.is_dirty()Unsaved changes since the last save
s.edit(label, |d| …)Change the document as one undo step called label
s.try_edit(label, |d| …)The same, but f returns a Result; on Err nothing changes
s.edit_merged(label, key, |d| …)Fold this edit into the previous one with the same label and key
s.undo() / s.redo()Step through the history; returns the label
s.can_undo() / s.can_redo()Whether there is a step

Edits are copy-on-write: the store clones the document once per edit, so keep large parts behind Arc and use Arc::make_mut for the part you change. The notes example keeps each note as Arc<Note>, so editing one note copies one note, not all of them.

fn delete(s: &mut Session<Notes>, p: &Value) -> Result<Value, CommandError> {
    let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| CommandError::bad_params("", "`id` is required"))?;
    s.try_edit("Delete Note", |d| {
        let i = d.index_of(id).ok_or_else(|| CommandError::failed("", format!("no note {id}")))?;
        d.notes.remove(i);
        Ok(json!({"deleted": id}))
    })
}

Gestures as one step

Typing into a field or dragging a slider produces many edits. edit_merged folds them into one undo step as long as the label and merge key stay the same:

if response.changed() {
    cx.edit_merged("Edit Note", &format!("note-{id}"), |d| d.set_title(id, &title));
}

View state

View state is data that is not in the document and not undoable: the selection, a scroll target, an open panel. It is JSON keyed by name, and agents can set it with the ui.set control method.

s.set_view("selected", &id);
let selected: Option<u64> = s.view("selected");
let zoom = s.view_or("zoom", 1.0_f64);

File formats

A Format describes how documents are stored: a JSON envelope with the format id, the program that wrote it, a schema version and the document.

fn v1_to_v2(mut file: Value) -> Result<Value, String> {
    // rewrite file["document"] from schema 1 to schema 2
    Ok(file)
}

let format = Format::<Notes>::new("acme.notes", concat!("Notes ", env!("CARGO_PKG_VERSION")))
    .migrations(vec![v1_to_v2]);
  • The schema version is the number of migrations plus one. Add a migration whenever the document shape changes; older files are migrated on open.
  • Files from a newer version, or of another format, are refused with a FormatError (TooNew, WrongFormat) instead of being misread.
  • .max_bytes(n) caps the size of a file that will be read; .pretty(true) writes indented JSON.
  • Saves are atomic: a crash during a save leaves the old file or the new one, never a mix.

Saving and opening

The built-in commands handle files: file.new, file.open, file.save, file.save_as, file.revert and app.quit. They ask before discarding unsaved changes, and agents can pass {"path": "…"} to file.open and file.save_as to skip the dialog.

From code, the session has s.open(path), s.save(), s.save_as(path), s.new_document(), s.path() and s.display_name().

Autosave and recovery

Autosave is on by default, every 60 seconds while the document has unsaved changes. Snapshots go to a Recovery folder in the app’s data folder. When the app ends normally they are removed. When it crashes or is killed, the next launch finds them and shows a dialog that offers to recover the work.

App::<Doc>::new("jot", "0.1.0")
    .autosave(Some(std::time::Duration::from_secs(30))) // or None to turn it off

Asking the window for something

A command has no window. It asks for one with an Effect, which a headless session (CLI, MCP) ignores:

s.toast(ToastLevel::Success, "Exported");
s.request(Effect::OpenPalette);
s.request(Effect::PickSaveAs);

The effects are Toast, OpenPalette, OpenKeymap, Theme, PickOpen, PickSaveAs, ConfirmDiscard and Quit.

Using the store on its own

The store is the ashlar-store crate. It is headless and builds for wasm32, so a CLI tool or a server can use Store, History, Format and Autosave without the UI. See docs.rs/ashlar-store.