Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to Quoll are documented here.

- Pasting text, tables, or rich content together with files now inserts the text and accepted image links together in one undoable step. Captions survive rejected files, and image links keep their clipboard order even when replies arrive out of order.
- Undoing while a paste is waiting for images preserves Redo and prevents late image replies from changing the document. Moving the caret while waiting keeps the paste at its original target without moving you back.
- If an internal error interrupts the editor right after a change, that change is still saved. If it cannot be queued, an error banner appears instead of the change being lost silently.

## 0.1.86 — 2026-11-03

Expand Down
19 changes: 16 additions & 3 deletions src/webview/cm/edit-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//
// Driven by the shell's synchronous post-commit dispatch (editor.ts +
// shell.ts):
// - onLocalChange from the CM updateListener (debounced post).
// - onLocalChange from the editor's dispatchTransactions (debounced post).
// - onHostSnapshot from applyDocument (records the snapshot's metadata and
// judges the in-flight Edit against it, demoting one the host did not apply
// into the replay buffer — never touches editInFlight, never drains).
Expand Down Expand Up @@ -160,7 +160,8 @@ export type EditSyncOptions = {
* The implementation must apply it as a host reseed, not as a user edit
* (non-history, and not read back through `onLocalChange`).
* MUST NEVER RUN INSIDE A CODEMIRROR UPDATE: it dispatches on the view. The
* only drain reachable from the update listener is the one driven by
* only drain reachable from a local edit's hand-off (`noteLocalChange` in
* the editor's `dispatchTransactions`) is the one driven by
* `local-edit-attempt`, and that dispatch is preceded by `discardBuffer()`,
* so the drain finds no buffer and never gets here.
* NOT called while a debounce timer is live: the view then holds a keystroke
Expand Down Expand Up @@ -213,8 +214,15 @@ export type EditSyncOptions = {
export type SettleOutcome = "settled" | "unsynced" | "cancelled";

export type EditSync = {
/** Editor content changed locally (CM updateListener docChanged). */
/** Editor content changed locally (a committed change, from the editor's
* `dispatchTransactions`). */
onLocalChange: () => void;
/** A committed local change could NOT be scheduled (the editor's
* `onLocalChange` hand-off threw). Hold the view's bytes as the replay
* buffer, so `viewHoldsUnackedEdit` keeps a same-lineage Document from
* reseeding over them and the next open-gate drain posts them. Same
* readonly rule as `trySend`: under `canWrite` false nothing is held. */
retainLocalChange: () => void;
/** A host Document arrived. Records its metadata — the version + canWrite
* edit-sync echoes on the next Edit, and the identity pair — and JUDGES the
* in-flight Edit against it (below). Returns `true` while that Edit still
Expand Down Expand Up @@ -1130,6 +1138,11 @@ export function createEditSync(opts: EditSyncOptions): EditSync {
// post), and then no reducer commit follows to wake a waiter.
rejudgeSettleWaiters();
}),
retainLocalChange: () => {
if (canWrite) {
buffered = stampHeld(opts.getDoc());
}
},
onHostSnapshot: (d) => {
const transition = isIdentityTransition(d.externalEpoch, d.epochGeneration);
// Generation-aware acceptance ordering (S3b): version order is meaningful
Expand Down
109 changes: 77 additions & 32 deletions src/webview/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,9 +484,81 @@ export function mountEditor(opts: EditorOptions): EditorHandle {
pendingCaret = null;
};

// A committed local change → edit-sync. Called from `dispatchTransactions`,
// NOT an updateListener: CodeMirror commits the state before its DOM phase and
// wraps each listener in its own try/catch, so a DOM throw skips the listeners
// and a listener throw is logged and swallowed — both leave the committed edit
// unscheduled with no sign it never reached the host (SYNC-01). Any throw here
// becomes a send failure: the banner says nothing is being saved, and the
// next keystroke's local-edit-attempt below is the retry.
function noteLocalChange(): void {
try {
// Send-failure recovery: when the prior Edit could not be sent,
// the next user keystroke is their retry intent. Two ops in
// STRICT order before scheduling the debounced flush:
// 1. discardBuffer — the buffered bytes precede this keystroke; the
// synchronous onReducerCommit → replayIfNeeded chain
// that fires INSIDE the local-edit-attempt dispatch
// below would otherwise post them again and the
// banner would flicker. ORDER IS LOAD-BEARING — twice
// over: that drain runs INSIDE this call, and
// with a buffer in hand it could reach `showHeld`, which
// dispatches a reseed on the view — right after the very
// update that is delivering the user's keystroke. An
// empty buffer keeps the drain out of that step.
// 2. dispatch(local-edit-attempt) — clears the gate via
// the reducer; drain sees empty buffer (step 1) → no-op.
// Then onLocalChange's debounce reads FRESH getDoc() — which
// includes the keystroke that was just committed —
// and ships a retry on the next timer tick. The order
// regression is pinned by the "synchronous drain does NOT
// replay stale buffered bytes" test in editor.test.ts.
if (opts.getState().sendFailure !== null) {
sync.discardBuffer();
opts.dispatch({ type: "local-edit-attempt" });
}
sync.onLocalChange();
} catch (err) {
console.error("[quoll] a committed edit could not be scheduled for sending", err);
const detail = err instanceof Error ? err.message : String(err);
try {
opts.dispatch({
type: "send-failure",
message: `an edit could not be scheduled for sending: ${detail}`,
});
} catch (dispatchErr) {
console.error("[quoll] send-failure dispatch itself failed", dispatchErr);
}
// AFTER the send-failure dispatch: its drain finds the gate closed, so
// the retained bytes are not posted from inside this update. Without the
// hold, a same-lineage Document (an earlier Edit's ack) finds nothing
// un-acked, reseeds over the unsent change, and its reducer arm clears
// the banner — the edit vanishes unseen.
try {
sync.retainLocalChange();
} catch (retainErr) {
console.error("[quoll] retaining the unscheduled edit failed", retainErr);
}
}
}

const viewCreateStart = QUOLL_PERF ? perfNow() : 0;
const view: EditorView = new EditorView({
parent: mount,
// `finally`, so a DOM-phase throw still hands the committed edit to
// edit-sync before it propagates to the dispatcher. Doc identity, not
// `docChanged`: a throw before the commit leaves the doc as it was, and
// there is then nothing to send.
dispatchTransactions(trs, v) {
const before = v.state.doc;
try {
v.update(trs);
} finally {
if (v.state.doc !== before && !seeding) {
noteLocalChange();
}
}
},
state: EditorState.create({
doc: "",
extensions: [
Expand All @@ -503,7 +575,7 @@ export function mountEditor(opts: EditorOptions): EditorHandle {
// In-editor find & replace over the raw-Markdown text canonical
// (@codemirror/search core module). `search({ top: true })` mounts the
// panel at the top (VS Code-like). A replace is an ordinary transaction,
// so it rides the normal updateListener → edit-sync → host write-lock →
// so it rides the normal dispatchTransactions → edit-sync → host write-lock →
// verified-write pipeline. In read-only
// mode CM's replace commands return false and the panel omits its replace
// UI (both guard on EditorState.readOnly, set when canWrite=false), so
Expand Down Expand Up @@ -900,33 +972,6 @@ export function mountEditor(opts: EditorOptions): EditorHandle {
]),
EditorView.updateListener.of((u) => {
const updateStart = QUOLL_PERF ? perfNow() : 0;
if (u.docChanged && !seeding) {
// Send-failure recovery: when the prior Edit could not be sent,
// the next user keystroke is their retry intent. Two ops in
// STRICT order before scheduling the debounced flush:
// 1. discardBuffer — the buffered bytes precede this keystroke; the
// synchronous onReducerCommit → replayIfNeeded chain
// that fires INSIDE the local-edit-attempt dispatch
// below would otherwise post them again and the
// banner would flicker. ORDER IS LOAD-BEARING — twice
// over: that drain runs INSIDE this update listener, and
// with a buffer in hand it could reach `showHeld`, which
// dispatches a reseed on the view — from within the very
// update that is delivering the user's keystroke. An
// empty buffer keeps the drain out of that step.
// 2. dispatch(local-edit-attempt) — clears the gate via
// the reducer; drain sees empty buffer (step 1) → no-op.
// Then onLocalChange's debounce reads FRESH getDoc() — which
// includes the keystroke that just fired this updateListener —
// and ships a retry on the next timer tick. The order
// regression is pinned by the "synchronous drain does NOT
// replay stale buffered bytes" test above.
if (opts.getState().sendFailure !== null) {
sync.discardBuffer();
opts.dispatch({ type: "local-edit-attempt" });
}
sync.onLocalChange();
}
// Caret handoff: report the caret on any selection change (debounced —
// see scheduleCaretReport). Suppressed during host seeds (`seeding` —
// the reseed restores a selection, not a user move) and during
Expand Down Expand Up @@ -1043,8 +1088,8 @@ export function mountEditor(opts: EditorOptions): EditorHandle {
});
} finally {
// try/finally: if dispatch throws, seeding must NOT stay stuck
// true — a stuck guard permanently suppresses the updateListener's
// echo-Edit detection.
// true — a stuck guard permanently suppresses noteLocalChange's
// hand-off of local edits to edit-sync.
seeding = false;
}
}
Expand All @@ -1056,8 +1101,8 @@ export function mountEditor(opts: EditorOptions): EditorHandle {
// that strips a folded heading's OWN marker can orphan its fold entirely (no
// canonical range left to clamp to). Only meaningful when the reseed actually
// changed the document (reseedChange !== null). This is a SEPARATE, display-only
// dispatch carrying NO `changes` — byte-identical round-trip, and a no-op for the
// updateListener's `docChanged`-gated edit-sync — so it behaves exactly like a
// dispatch carrying NO `changes` — byte-identical round-trip, and a no-op for
// dispatchTransactions' doc-identity-gated edit-sync — so it behaves exactly like a
// user fold/unfold (hence NOT annotated as a hostDocumentReseed; it rewrites no
// document bytes). addToHistory.of(false) keeps the clamp out of undo.
// reconcileReseedFolds self-guards on a complete parse tree. The edited-span
Expand Down
Loading
Loading