Skip to content

fix(core): isolate undo history across document loads - #354

Open
WolzRack wants to merge 4 commits into
floatboatai:mainfrom
WolzRack:fix/core-undo-history-boundary
Open

WolzRack wants to merge 4 commits into
floatboatai:mainfrom
WolzRack:fix/core-undo-history-boundary

Conversation

@WolzRack

Copy link
Copy Markdown

Summary / 摘要

Loading a file is recorded as an undoable edit, so Ctrl+Z before typing anything reverts the freshly opened buffer — and a redo left over from the previous document restores that document into the new one. setDocument now takes a history option, and the demo's file-open path uses it to make a load a real history boundary.

Motivation / 背景与动机

  • Issue: 打开文件后未编辑按 Ctrl+Z 会清空/回退缓冲区内容;编辑 A 后打开 B,在 B 中 Undo 会恢复 A 的内容
  • Roadmap (docs/ROADMAP.md): 相关条目 feat(core): add getSelectedText() API #8 Undo / redo grouping(本 PR 不含 grouping,只修边界)
  • OpenSpec change: openspec/changes/fix-document-load-undo-boundary/

loadDocument loads disk content with setDocument(content, { silent: true }). silent only suppresses the onChange / change event and the AST resync — it never touched the CM6 undo stack, so the whole-document replacement was recorded like a user edit. Result:

open file → Ctrl+Z → buffer reverts to the pre-load (often empty) document
edit A → open B → Ctrl+Z in B → A's text appears in B
edit A → undo → open B → Ctrl+Shift+Z in B → A's edit comes back

silent could not simply be reinterpreted as "document load": packages/react and packages/vue also use it for controlled-value sync, where dropping history would be wrong.

Changes / 变更内容

  • packages/core:
    • SetDocumentOptions.history?: "record" | "skip" | "reset", default "record" so every existing call site keeps today's semantics.
    • "skip" → Transaction.addToHistory.of(false); the load is not an undo entry but earlier entries stay mapped.
    • "reset" → also rebuilds the history extension: the editor owns a per-editor Compartment for history, and reset toggles the plugin's history() out and back in.
    • NexusPlugin.historyCompartment?: true — the opt-in marker that routes a plugin's cmExtensions through that compartment.
  • packages/plugin-history:
    • createHistoryPlugin() declares historyCompartment: true. Plugins that install history() themselves keep working unchanged and simply retain history across loads.
  • apps/electron-demo:
    • EditorShell.loadDocument passes { silent: true, history: "reset" }. All four shell.loadDocument(...) call sites in app.ts inherit the boundary, including close-file (loadDocument("")), which runs behind confirmDiscardIfDirty().
    • Untouched on purpose: search-bar.ts replace-all and plugin-toolbar formatting still rely on the "record" default so their edits stay undoable.
  • openspec/:
    • New change fix-document-load-undo-boundary (proposal / design / tasks / specs/editor-core delta with 7 scenarios).
  • README.md:
    • Documents the history option and the load-boundary semantics; updates the plugin-history row.

Why "reset" needs two dispatches (the part worth reviewing)

Parallel attempts were measured against real CodeMirror state before choosing; three of them silently do not work:

  • Transaction.addToHistory.of(false) alone does not clear what is already on the stack — CM6 keeps every earlier entry and maps the change through them.
  • isolateHistory.of("full") bounds the redo stack only; measured, done still held the previous document's edit and undo produced "BA".
  • historyField.init(factory) looks like a value replacement, but it returns an extension array ([field, initField.of(...)]), not a dispatchable effect — passing it as an effect throws effect.is is not a function. There is no public API to write a live field value.

What works is reconfiguring the history extension itself. A compartment keeps only the last reconfigure in a transaction, so performSetDocument issues an effects-only dispatch that drops the extension (and both stacks), then the load transaction that installs a fresh instance. Both run synchronously in one call, so no input can slip into the gap.

