From 698795d8fc6a3925b90cadd824e464bb231d04e6 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 22 Oct 2026 07:52:51 -0700 Subject: [PATCH 1/3] Repair document history ownership, synchronization, and recovery Bound typing transactions by monotonic age and accumulated size, and give editor sessions distinct group identities. Route document field actions through the shared history, including exhausted operations. Reconcile accepted source and editing positions independently of parent frames, reject retired asynchronous publications, and retain displaced content at whole-document replacement boundaries. Protect each distinct pre-traversal source with independent retained recovery work. Add deterministic, application, persistence and native GTK regressions, and document the durability boundary. --- docs/development/history.md | 166 ++- lib/src/editor/document_edit_history.dart | 167 +++ lib/src/editor/source/source_editor.dart | 487 ++++--- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 634 +++++++--- .../local_history_controller.dart | 122 +- .../local_history/local_history_models.dart | 1 + .../local_history/local_history_panel.dart | 2 + .../local_history/local_history_store.dart | 48 +- lib/src/workspace/document_buffer.dart | 31 + .../presentation/workspace_screen.dart | 58 + lib/src/workspace/workspace_controller.dart | 243 ++-- test/src/app_smoke_test.dart | 1124 ++++++++++++++++- test/src/document_edit_history_test.dart | 117 ++ test/src/local_history_store_test.dart | 58 + test/src/local_history_workspace_test.dart | 468 +++++++ test/src/nextcloud_workspace_test.dart | 47 + test/src/source_editor_widget_test.dart | 341 +++++ .../src/wysiwyg_selection_lifecycle_test.dart | 30 + test/src/wysiwyg_session_test.dart | 218 ++++ .../wysiwyg_structured_regression_test.dart | 7 + tools/document_history_smoke.dart | 503 ++++++++ 21 files changed, 4326 insertions(+), 546 deletions(-) create mode 100644 lib/src/editor/document_edit_history.dart create mode 100644 test/src/document_edit_history_test.dart create mode 100644 tools/document_history_smoke.dart diff --git a/docs/development/history.md b/docs/development/history.md index 442c8c0c..c3b3aa8e 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -3,6 +3,163 @@ This document records the invariants behind the user-facing [Clipboard History and Local History](../history.md) features. +## Document Undo/Redo + +`DocumentBuffer` owns application document history across Source, Editor and +Split. Flutter field history remains available for auxiliary inputs. Document +fields override Flutter's overridable `UndoTextIntent` and `RedoTextIntent` +actions, including rich table cells and the hidden table-selection IME client. +A configured shortcut or action is consumed even when its document stack is +exhausted. Exhaustion changes no source, revision, dirty flag, stack or checkpoint. +The field controllers are retained; removing an explicit Flutter undo controller +would create another implicit one. + +Both editors use `DocumentUndoGrouping`: a monotonic `Stopwatch` in production +and an injected `Duration` clock in deterministic tests. A group accepts edits +less than two seconds apart, lasts strictly less than ten seconds from its +first edit, and contains at most 64 inserted/deleted UTF-16 code units. The +next edit at the age/idle boundary, beyond the size budget, or after a reversed +test clock starts a new group. The ten-second/64-unit bounds retain short +phrases while limiting continuous-work loss independently of the 100-entry +history limit. Group IDs include a process-wide editor-session sequence. + +Typing, backspace and forward deletion have separate groups. A single-unit +operation is validated at its actual old/new carets, including ambiguous runs +of repeated characters. A selection replacement, caret relocation, +line/paragraph change, explicit Source command, +paste, cut, formatting, search replacement, spelling correction and inspector +transaction close continuous typing. Structural rich operations already publish +discrete transactions. Source observes caret moves as they happen, so leaving +and returning to the same offset cannot reconnect a typing group. IME +composition retains its existing input and command +guards. + +Each editor tracks its last accepted source/revision independently of parent +frames. Live shared-history identity distinguishes same-content save metadata +from traversal. Closing a group at the same revision preserves the accepted +content while starting a fresh transaction, including Source-to-Split input +before a parent frame. Controller publications also carry expected source/revision +preconditions. Consecutive edits advance that accepted base synchronously; +Undo/Redo or a different authoritative source invalidates it. The rich editor +reconciles after keyboard traversal immediately and on authoritative rebuilds, +including an A → B → A cycle that never rendered B in the parent. Rejected +publications reconcile the editor, and only accepted structured documents enter +the workspace cache. Uncontrolled rich widgets can change navigation properties +while retaining the same document object. Save As also updates the mounted rich +editor's publication path. +Source also applies the live readers' text and caret during reconciliation: an +unrelated rebuild carrying older properties cannot roll back an accepted edit +or restore a retired selection. +Its document-keyed field owns a matching focus-node lifetime. Reusing the old +focused node after field disposal leaves its keyboard token consumed and its +native text-input client cleared; document changes create a fresh node while +ordinary edits retain it. + +Rich edits publish their live pre-edit caret or document range before opening +the transaction. Stable text inputs also publish their resulting position +synchronously. Traversal restores selections immediately, including table-cell +ownership; deferred mounting and scroll restoration may proceed only while that +selection is still current. A later caret move or edit supersedes that callback. +This keeps no-frame Undo/Redo bursts and formatting ranges independent of the +normal post-frame session publication. + +Source clipboard and link operations retain an editing-epoch token as well as +their origin value. Content, selection, composition and controller lifetime +changes retire the token, even when Undo/Redo returns to the same value before +a rebuild. A supplied live document reader also validates the accepted version +after asynchronous work. Metadata-only acknowledgements do not retire an origin. + +`DocumentBuffer.replaced` makes an actual whole-source replacement one undoable +boundary. It preserves displaced source, selection, rich session and text-format +metadata, closes grouping and clears the displaced redo branch. Reload, clean +file-monitor replacement, workspace refresh, remote replacement and discard use +that boundary. Workspace refresh reconciles the replacement with the live editor +state rather than reattaching its old history. Same-content refresh/save and +derived rendering retain valid history. Traversal restores BOM, line endings and +final-newline metadata together with source and caret. + +## Recovery before traversal + +Before observing a successful Undo or Redo, Local History stages a separate +`beforeHistoryTraversal` protection for the source being left. Each unresolved +source has a distinct retained-work owner; subsequent traversal, a branching +edit, or another protective operation cannot replace it. The ordinary pending +checkpoint still follows the latest source on its fixed `putIfAbsent` timer. +The checkpoint window is not a debounce. + +A bounded set deduplicates repeated source/format protections per document +identity and recording epoch. That set contains metadata only: unresolved full +sources remain independently owned even if a deduplication key ages out. +Under its existing capture lock, the store also lets traversal protection reuse +any exact source/format revision in the same lineage. Ordinary checkpoints keep +their adjacent-only deduplication rule and timeline semantics. +If committed revisions share a timestamp, adjacency chooses the later appended +revision; the clock tie must not select an older baseline and duplicate a +checkpoint that protection already stored. +Recording-disabled and excluded documents create no traversal captures. Existing +failure banners, checkpoint-cadence retries, retained-work journals and startup +policy hydration remain in use. + +The first Undo after typing into an empty untitled buffer establishes its normal +baseline before binding the independent protection. It retains the single +history lineage that this case already had before the traversal repair. + +Undo/Redo is synchronous. Staging a protection in memory does **not** establish +crash durability. A recoverable boundary is reached after a successful retained +session-journal publication or Local History record publication. Required close +and shutdown settlement still drain that work and retain failures. A process +terminated before either publication completes can lose the newest in-memory +protection. The restart regression reads a fresh on-disk session journal before +retiring its original controller, then verifies both unresolved pre-traversal +sources in Local History and the latest branch in dirty-document recovery. + +## Document-history regression cases + +The maintained suites exercise these state transitions through production +editors/controllers and real keyboard or action dispatch: + +| Trigger | Required result | +| --- | --- | +| Fifteen minutes of one-second typing, including Source line breaks | Multiple finite groups; one Undo removes only the latest group. The injected clock is actually read by grouping. | +| Editor recreation, mode and tab return | A new typing transaction preserves earlier work. | +| Focused Source switches documents and returns | Native text input remains registered; raw keys reach the shared source. | +| Repeated exhausted shortcuts and platform actions | Source, revision, dirty status, stacks and pending checkpoint remain unchanged. | +| Type then Undo before a frame; immediately type again | Visible controller, rich projection/cache and shared source agree; restored content is not overwritten. | +| Source traversal invoked outside its keyboard handler | Historical caret is restored on the authoritative rebuild. | +| Caret moves away and back between two characters | The second character belongs to a new transaction. | +| Rich traversal before a frame, including table cells and formatting ranges | The live position, serialized model and shared session agree; a deferred restore cannot overwrite a newer caret. | +| Delayed Source paste, cut or link choice across Undo then Redo | The retired completion makes no edit, even when text, selection and widget revision match again. | +| Reload, monitor, workspace or remote replacement with both stacks populated | Replacement is undoable, the inappropriate redo branch is gone, displaced content survives. | +| Undo before checkpoint, then a new branch, with autosave on/off | The original pre-Undo work survives close/reopen. | +| Delayed/failed captures and multiple protections | Each obligation survives independently; a fresh disk-backed restart can finish recovery. | +| Same-content save/reload, composition and pending inspector draft | Existing editing, redo and ownership contracts remain intact. | + +The Source remount regression predates these repairs. WYSIWYG's exhausted +**keyboard** route already consumed no-ops; its Flutter action routes still +needed ownership overrides. Clipboard origin/selection checks, AI +proposal identity/revision checks, inspector draft guards, the fixed checkpoint +window and settings-before-pruning startup protection remain authoritative. + +A native acceptance target boots the production application and injects XTest +keys through GTK. It verifies content in the shared buffer, visible field, live +rich serialization, saved files and actual Local History records. It also exercises +mode/tab return, autosave, close/reopen, Local History restore and conflict-aware +reload. Use a disposable X11 display and isolated XDG directories: + +```sh +flutter build linux --release --no-pub -t tools/document_history_smoke.dart +# Create OUTPUT/data, config, cache, state, run and tmp first; chmod 700 OUTPUT/run. +# Set each XDG_*_HOME and XDG_RUNTIME_DIR beneath the absolute OUTPUT directory. +BUSYMARK_HISTORY_DISPOSABLE=1 GDK_BACKEND=x11 WAYLAND_DISPLAY= \ + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x1000x24' \ + build/linux/x64/release/bundle/busymark /absolute/OUTPUT +flutter build linux --release --no-pub -t lib/main.dart +``` + +Keep the resulting logs and `result.json` outside the repository. A widget pass, +a generated report before all assertions finish, or a controller-only call is +not native acceptance. + ## Clipboard publication and ownership `RichClipboardService` serializes clipboard reads and writes and owns the @@ -101,14 +258,15 @@ work. `LocalHistoryController` treats each `DocumentBuffer` as the source authority. Capture events are baseline, explicit save, automatic checkpoint, before reload, -before discard, before restore, before delete, and observed external change. +before discard, before restore, before delete, before Undo/Redo, and observed +external change. Autosave writes do not create their own history reason. Observation is serialized per buffer. The first edit starts a fixed checkpoint window; later edits replace the one pending full-source snapshot without moving -the deadline. A flush is a queue barrier. Retryable storage failures retain only -the latest pending source for that buffer and retry on the configured checkpoint -cadence. Unsupported or over-limit content fails visibly without a retry loop. +the deadline. A flush is a queue barrier. Retryable ordinary checkpoint failures retain the latest pending source for +that buffer. Independent protective sources also remain retained; retries use +the configured checkpoint cadence. Unsupported or over-limit content fails visibly without a retry loop. History identity is independent of widgets and temporary buffer IDs. Known file and directory moves remap paths while preserving earlier paths. The first save diff --git a/lib/src/editor/document_edit_history.dart b/lib/src/editor/document_edit_history.dart new file mode 100644 index 00000000..3b2155bd --- /dev/null +++ b/lib/src/editor/document_edit_history.dart @@ -0,0 +1,167 @@ +import 'package:flutter/widgets.dart'; + +/// The last source accepted by the document, independent of parent rebuilds. +/// Editors advance this base synchronously after each accepted publication. +@immutable +class DocumentEditBase { + const DocumentEditBase(this.source, this.revision, [this.history]); + final String source; + final int? revision; + final Object? history; + + /// Saves may advance a remote revision without invalidating editor content. + /// History identity distinguishes that acknowledgement from an ABA traversal. + /// Closing a group changes history metadata without advancing the revision. + bool sameDocumentVersion(DocumentEditBase other) => + source == other.source && + ((revision != null && revision == other.revision) || + (history != null && other.history != null + ? identical(history, other.history) + : revision == other.revision)); + + @override + bool operator ==(Object other) => + other is DocumentEditBase && + source == other.source && + revision == other.revision && + identical(history, other.history); + @override + int get hashCode => Object.hash(source, revision, identityHashCode(history)); +} + +typedef DocumentEditBaseReader = DocumentEditBase? Function(); +typedef DocumentUndoClock = Duration Function(); + +enum DocumentSimpleEditKind { typing, backspace, delete } + +/// Text-only diffs cannot locate an edit inside repeated characters. Validate +/// the one-unit operation at the old/new carets before deciding its group. +DocumentSimpleEditKind? documentSimpleEditKind({ + required String before, + required String after, + required TextSelection previousSelection, + required TextSelection selection, +}) { + if (!previousSelection.isValid || + !previousSelection.isCollapsed || + !selection.isValid || + !selection.isCollapsed) { + return null; + } + final caret = previousSelection.extentOffset; + final next = selection.extentOffset; + bool matches(int start, int removed, int inserted) { + if (start < 0 || + start + removed > before.length || + start + inserted > after.length) { + return false; + } + final unit = inserted == 1 + ? after.codeUnitAt(start) + : before.codeUnitAt(start); + if (unit == 10 || unit == 13) return false; + for (var i = 0; i < start; i++) { + if (before.codeUnitAt(i) != after.codeUnitAt(i)) return false; + } + for ( + var i = start + removed, j = start + inserted; + i < before.length; + i++, j++ + ) { + if (before.codeUnitAt(i) != after.codeUnitAt(j)) return false; + } + return true; + } + + if (after.length == before.length + 1 && + next == caret + 1 && + matches(caret, 0, 1)) { + return DocumentSimpleEditKind.typing; + } + if (after.length == before.length - 1) { + if (next == caret - 1 && matches(caret - 1, 1, 0)) { + return DocumentSimpleEditKind.backspace; + } + if (next == caret && matches(caret, 1, 0)) { + return DocumentSimpleEditKind.delete; + } + } + return null; +} + +/// A short word-sized transaction, bounded independently of inactivity. +/// Stopwatch is monotonic: system clock adjustments cannot prolong a group. +class DocumentUndoGrouping { + DocumentUndoGrouping({DocumentUndoClock? clock}) : _clock = clock ?? _now; + + static const idleLimit = Duration(seconds: 2); + static const ageLimit = Duration(seconds: 10); + static const sizeLimit = 64; // Inserted + removed UTF-16 code units. + static final _stopwatch = Stopwatch()..start(); + static Duration _now() => _stopwatch.elapsed; + static var _sessionSequence = 0; + final _session = ++_sessionSequence; + final DocumentUndoClock _clock; + var _sequence = 0; + String? _group; + Duration? _started; + Duration? _last; + int _size = 0; + + String next({ + required String owner, + required bool continues, + required int size, + }) { + final now = _clock(); + if (!continues || + _group == null || + now < _last! || + now < _started! || + now - _last! >= idleLimit || + now - _started! >= ageLimit || + _size + size > sizeLimit) { + _group = '$owner-$_session-${++_sequence}'; + _started = now; + _size = 0; + } + _last = now; + _size += size; + return _group!; + } +} + +/// Override Flutter's overridable field-history actions only around document +/// inputs. Exhaustion and disabled operations still consume their action. +class DocumentUndoActions extends StatelessWidget { + const DocumentUndoActions({ + super.key, + required this.onUndo, + required this.onRedo, + required this.child, + }); + final VoidCallback? onUndo; + final VoidCallback? onRedo; + final Widget child; + + @override + Widget build(BuildContext context) => Actions( + actions: { + if (onUndo != null) + UndoTextIntent: CallbackAction( + onInvoke: (_) { + onUndo!(); + return null; + }, + ), + if (onRedo != null) + RedoTextIntent: CallbackAction( + onInvoke: (_) { + onRedo!(); + return null; + }, + ), + }, + child: child, + ); +} diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 0ad226d7..622d47b4 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,3 +1,4 @@ +import '../document_edit_history.dart'; import '../../app/command_shortcuts.dart'; import '../../app/busymark_search_field.dart'; import '../link_destination.dart'; @@ -72,8 +73,6 @@ typedef BusyMarkSourceSessionChanged = enum SourceSymbolAction { declaration, usages, rename } -var _sourceEditorUndoSessionSequence = 0; - class BusyMarkSourceEditor extends StatefulWidget { const BusyMarkSourceEditor({ super.key, @@ -96,6 +95,9 @@ class BusyMarkSourceEditor extends StatefulWidget { this.onTransactionalChanged, this.onUndo, this.onRedo, + this.readEditBase, + this.readSelection, + this.undoClock, this.onOpenSearch, required this.onCloseSearch, this.onVisibleLineChanged, @@ -144,6 +146,9 @@ class BusyMarkSourceEditor extends StatefulWidget { final BusyMarkSourceTransactionalChanged? onTransactionalChanged; final TextEditingValue? Function()? onUndo; final TextEditingValue? Function()? onRedo; + final DocumentEditBaseReader? readEditBase; + final TextSelection? Function()? readSelection; + final DocumentUndoClock? undoClock; final VoidCallback? onOpenSearch; final VoidCallback onCloseSearch; final ValueChanged? onVisibleLineChanged; @@ -178,7 +183,9 @@ class BusyMarkSourceEditor extends StatefulWidget { class BusyMarkSourceEditorState extends State { late BusyMarkSourceController _controller; - late final FocusNode _focusNode; + Object _editingEpoch = Object(); + late TextEditingValue _lastObservedEditingValue; + late FocusNode _focusNode; late final ScrollController _scrollController; late final ScrollController _horizontalScrollController; late UndoHistoryController _undoController; @@ -207,8 +214,9 @@ class BusyMarkSourceEditorState extends State { _ContinuousSourceEdit? _continuousSourceEdit; _SourceSessionSnapshot? _lastPublishedSession; bool _sessionPublicationScheduled = false; - final _undoSessionId = ++_sourceEditorUndoSessionSequence; - var _undoGroupSequence = 0; + late final DocumentUndoGrouping _undoGrouping; + late DocumentEditBase _editBase; + DocumentEditBase get publicationBase => _editBase; late final _SourceClipboardInsertionTarget _historyInsertionTarget; RichClipboardService get _clipboard => @@ -339,6 +347,10 @@ class BusyMarkSourceEditorState extends State { @override void initState() { super.initState(); + _editBase = + widget.readEditBase?.call() ?? + DocumentEditBase(widget.text, widget.editRevision); + _undoGrouping = DocumentUndoGrouping(clock: widget.undoClock); _controller = BusyMarkSourceController( text: widget.text, language: widget.language, @@ -354,6 +366,7 @@ class BusyMarkSourceEditorState extends State { _restoreSessionState(); _lastPublishedSession = _sourceSessionSnapshot(); _syncSearchOptions(); + _lastObservedEditingValue = _fullEditingValue(); _controller.addListener(_handleControllerActivity); _scrollController.addListener(_scheduleSessionPublication); } @@ -383,25 +396,44 @@ class BusyMarkSourceEditorState extends State { widget.text != _pendingSharedSearchNavigation!.request.source)) { _cancelSharedSearchNavigation(); } + final nextBase = + widget.readEditBase?.call() ?? + DocumentEditBase(widget.text, widget.editRevision); + final incomingText = nextBase.source; + final authorityChanged = !nextBase.sameDocumentVersion(_editBase); + if (!identical(nextBase.history, _editBase.history)) { + _continuousSourceEdit = null; + } + _editBase = nextBase; var authoritativeDocumentChanged = false; if (pathChanged) { authoritativeDocumentChanged = true; _lastPath = path; _foldedRegionKeys.clear(); _withoutSessionPublication(() { - _replaceController(text: widget.text, language: widget.language); + _replaceController(text: incomingText, language: widget.language); _recomputeFoldRegions(resetCollapsed: true); _restoreSessionState(); }); _lastPublishedSession = _sourceSessionSnapshot(); - } else if (widget.text != _controller.fullText || languageChanged) { + } else if (incomingText != _controller.fullText || + languageChanged || + authorityChanged) { authoritativeDocumentChanged = true; _continuousSourceEdit = null; _withoutSessionPublication(() { _controller.replaceFullTextAndLanguage( - text: widget.text, + text: incomingText, language: widget.language, ); + final selection = + widget.readSelection?.call() ?? widget.initialSelection; + if (selection != null && selection.isValid) { + _controller.fullSelection = TextSelection( + baseOffset: selection.baseOffset.clamp(0, incomingText.length), + extentOffset: selection.extentOffset.clamp(0, incomingText.length), + ); + } _recomputeFoldRegions(resetCollapsed: languageChanged); }); } @@ -450,6 +482,19 @@ class BusyMarkSourceEditorState extends State { } void _handleControllerActivity() { + final value = _fullEditingValue(); + if (value != _lastObservedEditingValue) { + _lastObservedEditingValue = value; + _editingEpoch = Object(); + } + final continuous = _continuousSourceEdit; + if (continuous != null && + continuous.newText == _controller.fullText && + continuous.selection != _controller.fullSelection) { + // Observe the move itself: leaving and returning to the same caret must + // not reconnect typing on either side of that navigation. + _continuousSourceEdit = null; + } final pending = _pendingSharedSearchNavigation; if (pending != null && (pending.request.source != _controller.fullText || @@ -740,9 +785,8 @@ class BusyMarkSourceEditorState extends State { event, keyboard, )) { - final value = widget.onUndo?.call(); - if (value != null) { - _applyOwnedUndoValue(value); + if (widget.onUndo != null) { + _performDocumentHistory(redo: false); return KeyEventResult.handled; } } @@ -751,9 +795,8 @@ class BusyMarkSourceEditorState extends State { event, keyboard, )) { - final value = widget.onRedo?.call(); - if (value != null) { - _applyOwnedUndoValue(value); + if (widget.onRedo != null) { + _performDocumentHistory(redo: true); return KeyEventResult.handled; } } @@ -958,10 +1001,7 @@ class BusyMarkSourceEditorState extends State { ? widget.onUndo : widget.onRedo; if (callback != null) { - final value = callback(); - if (value != null) { - _applyOwnedUndoValue(value); - } + _performDocumentHistory(redo: !undo); } else if (undo) { _undoController.undo(); } else { @@ -1034,119 +1074,128 @@ class BusyMarkSourceEditorState extends State { }, child: DefaultTextHeightBehavior( textHeightBehavior: sourceTextHeightBehavior, - child: TextField( - readOnly: DocumentReadOnlyScope.of(context), - controller: _controller, - undoController: _undoController, - focusNode: _focusNode, - scrollController: _scrollController, - textDirection: TextDirection.ltr, - keyboardType: TextInputType.multiline, - autocorrect: false, - enableSuggestions: false, - smartDashesType: SmartDashesType.disabled, - smartQuotesType: SmartQuotesType.disabled, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: _sourceTextStyle, - strutStyle: sourceStrutStyle, - selectionHeightStyle: BusyMarkDocumentTextGeometry - .sourceSelectionHeightStyle, - selectionWidthStyle: - BusyMarkDocumentTextGeometry.selectionWidthStyle, - cursorColor: colors.foreground.withValues( - alpha: BusyMarkAlpha.sourceCursor, - ), - cursorHeight: - widget.editorFontSize * - BusyMarkTypography.sourceCursorHeightScale, - cursorWidth: BusyMarkStroke.sourceCursor, - decoration: const InputDecoration( - isCollapsed: true, - filled: false, - fillColor: BusyMarkLinuxPalette.transparent, - hoverColor: BusyMarkLinuxPalette.transparent, - focusColor: BusyMarkLinuxPalette.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: BusyMarkInsets.sourceEditor, - ), - contextMenuBuilder: (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: context.l10n.aiRefineWithAi, - readSpellingItems: - widget.readSpellingMenuItems == null - ? null - : (visibleOffset) => - widget.readSpellingMenuItems!( + child: DocumentUndoActions( + onUndo: widget.onUndo == null + ? null + : () => _performDocumentHistory(redo: false), + onRedo: widget.onRedo == null + ? null + : () => _performDocumentHistory(redo: true), + child: TextField( + readOnly: DocumentReadOnlyScope.of(context), + controller: _controller, + undoController: _undoController, + focusNode: _focusNode, + scrollController: _scrollController, + textDirection: TextDirection.ltr, + keyboardType: TextInputType.multiline, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: _sourceTextStyle, + strutStyle: sourceStrutStyle, + selectionHeightStyle: BusyMarkDocumentTextGeometry + .sourceSelectionHeightStyle, + selectionWidthStyle: BusyMarkDocumentTextGeometry + .selectionWidthStyle, + cursorColor: colors.foreground.withValues( + alpha: BusyMarkAlpha.sourceCursor, + ), + cursorHeight: + widget.editorFontSize * + BusyMarkTypography.sourceCursorHeightScale, + cursorWidth: BusyMarkStroke.sourceCursor, + decoration: const InputDecoration( + isCollapsed: true, + filled: false, + fillColor: BusyMarkLinuxPalette.transparent, + hoverColor: BusyMarkLinuxPalette.transparent, + focusColor: BusyMarkLinuxPalette.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: BusyMarkInsets.sourceEditor, + ), + contextMenuBuilder: (context, editableTextState) => + buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: + context.l10n.aiRefineWithAi, + readSpellingItems: + widget.readSpellingMenuItems == null + ? null + : (visibleOffset) => + widget.readSpellingMenuItems!( + _controller + .visibleOffsetToFullOffset( + visibleOffset, + ), + ), + onCheckSpelling: widget.onCheckSpelling, + additionalItems: [ + if (widget.onSymbolAction != null) + for (final action + in SourceSymbolAction.values) + BusyMarkPopupMenuItem( + value: () => widget.onSymbolAction!( + action, _controller - .visibleOffsetToFullOffset( - visibleOffset, - ), + .fullSelection + .extentOffset, ), - onCheckSpelling: widget.onCheckSpelling, - additionalItems: [ - if (widget.onSymbolAction != null) - for (final action - in SourceSymbolAction.values) - BusyMarkPopupMenuItem( - value: () => widget.onSymbolAction!( - action, - _controller - .fullSelection - .extentOffset, + label: switch (action) { + SourceSymbolAction.declaration => + context.l10n.goToDeclaration, + SourceSymbolAction.usages => + context.l10n.findUsages, + SourceSymbolAction.rename => + context.l10n.rename, + }, + shortcut: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.label, + gtkAccelerator: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.gtkAccelerator, ), - label: switch (action) { - SourceSymbolAction.declaration => - context.l10n.goToDeclaration, - SourceSymbolAction.usages => - context.l10n.findUsages, - SourceSymbolAction.rename => - context.l10n.rename, - }, - shortcut: - (BusyMarkCommandRegistryScope.read( - context, - ) ?? - BusyMarkCommandCatalog - .metadata)[sourceSymbolCommands[action]!] - ?.shortcut - ?.label, - gtkAccelerator: - (BusyMarkCommandRegistryScope.read( - context, - ) ?? - BusyMarkCommandCatalog - .metadata)[sourceSymbolCommands[action]!] - ?.shortcut - ?.gtkAccelerator, - ), - ], - onRefineWithAi: widget.onAiEdit == null - ? null - : () => unawaited(_runAiEdit()), - onCopy: () => - unawaited(_copyOrCutSource(cut: false)), - onCut: () => - unawaited(_copyOrCutSource(cut: true)), - onPaste: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.normal, + ], + onRefineWithAi: widget.onAiEdit == null + ? null + : () => unawaited(_runAiEdit()), + onCopy: () => + unawaited(_copyOrCutSource(cut: false)), + onCut: () => + unawaited(_copyOrCutSource(cut: true)), + onPaste: () => unawaited( + _pasteFromSystemClipboard( + BusyMarkPasteMode.normal, + ), ), - ), - onPastePlainText: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.plainText, + onPastePlainText: () => unawaited( + _pasteFromSystemClipboard( + BusyMarkPasteMode.plainText, + ), ), + readPasteAvailability: + _readContextMenuPasteAvailability, ), - readPasteAvailability: - _readContextMenuPasteAvailability, - ), - onChanged: (_) => _handleSourceChanged(), + onChanged: (_) => _handleSourceChanged(), + ), ), ), ), @@ -1883,6 +1932,32 @@ class BusyMarkSourceEditorState extends State { _controller.setFullText(widget.text); return; } + final live = widget.readEditBase?.call(); + if (live?.sameDocumentVersion(_editBase) == true) { + if (!identical(live!.history, _editBase.history)) { + _continuousSourceEdit = null; + } + _editBase = live; + } + if (widget.readEditBase != null && live != _editBase) { + if (live != null) { + _editBase = live; + _applyOwnedUndoValue( + TextEditingValue( + text: live.source, + selection: + widget.readSelection?.call() ?? + TextSelection.collapsed( + offset: _controller.fullSelection.extentOffset.clamp( + 0, + live.source.length, + ), + ), + ), + ); + } + return; + } _replacementWorker.cancel(); // Sidebar offsets belong to the pre-edit document. Once the user edits, // resume normal search on the new text instead of awaiting the old range. @@ -1894,7 +1969,8 @@ class BusyMarkSourceEditorState extends State { _controller.lastFullSelectionBeforeEdit ?? selection; final undoGroup = origin == _SourceEditOrigin.paste || - origin == _SourceEditOrigin.spellingCorrection + origin == _SourceEditOrigin.spellingCorrection || + origin == _SourceEditOrigin.command ? null : _undoGroupForSourceEdit( visibleEdit, @@ -1902,7 +1978,8 @@ class BusyMarkSourceEditorState extends State { selection: selection, ); if (origin == _SourceEditOrigin.paste || - origin == _SourceEditOrigin.spellingCorrection) { + origin == _SourceEditOrigin.spellingCorrection || + origin == _SourceEditOrigin.command) { _continuousSourceEdit = null; } final currentSearchIndex = _searchController.result.currentMatchIndex; @@ -1926,6 +2003,29 @@ class BusyMarkSourceEditorState extends State { undoGroup, ); } + final committed = widget.readEditBase?.call(); + if (widget.readEditBase != null && + committed != null && + committed.source != _controller.fullText) { + _editBase = committed; + _applyOwnedUndoValue( + TextEditingValue( + text: committed.source, + selection: + widget.readSelection?.call() ?? + TextSelection.collapsed( + offset: selection.extentOffset.clamp( + 0, + committed.source.length, + ), + ), + ), + ); + return; + } + _editBase = + committed ?? + DocumentEditBase(_controller.fullText, widget.editRevision); if (_autocompleteSuggestions.isNotEmpty) { _refreshAutocomplete(); } @@ -2131,13 +2231,24 @@ class BusyMarkSourceEditorState extends State { void _applyFullEditingValue( TextEditingValue value, { - _SourceEditOrigin origin = _SourceEditOrigin.userTyping, + _SourceEditOrigin origin = _SourceEditOrigin.command, }) { _controller.setFullEditingValue(value); _focusNode.requestFocus(); _handleSourceChanged(origin: origin); } + void _performDocumentHistory({required bool redo}) { + if (_hasActiveComposition || DocumentReadOnlyScope.of(context)) return; + final value = (redo ? widget.onRedo : widget.onUndo)?.call(); + if (value != null) { + _applyOwnedUndoValue(value); + _editBase = + widget.readEditBase?.call() ?? + DocumentEditBase(value.text, widget.editRevision); + } + } + void _applyOwnedUndoValue(TextEditingValue value) { _continuousSourceEdit = null; _controller.setFullEditingValue(value); @@ -2159,68 +2270,42 @@ class BusyMarkSourceEditorState extends State { _continuousSourceEdit = null; return null; } - final insertedLength = edit.replacement.length; - final removedLength = edit.replacedFullText.length; - final kind = switch ((insertedLength, removedLength)) { - (1, 0) => _SourceSimpleEditKind.typing, - (0, 1) => _SourceSimpleEditKind.deletion, - _ => null, - }; - if (kind == null || - !_sourceEditSelectionIsContinuous( - kind, - edit, - previousSelection, - selection, - )) { - _continuousSourceEdit = null; - return null; - } final currentText = _controller.fullText; final oldText = currentText.replaceRange( edit.fullStart, - edit.fullStart + insertedLength, + edit.fullStart + edit.replacement.length, edit.replacedFullText, ); - final now = DateTime.now(); + final kind = documentSimpleEditKind( + before: oldText, + after: currentText, + previousSelection: previousSelection, + selection: selection, + ); + if (kind == null) { + _continuousSourceEdit = null; + return null; + } final previous = _continuousSourceEdit; final continuous = previous != null && previous.kind == kind && previous.newText == oldText && - previous.selection == previousSelection && - now.difference(previous.timestamp) < const Duration(seconds: 2); - final group = continuous - ? previous.group - : 'source-${widget.documentId ?? widget.filePath ?? 'document'}-' - '$_undoSessionId-${++_undoGroupSequence}'; + previous.selection == previousSelection; + final group = _undoGrouping.next( + owner: 'source-${widget.documentId ?? widget.filePath ?? 'document'}', + continues: continuous, + size: 1, + ); _continuousSourceEdit = _ContinuousSourceEdit( kind: kind, newText: currentText, selection: selection, - timestamp: now, group: group, ); return group; } - bool _sourceEditSelectionIsContinuous( - _SourceSimpleEditKind kind, - SourceVisibleEdit edit, - TextSelection previousSelection, - TextSelection selection, - ) { - final previousCaret = previousSelection.extentOffset; - final caret = selection.extentOffset; - return switch (kind) { - _SourceSimpleEditKind.typing => - previousCaret == edit.fullStart && caret == edit.fullStart + 1, - _SourceSimpleEditKind.deletion => - (previousCaret == edit.fullStart || previousCaret == edit.fullEnd) && - caret == edit.fullStart, - }; - } - void _applyShortcutAction(BusyMarkEditorShortcutAction action) { switch (action) { case BusyMarkEditorShortcutAction.refineWithAi: @@ -2357,19 +2442,28 @@ class BusyMarkSourceEditorState extends State { text: _controller.fullText, selection: _controller.fullSelection, composing: _controller.fullComposing, + epoch: _editingEpoch, + base: _editBase, ); - bool _isClipboardTargetCurrent(_SourceClipboardOperationTarget target) => - mounted && - target.documentId == (widget.documentId ?? widget.filePath ?? '') && - target.filePath == widget.filePath && - target.language == widget.language && - target.format == _documentFormat && - target.markdownMode == _destinationMarkdownMode && - target.text == _controller.fullText && - target.selection == _controller.fullSelection && - target.composing == _controller.fullComposing && - !_hasActiveComposition; + bool _isClipboardTargetCurrent(_SourceClipboardOperationTarget target) { + final live = widget.readEditBase == null + ? _editBase + : widget.readEditBase!.call(); + return mounted && + identical(target.epoch, _editingEpoch) && + live != null && + target.base.sameDocumentVersion(live) && + target.documentId == (widget.documentId ?? widget.filePath ?? '') && + target.filePath == widget.filePath && + target.language == widget.language && + target.format == _documentFormat && + target.markdownMode == _destinationMarkdownMode && + target.text == _controller.fullText && + target.selection == _controller.fullSelection && + target.composing == _controller.fullComposing && + !_hasActiveComposition; + } SourcePasteDocumentSnapshot _sourcePasteSnapshot( _SourceClipboardOperationTarget target, @@ -3020,14 +3114,11 @@ class BusyMarkSourceEditorState extends State { Future _chooseLink() async { if (DocumentReadOnlyScope.of(context)) return; final target = _fullEditingValue(); - final id = widget.documentId; - final revision = widget.editRevision; - final path = widget.filePath; + final origin = _captureClipboardTarget(); + final path = origin.filePath; final destination = await widget.onChooseLink!(); if (!mounted || - widget.documentId != id || - widget.filePath != path || - widget.editRevision != revision || + !_isClipboardTargetCurrent(origin) || _fullEditingValue() != target || destination == null || DocumentReadOnlyScope.of(context) || @@ -3100,11 +3191,19 @@ class BusyMarkSourceEditorState extends State { }) { _continuousSourceEdit = null; final previous = _controller; + final previousFocus = _focusNode; _controller = BusyMarkSourceController(text: text, language: language); + // The document-keyed field is recreated here. Its focus node must have the + // same lifetime, so the new EditableText receives a fresh keyboard token + // instead of inheriting focus after the old field cleared its input client. + _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); + _editingEpoch = Object(); + _lastObservedEditingValue = _fullEditingValue(); _controller.addListener(_handleControllerActivity); _resetUndoHistory(); WidgetsBinding.instance.addPostFrameCallback((_) { previous.dispose(); + previousFocus.dispose(); }); } @@ -4104,21 +4203,17 @@ class _SourceLargeFileBanner extends StatelessWidget { } } -enum _SourceSimpleEditKind { typing, deletion } - class _ContinuousSourceEdit { const _ContinuousSourceEdit({ required this.kind, required this.newText, required this.selection, - required this.timestamp, required this.group, }); - final _SourceSimpleEditKind kind; + final DocumentSimpleEditKind kind; final String newText; final TextSelection selection; - final DateTime timestamp; final String group; } @@ -4151,6 +4246,8 @@ class _SourceClipboardOperationTarget { required this.text, required this.selection, required this.composing, + required this.epoch, + required this.base, }); final String documentId; @@ -4161,9 +4258,11 @@ class _SourceClipboardOperationTarget { final String text; final TextSelection selection; final TextRange composing; + final Object epoch; + final DocumentEditBase base; } -enum _SourceEditOrigin { userTyping, paste, spellingCorrection } +enum _SourceEditOrigin { userTyping, paste, spellingCorrection, command } class _SourceClipboardInsertionTarget implements diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 2bad3006..a562cc3e 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,3 +1,4 @@ +import '../document_edit_history.dart'; import '../../app/command_shortcuts.dart'; import 'package:flutter/foundation.dart' show listEquals; import '../../writerside/writerside_effective_properties.dart'; @@ -150,6 +151,9 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.onCloseSearch, this.onUndo, this.onRedo, + this.readEditBase, + this.readSessionState, + this.undoClock, this.headerBarService, this.documentLayout, this.visualizationRevision = 0, @@ -214,6 +218,9 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final VoidCallback? onCloseSearch; final VoidCallback? onUndo; final VoidCallback? onRedo; + final DocumentEditBaseReader? readEditBase; + final WysiwygEditorSessionState Function()? readSessionState; + final DocumentUndoClock? undoClock; final LinuxHeaderBarService? headerBarService; final BusyMarkDocumentLayoutSpec? documentLayout; final int visualizationRevision; @@ -290,7 +297,13 @@ class BusyMarkWysiwygEditorState extends State { bool _topicPropertiesSelected = false; String? _activeCellId; final _collapsibleExpansion = {}; - int _undoGroupSequence = 0; + late final DocumentUndoGrouping _undoGrouping; + late DocumentEditBase _editBase; + DocumentEditBase get publicationBase => _editBase; + + /// Serialization of the live structured document, independent of parent frames. + String get serializedSource => _documentController.markdown; + final _inputSelections = {}; _ContinuousTextEdit? _continuousTextEdit; _DocumentTextSelection? _documentSelection; _DocumentTextSelection? _documentInputSelection; @@ -308,7 +321,6 @@ class BusyMarkWysiwygEditorState extends State { double? _preferredVerticalCaretX; final _pendingInlineKindsByBlockId = >{}; int _preserveSelectionFocusCallbacks = 0; - bool _internalChange = false; bool _initialFocusScheduled = false; var _toolbarVisible = true; var _documentGeneration = 0; @@ -584,6 +596,10 @@ class BusyMarkWysiwygEditorState extends State { @override void initState() { super.initState(); + _editBase = + widget.readEditBase?.call() ?? + DocumentEditBase(widget.document.source ?? '', widget.contentRevision); + _undoGrouping = DocumentUndoGrouping(clock: widget.undoClock); _selectionFocusNode = FocusNode( debugLabel: 'BusyMark WYSIWYG document selection', onKeyEvent: _handleDocumentSelectionKeyEvent, @@ -619,10 +635,38 @@ class BusyMarkWysiwygEditorState extends State { final oldDocumentId = oldWidget.documentId ?? oldWidget.document.filePath; final fileChanged = oldDocumentId != _documentId || + oldWidget.document.filePath != widget.document.filePath || oldWidget.document.isXmlTopic != widget.document.isXmlTopic || oldWidget.document.mode != widget.document.mode; - final sourceChanged = oldWidget.document.source != widget.document.source; - if (fileChanged || (sourceChanged && !_internalChange)) { + final incoming = + widget.readEditBase?.call() ?? + DocumentEditBase(widget.document.source ?? '', widget.contentRevision); + final modelOnlyReplacement = + widget.readEditBase == null && + widget.document.source == null && + !identical(oldWidget.document, widget.document); + final acknowledged = + !modelOnlyReplacement && incoming.sameDocumentVersion(_editBase); + if (acknowledged) { + if (!identical(incoming.history, _editBase.history)) { + _continuousTextEdit = null; + } + _editBase = incoming; + } + final sourceChanged = + widget.document.source != null || widget.readEditBase != null + ? incoming.source != _editBase.source + : oldWidget.document != widget.document; + final revisionChanged = + incoming.revision != null && incoming.revision != _editBase.revision; + final incomingAuthority = + widget.readEditBase != null || + widget.contentRevision != null || + !identical(oldWidget.document, widget.document); + if (fileChanged || + (incomingAuthority && + !acknowledged && + (sourceChanged || revisionChanged))) { // Pending property text belongs to the current authored element. An // external replacement can reuse a projection ID for a different node. // Recreate the panel entries rather than retargeting their drafts. @@ -639,14 +683,21 @@ class BusyMarkWysiwygEditorState extends State { ); _undoStack.clear(); _redoStack.clear(); - _internalChange = false; _resetPerDocumentState(); } - _documentSelection = null; - _documentController.replaceDocument(widget.document); - _initialFocusScheduled = false; - _scheduleSessionRestore(widget.initialSessionState); - _scheduleVisibleHeadingReport(); + _continuousTextEdit = null; + if (widget.readEditBase != null) { + // The parent projection may lag an Undo, reload or replacement. Parse + // the actual authority rather than accepting its revision with old text. + synchronizeAuthoritativeState(force: true); + } else { + _editBase = incoming; + _documentSelection = null; + _documentController.replaceDocument(widget.document); + _initialFocusScheduled = false; + _scheduleSessionRestore(widget.initialSessionState); + _scheduleVisibleHeadingReport(); + } } else if (oldWidget.onVisibleHeadingChanged != widget.onVisibleHeadingChanged) { _hasReportedVisibleHeading = false; @@ -693,6 +744,7 @@ class BusyMarkWysiwygEditorState extends State { } void _resetPerDocumentState() { + _inputSelections.clear(); _lastInspectorSelection = null; _propertiesTargetId = null; _videoDraftTarget = null; @@ -961,17 +1013,13 @@ class BusyMarkWysiwygEditorState extends State { ), _UndoEditorIntent: CallbackAction<_UndoEditorIntent>( onInvoke: (intent) { - if (widget.useExternalUndoHistory || !_undoEditorChange()) { - widget.onUndo?.call(); - } + _traverseDocumentHistory(redo: false); return null; }, ), _RedoEditorIntent: CallbackAction<_RedoEditorIntent>( onInvoke: (intent) { - if (widget.useExternalUndoHistory || !_redoEditorChange()) { - widget.onRedo?.call(); - } + _traverseDocumentHistory(redo: true); return null; }, ), @@ -1046,12 +1094,18 @@ class BusyMarkWysiwygEditorState extends State { height: 1, child: ExcludeSemantics( child: IgnorePointer( - child: _DocumentSelectionTextInput( - key: ObjectKey(selection), - isCurrent: _documentSelectionInputGuard(selection), - onKeyEvent: _handleDocumentSelectionKeyEvent, - onEditingValue: _documentSelectionInputHandler( - selection, + child: DocumentUndoActions( + onUndo: () => _traverseDocumentHistory(redo: false), + onRedo: () => _traverseDocumentHistory(redo: true), + child: _DocumentSelectionTextInput( + key: ObjectKey(selection), + isCurrent: _documentSelectionInputGuard( + selection, + ), + onKeyEvent: _handleDocumentSelectionKeyEvent, + onEditingValue: _documentSelectionInputHandler( + selection, + ), ), ), ), @@ -1292,18 +1346,10 @@ class BusyMarkWysiwygEditorState extends State { ), session: _propertySession, onUndo: () { - if (widget.useExternalUndoHistory) { - widget.onUndo?.call(); - } else { - _undoEditorChange(); - } + _traverseDocumentHistory(redo: false); }, onRedo: () { - if (widget.useExternalUndoHistory) { - widget.onRedo?.call(); - } else { - _redoEditorChange(); - } + _traverseDocumentHistory(redo: true); }, onChooseCodeSource: () async { final guard = _captureDialogTarget(); @@ -2146,112 +2192,116 @@ class BusyMarkWysiwygEditorState extends State { final documentFilePath = _documentController.document.filePath; return _propertyHoverRegion( block, - BusyMarkWysiwygBlockField( - key: _blockKeyFor(block.id), - block: block, - first: first, - listRunEnd: listRunEnd, - documentFilePath: documentFilePath, - workspaceRoot: widget.workspaceRoot, - writersideRoot: widget.writersideRoot, - imagesDir: widget.imagesDir, - allowRemoteImages: widget.allowRemoteImages, - onRemoteImageBlocked: widget.onRemoteImageBlocked, - onMathDiagnostic: widget.onMathDiagnostic, - controller: _textControllerFor(block), - undoController: _textUndoControllerFor(block), - focusNode: _focusNodeFor(block), - selected: selectedBlockIds.contains(block.id), - selectionRange: selectionRangesByBlockId[block.id], - documentSelectionActive: _hasBlockSelection, - // The originating field must stop its local drag once the document owns - // it; otherwise EditableText refocuses and scrolls that field's caret. - documentSelectionDragging: - _hasBlockSelection && _pointerSelectionAnchor != null, - onPointerDown: (event) => _handleBlockPointerDown(block.id, event), - onPointerMove: _handleBlockPointerMove, - onPointerUp: _handleBlockPointerUp, - onPointerCancel: _handleBlockPointerCancel, - onFocused: () => _handleBlockFocused(block.id), - onCut: _cutCurrentSelection, - onCopy: _copyCurrentSelection, - onPaste: () => - unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.normal)), - onPastePlainText: () => - unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.plainText)), - readPasteAvailability: _readContextMenuPasteAvailability, - onCopyPlainText: _copyCurrentSelectionAsPlainText, - onRefineWithAi: widget.onAiEdit == null - ? null - : () => unawaited(_runAiEdit(blockId: block.id)), - onChanged: (value) => - _handleBlockTextChanged(documentFilePath, block.id, value), - onTableCellChanged: (cellId, value) => _handleTableCellTextChanged( - documentFilePath, - block.id, - cellId, - value, - ), - onTableCellSourceChanged: (cellId, value) => - _handleTableCellSourceChanged( - documentFilePath, - block.id, - cellId, - value, - ), - tableCellController: _tableCellControllerFor, - tableCellUndoController: _textUndoControllerFor, - tableCellFocusNode: (cell) => _tableCellFocusNodeFor(block.id, cell), - tableCellKey: _tableCellKeyFor, - spellingRanges: _spellingRangesForBlock(block.id), - spellingEditableKey: _spellingEditableKeyFor(block.id), - tableCellSpellingRanges: _spellingRangesForCell, - tableCellSpellingEditableKey: _tableCellSpellingEditableKeyFor, - spellingMenuReader: widget.readSpellingMenuItems == null - ? null - : (offset) => widget.readSpellingMenuItems!( - SpellingRichBlockTarget( - blockId: block.id, - documentGeneration: _documentGeneration, - ), - offset, + DocumentUndoActions( + onUndo: () => _traverseDocumentHistory(redo: false), + onRedo: () => _traverseDocumentHistory(redo: true), + child: BusyMarkWysiwygBlockField( + key: _blockKeyFor(block.id), + block: block, + first: first, + listRunEnd: listRunEnd, + documentFilePath: documentFilePath, + workspaceRoot: widget.workspaceRoot, + writersideRoot: widget.writersideRoot, + imagesDir: widget.imagesDir, + allowRemoteImages: widget.allowRemoteImages, + onRemoteImageBlocked: widget.onRemoteImageBlocked, + onMathDiagnostic: widget.onMathDiagnostic, + controller: _textControllerFor(block), + undoController: _textUndoControllerFor(block), + focusNode: _focusNodeFor(block), + selected: selectedBlockIds.contains(block.id), + selectionRange: selectionRangesByBlockId[block.id], + documentSelectionActive: _hasBlockSelection, + // The originating field must stop its local drag once the document owns + // it; otherwise EditableText refocuses and scrolls that field's caret. + documentSelectionDragging: + _hasBlockSelection && _pointerSelectionAnchor != null, + onPointerDown: (event) => _handleBlockPointerDown(block.id, event), + onPointerMove: _handleBlockPointerMove, + onPointerUp: _handleBlockPointerUp, + onPointerCancel: _handleBlockPointerCancel, + onFocused: () => _handleBlockFocused(block.id), + onCut: _cutCurrentSelection, + onCopy: _copyCurrentSelection, + onPaste: () => + unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.normal)), + onPastePlainText: () => + unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.plainText)), + readPasteAvailability: _readContextMenuPasteAvailability, + onCopyPlainText: _copyCurrentSelectionAsPlainText, + onRefineWithAi: widget.onAiEdit == null + ? null + : () => unawaited(_runAiEdit(blockId: block.id)), + onChanged: (value) => + _handleBlockTextChanged(documentFilePath, block.id, value), + onTableCellChanged: (cellId, value) => _handleTableCellTextChanged( + documentFilePath, + block.id, + cellId, + value, + ), + onTableCellSourceChanged: (cellId, value) => + _handleTableCellSourceChanged( + documentFilePath, + block.id, + cellId, + value, ), - tableCellSpellingMenuReader: widget.readSpellingMenuItems == null - ? null - : (cellId) => - (offset) => widget.readSpellingMenuItems!( - SpellingRichTableCellTarget( - tableBlockId: block.id, - cellId: cellId, - documentGeneration: _documentGeneration, - ), - offset, + tableCellController: _tableCellControllerFor, + tableCellUndoController: _textUndoControllerFor, + tableCellFocusNode: (cell) => _tableCellFocusNodeFor(block.id, cell), + tableCellKey: _tableCellKeyFor, + spellingRanges: _spellingRangesForBlock(block.id), + spellingEditableKey: _spellingEditableKeyFor(block.id), + tableCellSpellingRanges: _spellingRangesForCell, + tableCellSpellingEditableKey: _tableCellSpellingEditableKeyFor, + spellingMenuReader: widget.readSpellingMenuItems == null + ? null + : (offset) => widget.readSpellingMenuItems!( + SpellingRichBlockTarget( + blockId: block.id, + documentGeneration: _documentGeneration, ), - onCheckSpelling: widget.onCheckSpelling, - onTableCellFocused: (cellId) => - _handleTableCellFocused(block.id, cellId), - onTableRowInserted: (rowIndex, {required after}) => - _handleTableRowInserted(block.id, rowIndex, after: after), - onTableRowDeleted: (rowIndex) => - _handleTableRowDeleted(block.id, rowIndex), - onTableColumnInserted: (columnIndex, {required after}) => - _handleTableColumnInserted(block.id, columnIndex, after: after), - onTableColumnDeleted: (columnIndex) => - _handleTableColumnDeleted(block.id, columnIndex), - onTableColumnAlignmentChanged: (columnIndex, alignment) => - _handleTableColumnAlignmentChanged( - block.id, - columnIndex, - alignment, - ), - onTableDeleted: () => _handleTableDeleted(block.id), - onImageEditRequested: () => - unawaited(_handleImageBlockEditRequested(block.id)), - onHtmlEditRequested: () => - unawaited(_handleHtmlBlockEditRequested(block.id)), - onTaskChanged: (checked) => - _handleTaskCheckedChanged(block.id, checked), - editRevision: widget.visualizationRevision + _documentGeneration, + offset, + ), + tableCellSpellingMenuReader: widget.readSpellingMenuItems == null + ? null + : (cellId) => + (offset) => widget.readSpellingMenuItems!( + SpellingRichTableCellTarget( + tableBlockId: block.id, + cellId: cellId, + documentGeneration: _documentGeneration, + ), + offset, + ), + onCheckSpelling: widget.onCheckSpelling, + onTableCellFocused: (cellId) => + _handleTableCellFocused(block.id, cellId), + onTableRowInserted: (rowIndex, {required after}) => + _handleTableRowInserted(block.id, rowIndex, after: after), + onTableRowDeleted: (rowIndex) => + _handleTableRowDeleted(block.id, rowIndex), + onTableColumnInserted: (columnIndex, {required after}) => + _handleTableColumnInserted(block.id, columnIndex, after: after), + onTableColumnDeleted: (columnIndex) => + _handleTableColumnDeleted(block.id, columnIndex), + onTableColumnAlignmentChanged: (columnIndex, alignment) => + _handleTableColumnAlignmentChanged( + block.id, + columnIndex, + alignment, + ), + onTableDeleted: () => _handleTableDeleted(block.id), + onImageEditRequested: () => + unawaited(_handleImageBlockEditRequested(block.id)), + onHtmlEditRequested: () => + unawaited(_handleHtmlBlockEditRequested(block.id)), + onTaskChanged: (checked) => + _handleTaskCheckedChanged(block.id, checked), + editRevision: widget.visualizationRevision + _documentGeneration, + ), ), ); } @@ -2282,6 +2332,7 @@ class BusyMarkWysiwygEditorState extends State { } for (final id in _textControllers.keys.toList()) { if (!ids.contains(id)) { + _inputSelections.remove(id); _textControllers.remove(id)?.dispose(); } } @@ -2293,6 +2344,7 @@ class BusyMarkWysiwygEditorState extends State { } for (final id in _tableCellControllers.keys.toList()) { if (!cellIds.contains(id)) { + _inputSelections.remove(id); _tableCellControllers.remove(id)?.dispose(); } } @@ -2429,6 +2481,12 @@ class BusyMarkWysiwygEditorState extends State { } if (_cellSelectAllTarget == targetId) _cellSelectAllTarget = null; _scheduleSessionReport(); + final blockForSelection = _documentController.blockById(targetId); + if (blockForSelection != null && + busyMarkWysiwygEditableText(blockForSelection) == controller.text && + controller.selection.isValid) { + _inputSelections[targetId] = controller.selection; + } final continuous = _continuousTextEdit; if (continuous == null || continuous.targetId != targetId) { return; @@ -2439,7 +2497,8 @@ class BusyMarkWysiwygEditorState extends State { !controller.selection.isValid) { return; } - if (controller.selection.extentOffset != continuous.caret) { + if (controller.selection != + TextSelection.collapsed(offset: continuous.caret)) { _continuousTextEdit = null; } } @@ -2790,6 +2849,7 @@ class BusyMarkWysiwygEditorState extends State { } BusyDocument _historySnapshot() { + _reportLiveHistorySession(); final markdown = _documentController.markdown; return _documentController.document.copyWith( blocks: [ @@ -2820,6 +2880,7 @@ class BusyMarkWysiwygEditorState extends State { void _recordUndoSnapshot([BusyDocument? previousDocument]) { if (widget.useExternalUndoHistory) { + if (previousDocument == null) _reportLiveHistorySession(); return; } final snapshot = previousDocument ?? _historySnapshot(); @@ -2938,23 +2999,27 @@ class BusyMarkWysiwygEditorState extends State { return; } final controller = _textControllers[blockId]; + _recordTextUndoSnapshot(blockId, _inputSelections[blockId]); final undoGroup = origin == _WysiwygTextEditOrigin.paste ? null : _undoGroupForTextEdit( targetId: blockId, + previousSelection: _inputSelections[blockId], oldText: oldText, newText: value, selection: controller?.selection, ); if (origin == _WysiwygTextEditOrigin.paste) { _continuousTextEdit = null; + if (controller?.selection.isValid == true) { + _inputSelections[blockId] = controller!.selection; + } } - _recordUndoSnapshot(); final currentBlock = _documentController.blockById(blockId); if (currentBlock != null && busyMarkWysiwygBlockContainsMath(currentBlock)) { _documentController.updateMathSource(blockId, value); - _emitMarkdown(undoGroup: undoGroup); + _emitMarkdown(undoGroup: undoGroup, reportEditingSession: true); return; } final offset = @@ -2985,7 +3050,7 @@ class BusyMarkWysiwygEditorState extends State { if (value.isNotEmpty) { _pendingInlineKindsByBlockId.remove(blockId); } - _emitMarkdown(undoGroup: undoGroup); + _emitMarkdown(undoGroup: undoGroup, reportEditingSession: true); } String _replacementTextForFieldEdit(String oldText, String newText) { @@ -3071,7 +3136,8 @@ class BusyMarkWysiwygEditorState extends State { if (currentText == accepted) { return; } - _recordUndoSnapshot(); + final previousSelection = _inputSelections[cellId]; + _recordTextUndoSnapshot(cellId, previousSelection); if (markdownSource) { _documentController.updateTableCellMarkdownSource( tableBlockId, @@ -3082,8 +3148,10 @@ class BusyMarkWysiwygEditorState extends State { _documentController.updateTableCellText(tableBlockId, cellId, accepted); } _emitMarkdown( + reportEditingSession: true, undoGroup: _undoGroupForTextEdit( targetId: cellId, + previousSelection: previousSelection, oldText: currentText, newText: accepted, selection: controller?.selection, @@ -3257,7 +3325,10 @@ class BusyMarkWysiwygEditorState extends State { }); } - void _scheduleSessionRestore(WysiwygEditorSessionState session) { + void _scheduleSessionRestore( + WysiwygEditorSessionState session, { + bool restoreSelectionImmediately = false, + }) { _inspectorRestorePending = true; if (session.activeBlockId == null && session.anchorBlockId == null && @@ -3316,15 +3387,8 @@ class BusyMarkWysiwygEditorState extends State { viewportAlignment: session.viewportAlignment, ); } - _initialFocusScheduled = true; - final generation = _documentGeneration; - final documentId = _documentId; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - generation != _documentGeneration || - documentId != _documentId) { - return; - } + bool applySelection() { + var applied = false; _documentSelection = null; final activeBlock = session.activeBlockId == null ? null @@ -3356,6 +3420,7 @@ class BusyMarkWysiwygEditorState extends State { .clamp(0, controller.text.length) .toInt(), ); + applied = true; } } else { final start = _clampDocumentPosition( @@ -3375,10 +3440,53 @@ class BusyMarkWysiwygEditorState extends State { anchor: start, extent: end, ); + applied = true; } } } } + return applied; + } + + WysiwygEditorSessionState? immediatelyRestored; + Object selectionKey(WysiwygEditorSessionState value) => ( + value.activeBlockId, + value.activeCellId, + value.anchorBlockId, + value.anchorOffset, + value.extentBlockId, + value.extentOffset, + ); + if (restoreSelectionImmediately) { + final applied = applySelection(); + if (_hasBlockSelection) { + _selectionFocusNode.requestFocus(); + } else { + final focus = _activeCellId == null + ? _focusNodes[_activeBlockId] + : _tableCellFocusNodes[_activeCellId]; + focus?.requestFocus(); + } + if (applied) immediatelyRestored = _captureSessionState(); + } + _initialFocusScheduled = true; + final generation = _documentGeneration; + final documentId = _documentId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + generation != _documentGeneration || + documentId != _documentId) { + return; + } + if (immediatelyRestored != null && + selectionKey(immediatelyRestored) != + selectionKey(_captureSessionState())) { + // A newer caret/focus move owns the view, even without a source edit. + _inspectorRestorePending = false; + _scheduleSessionReport(); + return; + } + if (immediatelyRestored == null) applySelection(); final viewportBlockId = session.viewportBlockId; if (viewportBlockId != null) { var alignment = session.viewportAlignment.clamp(0.0, 1.0).toDouble(); @@ -3507,12 +3615,13 @@ class BusyMarkWysiwygEditorState extends State { void _reportSessionNow({ BusyMarkWysiwygSessionChanged? callback, String? documentId, + WysiwygEditorSessionState? editingSession, }) { final report = callback ?? widget.onSessionChanged; if (report == null) { return; } - final session = _captureSessionState(); + final session = editingSession ?? _captureSessionState(); final targetDocumentId = documentId ?? _documentId; if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) { @@ -3526,6 +3635,54 @@ class BusyMarkWysiwygEditorState extends State { report(targetDocumentId, session); } + void _recordTextUndoSnapshot(String targetId, TextSelection? selection) { + if (!widget.useExternalUndoHistory) { + _recordUndoSnapshot(); + return; + } + if (selection?.isValid != true || + !mounted || + DocumentReadOnlyScope.of(context)) { + return; + } + final live = widget.readEditBase?.call(); + if (widget.readEditBase != null && + live?.sameDocumentVersion(_editBase) != true) { + return; + } + final session = _captureSessionState(); + _reportSessionNow( + editingSession: WysiwygEditorSessionState( + activeBlockId: session.activeBlockId, + activeCellId: session.activeCellId, + anchorBlockId: targetId, + anchorOffset: selection!.baseOffset, + extentBlockId: targetId, + extentOffset: selection.extentOffset, + viewportBlockId: session.viewportBlockId, + viewportAlignment: session.viewportAlignment, + sourceSignature: session.sourceSignature, + sourceBindings: session.sourceBindings, + ), + ); + } + + void _reportLiveHistorySession() { + if (!widget.useExternalUndoHistory || + _pendingTextFocus != null || + !mounted || + DocumentReadOnlyScope.of(context) || + SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + return; + } + final live = widget.readEditBase?.call(); + if (widget.readEditBase == null || + live?.sameDocumentVersion(_editBase) == true) { + _reportSessionNow(); + } + } + String? _writersideSessionSourceSignature() { final source = _documentController.document.source; if (!_documentController.document.isWriterside || source == null) { @@ -4201,9 +4358,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_redoEditorChange()) { - widget.onRedo?.call(); - } + _traverseDocumentHistory(redo: true); return KeyEventResult.handled; } if (commands.shortcutAccepts( @@ -4211,9 +4366,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_undoEditorChange()) { - widget.onUndo?.call(); - } + _traverseDocumentHistory(redo: false); return KeyEventResult.handled; } if (widget.onOpenSearch != null && @@ -4480,9 +4633,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_undoEditorChange()) { - widget.onUndo?.call(); - } + _traverseDocumentHistory(redo: false); return KeyEventResult.handled; } if (commands.shortcutAccepts( @@ -4490,9 +4641,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_redoEditorChange()) { - widget.onRedo?.call(); - } + _traverseDocumentHistory(redo: true); return KeyEventResult.handled; } if (widget.onOpenSearch != null && @@ -5847,13 +5996,9 @@ class BusyMarkWysiwygEditorState extends State { case BusyMarkCommandIds.textPastePlainText: unawaited(_pasteFromSystemClipboard(BusyMarkPasteMode.plainText)); case 'text.undo': - if (widget.useExternalUndoHistory || !_undoEditorChange()) { - widget.onUndo?.call(); - } + _traverseDocumentHistory(redo: false); case 'text.redo': - if (widget.useExternalUndoHistory || !_redoEditorChange()) { - widget.onRedo?.call(); - } + _traverseDocumentHistory(redo: true); } } @@ -7434,51 +7579,43 @@ class BusyMarkWysiwygEditorState extends State { required String oldText, required String newText, required TextSelection? selection, + TextSelection? previousSelection, }) { - var prefix = 0; - final shortest = math.min(oldText.length, newText.length); - while (prefix < shortest && - oldText.codeUnitAt(prefix) == newText.codeUnitAt(prefix)) { - prefix++; - } - var oldEnd = oldText.length; - var newEnd = newText.length; - while (oldEnd > prefix && - newEnd > prefix && - oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) { - oldEnd--; - newEnd--; - } - final removedLength = oldEnd - prefix; - final insertedLength = newEnd - prefix; - final simpleTyping = - (removedLength == 0 && insertedLength == 1) || - (removedLength == 1 && insertedLength == 0); - final caret = selection?.isValid == true - ? selection!.extentOffset.clamp(0, newText.length).toInt() - : newEnd; - if (!simpleTyping) { + final live = widget.readEditBase?.call(); + if (live?.sameDocumentVersion(_editBase) == true && + !identical(live!.history, _editBase.history)) { + _continuousTextEdit = null; + } + if (selection?.isValid == true) _inputSelections[targetId] = selection!; + final kind = documentSimpleEditKind( + before: oldText, + after: newText, + previousSelection: + previousSelection ?? const TextSelection.collapsed(offset: -1), + selection: selection ?? const TextSelection.collapsed(offset: -1), + ); + if (kind == null) { _continuousTextEdit = null; return null; } - final now = DateTime.now(); + final caret = selection!.extentOffset; final previous = _continuousTextEdit; final continuous = previous != null && + previous.kind == kind && previous.targetId == targetId && previous.newText == oldText && - now.difference(previous.timestamp) < const Duration(seconds: 2) && - (previous.caret == prefix || - previous.caret == caret || - (insertedLength == 0 && previous.caret == oldEnd)); - final group = continuous - ? previous.group - : 'wysiwyg-$_documentId-${++_undoGroupSequence}'; + previous.caret == previousSelection!.extentOffset; + final group = _undoGrouping.next( + owner: 'wysiwyg-$_documentId', + continues: continuous, + size: 1, + ); _continuousTextEdit = _ContinuousTextEdit( targetId: targetId, + kind: kind, newText: newText, caret: caret, - timestamp: now, group: group, ); return group; @@ -7486,6 +7623,7 @@ class BusyMarkWysiwygEditorState extends State { void _emitMarkdown({ String? undoGroup, + bool reportEditingSession = false, WysiwygEditorSessionState? beforeSpellingSession, WysiwygEditorSessionState? afterSpellingSession, }) { @@ -7497,7 +7635,12 @@ class BusyMarkWysiwygEditorState extends State { if (undoGroup == null) { _continuousTextEdit = null; } - _internalChange = true; + final live = widget.readEditBase?.call(); + if (live?.sameDocumentVersion(_editBase) == true) _editBase = live!; + if (widget.readEditBase != null && live != _editBase) { + synchronizeAuthoritativeState(); + return; + } final markdown = _documentController.markdown; _documentController.rebaseCommittedSource(markdown); _lastInspectorSelection = null; @@ -7526,9 +7669,84 @@ class BusyMarkWysiwygEditorState extends State { widget.onSourceChanged(_documentController.document.filePath, markdown); } } - WidgetsBinding.instance.addPostFrameCallback((_) { - _internalChange = false; - }); + final committed = widget.readEditBase?.call(); + if (widget.readEditBase != null && committed != null) { + // The shared buffer retains its final-newline policy for rich edits. + final expected = !widget.useExternalUndoHistory + ? markdown + : _editBase.source.endsWith('\n') + ? (markdown.endsWith('\n') ? markdown : '$markdown\n') + : markdown.replaceFirst(RegExp(r'\n+$'), ''); + if (committed.source != expected) { + synchronizeAuthoritativeState(force: true); + return; + } + } + _editBase = committed ?? DocumentEditBase(markdown, widget.contentRevision); + if (_editBase.source != markdown) { + _documentController.rebaseCommittedSource(_editBase.source); + } + // An accepted edit supersedes a deferred traversal restore. Keep its caret + // in the shared session now, before another edit/traversal can arrive. + _inspectorRestorePending = false; + if (reportEditingSession) _reportSessionNow(); + } + + /// Read the document authority synchronously after traversal. A parent frame + /// can be delayed, or its source can equal the previous widget's source. + void synchronizeAuthoritativeState({bool force = false}) { + final live = widget.readEditBase?.call(); + if (live == null) return; + if (!force && live.sameDocumentVersion(_editBase)) { + if (!identical(live.history, _editBase.history)) { + _continuousTextEdit = null; + } + _editBase = live; + return; + } + final document = widget.document.isXmlTopic + ? const WritersideEditingAdapter().parseXml( + filePath: widget.document.filePath, + source: live.source, + ) + : const MarkdownParser() + .parse( + filePath: widget.document.filePath, + source: live.source, + mode: widget.document.mode, + workspaceRoot: widget.workspaceRoot, + validateLocalReferences: false, + ) + .busyDocument; + if (document == null) return; + _continuousTextEdit = null; + _editBase = live; + _releaseSelectionDrag(rebuild: false); + _pendingTextFocus = null; + _documentSelection = null; + _propertiesTargetId = null; + _videoDraftTarget = null; + _topicPropertiesSelected = false; + _documentController.replaceDocument(document); + _initialFocusScheduled = false; + _scheduleSessionRestore( + widget.readSessionState?.call() ?? widget.initialSessionState, + restoreSelectionImmediately: true, + ); + _scheduleVisibleHeadingReport(); + if (mounted) setState(() {}); + } + + void _traverseDocumentHistory({required bool redo}) { + if (_hasActiveClipboardComposition || DocumentReadOnlyScope.of(context)) { + return; + } + _reportLiveHistorySession(); + if (widget.useExternalUndoHistory || + !(redo ? _redoEditorChange() : _undoEditorChange())) { + (redo ? widget.onRedo : widget.onUndo)?.call(); + synchronizeAuthoritativeState(); + } } Future _runAiEdit({String? blockId}) async { @@ -9639,16 +9857,16 @@ enum _DocumentSelectionMenuAction { class _ContinuousTextEdit { const _ContinuousTextEdit({ required this.targetId, + required this.kind, required this.newText, required this.caret, - required this.timestamp, required this.group, }); final String targetId; + final DocumentSimpleEditKind kind; final String newText; final int caret; - final DateTime timestamp; final String group; } diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index bf3df938..91ced885 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -350,6 +350,9 @@ class LocalHistoryController extends Notifier { final _documentIdsByBuffer = {}; final _clearingDocumentIdsByBuffer = {}; final _pending = {}; + // Only deduplication metadata. The actual obligations live in independently + // owned retained protections, including after this bounded set rolls over. + final _traversalCaptures = <(String, String, String, String, int, int)>{}; // A baseline cannot coalesce with edited text: both snapshots are needed. final _baselines = {}; final _baselinesRequiringVacantPath = {}; @@ -416,10 +419,17 @@ class LocalHistoryController extends Notifier { _store = ref.read(localHistoryStoreProvider); _clock = ref.read(localHistoryClockProvider); _timerFactory = ref.read(localHistoryTimerFactoryProvider); - ref.listen( - appSettingsControllerProvider, - (previous, next) => unawaited(_applyRecordingPolicy(next)), - ); + ref.listen(appSettingsControllerProvider, (previous, next) { + if (previous?.localHistoryRecordingEnabled != + next.localHistoryRecordingEnabled || + !listEquals( + previous?.localHistoryExcludedPaths, + next.localHistoryExcludedPaths, + )) { + _traversalCaptures.clear(); + } + unawaited(_applyRecordingPolicy(next)); + }); ref.onDispose(() { _liveOperationOwners.remove(_operationOwnerToken); _historyGeneration++; @@ -700,6 +710,95 @@ class LocalHistoryController extends Notifier { }); } + /// Traversal must not coalesce the source being left into the latest pending + /// checkpoint. Stage a separate retained obligation before observing the new + /// source. Every unresolved source has its own durable owner and retry state. + void observeHistoryTraversal( + DocumentBuffer previous, + DocumentBuffer current, + ) { + if (previous.text == current.text || + !policy.recordingEnabled || + policy.excludes(previous.filePath)) { + return; + } + final fingerprint = ( + previous.id, + previous.identity, + sourceChecksum(previous.text), + jsonEncode(previous.format.toJson()), + _historyGeneration, + _bufferGeneration(previous.id), + ); + if (_traversalCaptures.add(fingerprint)) { + if (_traversalCaptures.length > DocumentUndoState.historyLimit * 2) { + _traversalCaptures.remove(_traversalCaptures.first); + } + final owner = _newDetachedOwner( + 'history-capture:traversal:${previous.id}', + ); + final previousSnapshot = _acceptAtCurrentClearEpoch( + LocalHistoryBufferSnapshot.fromBuffer(previous), + ); + final snapshot = previousSnapshot + .forBuffer(owner) + .withCaptureId(_newPathOperationId()); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(previous.id); + final documentId = _documentIdsByBuffer[previous.id]; + if (documentId != null) _documentIdsByBuffer[owner] = documentId; + _protections[owner] = ( + snapshot: snapshot, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + force: false, + ignoreBinding: false, + allowPathChange: false, + bindResult: false, + requireVacantPath: false, + expectedTarget: null, + captureId: snapshot.captureId, + ); + _closedBuffers.add(owner); + // Synchronous Undo remains responsive. Durability is established when + // retained-work persistence or the revision write completes, not here. + final persistence = _persistDurableStateNow(); + unawaited( + _enqueue(previous.id, () async { + await persistence; + if (!_protections.containsKey(owner) || + !_operationIsCurrent( + previous.id, + historyGeneration, + bufferGeneration, + )) { + return; + } + // The first edit of an empty untitled document has no baseline yet. + // Establish its source lineage before the independent protection, + // just as observeEdit would have done for the traversal itself. + if (!_documentIdsByBuffer.containsKey(previous.id) && + !_baselines.containsKey(previous.id) && + !(previousSnapshot.untitled && previousSnapshot.text.isEmpty)) { + await _captureBaseline( + previousSnapshot, + LocalHistoryCaptureReason.baseline, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + } + // Opening/baseline work may have established the lineage meanwhile. + final binding = _documentIdsByBuffer[previous.id]; + if (binding != null && _protections.containsKey(owner)) { + _documentIdsByBuffer[owner] = binding; + } + await _enqueue(owner, () => _settleBufferWork(owner)); + }), + ); + _scheduleCheckpoint(owner); + } + observeEdit(previous, current); + } + void observeEdit(DocumentBuffer previous, DocumentBuffer current) { if (!policy.recordingEnabled || policy.excludes(current.filePath)) return; final previousSnapshot = _acceptAtCurrentClearEpoch( @@ -5827,7 +5926,13 @@ class LocalHistoryController extends Notifier { ) => _captureFailures[id]?[stage]; _LocalHistoryCaptureFailure? _ownerFailure(String? id) { - final failures = _captureFailures[id]; + if (id == null) return null; + final failures = + _captureFailures[id] ?? + _captureFailures.entries + .where((entry) => _detachedClosedSourceId(entry.key) == id) + .map((entry) => entry.value) + .firstOrNull; // Prefer the operation preventing an explicit protective action, followed // by identity and original-source obligations, then coalesced checkpoints. return failures?[_LocalHistoryFailureStage.protection] ?? @@ -5868,8 +5973,11 @@ class LocalHistoryController extends Notifier { ); String? _detachedClosedSourceId(String owner) { - const prefix = 'history-capture:closed:'; - if (!owner.startsWith(prefix)) return null; + final prefix = [ + 'history-capture:closed:', + 'history-capture:traversal:', + ].where(owner.startsWith).firstOrNull; + if (prefix == null) return null; var sourceAndSuffix = owner.substring(prefix.length); // _newDetachedOwner appends one process token and one sequence field. // Strip exactly those fields so buffer IDs containing ':' remain intact. diff --git a/lib/src/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart index a44c9d4f..563e05ed 100644 --- a/lib/src/local_history/local_history_models.dart +++ b/lib/src/local_history/local_history_models.dart @@ -14,6 +14,7 @@ enum LocalHistoryCaptureReason { beforeReload, beforeDiscard, beforeRestore, + beforeHistoryTraversal, beforeDelete, externalChange, } diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index 05ee9f4f..9edcb202 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -421,6 +421,8 @@ String _reason(BuildContext context, LocalHistoryCaptureReason reason) { l10n.localHistoryReasonBeforeReload, LocalHistoryCaptureReason.beforeDiscard => l10n.localHistoryReasonBeforeDiscard, + LocalHistoryCaptureReason.beforeHistoryTraversal => + '${l10n.undo} / ${l10n.redo}', LocalHistoryCaptureReason.beforeRestore => l10n.localHistoryReasonBeforeRestore, LocalHistoryCaptureReason.beforeDelete => diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index 477e01b8..498abddd 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -275,17 +275,27 @@ class FileLocalHistoryStore implements LocalHistoryStore { null, (latest, revision) => latest == null || - revision.capturedAt.isAfter(latest.capturedAt) + !revision.capturedAt.isBefore(latest.capturedAt) ? revision : latest, ); - if (!request.force && adjacent?.checksum == checksum) { - final adjacentRevision = await _readRevisionFile( - _revisionFile(root, document.id, adjacent!.id), - expected: adjacent, + // A safety protection can reuse any exact durable revision in this + // lineage. Ordinary captures retain their adjacent-only timeline rule. + final candidates = + request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal + ? index.revisions.where( + (r) => r.documentId == document!.id && r.checksum == checksum, + ) + : [if (adjacent?.checksum == checksum) adjacent!]; + for (final candidate + in request.force ? [] : candidates) { + final existing = await _readRevisionFile( + _revisionFile(root, document.id, candidate.id), + expected: candidate, ); - if (adjacentRevision != null && - _sameTextFormat(adjacentRevision.format, request.format)) { + if (existing != null && + existing.source == request.source && + _sameTextFormat(existing.format, request.format)) { _requireCaptureStillAccepted(request); index = index.withDocument(document); await _publishIndex(root, index); @@ -295,7 +305,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { } return LocalHistoryCaptureResult( document: document, - revision: adjacent, + revision: candidate, deduplicated: true, ); } @@ -1783,17 +1793,27 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { .fold( null, (latest, revision) => - latest == null || revision.capturedAt.isAfter(latest.capturedAt) + latest == null || !revision.capturedAt.isBefore(latest.capturedAt) ? revision : latest, ); - if (!request.force && adjacent?.checksum == checksum) { - final adjacentRevision = _revisions[adjacent!.id]; - if (adjacentRevision != null && - _sameTextFormat(adjacentRevision.format, request.format)) { + final candidates = + request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal + ? _revisions.values + .map((r) => r.summary) + .where( + (r) => r.documentId == document!.id && r.checksum == checksum, + ) + : [if (adjacent?.checksum == checksum) adjacent!]; + for (final candidate + in request.force ? [] : candidates) { + final existing = _revisions[candidate.id]; + if (existing != null && + existing.source == request.source && + _sameTextFormat(existing.format, request.format)) { return LocalHistoryCaptureResult( document: document, - revision: adjacent, + revision: candidate, deduplicated: true, ); } diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart index c98b7fbf..9dcb42e8 100644 --- a/lib/src/workspace/document_buffer.dart +++ b/lib/src/workspace/document_buffer.dart @@ -21,9 +21,11 @@ class DocumentHistoryState { required this.text, required this.selection, this.wysiwygState = const WysiwygEditorSessionState(), + this.format, }); final String text; + final TextFormatMetadata? format; final TextSelection selection; final WysiwygEditorSessionState wysiwygState; } @@ -74,6 +76,10 @@ class DocumentUndoState { return null; } + DocumentUndoState closeGroup() => activeGroup == null + ? this + : DocumentUndoState(undo: undo, redo: redo, restoredEntry: restoredEntry); + DocumentUndoState push(DocumentHistoryState state, {String? group}) { if (group != null && group == activeGroup && undo.isNotEmpty) { return DocumentUndoState(undo: undo, redo: const [], activeGroup: group); @@ -333,6 +339,7 @@ class DocumentBuffer { undoState: editorState.undoState.push( DocumentHistoryState( text: text, + format: format, selection: historicalSelection, wysiwygState: previousWysiwygState ?? editorState.wysiwygState, ), @@ -342,6 +349,30 @@ class DocumentBuffer { ); } + DocumentHistoryState get historyState => DocumentHistoryState( + text: text, + format: format, + selection: _selectionForText(editorState.selection, text), + wysiwygState: editorState.wysiwygState, + ); + + /// An authoritative content replacement is a discrete, undoable transaction. + /// Metadata-only refreshes leave the history and its redo branch intact. + /// Remote readonly content may still be replaced by its authority. + DocumentBuffer replaced(String source) { + if (source == text) return this; + return copyWith( + text: source, + dirty: source != lastSavedText || isUntitled, + format: format.copyWith(hasFinalNewline: source.endsWith('\n')), + revision: revision + 1, + editorState: editorState.copyWith( + selection: _selectionForText(editorState.selection, source), + undoState: editorState.undoState.push(historyState), + ), + ); + } + DocumentBuffer copyWith({ Object? filePath = _bufferUnset, Object? untitledName = _bufferUnset, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 587ab8e5..4894dc00 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1,3 +1,4 @@ +import '../../editor/document_edit_history.dart'; import '../../search/search_result_preview.dart'; import '../../search/search_session.dart'; import '../../search/search_sidebar.dart'; @@ -11731,6 +11732,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ).writersideModule?.config.settings ?? const WritersideSettingsConfig(), key: _wysiwygEditorKey, + readEditBase: () => _readEditorBase( + activeBuffer.id, + source: false, + ), + readSessionState: () => ref + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState, document: wysiwygDocument, documentId: activeBuffer.id, contentRevision: activeBuffer.revision, @@ -11899,6 +11909,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { onChooseLink: () => showReferenceLinkChooser(context), key: _sourceEditorKey, + readEditBase: () => _readEditorBase( + activeBuffer?.id, + source: true, + ), + readSelection: () => ref + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .selection, text: widget.state.activeText, language: _sourceSyntaxLanguage( widget.state.workspace, @@ -13977,6 +13996,23 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } } + DocumentEditBase? _readEditorBase(String? bufferId, {required bool source}) { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null || buffer.id != bufferId) return null; + final mode = buffer.editorState.mode; + if (source + ? mode != DocumentViewModePreference.source && + mode != DocumentViewModePreference.split + : mode != DocumentViewModePreference.editor) { + return null; + } + return DocumentEditBase( + buffer.text, + buffer.revision, + buffer.editorState.undoState, + ); + } + void _handleTransactionalSourceChanged( String? expectedBufferId, String value, @@ -13999,6 +14035,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .updateActiveSourceText( value, sourceBufferId: expectedBufferId, + expectedSource: _sourceEditorKey.currentState?.publicationBase.source, + expectedRevision: + _sourceEditorKey.currentState?.publicationBase.revision, sourceFilePath: sourceFilePath ?? activePath, previousSelection: previousSelection, selection: selection, @@ -14025,6 +14064,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { controller.updateActiveText( value, sourceBufferId: expectedBufferId, + expectedSource: _wysiwygEditorKey.currentState?.publicationBase.source, + expectedRevision: + _wysiwygEditorKey.currentState?.publicationBase.revision, sourceFilePath: filePath, ); return; @@ -14033,6 +14075,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { value, document: document, sourceBufferId: expectedBufferId, + expectedSource: _wysiwygEditorKey.currentState?.publicationBase.source, + expectedRevision: + _wysiwygEditorKey.currentState?.publicationBase.revision, sourceFilePath: filePath, undoGroup: undoGroup, ); @@ -14065,6 +14110,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { value, document: document, sourceBufferId: expectedBufferId, + expectedSource: _wysiwygEditorKey.currentState?.publicationBase.source, + expectedRevision: + _wysiwygEditorKey.currentState?.publicationBase.revision, sourceFilePath: filePath, undoGroup: null, previousWysiwygState: beforeSession, @@ -14076,6 +14124,16 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { void _cacheCommittedWysiwygDocument(String bufferId, BusyDocument document) { final committed = ref.read(workspaceControllerProvider).activeBuffer; if (committed == null || committed.id != bufferId) return; + final source = document.source; + final expected = source == null + ? null + : committed.format.hasFinalNewline + ? (source.endsWith('\n') ? source : '$source\n') + : source.replaceFirst(RegExp(r'\n+$'), ''); + if (expected != committed.text) { + _clearWysiwygCache(); + return; + } // Workspace final-newline policy can change the emitted Markdown. Rebind // the live tree to that committed source, retaining its editor block IDs. // Reparsing here would give spelling targets IDs the mounted editor never diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index b6e86dfb..18d4ef75 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -487,22 +487,6 @@ class WorkspaceController extends Notifier { editorState: editorState ?? const DocumentEditorState(), ); - DocumentEditorState _remoteEditorState( - DocumentBuffer buffer, - String content, - ) => buffer.editorState.copyWith( - selection: buffer.editorState.selection.copyWith( - baseOffset: buffer.editorState.selection.baseOffset.clamp( - 0, - content.length, - ), - extentOffset: buffer.editorState.selection.extentOffset.clamp( - 0, - content.length, - ), - ), - ); - void _notesChanged() { if (!ref.mounted || state.workspace?.isRemote != true) return; final repository = _notesRepository; @@ -543,30 +527,31 @@ class WorkspaceController extends Notifier { ); contentChanged |= text != buffer.text; buffers.add( - buffer.copyWith( - text: text, - editorState: _remoteEditorState(buffer, text), - remoteTitle: note.title, - readonly: note.readonly || note.error, - revision: text == buffer.text - ? buffer.revision - : math.max(buffer.revision, note.revision) + 1, - ), + buffer + .replaced(text) + .copyWith( + remoteTitle: note.title, + readonly: note.readonly || note.error, + revision: text == buffer.text + ? buffer.revision + : math.max(buffer.revision, note.revision) + 1, + ), ); } else { contentChanged |= note.content != buffer.text; buffers.add( - buffer.copyWith( - text: note.content, - editorState: _remoteEditorState(buffer, note.content), - lastSavedText: note.content, - remoteEditBase: repository.editorBase(note.localId), - revision: note.content == buffer.text - ? math.max(buffer.revision, note.revision) - : math.max(buffer.revision, note.revision) + 1, - remoteTitle: note.title, - readonly: note.readonly || note.error, - ), + buffer + .replaced(note.content) + .copyWith( + dirty: false, + lastSavedText: note.content, + remoteEditBase: repository.editorBase(note.localId), + revision: note.content == buffer.text + ? math.max(buffer.revision, note.revision) + : math.max(buffer.revision, note.revision) + 1, + remoteTitle: note.title, + readonly: note.readonly || note.error, + ), ); } } @@ -902,20 +887,20 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer( state.documentBuffers, - latest.copyWith( - text: text, - editorState: _remoteEditorState(latest, text), - lastSavedText: saved.content, - remoteEditBase: unchanged - ? repository.editorBase(reference.localId) - : latest.remoteEditBase, - dirty: text != saved.content, - revision: unchanged - ? saved.revision - : math.max(latest.revision, saved.revision + 1), - remoteTitle: saved.title, - readonly: saved.readonly || saved.error, - ), + latest + .replaced(text) + .copyWith( + lastSavedText: saved.content, + remoteEditBase: unchanged + ? repository.editorBase(reference.localId) + : latest.remoteEditBase, + dirty: text != saved.content, + revision: unchanged + ? saved.revision + : math.max(latest.revision, saved.revision + 1), + remoteTitle: saved.title, + readonly: saved.readonly || saved.error, + ), ), ); if (text != latest.text) { @@ -3126,17 +3111,18 @@ class WorkspaceController extends Notifier { ); return; } - final reloaded = latest.copyWith( - text: disk.text, - lastSavedText: disk.text, - dirty: false, - diskSnapshot: disk.snapshot, - format: disk.format, - revision: latest.revision + 1, - diskState: DocumentDiskState.present, - diskVersionText: null, - diskVersionSnapshot: null, - ); + final reloaded = latest + .replaced(disk.text) + .copyWith( + lastSavedText: disk.text, + dirty: false, + diskSnapshot: disk.snapshot, + format: disk.format, + revision: latest.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + ); _updateBufferFromMonitor( reloaded, workspaceGeneration: workspaceGeneration, @@ -3806,18 +3792,19 @@ class WorkspaceController extends Notifier { force: false, ); if (!_bufferOperationTargetIsCurrent(buffer)) return false; - final reloaded = buffer.copyWith( - text: disk.text, - lastSavedText: disk.text, - dirty: false, - diskSnapshot: disk.snapshot, - format: disk.format, - revision: buffer.revision + 1, - diskState: DocumentDiskState.present, - diskVersionText: null, - diskVersionSnapshot: null, - recovered: false, - ); + final reloaded = buffer + .replaced(disk.text) + .copyWith( + lastSavedText: disk.text, + dirty: false, + diskSnapshot: disk.snapshot, + format: disk.format, + revision: buffer.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); _updateBufferFromMonitor(reloaded); final derivedWorkspace = state.workspace; final derivedOperationRevision = _activeDocumentRevision; @@ -6194,7 +6181,12 @@ class WorkspaceController extends Notifier { if (buffer == null || buffer.editorState.mode == mode) { return; } - updateActiveEditorState(buffer.editorState.copyWith(mode: mode)); + updateActiveEditorState( + buffer.editorState.copyWith( + mode: mode, + undoState: buffer.editorState.undoState.closeGroup(), + ), + ); if (_modeShowsPreview(mode) && _activePreviewIsStale) { _requestDerivedRefresh(rebuildPreview: true); } @@ -6209,15 +6201,11 @@ class WorkspaceController extends Notifier { } final undo = buffer.editorState.undoState; final target = undo.undo.last; - final current = DocumentHistoryState( - text: buffer.text, - selection: buffer.editorState.selection, - wysiwygState: buffer.editorState.wysiwygState, - ); + final current = buffer.historyState; final next = buffer.copyWith( text: target.text, dirty: target.text != buffer.lastSavedText || buffer.isUntitled, - format: buffer.format.copyWith( + format: (target.format ?? buffer.format).copyWith( hasFinalNewline: target.text.endsWith('\n'), ), revision: buffer.revision + 1, @@ -6230,7 +6218,7 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); - _localHistory.observeEdit(buffer, next); + _localHistory.observeHistoryTraversal(buffer, next); _requestDerivedRefresh( rebuildPreview: _activeModeShowsPreview, refreshOutline: !_activeModeShowsPreview, @@ -6249,15 +6237,11 @@ class WorkspaceController extends Notifier { } final undo = buffer.editorState.undoState; final target = undo.redo.last; - final current = DocumentHistoryState( - text: buffer.text, - selection: buffer.editorState.selection, - wysiwygState: buffer.editorState.wysiwygState, - ); + final current = buffer.historyState; final next = buffer.copyWith( text: target.text, dirty: target.text != buffer.lastSavedText || buffer.isUntitled, - format: buffer.format.copyWith( + format: (target.format ?? buffer.format).copyWith( hasFinalNewline: target.text.endsWith('\n'), ), revision: buffer.revision + 1, @@ -6270,7 +6254,7 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); - _localHistory.observeEdit(buffer, next); + _localHistory.observeHistoryTraversal(buffer, next); _requestDerivedRefresh( rebuildPreview: _activeModeShowsPreview, refreshOutline: !_activeModeShowsPreview, @@ -6283,11 +6267,15 @@ class WorkspaceController extends Notifier { void updateActiveText( String text, { String? sourceBufferId, + String? expectedSource, + int? expectedRevision, String? sourceFilePath, }) { _updateActiveText( text, sourceBufferId: sourceBufferId, + expectedSource: expectedSource, + expectedRevision: expectedRevision, sourceFilePath: sourceFilePath, rebuildPreview: state.activeBuffer?.editorState.mode != @@ -6325,6 +6313,8 @@ class WorkspaceController extends Notifier { void updateActiveSourceText( String text, { String? sourceBufferId, + String? expectedSource, + int? expectedRevision, String? sourceFilePath, required TextSelection previousSelection, required TextSelection selection, @@ -6333,6 +6323,8 @@ class WorkspaceController extends Notifier { _updateActiveText( text, sourceBufferId: sourceBufferId, + expectedSource: expectedSource, + expectedRevision: expectedRevision, sourceFilePath: sourceFilePath, rebuildPreview: _activeModeShowsPreview, previousSelection: previousSelection, @@ -6347,6 +6339,8 @@ class WorkspaceController extends Notifier { String text, { required BusyDocument document, String? sourceBufferId, + String? expectedSource, + int? expectedRevision, String? sourceFilePath, String? undoGroup, WysiwygEditorSessionState? previousWysiwygState, @@ -6355,6 +6349,8 @@ class WorkspaceController extends Notifier { _updateActiveText( text, sourceBufferId: sourceBufferId, + expectedSource: expectedSource, + expectedRevision: expectedRevision, sourceFilePath: sourceFilePath, rebuildPreview: false, liveOutline: document.outline, @@ -6466,6 +6462,8 @@ class WorkspaceController extends Notifier { String text, { required bool rebuildPreview, String? sourceBufferId, + String? expectedSource, + int? expectedRevision, String? sourceFilePath, List? liveOutline, bool preserveFinalNewline = false, @@ -6481,6 +6479,10 @@ class WorkspaceController extends Notifier { return; } if (sourceBufferId != null && activeBuffer.id != sourceBufferId) return; + if (expectedSource != null && activeBuffer.text != expectedSource) return; + if (expectedRevision != null && activeBuffer.revision != expectedRevision) { + return; + } final activeEditorPath = workspace == null ? activeBuffer.filePath : resolveWorkspaceDocumentContext(workspace, activeBuffer).parserPath; @@ -8136,12 +8138,13 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer( state.documentBuffers, - current.copyWith( - text: cached.content, - lastSavedText: cached.content, - dirty: false, - revision: math.max(cached.revision, current.revision + 1), - ), + current + .replaced(cached.content) + .copyWith( + lastSavedText: cached.content, + dirty: false, + revision: math.max(cached.revision, current.revision + 1), + ), ), ); _requestDerivedRefresh( @@ -8177,18 +8180,19 @@ class WorkspaceController extends Notifier { if (currentBuffer == null) { return false; } - final discardedBuffer = currentBuffer.copyWith( - text: load.text, - lastSavedText: load.text, - dirty: false, - diskSnapshot: load.snapshot, - format: load.format, - revision: currentBuffer.revision + 1, - diskState: DocumentDiskState.present, - diskVersionText: null, - diskVersionSnapshot: null, - recovered: false, - ); + final discardedBuffer = currentBuffer + .replaced(load.text) + .copyWith( + lastSavedText: load.text, + dirty: false, + diskSnapshot: load.snapshot, + format: load.format, + revision: currentBuffer.revision + 1, + diskState: DocumentDiskState.present, + diskVersionText: null, + diskVersionSnapshot: null, + recovered: false, + ); final remainsActive = state.activeBufferId == refreshedTarget.bufferId; final currentWorkspace = state.workspace; if (currentWorkspace == null || @@ -8306,17 +8310,18 @@ class WorkspaceController extends Notifier { final sourceChanged = requested.text != load.text; replacements[requested.id] = _WorkspaceRefreshBufferReplacement( requested: requested, - replacement: requested.copyWith( - text: load.text, - lastSavedText: load.text, - dirty: false, - diskSnapshot: load.snapshot, - format: load.format, - revision: sourceChanged - ? requested.revision + 1 - : requested.revision, - diskState: DocumentDiskState.present, - ), + replacement: requested + .replaced(load.text) + .copyWith( + lastSavedText: load.text, + dirty: false, + diskSnapshot: load.snapshot, + format: load.format, + revision: sourceChanged + ? requested.revision + 1 + : requested.revision, + diskState: DocumentDiskState.present, + ), ); } if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { @@ -9356,7 +9361,9 @@ List _reconcileWorkspaceRefreshBuffers( for (final live in liveBuffers) if (replacements[live.id] case final result?) if (_workspaceRefreshRequestStillMatches(live, result.requested)) - result.replacement.copyWith(editorState: live.editorState) + result.replacement.copyWith( + editorState: live.replaced(result.replacement.text).editorState, + ) else live else diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 451e5d75..aee4fa68 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -9263,6 +9263,12 @@ code () => !container.read(searchSessionProvider).searching && container.read(searchSessionProvider).results.length == 2, + debugState: () { + final search = container.read(searchSessionProvider); + return 'query=${search.query}, searching=${search.searching}, ' + 'results=${search.results.map((r) => r.identity).toList()}, ' + 'sources=$finalSources'; + }, ); expect(container.read(searchSessionProvider).query, 'needle'); expect( @@ -10469,6 +10475,1121 @@ code expect(service.savedText, '# Edited Introduction\n'); }); + Future historyApp( + WidgetTester tester, + DocumentViewModePreference mode, { + WorkspaceService? service, + String startupPath = '/tmp/disposable-history.md', + String initialSource = 'abc', + }) async { + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: mode, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settings), + workspaceServiceProvider.overrideWithValue( + service ?? _SearchWorkspaceService(initialSource), + ), + localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _pumpUntilFound( + tester, + find.byType( + mode == DocumentViewModePreference.source + ? BusyMarkSourceEditor + : BusyMarkWysiwygEditor, + ), + ); + return container; + } + + Finder historyField(DocumentViewModePreference mode) => find + .descendant( + of: find.byType( + mode == DocumentViewModePreference.source + ? BusyMarkSourceEditor + : BusyMarkWysiwygEditor, + ), + matching: find.byType(TextField), + ) + .first; + + Future historyKey(WidgetTester tester, {bool redo = false}) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (redo) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + if (redo) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + testWidgets('${mode.name} exhausted keyboard history is a consumed no-op', ( + tester, + ) async { + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + final field = historyField(mode); + await tester.tap(field); + await tester.showKeyboard(field); + for (final text in ['abcd', 'abcde', 'abcdef']) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + ); + // Populate Flutter's throttled field history as well as shared history. + await tester.pump(const Duration(milliseconds: 600)); + } + while (container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .isNotEmpty) { + await historyKey(tester); + await tester.pump(const Duration(milliseconds: 600)); + } + for (final redo in [false, true]) { + if (redo) { + while (container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .redo + .isNotEmpty) { + await historyKey(tester, redo: true); + await tester.pump(const Duration(milliseconds: 600)); + } + } + final before = container + .read(workspaceControllerProvider) + .activeBuffer!; + final history = container.read(localHistoryControllerProvider.notifier); + final pending = history.pendingSnapshotForBuffer(before.id); + for (var i = 0; i < 4; i++) { + await historyKey(tester, redo: redo); + await tester.pump(const Duration(milliseconds: 600)); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (redo) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ); + if (redo) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + final after = container.read(workspaceControllerProvider).activeBuffer!; + expect(after.text, before.text); + expect(after.revision, before.revision); + expect(after.dirty, before.dirty); + expect(after.editorState.undoState, same(before.editorState.undoState)); + expect(history.pendingSnapshotForBuffer(before.id), same(pending)); + expect( + tester.widget(historyField(mode)).controller!.text, + after.text, + ); + } + expect(workspace.undoActiveBuffer(), isTrue); + }); + + testWidgets('${mode.name} remapped history preserves search field undo', ( + tester, + ) async { + final container = await historyApp(tester, mode); + final keymap = container.read(keymapControllerProvider.notifier); + for (final entry in { + BusyMarkCommandIds.textUndo: LogicalKeyboardKey.f9, + BusyMarkCommandIds.textRedo: LogicalKeyboardKey.f10, + }.entries) { + expect( + (await keymap.update( + entry.key, + BusyMarkKeyBinding(entry.value, control: true, alt: true), + expected: container + .read(appSettingsControllerProvider) + .shortcutOverrides, + )).status, + KeymapUpdateStatus.saved, + ); + } + await tester.pump(); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 3); + await tester.enterText(historyField(mode), 'abcd'); + await tester.pump(const Duration(milliseconds: 600)); + await historyKey(tester); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + 'abcd', + reason: 'The remapped default is blocked in document inputs', + ); + + Future remapped(LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + } + + await remapped(LogicalKeyboardKey.f9); + expect(container.read(workspaceControllerProvider).activeText, 'abc'); + final exhausted = container + .read(workspaceControllerProvider) + .activeBuffer!; + await remapped(LogicalKeyboardKey.f9); + expect( + container.read(workspaceControllerProvider).activeBuffer!.revision, + exhausted.revision, + ); + await remapped(LogicalKeyboardKey.f10); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + final document = container + .read(workspaceControllerProvider) + .activeBuffer!; + await remapped(LogicalKeyboardKey.f10); + expect( + container.read(workspaceControllerProvider).activeBuffer!.revision, + document.revision, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + final query = find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), + ); + await tester.showKeyboard(query); + await tester.enterText(query, 'first query'); + await tester.pump(const Duration(milliseconds: 600)); + await tester.enterText(query, 'second query'); + await tester.pump(const Duration(milliseconds: 600)); + await historyKey(tester); + await tester.pump(); + expect(tester.widget(query).controller!.text, 'first query'); + final after = container.read(workspaceControllerProvider).activeBuffer!; + expect(after.text, document.text); + expect(after.revision, document.revision); + expect(after.editorState.undoState, same(document.editorState.undoState)); + container.read(searchSessionProvider.notifier).close(restoreFocus: false); + await tester.pump(const Duration(milliseconds: 200)); + }); + + testWidgets( + '${mode.name} accepts typing after Local History restore traversal', + (tester) async { + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + final store = container.read(localHistoryStoreProvider); + final buffer = container + .read(workspaceControllerProvider) + .activeBuffer!; + final capture = await tabOperation( + tester, + () => store.capture( + LocalHistoryCaptureRequest( + path: buffer.filePath, + displayName: buffer.displayName, + source: 'recoverable', + format: buffer.format, + capturedAt: DateTime(2026), + reason: LocalHistoryCaptureReason.saved, + force: true, + ), + container.read(localHistoryControllerProvider.notifier).policy, + ), + ); + final revision = await store.readRevision(capture.revision!.id); + workspace.updateActiveText('latest branch'); + await tester.pump(); + expect( + await tabOperation( + tester, + () => workspace.restoreLocalHistoryRevision( + document: capture.document, + revision: revision!, + ), + ), + isTrue, + ); + await tester.pump(); + await tester.showKeyboard(historyField(mode)); + await historyKey(tester); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + 'latest branch', + ); + await historyKey(tester, redo: true); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + 'recoverable', + ); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 11); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'recoverabler', + selection: TextSelection.collapsed(offset: 12), + ), + ); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + 'recoverabler', + ); + expect( + tester.widget(historyField(mode)).controller!.text, + 'recoverabler', + ); + }, + ); + + testWidgets('${mode.name} caret round trip closes typing transaction', ( + tester, + ) async { + final container = await historyApp(tester, mode); + final field = historyField(mode); + await tester.showKeyboard(field); + final text = tester.widget(field).controller!; + text.selection = const TextSelection.collapsed(offset: 3); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcd', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + text.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + text.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcde', + selection: TextSelection.collapsed(offset: 5), + ), + ); + await tester.pump(); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo, + hasLength(2), + ); + await historyKey(tester); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + expect(text.text, 'abcd'); + }); + } + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + testWidgets('${mode.name} sustained typing has finite undo size', ( + tester, + ) async { + final container = await historyApp(tester, mode); + await tester.tap(historyField(mode)); + await tester.showKeyboard(historyField(mode)); + var text = 'abc'; + for (var i = 0; i < 180; i++) { + text += mode == DocumentViewModePreference.source && i % 20 == 19 + ? '\n' + : 'x'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + ); + } + final undo = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState; + expect(undo.undo.length, greaterThan(1)); + await historyKey(tester); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + isNot('abc'), + ); + }); + } + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + testWidgets( + '${mode.name} typing and deletion direction are distinct transactions', + (tester) async { + final container = await historyApp(tester, mode); + final field = historyField(mode); + await tester.showKeyboard(field); + tester.widget(field).controller!.selection = + const TextSelection.collapsed(offset: 2); + final groups = []; + for (final (text, caret) in [ + ('abxc', 3), + ('abc', 2), + ('ac', 1), + ('a', 1), + ]) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret), + ), + ); + groups.add( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .activeGroup, + ); + } + expect(groups, everyElement(isNotNull)); + expect(groups[0], isNot(groups[1])); + expect(groups[1], groups[2]); + expect(groups[2], isNot(groups[3])); + final before = container + .read(workspaceControllerProvider) + .activeBuffer!; + await historyKey(tester); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'ac'); + expect(before.editorState.undoState.undo.length, 3); + }, + ); + + testWidgets('${mode.name} platform history actions remain document owned', ( + tester, + ) async { + final container = await historyApp(tester, mode); + final field = historyField(mode); + await tester.enterText(field, 'abcd'); + await tester.pump(const Duration(milliseconds: 600)); + EditableTextState editable() => tester.state( + find.descendant(of: field, matching: find.byType(EditableText)), + ); + Actions.invoke( + editable().context, + const UndoTextIntent(SelectionChangedCause.keyboard), + ); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abc'); + final exhausted = container + .read(workspaceControllerProvider) + .activeBuffer!; + for (var i = 0; i < 4; i++) { + Actions.invoke( + editable().context, + const UndoTextIntent(SelectionChangedCause.keyboard), + ); + } + expect( + container.read(workspaceControllerProvider).activeBuffer!.revision, + exhausted.revision, + ); + Actions.invoke( + editable().context, + const RedoTextIntent(SelectionChangedCause.keyboard), + ); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + final redone = container.read(workspaceControllerProvider).activeBuffer!; + Actions.invoke( + editable().context, + const RedoTextIntent(SelectionChangedCause.keyboard), + ); + expect( + container.read(workspaceControllerProvider).activeBuffer!.revision, + redone.revision, + ); + }); + + testWidgets( + '${mode.name} rejects a publication invalidated before parent rebuild', + (tester) async { + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.enterText(historyField(mode), 'abcd'); + workspace.updateActiveText('authoritative'); + final revision = container + .read(workspaceControllerProvider) + .activeBuffer! + .revision; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcdx', + selection: TextSelection.collapsed(offset: 5), + ), + ); + expect( + container.read(workspaceControllerProvider).activeText, + 'authoritative', + ); + expect( + container.read(workspaceControllerProvider).activeBuffer!.revision, + revision, + ); + await tester.pump(); + expect( + tester.widget(historyField(mode)).controller!.text, + 'authoritative', + ); + await tester.enterText(historyField(mode), 'authoritativex'); + expect( + container.read(workspaceControllerProvider).activeText, + 'authoritativex', + ); + }, + ); + } + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + testWidgets( + '${mode.name} authoritative traversal restores the historical caret', + (tester) async { + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 1); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'aXbc', + selection: TextSelection.collapsed(offset: 2), + ), + ); + await tester.pump(); + expect(workspace.undoActiveBuffer(), isTrue); + await tester.pump(); + expect( + tester.widget(historyField(mode)).controller!.text, + 'abc', + ); + expect( + tester.widget(historyField(mode)).controller!.selection, + const TextSelection.collapsed(offset: 1), + ); + expect(workspace.redoActiveBuffer(), isTrue); + await tester.pump(); + expect( + tester.widget(historyField(mode)).controller!.text, + 'aXbc', + ); + expect( + tester.widget(historyField(mode)).controller!.selection, + const TextSelection.collapsed(offset: 2), + ); + }, + ); + } + + testWidgets('WYSIWYG Save As updates the mounted publication path', ( + tester, + ) async { + final root = await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-history-save-as-'), + ); + addTearDown(() async { + if (await root!.exists()) await root.delete(recursive: true); + }); + final original = File('${root!.path}/original.md'); + final destination = '${root.path}/renamed.md'; + await tester.runAsync(() => original.writeAsString('abc')); + const mode = DocumentViewModePreference.editor; + final container = await historyApp( + tester, + mode, + service: const WorkspaceService(), + startupPath: original.path, + ); + await tester.enterText(historyField(mode), 'abcd'); + await tester.pump(); + final workspace = container.read(workspaceControllerProvider.notifier); + expect( + await tabOperation(tester, () => workspace.saveActiveAs(destination)), + isTrue, + ); + await tester.pump(); + await tester.enterText(historyField(mode), 'abcdx'); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcdx'); + expect( + tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document + .filePath, + destination, + ); + await historyKey(tester); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + }); + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + for (final deletion in [false, true]) { + testWidgets( + '${mode.name} repeated-character ${deletion ? "deletion" : "typing"} groups at the actual caret', + (tester) async { + final initial = deletion ? 'aaaaaa' : 'aaaa'; + final container = await historyApp( + tester, + mode, + initialSource: initial, + ); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + TextSelection.collapsed(offset: deletion ? 4 : 2); + for (final (text, caret) + in deletion + ? [('aaaaa', 3), ('aaaa', 2)] + : [('aaaaa', 3), ('aaaaaa', 4), ('aaaaaaa', 5)]) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret), + ), + ); + } + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo, + hasLength(1), + ); + await historyKey(tester); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + initial, + ); + }, + ); + } + } + + testWidgets( + 'Source to Split closes the group without dropping a no-frame edit', + (tester) async { + const mode = DocumentViewModePreference.source; + final container = await historyApp(tester, mode); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 3); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcd', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + final workspace = container.read(workspaceControllerProvider.notifier); + workspace.updateActiveEditorMode(DocumentViewModePreference.split); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcdx', + selection: TextSelection.collapsed(offset: 5), + ), + ); + expect(container.read(workspaceControllerProvider).activeText, 'abcdx'); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo, + hasLength(2), + ); + expect(workspace.undoActiveBuffer(), isTrue); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + expect( + tester.widget(historyField(mode)).controller!.text, + 'abcd', + ); + }, + ); + + testWidgets('WYSIWYG remount starts a distinct undo group', (tester) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 3); + await tester.enterText(historyField(mode), 'abcd'); + await tester.pump(); + final first = container.read(workspaceControllerProvider).activeBuffer!; + expect(first.editorState.undoState.activeGroup, isNotNull); + workspace.updateActiveEditorMode(DocumentViewModePreference.preview); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(BusyMarkWysiwygEditor), findsNothing); + workspace.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await tester.pump(const Duration(milliseconds: 100)); + await tester.showKeyboard(historyField(mode)); + tester.widget(historyField(mode)).controller!.selection = + const TextSelection.collapsed(offset: 4); + await tester.enterText(historyField(mode), 'abcde'); + await tester.pump(); + final second = container.read(workspaceControllerProvider).activeBuffer!; + expect( + second.editorState.undoState.activeGroup, + isNot(first.editorState.undoState.activeGroup), + ); + await historyKey(tester); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + expect( + tester.widget(historyField(mode)).controller!.text, + 'abcd', + ); + }); + + for (final beforeFrame in [false, true]) { + testWidgets( + 'WYSIWYG same-frame traversal and next edit (early=$beforeFrame)', + (tester) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.enterText(historyField(mode), 'abcd'); + expect(container.read(workspaceControllerProvider).activeText, 'abcd'); + await historyKey(tester); + expect(container.read(workspaceControllerProvider).activeText, 'abc'); + expect( + tester + .state( + find.byType(BusyMarkWysiwygEditor), + ) + .serializedSource, + 'abc', + ); + if (!beforeFrame) await tester.pump(); + final controller = tester + .widget(historyField(mode)) + .controller!; + if (!beforeFrame) expect(controller.text, 'abc'); + // Native input may arrive before the parent has acknowledged Undo. + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: '${controller.text}x', + selection: TextSelection.collapsed( + offset: controller.text.length + 1, + ), + ), + ); + await tester.pump(); + final current = container.read(workspaceControllerProvider).activeText; + expect(current, 'abcx'); + expect( + tester + .state( + find.byType(BusyMarkWysiwygEditor), + ) + .serializedSource, + current, + ); + expect( + tester.widget(historyField(mode)).controller!.text, + current, + ); + expect( + tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document + .source, + current, + ); + expect(workspace.redoActiveBuffer(), isFalse); + }, + ); + } + + testWidgets( + 'rich traversal bursts reconcile live serialization without frames', + (tester) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode); + final field = historyField(mode); + await tester.showKeyboard(field); + final text = tester.widget(field).controller!; + text.selection = const TextSelection.collapsed(offset: 3); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcd', + selection: TextSelection.collapsed(offset: 4), + ), + ); + text.selection = const TextSelection.collapsed(offset: 0); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Xabcd', + selection: TextSelection.collapsed(offset: 1), + ), + ); + final rich = tester.state( + find.byType(BusyMarkWysiwygEditor), + ); + void agrees(String expected, int caret) { + expect( + container.read(workspaceControllerProvider).activeText, + expected, + ); + expect(text.text, expected); + expect(rich.serializedSource, expected); + expect(text.selection, TextSelection.collapsed(offset: caret)); + } + + agrees('Xabcd', 1); + for (var i = 0; i < 5; i++) { + await historyKey(tester); + agrees('abcd', 0); + await historyKey(tester); + agrees('abc', 3); + await historyKey(tester, redo: true); + agrees('abcd', 0); + await historyKey(tester, redo: true); + agrees('Xabcd', 1); + } + await historyKey(tester); + agrees('abcd', 0); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'xabcd', + selection: TextSelection.collapsed(offset: 1), + ), + ); + agrees('xabcd', 1); + await tester.pump(); + agrees('xabcd', 1); + expect( + tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document + .source, + 'xabcd', + ); + expect( + container.read(workspaceControllerProvider.notifier).redoActiveBuffer(), + isFalse, + ); + }, + ); + + testWidgets( + 'rich group boundaries capture the live pre-edit caret without frames', + (tester) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode); + await tester.showKeyboard(historyField(mode)); + final text = tester.widget(historyField(mode)).controller!; + text.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcd', + selection: TextSelection.collapsed(offset: 4), + ), + ); + text.selection = const TextSelection.collapsed(offset: 0); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Xabcd', + selection: TextSelection.collapsed(offset: 1), + ), + ); + final history = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState; + expect(history.undo, hasLength(2)); + expect(history.undo.last.text, 'abcd'); + expect(history.undo.last.wysiwygState.anchorOffset, 0); + expect(history.undo.first.wysiwygState.anchorOffset, 3); + }, + ); + + testWidgets('rich no-frame Undo restores the caret before the next input', ( + tester, + ) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode, initialSource: 'abcdef'); + final field = historyField(mode); + await tester.showKeyboard(field); + final text = tester.widget(field).controller!; + text.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + await tester.pump(); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState + .anchorOffset, + 3, + ); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcXdef', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + await tester.pump(); + await historyKey(tester); + expect(container.read(workspaceControllerProvider).activeText, 'abcdef'); + expect(text.selection, const TextSelection.collapsed(offset: 3)); + final caret = text.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text.text.replaceRange(caret, caret, 'Y'), + selection: TextSelection.collapsed(offset: caret + 1), + ), + ); + expect(container.read(workspaceControllerProvider).activeText, 'abcYdef'); + await tester.pump(); + expect(text.selection, const TextSelection.collapsed(offset: 4)); + }); + + testWidgets( + 'rich table traversal restores cell focus and caret without a frame', + (tester) async { + const mode = DocumentViewModePreference.editor; + const source = '| One | Two |\n| --- | --- |\n| abcdef | ghijkl |\n'; + final container = await historyApp(tester, mode, initialSource: source); + Finder cell(String value) => find + .descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == value, + ), + ) + .first; + final first = cell('abcdef'); + await tester.showKeyboard(first); + final a = tester.widget(first); + a.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcXdef', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.showKeyboard(cell('ghijkl')); + final b = tester.widget(cell('ghijkl')); + b.controller!.selection = const TextSelection.collapsed(offset: 2); + await tester.pump(); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'ghYijkl', + selection: TextSelection.collapsed(offset: 3), + ), + ); + await tester.pump(); + await tester.pump(); + await historyKey(tester); + expect(b.controller!.text, 'ghijkl'); + expect(b.controller!.selection, const TextSelection.collapsed(offset: 2)); + await historyKey(tester); + expect(container.read(workspaceControllerProvider).activeText, source); + expect(a.controller!.text, 'abcdef'); + expect(a.controller!.selection, const TextSelection.collapsed(offset: 3)); + expect(a.focusNode!.hasPrimaryFocus, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcYdef', + selection: TextSelection.collapsed(offset: 4), + ), + ); + expect( + container.read(workspaceControllerProvider).activeText, + contains('abcYdef'), + ); + final rich = tester.state( + find.byType(BusyMarkWysiwygEditor), + ); + expect( + rich.serializedSource, + container.read(workspaceControllerProvider).activeText, + ); + await tester.pump(); + expect(a.controller!.selection, const TextSelection.collapsed(offset: 4)); + }, + ); + + testWidgets( + 'rich no-frame Undo restores document selection command ownership', + (tester) async { + const mode = DocumentViewModePreference.editor; + const source = 'abc\n\ndef'; + final container = await historyApp(tester, mode, initialSource: source); + await tester.showKeyboard(historyField(mode)); + for (var i = 0; i < 2; i++) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + await tester.pump(); + await tester.pump(); + final selected = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState; + expect(selected.anchorBlockId, isNot(selected.extentBlockId)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + contains('**abc**'), + ); + await historyKey(tester); + expect(container.read(workspaceControllerProvider).activeText, source); + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + expect(container.read(workspaceControllerProvider).activeText, isEmpty); + await tester.pump(); + }, + ); + + testWidgets('rich caret movement supersedes a pending traversal restore', ( + tester, + ) async { + const mode = DocumentViewModePreference.editor; + final container = await historyApp(tester, mode, initialSource: 'abcdef'); + await tester.showKeyboard(historyField(mode)); + final text = tester.widget(historyField(mode)).controller!; + text.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcXdef', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + await tester.pump(); + await historyKey(tester); + expect(container.read(workspaceControllerProvider).activeText, 'abcdef'); + text.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + expect(text.selection, const TextSelection.collapsed(offset: 1)); + }); + + testWidgets( + 'rich formatting captures a new document range before any frame', + (tester) async { + const mode = DocumentViewModePreference.editor; + const source = 'abc\n\ndef'; + final container = await historyApp(tester, mode, initialSource: source); + await tester.showKeyboard(historyField(mode)); + for (var i = 0; i < 2; i++) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect( + container.read(workspaceControllerProvider).activeText, + contains('**abc**'), + ); + final before = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .last; + expect(before.text, source); + expect( + before.wysiwygState.anchorBlockId, + isNot(before.wysiwygState.extentBlockId), + ); + expect(before.wysiwygState.anchorOffset, 0); + expect(before.wysiwygState.extentOffset, 3); + }, + ); + testWidgets('Source groups typing and deletion undo with historical carets', ( tester, ) async { @@ -18744,6 +19865,7 @@ Future _waitForSearchCondition( WidgetTester tester, bool Function() condition, { int attempts = 300, + String Function()? debugState, }) async { for (var attempt = 0; attempt < attempts; attempt++) { await tester.pump(const Duration(milliseconds: 20)); @@ -18752,7 +19874,7 @@ Future _waitForSearchCondition( () => Future.delayed(const Duration(milliseconds: 10)), ); } - fail('Timed out waiting for workspace search.'); + fail('Timed out waiting for workspace search. ${debugState?.call() ?? ''}'); } BusyMarkSourceEditingController _searchSourceController(WidgetTester tester) => diff --git a/test/src/document_edit_history_test.dart b/test/src/document_edit_history_test.dart new file mode 100644 index 00000000..03526357 --- /dev/null +++ b/test/src/document_edit_history_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/services.dart'; +import 'package:busymark/src/editor/document_edit_history.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'save acknowledgements preserve content ownership and ABA traversal invalidates it', + () { + final history = Object(); + final accepted = DocumentEditBase('same source', 1, history); + expect( + DocumentEditBase( + 'same source', + 1, + Object(), + ).sameDocumentVersion(accepted), + isTrue, + reason: 'Closing a group preserves the accepted content revision', + ); + expect( + DocumentEditBase( + 'same source', + 2, + history, + ).sameDocumentVersion(accepted), + isTrue, + ); + expect( + DocumentEditBase( + 'same source', + 3, + Object(), + ).sameDocumentVersion(accepted), + isFalse, + ); + expect( + DocumentEditBase( + 'different source', + 1, + history, + ).sameDocumentVersion(accepted), + isFalse, + ); + }, + ); + + test('single-unit classification uses actual carets in repeated text', () { + DocumentSimpleEditKind? kind( + String before, + String after, + int oldCaret, + int caret, + ) => documentSimpleEditKind( + before: before, + after: after, + previousSelection: TextSelection.collapsed(offset: oldCaret), + selection: TextSelection.collapsed(offset: caret), + ); + expect(kind('aaaa', 'aaaaa', 2, 3), DocumentSimpleEditKind.typing); + expect(kind('aaaaaa', 'aaaaa', 4, 3), DocumentSimpleEditKind.backspace); + expect(kind('aaaaaa', 'aaaaa', 3, 3), DocumentSimpleEditKind.delete); + expect(kind('abc', 'ab\nc', 2, 3), isNull); + expect(kind('ab\nc', 'abc', 3, 2), isNull); + expect(kind('abc', 'abcx', 0, 4), isNull); + expect( + documentSimpleEditKind( + before: 'aaaa', + after: 'aaaaa', + previousSelection: const TextSelection(baseOffset: 1, extentOffset: 3), + selection: const TextSelection.collapsed(offset: 4), + ), + isNull, + ); + }); + + test('typing group has exact idle, total-age and size bounds', () { + var now = Duration.zero; + final groups = DocumentUndoGrouping(clock: () => now); + String edit({bool continues = true}) => + groups.next(owner: 'document', continues: continues, size: 1); + final first = edit(continues: false); + now += const Duration(microseconds: 1999999); + expect(edit(), first); + now += DocumentUndoGrouping.idleLimit; + final afterIdle = edit(); + expect(afterIdle, isNot(first)); + final start = now; + for (var i = 1; i < 10; i++) { + now = start + Duration(seconds: i); + expect(edit(), afterIdle); + } + now = start + DocumentUndoGrouping.ageLimit; + final afterAge = edit(); + expect(afterAge, isNot(afterIdle)); + for (var i = 1; i < DocumentUndoGrouping.sizeLimit; i++) { + expect(edit(), afterAge); + } + expect(edit(), isNot(afterAge)); + }); + + test('clock reversal, operation boundary and new lifetime close groups', () { + var now = const Duration(seconds: 10); + final groups = DocumentUndoGrouping(clock: () => now); + String edit({bool continues = true}) => + groups.next(owner: 'document', continues: continues, size: 1); + final first = edit(); + now -= const Duration(microseconds: 1); + final reversed = edit(); + expect(reversed, isNot(first)); + expect(edit(continues: false), isNot(reversed)); + final recreated = DocumentUndoGrouping(clock: () => now); + expect( + recreated.next(owner: 'document', continues: false, size: 1), + isNot(first), + ); + }); +} diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index aa2bd855..ff72170d 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -60,6 +60,64 @@ void main() { maximumBytes: 16 * 1024 * 1024, ); + for (final memory in [false, true]) { + test( + 'equal-time ordinary captures use the latest revision (memory=$memory)', + () async { + final LocalHistoryStore tested = memory + ? MemoryLocalHistoryStore() + : store; + final time = DateTime(2026); + await tested.capture(request('first', time), policy); + final latest = await tested.capture(request('second', time), policy); + final duplicate = await tested.capture(request('second', time), policy); + expect(duplicate.deduplicated, isTrue); + expect(duplicate.revision!.id, latest.revision!.id); + expect((await tested.load()).revisions, hasLength(2)); + final returned = await tested.capture(request('first', time), policy); + expect(returned.deduplicated, isFalse); + expect((await tested.load()).revisions, hasLength(3)); + }, + ); + test( + 'traversal protection reuses exact durable content (memory=$memory)', + () async { + final LocalHistoryStore tested = memory + ? MemoryLocalHistoryStore() + : store; + final first = await tested.capture( + request('original', DateTime(2026)), + policy, + ); + await tested.capture(request('later', DateTime(2026, 1, 2)), policy); + final protection = await tested.capture( + request( + 'original', + DateTime(2026, 1, 3), + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + expect(protection.deduplicated, isTrue); + expect(protection.revision!.id, first.revision!.id); + expect((await tested.load()).revisions, hasLength(2)); + final differentFormat = await tested.capture( + LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: 'original', + format: TextFormatMetadata.utf8Lf.copyWith(hasUtf8Bom: true), + capturedAt: DateTime(2026, 1, 4), + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + expect(differentFormat.deduplicated, isFalse); + expect((await tested.load()).revisions, hasLength(3)); + }, + ); + } + final sentinelTime = DateTime.utc(2000); LocalHistoryCaptureRequest journalRequest(String text, int minute) => request(text, DateTime.utc(2026, 10, 17).add(Duration(minutes: minute))); diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 3a15c517..5fc4206c 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -36,6 +36,437 @@ void main() { if (await root.exists()) await root.delete(recursive: true); }); + test( + 'reload is an undoable replacement after an existing redo branch', + () async { + final path = p.join(root.path, 'replacement.md'); + await File(path).writeAsString('original'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store); + await h.controller.openPath(path); + h.controller.updateActiveText('first'); + h.controller.updateActiveText('second'); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.state.activeText, 'first'); + expect(h.state.activeBuffer!.editorState.undoState.redo, isNotEmpty); + await File(path).writeAsString('disk replacement\n'); + expect( + await h.controller.reloadBufferFromDisk(h.state.activeBuffer!.id), + isTrue, + ); + expect(h.state.activeText, 'disk replacement\n'); + expect(h.state.activeBuffer!.isDirty, isFalse); + expect(h.controller.redoActiveBuffer(), isFalse); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.state.activeText, 'first'); + expect(h.state.activeBuffer!.isDirty, isTrue); + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeText, 'disk replacement\n'); + expect(h.state.activeBuffer!.isDirty, isFalse); + }, + ); + + for (final autoSave in [false, true]) { + test( + 'pre-traversal recovery survives branch and reopen (autosave=$autoSave)', + () async { + final path = p.join(root.path, 'traversal.md'); + await File(path).writeAsString('baseline'); + final store = FileLocalHistoryStore( + rootDirectory: () async => Directory(p.join(root.path, 'history')), + ); + final sessions = MemoryDocumentSessionStore(); + final recovery = MemoryDocumentRecoveryStore(); + final h = await _harness( + store, + sessionStore: sessions, + recoveryStore: recovery, + ); + await h.container + .read(appSettingsControllerProvider.notifier) + .setAutoSave(autoSave); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + h.controller.updateActiveText('uncheckpointed work to recover'); + expect(h.controller.undoActiveBuffer(), isTrue); + h.controller.updateActiveText('new branch'); + expect(h.controller.redoActiveBuffer(), isFalse); + if (autoSave) { + await _waitFor( + () => !h.state.activeBuffer!.isDirty, + operation: 'autosave after branch', + ); + } + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect( + await h.controller.closeDocumentBuffer( + h.state.activeBuffer!.id, + discard: true, + ), + isTrue, + ); + await h.controller.flushPersistence(); + h.container.dispose(); + final reopened = await _harness( + store, + sessionStore: sessions, + recoveryStore: recovery, + ); + await reopened.controller.openPath(path); + final sources = await _revisionSources( + store, + (await store.load()).revisions, + ); + expect(sources, contains('uncheckpointed work to recover')); + expect(sources, contains('new branch')); + }, + ); + } + + test( + 'failed traversal protections remain independent and restart from disk', + () async { + final path = p.join(root.path, 'failed-traversal.md'); + await File(path).writeAsString('baseline'); + final historyRoot = Directory(p.join(root.path, 'history')); + final disk = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final store = _ControllableHistoryStore(disk); + final sessionPath = p.join(root.path, 'session.json'); + final recoveryPath = p.join(root.path, 'recovery.json'); + final h = await _harness( + store, + sessionStore: JsonDocumentSessionStore(filePathOverride: sessionPath), + recoveryStore: JsonDocumentRecoveryStore( + filePathOverride: recoveryPath, + ), + ); + await h.controller.openPath(path); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + store.capturesAvailable = false; + h.controller.updateActiveText('first unresolved source'); + h.controller.updateActiveText('second unresolved source'); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.controller.undoActiveBuffer(), isTrue); + h.controller.updateActiveText('new branch after two undos'); + expect(await history.flushAll(h.state.documentBuffers), isFalse); + expect(history.warningForBuffer(h.state.activeBuffer!.id), isNotNull); + final protected = history.retainedDetachedCaptures + .map((c) => c.protection?.snapshot.source) + .toList(); + expect( + protected, + containsAll(['first unresolved source', 'second unresolved source']), + ); + await h.controller.flushPersistence(); + // Inspect a fresh disk reader before retiring the old owner. A queued + // in-memory snapshot alone does not satisfy the recovery assertion. + final durable = await JsonDocumentSessionStore( + filePathOverride: sessionPath, + ).load(); + expect( + durable!.retainedLocalHistoryCaptures.map( + (c) => c.protection?.snapshot.source, + ), + containsAll(['first unresolved source', 'second unresolved source']), + ); + h.container.dispose(); + final restarted = await _harness( + FileLocalHistoryStore(rootDirectory: () async => historyRoot), + sessionStore: JsonDocumentSessionStore(filePathOverride: sessionPath), + recoveryStore: JsonDocumentRecoveryStore( + filePathOverride: recoveryPath, + ), + ); + expect(await restarted.controller.restorePreviousSession(), isTrue); + expect(restarted.state.activeText, 'new branch after two undos'); + final resumed = restarted.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await resumed.flushAll(restarted.state.documentBuffers), isTrue); + expect( + await _revisionSources(disk, (await disk.load()).revisions), + containsAll(['first unresolved source', 'second unresolved source']), + ); + }, + ); + + test( + 'delayed traversal capture cannot be overwritten by later protection', + () async { + final path = p.join(root.path, 'delayed.md'); + await File(path).writeAsString('baseline'); + final store = _BlockingTraversalStore(); + final h = await _harness(store); + await h.controller.openPath(path); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + h.controller.updateActiveText('first'); + h.controller.updateActiveText('second'); + expect(h.controller.undoActiveBuffer(), isTrue); + await store.started.future; + expect(h.controller.undoActiveBuffer(), isTrue); + h.controller.updateActiveText('branch'); + expect( + history.retainedDetachedCaptures.map( + (c) => c.protection?.snapshot.source, + ), + containsAll(['first', 'second']), + ); + store.release.complete(); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final sources = await _revisionSources( + store, + (await store.load()).revisions, + ); + expect(sources, containsAll(['first', 'second', 'branch'])); + // Repeated traversal of already protected sources is deduplicated. + final count = sources.length; + for (var i = 0; i < 12; i++) { + h.controller.undoActiveBuffer(); + h.controller.redoActiveBuffer(); + } + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect( + (await store.load()).revisions.length, + lessThanOrEqualTo(count + 2), + ); + }, + ); + + for (final excluded in [false, true]) { + test( + 'history traversal respects ${excluded ? "excluded paths" : "disabled recording"}', + () async { + final path = p.join(root.path, 'private.md'); + await File(path).writeAsString('private baseline'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store); + final settings = h.container.read( + appSettingsControllerProvider.notifier, + ); + if (excluded) { + await settings.setLocalHistoryExcludedPaths([path]); + } else { + await settings.setLocalHistoryRecordingEnabled(false); + } + await h.controller.openPath(path); + h.controller.updateActiveText('private edit'); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.controller.redoActiveBuffer(), isTrue); + h.controller.updateActiveText('private branch'); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect(history.retainedDetachedCaptures, isEmpty); + expect((await store.load()).revisions, isEmpty); + }, + ); + } + + for (final refresh in [false, true]) { + test( + '${refresh ? "workspace refresh" : "file monitor"} replacement closes undo and redo boundaries', + () async { + final path = p.join(root.path, 'monitored.md'); + await File(path).writeAsString('baseline'); + final monitor = _ControlledFileMonitor(); + final h = await _harness( + MemoryLocalHistoryStore(), + fileMonitor: monitor, + ); + await h.controller.openPath(path); + h.controller.updateActiveText('first'); + h.controller.updateActiveText('second'); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(await h.controller.saveActive(), isTrue); + await File(path).writeAsString('external replacement'); + if (refresh) { + await h.controller.refreshWorkspaceFromDiskPreservingOpenTabs(); + } else { + monitor.emitChange(path); + await _waitFor( + () => h.state.activeText == 'external replacement', + operation: 'external replacement', + ); + } + expect(h.state.activeText, 'external replacement'); + expect(h.controller.redoActiveBuffer(), isFalse); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.state.activeText, 'first'); + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeText, 'external replacement'); + }, + ); + } + + test( + 'replacement traversal restores BOM, CRLF, final newline and selection', + () async { + final path = p.join(root.path, 'format.md'); + await File( + path, + ).writeAsBytes([0xef, 0xbb, 0xbf, ...'one\r\ntwo\r\n'.codeUnits]); + final h = await _harness(MemoryLocalHistoryStore()); + await h.controller.openPath(path); + h.controller.updateActiveSourceText( + 'one\r\ntwo edited', + previousSelection: const TextSelection.collapsed(offset: 2), + selection: const TextSelection.collapsed(offset: 9), + ); + h.controller.updateActiveText('another edit'); + expect(h.controller.undoActiveBuffer(), isTrue); + await File(path).writeAsString('replacement\n'); + expect( + await h.controller.reloadBufferFromDisk(h.state.activeBuffer!.id), + isTrue, + ); + expect(h.controller.undoActiveBuffer(), isTrue); + final restored = h.state.activeBuffer!; + expect(restored.text, 'one\r\ntwo edited'); + expect(restored.format.hasUtf8Bom, isTrue); + expect(restored.format.lineEnding, DocumentLineEnding.crlf); + expect(restored.format.hasFinalNewline, isFalse); + expect(restored.editorState.selection.extentOffset, 9); + expect(await h.controller.saveActive(), isTrue); + expect(await File(path).readAsBytes(), [ + 0xef, + 0xbb, + 0xbf, + ...'one\r\ntwo edited'.codeUnits, + ]); + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeBuffer!.format.hasUtf8Bom, isFalse); + expect(h.state.activeBuffer!.format.lineEnding, DocumentLineEnding.lf); + expect(h.state.activeBuffer!.format.hasFinalNewline, isTrue); + expect(await h.controller.saveActive(), isTrue); + expect(await File(path).readAsString(), 'replacement\n'); + }, + ); + + test('same-content reload preserves the valid redo branch', () async { + final path = p.join(root.path, 'same.md'); + await File(path).writeAsString('baseline'); + final h = await _harness(MemoryLocalHistoryStore()); + await h.controller.openPath(path); + h.controller.updateActiveText('first'); + h.controller.updateActiveText('second'); + h.controller.undoActiveBuffer(); + expect(await h.controller.saveActive(), isTrue); + final undo = h.state.activeBuffer!.editorState.undoState; + expect( + await h.controller.reloadBufferFromDisk(h.state.activeBuffer!.id), + isTrue, + ); + expect(h.state.activeBuffer!.editorState.undoState, same(undo)); + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeText, 'second'); + }); + + test( + 'source preconditions accept consecutive edits and reject invalidated bases', + () async { + final path = p.join(root.path, 'preconditions.md'); + await File(path).writeAsString('baseline'); + final h = await _harness(MemoryLocalHistoryStore()); + await h.controller.openPath(path); + void edit(String expectedSource, int expectedRevision, String source) => + h.controller.updateActiveSourceText( + source, + sourceBufferId: h.state.activeBuffer!.id, + expectedSource: expectedSource, + expectedRevision: expectedRevision, + previousSelection: const TextSelection.collapsed(offset: 0), + selection: const TextSelection.collapsed(offset: 0), + ); + edit('baseline', h.state.activeBuffer!.revision, 'first'); + final first = h.state.activeBuffer!; + edit('first', first.revision, 'second'); + expect(h.state.activeText, 'second'); + h.controller.undoActiveBuffer(); + expect(h.state.activeText, 'first'); + final restored = h.state.activeBuffer!; + edit('first', first.revision, 'stale first editor'); + expect(h.state.activeBuffer, same(restored)); + edit('second', restored.revision, 'stale second editor'); + expect(h.state.activeBuffer, same(restored)); + edit('first', restored.revision, 'legitimate next edit'); + expect(h.state.activeText, 'legitimate next edit'); + expect(h.controller.redoActiveBuffer(), isFalse); + }, + ); + + test( + 'transient retained-journal failure keeps Undo and recovery usable', + () async { + final path = p.join(root.path, 'journal-failure.md'); + await File(path).writeAsString('baseline'); + final store = MemoryLocalHistoryStore(); + final sessions = _TransientTraversalSessionStore(); + final h = await _harness( + store, + sessionStore: sessions, + recoveryStore: MemoryDocumentRecoveryStore(), + ); + await h.controller.openPath(path); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + await h.controller.flushPersistence(); + h.controller.updateActiveText('work before Undo'); + await _waitFor( + () => + history.pendingSnapshotForBuffer(h.state.activeBuffer!.id)?.text == + 'work before Undo', + operation: 'edit observation before journal failure', + ); + sessions.failNext = true; + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.state.activeText, 'baseline'); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + await h.controller.flushPersistence(); + expect(sessions.failures, 1); + expect( + await _revisionSources(store, (await store.load()).revisions), + contains('work before Undo'), + ); + h.controller.updateActiveText('new branch'); + expect(h.controller.redoActiveBuffer(), isFalse); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + }, + ); + + test( + 'first untitled Undo retains its existing single history lineage', + () async { + final store = MemoryLocalHistoryStore(); + final h = await _harness(store); + expect(await h.controller.createMarkdownWorkspace(), isTrue); + expect(h.state.activeText, isEmpty); + h.controller.updateActiveText('first uncheckpointed work'); + expect(h.controller.undoActiveBuffer(), isTrue); + h.controller.updateActiveText('new branch'); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + final id = history.documentIdForBuffer(h.state.activeBuffer!.id); + expect(id, isNotNull); + expect( + await _revisionSources( + store, + snapshot.revisions.where((r) => r.documentId == id).toList(), + ), + containsAll(['first uncheckpointed work', 'new branch']), + ); + }, + ); + test('pristine empty draft discards without protective storage', () async { final store = _UnavailableCaptureStore(); final harness = await _harness(store); @@ -7283,6 +7714,10 @@ class _ControlledFileMonitor extends WorkspaceFileMonitor { @override Stream get events => _events.stream; + void emitChange(String path) => _events.add( + WorkspaceFileMonitorEvent(kind: WorkspaceFileEventKind.changed, path: path), + ); + void emitMove( String sourcePath, String destinationPath, { @@ -7428,3 +7863,36 @@ class _DelayedSaveWorkspaceService extends WorkspaceService { ); } } + +class _BlockingTraversalStore extends MemoryLocalHistoryStore { + final started = Completer(); + final release = Completer(); + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal && + !release.isCompleted) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return super.capture(request, policy); + } +} + +class _TransientTraversalSessionStore extends MemoryDocumentSessionStore { + bool failNext = false; + int failures = 0; + @override + Future save(WorkspaceSessionSnapshot snapshot) async { + if (failNext) { + failNext = false; + failures++; + throw const FileSystemException( + 'Injected retained-journal write failure', + ); + } + return super.save(snapshot); + } +} diff --git a/test/src/nextcloud_workspace_test.dart b/test/src/nextcloud_workspace_test.dart index 92df0650..c76d3b8c 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,53 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + test( + 'remote refresh is an undoable boundary with an existing redo branch', + () async { + var remote = serverNote(1, content: 'baseline', etag: 'base'); + final h = await _Harness.create( + seedLocal: false, + client: MockClient((request) async { + if (request.method == 'PUT') { + remote = serverNote( + 1, + content: (jsonDecode(request.body) as Map)['content'] as String, + etag: 'saved', + ); + return http.Response(jsonEncode(remote), 200); + } + return http.Response( + jsonEncode(request.url.path.endsWith('/1') ? remote : [remote]), + 200, + ); + }), + ); + addTearDown(h.dispose); + await h.repository.synchronize(_accountId, allowWrites: false); + await h.controller.openNextcloudWorkspace(_accountId); + await h.controller.refreshNextcloudNotes(); + h.controller.updateActiveText('first'); + h.controller.updateActiveText('second'); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(await h.controller.saveActive(), isTrue); + await h.repository.synchronize(_accountId); + expect(h.state.activeText, 'first'); + expect(h.state.activeBuffer!.editorState.undoState.redo, isNotEmpty); + remote = serverNote( + 1, + content: 'server replacement', + etag: 'replacement', + ); + await h.controller.refreshNextcloudNotes(); + expect(h.state.activeText, 'server replacement'); + expect(h.controller.redoActiveBuffer(), isFalse); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(h.state.activeText, 'first'); + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeText, 'server replacement'); + }, + ); + test( 'no-op properties Save preserves revision/time and schedules no server pass', () async { diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index cbcb5814..2aee51b3 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/editor/document_edit_history.dart'; import 'package:busymark/src/editor/link_destination.dart'; import 'package:busymark/src/assets/document_media_context.dart'; import 'dart:async'; @@ -57,6 +58,259 @@ import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; void main() { + testWidgets( + 'Source identity changes keep the focused input client registered', + (tester) async { + final key = GlobalKey(); + Widget host(String id) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + key: key, + text: id, + documentId: id, + filePath: '$id.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onCloseSearch: () {}, + onChanged: (_, _) {}, + ), + ), + ); + await tester.pumpWidget(host('first')); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField).first); + expect(tester.testTextInput.hasAnyClients, isTrue); + for (final id in ['second', 'first']) { + await tester.pumpWidget(host(id)); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField).first); + field.focusNode!.requestFocus(); + await tester.pump(); + expect(field.focusNode!.hasPrimaryFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect(field.controller!.text, id); + } + }, + ); + + testWidgets( + 'Source retains an accepted edit through a delayed widget acknowledgement', + (tester) async { + var base = const DocumentEditBase('abc', 0); + Widget host({required bool wrap}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: 'abc', + filePath: 'one.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: wrap, + searchActive: false, + searchOptions: const SourceSearchOptions(), + readEditBase: () => base, + onCloseSearch: () {}, + onChanged: (text, _) => + base = DocumentEditBase(text, base.revision! + 1), + ), + ), + ); + await tester.pumpWidget(host(wrap: true)); + final field = find.byType(TextField).first; + await tester.enterText(field, 'abcd'); + final controller = tester.widget(field).controller!; + expect(base.source, 'abcd'); + await tester.pumpWidget(host(wrap: false)); + expect(tester.widget(field).controller, same(controller)); + expect(controller.text, 'abcd'); + expect(controller.selection.extentOffset, 4); + await tester.enterText(field, 'abcdx'); + expect(base.source, 'abcdx'); + }, + ); + + testWidgets( + 'Source reconciles live authority and caret through stale widget properties', + (tester) async { + var base = const DocumentEditBase('abc', 0); + var selection = const TextSelection.collapsed(offset: 0); + Widget host() => MaterialApp( + home: Scaffold( + body: BusyMarkSourceEditor( + text: 'abc', + filePath: 'one.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + initialSelection: const TextSelection.collapsed(offset: 0), + readEditBase: () => base, + readSelection: () => selection, + onChanged: (_, _) => fail('Reconciliation must not republish'), + onCloseSearch: () {}, + ), + ), + ); + await tester.pumpWidget(host()); + base = const DocumentEditBase('restored', 1); + selection = const TextSelection.collapsed(offset: 5); + await tester.pumpWidget(host()); + final controller = tester + .widget(find.byType(TextField).first) + .controller!; + expect(controller.text, 'restored'); + expect(controller.selection, selection); + }, + ); + + testWidgets('rejected Source publication reconciles before the next edit', ( + tester, + ) async { + var base = const DocumentEditBase('abc', 0); + var accept = false; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: 'abc', + filePath: 'one.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + readEditBase: () => base, + onCloseSearch: () {}, + onChanged: (text, _) { + if (accept) base = DocumentEditBase(text, base.revision! + 1); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'abcd'); + await tester.pump(); + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'abc', + ); + expect(base.source, 'abc'); + expect(base.revision, 0); + accept = true; + await tester.enterText(find.byType(TextField).first, 'abcx'); + await tester.pump(); + expect(base.source.trimRight(), 'abcx'); + expect(base.revision, 1); + }); + + testWidgets( + 'fifteen minutes of continuous Source multiline typing stays bounded', + (tester) async { + var now = Duration.zero; + var clockReads = 0; + var buffer = DocumentBuffer.untitled( + id: 'long-typing', + name: 'typing.md', + text: 'abc', + ); + Duration clock() { + clockReads++; + return now; + } + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: 'abc', + documentId: buffer.id, + filePath: null, + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + undoClock: clock, + onChanged: (_, _) {}, + onCloseSearch: () {}, + onTransactionalChanged: (text, _, before, after, group) => + buffer = buffer.edited( + text, + previousSelection: before, + nextSelection: after, + undoGroup: group, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ) + .first; + await tester.showKeyboard(field); + tester.widget(field).controller!.selection = + const TextSelection.collapsed(offset: 3); + var text = 'abc'; + final sizes = {}; + for (var i = 0; i <= 900; i++) { + now = Duration(seconds: i); + text += i % 37 == 36 ? '\n' : 'x'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + ); + final group = buffer.editorState.undoState.activeGroup; + if (group != null) sizes.update(group, (n) => n + 1, ifAbsent: () => 1); + if (i % 50 == 0) await tester.pump(); + } + expect(now, const Duration(minutes: 15)); + expect( + clockReads, + greaterThan(800), + reason: 'The injected clock is consulted by real edit grouping', + ); + expect(sizes.length, greaterThanOrEqualTo(90)); + expect(sizes.values, everyElement(lessThanOrEqualTo(10))); + final previous = buffer.editorState.undoState.undo.last; + expect(previous.text.length, greaterThan(880)); + expect(buffer.text.length - previous.text.length, lessThanOrEqualTo(10)); + // Exactly one document undo restores this bounded group's baseline. + buffer = buffer.copyWith( + text: previous.text, + editorState: buffer.editorState.copyWith( + undoState: buffer.editorState.undoState.afterUndo( + buffer.historyState, + ), + ), + ); + expect(buffer.text, previous.text); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets( 'Source consumes shared pages and replaces in an unsaved buffer', (tester) async { @@ -8712,6 +8966,93 @@ void main() { expect(controller.text, 'Before\n\n$procedure\n'); }); + for (final operation in ['paste', 'cut', 'link']) { + testWidgets('Source delayed $operation rejects an Undo Redo round trip', ( + tester, + ) async { + const selection = TextSelection(baseOffset: 1, extentOffset: 3); + var buffer = DocumentBuffer.untitled( + id: 'source-document', + name: 'Async origin', + text: 'older', + ).edited('abcdef', nextSelection: selection); + TextEditingValue traverse(bool redo) { + final history = buffer.editorState.undoState; + final target = (redo ? history.redo : history.undo).last; + final nextHistory = redo + ? history.afterRedo(buffer.historyState) + : history.afterUndo(buffer.historyState); + buffer = buffer.copyWith( + text: target.text, + revision: buffer.revision + 1, + editorState: buffer.editorState.copyWith( + selection: target.selection, + undoState: nextHistory, + ), + ); + return TextEditingValue(text: target.text, selection: target.selection); + } + + final clipboard = _SourceTestClipboard( + readData: const RichClipboardData(text: 'paste', generation: 53), + delayRead: operation == 'paste', + delayWrite: operation == 'cut', + ); + final link = Completer(); + final linkStarted = Completer(); + final controller = await _pumpClipboardSourceEditor( + tester, + source: buffer.text, + clipboard: clipboard, + onTransactionalChanged: (source, _, previous, next, group) { + buffer = buffer.edited( + source, + previousSelection: previous, + nextSelection: next, + undoGroup: group, + ); + }, + onUndo: () => traverse(false), + onRedo: () => traverse(true), + onChooseLink: () { + linkStarted.complete(); + return link.future; + }, + ); + controller.selection = selection; + await _pressControlKey(tester, switch (operation) { + 'paste' => LogicalKeyboardKey.keyV, + 'cut' => LogicalKeyboardKey.keyX, + _ => LogicalKeyboardKey.keyK, + }); + await switch (operation) { + 'paste' => clipboard.readStarted.future, + 'cut' => clipboard.writeStarted.future, + _ => linkStarted.future, + }; + // No parent rebuild: widget revision and the final editing value are + // identical, but the asynchronous operation belongs to a retired state. + await _pressControlKey(tester, LogicalKeyboardKey.keyZ); + await _pressControlKey(tester, LogicalKeyboardKey.keyZ, shift: true); + expect( + controller.value, + const TextEditingValue(text: 'abcdef', selection: selection), + ); + final revision = buffer.revision; + final history = buffer.editorState.undoState; + if (operation == 'paste') clipboard.releaseRead(); + if (operation == 'cut') clipboard.releaseWrite(); + if (operation == 'link') { + link.complete(const LinkDestination('https://example.test')); + } + await tester.pumpAndSettle(); + expect(controller.text, 'abcdef'); + expect(buffer.text, 'abcdef'); + expect(buffer.revision, revision); + expect(buffer.editorState.undoState, same(history)); + }); + } + testWidgets('Source delayed paste rejects a changed selection', ( tester, ) async { diff --git a/test/src/wysiwyg_selection_lifecycle_test.dart b/test/src/wysiwyg_selection_lifecycle_test.dart index cb7a6cef..5025f4ac 100644 --- a/test/src/wysiwyg_selection_lifecycle_test.dart +++ b/test/src/wysiwyg_selection_lifecycle_test.dart @@ -251,6 +251,36 @@ void _expectTableGeometry(WidgetTester tester) { } void main() { + testWidgets('table selection IME input delegates platform history actions', ( + tester, + ) async { + final state = await _mount( + tester, + _table, + MemoryRichClipboard(), + externalHistory: true, + ); + state.recreate(_documentSession(state.document)); + await tester.pumpAndSettle(); + final input = find.byWidgetPredicate( + (w) => w is EditableText && w.focusNode.hasFocus, + ); + expect(input, findsOneWidget); + final context = tester.state(input).context; + Actions.invoke( + context, + const UndoTextIntent(SelectionChangedCause.keyboard), + ); + expect(state.undoCalls, 1); + Actions.invoke( + context, + const RedoTextIntent(SelectionChangedCause.keyboard), + ); + expect(state.redoCalls, 1); + expect(state.source, _table); + expect(state.changes, isEmpty); + }); + _registerLifecycleMatrix(); _registerRestorationAndTerminalTests(); setUp( diff --git a/test/src/wysiwyg_session_test.dart b/test/src/wysiwyg_session_test.dart index c58315b3..eca407dd 100644 --- a/test/src/wysiwyg_session_test.dart +++ b/test/src/wysiwyg_session_test.dart @@ -1,3 +1,5 @@ +import 'package:busymark/src/editor/document_edit_history.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; @@ -24,8 +26,224 @@ class _SessionReportNotifier extends Notifier { } void main() { + testWidgets( + 'rich controlled model replacement works without source metadata', + (tester) async { + Widget host(String source) { + final parsed = const MarkdownParser() + .parse(filePath: 'one.md', source: source) + .busyDocument; + return _app( + BusyMarkWysiwygEditor( + document: BusyDocument( + filePath: parsed.filePath, + mode: parsed.mode, + blocks: parsed.blocks, + ), + onSourceChanged: (_, _) {}, + ), + ); + } + + await tester.pumpWidget(host('abc')); + await tester.pumpAndSettle(); + await tester.pumpWidget(host('replacement')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'replacement', + ); + }, + ); + + for (final afterTyping in [false, true]) { + testWidgets( + 'rich live authority survives a stale widget source afterTyping=$afterTyping', + (tester) async { + var base = const DocumentEditBase('abc', 0); + final key = GlobalKey(); + final original = const MarkdownParser() + .parse(filePath: 'one.md', source: 'abc') + .busyDocument; + Widget host() => _app( + BusyMarkWysiwygEditor( + key: key, + document: original, + contentRevision: 0, + readEditBase: () => base, + onSourceChanged: (_, source) => + base = DocumentEditBase(source, base.revision! + 1), + ), + ); + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + if (afterTyping) { + await tester.enterText(find.byType(TextField).first, 'abcd'); + expect(base.source.trimRight(), 'abcd'); + } + // The live authority advances before the parent's document projection. + // An unrelated rebuild still carries the original source and revision. + base = DocumentEditBase('restored', base.revision! + 1); + await tester.pumpWidget(host()); + await tester.pumpAndSettle(); + final field = find.byType(TextField).first; + expect(tester.widget(field).controller!.text, 'restored'); + expect(key.currentState!.serializedSource, 'restored'); + await tester.enterText(field, 'restoredx'); + expect(base.source.trimRight(), 'restoredx'); + }, + ); + } + + testWidgets('rejected rich publication reconciles before the next edit', ( + tester, + ) async { + var base = const DocumentEditBase('abc', 0); + var accept = false; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: const MarkdownParser() + .parse(filePath: 'one.md', source: 'abc') + .busyDocument, + readEditBase: () => base, + onSourceChanged: (_, text) { + if (accept) base = DocumentEditBase(text, base.revision! + 1); + }, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'abcd'); + await tester.pump(); + expect( + tester.widget(find.byType(TextField).first).controller!.text, + 'abc', + ); + expect(base.source, 'abc'); + expect(base.revision, 0); + accept = true; + await tester.enterText(find.byType(TextField).first, 'abcx'); + await tester.pump(); + expect(base.source.trimRight(), 'abcx'); + expect(base.revision, 1); + }); + + testWidgets('fifteen minutes of continuous rich typing stays bounded', ( + tester, + ) async { + var now = Duration.zero; + var clockReads = 0; + var buffer = DocumentBuffer.untitled( + id: 'long-typing', + name: 'typing.md', + text: 'abc', + ); + Duration clock() { + clockReads++; + return now; + } + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: const MarkdownParser() + .parse(filePath: 'typing.md', source: 'abc') + .busyDocument, + documentId: buffer.id, + undoClock: clock, + useExternalUndoHistory: true, + onSourceChanged: (_, _) {}, + onTransactionalSourceChanged: (_, text, group) => + buffer = buffer.edited(text, undoGroup: group), + ), + ), + ); + await tester.pumpAndSettle(); + final field = find + .descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byType(TextField), + ) + .first; + await tester.showKeyboard(field); + tester.widget(field).controller!.selection = + const TextSelection.collapsed(offset: 3); + var text = 'abc'; + final sizes = {}; + for (var i = 0; i <= 900; i++) { + now = Duration(seconds: i); + text += 'x'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + ); + final group = buffer.editorState.undoState.activeGroup; + if (group != null) sizes.update(group, (n) => n + 1, ifAbsent: () => 1); + if (i % 50 == 0) await tester.pump(); + } + expect(now, const Duration(minutes: 15)); + expect( + clockReads, + greaterThan(800), + reason: 'The injected clock is consulted by real edit grouping', + ); + expect(sizes.length, greaterThanOrEqualTo(90)); + expect(sizes.values, everyElement(lessThanOrEqualTo(10))); + final previous = buffer.editorState.undoState.undo.last; + expect(previous.text.length, greaterThan(880)); + expect(buffer.text.length - previous.text.length, lessThanOrEqualTo(10)); + // Exactly one document undo restores this bounded group's baseline. + buffer = buffer.copyWith( + text: previous.text, + editorState: buffer.editorState.copyWith( + undoState: buffer.editorState.undoState.afterUndo(buffer.historyState), + ), + ); + expect(buffer.text, previous.text); + await tester.pumpWidget(const SizedBox.shrink()); + }); + const parser = MarkdownParser(); + for (final replacement in ['abc', 'Restored']) { + testWidgets( + 'authoritative $replacement wins during rich publication frame', + (tester) async { + BusyDocument parse(String source) => + parser.parse(filePath: 'one.md', source: source).busyDocument; + var published = 'abc'; + Widget editor(String source) => _app( + BusyMarkWysiwygEditor( + document: parse(source), + documentId: 'one', + useExternalUndoHistory: true, + onSourceChanged: (_, value) => published = value, + ), + ); + await tester.pumpWidget(editor('abc')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'abcd'); + expect(published, contains('abcd')); + // No settled frame between publication and authoritative replacement. + await tester.pumpWidget(editor(replacement)); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(TextField).first) + .controller! + .text, + replacement, + ); + await tester.enterText(find.byType(TextField).first, '${replacement}x'); + expect(published, contains('${replacement}x')); + expect(published, isNot(contains('abcd'))); + }, + ); + } + testWidgets( 'pinned non-list Markdown owner refreshes direct edits with authored positions', (tester) async { diff --git a/test/src/wysiwyg_structured_regression_test.dart b/test/src/wysiwyg_structured_regression_test.dart index 6a6366dd..39877a1e 100644 --- a/test/src/wysiwyg_structured_regression_test.dart +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -1489,6 +1489,7 @@ Body. key: key, document: document, useExternalUndoHistory: true, + undoClock: () => Duration.zero, onSourceChanged: (_, _) {}, onTransactionalSourceChanged: (_, value, group) { groups.add(group); @@ -1498,6 +1499,12 @@ Body. ), ); await tester.pump(); + tester + .widget(find.byType(TextField).first) + .controller! + .selection = const TextSelection.collapsed( + offset: 1, + ); await tester.enterText(find.byType(TextField).first, 'ab'); await tester.pump(); await tester.enterText(find.byType(TextField).first, 'abc'); diff --git a/tools/document_history_smoke.dart b/tools/document_history_smoke.dart new file mode 100644 index 00000000..348963cc --- /dev/null +++ b/tools/document_history_smoke.dart @@ -0,0 +1,503 @@ +// Native Linux document-history acceptance. All typing and Undo/Redo below use +// XTest through GTK. Controllers only set up modes/tabs and invoke app commands. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/main.dart' as application; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/source_highlighter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_models.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +Future main(List args) async { + if (args.length != 1 || + Platform.environment['BUSYMARK_HISTORY_DISPOSABLE'] != '1') { + throw ArgumentError( + 'Requires OUTPUT_DIRECTORY and BUSYMARK_HISTORY_DISPOSABLE=1 on a disposable X11 display', + ); + } + final root = Directory(args.single).absolute; + final data = Platform.environment['XDG_DATA_HOME'] ?? ''; + if (!data.startsWith('${root.path}/') || + Platform.environment['GDK_BACKEND'] != 'x11') { + throw ArgumentError( + 'Application support and the X11 display must be isolated', + ); + } + await root.create(recursive: true); + _NativeTrace.file = File('${root.path}/platform.jsonl'); + _HistoryProbeBinding(); + HardwareKeyboard.instance.addHandler((event) { + _NativeTrace.write({ + 'event': 'key', + 'down': event is KeyDownEvent, + 'up': event is KeyUpEvent, + 'logical': event.logicalKey.keyLabel, + 'control': HardwareKeyboard.instance.isControlPressed, + 'shift': HardwareKeyboard.instance.isShiftPressed, + }); + return false; + }); + final initial = File('${root.path}/source.md'); + await initial.writeAsString('abc'); + await File('${root.path}/editor.md').writeAsString('abc'); + await File('${root.path}/other.md').writeAsString('other'); + await application.main([initial.path]); + unawaited(_Driver(root).run()); +} + +class _NativeTrace { + static late File file; + static final clock = Stopwatch()..start(); + static void write(Map event) => file.writeAsStringSync( + '${jsonEncode({'elapsedUs': clock.elapsedMicroseconds, ...event})}\n', + mode: FileMode.append, + ); +} + +// Observe the production text-input channel while forwarding every request. +class _HistoryProbeBinding extends WidgetsFlutterBinding { + @override + BinaryMessenger createBinaryMessenger() => + _HistoryProbeMessenger(super.createBinaryMessenger()); +} + +class _HistoryProbeMessenger extends BinaryMessenger { + _HistoryProbeMessenger(this.delegate); + final BinaryMessenger delegate; + void record(String direction, String channel, ByteData? message) { + if (channel != SystemChannels.textInput.name || message == null) return; + final call = SystemChannels.textInput.codec.decodeMethodCall(message); + if (const { + 'TextInput.setClient', + 'TextInput.clearClient', + 'TextInput.setEditingState', + 'TextInput.show', + 'TextInput.hide', + 'TextInputClient.updateEditingState', + 'TextInputClient.updateEditingStateWithDeltas', + 'TextInputClient.performAction', + }.contains(call.method)) { + _NativeTrace.write({ + 'direction': direction, + 'method': call.method, + 'arguments': call.arguments, + }); + } + } + + @override + Future? send(String channel, ByteData? message) { + record('out', channel, message); + return delegate.send(channel, message); + } + + @override + void setMessageHandler(String channel, MessageHandler? handler) => + delegate.setMessageHandler( + channel, + handler == null + ? null + : (message) { + record('in', channel, message); + return handler(message); + }, + ); + @override + Future handlePlatformMessage( + String channel, + ByteData? data, + ui.PlatformMessageResponseCallback? callback, + ) async { + ServicesBinding.instance.channelBuffers.push( + channel, + data, + callback ?? (_) {}, + ); + } +} + +class _Driver { + _Driver(this.root); + final Directory root; + final checks = []; + late ProviderContainer container; + WorkspaceController get controller => + container.read(workspaceControllerProvider.notifier); + DocumentBuffer get buffer => + container.read(workspaceControllerProvider).activeBuffer!; + + Iterable elements() sync* { + final all = []; + void visit(Element e) { + all.add(e); + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + yield* all; + } + + TextField get field => elements() + .map((e) => e.widget) + .whereType() + .firstWhere( + (f) => + f.controller is BusyMarkSourceEditingController || + f.controller is BusyMarkWysiwygTextController, + ); + + Future until(bool Function() condition, String operation) async { + final timeout = Stopwatch()..start(); + while (!condition()) { + if (timeout.elapsed > const Duration(seconds: 25)) { + throw StateError( + 'Timeout: $operation; source=${buffer.text} ' + 'visible=${field.controller!.text} focus=${field.focusNode!.hasPrimaryFocus} ' + 'revision=${buffer.revision}', + ); + } + await Future.delayed(const Duration(milliseconds: 20)); + } + } + + void check(bool condition, String name) { + if (!condition) { + throw StateError( + '$name; buffer=${buffer.text} visible=${field.controller!.text}', + ); + } + checks.add(name); + stdout.writeln('PASS $name'); + } + + Future focusEnd() async { + field.focusNode!.requestFocus(); + field.controller!.selection = TextSelection.collapsed( + offset: field.controller!.text.length, + ); + await windowManager.focus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + Future key(List keys) async { + await _recordInput('before', keys); + final result = await Process.run('python3', ['-c', _nativeKeys, ...keys]); + if (result.exitCode != 0) throw StateError('XTest: ${result.stderr}'); + await Future.delayed(const Duration(milliseconds: 80)); + await _recordInput('after', keys); + } + + Future _recordInput(String phase, List keys) async { + final source = elements() + .whereType() + .map((e) => e.state) + .whereType() + .firstOrNull; + final event = { + 'phase': phase, + 'keys': keys, + 'source': buffer.text, + 'revision': buffer.revision, + 'visible': field.controller!.text, + 'selectionBase': field.controller!.selection.baseOffset, + 'selectionExtent': field.controller!.selection.extentOffset, + 'focus': field.focusNode!.hasPrimaryFocus, + 'readOnly': field.readOnly, + 'bufferReadOnly': buffer.readonly, + 'filePath': buffer.filePath, + 'editorPath': source?.widget.filePath, + 'publicationSource': source?.publicationBase.source, + 'publicationRevision': source?.publicationBase.revision, + }; + await File('${root.path}/input.jsonl').writeAsString( + '${jsonEncode(event)}\n', + mode: FileMode.append, + flush: true, + ); + } + + Future text(String expected) async { + await until( + () => buffer.text == expected && field.controller!.text == expected, + 'text $expected', + ); + final rich = elements() + .map((e) => e.widget) + .whereType() + .firstOrNull; + if (rich != null) { + check( + rich.document.source == expected, + 'rich projection agrees with buffer', + ); + final state = elements() + .whereType() + .map((e) => e.state) + .whereType() + .first; + check( + state.serializedSource == expected, + 'rich serialization agrees with buffer', + ); + } + } + + Future undo(String expected) async { + await key(['Control_L', 'z']); + await text(expected); + } + + Future redo(String expected) async { + await key(['Control_L', 'Shift_L', 'z']); + await text(expected); + } + + Future mode(DocumentViewModePreference mode) async { + controller.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await until( + () => elements().any( + (e) => mode == DocumentViewModePreference.source + ? e.widget is BusyMarkSourceEditor + : e.widget is BusyMarkWysiwygEditor, + ), + 'mode ${mode.name}', + ); + await Future.delayed(const Duration(milliseconds: 150)); + } + + Future saved(String expected) async { + await until(() => !buffer.isDirty, 'autosave'); + check( + await File(buffer.filePath!).readAsString() == expected, + 'saved bytes equal $expected', + ); + } + + Future run() async { + Object? failure; + try { + await until( + () => elements().any( + (e) => + e.widget is BusyMarkWysiwygEditor || + e.widget is BusyMarkSourceEditor, + ), + 'initial editor', + ); + final editor = elements().firstWhere( + (e) => + e.widget is BusyMarkWysiwygEditor || + e.widget is BusyMarkSourceEditor, + ); + container = ProviderScope.containerOf(editor, listen: false); + final settings = container.read(appSettingsControllerProvider.notifier); + await settings.setAutoSave(false); + await settings.setValidateOnEdit(false); + for (final editingMode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.editor, + ]) { + final name = editingMode.name; + final path = + '${root.path}/${name == 'source' ? 'source' : 'editor'}.md'; + if (buffer.filePath != path) await controller.openActiveFile(path); + await mode(editingMode); + await text('abc'); + await focusEnd(); + await key(['x']); + await text('abcx'); + await key(['y']); + await text('abcxy'); + // Native/JIT work can cross the real idle boundary on a loaded host. + // Traverse each actual transaction, then verify the known baseline. + final latestTypingBaseline = + buffer.editorState.undoState.undo.last.text; + while (buffer.editorState.undoState.undo.isNotEmpty) { + await undo(buffer.editorState.undoState.undo.last.text); + } + await text('abc'); + var revision = buffer.revision; + final exhausted = buffer.editorState.undoState; + for (var i = 0; i < 3; i++) { + await key(['Control_L', 'z']); + } + check( + buffer.revision == revision && + identical(buffer.editorState.undoState, exhausted) && + buffer.text == 'abc', + '$name exhausted Undo is unchanged', + ); + while (buffer.editorState.undoState.redo.isNotEmpty) { + await redo(buffer.editorState.undoState.redo.last.text); + } + await text('abcxy'); + revision = buffer.revision; + for (var i = 0; i < 3; i++) { + await key(['Control_L', 'Shift_L', 'z']); + } + check( + buffer.revision == revision && buffer.text == 'abcxy', + '$name exhausted Redo is unchanged', + ); + for (var i = 0; i < 5; i++) { + await undo(latestTypingBaseline); + await redo('abcxy'); + } + check(true, '$name native rapid alternation'); + + controller.updateActiveEditorMode(DocumentViewModePreference.preview); + await settings.setDocumentViewMode(DocumentViewModePreference.preview); + await until( + () => !elements().any( + (e) => + e.widget is BusyMarkSourceEditor || + e.widget is BusyMarkWysiwygEditor, + ), + 'reading mode', + ); + await mode(editingMode); + await focusEnd(); + await key(['z']); + await text('abcxyz'); + await undo('abcxy'); + await redo('abcxyz'); + check(true, '$name remount keeps the earlier session'); + final id = buffer.id; + await controller.openActiveFile('${root.path}/other.md'); + await controller.activateDocumentBuffer(id); + await text('abcxyz'); + await focusEnd(); + await key(['q']); + await text('abcxyzq'); + await undo('abcxyz'); + check(true, '$name tab return creates a separate transaction'); + + await settings.setAutoSave(true); + await focusEnd(); + await key(['k']); + await text('abcxyzk'); + await undo('abcxyz'); + await key(['v']); + await text('abcxyzv'); + await saved('abcxyzv'); + final history = container.read(localHistoryControllerProvider.notifier); + check( + await history.flushAll( + container.read(workspaceControllerProvider).documentBuffers, + ), + '$name history settled', + ); + final store = container.read(localHistoryStoreProvider); + var snapshot = await store.load(); + LocalHistoryRevision? protected; + for (final summary in snapshot.revisions) { + final candidate = await store.readRevision(summary.id); + if (candidate?.source == 'abcxyzk') protected = candidate; + } + check(protected != null, '$name original pre-Undo work persisted'); + check( + await controller.closeDocumentBuffer(id), + '$name closes saved branch', + ); + await controller.openActiveFile(path); + await mode(editingMode); + await text('abcxyzv'); + snapshot = await store.load(); + final historyDocument = snapshot.documents.firstWhere( + (d) => d.id == protected!.summary.documentId, + ); + check( + await controller.restoreLocalHistoryRevision( + document: historyDocument, + revision: protected!, + ), + '$name Local History restores original work', + ); + await text('abcxyzk'); + await focusEnd(); + await undo('abcxyzv'); + await redo('abcxyzk'); + check(true, '$name restore is one native undo/redo boundary'); + + await settings.setAutoSave(false); + await focusEnd(); + await key(['r']); + await text('abcxyzkr'); + await key(['Left']); + await key(['Right']); + await key(['s']); + await text('abcxyzkrs'); + await undo('abcxyzkr'); // Dirty, with both stacks populated. + await File(path).writeAsString('disk replacement'); + await until( + () => buffer.hasConflict, + 'monitor reports the external conflict', + ); + check( + await controller.reloadBufferFromDisk(buffer.id), + '$name reload succeeds', + ); + await text('disk replacement'); + revision = buffer.revision; + await key(['Control_L', 'Shift_L', 'z']); + check( + buffer.revision == revision && buffer.text == 'disk replacement', + '$name reload invalidates old redo', + ); + await focusEnd(); + await undo('abcxyzkr'); + await redo('disk replacement'); + check(true, '$name displaced source survives reload'); + await controller.saveActive(); + } + await controller.markCleanShutdown(); + } catch (error, stack) { + failure = error; + stderr.writeln('$error\n$stack'); + } + await File('${root.path}/result.json').writeAsString( + jsonEncode({ + 'passed': failure == null, + 'checks': checks, + 'failure': failure?.toString(), + }), + flush: true, + ); + exit(failure == null ? 0 : 1); + } +} + +const _nativeKeys = r''' +import ctypes as C, ctypes.util, os, sys, time +x=C.CDLL(ctypes.util.find_library('X11')); t=C.CDLL(ctypes.util.find_library('Xtst')) +x.XOpenDisplay.argtypes=[C.c_char_p]; x.XOpenDisplay.restype=C.c_void_p +x.XStringToKeysym.argtypes=[C.c_char_p]; x.XStringToKeysym.restype=C.c_ulong +x.XKeysymToKeycode.argtypes=[C.c_void_p,C.c_ulong]; x.XKeysymToKeycode.restype=C.c_uint +x.XSync.argtypes=[C.c_void_p,C.c_int]; x.XCloseDisplay.argtypes=[C.c_void_p] +t.XTestFakeKeyEvent.argtypes=[C.c_void_p,C.c_uint,C.c_int,C.c_ulong] +d=x.XOpenDisplay(os.environ['DISPLAY'].encode()) +if not d: raise RuntimeError('No disposable X11 display') +try: + codes=[x.XKeysymToKeycode(d,x.XStringToKeysym(k.encode())) for k in sys.argv[1:]] + for code,pressed in [(c,1) for c in codes]+[(c,0) for c in reversed(codes)]: + if not code or not t.XTestFakeKeyEvent(d,code,pressed,0): raise RuntimeError('Native key failed') + x.XSync(d,0) + time.sleep(0.015) + x.XSync(d,0) +finally: x.XCloseDisplay(d) +'''; From da106f0af71bf1fb9413a427d580413cb55db356 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 22 Oct 2026 08:03:40 -0700 Subject: [PATCH 2/3] Verify long typing groups through workspace Undo and Redo Use the production workspace controller with memory stores for both fifteen-minute editor regressions. Dispatch Ctrl+Z and Ctrl+Shift+Z and compare shared source, visible controllers, historical Source caret and live rich serialization. The original grouping still fails both unchanged group-bound assertions. --- docs/development/history.md | 4 ++ test/src/source_editor_widget_test.dart | 47 +++++++++------ test/src/wysiwyg_session_test.dart | 65 +++++++++++++------- test/support/document_history_harness.dart | 70 ++++++++++++++++++++++ 4 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 test/support/document_history_harness.dart diff --git a/docs/development/history.md b/docs/development/history.md index c3b3aa8e..1ec9e09c 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -118,6 +118,10 @@ sources in Local History and the latest branch in dirty-document recovery. The maintained suites exercise these state transitions through production editors/controllers and real keyboard or action dispatch: +The fifteen-minute fixtures inject the clock read by each editor, send keyboard +Undo/Redo through the production workspace controller, and compare the shared +source with the visible controller and live rich serialization. + | Trigger | Required result | | --- | --- | | Fifteen minutes of one-second typing, including Source line breaks | Multiple finite groups; one Undo removes only the latest group. The injected clock is actually read by grouping. | diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 2aee51b3..0373f0fe 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -57,6 +57,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; +import '../support/document_history_harness.dart'; + void main() { testWidgets( 'Source identity changes keep the focused input client registered', @@ -222,11 +224,11 @@ void main() { (tester) async { var now = Duration.zero; var clockReads = 0; - var buffer = DocumentBuffer.untitled( - id: 'long-typing', - name: 'typing.md', - text: 'abc', + final history = await DocumentHistoryHarness.create( + mode: DocumentViewModePreference.source, ); + addTearDown(history.dispose); + DocumentBuffer buffer() => history.buffer; Duration clock() { clockReads++; return now; @@ -239,7 +241,7 @@ void main() { home: Scaffold( body: BusyMarkSourceEditor( text: 'abc', - documentId: buffer.id, + documentId: buffer().id, filePath: null, language: SourceSyntaxLanguage.markdown, diagnostics: const [], @@ -248,13 +250,17 @@ void main() { searchActive: false, searchOptions: const SourceSearchOptions(), undoClock: clock, + readEditBase: history.readBase, + readSelection: () => buffer().editorState.selection, + onUndo: history.undoText, + onRedo: history.redoText, onChanged: (_, _) {}, onCloseSearch: () {}, onTransactionalChanged: (text, _, before, after, group) => - buffer = buffer.edited( + history.controller.updateActiveSourceText( text, previousSelection: before, - nextSelection: after, + selection: after, undoGroup: group, ), ), @@ -282,7 +288,7 @@ void main() { selection: TextSelection.collapsed(offset: text.length), ), ); - final group = buffer.editorState.undoState.activeGroup; + final group = buffer().editorState.undoState.activeGroup; if (group != null) sizes.update(group, (n) => n + 1, ifAbsent: () => 1); if (i % 50 == 0) await tester.pump(); } @@ -294,19 +300,22 @@ void main() { ); expect(sizes.length, greaterThanOrEqualTo(90)); expect(sizes.values, everyElement(lessThanOrEqualTo(10))); - final previous = buffer.editorState.undoState.undo.last; + final previous = buffer().editorState.undoState.undo.last; expect(previous.text.length, greaterThan(880)); - expect(buffer.text.length - previous.text.length, lessThanOrEqualTo(10)); - // Exactly one document undo restores this bounded group's baseline. - buffer = buffer.copyWith( - text: previous.text, - editorState: buffer.editorState.copyWith( - undoState: buffer.editorState.undoState.afterUndo( - buffer.historyState, - ), - ), + expect( + buffer().text.length - previous.text.length, + lessThanOrEqualTo(10), ); - expect(buffer.text, previous.text); + await _pressControlKey(tester, LogicalKeyboardKey.keyZ); + expect(buffer().text, previous.text); + expect(tester.widget(field).controller!.text, previous.text); + expect( + tester.widget(field).controller!.selection, + previous.selection, + ); + await _pressControlKey(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(buffer().text, text); + expect(tester.widget(field).controller!.text, text); await tester.pumpWidget(const SizedBox.shrink()); }, ); diff --git a/test/src/wysiwyg_session_test.dart b/test/src/wysiwyg_session_test.dart index eca407dd..cd7f6885 100644 --- a/test/src/wysiwyg_session_test.dart +++ b/test/src/wysiwyg_session_test.dart @@ -14,6 +14,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../support/document_history_harness.dart'; + final _sessionReportProvider = NotifierProvider<_SessionReportNotifier, int>( _SessionReportNotifier.new, ); @@ -134,11 +136,13 @@ void main() { ) async { var now = Duration.zero; var clockReads = 0; - var buffer = DocumentBuffer.untitled( - id: 'long-typing', - name: 'typing.md', - text: 'abc', - ); + final history = await DocumentHistoryHarness.create(); + addTearDown(history.dispose); + DocumentBuffer buffer() => history.buffer; + var document = const MarkdownParser() + .parse(filePath: 'typing.md', source: 'abc') + .busyDocument; + final key = GlobalKey(); Duration clock() { clockReads++; return now; @@ -147,15 +151,28 @@ void main() { await tester.pumpWidget( _app( BusyMarkWysiwygEditor( - document: const MarkdownParser() - .parse(filePath: 'typing.md', source: 'abc') - .busyDocument, - documentId: buffer.id, + key: key, + document: document, + documentId: buffer().id, undoClock: clock, useExternalUndoHistory: true, + readEditBase: history.readBase, + readSessionState: () => buffer().editorState.wysiwygState, + onUndo: history.controller.undoActiveBuffer, + onRedo: history.controller.redoActiveBuffer, + onSessionChanged: (id, session) => + history.controller.updateDocumentEditorState( + id, + buffer().editorState.copyWith(wysiwygState: session), + ), + onDocumentChanged: (value) => document = value, onSourceChanged: (_, _) {}, onTransactionalSourceChanged: (_, text, group) => - buffer = buffer.edited(text, undoGroup: group), + history.controller.updateActiveWysiwygText( + text, + document: document, + undoGroup: group, + ), ), ), ); @@ -180,7 +197,7 @@ void main() { selection: TextSelection.collapsed(offset: text.length), ), ); - final group = buffer.editorState.undoState.activeGroup; + final group = buffer().editorState.undoState.activeGroup; if (group != null) sizes.update(group, (n) => n + 1, ifAbsent: () => 1); if (i % 50 == 0) await tester.pump(); } @@ -192,17 +209,23 @@ void main() { ); expect(sizes.length, greaterThanOrEqualTo(90)); expect(sizes.values, everyElement(lessThanOrEqualTo(10))); - final previous = buffer.editorState.undoState.undo.last; + final previous = buffer().editorState.undoState.undo.last; expect(previous.text.length, greaterThan(880)); - expect(buffer.text.length - previous.text.length, lessThanOrEqualTo(10)); - // Exactly one document undo restores this bounded group's baseline. - buffer = buffer.copyWith( - text: previous.text, - editorState: buffer.editorState.copyWith( - undoState: buffer.editorState.undoState.afterUndo(buffer.historyState), - ), - ); - expect(buffer.text, previous.text); + expect(buffer().text.length - previous.text.length, lessThanOrEqualTo(10)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(buffer().text, previous.text); + expect(tester.widget(field).controller!.text, previous.text); + expect(key.currentState!.serializedSource, previous.text); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(buffer().text, text); + expect(tester.widget(field).controller!.text, text); + expect(key.currentState!.serializedSource, text); await tester.pumpWidget(const SizedBox.shrink()); }); diff --git a/test/support/document_history_harness.dart b/test/support/document_history_harness.dart new file mode 100644 index 00000000..ded49ac0 --- /dev/null +++ b/test/support/document_history_harness.dart @@ -0,0 +1,70 @@ +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/editor/document_edit_history.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/recovery_persistence.dart'; +import 'package:busymark/src/workspace/session_persistence.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Real workspace history and edit producers with disposable memory stores. +class DocumentHistoryHarness { + DocumentHistoryHarness._(this.container, this.controller); + + static Future create({ + DocumentViewModePreference mode = DocumentViewModePreference.editor, + }) async { + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_HistorySettings()), + localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + ], + ); + final settings = container.read(appSettingsControllerProvider.notifier); + await settings.waitUntilLoaded(); + await settings.setAutoSave(false); + await settings.setValidateOnEdit(false); + final controller = container.read(workspaceControllerProvider.notifier); + await controller.createMarkdownFile(); + controller.updateActiveEditorMode(mode); + controller.updateActiveText('abc'); + return DocumentHistoryHarness._(container, controller); + } + + final ProviderContainer container; + final WorkspaceController controller; + DocumentBuffer get buffer => + container.read(workspaceControllerProvider).activeBuffer!; + + DocumentEditBase readBase() => DocumentEditBase( + buffer.text, + buffer.revision, + buffer.editorState.undoState, + ); + + TextEditingValue? undoText() => + controller.undoActiveBuffer() ? _editingValue : null; + TextEditingValue? redoText() => + controller.redoActiveBuffer() ? _editingValue : null; + TextEditingValue get _editingValue => TextEditingValue( + text: buffer.text, + selection: buffer.editorState.selection, + ); + + void dispose() => container.dispose(); +} + +class _HistorySettings implements LocalSettingsStore { + @override + Future> load() async => AppSettings.defaults().toJson(); + @override + Future save(Map values) async {} +} From 4ab40d0c994097eacc72993b2f6ba616d7906a38 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 22 Oct 2026 17:59:08 -0700 Subject: [PATCH 3/3] Keep traversal recovery valid after history retention --- docs/development/history.md | 33 +- .../local_history_controller.dart | 96 +++-- .../local_history/local_history_models.dart | 30 ++ .../local_history/local_history_store.dart | 170 ++++++-- test/src/local_history_store_test.dart | 266 ++++++++++++ test/src/local_history_workspace_test.dart | 391 ++++++++++++++++++ 6 files changed, 914 insertions(+), 72 deletions(-) diff --git a/docs/development/history.md b/docs/development/history.md index 1ec9e09c..fd9d62e1 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -87,12 +87,30 @@ edit, or another protective operation cannot replace it. The ordinary pending checkpoint still follows the latest source on its fixed `putIfAbsent` timer. The checkpoint window is not a debounce. -A bounded set deduplicates repeated source/format protections per document -identity and recording epoch. That set contains metadata only: unresolved full -sources remain independently owned even if a deduplication key ages out. -Under its existing capture lock, the store also lets traversal protection reuse -any exact source/format revision in the same lineage. Ordinary checkpoints keep -their adjacent-only deduplication rule and timeline semantics. +Deduplication shares an unresolved owner only while it still holds the exact +source, format, document identity and recording epoch. Completed captures leave +no fingerprint that could discharge a later obligation: retention may have +removed their revision. A traversal joining a retained owner advances that +owner's required revision and assigns a fresh capture identity. An older +completion or failure cannot release or overwrite the new obligation; a +retention tombstone for its earlier attempt cannot cancel the fresh traversal. +Distinct unresolved sources remain independently owned. + +Under its existing serialization and file lock, the store verifies that a reused +revision still exists and contains the exact source and format. Traversal reuse +renews its optional `protectedAt` timestamp in the record and index before +releasing the retained owner. Age and storage-budget pruning use the later of +`capturedAt` and `protectedAt`; the original capture time, reason, identity and +timeline position stay unchanged. This applies even when retrying a durable +capture identity. Repeated traversal renews one record rather than appending +duplicate full-source revisions. Ordinary checkpoints keep their adjacent-only +deduplication rule and timeline semantics. + +Renewal publishes the complete record before the index so index reconstruction +preserves the protection. Failed publication leaves independent retained work +available for retry or restart; cancellation restores the prior record and +index. Renewed records remain subject to the configured age and size limits, +and expire normally after their last protection. No retention setting changes. If committed revisions share a timestamp, adjacency chooses the later appended revision; the clock tie must not select an older baseline and duplicate a checkpoint that protection already stored. @@ -136,6 +154,9 @@ source with the visible controller and live rich serialization. | Reload, monitor, workspace or remote replacement with both stacks populated | Replacement is undoable, the inappropriate redo branch is gone, displaced content survives. | | Undo before checkpoint, then a new branch, with autosave on/off | The original pre-Undo work survives close/reopen. | | Delayed/failed captures and multiple protections | Each obligation survives independently; a fresh disk-backed restart can finish recovery. | +| Successful protection, normal budget eviction, then another traversal and branch | The second protection preserves the source through close and a fresh disk-backed restart. | +| Reuse of expired or older budget-priority content, in file and memory stores | Fresh protection survives branch capture and pruning; its original timeline event stays unchanged and it still expires normally. | +| Repeated traversal, delayed completion, failed renewal or index reconstruction | One record per exact protected source; no old completion discharges a newer obligation, and retry/restart preserves the source. | | Same-content save/reload, composition and pending inspector draft | Existing editing, redo and ownership contracts remain intact. | The Source remount regression predates these repairs. WYSIWYG's exhausted diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 91ced885..c05f4983 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -350,9 +350,6 @@ class LocalHistoryController extends Notifier { final _documentIdsByBuffer = {}; final _clearingDocumentIdsByBuffer = {}; final _pending = {}; - // Only deduplication metadata. The actual obligations live in independently - // owned retained protections, including after this bounded set rolls over. - final _traversalCaptures = <(String, String, String, String, int, int)>{}; // A baseline cannot coalesce with edited text: both snapshots are needed. final _baselines = {}; final _baselinesRequiringVacantPath = {}; @@ -419,17 +416,10 @@ class LocalHistoryController extends Notifier { _store = ref.read(localHistoryStoreProvider); _clock = ref.read(localHistoryClockProvider); _timerFactory = ref.read(localHistoryTimerFactoryProvider); - ref.listen(appSettingsControllerProvider, (previous, next) { - if (previous?.localHistoryRecordingEnabled != - next.localHistoryRecordingEnabled || - !listEquals( - previous?.localHistoryExcludedPaths, - next.localHistoryExcludedPaths, - )) { - _traversalCaptures.clear(); - } - unawaited(_applyRecordingPolicy(next)); - }); + ref.listen( + appSettingsControllerProvider, + (previous, next) => unawaited(_applyRecordingPolicy(next)), + ); ref.onDispose(() { _liveOperationOwners.remove(_operationOwnerToken); _historyGeneration++; @@ -722,24 +712,51 @@ class LocalHistoryController extends Notifier { policy.excludes(previous.filePath)) { return; } - final fingerprint = ( - previous.id, - previous.identity, - sourceChecksum(previous.text), - jsonEncode(previous.format.toJson()), - _historyGeneration, - _bufferGeneration(previous.id), + final previousSnapshot = _acceptAtCurrentClearEpoch( + LocalHistoryBufferSnapshot.fromBuffer(previous), ); - if (_traversalCaptures.add(fingerprint)) { - if (_traversalCaptures.length > DocumentUndoState.historyLimit * 2) { - _traversalCaptures.remove(_traversalCaptures.first); + // Only an unresolved owner still holding this source can discharge the + // obligation here. Completed captures must be revalidated by the store: + // normal retention may already have evicted their durable revision. + final existingOwner = _protections.entries.where((entry) { + final retained = entry.value.snapshot; + return entry.value.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal && + _detachedClosedSourceId(entry.key) == previous.id && + retained.acceptedClearEpoch == previousSnapshot.acceptedClearEpoch && + _sameOptionalPath(retained.path, previousSnapshot.path) && + retained.remoteNote == previousSnapshot.remoteNote && + _sameHistoryFormat(retained.format, previousSnapshot.format) && + retained.text == previousSnapshot.text; + }).firstOrNull; + if (existingOwner != null) { + // Keep one source owner, but advance its accepted obligation. An older + // in-flight completion must not release protection for this traversal. + final owner = existingOwner.key; + final protection = existingOwner.value; + // Its previous capture identity may already be retention-tombstoned. + // A fresh obligation must revalidate content without replaying that ID. + final snapshot = previousSnapshot + .forBuffer(owner) + .withCaptureId(_newPathOperationId()); + _protections[owner] = ( + snapshot: snapshot, + reason: protection.reason, + force: protection.force, + ignoreBinding: protection.ignoreBinding, + allowPathChange: protection.allowPathChange, + bindResult: protection.bindResult, + requireVacantPath: protection.requireVacantPath, + expectedTarget: protection.expectedTarget, + captureId: snapshot.captureId, + ); + if (_inFlightSnapshots.containsKey(owner)) { + unawaited(_enqueue(owner, () => _settleBufferWork(owner))); } + } else { final owner = _newDetachedOwner( 'history-capture:traversal:${previous.id}', ); - final previousSnapshot = _acceptAtCurrentClearEpoch( - LocalHistoryBufferSnapshot.fromBuffer(previous), - ); final snapshot = previousSnapshot .forBuffer(owner) .withCaptureId(_newPathOperationId()); @@ -5088,16 +5105,21 @@ class LocalHistoryController extends Notifier { if (stage == _LocalHistoryFailureStage.baseline) { _baselines.putIfAbsent(snapshot.bufferId, () => snapshot); } else if (stage == _LocalHistoryFailureStage.protection) { - _protections[snapshot.bufferId] = ( - snapshot: snapshot, - reason: reason, - force: force, - ignoreBinding: ignoreBinding, - allowPathChange: allowPathChange, - bindResult: bindResult, - requireVacantPath: requireVacantPath, - expectedTarget: expectedTarget, - captureId: effectiveCaptureId, + // An older in-flight failure cannot replace accepted protection + // for a later traversal, including its fresh capture identity. + _protections.putIfAbsent( + snapshot.bufferId, + () => ( + snapshot: snapshot, + reason: reason, + force: force, + ignoreBinding: ignoreBinding, + allowPathChange: allowPathChange, + bindResult: bindResult, + requireVacantPath: requireVacantPath, + expectedTarget: expectedTarget, + captureId: effectiveCaptureId, + ), ); } _recordFailure( diff --git a/lib/src/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart index 563e05ed..58047567 100644 --- a/lib/src/local_history/local_history_models.dart +++ b/lib/src/local_history/local_history_models.dart @@ -149,6 +149,7 @@ class LocalHistoryRevisionSummary { required this.storageBytes, required this.sourceLength, this.historicalPath, + this.protectedAt, }); final String id; @@ -160,6 +161,30 @@ class LocalHistoryRevisionSummary { final int sourceLength; final String? historicalPath; + /// A later traversal can protect an existing record without moving its + /// original timeline event or creating another full-source revision. + final DateTime? protectedAt; + + DateTime get retentionTime => + protectedAt != null && protectedAt!.isAfter(capturedAt) + ? protectedAt! + : capturedAt; + + LocalHistoryRevisionSummary copyWith({ + DateTime? protectedAt, + int? storageBytes, + }) => LocalHistoryRevisionSummary( + id: id, + documentId: documentId, + capturedAt: capturedAt, + reason: reason, + checksum: checksum, + storageBytes: storageBytes ?? this.storageBytes, + sourceLength: sourceLength, + historicalPath: historicalPath, + protectedAt: protectedAt ?? this.protectedAt, + ); + Map toJson() => { 'id': id, 'documentId': documentId, @@ -169,6 +194,8 @@ class LocalHistoryRevisionSummary { 'storageBytes': storageBytes, 'sourceLength': sourceLength, 'historicalPath': historicalPath, + if (protectedAt != null) + 'protectedAt': protectedAt!.toUtc().toIso8601String(), }; factory LocalHistoryRevisionSummary.fromJson(Map json) { @@ -183,6 +210,9 @@ class LocalHistoryRevisionSummary { storageBytes: (json['storageBytes'] as num?)?.toInt() ?? 0, sourceLength: (json['sourceLength'] as num?)?.toInt() ?? 0, historicalPath: json['historicalPath']?.toString(), + protectedAt: json['protectedAt'] == null + ? null + : DateTime.parse(json['protectedAt'].toString()).toUtc(), ); } } diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index 498abddd..ea0e34cb 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -232,6 +232,17 @@ class FileLocalHistoryStore implements LocalHistoryStore { throw const LocalHistoryReconciliationConflict(); } _requireCaptureStillAccepted(request); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + root, + index, + document, + existing, + request, + policy, + ); + } return LocalHistoryCaptureResult( document: document, revision: existing, @@ -279,8 +290,9 @@ class FileLocalHistoryStore implements LocalHistoryStore { ? revision : latest, ); - // A safety protection can reuse any exact durable revision in this - // lineage. Ordinary captures retain their adjacent-only timeline rule. + // Reuse validates the complete record while holding the store lock. + // Traversal also renews retention eligibility before releasing its + // retained owner; original timeline timestamps remain unchanged. final candidates = request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal ? index.revisions.where( @@ -297,6 +309,17 @@ class FileLocalHistoryStore implements LocalHistoryStore { existing.source == request.source && _sameTextFormat(existing.format, request.format)) { _requireCaptureStillAccepted(request); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + root, + index, + document, + candidate, + request, + policy, + ); + } index = index.withDocument(document); await _publishIndex(root, index); if (!_captureStillAccepted(request)) { @@ -344,16 +367,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { await _deleteFileBestEffort(target); throw const LocalHistoryCaptureCancelled(); } - summary = LocalHistoryRevisionSummary( - id: summary.id, - documentId: summary.documentId, - capturedAt: summary.capturedAt, - reason: summary.reason, - checksum: summary.checksum, - storageBytes: await target.length(), - sourceLength: summary.sourceLength, - historicalPath: summary.historicalPath, - ); + summary = summary.copyWith(storageBytes: await target.length()); index = index.withCapture(document, summary); // A complete revision exists before the index points to it. await _publishIndex(root, index); @@ -379,6 +393,66 @@ class FileLocalHistoryStore implements LocalHistoryStore { }); } + Future _reuseTraversalProtection( + Directory root, + _Index index, + LocalHistoryDocument document, + LocalHistoryRevisionSummary previous, + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + var renewed = _renewedTraversalProtection(previous, request.capturedAt); + final target = _revisionFile(root, document.id, previous.id); + final previousRecord = identical(renewed, previous) + ? null + : await target.readAsString(); + _requireCaptureStillAccepted(request); + if (previousRecord != null) { + // Publish the complete record first so an index rebuild retains the + // protection. A failed index publication leaves the caller owning its + // independent source and retry work, just like a new revision write. + await _publishReplacingFile( + target, + _revisionJson(document, renewed, request), + ); + renewed = renewed.copyWith(storageBytes: await target.length()); + } + if (!_captureStillAccepted(request)) { + if (previousRecord != null) { + await _publishReplacingFile(target, previousRecord); + } + throw const LocalHistoryCaptureCancelled(); + } + final updated = index + .withDocument(document) + .copyWith( + revisions: [ + for (final revision in index.revisions) + if (revision.id == previous.id) renewed else revision, + ], + ); + await _publishIndex(root, updated); + if (!_captureStillAccepted(request)) { + if (previousRecord != null) { + await _publishReplacingFile(target, previousRecord); + } + await _publishIndex(root, index); + throw const LocalHistoryCaptureCancelled(); + } + await _applyRetention( + root, + updated, + policy, + renewed.id, + request.capturedAt.toUtc(), + ); + return LocalHistoryCaptureResult( + document: document, + revision: renewed, + deduplicated: true, + ); + } + @override Future readRevision(String revisionId) => _serialized( (root) => _withFileLock(root, () async { @@ -1000,18 +1074,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { continue; } documents[document.id] = document; - revisions.add( - LocalHistoryRevisionSummary( - id: summary.id, - documentId: summary.documentId, - capturedAt: summary.capturedAt, - reason: summary.reason, - checksum: summary.checksum, - storageBytes: await entity.length(), - sourceLength: summary.sourceLength, - historicalPath: summary.historicalPath, - ), - ); + revisions.add(summary.copyWith(storageBytes: await entity.length())); } on Object { // A malformed record cannot invalidate independently intact records. } @@ -1090,8 +1153,9 @@ class FileLocalHistoryStore implements LocalHistoryStore { DateTime now, ) async { final cutoff = now.subtract(policy.retentionAge); - final ordered = [...index.revisions] - ..sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); + final ordered = [ + ...index.revisions, + ]..sort((left, right) => left.retentionTime.compareTo(right.retentionTime)); var total = ordered.fold( 0, (value, revision) => value + revision.storageBytes, @@ -1099,7 +1163,8 @@ class FileLocalHistoryStore implements LocalHistoryStore { final removed = {}; for (final revision in ordered) { if (revision.id == protectedRevisionId) continue; - if (revision.capturedAt.isBefore(cutoff) || total > policy.maximumBytes) { + if (revision.retentionTime.isBefore(cutoff) || + total > policy.maximumBytes) { removed.add(revision.id); total -= revision.storageBytes; } @@ -1400,6 +1465,13 @@ bool _sameTextFormat(TextFormatMetadata left, TextFormatMetadata right) => left.crlfCount == right.crlfCount && left.crCount == right.crCount; +LocalHistoryRevisionSummary _renewedTraversalProtection( + LocalHistoryRevisionSummary previous, + DateTime time, +) => time.toUtc().isAfter(previous.retentionTime) + ? previous.copyWith(protectedAt: time.toUtc()) + : previous; + LocalHistoryDocument _updatedDocument( LocalHistoryDocument document, LocalHistoryCaptureRequest request, { @@ -1746,6 +1818,15 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { throw const LocalHistoryReconciliationConflict(); } _requireCaptureStillAccepted(request); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + document, + existing.summary, + request, + policy, + ); + } return LocalHistoryCaptureResult( document: document, revision: existing.summary, @@ -1811,6 +1892,15 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { if (existing != null && existing.source == request.source && _sameTextFormat(existing.format, request.format)) { + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + document, + candidate, + request, + policy, + ); + } return LocalHistoryCaptureResult( document: document, revision: candidate, @@ -1845,6 +1935,27 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { return LocalHistoryCaptureResult(document: document, revision: summary); } + LocalHistoryCaptureResult _reuseTraversalProtection( + LocalHistoryDocument document, + LocalHistoryRevisionSummary previous, + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + final renewed = _renewedTraversalProtection(previous, request.capturedAt); + _requireCaptureStillAccepted(request); + _revisions[previous.id] = LocalHistoryRevision( + summary: renewed, + source: request.source, + format: request.format, + ); + _applyMemoryRetention(policy, request.capturedAt.toUtc(), renewed.id); + return LocalHistoryCaptureResult( + document: document, + revision: renewed, + deduplicated: true, + ); + } + void _applyMemoryRetention( LocalHistoryPolicy policy, DateTime now, @@ -1853,7 +1964,7 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { final ordered = _revisions.values.toList() ..sort( (left, right) => - left.summary.capturedAt.compareTo(right.summary.capturedAt), + left.summary.retentionTime.compareTo(right.summary.retentionTime), ); var total = ordered.fold( 0, @@ -1863,7 +1974,8 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { for (final revision in ordered) { final summary = revision.summary; if (summary.id == protectedId) continue; - if (summary.capturedAt.isBefore(cutoff) || total > policy.maximumBytes) { + if (summary.retentionTime.isBefore(cutoff) || + total > policy.maximumBytes) { _revisions.remove(summary.id); total -= summary.storageBytes; } diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index ff72170d..d411552f 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -44,6 +44,7 @@ void main() { LocalHistoryCaptureReason reason = LocalHistoryCaptureReason.saved, bool force = false, bool Function()? commitGuard, + String? captureId, }) => LocalHistoryCaptureRequest( path: path, displayName: p.basename(path), @@ -53,6 +54,7 @@ void main() { reason: reason, force: force, commitGuard: commitGuard, + captureId: captureId, ); const policy = LocalHistoryPolicy( @@ -61,6 +63,123 @@ void main() { ); for (final memory in [false, true]) { + test( + 'retry of a traversal capture identity renews expired protection (memory=$memory)', + () async { + final LocalHistoryStore tested = memory + ? MemoryLocalHistoryStore() + : store; + var now = DateTime.utc(2026); + final original = await tested.capture( + request('recover X', now), + policy, + ); + now = now.add(const Duration(days: 31)); + final result = await tested.capture( + request( + 'recover X', + now, + captureId: original.revision!.id, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + expect(result.revision!.id, original.revision!.id); + await tested.capture( + request('branch', now.add(const Duration(seconds: 1))), + policy, + ); + await tested.prune(policy, now.add(const Duration(seconds: 2))); + expect(await tested.readRevision(result.revision!.id), isNotNull); + await tested.prune(policy, now.add(const Duration(days: 31))); + expect( + await tested.readRevision(result.revision!.id), + isNull, + reason: 'Renewed protection still expires under the configured age', + ); + }, + ); + + test( + 'fresh traversal protection survives budget pruning (memory=$memory)', + () async { + final LocalHistoryStore tested = memory + ? MemoryLocalHistoryStore() + : store; + var now = DateTime.utc(2026, 1, 1); + final source = 'X' * (6 * 1024 * 1024); + final first = await tested.capture(request(source, now), policy); + now = now.add(const Duration(days: 1)); + await tested.capture(request('Y' * (6 * 1024 * 1024), now), policy); + now = now.add(const Duration(days: 1)); + final protection = await tested.capture( + request( + source, + now, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + now = now.add(const Duration(seconds: 1)); + await tested.capture(request('Z' * (6 * 1024 * 1024), now), policy); + await tested.prune(policy, now); + final protected = await tested.readRevision(protection.revision!.id); + expect( + protected, + isNotNull, + reason: 'Fresh protection must participate as fresh retained work', + ); + expect(protected!.source == source, isTrue); + expect( + protected.summary.capturedAt, + first.revision!.capturedAt, + reason: 'Reuse preserves the original timeline event', + ); + }, + ); + + test( + 'fresh traversal protection survives expiry and pruning (memory=$memory)', + () async { + final LocalHistoryStore tested = memory + ? MemoryLocalHistoryStore() + : store; + var now = DateTime.utc(2026, 1, 1); + final original = await tested.capture( + request('recover X', now), + policy, + ); + now = now.add(const Duration(days: 1)); + await tested.capture(request('different source', now), policy); + now = now.add(const Duration(days: 30)); + // The expired record is still available when reuse is considered. + expect(await tested.readRevision(original.revision!.id), isNotNull); + final protection = await tested.capture( + request( + 'recover X', + now, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + now = now.add(const Duration(seconds: 1)); + await tested.capture(request('new branch', now), policy); + await tested.prune(policy, now); + final LocalHistoryStore reopened = memory + ? tested + : FileLocalHistoryStore(rootDirectory: () async => root); + final sources = await Future.wait( + (await reopened.load()).revisions.map( + (r) async => (await reopened.readRevision(r.id))!.source, + ), + ); + expect(sources, containsAll(['recover X', 'new branch'])); + expect(await reopened.readRevision(protection.revision!.id), isNotNull); + await reopened.prune(policy, now.add(const Duration(days: 31))); + expect(await reopened.readRevision(protection.revision!.id), isNull); + }, + ); + test( 'equal-time ordinary captures use the latest revision (memory=$memory)', () async { @@ -118,6 +237,153 @@ void main() { ); } + test( + 'renewed protection survives index reconstruction without moving its timeline event', + () async { + final originalTime = DateTime.utc(2026); + final first = await store.capture( + request('recover X', originalTime), + policy, + ); + await store.capture( + request('other', originalTime.add(const Duration(days: 1))), + policy, + ); + final now = originalTime.add(const Duration(days: 31)); + final protection = await store.capture( + request( + 'recover X', + now, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + await File(p.join(root.path, 'index.json')).delete(); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + await reopened.load(); + await reopened.prune(policy, now.add(const Duration(seconds: 1))); + final restored = await reopened.readRevision(protection.revision!.id); + expect(restored, isNotNull); + expect(restored!.source, 'recover X'); + expect(restored.summary.capturedAt, first.revision!.capturedAt); + expect(restored.summary.protectedAt, now); + await reopened.prune(policy, now.add(const Duration(days: 31))); + expect(await reopened.readRevision(protection.revision!.id), isNull); + }, + ); + + test('failed index publication leaves a renewed record retryable', () async { + File? record; + var failAfterRecord = false; + final tested = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () { + if (failAfterRecord && + record != null && + (jsonDecode(record.readAsStringSync()) + as Map)['revision']['protectedAt'] != + null) { + failAfterRecord = false; + throw const FileSystemException('Injected index publication failure'); + } + return 'transaction_${(++ids).toString().padLeft(12, '0')}'; + }, + ); + final original = await tested.capture( + request('recover X', DateTime.utc(2026)), + policy, + ); + record = File( + p.join( + root.path, + 'revisions', + original.document.id, + '${original.revision!.id}.json', + ), + ); + final now = DateTime.utc(2026).add(const Duration(days: 31)); + final protection = request( + 'recover X', + now, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + failAfterRecord = true; + await expectLater( + tested.capture(protection, policy), + throwsA(isA()), + ); + expect(failAfterRecord, isFalse); + expect( + (jsonDecode(record.readAsStringSync()) as Map)['revision']['protectedAt'], + now.toIso8601String(), + ); + expect( + (await tested.load()).revisions.single.protectedAt, + isNull, + reason: 'A record write alone did not publish the renewed index', + ); + final retried = await tested.capture(protection, policy); + expect(retried.deduplicated, isTrue); + expect(retried.revision!.id, original.revision!.id); + await tested.capture( + request('new branch', now.add(const Duration(seconds: 1))), + policy, + ); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + await reopened.prune(policy, now.add(const Duration(seconds: 2))); + expect( + (await reopened.readRevision(retried.revision!.id))!.source, + 'recover X', + ); + }); + + for (final afterIndex in [false, true]) { + test( + 'cancelled protection renewal restores published metadata (afterIndex=$afterIndex)', + () async { + final original = await store.capture( + request('recover X', DateTime.utc(2026)), + policy, + ); + final record = File( + p.join( + root.path, + 'revisions', + original.document.id, + '${original.revision!.id}.json', + ), + ); + final index = File(p.join(root.path, 'index.json')); + final originalRecord = await record.readAsString(); + final originalIndex = await index.readAsString(); + bool accepted() { + final data = + jsonDecode((afterIndex ? index : record).readAsStringSync()) + as Map; + final Map summary = afterIndex + ? (data['revisions'] as List).single as Map + : data['revision'] as Map; + return summary['protectedAt'] == null; + } + + await expectLater( + store.capture( + request( + 'recover X', + DateTime.utc(2026, 2, 1), + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + commitGuard: accepted, + ), + policy, + ), + throwsA(isA()), + ); + expect(await record.readAsString(), originalRecord); + expect(await index.readAsString(), originalIndex); + }, + ); + } + final sentinelTime = DateTime.utc(2000); LocalHistoryCaptureRequest journalRequest(String text, int minute) => request(text, DateTime.utc(2026, 10, 17).add(Duration(minutes: minute))); diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 5fc4206c..f33b1c2c 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -36,6 +36,352 @@ void main() { if (await root.exists()) await root.delete(recursive: true); }); + for (final delayedFailure in [false, true]) { + test( + 'retention eviction does not discharge a later traversal protection (delayedFailure=$delayedFailure)', + () async { + const source = 'recoverable pre-traversal X'; + final path = p.join(root.path, 'retained.md'); + await File(path).writeAsString('baseline'); + final historyRoot = Directory(p.join(root.path, 'history')); + final disk = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final store = _ObservedTraversalStore(disk); + addTearDown(() { + if (!store.release.isCompleted) store.release.complete(); + }); + var now = DateTime.utc(2026, 1, 1); + DateTime clock() => now = now.add(const Duration(seconds: 1)); + final sessionPath = p.join(root.path, 'session.json'); + final recoveryPath = p.join(root.path, 'recovery.json'); + final h = await _harness( + store, + clock: clock, + sessionStore: JsonDocumentSessionStore(filePathOverride: sessionPath), + recoveryStore: JsonDocumentRecoveryStore( + filePathOverride: recoveryPath, + ), + ); + await h.container + .read(appSettingsControllerProvider.notifier) + .setLocalHistoryMaximumStorageMiB(16); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + h.controller.updateActiveText(source); + expect(h.controller.undoActiveBuffer(), isTrue); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect(history.retainedDetachedCaptures, isEmpty); + final protected = (await disk.load()).revisions.singleWhere( + (r) => r.checksum == sourceChecksum(source), + ); + expect( + protected.reason, + LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + expect(store.requests.where((r) => r.source == source), hasLength(1)); + + // Ordinary captures exceed the valid 16 MiB budget. Retention deletes X + // while another revision keeps this document's history identity alive. + for (final unit in ['Y', 'Z']) { + await disk.capture( + LocalHistoryCaptureRequest( + documentId: protected.documentId, + path: path, + displayName: 'retained.md', + source: unit * (8 * 1024 * 1024), + format: TextFormatMetadata.utf8Lf, + capturedAt: clock(), + reason: LocalHistoryCaptureReason.saved, + ), + history.policy, + ); + } + expect(await disk.readRevision(protected.id), isNull); + expect((await disk.load()).documents.single.id, protected.documentId); + expect( + h.state.activeBuffer!.editorState.undoState.redo.last.text, + source, + ); + final afterEviction = store.requests.length; + if (delayedFailure) store.blockedSource = source; + + // No await or checkpoint between returning to X and retiring it again. + for (var i = 0; i < (delayedFailure ? 12 : 1); i++) { + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.state.activeText, source); + expect(h.controller.undoActiveBuffer(), isTrue); + } + h.controller.updateActiveText('new branch'); + expect(h.state.activeBuffer!.editorState.undoState.redo, isEmpty); + expect( + history.retainedDetachedCaptures.where( + (c) => c.protection?.snapshot.source == source, + ), + hasLength(1), + ); + if (delayedFailure) { + await store.started.future; + await h.controller.flushPersistence(); + final journal = await JsonDocumentSessionStore( + filePathOverride: sessionPath, + ).load(); + expect( + journal!.retainedLocalHistoryCaptures.where( + (c) => c.protection?.snapshot.source == source, + ), + hasLength(1), + ); + expect( + store.requests.skip(afterEviction).where((r) => r.source == source), + hasLength(1), + ); + store.release.complete(); + } + expect( + await history.flushAll(h.state.documentBuffers), + !delayedFailure, + ); + final freshX = store.requests + .skip(afterEviction) + .where((r) => r.source == source) + .toList(); + expect( + freshX.every( + (r) => r.reason == LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + isTrue, + ); + if (!delayedFailure) { + expect(await h.controller.saveActive(), isTrue); + expect( + await h.controller.closeDocumentBuffer(h.state.activeBuffer!.id), + isTrue, + ); + } else { + expect( + history.retainedDetachedCaptures.where( + (c) => c.protection?.snapshot.source == source, + ), + hasLength(1), + ); + } + await h.controller.flushPersistence(); + h.container.dispose(); + + final reopened = await _harness( + FileLocalHistoryStore(rootDirectory: () async => historyRoot), + clock: clock, + sessionStore: JsonDocumentSessionStore(filePathOverride: sessionPath), + recoveryStore: JsonDocumentRecoveryStore( + filePathOverride: recoveryPath, + ), + ); + await reopened.container + .read(appSettingsControllerProvider.notifier) + .setLocalHistoryMaximumStorageMiB(16); + if (delayedFailure) { + expect(await reopened.controller.restorePreviousSession(), isTrue); + final resumed = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + expect( + await resumed.flushAll(reopened.state.documentBuffers), + isTrue, + ); + } else { + await reopened.controller.openPath(path); + } + expect(reopened.state.activeText, 'new branch'); + final freshDisk = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final sources = await _revisionSources( + freshDisk, + (await freshDisk.load()).revisions, + ); + expect( + sources.contains(source), + isTrue, + reason: + 'X must remain recoverable after retention, branching and restart', + ); + if (!delayedFailure) { + expect( + freshX, + hasLength(1), + reason: 'Only a protective capture may preserve X', + ); + } + await reopened.controller.flushPersistence(); + }, + ); + } + + for (final memory in [false, true]) { + for (final scenario in [ + (eviction: false, failedCompletion: false, settledFailure: false), + (eviction: true, failedCompletion: false, settledFailure: false), + (eviction: true, failedCompletion: true, settledFailure: false), + (eviction: true, failedCompletion: true, settledFailure: true), + ]) { + final (:eviction, :failedCompletion, :settledFailure) = scenario; + test( + 'a later traversal renews an in-flight protection (memory=$memory, eviction=$eviction, failedCompletion=$failedCompletion, settledFailure=$settledFailure)', + () async { + final path = p.join(root.path, 'delayed-completion.md'); + await File(path).writeAsString('baseline'); + final LocalHistoryStore backing = memory + ? MemoryLocalHistoryStore() + : FileLocalHistoryStore( + rootDirectory: () async => + Directory(p.join(root.path, 'history')), + ); + final store = _ObservedTraversalStore(backing) + ..holdCompletedSource = 'delayed X' + ..failHeldCompletion = failedCompletion; + addTearDown(() { + if (!store.release.isCompleted) store.release.complete(); + }); + var now = DateTime.utc(2026); + final h = await _harness(store, clock: () => now); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + h.controller.updateActiveText('delayed X'); + expect(h.controller.undoActiveBuffer(), isTrue); + await store.started.future; + if (settledFailure) { + store.release.complete(); + await _waitFor( + () => + h.container + .read(localHistoryControllerProvider) + .warning + ?.kind == + LocalHistoryWarningKind.capture, + operation: 'first protective publication failure settles', + ); + expect( + store.requests.where((request) => request.source == 'delayed X'), + hasLength(1), + ); + } + now = now.add(const Duration(days: 31)); + if (eviction) { + final original = (await backing.load()).revisions.singleWhere( + (revision) => revision.checksum == sourceChecksum('delayed X'), + ); + await backing.capture( + LocalHistoryCaptureRequest( + documentId: original.documentId, + path: path, + displayName: 'delayed-completion.md', + source: 'retention keeper', + format: TextFormatMetadata.utf8Lf, + capturedAt: now, + reason: LocalHistoryCaptureReason.saved, + ), + history.policy, + ); + expect(await backing.readRevision(original.id), isNull); + expect( + (await backing.load()).documents.single.id, + original.documentId, + ); + } + expect(h.controller.redoActiveBuffer(), isTrue); + expect(h.controller.undoActiveBuffer(), isTrue); + h.controller.updateActiveText('new branch'); + expect( + history.retainedDetachedCaptures.where( + (c) => c.protection?.snapshot.source == 'delayed X', + ), + hasLength(1), + ); + if (!store.release.isCompleted) store.release.complete(); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final sources = await _revisionSources( + backing, + (await backing.load()).revisions, + ); + expect(sources, containsAll(['delayed X', 'new branch'])); + expect( + sources.where((source) => source == 'delayed X'), + hasLength(1), + ); + expect( + store.requests.where((request) => request.source == 'delayed X'), + hasLength(2), + ); + }, + ); + } + + test( + 'completed traversal captures are revalidated without duplicate revisions (memory=$memory)', + () async { + final path = p.join(root.path, 'repeated.md'); + await File(path).writeAsString('baseline'); + final LocalHistoryStore backing = memory + ? MemoryLocalHistoryStore() + : FileLocalHistoryStore( + rootDirectory: () async => + Directory(p.join(root.path, 'history')), + ); + final store = _ObservedTraversalStore(backing); + var now = DateTime.utc(2026); + final h = await _harness( + store, + clock: () => now = now.add(const Duration(seconds: 1)), + ); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + h.controller.updateActiveText('repeated X'); + expect(h.controller.undoActiveBuffer(), isTrue); + await _waitFor( + () => history.retainedDetachedCaptures.isEmpty, + operation: 'first traversal protection settles', + ); + for (var i = 0; i < 12; i++) { + expect(h.controller.redoActiveBuffer(), isTrue); + await _waitFor( + () => history.retainedDetachedCaptures.isEmpty, + operation: 'redo protection settles', + ); + expect(h.controller.undoActiveBuffer(), isTrue); + await _waitFor( + () => history.retainedDetachedCaptures.isEmpty, + operation: 'undo protection settles', + ); + } + expect( + store.requests.where( + (r) => r.reason == LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + hasLength(25), + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final snapshot = await backing.load(); + expect( + snapshot.revisions.where( + (r) => r.checksum == sourceChecksum('repeated X'), + ), + hasLength(1), + ); + expect(snapshot.revisions.length, lessThanOrEqualTo(3)); + }, + ); + } + test( 'reload is an undoable replacement after an existing redo branch', () async { @@ -6958,6 +7304,51 @@ class _MemorySettingsStore implements LocalSettingsStore { Future save(Map json) async => value = json; } +class _ObservedTraversalStore extends _FailingProtectiveStore { + _ObservedTraversalStore(super.delegate); + + final requests = []; + String? blockedSource; + String? holdCompletedSource; + var failHeldCompletion = false; + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + requests.add(request); + if (request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal && + request.source == blockedSource) { + if (!started.isCompleted) started.complete(); + await release.future; + throw FileSystemException( + 'Injected re-protection publication failure', + request.path ?? '', + const OSError('temporarily unavailable', 11), + ); + } + if (request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal && + request.source == holdCompletedSource && + !started.isCompleted) { + final result = await delegate.capture(request, policy); + started.complete(); + await release.future; + if (failHeldCompletion) { + throw const FileSystemException( + 'Injected failure after protective publication', + '', + OSError('temporarily unavailable', 11), + ); + } + return result; + } + return delegate.capture(request, policy); + } +} + class _FailingProtectiveStore implements LocalHistoryStore { const _FailingProtectiveStore(this.delegate);