diff --git a/docs/development/history.md b/docs/development/history.md index 442c8c0c..fd9d62e1 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -3,6 +3,188 @@ 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. + +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. +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: + +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. | +| 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. | +| 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 +**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 +283,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..c05f4983 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -700,6 +700,122 @@ 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 previousSnapshot = _acceptAtCurrentClearEpoch( + LocalHistoryBufferSnapshot.fromBuffer(previous), + ); + // 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 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( @@ -4989,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( @@ -5827,7 +5948,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 +5995,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..58047567 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, } @@ -148,6 +149,7 @@ class LocalHistoryRevisionSummary { required this.storageBytes, required this.sourceLength, this.historicalPath, + this.protectedAt, }); final String id; @@ -159,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, @@ -168,6 +194,8 @@ class LocalHistoryRevisionSummary { 'storageBytes': storageBytes, 'sourceLength': sourceLength, 'historicalPath': historicalPath, + if (protectedAt != null) + 'protectedAt': protectedAt!.toUtc().toIso8601String(), }; factory LocalHistoryRevisionSummary.fromJson(Map json) { @@ -182,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_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..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, @@ -275,18 +286,40 @@ 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, + // 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( + (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); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + root, + index, + document, + candidate, + request, + policy, + ); + } index = index.withDocument(document); await _publishIndex(root, index); if (!_captureStillAccepted(request)) { @@ -295,7 +328,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { } return LocalHistoryCaptureResult( document: document, - revision: adjacent, + revision: candidate, deduplicated: true, ); } @@ -334,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); @@ -369,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 { @@ -990,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. } @@ -1080,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, @@ -1089,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; } @@ -1390,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, { @@ -1736,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, @@ -1783,17 +1874,36 @@ 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)) { + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + document, + candidate, + request, + policy, + ); + } return LocalHistoryCaptureResult( document: document, - revision: adjacent, + revision: candidate, deduplicated: true, ); } @@ -1825,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, @@ -1833,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, @@ -1843,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/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..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( @@ -60,6 +62,328 @@ void main() { maximumBytes: 16 * 1024 * 1024, ); + 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 { + 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)); + }, + ); + } + + 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 3a15c517..f33b1c2c 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -36,6 +36,783 @@ 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 { + 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); @@ -6527,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); @@ -7283,6 +8105,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 +8254,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..0373f0fe 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'; @@ -56,7 +57,269 @@ 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', + (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; + final history = await DocumentHistoryHarness.create( + mode: DocumentViewModePreference.source, + ); + addTearDown(history.dispose); + DocumentBuffer buffer() => history.buffer; + 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, + readEditBase: history.readBase, + readSelection: () => buffer().editorState.selection, + onUndo: history.undoText, + onRedo: history.redoText, + onChanged: (_, _) {}, + onCloseSearch: () {}, + onTransactionalChanged: (text, _, before, after, group) => + history.controller.updateActiveSourceText( + text, + previousSelection: before, + selection: 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), + ); + 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()); + }, + ); + testWidgets( 'Source consumes shared pages and replaces in an unsaved buffer', (tester) async { @@ -8712,6 +8975,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..cd7f6885 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'; @@ -12,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, ); @@ -24,8 +28,245 @@ 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; + 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; + } + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + 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) => + history.controller.updateActiveWysiwygText( + text, + document: document, + 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)); + 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()); + }); + 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/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 {} +} 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) +''';