Two details that a first draft got wrong and the tests now pin down:

  • The history extensions must live in the compartment only. The first working draft also spread the plugin's cmExtensions into the plain extension list. Clearing the compartment then left the duplicate copy alive, so "reset" silently did nothing even though the disable dispatch ran (historyExtensions: 2, hasDisable: true, yet undone stayed at 1). Declaring historyCompartment: true now moves those extensions into the compartment.
  • The compartment is per-editor. Measured: a shared instance does not in fact leak — CodeMirror computes compartment values per state, so resetting one editor left another's stacks intact. Per-editor is kept as the explicit contract (each editor owns its history slot) rather than as a bug fix, and a createEditorShell test locks that isolation.

Testing / 测试

  • pnpm test passes / 全绿 — Test Files 1 failed | 67 passed, Tests 10 failed | 900 passed (910)
  • Affected packages build (pnpm build) / 受影响包构建通过
  • pnpm typecheck clean, pnpm check:api passes, pnpm build:electron-demo succeeds
  • New / updated vitest cases / 新增或更新的 vitest 用例:
    • packages/core/test/editor.test.ts — 8 new cases in setDocument undo history boundary (default records; skip; reset; undo-after-edit stops at the loaded content; redo stack cleared; no history plugin installed — reset and skip; reset load deferred by IME composition)
    • packages/plugin-history/test/plugin-history.test.ts — 2 existing cases re-pointed to history: "record" (they asserted the buggy assumption that a bare setDocument is undoable) + 2 new isolation cases
    • apps/electron-demo/test/editor-shell.test.ts — 2 integration cases over the real createEditorShell + loadDocument path, including that resetting one shell leaves another live shell's stacks intact
  • Manual UI check in electron-demo / electron-demo 手动验证:按照该流程进行了手动验证,测试见末尾gif:打开 → Ctrl+Z(内容不变)→ 编辑 → Ctrl+Z(回到加载内容)→ Ctrl+Shift+Z → 打开另一个文件 → Ctrl+Z(不恢复上一个文件)

Pre-existing failures, not caused by this change

apps/electron-demo/test/plugin-host-broker.test.ts fails 10 cases on this Windows machine both before and after the change (888 passed / 10 failed on a clean main checkout, 900 passed / 10 failed with this change). They are environmental: symlink creation needs elevation (EPERM) and O_NONBLOCK has different semantics on Windows. None of them touch undo history.

The first red run of the new tests failed for the right reason (option not implemented yet); the IME case initially failed on my own test harness (fake timers prevented CodeMirror from initializing), which I fixed before implementing.

Compliance / 合规自检

  • CLA signed — first-time contributors will be prompted automatically by the CLA bot
  • AI disclosure: the functional code in this PR is not primarily generated by AI — I located the root cause, chose the design, and can defend every decision above. AI assistance, if any, is described below.
    AI-assisted notes / AI 使用说明:bug发现为本人实际使用demo后发现,交给Codex,使用GPT6Astra 验证和提出解决方案,使用DSH搭配DSv4.1Flash实现.
  • New dependencies: none. Compartment, StateEffect, and Transaction.addToHistory come from @codemirror/state, already a packages/core dependency. @codemirror/commands is not used by the reset path at all.
  • No build artifacts committed (dist/, dist-electron/, compiled .js from .ts) — git status shows only source, tests, README, and the OpenSpec change
  • No secrets / .env / personal vault data committed

Checklist / 自检清单

  • Title follows Conventional Commits / 标题遵循 Conventional Commits
  • Public API changes update package README / types — README.md documents history; both new options carry JSDoc. No exports were added or removed, so the check:api snapshot is unchanged.
  • Touched live-preview-table.ts → walked through the 12 Table Widget rules in CLAUDE.md — not touched
  • New capability / breaking change → OpenSpec proposal linked — additive option, no breaking change; proposal included for the public API addition
  • Change aligns with project scope (GOVERNANCE.md §4) — packages/core bug fix + demo caller

Screenshots / Recordings · 截图或录屏 (UI changes)

修复前:
before
修复后:
fixed

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants