From 6e0a576dc5e34658d844286c1178fc877a0ed497 Mon Sep 17 00:00:00 2001 From: 1'm s0rry <50732046+75409885@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:38:41 +0800 Subject: [PATCH 1/4] feat(core): add setDocument history option for load boundaries --- packages/core/src/editor.ts | 65 +++++++++++++++++++++++++--- packages/core/src/types.ts | 33 ++++++++++++++ packages/plugin-history/src/index.ts | 3 ++ 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..ca58e0df 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -1,12 +1,12 @@ -import { Annotation, EditorSelection, EditorState } from "@codemirror/state"; +import { Annotation, Compartment, EditorSelection, EditorState, StateEffect, Transaction } from "@codemirror/state"; +import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; +import { indentWithTab, redo as cmRedo, undo as cmUndo } from "@codemirror/commands"; +import { closeBrackets } from "@codemirror/autocomplete"; // Annotation attached to dispatches that load content programmatically (e.g. // setDocument from file open) so updateListener can skip the user-edit path — // no onChange emission, no AST reparse for the onChange pipeline. const silentDocChange = Annotation.define(); -import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; -import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/commands"; -import { closeBrackets } from "@codemirror/autocomplete"; import type { Root } from "mdast"; import type { Heading } from "mdast"; import rehypeStringify from "rehype-stringify"; @@ -260,7 +260,19 @@ export function createEditor(config: EditorConfig): EditorAPI { const customParser = config.parser; const shortcuts = plugins.flatMap((plugin) => plugin.shortcuts ?? []); const slashCommands = plugins.flatMap((plugin) => plugin.slashCommands ?? []); - const cmExtensions = plugins.flatMap((plugin) => plugin.cmExtensions ?? []); + // History extensions are installed through a compartment so that + // `setDocument(..., { history: "reset" })` can rebuild them empty. They must + // be routed through the compartment *exclusively*: an earlier attempt also + // spread them into the plain extension list, and clearing the compartment + // then left the duplicate copy alive, so the reset silently did nothing. + const historyExtensions = plugins + .filter((plugin) => plugin.historyCompartment === true) + .flatMap((plugin) => plugin.cmExtensions ?? []); + // Per editor, so no other editor can reconfigure this one's history slot. + const historyCompartment = new Compartment(); + const cmExtensions = plugins + .filter((plugin) => plugin.historyCompartment !== true) + .flatMap((plugin) => plugin.cmExtensions ?? []); const widgetDefs = plugins.flatMap((plugin) => plugin.widgets ?? []); // 命名命令(类 Obsidian addCommand)。同 id 以先注册者为准。 const commands = plugins.flatMap((plugin) => plugin.commands ?? []); @@ -426,6 +438,21 @@ export function createEditor(config: EditorConfig): EditorAPI { }, COMPOSITION_FLUSH_DELAY_MS); } + // Undo/redo stacks live in a CodeMirror state field with no public reset. + // Rebuilding it means toggling the history extension out of the compartment + // and back in, and a compartment keeps only the last reconfigure in a + // transaction — so "reset" needs two dispatches: this one drops the + // extension (and with it the stacks), and the load transaction right after + // it installs a fresh instance. Both run synchronously in the same task, so + // no input can slip in between. + function disableHistoryBoundary(): StateEffect | null { + return historyExtensions.length === 0 ? null : historyCompartment.reconfigure([]); + } + + function enableHistoryBoundary(): StateEffect | null { + return historyExtensions.length === 0 ? null : historyCompartment.reconfigure(historyExtensions); + } + // 整文档替换的实际执行体。setDocument(公开 API)在组合输入中会推迟调用本函数。 function performSetDocument(next: string, opts?: SetDocumentOptions) { // Table cells intentionally keep their DOM edits local until blur so the @@ -436,6 +463,7 @@ export function createEditor(config: EditorConfig): EditorAPI { if (tableEditFlushed) flushScheduledChangeNow(); const beforeSelection = view.state.selection.main; const silent = opts?.silent === true; + const historyMode = opts?.history ?? "record"; const selection = resolveDocumentSelection( { anchor: beforeSelection.anchor, head: beforeSelection.head }, next.length, @@ -443,20 +471,40 @@ export function createEditor(config: EditorConfig): EditorAPI { ); debugNexus("setDocument", { silent, + historyMode, oldLength: view.state.doc.length, nextLength: next.length, beforeSelection: { anchor: beforeSelection.anchor, head: beforeSelection.head }, selection, }); + // A document load must not become an undo step: undoing it would erase the + // opened file, and a surviving redo stack would restore the previous + // document into the new buffer. `addToHistory` keeps the load itself out of + // the history, but on its own it leaves every earlier entry reachable, so + // "reset" additionally rebuilds the history extension from scratch. + const historyAnnotations: Annotation[] = []; + if (historyMode !== "record") { + historyAnnotations.push(Transaction.addToHistory.of(false)); + } + + let historyEffects: StateEffect[] | undefined; + if (historyMode === "reset") { + const disable = disableHistoryBoundary(); + if (disable) view.dispatch({ effects: [disable] }); + const enable = enableHistoryBoundary(); + historyEffects = enable ? [enable] : undefined; + } + const dispatchSpec = { changes: { from: 0, to: view.state.doc.length, insert: next }, - annotations: silent ? silentDocChange.of(true) : undefined, + annotations: silent ? [...historyAnnotations, silentDocChange.of(true)] : historyAnnotations, ...(selection ? { selection } : {}), + effects: historyEffects, }; view.dispatch(dispatchSpec); @@ -766,7 +814,10 @@ export function createEditor(config: EditorConfig): EditorAPI { ...createWidgetExtension(widgetParser, widgetDefs), ...shortcutExtensions, ...commandKeymapExtensions, - ...cmExtensions + ...cmExtensions, + // Single configuration point for the history compartment; see + // `historyExtensions` above and `disableHistoryBoundary` below. + historyCompartment.of(historyExtensions) ] }) }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..06dc7328 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -364,6 +364,24 @@ export interface SetDocumentOptions { anchor: number; head?: number; }; + /** + * How this replacement interacts with the undo history. Defaults to + * `"record"`, which keeps the replacement undoable. + * + * - `"record"` — the replacement is one undo entry. Correct for + * programmatic *user edits* such as toolbar formatting. + * - `"skip"` — the replacement is not an undo entry, but earlier entries + * stay undoable. Use when incoming text should be visible to undo + * position mapping without becoming a step of its own. + * - `"reset"` — the replacement is not an undo entry AND the undo/redo + * stacks are cleared, making the load a document boundary. Use when + * opening or switching files: undo must not revert the load, and redo + * must not restore the previous document. + * + * Independent of `silent`: either may be set without the other. `"reset"` + * is a no-op when no CodeMirror history extension is installed. + */ + history?: "record" | "skip" | "reset"; } export interface EditorAPI { @@ -393,6 +411,10 @@ export interface EditorAPI { * @param opts.silent When true, skip the onChange pipeline. Use when * loading a file from disk — avoids treating a file-open as a user * edit (no redundant mdast parse / link-index rebuild). + * @param opts.history How the replacement interacts with the undo + * history. Defaults to `"record"`. Pass `"reset"` when opening or + * switching documents so the load cannot be undone and the previous + * document cannot be redone back into the buffer. * * 组合输入(IME)进行中时,整文档替换会打断输入法、丢失正在合成的文字 * 并把视口重置到顶部。此时本次替换会延迟到 compositionend 再应用,只保留 @@ -585,5 +607,16 @@ export interface NexusPlugin { handlers?: EditorEventHandlers; remarkPlugins?: Array>; cmExtensions?: Extension[]; + /** + * Marks `cmExtensions` as the editor's undo-history extension. The editor + * installs them inside a per-instance compartment so that a document load can + * rebuild the history state from scratch (`setDocument(..., { history: + * "reset" })`), which is the only supported way to drop the undo and redo + * stacks while they stay enabled. + * + * Plugins that install `history()` themselves instead of declaring this are + * still valid; they simply keep their history across document loads. + */ + historyCompartment?: true; widgets?: WidgetDefinition[]; } diff --git a/packages/plugin-history/src/index.ts b/packages/plugin-history/src/index.ts index 4f65b373..396ba733 100644 --- a/packages/plugin-history/src/index.ts +++ b/packages/plugin-history/src/index.ts @@ -6,6 +6,9 @@ import type { NexusPlugin } from "@floatboat/nexus-core"; export function createHistoryPlugin(): NexusPlugin { return { name: "plugin-history", + // Declaring the compartment lets the editor drop the undo/redo stacks when + // a host loads a new document with `setDocument(..., { history: "reset" })`. + historyCompartment: true, cmExtensions: [history(), keymap.of(historyKeymap)] }; } From ab0888afd067b1341fe4a138159cb355dedd19da Mon Sep 17 00:00:00 2001 From: 1'm s0rry <50732046+75409885@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:38:49 +0800 Subject: [PATCH 2/4] fix(electron): reset undo history when opening a file --- apps/electron-demo/src/renderer/editor-shell.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/electron-demo/src/renderer/editor-shell.ts b/apps/electron-demo/src/renderer/editor-shell.ts index f63ec212..82c8cbb0 100644 --- a/apps/electron-demo/src/renderer/editor-shell.ts +++ b/apps/electron-demo/src/renderer/editor-shell.ts @@ -363,8 +363,11 @@ export function createEditorShell(options: EditorShellOptions): EditorShell { // onChange pipeline (avoids redundant parse + link-index rebuild on // every file open). Caller owns state.content / dirty below and the // link index is re-seeded as part of vault open. + // `history: "reset"` makes the open a document boundary: Ctrl+Z must + // not revert the file that was just opened, and a redo left over from + // the previous document must not restore it into this buffer. const t0 = performance.now(); - editor.setDocument(content, { silent: true }); + editor.setDocument(content, { silent: true, history: "reset" }); const t1 = performance.now(); state.content = content; state.dirty = false; From 3cb8340480a69d05a6adc42e5f3a971f0db1e942 Mon Sep 17 00:00:00 2001 From: 1'm s0rry <50732046+75409885@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:39:00 +0800 Subject: [PATCH 3/4] test(core): cover undo history boundaries across document loads --- apps/electron-demo/test/editor-shell.test.ts | 74 +++++++++++ packages/core/test/editor.test.ts | 120 ++++++++++++++++++ .../test/plugin-history.test.ts | 43 ++++++- 3 files changed, 234 insertions(+), 3 deletions(-) diff --git a/apps/electron-demo/test/editor-shell.test.ts b/apps/electron-demo/test/editor-shell.test.ts index bc762f32..4486ec6f 100644 --- a/apps/electron-demo/test/editor-shell.test.ts +++ b/apps/electron-demo/test/editor-shell.test.ts @@ -65,6 +65,80 @@ describe("createEditorShell", () => { shell.destroy(); }); + it("loads a document as a fresh undo/redo history boundary", () => { + const container = document.createElement("div"); + const state = createState(); + const shell = createEditorShell({ + container, + state, + settings: defaultSettings(), + onStateChange: vi.fn(), + }); + + // Opening a file must not become an undo step — otherwise Ctrl+Z before + // typing anything reverts the loaded buffer back to the empty document. + shell.loadDocument("opened file"); + expect(shell.editor.undo()).toBe(false); + expect(shell.editor.getDocument()).toBe("opened file"); + + // An edit after the load is undoable, and undo stops at the loaded content. + shell.editor.setDocument("opened file edited", { history: "record" }); + expect(shell.editor.undo()).toBe(true); + expect(shell.editor.getDocument()).toBe("opened file"); + expect(shell.editor.redo()).toBe(true); + expect(shell.editor.getDocument()).toBe("opened file edited"); + + // Switching documents must not let the previous file leak back in, in + // either direction. + shell.loadDocument("second file"); + expect(shell.editor.undo()).toBe(false); + expect(shell.editor.redo()).toBe(false); + expect(shell.editor.getDocument()).toBe("second file"); + + shell.destroy(); + }); + + it("keeps each shell's undo history isolated from other live editors", () => { + const firstContainer = document.createElement("div"); + document.body.append(firstContainer); + const secondContainer = document.createElement("div"); + document.body.append(secondContainer); + + const first = createEditorShell({ + container: firstContainer, + state: createState(), + settings: defaultSettings(), + onStateChange: vi.fn(), + }); + const second = createEditorShell({ + container: secondContainer, + state: createState(), + settings: defaultSettings(), + onStateChange: vi.fn(), + }); + + first.loadDocument("first opened file"); + first.editor.setDocument("first opened file edited", { history: "record" }); + + // A reset in one shell must not disturb another live shell's stacks. This + // holds because each editor owns its history compartment; the assertions + // lock the contract rather than reproducing a known defect. + second.loadDocument("second opened file"); + second.editor.setDocument("second opened file edited", { history: "record" }); + expect(second.editor.undo()).toBe(true); + expect(second.editor.getDocument()).toBe("second opened file"); + expect(second.editor.undo()).toBe(false); + + expect(first.editor.undo()).toBe(true); + expect(first.editor.getDocument()).toBe("first opened file"); + expect(first.editor.undo()).toBe(false); + + first.destroy(); + second.destroy(); + firstContainer.remove(); + secondContainer.remove(); + }); + it("leaves migrated UI to the runtime owner in runtime mode", () => { const container = document.createElement("div"); document.body.append(container); diff --git a/packages/core/test/editor.test.ts b/packages/core/test/editor.test.ts index 85505eba..c73ad4ff 100644 --- a/packages/core/test/editor.test.ts +++ b/packages/core/test/editor.test.ts @@ -918,3 +918,123 @@ describe("createEditor — DOM event hook layer", () => { editor.destroy(); }); }); + +describe("createEditor — setDocument undo history boundary", () => { + function createHistoryEditor(initialValue: string) { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue, plugins: [createHistoryPlugin()] }); + return editor; + } + + it("records a bare setDocument so the replacement stays undoable", () => { + const editor = createHistoryEditor("start"); + + editor.setDocument("next"); + + expect(editor.getDocument()).toBe("next"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("start"); + editor.destroy(); + }); + + it("skips a skipped load without consuming an undo entry", () => { + const editor = createHistoryEditor("A"); + + editor.setDocument("B", { history: "skip" }); + editor.setDocument("C", { history: "record" }); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("B"); + // "B" was loaded with skip, so nothing earlier is reachable. + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("B"); + editor.destroy(); + }); + + it("leaves nothing to undo after a reset load", () => { + const editor = createHistoryEditor("A"); + + editor.setDocument("B", { history: "reset" }); + + expect(editor.getDocument()).toBe("B"); + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("B"); + editor.destroy(); + }); + + it("undoes an edit back to the reset-loaded content, not past it", () => { + const editor = createHistoryEditor("A"); + + editor.setDocument("B", { history: "reset" }); + editor.setDocument("B edited", { history: "record" }); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("B"); + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("B"); + editor.destroy(); + }); + + it("clears the redo stack so a previous document cannot be restored", () => { + const editor = createHistoryEditor("A"); + + editor.setDocument("B", { history: "record" }); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("A"); + + editor.setDocument("C", { history: "reset" }); + + expect(editor.redo()).toBe(false); + expect(editor.getDocument()).toBe("C"); + editor.destroy(); + }); + + it("accepts a reset load without a history extension", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "start" }); + + expect(() => editor.setDocument("next", { history: "reset" })).not.toThrow(); + expect(editor.getDocument()).toBe("next"); + // No history extension means no stacks to reset, and no history to replay. + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("next"); + editor.destroy(); + }); + + it("accepts a skip load without a history extension", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "start" }); + + expect(() => editor.setDocument("next", { history: "skip" })).not.toThrow(); + expect(editor.getDocument()).toBe("next"); + expect(editor.undo()).toBe(false); + editor.destroy(); + }); + + it("applies a reset load deferred by IME composition", async () => { + const container = document.createElement("div"); + let capturedView: EditorView | null = null; + const editor = createEditor({ + container, + initialValue: "A", + plugins: [ + createHistoryPlugin(), + { name: "capture", cmExtensions: [captureViewPlugin((view) => (capturedView = view))] }, + ], + }); + const view = requireEditorView(capturedView); + + view.contentDOM.dispatchEvent(new Event("compositionstart", { bubbles: true })); + editor.setDocument("B", { history: "reset" }); + expect(editor.getDocument()).toBe("A"); + + view.contentDOM.dispatchEvent(new Event("compositionend", { bubbles: true })); + // The deferred load lands on the composition flush timer, not synchronously. + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(editor.getDocument()).toBe("B"); + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("B"); + editor.destroy(); + }); +}); diff --git a/packages/plugin-history/test/plugin-history.test.ts b/packages/plugin-history/test/plugin-history.test.ts index 11d8d0d0..9f018e21 100644 --- a/packages/plugin-history/test/plugin-history.test.ts +++ b/packages/plugin-history/test/plugin-history.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { createHistoryPlugin } from "../src/index"; describe("@floatboat/nexus-plugin-history", () => { - it("undoes the most recent document change through codemirror key handling", () => { + it("undoes a recorded replacement through codemirror key handling", () => { const container = document.createElement("div"); const editor = createEditor({ container, @@ -13,7 +13,7 @@ describe("@floatboat/nexus-plugin-history", () => { const content = container.querySelector("[contenteditable='true']"); - editor.setDocument("next"); + editor.setDocument("next", { history: "record" }); content?.dispatchEvent( new KeyboardEvent("keydown", { @@ -38,7 +38,7 @@ describe("@floatboat/nexus-plugin-history", () => { const content = container.querySelector("[contenteditable='true']"); - editor.setDocument("next"); + editor.setDocument("next", { history: "record" }); content?.dispatchEvent( new KeyboardEvent("keydown", { @@ -48,6 +48,7 @@ describe("@floatboat/nexus-plugin-history", () => { cancelable: true }) ); + expect(editor.getDocument()).toBe("start"); content?.dispatchEvent( new KeyboardEvent("keydown", { @@ -61,4 +62,40 @@ describe("@floatboat/nexus-plugin-history", () => { expect(editor.getDocument()).toBe("next"); editor.destroy(); }); + + // A file load must never become an undo step: undoing it would erase the + // opened document or restore the previously open file into the buffer. + it("does not undo a programmatic document load", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "previous document", + plugins: [createHistoryPlugin()] + }); + + editor.setDocument("opened file", { history: "reset" }); + + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("opened file"); + editor.destroy(); + }); + + it("keeps undo history isolated to the document loaded last", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "document A", + plugins: [createHistoryPlugin()] + }); + + editor.setDocument("document A edited", { history: "record" }); + editor.setDocument("document B", { history: "reset" }); + editor.setDocument("document B edited", { history: "record" }); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("document B"); + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("document B"); + editor.destroy(); + }); }); From 766a547e131d4a91290053311a9794682fd28ee4 Mon Sep 17 00:00:00 2001 From: 1'm s0rry <50732046+75409885@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:39:13 +0800 Subject: [PATCH 4/4] docs(openspec): add fix-document-load-undo-boundary change --- README.md | 19 +++- .../fix-document-load-undo-boundary/design.md | 88 +++++++++++++++++++ .../proposal.md | 24 +++++ .../specs/editor-core/spec.md | 58 ++++++++++++ .../fix-document-load-undo-boundary/tasks.md | 32 +++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/fix-document-load-undo-boundary/design.md create mode 100644 openspec/changes/fix-document-load-undo-boundary/proposal.md create mode 100644 openspec/changes/fix-document-load-undo-boundary/specs/editor-core/spec.md create mode 100644 openspec/changes/fix-document-load-undo-boundary/tasks.md diff --git a/README.md b/README.md index e12a11ae..8e067c7d 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the | `@floatboat/nexus-react` | React binding — `useEditor` hook and `` component | | `@floatboat/nexus-vue` | Vue 3 binding — `useEditor` composable | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown preset (tables, strikethrough, task lists) | -| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z` | +| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z`, plus document-load history boundaries | | `@floatboat/nexus-plugin-search` | Search and replace helpers | | `@floatboat/nexus-plugin-slash` | Slash command detection, ranking, and a vanilla-DOM floating menu UI | | `@floatboat/nexus-plugin-toolbar` | Toolbar primitives for formatting commands | @@ -209,9 +209,10 @@ Plugin platform documentation (Chinese): [native plugin API](./docs/plugins/nati ```ts editor.getDocument() // current Markdown string editor.getAst() // current mdast Root -editor.setDocument(md) // replace entire document +editor.setDocument(md) // replace entire document (undoable) editor.setDocument(md, { silent: true, preserveSelection: true }) editor.setDocument(md, { selection: { anchor: 0 } }) +editor.setDocument(md, { silent: true, history: "reset" }) // open a file: not undoable, clears undo/redo editor.setSelection(pos) // move cursor editor.focus() / editor.blur() editor.destroy() @@ -226,6 +227,20 @@ editor.off("change", handler) editor.getCoordsAtPos(pos) // { left, right, top, bottom } | null ``` +**Document loading and undo history.** `setDocument` accepts `history`, which +controls how the replacement interacts with undo/redo: + +| `history` | Replacement becomes an undo entry | Clears undo/redo stacks | Use for | +|---|---|---|---| +| `"record"` *(default)* | yes | no | programmatic user edits — toolbar formatting, replace-all | +| `"skip"` | no | no | incoming text that should not itself be undoable | +| `"reset"` | no | yes | opening or switching files | + +Loading a file with the default records the load as an edit, so `Ctrl+Z` before +typing reverts the buffer and a redo from the previous file leaks into the new +one. Pass `history: "reset"` at a document boundary instead. It is a no-op when +no history plugin is installed. +
diff --git a/openspec/changes/fix-document-load-undo-boundary/design.md b/openspec/changes/fix-document-load-undo-boundary/design.md new file mode 100644 index 00000000..62201541 --- /dev/null +++ b/openspec/changes/fix-document-load-undo-boundary/design.md @@ -0,0 +1,88 @@ +## Context + +`setDocument` is the single whole-document replacement primitive on `EditorAPI`. It has two callers with opposite requirements: + +| Caller | Example | Requirement | +|---|---|---| +| A programmatic user edit | `plugin-toolbar` formatting, `plugin-search` replace-all, `apps/electron-demo` image drag-resize | The edit must be undoable — Ctrl+Z has to take it back | +| A document load | `EditorShell.loadDocument`, controlled `value` sync in `packages/react` / `packages/vue` | The load must not be undoable — Ctrl+Z must never erase an opened file | + +Today both collapse into one behavior: any non-empty change set is recorded by the CM6 history field unless the transaction carries `Transaction.addToHistory.of(false)`. `silent` does not help — it only suppresses the `onChange` / `change` event and the AST resync (`packages/core/src/editor.ts`, `silentDocChange` annotation), and it is also used by the framework bindings for controlled-value sync, which must **not** silently drop user history. + +There is also no existing spec for this: `openspec/specs/` contains only plugin-* capabilities, and the archived `plugin-editor-extensions` spec only requires that reconfiguration preserve "compatible undo/redo history". The load-boundary behavior was never specified, which is why the bug shipped with all 7 existing history/editor-demo tests green. + +## Goals / Non-Goals + +- Goals + - Make the document-load boundary explicit and opt-in, without changing any existing call site's behavior. + - Guarantee that undo/redo cannot cross a document load in either direction. + - Keep the fix inside `packages/core` + the demo caller: no new dependency, no new package, no plugin API surface. +- Non-Goals + - Undo/redo *grouping* (Roadmap item 8) — merging adjacent edits into fewer entries is a separate change. + - Multi-document history stacks or per-file history restoration. `"reset"` discards history; restoring a previous document's history is not attempted. + - Changing the history behavior of `replaceRange` or `replaceSelection`. Range edits stay `"record"`-only in this change so the surface stays minimal. + +## Decisions + +### Decision: a three-valued `history` option rather than a boolean + +`"record" | "skip" | "reset"` names three genuinely different intents, and a boolean (`resetHistory: true`) cannot express the middle one. The middle case is not hypothetical: a host that renders external content (sync pull, formatter, test harness) wants the incoming text to be visible to subsequent undo mapping without itself becoming an undo step. + +- Alternative considered: `resetHistory?: boolean` — rejected, cannot express `"skip"`, and `false` would be indistinguishable from the default. +- Alternative considered: a separate `editor.resetHistory()` method plus `setDocument(..., { skipHistory: true })` — rejected as two coordinated calls for one intent. Two dispatches also reintroduce the ordering hazard that `add-selection-api` already fixed for edits: a caller that forgets the second call silently keeps the bug. + +### Decision: default stays `"record"` + +`setDocument` is used for user-initiated edits in `packages/plugin-toolbar` (6 sites) and `packages/plugin-search` (2 sites). Those are covered by regression tests that assert a single Ctrl+Z reverts a toolbar toggle (`packages/plugin-toolbar/test/plugin-toolbar.test.ts`, "toggleUnorderedList — atomic undo"). Flipping the default to `"skip"` would break undo for formatting commands, so the load caller opts in instead. + +### Decision: reset is a two-transaction compartment toggle, not a field write + +Clearing the stacks turned out to be the hard part, and three plausible approaches were measured against a real CodeMirror state before choosing. Only one works: + +1. `Transaction.addToHistory.of(false)` stops the load from *becoming* an entry, but explicitly does not clear what is already on the stack — CM6 maps the change through `state.addMapping(tr.changes.desc)` and keeps every earlier entry. Undo still reverts to the pre-load document. **Rejected on its own.** +2. `isolateHistory.of("full")` bounds the *redo* stack only. Measured: after loading B over an edited A, `done` still held the A edit and undo produced `"BA"`. **Rejected.** +3. `historyField.init(factory)` looks like the way to supply a replacement value, but it returns an *extension array* (`[field, initField.of(...)]`), not a dispatchable `StateEffect` — `initField` is a facet consulted on the field's create/reconfigure path. Passing it as an effect throws `effect.is is not a function`. **Rejected; the API for writing a live field value does not exist.** +4. **Chosen:** rebuild the extension itself. Reconfiguring the history compartment to `[]` drops the history field (and both stacks); reconfiguring it back to the plugin's `history()` re-creates it empty. + +A CodeMirror compartment keeps only the **last** `reconfigure` in a transaction — measured directly — so the toggle cannot be a single dispatch. `performSetDocument` therefore issues two dispatches for `"reset"`: an effects-only dispatch that drops the extension, then the load transaction that installs a fresh instance. Both run synchronously in the same task with no input handling between them, so nothing can slip into the gap. The load transaction still carries `addToHistory(false)`, which keeps the replacement itself out of the rebuilt stack. + +- Alternative considered: `view.setState(EditorState.create({ doc, extensions: state.config.extensions }))` — this does clear the stacks, but rebuilds every view plugin, losing scroll position, focus, and widget DOM. Rejected as disproportionate for a file open. +- Alternative considered: drain the stack with `while (editor.undo()) {}` — rejected. It is O(history depth) dispatches, each rebuilding decorations and firing `selectionChange`, and it reads as a workaround rather than a boundary. + +### Decision: history extensions go through the compartment *exclusively* + +The first working draft kept the plugin's `cmExtensions` in the plain extension list **and** installed a copy inside the compartment. Resetting then cleared only the compartment copy while the duplicate kept the history field — and its stacks — alive, so `"reset"` silently did nothing even though the disable dispatch ran. Measured on the real editor: `historyExtensions: 2, hasDisable: true` and yet `undone=1` after the load. + +Declaring `historyCompartment: true` therefore *moves* a plugin's extensions into the compartment rather than copying them: the editor filters those plugins out of the plain list. This is the load-bearing part of the design and is pinned by the `"reset"` regression tests. + +### Decision: the compartment is per-editor + +Each `createEditor` call creates its own `Compartment`. CodeMirror computes compartment values per state, so a shared instance was measured **not** to leak across editors — resetting one editor left another's `done`/`undone` stacks intact. A per-editor instance is still the right contract: it makes "this editor owns its history slot" structural instead of incidental, and it keeps a plugin-level singleton from becoming the thing that breaks if a future CodeMirror version resolves compartments differently. + +Plugins opt in with `historyCompartment?: true`, and `createHistoryPlugin()` sets it. Plugins that install `history()` themselves keep working unchanged and simply keep their history across document loads. + +- Alternative considered: exporting a factory so plugins could create the compartment and hand it back — rejected as a wider public API with an ordering hazard for no additional capability. +- Alternative considered: an internal marker that does not appear on `NexusPlugin` — rejected as hidden coupling; a documented optional field is honest about the contract. + +### Decision: no new dependency + +Everything used is already exported by current dependencies: `Compartment` and `Transaction.addToHistory` from `@codemirror/state`, which `packages/core` already depends on. `@codemirror/commands` is not even needed for the reset path. This change adds no `dependencies` entry, so `GOVERNANCE.md` §6.3 does not apply. + +## Risks / Trade-offs + +- **Toggling a third-party extension off and back on** → Uses only documented `Compartment` behaviour, and the resulting semantics are locked by regression tests covering first load, load-after-undo, load-after-edit, and no-history-plugin. If `@codemirror/commands` ever exposes a supported reset, this can be simplified. +- **A plugin declaring `historyCompartment` moves its extensions into the compartment** → The contract is documented on `NexusPlugin.historyCompartment` and covered by the `"reset"` tests; a plugin that both declares it and expects its extensions on the plain path would notice immediately, because its history would reset on load. +- **`"reset"` degrades to a no-op when no history plugin is installed** → `historyExtensions` is empty, so the editor skips both dispatches instead of reconfiguring. A host without history has no stacks to clear, and the option is documented as a no-op there. +- **A host that expects file-switch undo** → This is the bug being fixed, and the option is opt-in, so only `EditorShell.loadDocument` changes behavior. The framework bindings keep their current controlled-sync semantics. +- **Two existing `plugin-history` tests assert the buggy assumption** → They are re-pointed to `history: "record"` so the test keeps covering "a recorded replacement is undoable" while a new case covers the boundary. Re-pointing is deliberate, not test-fitting, and it is called out in the PR description. +- **Undo across `"skip"` loads** → A `"skip"` load is still mapped, so position mapping is the only expectation; the scenario pins that a later `"record"` edit undoes to the skipped content, not past it. +- **Two dispatches instead of one for `"reset"`** → Visible to any transaction filter that counts dispatches per `setDocument` call. Accepted: it is the only way to express the toggle, and both dispatches happen inside one synchronous call. + +## Migration Plan + +Additive and opt-in. `EditorShell.loadDocument` is the only call site that switches to `"reset"`; every other caller keeps `"record"` by default and needs no change. No data migration, no serialized-state format change, no public API removal. Rollback is reverting the option plumbing — the default path is untouched. + +## Open Questions + +- Should `replaceRange` grow the same option? Deferred deliberately: no caller needs it today, and adding it later is a compatible change. Recorded here so the omission is a decision rather than an oversight. +- Should a `"reset"` load also reset scroll position or folding state? Out of scope; the load path already owns viewport behavior in the demo shell. diff --git a/openspec/changes/fix-document-load-undo-boundary/proposal.md b/openspec/changes/fix-document-load-undo-boundary/proposal.md new file mode 100644 index 00000000..886d5551 --- /dev/null +++ b/openspec/changes/fix-document-load-undo-boundary/proposal.md @@ -0,0 +1,24 @@ +# Change: Isolate undo history across document loads + +## Why +Opening a file is recorded as an undoable edit. `loadDocument` loads disk content through `editor.setDocument(content, { silent: true })`, and `silent` only suppresses the `onChange` pipeline — the whole-document replacement still lands in the CM6 undo stack. Consequences a user hits immediately: + +- Open a file, press Ctrl+Z before typing anything, and the buffer is reverted to the empty document that preceded the load (content appears deleted). +- Edit A, open B, press Ctrl+Z in B, and A's text is restored into B's buffer. +- Undo in B can also redo A's edits, because the redo stack survives the switch. + +This is a data-presentation bug in the load path, not a request for new undo behavior. + +## What Changes +- **ADDED** `SetDocumentOptions.history?: "record" | "skip" | "reset"` on `setDocument`, with `"record"` as the default so every existing programmatic-replacement call site keeps its current undo semantics (toolbar formatting, search replace-all). + - `"skip"` — the replacement participates in history mapping but is not recorded as an undo entry. + - `"reset"` — the replacement is not recorded **and** the undo and redo stacks are cleared, making the load an explicit document boundary. Implemented by routing the history extension through a per-editor compartment and rebuilding it. +- **ADDED** optional `NexusPlugin.historyCompartment` so the editor installs a plugin's history extension inside that compartment instead of the plain extension list. `createHistoryPlugin()` declares it. +- **MODIFIED** `apps/electron-demo` `EditorShell.loadDocument` to pass `history: "reset"`, so file open and file switch start a fresh history. +- **No breaking changes**: the option is additive and defaults to today's behavior; plugins that install `history()` themselves are unaffected. + +## Impact +- Affected specs: editor-core +- Affected code: `packages/core/src/types.ts`, `packages/core/src/editor.ts`, `packages/plugin-history/src/index.ts`, `apps/electron-demo/src/renderer/editor-shell.ts`, `README.md` +- No new runtime dependencies; `Compartment` and `Transaction.addToHistory` come from `@codemirror/state`, a current `packages/core` dependency. +- Hosts that do not load `plugin-history` are unaffected: `history: "reset"` degrades to a no-op instead of throwing. diff --git a/openspec/changes/fix-document-load-undo-boundary/specs/editor-core/spec.md b/openspec/changes/fix-document-load-undo-boundary/specs/editor-core/spec.md new file mode 100644 index 00000000..2e7df58d --- /dev/null +++ b/openspec/changes/fix-document-load-undo-boundary/specs/editor-core/spec.md @@ -0,0 +1,58 @@ +# Editor Core Spec + +## ADDED Requirements + +### Requirement: setDocument undo history boundary + +`editor.setDocument(next, opts?)` SHALL accept `opts.history` with the values `"record"`, `"skip"`, and `"reset"`. The option SHALL default to `"record"`, which keeps the whole-document replacement undoable exactly as before this change. + +- `"record"` — the replacement SHALL produce one undo entry. +- `"skip"` — the replacement SHALL NOT be recorded as an undo entry. Previously recorded entries SHALL remain undoable, and the changed positions SHALL still be mapped through the replacement. +- `"reset"` — the replacement SHALL NOT be recorded as an undo entry AND the undo and redo stacks SHALL be cleared, so no history entry before the load can be reached in either direction. + +`opts.history` SHALL be independent of `opts.silent`; either may be used without the other. When no CodeMirror history extension is installed, `opts.history` SHALL be accepted without throwing and the document content SHALL still be replaced. + +#### Scenario: default replacement stays undoable +- **GIVEN** an editor with the history extension, initial content `"start"`, and no prior edits +- **WHEN** `setDocument("next")` is called without `opts.history` +- **THEN** `undo()` SHALL return `true` +- **AND** `getDocument()` SHALL return `"start"` + +#### Scenario: skip is not recorded but keeps earlier entries undoable +- **GIVEN** an editor with the history extension whose content is `"A"` +- **WHEN** `setDocument("B", { history: "skip" })` is called and then `setDocument("C", { history: "record" })` is called +- **THEN** one `undo()` SHALL return `true` +- **AND** `getDocument()` SHALL return `"B"` +- **AND** the next `undo()` SHALL return `false`, because the `"skip"` load created no entry + +#### Scenario: undo cannot revert a reset load +- **GIVEN** an editor with the history extension whose content is `"A"` +- **WHEN** `setDocument("B", { history: "reset" })` is called +- **THEN** `undo()` SHALL return `false` +- **AND** `getDocument()` SHALL return `"B"` + +#### Scenario: an edit after a reset load undoes back to the loaded content +- **GIVEN** an editor with the history extension whose content is `"A"` +- **WHEN** `setDocument("B", { history: "reset" })` is called and then `setDocument("B edited", { history: "record" })` is called +- **THEN** `undo()` SHALL return `true` +- **AND** `getDocument()` SHALL return `"B"` +- **AND** a second `undo()` SHALL return `false` + +#### Scenario: reset clears the redo stack +- **GIVEN** an editor with the history extension whose content is `"A"` that has been replaced with `"B"` and then undone, so `"B"` sits on the redo stack +- **WHEN** `setDocument("C", { history: "reset" })` is called +- **THEN** `redo()` SHALL return `false` +- **AND** `getDocument()` SHALL return `"C"` + +#### Scenario: reset without a history extension does not throw +- **GIVEN** an editor created without the history extension +- **WHEN** `setDocument("next", { history: "reset" })` is called +- **THEN** the call SHALL NOT throw +- **AND** `getDocument()` SHALL return `"next"` + +#### Scenario: a reset load deferred by IME composition still applies +- **GIVEN** an editor with the history extension whose content is `"A"` and an active IME composition +- **WHEN** `setDocument("B", { history: "reset" })` is called while composition is active +- **THEN** the replacement SHALL be deferred until composition ends +- **AND** after composition ends `getDocument()` SHALL return `"B"` +- **AND** `undo()` SHALL return `false` diff --git a/openspec/changes/fix-document-load-undo-boundary/tasks.md b/openspec/changes/fix-document-load-undo-boundary/tasks.md new file mode 100644 index 00000000..a0eac1a5 --- /dev/null +++ b/openspec/changes/fix-document-load-undo-boundary/tasks.md @@ -0,0 +1,32 @@ +# Tasks: fix-document-load-undo-boundary + +## Phase 1: Spec and failing tests + +- [x] 1.1 Create `openspec/changes/fix-document-load-undo-boundary/proposal.md`, `design.md`, `tasks.md`, and `specs/editor-core/spec.md` +- [x] 1.2 Add red tests for the `history` option in `packages/core/test/editor.test.ts` (default records, `skip`, `reset`, redo isolation, `reset` and `skip` without a history extension, IME-deferred load) +- [x] 1.3 Add red integration tests in `apps/electron-demo/test/editor-shell.test.ts` for the `loadDocument` boundary and for isolation between two live shells +- [x] 1.4 Re-point the two `packages/plugin-history/test/plugin-history.test.ts` cases that assumed `setDocument` is undoable, and add a `reset` isolation case +- [x] 1.5 Run the targeted suites and confirm the new cases fail for the expected reason before implementing + +## Phase 2: Core API + +- [x] 2.1 Add `history?: "record" | "skip" | "reset"` and its JSDoc to `SetDocumentOptions` in `packages/core/src/types.ts` +- [x] 2.2 Annotate `performSetDocument` in `packages/core/src/editor.ts`: `Transaction.addToHistory.of(false)` for `skip`/`reset` +- [x] 2.3 Add the optional `historyCompartment` marker to `NexusPlugin` (`packages/core/src/types.ts`) and declare it in `createHistoryPlugin()` +- [x] 2.4 Give `createEditor` a per-editor `historyCompartment` that installs the declaring plugin's `cmExtensions` +- [x] 2.5 For `reset`, drop the compartment and install a fresh history instance in the load transaction; skip both dispatches when no history plugin is present + +## Phase 3: Integration and docs + +- [x] 3.1 Pass `history: "reset"` from `EditorShell.loadDocument` in `apps/electron-demo/src/renderer/editor-shell.ts` +- [x] 3.2 Document the option and the load-boundary semantics in `README.md` +- [x] 3.3 Confirm the targeted suites pass + +## Phase 4: Validation + +- [ ] 4.1 `pnpm typecheck` +- [ ] 4.2 `pnpm test` +- [ ] 4.3 `pnpm check:api` +- [ ] 4.4 `pnpm build` and `pnpm build:electron-demo` +- [ ] 4.5 Manual check in electron-demo: open → Ctrl+Z (no change) → edit → Ctrl+Z (back to loaded content) → Ctrl+Shift+Z → open another file → Ctrl+Z (does not restore the previous file) +- [ ] 4.6 Run `openspec validate fix-document-load-undo-boundary --strict` if the OpenSpec CLI is available