From 06204feba4a638cf4daa3db58c76a0f5bcbb9fe5 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Mon, 22 Jun 2026 12:30:58 +0200 Subject: [PATCH] fix(undo): keep per-document undo so Cmd+Z survives file switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single reused NSTextView shared one undo manager across all open documents, and the editor wiped it via removeAllActions() on every document switch — so Cmd+Z stopped working after changing files. Vend a per-documentId UndoManager through the new undoManager(for:) delegate method; each file keeps its own stack that survives switching away and back. On switch we break undo coalescing on the outgoing document instead of clearing it, and evict undo stacks for documents no longer retained (alongside the existing scrollOffsets pruning). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ativeTextViewCoordinator+TextDelegate.swift | 18 ++++++++++++++++++ .../NativeTextViewCoordinator.swift | 6 ++++++ .../TextView/NativeTextViewWrapper.swift | 17 ++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index aa9dd33..b483959 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -16,6 +16,24 @@ import AppKit extension NativeTextViewCoordinator { + /// Supplies a per-document `UndoManager` to the text view. + /// + /// AppKit reuses one `NSTextView` across every open document, so the built-in + /// view-wide undo manager would blend files together (and used to be wiped on + /// each switch). Returning a manager keyed on the current `documentId` gives + /// each file its own undo stack that survives switching away and back. + /// Returning the *same* instance for a given document on every call is + /// required — a fresh manager per call breaks undo. + public func undoManager(for view: NSTextView) -> UndoManager? { + let key = documentId ?? "__default__" + if let existing = undoManagers[key] { + return existing + } + let manager = UndoManager() + undoManagers[key] = manager + return manager + } + /// Force base typingAttributes on every change so AppKit's auto-inheritance /// can't bleed a heading paragraphStyle into the trailing extra-line /// fragment's metrics. diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 2fa786f..f21e8c9 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -22,6 +22,12 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { /// Remembered scroll offset (`bounds.origin.y`) per `documentId` — saved on /// switch-away, restored on switch-back. var scrollOffsets: [String: CGFloat] = [:] + /// Per-`documentId` undo manager. AppKit reuses a single `NSTextView` across + /// all open documents, so its built-in (view-wide) undo manager would mix + /// files together. Keying a manager on the current document gives each file + /// its own undo stack that survives switching away and back. Vended through + /// the `undoManager(for:)` delegate method; pruned alongside `scrollOffsets`. + var undoManagers: [String: UndoManager] = [:] @Binding var text: String @Binding var isWikiLinkActive: Bool var fontName: String diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 59d1ed7..bc4c3fd 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -367,6 +367,16 @@ public struct NativeTextViewWrapper: NSViewRepresentable { $0.key == documentId || retained.contains($0.key) } } + // Evict undo stacks for documents no longer retained (keep the + // current one). removeAllActions() first so a stale registered undo + // can't later fire against a swapped-out document. + let staleUndoKeys = context.coordinator.undoManagers.keys.filter { key in + key != documentId && key != "__default__" && !retained.contains(key) + } + for key in staleUndoKeys { + context.coordinator.undoManagers[key]?.removeAllActions() + context.coordinator.undoManagers.removeValue(forKey: key) + } } let wtActive: Bool = { @@ -479,8 +489,13 @@ public struct NativeTextViewWrapper: NSViewRepresentable { retainedScrollDocumentIds?.contains(outgoingId) ?? true { context.coordinator.scrollOffsets[outgoingId] = nsView.contentView.bounds.origin.y } + // Per-document undo: close the OUTGOING document's open coalescing group + // (while its manager is still active), then switch the active documentId so + // `undoManager(for:)` starts vending the INCOMING document's own manager. We + // no longer clear undo here — that `removeAllActions()` is what killed Cmd+Z + // across a file switch. + textView.breakUndoCoalescing() context.coordinator.documentId = documentId - textView.undoManager?.removeAllActions() context.coordinator.didInitialFormatting = false context.coordinator.didEnsureLayoutForCurrentDocument = false context.coordinator.resetImageEmbedState()