diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 85df4900..7bda1faa 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - main - - release/v0.6.3 + - release/v0.7.0 push: branches: - main diff --git a/docs/development/deferred-focus-restoration.md b/docs/development/deferred-focus-restoration.md new file mode 100644 index 00000000..d65c2304 --- /dev/null +++ b/docs/development/deferred-focus-restoration.md @@ -0,0 +1,423 @@ +# Deferred dialog and document-selection focus + +## Scope and baseline + +This follow-up starts at `b8dd70c` with a clean worktree. It changes focus +coordination only, not the heading/Enter/inline-image repair on this branch. +The parser, document controller, serializers, dependency pins and native UI are +unchanged. Repository Linux and compiled-application verification instructions +were followed. No applicable `AGENTS.md` was present. + +The official SDK is `/home/albert/flutter-sdk`: Flutter 3.47.5, +framework `6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine revision +`af7e796e161ae0bb1ff0758c71a7105418bd9ded`, Dart 3.13.4. +`flutter pub get --enforce-lockfile` passed without changing either manifest. + +Evidence below is retained locally under `build/deferred-focus-evidence/` and +the named native output directories. These ignored artifacts are not committed. +The committed tests and probes reproduce the checks without those artifacts. + +## Independently confirmed findings + +| Finding | Reproduction and actual cause | Repair | +| --- | --- | --- | +| Delayed modal release can reclaim an older editor. | Close a production dialog, hold native barrier release, focus another field, then deactivate the application. The old guard accepts *any* `FocusScopeNode`, including the desktop root while the newer owner is suspended. A newer owner followed by unfocus also bypasses it. Capturing `closingFocus` after `pop()` additionally mistakes a newer control for the closing dialog. | Identify the actual closing route. A per-release permission observes focus ownership and lifecycle, permanently invalidating on a newer owner or inactivity. Only the previous owner, its ancestor scopes and that closing route are eligible. Flush pending FocusManager requests before the final check; remove listeners on invalidation or completion. | +| Deferred cross-block selection extent can override another control or inactive desktop focus. | Perform a real production pointer drag across blocks; release it, then focus a search-like control or deactivate before the post-frame handoff. The original callback checks selection and generation but not owner/lifecycle. Table-ended ranges also have a second, unconditional child autofocus callback. | One guarded handoff owns ordinary and table-ended range input. Capture selection/request/document/navigation identities and the actual focus owner. Cancel on a competing owner or newer action; wait without focusing while the desktop suspends the owner. Resume only when FocusManager restores that same ownership. Remove the child's independent autofocus. | +| A canceled toolbar dialog can bypass the shared dialog guard. | Hold release of the real Link toolbar dialog and focus another field. The shared guard can leave it alone, but toolbar `whenComplete` snapshots the new owner and schedules an editor return anyway. | Completion is not a new editing intent: check permission before scheduling toolbar restoration. | +| A newer focus request can be pending, not yet primary. | Register a newer-control request earlier in the same post-frame phase, then queue `restoreEditingFocus()`. Without flushing FocusManager, the old callback sees the old primary and overwrites the newer request. | Apply pending focus changes before the existing request/document/selection/navigation checks. | + +Both supplied findings are confirmed, but not every neighboring path was broken. +The existing resumed newer-owner modal case already passes. Document replacement, +disposal, ordinary completion, intentional keyboard collapse and quick lifecycle +round trips are retained as controls, not claimed as additional original defects. +The existing canceled nested-menu regression is preserved; its ordinary +cancellation is not evidence of a new defect. + +## Why editing and reading remain independent + +No repair moves a caret, fabricates composition, changes read-only state, +unfocuses an editor, restores a viewport after a jump, or uses a timer/fixed +frame-count suppression. The existing operation-scoped caret-reveal protection +is unchanged. A passive handoff does not call its intentional `reveal()` API. +Actual input and purposeful navigation continue through the original editing +and reveal paths. + +Validation of the first repair exposed a necessary lifetime detail: an ordinary +selection extent waiting through prolonged inactivity can leave the virtualized +cache before its handoff. The pending operation now holds a keep-alive lease for +that one target only, released on success, cancellation or disposal. Tests assert +that the rest of the document remains virtualized. This is not whole-document +retention, and the lease neither requests focus nor changes scrolling. + +## Regression evidence + +The final 56 new cases were copied into a separate `git archive b8dd70c` +checkout. Its production editor/dialog blobs were verified unchanged +(`52bb3fda7412e6c1de67d5ee2301a057108c02e1` and +`a855768ad0c11853e1c8c974fa298c627ff0a1c0`, respectively). + +```sh +# Run in the unchanged archive and in the repaired checkout: +/home/albert/flutter-sdk/bin/flutter test --no-pub --reporter expanded \ + test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + --name 'deferred modal focus|deferred extent|deferred toolbar' +``` + +The unchanged production code fails **29** cases; **27** controls pass +(`before-definitive.log`, exit 1). The repaired checkout passes all 56 as part +of the complete affected suites (`affected-final.log`, exit 0). + +Coverage includes both range directions, ordinary/table extents, competing +owners before/after close, newer-owner-to-scope transitions, inactivity and +resumption, cancellation, 90 inactive frames, disposal, document replacement, +new caret navigation, and a newer request in the same post-frame phase. +Off-cache reading tests measure actual visible geometry every frame with a +0.1-logical-pixel tolerance. They assert unchanged range endpoints, source and +undo history, deliver platform text input without tapping/refocusing, verify +replacement at the correct target and deliberate reveal, then undo. + +The original lifecycle helper explicitly enables Linux FocusManager lifecycle +listening and asserts inactive primary focus is the desktop root. It is not a +test that merely changes an unrelated lifecycle value. + +An early pending-owner test did not schedule a frame, and early toolbar tests +used an obstructed target/incorrect dialog finder. Those attempts are not +regression evidence; `before-definitive.log` uses the corrected tests. + +One combined run encountered an existing test-harness pointer-ID collision: +an old context-menu test fixes pointer 71, while newly auto-allocated gestures +advanced to 71. The same GestureArena assertion was independently reproduced +on unchanged production code by seeding the test pointer allocator to 71 +(`original-pointer-id-collision.log`). New tests use explicit noncolliding IDs; +the old test and its assertions are unchanged. The final full affected run +passes, as does the isolated old context-menu test. + +## Verification commands + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/wysiwyg_session_test.dart test/src/source_editor_widget_test.dart + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/app_smoke_test.dart \ + test/src/busymark_design_test.dart test/src/workspace_controller_test.dart \ + test/src/workspace_document_context_test.dart \ + test/src/writerside_toc_menu_test.dart test/src/source_commands_test.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/busymark_document_test.dart \ + --name 'WYSIWYG link dialog' + +/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib/src/app/busymark_dialogs.dart \ + lib/src/editor/editor_focus_restoration.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart tools/focus_restoration_smoke.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub +/usr/bin/python3 -m py_compile tools/focus_restoration_native.py \ + tools/deferred_focus_native.py +/usr/bin/python3 -m unittest tools/test_owned_processes.py \ + tools/test_application_close_smoke.py tools/test_run_owned_fixture.py \ + tools/test_official_flutter_workflow.py + +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release \ + --no-pub -t lib/main.dart +``` + +The four affected suites pass **487 tests** in 3m24s (`affected-final.log`). +The six broader integration suites pass **505 tests** in 4m56s +(`integration-suites.log`), including existing menu cancellation, workspace +navigation, search and view transitions. All **3** existing accepted Link-dialog +editing tests pass (`accepted-dialog.log`). No assertions were weakened or new +regressions skipped. The unrelated entire Flutter suite was not rerun. + +Formatting checks all six Dart files with zero changes (`format-final.log`). +Static analysis reports no issues (`analyze-commit.log`, 26.0s). Both Python +drivers compile; the existing process/acceptance/workflow tooling suites pass +**92** tests (`tooling-final.log`). The normal `lib/main.dart` Linux release +build passes (`linux-release-final.log`); its bundle is separately retained in +`build/deferred-focus-shipping-bundle/` while compiling probes. The shipping +engine SHA-256 matches the official SDK artifact: +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`. + +## Native Linux acceptance + +The probe uses the actual application and official engine, disposable Markdown, +private GNOME/Mutter headless desktops, a separate GTK companion application, +and real Mutter Alt-Tab/key injection. Profiles are isolated *before* starting +the owned D-Bus broker; activated helpers inherit that isolation. The owned-tree +supervisor checks cleanup and does not signal unrelated processes. + +Four application-switch cycles include a 15-second inactive interval. Every +rendered frame records focus/lifecycle and reading geometry, never document +contents. Native `z` input is delivered without clicking/refocusing the field. +Deferred dialog setup deterministically closes the production Navigator route +and gates its real barrier release; this is explicitly **programmatic dialog +dismissal**, not proof of native Escape handling. Selection setup uses real +native XTest pointer input on XWayland, accounting for GTK frame extents, and +introduces a newer owner before the post-frame handoff. + +```sh +# Build the alternate entry point, retain that bundle, then restore lib/main.dart. +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release \ + --no-pub -t tools/focus_restoration_smoke.dart +/usr/bin/python3 tools/deferred_focus_native.py \ + build/deferred-focus-release-probe/busymark NEW_SHORT_OUTPUT x11 deferred-dialog +# Backends: x11 (XWayland), wayland. Modes: editor, deferred-dialog, +# deferred-selection, deferred-selection-table (selection gesture: x11 only). +``` + +The XWayland Editor control passes in debug (`build/df-xe/desktop/probe/`) and +release (`build/df-xe2/desktop/probe/`): all +passive geometry/focus/source/history checks, correct insertion and intentional +reveal. Its supervisor exits 0 with no unresolved descendants. A too-long +Wayland socket path and two native Escape-dismissal prerequisites failed in +earlier attempts; none is counted as acceptance. Their logs are retained. +The Editor control is explicitly retired by the supervisor after its focus/input +measurements; it is not a successful natural-shutdown claim. The three deferred +release cases below do require natural application exit. + +The final release XWayland delayed-dialog case passes +(`build/df-xd4/desktop/probe/{frames.json,result.json}`): barrier completion while +inactive leaves focus at the root, resumed input edits only the newer control, +and all per-frame viewport, range, bytes/history/dirty/revision assertions pass. +The application exits naturally with status 0; the owned-tree supervisor reports +no unresolved descendants (`build/df-xd4/ownership.json`). + +Release XWayland ordinary and table-ended cross-block selections both pass +(`build/df-xs4/desktop/probe/` and `build/df-xt/desktop/probe/`). The recorded +native pointer-up coordinates equal the requested rendered caret positions; +the production editor reports a cross-block range. Each case preserves the +range and reading geometry through all four native focus cycles and directs +native input only to the newer control. Both applications exit naturally with +status 0 and both ownership reports contain no unresolved descendants. + +The native Wayland dialog case remains **unverified**: even after a real Alt-Tab +activation attempt, GTK does not report the application's window active on +this owned desktop (`build/df-wd3/desktop/probe/error.txt`). Earlier Mutter +relative-pointer attempts also failed to deliver the native selection; those +failed prerequisites are retained separately and not counted as passes. +The final XTest cases do not weaken any production assertion. The native GTK +Escape dismissal path is not established by the deterministic dialog prelude. + +Native acceptance is limited to the recorded owned headless desktops; it is not +a claim about an untested physical desktop, strict Snap, minimize/restore, or +window-chrome reactivation. The entire unrelated Flutter suite is not rerun by +this focused follow-up. + +## Semantic-text cancellation follow-up (P2) + +Starting at `b139f51`, the remaining Writerside semantic-dialog caller was +independently confirmed to bypass the async-toolbar completion preflight. +After the production UI Control or File / Path dialog is canceled, hold its +native-barrier release, focus a newer search-like control, then complete release +while resumed. `_isDialogTargetCurrent()` still succeeds, and the semantic +caller schedules `_restoreEditingFocusAfterFrame()` with that newer control as +the captured owner. Its unchanged ownership at the deferred check is therefore +mistaken for permission to return to editing. + +Only the null/empty-result branch of `_insertSemanticText()` changes: it now +requires `_mayRestoreEditingFocus()` as well as the existing document validity +guard before scheduling restoration, matching async toolbar completion. This +does not change accepted semantic insertion, purposeful focus requests, the +earlier Alt-Tab repair, or structural editing. + +Eight new regressions in `writerside_authoring_ui_test.dart` cover Markdown and +XML topics, UI Control and File / Path, and ordinary versus newer-owner delayed +cancellation. They use the production native-menu dispatch and semantic dialog, +gating only the barrier service. They assert the document generation is still +current, preserve the collapsed caret, source and undo history, check focus and +actual reading geometry on every rendered frame (0.1 logical-pixel tolerance), +and send text through the existing input connection without refocusing. Ordinary +cancellation then edits the original target and exercises one-step undo/redo; +newer-owner input affects only the newer control. Existing immediate semantic +cancellation tests now also assert primary editor focus and an input client. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/writerside_authoring_ui_test.dart \ + --name 'delayed semantic cancellation|collapsed.*enters text and cancels neutrally' +``` + +The unchanged production implementation fails the four newer-owner cases; the +four ordinary delayed controls and eight existing controls pass +(`build/semantic-dialog-focus-evidence/before-framed.log`: 12 passed, 4 failed). +An initial harness did not schedule a render after barrier release, so its +deferred callback did not run; that attempt is not regression evidence. The +final tests explicitly schedule rendered frames, not merely fake-clock ticks. + +The final focused command passes all **16** tests in 48 seconds +(`after-final-focused.log`). The complete five affected suites pass **556** +tests in 3m53s (`affected-suites.log`): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/writerside_authoring_ui_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/busymark_dialogs_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart +``` + +Follow-up logs are under `build/semantic-dialog-focus-evidence/`. Formatting +checks both changed Dart files with zero changes (`format.log`); pinned +`flutter analyze --no-pub` reports no issues (`analyze-final.log`, 43.7s). +The normal pinned `flutter build linux --release --no-pub -t lib/main.dart` +passes (`linux-release.log`), with the same official release-engine hash recorded +above. Dependency pins and unrelated structural code remain unchanged. + +This follow-up does not repeat native semantic-dialog acceptance. The earlier +native results above cover their recorded generic dialog/selection paths, not +this additional semantic caller. No new native pass is claimed. + +## Toolbar-menu cancellation and passive-caller audit (P2) + +Starting at `8b9aa85` with a clean worktree, same-document cancellation of both +Writerside and Text style menus was independently confirmed to bypass ownership +checks. The production native-menu channel result was held while another text +control acquired focus, then completed with null. Document identity and +generation remained unchanged. `onMenuCanceled` passed its document guard and +the deferred helper treated the newly captured owner as permission to return +to the editor. A different toolbar menu's trigger could also be displaced. + +This asynchronous boundary is present in production, not just in the test +double: `native_menu_deactivate_cb()` in `linux/runner/my_application.cc` +schedules `native_menu_cleanup_idle_cb()` with `G_PRIORITY_DEFAULT_IDLE`; +cleanup returns the result to Dart. GLib documents that idle work yields to +ready higher-priority sources. This establishes the possible ordering, not a +measurement of native race frequency. [GLib idle_add_full](https://docs.gtk.org/glib/func.idle_add_full.html) + +Passive completion is now an explicit policy on +`_restoreEditingFocusAfterFrame(passive: true)`. It checks lifecycle and current +ownership before capturing the request and again in the post-frame callback +after pending FocusManager changes are applied. The originating menu context +travels through `BusyMarkHeaderPopupMenuButton.onCanceled` and the toolbar. +This allows return from **that menu's own trigger**, as well as the editor or +its ordinary scopes, without permitting a different menu trigger or text field. +It adds no widgets, focus nodes, timers, SDK changes, or native UI changes. + +The helper's callers were reviewed together: + +| Caller | Intent and policy | +| --- | --- | +| Writerside/Text style cancellation | Passive; document guard plus shared permission, with the exact closing menu's trigger context. | +| Semantic-text dialog cancellation | Passive; existing document guard retained, previous ownership check moved into the shared policy. | +| Async toolbar completion | Passive; previous preflight moved into the same shared policy, with execution-time revalidation too. | +| Rich `restoreSpellingFocus()` after review | Passive; an additional real workspace-review dialog regression reproduced the same theft during delayed barrier release, and this entry point now uses the shared guard. | +| Synchronous toolbar commands, accepted Writerside/semantic insertions, and explicit inspector-close focus return | Purposeful editing/navigation; retain the existing policy and request/document/selection checks. | + +The spelling finding was reproduced through the actual F7 review dialog and +workspace `finally` path, not by directly calling a private helper. Source-mode +spelling return does not use this helper and is not changed or claimed fixed. +No spelling projection/worker algorithm or unrelated structural-editing code +was changed. + +### Failing-before / passing-after evidence + +Artifacts are retained locally in `build/toolbar-menu-focus-evidence/`. + +- `before-menu-valid.log`: unchanged production fails **16** cases and passes + **16** ordinary controls. The 32 cases cover both menus, Writerside Markdown + and XML, collapsed/reversed local selections, ordinary editor return, return + from the exact trigger, a newer text control, and a different menu trigger. +- `before-spelling-valid.log`: unchanged production fails the newer-owner + workspace-review case; ordinary review cancellation/input/undo passes. +- `after-focused.log`: all **50** focused authoring cases pass, including the + menu matrix and previous semantic-dialog regressions/ordinary controls. +- `after-spelling.log`: both focused workspace-review cases pass. + +The menu regressions explicitly render every deferred frame, measure the real +visible anchor and scroll position within 0.1 logical pixel, and assert unchanged +selection direction, source and undo history. They send two successive input +updates over the newer control's existing connection without tapping/refocusing. +Ordinary return types into the original target, reveals it, and preserves +one-step undo/redo. Workspace-review cases also assert unchanged buffer revision, +dirty state, undo and redo histories. No final-position-only workaround is used. + +An initial test tried to read `Focus.focusNode`, which is null when Flutter owns +the internal node. That invalid trigger setup is not defect evidence. The final +test uses the public `Focus.of()` lookup below the real trigger's Focus widget. + +### Verification + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=2 --reporter expanded \ + test/src/writerside_authoring_ui_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/busymark_dialogs_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart test/src/spelling_workspace_test.dart \ + test/src/busymark_design_test.dart test/src/native_menu_service_test.dart \ + test/src/writerside_toc_menu_test.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/app_smoke_test.dart \ + --name 'Writerside|inspector|properties|focus|canceled|spelling|menu' +``` + +All **689** tests in the nine full suites pass in 3m51s (`affected-final.log`), +and all **34** selected real-workspace controls pass (`workspace-controls.log`). +The entire unrelated Flutter suite was not rerun. + +The initial loaded run had two failures (`affected-suites.log`): a new fixture's +post-undo spelling debounce outliving its widget-test body, and an unchanged +large Markdown spelling check timing out while analysis/build jobs competed. +The new fixture now retires its session in the real async zone, before fake-clock +timer invariants; native-worker shutdown itself has a real-I/O timeout and must +not be moved onto that fake clock. No production spelling code or assertions +were weakened. The unchanged large fixture and all **36** spelling workspace +tests pass alone (`spelling-suite-verified.log`), and then the complete combined +run also passes with controlled concurrency and no competing builds. + +Pinned `flutter analyze --no-pub` reports no issues (`analyze-final.log`, 35.7s). +`dart format --output=none --set-exit-if-changed` checks all five changed Dart +files with zero changes (`format.log`). The normal +`flutter build linux --release --no-pub -t lib/main.dart` passes +(`linux-release.log`), using the same Node PATH/TMPDIR and official toolchain +commands recorded above. Dependency pins, SDK revision and the official engine +hash remain unchanged. + +```sh +/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib/src/app/busymark_design.dart \ + lib/src/editor/wysiwyg/wysiwyg_toolbar.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + test/src/writerside_authoring_ui_test.dart test/src/spelling_workspace_test.dart +``` + +The existing release-mode native Editor control passes on an owned GNOME +headless XWayland desktop (`native-editor-control-retry.log`). It performs four +real Alt-Tab cycles, including 15 seconds inactive, then types native `z` without +refocusing. Per-frame passive viewport/reading geometry, focus/selection, +bytes/dirty/revision and undo/redo checks pass; input edits the original target +and intentionally reveals it. Retained measurements and ownership/actions are +under `build/toolbar-menu-focus-evidence/native-control/`. The supervisor exits +0 with no unresolved descendants. Like the earlier Editor control, it is retired +after focus/input measurements, not claimed as a natural-shutdown acceptance. +This control does **not** natively reproduce the gated GTK menu-result race or +spelling-review race; those are production-widget/channel regressions above. +Native Wayland and physical-desktop menu acceptance were not rerun. + +```sh +# Build with the earlier pinned Node PATH and TMPDIR=/tmp, retain the probe +# separately, then restore the normal lib/main.dart bundle. +/home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t tools/focus_restoration_smoke.dart +/usr/bin/python3 tools/deferred_focus_native.py \ + /tmp/busymark-toolbar-focus-gKNPB3/release-probe/busymark \ + /tmp/NEW_SHORT_FOCUS_OUTPUT x11 editor +``` + +The first native-control launch could not finish creating its isolated profile +because staging duplicate bundles exhausted available workspace space; it did +not reach supervisor launch. Three generated bundle copies were moved, without +deletion, to `/tmp/busymark-toolbar-focus-gKNPB3/`. The successful retry kept its +desktop/profile there too. The normal shipping bundle was restored to +`build/linux/x64/release/bundle/` and compared byte-for-byte with the retained +`/tmp/busymark-toolbar-focus-gKNPB3/shipping/` copy. No source or evidence logs +were removed. The workspace volume remains nearly full (about 700 MiB available +after relocation); the temporary copies can be regenerated from the pinned +toolchain if `/tmp` is cleared. diff --git a/docs/development/editor-focus-restoration.md b/docs/development/editor-focus-restoration.md new file mode 100644 index 00000000..dbd1664e --- /dev/null +++ b/docs/development/editor-focus-restoration.md @@ -0,0 +1,346 @@ +# Editor focus and reading-position restoration + +## Investigation boundary + +The starting checkout was clean at +`aac4d66872b794b7722d0ededf11313ee7eccd15`. Repository/parent instructions, +dependency pins and the existing Linux verification documentation were checked +before production changes. The repair uses the existing official Flutter 3.47.5 +SDK at `/home/albert/flutter-sdk`: framework +`6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine +`af7e796e161ae0bb1ff0758c71a7105418bd9ded`, Dart 3.13.4. The SDK checkout, +`pubspec.yaml`, and `pubspec.lock` are unchanged. `flutter pub get +--enforce-lockfile` succeeded; the installed positioned-list package is 0.3.8. + +The supplied review was treated as leads. The installed Flutter and package +sources, rather than archive line numbers, determined the implementation. +Evidence is retained locally in `build/focus-evidence/`; these ignored build +artifacts are not document data and are not published. Early exploratory tests +with an incorrect platform setup, an off-screen tap, or a missing render-host +override were corrected and are not counted as defect evidence. + +## Independently confirmed defects + +Each row identifies a regression that failed before its respective repair. Some +additional paths were investigated after the first focus repair; their logs +therefore isolate those remaining defects, not an entirely unmodified baseline. + +| Defect | Failing evidence | Passing evidence | +| --- | --- | --- | +| Linux desktop focus suspension/restoration schedules an old caret reveal in the rich document's ancestor viewport. The visible field moved from 50 to 431.12 logical pixels in an intervening frame. | `before-core.log`, `before-desktop-valid.log` | Editor lifecycle suite; native frame traces below | +| Source's own vertical controller also scrolls on passive focus: 1800 to 1661.75 pixels. Blocking ancestor implicit scrolling alone cannot fix this. | `before-source.log` | `after-source-composition.log`; Source lifecycle suite | +| A partially clipped/oversized block loses its intra-block position. A code block naturally scrolled 2300 pixels restored from top -2219 to 81. | `before-lifetime-dialog-session.log` | `after-session-layout.log`, `clipboard-and-viewport.log`; tall paragraph/code regressions | +| Recreating a session with an off-screen caret can restore reading position but leave no mounted text-input client. | `expanded-first.log` | `after-session-input.log`, `after-session-detach.log`, `after-session-owner.log` | +| A queued editor focus restoration can override a newer search/inspector-like focus owner. | `before-newer-focus.log` | `after-newer-focus.log` | +| A queued toolbar focus callback can reclaim primary focus while the desktop focus manager has suspended the application. | `before-toolbar-suspension.log` | `session-and-toolbar-verified.log` | +| A document refresh/session restore can take focus from an existing competing owner, including while the focus manager has suspended it. | `before-session-owner.log` (both resumed and inactive cases) | `after-session-owner.log` (4 tests passed, including existing recreation cases) | +| Canceling a dialog replaces a table cell's reversed 7..2 selection with 0..9. | `before-table-math.log` (dialog case only) | `math-and-restores.log`; lifecycle suite | +| A math editable can change to its rendered representation on inactivity, detaching its input client. | `math-and-restores.log` (valid render-host setup) | Lifecycle suite's prolonged math-source case | +| Source's pending session jump can overwrite a newer manual scroll (600 becomes 1200). | `before-source-pending.log` | `after-source-pending.log` | +| Legitimate platform composition cleanup while inactive triggers Source's separate horizontal-caret helper (400 becomes 68.67). | `before-source-composition.log` | `after-source-composition.log` (5 tests passed) | +| Delayed native modal-barrier release can restore an older focus owner over a control selected while release was pending. | `before-modal-owner.log` | `after-modal-owner.log`, `serial-retries-one.log` | +| A retained table outside the package's two-screen layout cache has a zero paint transform. Floating controls attempted to paint at NaN coordinates, and the new session metadata attempted to serialize NaN. | `short-table-outside-cache-before.log`; native `ship-x11-table/application.log` | `short-table-finite-verified.log`; the expanded short-cell recreation case in `focused-cache-complete.log` | + +### Conclusions not supported by reproduction + +- No BusyMark application-reactivation handler explicitly scrolling the document + to the top was found. The reproduced mechanism is Flutter's focus-driven caret + reveal, plus the independently reproduced Source horizontal helper. +- Ordinary table-cell *application resumption* did not reproduce select-all. + The pinned `EditableText._justResumed` path protects that case. Dialog return + is different and did reproduce it; the two are not conflated. +- The existing recreation test is useful and remains present, but its caret in + the visible region does not establish application-reactivation behavior. +- Negative item leading edges are legitimate; negative `jumpTo` alignments are + not the repair. The package's documented alignment range remains respected. +- Menu/dialog/Source/Split paths were not all declared defective merely because + they restore focus. Passing cancellation, competing-owner and navigation + cases are retained as coverage, not additional claimed bugs. +- A later native-probe failure initially suggested that a short table cell did + not reveal on typing. Stronger measurements disproved that diagnosis: the + active viewport moved from 1900 to 129.99 pixels and the target was visible at + y=66. The probe retained an obsolete `ScrollPosition` after a viewport + replacement (`accept-w-table/probe/frames.json`, `samePosition:false`). The + measurement now follows the live viewport and also checks actual rendered + reading/target geometry. No editor scrolling workaround was added for this + false diagnosis. The separate non-finite table/session geometry was genuinely + reproduced and repaired. + +## Repair mechanism + +`BusyMarkEditorFocusRestoration` surrounds only production editor fields. On a +focus-only restoration it intercepts both halves of the pinned framework's +deferred caret reveal: the field controller's direct `animateTo`/`jumpTo`, then +the descendant `showOnScreen` request that would reach ancestor viewports. The +actual `showOnScreen` callback consumes the operation token. There is no timer, +fixed number of ignored frames, delayed jump-back, temporary read-only state, +selection movement, SDK modification, or application-wide visibility policy. + +Text/selection changes and real editing keys/pointer input release suppression; +explicit editor navigation/focus helpers identify purposeful reveal requests. +The framework still performs normal caret scrolling for input, selection changes, +search and explicit navigation. Manual wheel/drag scrolling is not disabled. +The repair does not fabricate or replay text-input/composition events. Source +ignores only composing-only, non-editing lifecycle notifications when deciding +whether to schedule its own horizontal reveal. + +A focus-scoped keep-alive retains only the last editing target for each editor, +including while the desktop focus manager suspends primary focus. Changing to +another editing target releases the previous retained target. Ordinary document +virtualization remains enabled; the whole document is not retained. Math source +presentation also remains stable during inactivity. +For retained children beyond the layout cache, session capture uses the public +viewport's logical reveal coordinate when the paint transform is not invertible. +Unanchored table controls are not painted until their table has a valid paint +transform again. This avoids both non-finite persistence and invalid overlay +painting without widening the virtualization cache. + +Session state adds optional/backward-readable clipped-pixel offset, editing-block +viewport offset and source/layout signature fields. With unchanged layout the +saved editable is mounted and the document's actual scroll position is restored +in the same operation, preserving its off-screen input client. Otherwise the +existing content anchor is used with legal `[0,1]` item alignment followed by a +synchronous pixel offset, including leading scroll padding and nested entries. +Current layout enforces genuine bounds. Old sessions retain their previous +alignment semantics: missing intra-block precision cannot be reconstructed. +JSON, workspace coalescing and Writerside source-binding copies include the new +fields; no state is discarded and no migration framework is introduced. + +Queued operations use existing document generations plus request identity, +selection/navigation and focus-ownership checks. New scrolling or navigation, +replacement content, disposal, and a newer owner invalidate stale operations. +Document refresh does not claim focus while inactive or while another control +owns it. Shared dialogs validate their previous node and ownership again after +asynchronous barrier release. Table fields preserve their own caret/range; +explicit cell/document Select All commands remain in the editor. + +## Automated regression coverage + +`test/support/desktop_focus.dart` explicitly installs the pinned focus manager's +desktop lifecycle listener in the Linux-targeted test binding. It verifies that +inactive focus is the root scope and resumed focus is the original node. Tests +inspect geometry during every inactive/resumed frame and after callbacks and +animations, with 0.1 logical-pixel tolerance. They do not silently refocus the +field before delivering subsequent text input. + +During implementation, an additional cross-cell Tab test caught an overbroad +suppression at an explicit focus handoff (`navigation-boundary-measured-before.log`, +caret x=-1275.67). Explicit navigation markers repair that handoff; the same test +and both pending-scroll/navigation cases pass in +`navigation-boundary-verified.log`. Its intentional horizontal offset is compared +with fresh Tab navigation at 0.1-pixel tolerance: assuming it must be exactly zero +was incorrect for the pinned framework's fractional caret geometry (0.1667). +Passive geometry checks were not relaxed. Existing exact-reparse tests also +caught an overly strict root/view-scope ownership check; root scopes without a +route are now distinguished from a competing dialog/control, retaining both +normal recreation and competing-owner protection. + +The expanded lifecycle, session and Source suites cover caret above/below the +viewport; short/90-frame inactive intervals and repeated cycles; reversed local +and cross-block ranges; paragraphs, lists, code, table internal scrolling and +math representation; off-screen recreation; oversized/plain/nested blocks; +new scrolling/navigation while a callback is pending; canceled nested menus and +dialogs; newer focus owners; document replacement/disposal; committed input, +composition, paste and undo/redo. Existing viewport coverage is retained. +Workspace coverage checks meaningful pixel-only session updates, coalescing +sub-tolerance changes, unchanged bytes/dirty/history, and existing document/view +switching behavior. Native Source/Split coverage complements the widget cases. + +## Native Linux acceptance + +`tools/focus_restoration_smoke.dart` is an alternate entry point into the actual +application and production editors, not a substitute editor. The supervisor +`tools/focus_restoration_native.py` creates an owned GNOME/Mutter headless desktop +with a 1280x960 virtual monitor, isolated profiles, disposable Markdown/Writerside +documents, a separate real GTK application, and real Mutter keyboard input. It +does not operate the user's desktop. Both native Wayland and GTK X11 through +XWayland were exercised; this does not claim a separate standalone Xorg session. + +The successful matrix is Markdown Editor, Source, Split, and table-cell editing +on both backends, plus Writerside Editor on both backends (10 runs). Each run +uses four real Alt-Tab out/back cycles, including a 15-second inactive interval, +then types a native `z` without tapping or refocusing the editor. All recorded +frames preserve the passive viewport. Exact insertion at the original authored +source position and deliberate caret reveal both pass. Passive checks also +verify focus suspension/restoration, original selection, exact bytes, dirty +state, edit revision and undo/redo counts. + +Final frame-level evidence (no private document contents): + +- `build/focus-evidence/safe-x-{editor,source,split,table,ws}/probe/{frames.json,result.json}` (XWayland) +- `build/focus-evidence/safe-w-{editor,source,split,table,ws}/probe/{frames.json,result.json}` (Wayland) +- `build/focus-evidence/native-final-summary.json` and `native-final-lifecycle.jsonl` + +All 10 final runs pass. Each records four acknowledged real inactive/resumed +cycles. Live viewport drift is zero; rendered reading-anchor drift is zero where +the rich editor exposes an anchor. The probe follows the current scroll position +after viewport replacement and checks actual target visibility after input. +The normal release bundle is restored at `build/linux/x64/release/bundle/`; +its application-library hash matches `shipping-app-sha256.txt`. The separately +retained instrumentation bundle is `build/focus-verified-probe/`. + +These frame traces are the equivalent frame-level evidence, not a video. Earlier +native attempts and prerequisite failures are retained separately and are not +counted as passes. Native pre-repair recording was not obtained: the initial +build was blocked by the existing visualization build step's missing Node on +PATH. An already-installed Node 24.13.0 was subsequently located and used only +on the build command's PATH; dependencies and build scripts were not changed. + +Minimize/restore and non-editing window-chrome reactivation remain **unverified**. +The headless `full` and follow-up `minimize` attempts did not establish the native +window-state prerequisite. The final bounded X11 attempt also could not verify +native unmapping after the minimize request (`safe-x-full.log`). Failed +prerequisites and any failed focus/input results are retained, not represented as +successful acceptance. This limitation is not evidence that +the editor repair passes those transitions, nor a diagnosis of another editor +bug. The supervisor forcibly retires its owned processes afterward; it makes no +natural-shutdown acceptance claim. + +### Native harness isolation correction + +Earlier runs gave BusyMark disposable XDG directories but did not update the +private broker's environment for D-Bus-activated GNOME helpers. Some earlier +helper logs show attempted migrations of the host Evolution cache, reporting +file-exists errors. BusyMark itself used only disposable documents/profiles. +The harness now verifies that both its parent and broker belong to the dedicated +`dbus-run-session`, updates activation environment before starting the desktop +and after display discovery, and isolates keyring/session-agent settings too. +Final `safe-*` runs have no host-cache migration warnings. No host-cache cleanup, +deletion or attempted rollback was performed; the earlier logs do not establish +that every incidental helper action was side-effect-free. + +Reproduction commands use a new output directory on every run: + +```sh +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t tools/focus_restoration_smoke.dart +dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py \ + build/linux/x64/release/bundle/busymark NEW_OUTPUT x11 editor alt-tab +dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py \ + build/linux/x64/release/bundle/busymark NEW_OUTPUT wayland source alt-tab +# Other modes: split, table. Optional final document format: writerside. +# Final retained-bundle runs used build/focus-verified-probe/busymark instead. +``` + +## Verification record + +The verification logs include unsuccessful runs as well as successful retries; +no assertions or deadlines were relaxed, no new regression was skipped, and no +unrelated production code was changed to make the broader suite pass. + +All Flutter/Dart commands used the pinned SDK. Final formatter checking covered +the 14 changed/new Dart files; `git diff --check` and Python `py_compile` also +passed. `analyze-shipping-final.log` records `flutter analyze --no-pub`: no issues; +`format-shipping-final.log` records all 14 files checked with zero changes. +The bundled official release engine and the SDK artifact have the same SHA-256, +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1` +(`engine-sha256.txt`); `sdk.json` and the empty `dependency-pin-diff.log` retain +the SDK identity and unchanged pins. + +Commands and completed independent checks: + +```sh +/home/albert/flutter-sdk/bin/flutter pub get --enforce-lockfile +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/app_smoke_test.dart --name 'workspace coalesces pixel viewport' \ + --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/busymark_dialogs_test.dart test/src/spellcheck_projection_test.dart \ + test/src/headless_chrome_support_test.dart --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/nextcloud_notes_interaction_test.dart --reporter expanded +``` + +The workspace check passed 1 test (`workspace-coalescing.log`), the serial +dialog/projection/browser run passed all 111 (`serial-retries-one.log`), and the +unchanged Notes interaction suite passed all 12 (`notes-scan-normal-tmp.log`). +The six targeted exact-reparse/toolbar-suspension/table-Tab/competing-owner cases +passed together (`session-and-toolbar-verified.log`). + +Two broad runs are retained, not presented as all-green results: + +```sh +TMPDIR=/home/albert/busystack/busymark/.tmp/focus-regression \ + /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=4 \ + --reporter expanded +``` + +`full-suite.log`: 4790 passed, 62 existing skips, 4 failed. It compiled before +the final Source composing-only and rich clipboard build-order corrections. +Those regressions subsequently passed (`after-source-composition.log`, +`clipboard-and-viewport.log`). Its unchanged Notes scan case was still displaying +"Scanning files" at its assertion; the normal-`/tmp` retry passed. Its unchanged +automatic-spelling case passed the serial retry below. The task-created temporary +cache was subsequently moved, recoverably, to `build/focus-temp-retained/`. + +`full-suite-final.log`: 4782 passed, 62 existing skips, 13 failed. This run's +compilation predates the final modal ownership correction; that failure passes +in both later dialog runs. The remaining failures were the unchanged spelling +projection's 5-second performance assertion (5.039 seconds), browser writer +fixture, two asset-process deadlines, automatic spelling's existing deadline, +and seven History fixture cases. Serial projection/browser/dialog retries pass +all 111 as above. The following unchanged serial retry passed the entire asset +and spelling suites; only the seven History cases remained (98 passed, 7 failed): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/local_history_store_test.dart test/src/asset_ingestion_service_test.dart \ + test/src/spelling_workspace_test.dart --reporter expanded +``` + +See `serial-retries-two.log`. A direct build of the unchanged History fixture +succeeded (exit 0), but a further isolated suite retry still exceeded its +supervisor's existing 60-second compilation deadline: 52 passed, 7 failed in +`history-warm-retry.log`. The supervisor diagnostics verify retirement of its +owned child tree. The fixture imports History/store/format modules, not the +editor. This remains an unresolved verification limitation on this host; no +History implementation or deadline was changed. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build bundle --debug --no-pub \ + --target-platform=linux-x64 --asset-dir build/focus-history-assets \ + --depfile build/focus-history.d --target test/support/local_history_store_process.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/local_history_store_test.dart --reporter expanded +``` + +The superseded `focused-shipping.log` was stopped after it identified the +incorrect zero-offset expectation and root/view-scope integration regression +described above. It is retained for transparency; the corrected complete run +is `focused-complete.log`: all 484 tests passed. The additional beyond-cache +table/finite-JSON/exact-recreation case was then included in the final complete +rerun, `focused-cache-complete.log`: **all 485 tests passed**, with no skipped +new cases. Final analysis also passes in `analyze-final-cache.log`, and +`format-cache-check.log` records 14 files checked with no formatting changes. + +The final normal-entry-point release command succeeded (exit 0) in +`linux-release-complete.log`. The normal and instrumentation bundles are retained +separately during native checking; the normal application remains the shipping +artifact, not the alternate probe entry point. + +```sh +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t lib/main.dart +``` + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + test/src/wysiwyg_selection_lifecycle_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart test/src/busymark_dialogs_test.dart \ + test/src/busymark_design_test.dart --reporter expanded +/home/albert/flutter-sdk/bin/dart format --output=none --set-exit-if-changed \ + lib/src/app/busymark_dialogs.dart lib/src/editor/editor_focus_restoration.dart \ + lib/src/editor/source/source_editor.dart lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart lib/src/editor/wysiwyg/wysiwyg_session_state.dart \ + lib/src/workspace/presentation/workspace_screen.dart test/src/app_smoke_test.dart \ + test/src/busymark_dialogs_test.dart test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart test/src/wysiwyg_session_test.dart \ + test/support/desktop_focus.dart tools/focus_restoration_smoke.dart +git diff --check +python3 -m py_compile tools/focus_restoration_native.py +``` diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md index 5286bc5f..c5be6f06 100644 --- a/docs/development/export-verification.md +++ b/docs/development/export-verification.md @@ -142,7 +142,7 @@ do not certify physical-printer or assistive-technology behavior. The automatic `.github/workflows/flutter-linux.yml` workflow runs bounded X11 host, renderer and compiled visualization/PDF checks on pull requests to `main` -and `release/v0.6.3`, and pushes to `main`. The same job reuses its release build for dictionary and Wayland checks. It +and `release/v0.7.0`, and pushes to `main`. The same job reuses its release build for dictionary and Wayland checks. It calls `.github/workflows/flutter-linux-native.yml` for genuine strict-Snap checks; hundred-close and debug/profile campaigns remain opt-in. Each workflow declares its native 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/docs/development/inline-break-anchor-restoration.md b/docs/development/inline-break-anchor-restoration.md new file mode 100644 index 00000000..ef6520ed --- /dev/null +++ b/docs/development/inline-break-anchor-restoration.md @@ -0,0 +1,188 @@ +# Inline images beside existing hard breaks + +## Scope and independent reproduction + +Started from clean `d0844de` on 2026-10-22, following the README and Linux/native +development instructions. No applicable `AGENTS.md` was present. The official +Flutter 3.47.5 SDK at `/home/albert/flutter-sdk` and the enforced dependency +lockfile are unchanged. Heading/Enter splitting, Backspace and focus-restoration +logic are unchanged. The parser, serializer, UI architecture and dependency pins +are not modified by this correction. + +The report is confirmed through three independent production paths: + +1. Parse `a \n![](first.png)b\n` with `MarkdownParser`. Its paragraph text is + `a\nb`, with the empty-alt image at text offset 2. Call `insertHardBreak(id, 1)` + or `insertBlankLine(id, 1)`. Before repair the image stays at offset 2 rather + than advancing to 3 or 4. +2. The production editor's Hard Break and Blank Line toolbar buttons reproduce + the same failure. Real application/workspace tests independently reproduce + Shift+Enter and the Blank Line button before the correction. +3. The compiled official-engine Linux GTK application on an isolated X11/Xvfb + display reproduces the error with an actual XTest Shift+Enter event. The + retained `native-before/probe/failure-source.md` is: + + `"a \n![](first.png) \nb\n"` + + The image has incorrectly moved between the two breaks. The driver reports + failure, exits nonzero, and does not claim native acceptance for this run. + +Evidence is retained locally under ignored `build/hard-break-anchor-evidence/`. +It uses only synthetic fixtures and isolated profiles, not personal documents. + +## Cause and narrowly scoped repair + +`_insertHardBreaks` already knows the clamped insertion offset and exact length. +Its style-range remapping uses those coordinates, but semantic-anchor restoration +previously discarded them and inferred a common prefix/suffix from old/new text. +For `a\nb` → `a\n\nb`, the common prefix includes the *existing* newline. That +plausible text diff places the insertion after the old break instead of at the +actual caret. An anchor at offset 2 is consequently classified as preceding +the insertion and left in the wrong place. Consecutive breaks make the same +ambiguity possible over a longer span. + +The command now passes `(offset: safeOffset, length: count)` into +`_restoreSemanticInlineAnchors`. Known insertions use those coordinates directly; +they do not infer the edit from text. Other callers retain their existing +text-input inference and heading-specific behavior. Wrapper normalization and +the established boundary rule are unchanged: an object exactly at the insertion +point stays before the inserted breaks; an object after it advances by the +inserted length. Objects remain in order, exactly once, with their formatting +and attributes intact. No additional production defect was established in this +scoped investigation. + +## Regression evidence + +- The unmodified existing structured suite passed **226 tests** + (`baseline-structured.log`). +- The final new matrix against an isolated archive of `d0844de` has **38 failing + regressions and 36 passing controls** (`before-final-regressions.log`). Only + the test file was overlaid; the original controller blob is + `35b3fa803e6ccc716472da4442f7c8642e359d73`, matching the archived commit exactly. +- The separate real-workspace regression failed both commands before the fix + (`before-workspace.log`), with image offset 2 instead of 3/4. +- After repair, all **76 focused regressions/controls pass** (`after-focused.log`). + This includes 72 parser/controller cases, two local-editor widget cases and + two real-workspace cases. Coverage includes CommonMark, GFM and Writerside + Markdown; single/consecutive existing breaks; insertion on either side of + breaks; multiple images at a shared offset; images before, at and after the + caret; strong/emphasis contexts; source rebinding and reparsing; single-step + local/external undo/redo; focus/caret; view switching; actual save and reopen. + +Tests inspect each leaf image's actual text offset, destination, attributes and +enclosing inline formatting, not only the image count or a saved session. Two +equivalent adjacent formatting wrappers may be coalesced without changing these +semantics. Preliminary exploratory tests incorrectly equated that wrapper +grouping with object identity, used one invalid emphasis fixture, and assumed +single paragraph-boundary breaks survive reparsing. Those assumptions were +corrected without production changes; the definitive before/after matrix uses +valid parsed formatting and interior insertions. It does not change the existing +blank-paragraph or boundary-break lifecycle. + +Focused commands (from the repository root unless noted): + +```sh +flutter pub get --enforce-lockfile +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/wysiwyg_structured_regression_test.dart test/src/app_smoke_test.dart \ + --name 'explicit line-break anchors|inline break anchors survive workspace' \ + --reporter expanded +# Inside build/hard-break-anchor-baseline (original commit, tests overlaid): +TMPDIR=/tmp flutter test --no-pub --concurrency=1 \ + test/src/wysiwyg_structured_regression_test.dart \ + --name 'explicit line-break anchors' --reporter expanded +``` + +## Native Linux acceptance + +The existing structural acceptance driver has an opt-in `inline-breaks` scenario; +it does not run or alter the prior heading/list/focus scenarios. It uses the real +application, workspace, editor, publication and save/close/reopen workflows. +Both default Shift+Enter and an actual pointer click on Blank Line are tested. +The runner establishes private XDG directories before private D-Bus/Xvfb startup +and injects events only into its own PID-owned window. + +The eight cases cross Markdown/Writerside Markdown, existing/consecutive breaks, +and Hard Break/Blank Line. Each checks the actual document mode, live object +positions/formatting, focus/caret, exactly one undo operation, native undo/redo, +actual saved bytes, and the reopened inline tree. Saved Markdown and screenshots +are retained per case; screenshots are not substituted for structural assertions. +The simple tracked fixture uses the equivalent backslash hard-break spelling to +avoid trailing-space warnings; controller/widget regressions also test the +two-space spelling from the reproduction. + +```sh +TMPDIR=/tmp flutter build linux --debug --no-pub \ + -t tools/structural_editing_smoke.dart +python3 tools/structural_editing_native.py \ + build/hard-break-anchor-native-bundle/busymark \ + build/hard-break-anchor-evidence/native-final inline-breaks +``` + +The bundle was copied from `build/linux/x64/debug/bundle` after the repaired +driver build. Output directories must be new on each run. Both `native-after` +(two-space break fixture) and `native-final` (final tracked backslash fixture) +passed **all 88 checks across eight scenarios**. Each exited naturally with code +0; both ownership reports have `ok: true`, `cleanupRequired: false`, and no +unresolved survivors. The original expected-failure run exited 1 and also left +no unresolved descendants. No forced cleanup is counted as acceptance. + +The retained bundle's debug engine matches the official SDK engine byte for byte +(SHA-256 `7a5698608a3c79ad3f123a74bce8074339ffa1a83c896d73ae853c9f92a43cc3`). +This campaign covers X11/Xvfb with the official debug GTK engine, not Wayland, +Writerside XML native interaction, or a separate uninstrumented release-GUI +campaign. The normal release build uses `lib/main.dart`, not the driver. + +## Final verification + +- `affected.log`: **811 tests passed** in 2m30s, no skips/failures. +- `after-focused.log`: **76 tests passed** in 18s, including both real-file + workspace/save/reopen tests. +- `analyze.log`: `flutter analyze --no-pub` found no issues (105.8s). +- `format.log`: four changed Dart files checked, zero formatting changes. +- Python compilation passed; `tooling-tests.log`: all **19** owned-process + tooling tests passed. The fixture-report rejection message is from an + intentional negative test, not a failed test. +- Dependency pins and the official SDK's tracked tree are unchanged. +- `linux-release.log`: the normal Linux release build passed, using + `lib/main.dart`. Its `libapp.so` SHA-256 is + `623df5ac13a3eeaed4d4129f45be2f898df589cffb81d834fd3f437f9cac1cd4`. + The packaged release engine matches the official SDK release engine (SHA-256 + `d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`). +- `full-suite.log`: **5,109 passed, 62 existing skips, one failure**, in 17m38s. + `general Markdown fixture finishes automatic rich spelling` exceeded its + existing deadline while its status was still `checking` (catalog available). + This exact failure also appears in the retained pre-change + `build/structural-evidence/full-suite.log` and the earlier committed repair + report. No spelling/test-deadline code changed. The unchanged isolated retry + passed in **14s** (`spelling-retry.log`); no assertions were weakened and no new + regressions were skipped. The full run is not reported as an all-green pass. + +All `flutter` / `dart` commands below use `/home/albert/flutter-sdk/bin/`. + +```sh +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/busymark_document_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/writerside_authoring_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/writerside_inspector_session_test.dart --reporter expanded +dart format --output=none --set-exit-if-changed \ + lib/src/editor/wysiwyg/wysiwyg_document_controller.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/app_smoke_test.dart tools/structural_editing_smoke.dart +flutter analyze --no-pub +python3 -m py_compile tools/structural_editing_native.py +python3 -m unittest tools/test_owned_processes.py tools/test_run_owned_fixture.py +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp flutter build linux --release --no-pub -t lib/main.dart +TMPDIR=/tmp flutter test --no-pub --concurrency=4 --reporter expanded +TMPDIR=/tmp flutter test --no-pub --concurrency=1 \ + test/src/spelling_workspace_test.dart \ + --name 'general Markdown fixture finishes automatic rich spelling' \ + --reporter expanded +git diff --check +``` + +No push or publication is part of this work. diff --git a/docs/development/linux-engine.md b/docs/development/linux-engine.md index bcd93ee5..ac81e55a 100644 --- a/docs/development/linux-engine.md +++ b/docs/development/linux-engine.md @@ -25,7 +25,7 @@ Standalone smoke checks use disposable D-Bus sessions to retire desktop helpers. `Flutter Linux` automatically runs bounded GTK/WebKit, compiled visualization/PDF, dictionary installation/offline restart, Wayland and strict-Snap build/install -checks for PRs to `main` and `release/v0.6.3`, and pushes to `main`. It reuses +checks for PRs to `main` and `release/v0.7.0`, and pushes to `main`. It reuses `Linux native acceptance` with `run-stress: false`. Run that workflow manually with `run-stress` enabled for hundred-close, compiled document/media and debug/profile campaigns. See [compiled application checks](application-smoke-tests.md). diff --git a/docs/development/pr59-merge-integration.md b/docs/development/pr59-merge-integration.md new file mode 100644 index 00000000..9b462991 --- /dev/null +++ b/docs/development/pr59-merge-integration.md @@ -0,0 +1,258 @@ +# PR 59: editor and document-history integration + +The merge combines `fix/editor-view` at `3007fa1` with +`release/v0.7.0` at `77a5b5a` (PR 58). The starting worktree was clean. +The five textual conflicts were in the Source editor, rich editor, and their +Source, selection-lifecycle and session suites. Both branches' test cases are +retained; the three conflicted test files lose no test declarations from either +parent. Dependency manifests, Flutter 3.47.5 and the official engine are unchanged. + +## Integration decisions + +- Document-owned Undo/Redo and monotonic, bounded typing groups remain the + authority. Source editing epochs, publication preconditions, fresh input focus + nodes on document changes, and immediate history synchronization are retained. +- Source's document-keyed input is wrapped in both `DocumentUndoActions` and the + existing editor focus/scroll protection. Composition lifecycle observation uses + the visible editing value; stale-input validation still uses the full value. +- Rich history restores model and historical selection synchronously, including + a traversal followed by input before the next parent frame. Viewport restoration + remains separately deferred and guarded by document/request/selection/navigation + identity and focus ownership. A competing control or inactive application is + not permission to focus an editor. +- The hidden table-range input keeps document Undo/Redo actions and the central + guarded focus handoff. The release branch's independent child autofocus is not + reintroduced. +- Every reconstructed rich session retains `viewportOffset`, + `viewportEditingOffset` and `viewportLayoutSignature`, together with source + bindings. Structural-editing, passive menu/dialog and selection protections are + preserved. +- Automatically merged workspace publication still checks live buffer identity, + source and revision, and caches only an accepted committed projection. Local + History keeps the release branch's independent retained traversal obligations, + retry/recovery, format preservation and retention-safe protection renewal. + +## Independently reproduced integration failures + +Evidence is retained locally in `build/pr59-merge-evidence/` (ignored artifacts). +The following tests first failed on the combined implementation before their +targeted corrections; no assertions were weakened. + +| Failure | Evidence and regression | Correction | +| --- | --- | --- | +| Source loses passive scroll protection after a document switch. | `integration-before.log`: both wrap variants drift from 1800 to 1661.7494 logical pixels after a real Linux FocusManager suspend/resume. The new Source tests assert every frame within 0.1 pixel and deliver input without refocusing. | A retired wrapper clears its shared scroll controller hook only if it still owns that hook. This preserves the fresh document's input client and suppression callback. | +| Rich pre-edit history records the post-edit caret. | `integration-before.log` and `integration-extra-before.log`: expected offset 4, actual 5 in the clipped-code-block test. | Report the known pre-edit selection to shared history without subsequently overwriting it with a live post-input capture. Local snapshots are captured before mutation but recorded only after acceptance. | +| Table-cell history drops the pixel viewport anchor. | `integration-extra-before.log`: expected `viewportOffset` 2242, actual 0. The code/table session tests also assert the signed editing offset and layout signature. | Copy all viewport fields in the pre-edit session constructor. | +| Rejected structural input adds history and destroys Redo. | The existing rejected-newline regression fails in `affected-initial.log`; the new edit/Undo/reject/Redo case fails in `integration-extra-before.log` (expected zero snapshots, actual one). | Keep pre-edit session reporting separate from recording an accepted local undo operation. Rejected newline input remains atomic. | +| Rejected boundary Backspace also destroys Redo. | `backspace-before.log`: attributed sole-list-owner Backspace leaves source unchanged but adds an undo snapshot (expected zero, actual one). | Capture before the command, then record only when the controller accepts the boundary edit, matching Enter's transaction order. | + +`integration-after.log` passes all seven integration checks, including the +existing rejected-newline test. The Source tests verify new document focus/input +ownership, prolonged inactivity, independent vertical/horizontal reading position, +correct insertion and deliberate reveal. The rich tests inspect the authoritative +history entry before a frame. Rejection tests preserve source/publication count, +Undo count and the existing Redo result. + +### Non-editable selection endpoint found by the full suite + +The full clipboard suite exposed another confirmed focus defect: a saved range +ending at a thematic break never settles on mount. The same loop is reachable by +ordinary Select All. `_focusDocumentSelectionExtent()` schedules another guarded +handoff whenever a non-editable endpoint has no auxiliary input, although only a +table mounts that input. A thematic break therefore schedules frames forever. + +This defect predates the merge. The existing clipboard regression fails in an +unchanged `git archive 3007fa1` checkout (`nonfield-parent-editor.log`) and passes +in an unchanged `git archive 77a5b5a` checkout (`nonfield-parent-release.log`). +Both use the pinned SDK and offline, lockfile-enforced dependencies. The merged +implementation independently fails the same regression +(`nonfield-paste-isolated.log` and `full-batch-2-before-nonfield-fix.log`). + +Retries now apply only to tables, matching the actual auxiliary-input lifetime. +Other non-editable endpoints keep document-command focus as on the release +branch. The table handoff retains all lifecycle/owner/request safeguards and has +no independent autofocus. No selection, viewport, source or history is changed +to stop the loop. + +Four new lifecycle cases exercise both range directions and local/shared +history; a fifth uses actual Select All and Paste keys. Before correction, the +two forward restoration cases fail and both reverse controls pass +(`nonfield-new-before.log`); keyboard Select All also fails +(`nonfield-selectall-before.log`). The existing clipboard case plus all five +new cases pass afterward (`nonfield-after.log`, six tests in seven seconds). +They verify stable rendered geometry, range endpoints, neutral source/history, +command ownership, subsequent paste/input without refocusing, and Undo/Redo. +The original clipboard regression remains unchanged. + +## Verification + +The pinned SDK is `/home/albert/flutter-sdk`. Dependency resolution used +`flutter pub get --enforce-lockfile` and changed neither manifest. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_session_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + --name 'merged |rejected text-input list exit' + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/wysiwyg_session_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/writerside_authoring_ui_test.dart test/src/busymark_dialogs_test.dart \ + test/src/app_smoke_test.dart test/src/document_edit_history_test.dart \ + test/src/local_history_store_test.dart test/src/local_history_workspace_test.dart \ + test/src/nextcloud_workspace_test.dart + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=1 --reporter expanded test/src/local_history_store_test.dart + +mapfile -t pr59_dart_files < <(git diff --cached --name-only --diff-filter=ACMR -- '*.dart') +TMPDIR=/tmp /home/albert/flutter-sdk/bin/dart format \ + --output=none --set-exit-if-changed "${pr59_dart_files[@]}" +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub + +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + CMAKE_BUILD_PARALLEL_LEVEL=2 TMPDIR=/tmp \ + /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub -t lib/main.dart +``` + +The focused run passes seven tests in nine seconds. The combined affected run +passes 1,408 tests, but seven process tests share one failed fixture compilation: +its 60-second compiler deadline expires during concurrent AOT/analysis work. +The failure is retained in `affected-after.log`; it is not counted as a passing +run. With no competing builds, the complete store suite passes all 73 tests in +72 seconds (`history-store-isolated.log`), including actual contention, independent +writers, stale identities and failure cleanup. No compiler or operation deadline +was increased, and no test was skipped. + +Formatting checks all 22 Dart files with zero changes (`format-final.log`). +Final-code analysis reports no issues (`analyze-final-code.log`, 47.0 seconds), and the normal +release build passes again after the last correction (`linux-release-final-code.log`). The shipped engine and the +official SDK engine have identical SHA-256: +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1` +(`engine-final-code-sha256.log`). + +A broader formatting check also passes all 642 maintained Dart files with zero +changes (`format-repository.log`, 36.81 seconds): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib test tools packages +``` + +The same complete formatting check passes again after the non-editable endpoint +repair (`format-final-code.log`, 642 files, zero changes, 23.96 seconds). + +The first unpartitioned full-suite command was: + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=2 --reporter expanded +``` + +It received SIGTERM (exit 143) after 2,519 passes and 48 existing opt-in skips, +without a reported assertion failure (`full-suite.log`). It is not counted as a +completed verification. No project-owned compiler or test workers survived. +The complete corpus is rerun in four bounded batches: the large application +file alone, and all other files balanced by source size. The retained +`all-test-files.txt` and `test-batch-{0,1,2,3}.txt` manifests were checked to contain +all **229** discovered test files exactly once, with no omissions or duplicates. +Each batch uses the following command, substituting its manifest/log number: + +```sh +set -euo pipefail +mapfile -t pr59_test_files < build/pr59-merge-evidence/test-batch-0.txt +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + BUSYMARK_D2_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/d2" \ + BUSYMARK_TYPST_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/typst" \ + BUSYMARK_TEST_SPELLING_ROOT="$PWD/build/spelling-test" TMPDIR=/tmp \ + /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + --reporter expanded "${pr59_test_files[@]}" 2>&1 | \ + tee build/pr59-merge-evidence/full-batch-0.log +``` + +The installed D2/Typst executables, Poppler tools and already prepared spelling +dictionaries enable the optional checks as well. No dependencies are upgraded, +test deadlines changed, or test assertions bypassed. + +The first 76-file clipboard/session batch found the non-editable endpoint failure +described above (1,765 passes, one failure). Its before log is retained. All four +batches are rerun on the corrected production implementation; earlier runs are +not substituted for final-code results. + +The final-code results (`full-batch-0.log` through `full-batch-3.log`, summarized +and coverage-checked in `full-suite-summary.json`) are: + +| Batch | Files | Passed | Conditional skips | Runtime | +| --- | ---: | ---: | ---: | --- | +| 0 | 1 | 259 | 0 | 5m50s | +| 1 | 76 | 1,614 | 1 | 8m35s | +| 2 | 76 | 1,766 | 0 | 3m41s | +| 3 | 76 | 1,736 | 0 | 8m21s | +| Total | 229 | 5,375 | 1 | | + +Every batch exits zero, without an assertion failure. The single conditional +case requires `BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY`. The complete two-test +`native_writerside_dialog_source_test.dart` file is then run under owned +Xvfb/private D-Bus with that variable set to its disposable display: both tests +pass, including the previously skipped case (`native-gtk-dialog-final.log`, +`/tmp/bm59-xDlujt/gtk-dialog-final/tests.log`). Its ownership report exits zero +with no forced cleanup or unresolved descendants. All discovered cases are thus +exercised; none of the new regressions is skipped. + +## Native Linux evidence + +Native artifacts are retained under `/tmp/bm59-xDlujt/`; summaries and build logs +are in `build/pr59-merge-evidence/`. All cases use disposable documents, isolated +XDG profiles established before their private D-Bus broker, the official release +engine, and the repository's owned-process supervisor. No installed application +or host display/session is used. Probe bundles are kept separately; the normal +`lib/main.dart` bundle is restored at `build/linux/x64/release/bundle/`. + +| Case | Result and evidence | +| --- | --- | +| Document history, X11/Xvfb | `history-x11/`: 100 checks pass using XTest keyboard input through GTK. Covers exhausted history, repeated traversal, mode/tab return, autosave, actual saved bytes, Local History restore, conflict reload, and displaced-source recovery in Source and Editor. | +| Editor, XWayland/Mutter | `editor-min-x11/`: four Alt-Tab cycles including 15 seconds inactive, then native minimize/restore. All passive viewport/geometry/selection/source/history checks and subsequent input/reveal checks pass. Recorded frames include the hidden lifecycle. | +| Editor, Wayland/Mutter | `editor-wayland/`: four real Alt-Tab cycles including 15 seconds inactive. All passive state/geometry checks and native insertion/reveal checks pass without refocusing. | +| Source and Split, XWayland/Mutter | `source-x11/` and `split-x11/`: four real application-switch cycles, unchanged reading position and state, and native insertion at the old target without refocusing; all checks pass. | +| Table cell, XWayland/Mutter | `table-x11/`: the same real application-switch/input checks pass. | +| Deferred table-range selection, XWayland/Mutter | `extent-table-x11/`: actual native pointer drag establishes the document range; a newer control acquires focus before the deferred handoff. Four application-switch cycles preserve range/geometry/source/history, and subsequent native input reaches only that newer control. All checks pass. | +| Delayed modal release, XWayland/Mutter | `dialog-x11/`: a production Navigator dialog is dismissed programmatically with its barrier release gated. Release while inactive leaves the newer owner intact; resumed native input reaches it without editing or scrolling the document. All checks pass. This does not claim native Escape handling or the GTK menu-result interleaving. | +| Structural editing, X11/Xvfb | `structural-x11/`: 130 checks pass for Markdown/Writerside headings, immediate Enter/Backspace inverse, descendants in all native list kinds, inline objects/formatting, one-operation history, view transitions and saved/reopened bytes. | +| Inline images beside existing breaks, X11/Xvfb | `inline-breaks-x11/`: 88 checks pass for both one/two-break insertion commands, existing/consecutive newlines and Markdown/Writerside. Verifies exact live object positions, attributes, formatting, undo/redo and reopened bytes/objects. | +| Shipping application, X11/Xvfb | `shipping-close/results/summary.json`: the normal `lib/main.dart` executable starts and exits naturally through WM_DELETE_WINDOW, with clean shutdown and no surviving descendants. The probe verifies the actually mapped official engine through its retained file descriptor; zero failures. | + +Each successful ownership report has exit code zero and no unresolved descendants. +The structural probes close through the production close listener. The document +history probe exits after its explicit clean-shutdown checkpoint. Ordinary focus +controls are retired after their measurements; they are not natural-shutdown +acceptance claims. Both deferred-focus probes exit naturally with status zero. +`frames.json` records every rendered focus-test frame with +0.1-logical-pixel passive tolerances; it does not log private document contents. + +The initial `editor-x11/` **full** probe is not a pass: its native window lookup +cannot establish an unmapped normal window after minimize, before the chrome +reactivation stage. The separate maintained `minimize` scenario passes and +records the real hidden lifecycle. Window-chrome reactivation remains unverified +by that failed full attempt. Neither its prerequisite nor product assertions +were weakened. + +After correcting the non-editable endpoint, the focus entry point is rebuilt +from final source (`build-focus-final-code.log`) and the native table-range race +is repeated in `extent-final-x11/`: every passive/selection/history and newer +control input assertion passes. The application exits naturally with code zero, +and its owned-tree report verifies complete retirement. The shipping bundle is +then rebuilt from `lib/main.dart`; no probe entry point is left in the normal +bundle directory. + +Final-code Wayland acceptance also passes (`native-wayland-final-code.log`, +`editor-final-wayland/`): four application switches including 15 seconds inactive, +unchanged reading geometry/state, and native insertion at the original target +without refocusing. The final shipping executable passes the owned X11 natural +close check (`native-shipping-final-code.log`, `shipping-final-close/`), including +the mapped official engine verification. Both supervisors exit zero with no +unresolved descendants. The earlier window-chrome observation limitation remains +as recorded above. diff --git a/docs/development/structural-editing-restoration.md b/docs/development/structural-editing-restoration.md new file mode 100644 index 00000000..e08fcb29 --- /dev/null +++ b/docs/development/structural-editing-restoration.md @@ -0,0 +1,291 @@ +# Structural Enter, split and merge repair + +## Baseline and execution paths + +Reviewed the clean `0c55c7f` checkout on 2026-10-22. No applicable `AGENTS.md` +was found. The README and Linux/application/Writerside development verification +instructions apply. The official `/home/albert/flutter-sdk` is Flutter 3.47.5, +framework `6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine revision +`af7e796e16`. `flutter pub get --enforce-lockfile` succeeded; dependency/runtime +pins are unchanged. No engine, SDK, native UI or source-editor patch is used. + +The supplied attachment itself is not present in this checkout. The tracked +`test/fixtures/structural_editing/header_test_text.md` is the requested minimal +`## About the job` reproduction, not a claimed copy of an unavailable attachment. + +Production `MarkdownParser` / `MarkdownAstAdapter` create the editing document. +`BusyMarkWysiwygEditor` handles physical Enter with `applyEnterAt`; field text +input and plain-text paste reach `replaceBlockTextWithParagraphs`. Document-range +replacement uses `replaceTextSelectionWithText`. The controller's Markdown +serializer publishes source; the editor then calls `rebaseCommittedSource` and +the workspace updates its real buffer/preview. Local editor history stores +semantic snapshots; workspace history stores source and session state. Real-file +workspace tests exercise external history, view changes, saving and reopening. + +Evidence is retained locally under ignored `build/structural-evidence/`. The +separate `build/structural-baseline/` is an archive of the original commit with +only the new tests overlaid. Its production controller blob is exactly +`0159079dd431b301ce7548954ec713d57c2b856f`, matching the original commit. It uses +the same locked dependencies and official SDK. Neither artifacts nor fixtures +contain private document content. + +## Findings and disposition + +| Finding | Independently observed failure | Repair / regression | +| --- | --- | --- | +| Heading-start Enter | The original H1–H6 becomes an empty heading and a new paragraph receives its text/identity. Controller and actual key-event widget failures. | Shared position-aware split ownership; the original heading stays on the right, with its complete inline tree, attributes and editing ID. `structural boundaries … retains heading identity`, workspace/native cases. | +| Leading newline through text input/paste | Same corruption through the independent reconstruction path. | Both routes use the shared fragment policy; clipboard tests exercise real text input and plain-paste handlers, focus, subsequent typing and undo/redo. | +| Prefix versus entire selection | Prefix deletion should leave the remaining heading intact; whole-heading replacement must not preserve an untouched heading. A newline followed by identical text has the same string diff as an insertion. | Staged selection Enter and actual pre-edit field selection distinguish these cases. The accepted-selection cache is updated after ordinary edits, cleared/pruned with its controllers, and is not an IME replay mechanism. `range replacement newline`, `identical suffix`, `stale full-heading selection context`. | +| Empty preceding paragraph / nested owner | Backspace adopts the empty paragraph's kind, dropping heading semantics. Inside a quote it cannot find the sibling at all. | Only genuinely empty, unauthored text paragraphs with no objects/children are removable. Keep the current heading intact; resolve real structural siblings, not the synthetic outline path. Inverse H1–H6, nested quote and authored/semantic-empty controls. | +| Writerside heading attribute leak | `collapsible`, `default-state` and the authored-attribute snapshot reach the new paragraph and serializer. | New paragraphs get fresh paragraph-owned attributes; all heading attributes remain with the retained heading. No blanket removal of metadata from other block kinds. Start/middle/end, serialization/rebase/reparse coverage. | +| Empty list parent loses descendants | Clearing just the parent caption and pressing Enter or supplying a newline replaces the item with an empty paragraph, discarding children and grandchildren. Empty-alt inline content can also disappear. | List exit requires safely discardable content: no subtree, semantic objects or authored item properties. Otherwise split into valid list items, retaining the original subtree exactly once. Existing `allowListExit: false` whitespace-paste protection remains covered. | +| Leading list split misowns children | The new empty left item takes the old subtree while the parent text moves right. | Original parent ID, authored state and children stay together on the right; new left item owns no subtree. Ordered markers advance according to the existing increment rule. CommonMark/GFM-style lists and Writerside native ownership/rebinding regressions. | +| Zero-width objects lost | Newline reconstruction, Insert Hard Break and Insert Blank Line drop embedded empty-alt images. | Rebuild text with semantic anchors once, then partition the actual inline tree. Preserve destinations, attributes, wrapper formatting, ordering, and boundary offsets, including multiple anchors at one offset. Leading heading objects stay with that heading. | +| Non-atomic local Enter | A full selection in the sole member of an attributed native list is deleted before list exit returns null; the field/model diverge and history changes. | Stage deletion plus Enter, publish once on success, and record history only after acceptance. Consume rejected physical Enter without a text-input fallback. | +| Rejected input fallback | The field's newline path returns null for protected list exit, then publishes the same input as an ordinary local edit. | Explicitly distinguish “no structural newline” from rejection; restore the last valid model selection/text, with no publication or undo entry. | +| Empty task serialization | End-of-item Enter emits `- [ ]` without required whitespace. The pinned parser reparses the empty task as an ordinary list item and Writerside rebinding can lose editing IDs. | Emit and preserve the required trailing space for an empty task, without adding visible or semantic filler. Parser/save/rebase and native-list owner tests. No parser/dependency patch. | +| Nested checkbox ownership | Native saved source lost the checked/unchecked markers on children when a task parent was edited. The original AST adapter recursively extracted/removed checkboxes from descendant lists; it could also promote an unmarked parent to a task. | Extract/remove only the owning item's direct/caption-paragraph checkbox, matching the pinned package AST. All 18 tight/loose, unordered/ordered/task-parent cases fail against the original adapter. Descendant kinds and checked states survive publication and reopening. | +| Formatted semantic anchors | Ordinary typing already retained zero-width images but split their strong wrapper into adjacent runs. Serialization emitted invalid repeated delimiters, losing formatting and inserting literal asterisks on reopen. | Rejoin equivalent adjacent wrappers after anchor restoration using the existing normalization helper. A genuinely parsed formatted-image fixture establishes this independently of newline operations. | +| Read-only history mutation | Physical Enter and the hard/blank-line toolbar callbacks record history before source publication rejects the mutation. | Reject these operations before modifying the model or history. Copy/selection and arrow navigation remain available. Independent original-code keyboard and toolbar failures. | +| Protected/generated endpoints | Direct controller calls report structural success for protected content (or change generated/source-only projections). | Validate structural endpoints and owners before mutation; reject table-cell block splitting/merging, which has a dedicated editing path. This is controller-level evidence, not a claim that the UI exposed every direct call. | + +### Leads not adopted as production defects + +- Changing `_splitKindFor()` globally to return headings would break correct + middle/end behavior. It still maps headings to paragraphs. The full matrix + explicitly preserves middle/end splitting and the existing empty-heading + end-split behavior in all three Markdown modes. +- The proposed stale-property-source failure did **not** reproduce through the + normal property publication → `rebaseCommittedSource` → nested structural edit + sequence. `rebaseCommittedSource` clears property-only edit records and binds + current source metadata. The passing quote/property regression is retained; + `_replaceBlockWithMany` was not broadly rewritten to fit that diagnosis. +- A zero visible-text length alone is not proof of an empty block. Semantic-only + paragraph/list and authored-paragraph controls explicitly prevent that shortcut. +- Blank-only paragraphs still intentionally disappear as distinct semantic + paragraphs when reparsed. The existing document tests remain unchanged. + +## Repair boundaries + +Only the existing WYSIWYG split/merge/input handlers, semantic-anchor restoration, +empty-task serializer and Markdown AST checkbox ownership are changed. New +fragment ownership is shared between physical and text-input +splits. The original owner receives its children and authored attributes; a newly +inserted ordinary paragraph does not inherit heading identity, collapsibility, +or a stale authored source record. Source spans/raw fragments are not copied onto +new structural blocks. Required whitespace/session metadata is still generated +through the existing helpers. + +An inserted hard break is still a hard break inside one block, not a paragraph +split. Explicit navigation, typing and clipboard routes remain the existing +production routes. No changes to the focus-restoration repair, editor architecture, +virtualization, native UI, save format, dependency pins or blank-paragraph policy +were introduced. + +Native validation also caught an implementation omission in the new empty- +paragraph removal branch: after Writerside publication the retained heading was +clean, so source patching retained the old blank gap. The final branch reuses +`_withoutSourceSpan(..., dirty: true)` to invalidate that structural boundary. +`repair-inverse-publication-before.log` fails before that correction; +`after-inverse-publication.log` passes 199 tests afterward. This is recorded as +validation of the repair, not as a pre-existing review finding. + +## Automated evidence + +The unmodified baseline's existing document/structured suites passed **203 tests** +(`baseline.log`). Initial new boundary tests failed 84 cases while the original +middle/end controls passed (`before-boundaries.log`). Corrected key-based widget +finders then independently reproduced the physical/selection/rejection failures +(`before-integration.log`); the early bad-finder runs are not keyboard evidence. + +The isolated original checkout ran the completed early regressions: **111 failed, +8 passed** (`before-original-checkout.log`). The complete first/later H1–H6 matrix +plus the identical-suffix cases then yielded **151 failures, 38 passing controls** +(`before-original-full-heading-matrix.log`). Original read-only toolbar commands +failed both cases (`before-original-readonly-toolbar.log`). These are intentional +failing-before results, not skips or relaxed assertions. + +Additional focused before logs include `before-input-rejection.log`, +`before-range-replacement.log`, `before-empty-task-serialization.log`, and +`before-identical-suffix.log`. The 711-test affected run passed in +`affected-final.log` before the final explicit-selection-context refinement; +the refinement's full targeted matrix passed **197 tests** in +`after-final-selection-context.log`. The workspace/reparse control passed in +`workspace-transient-redo.log`; the final full checks are recorded below. + +The nested-checkbox addition fails all 18 cases in the original archive +(`before-original-nested-checkboxes.log`). With that repair the complete affected +set passed **736 tests** (`affected-complete.log`). Native image setup then exposed +an invalid emphasis fixture: the mid-word delimiter was literal, not bold. The +final fixture asserts `plainText == 'abc'` and actual strong ranges before any +edit. Its ordinary-edit/newline/hard-break/blank-line cases fail all 13 original +tests (`before-original-valid-formatted-objects.log`); all 13 pass with wrapper +coalescing (`after-valid-formatted-objects.log`). Earlier passing image runs must +not be used as evidence of genuine enclosing-format preservation. + +Commands use `/home/albert/flutter-sdk/bin/flutter` and its adjacent `dart`: + +```sh +flutter pub get --enforce-lockfile +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/busymark_document_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/writerside_authoring_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/writerside_inspector_session_test.dart --reporter expanded +TMPDIR=/tmp flutter test --no-pub --concurrency=1 test/src/app_smoke_test.dart \ + --name 'heading boundary survives workspace' --reporter expanded +``` + +The isolated original matrix command was: + +```sh +cd build/structural-baseline +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + --name 'structural boundaries|identical suffix' --reporter expanded +``` + +## Native Linux acceptance + +`tools/structural_editing_smoke.dart` opens the production application and its +workspace, not a substitute editor. `tools/structural_editing_native.py` creates +an owned X11/Xvfb display and private XDG profile *before* starting a private +D-Bus broker. Input uses XTest against only the caller's PID-owned window; +profiles, documents and saved-source captures are disposable. The existing +owned-process supervisor retains shutdown/cleanup observations. This campaign +does not claim Wayland coverage. + +The original native exploratory sequence failed its Backspace inverse check +**after external undo/redo** (`native-x-initial`). A new production-workspace +widget measurement established that external history reparses source and has +already removed the transient blank paragraph (`workspace-transient-redo.log`). +There is no preceding paragraph left to remove. The native check was corrected +to test the direct inverse immediately, and to test source/heading restoration +separately across undo/redo. The failed attempt is retained; it was interrupted +through its owned supervisor, with no unresolved survivors. No production +change was made to force blank paragraphs to survive reparsing. + +Two subsequent attempts correctly rejected their Writerside-mode prerequisite: +the harness was calling `openPath(file)`, which selects a standalone workspace. +The final harness explicitly opens the project directory, then opens its topic +using the production `openActiveFile` workflow. No production mode-routing +change was made. Native close can reject a captured immutable buffer when a +session report replaces it during History flush; the harness records that +observation and retries only if ID, revision, saved bytes and clean state still +agree. `native-x-close/probe/markdown-close.json` records exactly that case, not +a source edit or an ignored protection. + +`native-x-inverse` verified the corrected Writerside inverse and saved heading +properties, then discovered the actual nested-checkbox source loss. After the +adapter repair `native-x-checkbox` passed both heading scenarios and all six +list scenarios, including subtree order, identities and checkbox counts. It +stopped at the invalid image fixture prerequisite; no image success is claimed +for that attempt. Every failed attempt and its ownership outcome is retained. + +### Final native result + +`native-x-formatted/probe/result.json` records **all 130 checks passed** across +nine scenarios: the minimal Markdown heading, the actual Writerside Markdown +project/topic, six unordered/ordered/task parent boundary cases (nonempty and +caption-cleared), and genuinely formatted embedded empty-alt images. + +Real GTK keyboard input exercises Enter, immediate Backspace, typing without +refocusing, Undo/Redo, default Shift+Enter, a configured F8 hard-break shortcut, +and Ctrl+S. A real native pointer click invokes Insert Blank Line. Focus and +caret assertions accompany editing. View transitions, actual saved bytes, +reopened semantic trees, subtree order, checkbox states, and image destinations/ +formatting are checked. The nine `*-saved.md` files are actual saved-source +captures; PNGs show rendered results and do not substitute for those checks. + +The final native app is the pinned official **debug** GTK build of the production +application with an acceptance driver. It exited naturally with status 0; +`native-x-formatted/ownership.json` has `ok: true`, `cleanupRequired: false`, and +no unresolved survivors. No natural-close success is claimed for failed attempts. +The final retained bundle is `build/structural-native-formatted-bundle/`. + +Run from the repository root, with the existing Node installation on PATH: + +```sh +TMPDIR=/tmp flutter build linux --debug --no-pub \ + -t tools/structural_editing_smoke.dart +cp -a build/linux/x64/debug/bundle build/structural-native-formatted-bundle +/usr/bin/python3 tools/structural_editing_native.py \ + build/structural-native-formatted-bundle/busymark NEW_OUTPUT_DIRECTORY +TMPDIR=/tmp flutter build linux --release --no-pub -t lib/main.dart +``` + +This verifies X11/Xvfb, not Wayland, Writerside XML native interaction, or a +separate uninstrumented release-desktop campaign. The normal release is built +separately, not left pointing at the driver. The native executable's RUNPATH is +`$ORIGIN/lib`; no `LD_LIBRARY_PATH`/`LD_PRELOAD` override was set. Its bundled +debug engine matches the SDK artifact byte-for-byte (SHA-256 +`7a5698608a3c79ad3f123a74bce8074339ffa1a83c896d73ae853c9f92a43cc3`). + +## Final verification + +- `affected-shipping.log`: **737 tests passed**, including all final parser, + publication-boundary and genuine-formatting cases, no new skips. +- `analyze-complete.log`: `flutter analyze --no-pub`, **no issues**, 44.7 seconds. +- `format-shipping.log`: formatter check over all **10 changed Dart files**, zero + changes. `python3 -m py_compile tools/structural_editing_native.py` succeeds; + `git diff --check` is clean. +- `owned-tooling-tests.log`: the existing process ownership/supervisor tests pass + **19 tests** without modifications. +- `linux-release-shipping.log`: the normal + `flutter build linux --release --no-pub -t lib/main.dart` succeeds. Node is the + already installed 24.13.0 executable under + `/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin`; + no installation or version/pin change was made. + +The shipping `libapp.so` SHA-256 is +`654afa080a96db8445b8777af0a55ed6da22697475b9ece1a1e8af689f615c9b`. +Its official release engine and the pinned SDK artifact both have SHA-256 +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`. + +The first broader run (`full-suite.log`, before the last parser/formatting +refinements) completed **5,013 passes, 62 existing skips and one failure**: +`general Markdown fixture finishes automatic rich spelling` exceeded its existing +deadline while still checking. The exact unchanged test passes in isolation +(`spelling-retry.log`, one pass in 15 seconds); no assertion/deadline was weakened. +The complete final-source rerun in `full-suite-shipping.log` passes **5,034 tests +with 62 existing skips and no failures**, in 15 minutes 9 seconds. The earlier +spelling deadline failure did not recur. No new regression was skipped. + +Final-source full-suite command: + +```sh +TMPDIR=/tmp flutter test --no-pub --concurrency=4 --reporter expanded +``` + +Final-source affected suites use the six-file command above. Additional checks: + +```sh +TMPDIR=/tmp flutter analyze --no-pub +python3 -m py_compile tools/structural_editing_native.py +python3 -m unittest tools/test_owned_processes.py tools/test_run_owned_fixture.py +git diff --check +``` + +The formatter command lists the four changed production Dart files, five changed +test files and `tools/structural_editing_smoke.dart`, using +`dart format --output=none --set-exit-if-changed`. SDK tracked files and +`pubspec.yaml`/`pubspec.lock` remain unchanged. Only this repair's files are +committed locally; nothing is pushed or published. + +Exact formatter command: + +```sh +/home/albert/flutter-sdk/bin/dart format --output=none --set-exit-if-changed \ + lib/src/editor/wysiwyg/wysiwyg_document_controller.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + lib/src/markdown/busymark_markdown_serializer.dart \ + lib/src/markdown/markdown_ast_adapter.dart \ + test/src/app_smoke_test.dart test/src/busymark_document_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + tools/structural_editing_smoke.dart +``` diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index c0593c74..1761dcb9 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.6.3'; +const busyMarkAppVersion = '0.7.0'; diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index d5365454..b447ea70 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1227,7 +1227,9 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { final FutureOr>> Function(BuildContext context) itemBuilder; final ValueChanged onSelected; - final VoidCallback? onCanceled; + + /// Identifies this presentation's trigger subtree for focus-return policy. + final ValueChanged? onCanceled; final bool transparent; /// Uses the theme's physical button elevation. @@ -1325,7 +1327,7 @@ class _BusyMarkHeaderPopupMenuButtonState if (selection != null) { onSelected(selection); } else { - onCanceled?.call(); + onCanceled?.call(context); } } } finally { diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 854b7e16..99188cd9 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -203,7 +203,7 @@ Future showBusyMarkModalDialog( Future _coordinateBusyMarkModal( BuildContext context, { required LinuxHeaderBarService? headerBarService, - required Future Function() showSurface, + required ({Future result, ModalRoute route}) Function() showSurface, }) async { final previousFocus = FocusManager.instance.primaryFocus; await acquireBusyMarkModalBarrier(headerBarService); @@ -211,17 +211,91 @@ Future _coordinateBusyMarkModal( await releaseBusyMarkModalBarrier(headerBarService); return null; } + ModalRoute? closingRoute; try { - return await showSurface(); + final surface = showSurface(); + closingRoute = surface.route; + return await surface.result; } finally { - await releaseBusyMarkModalBarrier(headerBarService); - if (previousFocus?.context != null && previousFocus!.canRequestFocus) { - previousFocus.requestFocus(); + final restoration = _ModalFocusRestoration(previousFocus, closingRoute); + try { + await releaseBusyMarkModalBarrier(headerBarService); + if (context.mounted && restoration.mayRestore) { + previousFocus!.requestFocus(); + } + } finally { + restoration.dispose(); + } + } +} + +/// Permission for this one closing surface, not a snapshot of primaryFocus. +/// A newer owner or lifecycle transition permanently supersedes its release. +class _ModalFocusRestoration with WidgetsBindingObserver { + _ModalFocusRestoration(this.previous, this.closingRoute) + : _previousScopes = {...?previous?.ancestors.whereType()} { + _current = _isResumed && _owns(FocusManager.instance.primaryFocus); + if (_current) { + _listening = true; + FocusManager.instance.addListener(_focusChanged); + WidgetsBinding.instance.addObserver(this); } } + + final FocusNode? previous; + final ModalRoute? closingRoute; + final Set _previousScopes; + bool _current = true; + bool _listening = false; + + bool get _isResumed => + WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; + + bool _owns(FocusNode? node) => + node == null || + node == previous || + _previousScopes.contains(node) || + (closingRoute != null && + node.context?.mounted == true && + ModalRoute.of(node.context!) == closingRoute); + + void _focusChanged() { + if (previous?.context?.mounted != true || + !_owns(FocusManager.instance.primaryFocus)) { + _invalidate(); + } + } + + void _invalidate() { + _current = false; + dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _invalidate(); + } + + bool get mayRestore { + // Honor a newer request even if FocusManager has not notified it yet. + FocusManager.instance.applyFocusChangesIfNeeded(); + return _current && + _isResumed && + previous?.context?.mounted == true && + previous!.canRequestFocus && + _owns(FocusManager.instance.primaryFocus); + } + + void dispose() { + if (!_listening) return; + _listening = false; + FocusManager.instance.removeListener(_focusChanged); + WidgetsBinding.instance.removeObserver(this); + } } -Future _showBusyMarkFlutterDialog( +({Future result, ModalRoute route}) _showBusyMarkFlutterDialog( BuildContext context, { required WidgetBuilder builder, Color? barrierColor, @@ -229,16 +303,15 @@ Future _showBusyMarkFlutterDialog( }) { final navigator = Navigator.of(context, rootNavigator: true); final themes = InheritedTheme.capture(from: context, to: navigator.context); - return navigator.push( - _BusyMarkDialogRoute( - context: context, - builder: builder, - themes: themes, - fixedBarrierColor: barrierColor, - initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), - barrierDismissible: barrierDismissible, - ), + final route = _BusyMarkDialogRoute( + context: context, + builder: builder, + themes: themes, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), + barrierDismissible: barrierDismissible, ); + return (result: navigator.push(route), route: route); } class _BusyMarkDialogRoute extends DialogRoute { 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/editor_focus_restoration.dart b/lib/src/editor/editor_focus_restoration.dart new file mode 100644 index 00000000..9dbd2200 --- /dev/null +++ b/lib/src/editor/editor_focus_restoration.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +/// One editor's focus and virtualized editable lifetime, independent of the +/// viewport. Search fields and other editors do not participate in this scope. +class BusyMarkEditorFocusScope extends InheritedWidget { + const BusyMarkEditorFocusScope({ + super.key, + required super.child, + this.documentId, + }); + + final String? documentId; + + @override + bool updateShouldNotify(BusyMarkEditorFocusScope oldWidget) => false; +} + +/// Intercepts both halves of EditableText's deferred focus reveal: its direct +/// controller scroll, then showOnScreen through its ancestors. The latter ends +/// the operation, so no timer or frame count is involved. A newer editing value +/// or explicit input wins even when Flutter coalesces it with the focus callback. +class BusyMarkEditorFocusRestoration extends StatefulWidget { + const BusyMarkEditorFocusRestoration({ + super.key, + required this.focusNode, + required this.controller, + required this.builder, + this.scrollController, + this.readOnly = false, + }); + + final FocusNode focusNode; + final TextEditingController controller; + final BusyMarkCaretScrollController? scrollController; + final bool readOnly; + final Widget Function(BuildContext, ScrollController) builder; + + static final _fields = Expando<_EditorFocusRestorationState>(); + + /// For purposeful commands that request focus without changing the caret. + static void reveal(FocusNode node) => _fields[node]?._allowReveal(); + + /// Keep one pending editing target mounted without requesting focus. A + /// deferred selection handoff may outlive the viewport cache while inactive. + static VoidCallback? retainPendingTarget(FocusNode node) => + _fields[node]?._retainPendingTarget(); + + static bool ownsDocumentFocus(FocusNode node, String documentId) { + final field = _fields[node]; + return field?.mounted == true && + (field?._scope?.widget as BusyMarkEditorFocusScope?)?.documentId == + documentId; + } + + static bool keepsEditingWhileInactive(FocusNode node) => + WidgetsBinding.instance.lifecycleState != null && + WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed && + (_fields[node]?._retainEditingTarget ?? false); + + @override + State createState() => + _EditorFocusRestorationState(); +} + +class _EditorFocusRestorationState extends State + with AutomaticKeepAliveClientMixin { + late BusyMarkCaretScrollController _scrollController; + late TextEditingValue _lastValue; + bool _pendingFocusReveal = false; + late final bool Function() _suppressFocusReveal = _shouldSuppressFocusReveal; + bool _intentionalFocus = false; + bool _retainEditingTarget = false; + final _pendingTargetRetentions = {}; + InheritedElement? _scope; + + bool _shouldSuppressFocusReveal() => _pendingFocusReveal; + + @override + bool get wantKeepAlive => + _retainEditingTarget || _pendingTargetRetentions.isNotEmpty; + + VoidCallback _retainPendingTarget() { + final request = Object(); + _pendingTargetRetentions.add(request); + updateKeepAlive(); + return () { + if (_pendingTargetRetentions.remove(request) && mounted) { + updateKeepAlive(); + } + }; + } + + @override + void initState() { + super.initState(); + _attach(); + FocusManager.instance.addListener(_focusOwnerChanged); + FocusManager.instance.addEarlyKeyEventHandler(_keyEvent); + } + + void _attach() { + _lastValue = widget.controller.value; + _scrollController = + widget.scrollController ?? BusyMarkCaretScrollController(); + _scrollController.suppressFocusReveal = _suppressFocusReveal; + BusyMarkEditorFocusRestoration._fields[widget.focusNode] = this; + widget.focusNode.addListener(_focusChanged); + widget.controller.addListener(_valueChanged); + if (widget.focusNode.hasFocus) _retainEditingTarget = true; + } + + void _detach(BusyMarkEditorFocusRestoration field) { + field.focusNode.removeListener(_focusChanged); + field.controller.removeListener(_valueChanged); + BusyMarkEditorFocusRestoration._fields[field.focusNode] = null; + // A document-keyed replacement can attach its new field before this old + // state is disposed. Do not retire the new owner's shared-controller hook. + if (identical( + _scrollController.suppressFocusReveal, + _suppressFocusReveal, + )) { + _scrollController.suppressFocusReveal = null; + } + if (field.scrollController == null) _scrollController.dispose(); + _pendingFocusReveal = false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scope = context + .getElementForInheritedWidgetOfExactType(); + } + + @override + void didUpdateWidget(BusyMarkEditorFocusRestoration oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.readOnly) _pendingFocusReveal = false; + if (oldWidget.focusNode != widget.focusNode || + oldWidget.controller != widget.controller || + oldWidget.scrollController != widget.scrollController) { + _detach(oldWidget); + _attach(); + } + } + + void _focusChanged() { + if (widget.focusNode.hasFocus) { + _pendingFocusReveal = !_intentionalFocus && !widget.readOnly; + _intentionalFocus = false; + _retainEditingTarget = true; + updateKeepAlive(); + } else { + _intentionalFocus = false; + } + } + + void _focusOwnerChanged() { + final owner = FocusManager.instance.primaryFocus; + final other = owner == null + ? null + : BusyMarkEditorFocusRestoration._fields[owner]; + if (_retainEditingTarget && + other != null && + other != this && + identical(other._scope, _scope)) { + // Keep only this editor's last editing target alive across application + // suspension, menus and dialogs. Do not retain every visited field. + _retainEditingTarget = false; + updateKeepAlive(); + } + } + + void _allowReveal() { + _pendingFocusReveal = false; + _intentionalFocus = !widget.focusNode.hasFocus; + } + + void _valueChanged() { + final value = widget.controller.value; + if (value.text != _lastValue.text || + value.selection != _lastValue.selection) { + _pendingFocusReveal = false; + } + // Composing-only platform lifecycle changes are not navigation. Never + // restore, fabricate or replay a composing range or a text-input message. + _lastValue = value; + } + + KeyEventResult _keyEvent(KeyEvent event) { + if (widget.focusNode.hasFocus && + (event is KeyDownEvent || event is KeyRepeatEvent) && + !{ + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + }.contains(event.logicalKey)) { + _allowReveal(); + } + return KeyEventResult.ignored; + } + + bool _consumeFocusReveal() { + final suppress = _pendingFocusReveal; + _pendingFocusReveal = false; + return suppress; + } + + @override + void dispose() { + FocusManager.instance.removeEarlyKeyEventHandler(_keyEvent); + FocusManager.instance.removeListener(_focusOwnerChanged); + _detach(widget); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Listener( + onPointerDown: (_) => _allowReveal(), + child: _CaretRevealBoundary( + consume: _consumeFocusReveal, + child: widget.builder(context, _scrollController), + ), + ); + } +} + +class BusyMarkCaretScrollController extends ScrollController { + bool Function()? suppressFocusReveal; + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) => _CaretScrollPosition( + physics: physics, + context: context, + oldPosition: oldPosition, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + suppress: () => suppressFocusReveal?.call() ?? false, + ); +} + +class _CaretScrollPosition extends ScrollPositionWithSingleContext { + _CaretScrollPosition({ + required super.physics, + required super.context, + super.oldPosition, + super.initialPixels, + super.keepScrollOffset, + required this.suppress, + }); + final bool Function() suppress; + + @override + Future animateTo( + double to, { + required Duration duration, + required Curve curve, + }) { + if (suppress()) return Future.value(); + return super.animateTo(to, duration: duration, curve: curve); + } + + @override + void jumpTo(double value) { + if (!suppress()) super.jumpTo(value); + } +} + +class _CaretRevealBoundary extends SingleChildRenderObjectWidget { + const _CaretRevealBoundary({required this.consume, required super.child}); + final bool Function() consume; + @override + RenderObject createRenderObject(BuildContext context) => + _RenderCaretRevealBoundary(consume); + @override + void updateRenderObject( + BuildContext context, + _RenderCaretRevealBoundary renderObject, + ) => renderObject.consume = consume; +} + +class _RenderCaretRevealBoundary extends RenderProxyBox { + _RenderCaretRevealBoundary(this.consume); + bool Function() consume; + @override + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (!consume()) { + super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + } +} diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 0ad226d7..a16d8832 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,6 +1,8 @@ +import '../document_edit_history.dart'; import '../../app/command_shortcuts.dart'; import '../../app/busymark_search_field.dart'; import '../link_destination.dart'; +import '../editor_focus_restoration.dart'; import '../wysiwyg/writerside_editing_adapter.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; @@ -72,8 +74,6 @@ typedef BusyMarkSourceSessionChanged = enum SourceSymbolAction { declaration, usages, rename } -var _sourceEditorUndoSessionSequence = 0; - class BusyMarkSourceEditor extends StatefulWidget { const BusyMarkSourceEditor({ super.key, @@ -96,6 +96,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 +147,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,8 +184,10 @@ class BusyMarkSourceEditor extends StatefulWidget { class BusyMarkSourceEditorState extends State { late BusyMarkSourceController _controller; - late final FocusNode _focusNode; - late final ScrollController _scrollController; + Object _editingEpoch = Object(); + late TextEditingValue _lastObservedEditingValue; + late FocusNode _focusNode; + late final BusyMarkCaretScrollController _scrollController; late final ScrollController _horizontalScrollController; late UndoHistoryController _undoController; final _sourceEditorKey = GlobalKey(); @@ -201,14 +209,17 @@ class BusyMarkSourceEditorState extends State { var _autocompleteSelection = 0; String _lastPath = ''; bool _horizontalCaretScheduled = false; + TextEditingValue _lastControllerValue = TextEditingValue.empty; bool _contentShrinkCorrectionScheduled = false; Timer? _searchDebounce; Timer? _foldRefreshDebounce; _ContinuousSourceEdit? _continuousSourceEdit; _SourceSessionSnapshot? _lastPublishedSession; bool _sessionPublicationScheduled = false; - final _undoSessionId = ++_sourceEditorUndoSessionSequence; - var _undoGroupSequence = 0; + Object? _sessionRestoreRequest; + late final DocumentUndoGrouping _undoGrouping; + late DocumentEditBase _editBase; + DocumentEditBase get publicationBase => _editBase; late final _SourceClipboardInsertionTarget _historyInsertionTarget; RichClipboardService get _clipboard => @@ -339,6 +350,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, @@ -346,7 +361,7 @@ class BusyMarkSourceEditorState extends State { _historyInsertionTarget = _SourceClipboardInsertionTarget(this); widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); - _scrollController = ScrollController(); + _scrollController = BusyMarkCaretScrollController(); _horizontalScrollController = ScrollController(); _undoController = UndoHistoryController(); _lastPath = widget.documentId ?? widget.filePath ?? ''; @@ -354,6 +369,8 @@ class BusyMarkSourceEditorState extends State { _restoreSessionState(); _lastPublishedSession = _sourceSessionSnapshot(); _syncSearchOptions(); + _lastControllerValue = _controller.value; + _lastObservedEditingValue = _fullEditingValue(); _controller.addListener(_handleControllerActivity); _scrollController.addListener(_scheduleSessionPublication); } @@ -383,25 +400,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 +486,28 @@ class BusyMarkSourceEditorState extends State { } void _handleControllerActivity() { + final visibleValue = _controller.value; + final lifecycle = WidgetsBinding.instance.lifecycleState; + final passiveCompositionChange = + visibleValue.text == _lastControllerValue.text && + visibleValue.selection == _lastControllerValue.selection && + visibleValue.composing != _lastControllerValue.composing && + (!_focusNode.hasFocus || + (lifecycle != null && lifecycle != AppLifecycleState.resumed)); + _lastControllerValue = visibleValue; + 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 || @@ -457,7 +515,9 @@ class BusyMarkSourceEditorState extends State { _cancelSharedSearchNavigation(); } _scheduleSessionPublication(); - if (widget.wordWrap || _horizontalCaretScheduled) { + if (passiveCompositionChange || + widget.wordWrap || + _horizontalCaretScheduled) { return; } _horizontalCaretScheduled = true; @@ -509,6 +569,7 @@ class BusyMarkSourceEditorState extends State { /// Reveals a full-document offset, including a destination inside a fold. void scrollToOffset(int offset, {bool updateSelection = true}) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); final textOffset = offset.clamp(0, _controller.fullText.length); final line = '\n'.allMatches(_controller.fullText.substring(0, textOffset)).length + @@ -536,6 +597,7 @@ class BusyMarkSourceEditorState extends State { required int startOffset, required int endOffset, }) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); _cancelSharedSearchNavigation(); _unfoldSourceRange(startOffset, endOffset); final start = startOffset.clamp(0, _controller.fullText.length).toInt(); @@ -740,9 +802,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 +812,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 +1018,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 +1091,147 @@ 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!( - _controller - .visibleOffsetToFullOffset( - visibleOffset, - ), - ), - 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, - ), - ], - onRefineWithAi: widget.onAiEdit == null - ? null - : () => unawaited(_runAiEdit()), - onCopy: () => - unawaited(_copyOrCutSource(cut: false)), - onCut: () => - unawaited(_copyOrCutSource(cut: true)), - onPaste: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.normal, - ), + child: BusyMarkEditorFocusScope( + documentId: _lastPath, + child: BusyMarkEditorFocusRestoration( + readOnly: DocumentReadOnlyScope.of(context), + focusNode: _focusNode, + controller: _controller, + scrollController: _scrollController, + builder: (context, caretScrollController) => 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: caretScrollController, + 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, ), - onPastePlainText: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.plainText, - ), + 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, ), - readPasteAvailability: - _readContextMenuPasteAvailability, + 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 + .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, + ), + ], + 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, + ), + ), + readPasteAvailability: + _readContextMenuPasteAvailability, + ), + onChanged: (_) => _handleSourceChanged(), ), - onChanged: (_) => _handleSourceChanged(), + ), + ), ), ), ), @@ -1701,6 +1786,7 @@ class BusyMarkSourceEditorState extends State { }); return; } + BusyMarkEditorFocusRestoration.reveal(_focusNode); if (match.hidden) { _unfoldSourceRange(match.fullStart, match.fullEnd); } @@ -1883,6 +1969,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 +2006,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 +2015,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 +2040,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(); } @@ -2035,6 +2172,8 @@ class BusyMarkSourceEditorState extends State { } void _restoreSessionState() { + final request = Object(); + _sessionRestoreRequest = request; final validKeys = {for (final region in _foldRegions) region.key}; _foldedRegionKeys ..clear() @@ -2051,14 +2190,24 @@ class BusyMarkSourceEditorState extends State { .toInt(), ); } + final documentId = _lastPath; + final controller = _controller; + final value = controller.value; + final source = controller.fullText; + final offset = widget.initialScrollOffset; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) { + if (!mounted || + !identical(request, _sessionRestoreRequest) || + documentId != _lastPath || + !identical(controller, _controller) || + controller.fullText != source || + controller.selection != value.selection || + !_scrollController.hasClients) { return; } + _sessionRestoreRequest = null; _scrollController.jumpTo( - widget.initialScrollOffset - .clamp(0, _scrollController.position.maxScrollExtent) - .toDouble(), + offset.clamp(0, _scrollController.position.maxScrollExtent).toDouble(), ); }); } @@ -2080,6 +2229,11 @@ class BusyMarkSourceEditorState extends State { } void _scheduleSessionPublication() { + if (_scrollController.hasClients && + _scrollController.position.userScrollDirection != + ScrollDirection.idle) { + _sessionRestoreRequest = null; + } if (_suppressSessionPublication || _sessionPublicationScheduled) { return; } @@ -2131,13 +2285,25 @@ class BusyMarkSourceEditorState extends State { void _applyFullEditingValue( TextEditingValue value, { - _SourceEditOrigin origin = _SourceEditOrigin.userTyping, + _SourceEditOrigin origin = _SourceEditOrigin.command, }) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); _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 +2325,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 +2497,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 +3169,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 +3246,20 @@ 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(); + _lastControllerValue = _controller.value; _controller.addListener(_handleControllerActivity); _resetUndoHistory(); WidgetsBinding.instance.addPostFrameCallback((_) { previous.dispose(); + previousFocus.dispose(); }); } @@ -4104,21 +4259,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 +4302,8 @@ class _SourceClipboardOperationTarget { required this.text, required this.selection, required this.composing, + required this.epoch, + required this.base, }); final String documentId; @@ -4161,9 +4314,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_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 8f46fe69..c136599d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -23,6 +23,7 @@ import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_video.dart'; import '../editor_text_context_menu.dart'; +import '../editor_focus_restoration.dart'; import 'wysiwyg_inline_controller.dart'; import 'wysiwyg_visualization_navigation.dart'; @@ -484,7 +485,9 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { ); } final renderedMath = - busyMarkWysiwygBlockContainsMath(block) && !focusNode.hasFocus + busyMarkWysiwygBlockContainsMath(block) && + !focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive(focusNode) ? Focus( focusNode: focusNode, child: GestureDetector( @@ -540,60 +543,68 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { selectionRange: selectionRange, child: KeyedSubtree( key: spellingEditableKey, - child: TextField( + child: BusyMarkEditorFocusRestoration( readOnly: DocumentReadOnlyScope.of(context), - key: ValueKey( - 'wysiwyg-field-$documentFilePath-${block.id}', - ), - controller: controller, - undoController: undoController, focusNode: focusNode, - enableInteractiveSelection: - !documentSelectionDragging, - maxLines: null, - minLines: 1, - textDirection: textDirection, - style: style, - cursorWidth: BusyMarkDocumentTextGeometry - .editableCursorWidth, - selectionHeightStyle: - BusyMarkDocumentTextGeometry - .selectionHeightStyle, - selectionWidthStyle: - BusyMarkDocumentTextGeometry - .selectionWidthStyle, - decoration: const InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hoverColor: BusyMarkLinuxPalette.transparent, - contentPadding: EdgeInsets.zero, + controller: controller, + builder: (context, caretScrollController) => TextField( + readOnly: DocumentReadOnlyScope.of(context), + key: ValueKey( + 'wysiwyg-field-$documentFilePath-${block.id}', + ), + controller: controller, + undoController: undoController, + focusNode: focusNode, + scrollController: caretScrollController, + enableInteractiveSelection: + !documentSelectionDragging, + maxLines: null, + minLines: 1, + textDirection: textDirection, + style: style, + cursorWidth: BusyMarkDocumentTextGeometry + .editableCursorWidth, + selectionHeightStyle: + BusyMarkDocumentTextGeometry + .selectionHeightStyle, + selectionWidthStyle: + BusyMarkDocumentTextGeometry + .selectionWidthStyle, + decoration: const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hoverColor: + BusyMarkLinuxPalette.transparent, + contentPadding: EdgeInsets.zero, + ), + contextMenuBuilder: documentSelectionActive + ? (context, editableTextState) => + const SizedBox.shrink() + : ( + context, + editableTextState, + ) => buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: + context.l10n.aiRefineWithAi, + onRefineWithAi: onRefineWithAi, + onCut: onCut, + onCopy: onCopy, + onPaste: onPaste, + onPastePlainText: onPastePlainText, + readPasteAvailability: + readPasteAvailability, + onCopyPlainText: onCopyPlainText, + readSpellingItems: spellingMenuReader, + onCheckSpelling: onCheckSpelling, + ), + onTap: onFocused, + onChanged: onChanged, ), - contextMenuBuilder: documentSelectionActive - ? (context, editableTextState) => - const SizedBox.shrink() - : (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: - context.l10n.aiRefineWithAi, - onRefineWithAi: onRefineWithAi, - onCut: onCut, - onCopy: onCopy, - onPaste: onPaste, - onPastePlainText: onPastePlainText, - readPasteAvailability: - readPasteAvailability, - onCopyPlainText: onCopyPlainText, - readSpellingItems: - spellingMenuReader, - onCheckSpelling: onCheckSpelling, - ), - onTap: onFocused, - onChanged: onChanged, ), ), ), @@ -699,7 +710,12 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final level = int.tryParse(block.attributes['level'] ?? '') ?? 0; return switch (block.kind) { BusyBlockKind.heading => busyMarkDocumentHeadingTextStyle(context, level), - _ when busyMarkWysiwygBlockContainsMath(block) && focusNode.hasFocus => + _ + when busyMarkWysiwygBlockContainsMath(block) && + (focusNode.hasFocus || + BusyMarkEditorFocusRestoration.keepsEditingWhileInactive( + focusNode, + )) => busyMarkDocumentCodeTextStyle(context), BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context), _ => busyMarkDocumentBodyTextStyle(context), @@ -1780,6 +1796,10 @@ class _TableBlockEditorState extends State<_TableBlockEditor> { layoutInfo.childPaintTransform, Offset.zero & layoutInfo.childSize, ); + // A retained editable can be outside the sliver's layout cache. Its paint + // transform is then zero: the input remains alive, but it has no overlay + // anchor until the table returns to the painted portion of the viewport. + if (!tableRect.isFinite) return const SizedBox.shrink(); final controlSize = BusyMarkSizes.iconButton; final rowRect = active == null || active.row >= _rowAnchorContexts.length ? null @@ -2245,7 +2265,10 @@ class _TableCellEditorState extends State<_TableCellEditor> { } if (mounted) { setState(() { - if (!_focusNode.hasFocus) { + if (!_focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive( + _focusNode, + )) { _sourceEditing = false; } }); @@ -2265,7 +2288,8 @@ class _TableCellEditorState extends State<_TableCellEditor> { } if (busyMarkWysiwygBlockContainsMath(cell) && !_sourceEditing && - !_focusNode.hasFocus) { + !_focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive(_focusNode)) { return Padding( padding: BusyMarkInsets.documentTableCell, child: GestureDetector( @@ -2307,69 +2331,81 @@ class _TableCellEditorState extends State<_TableCellEditor> { end: _controller.text.length, ) : null, - child: TextField( + child: BusyMarkEditorFocusRestoration( readOnly: DocumentReadOnlyScope.of(context), - key: ValueKey(cell.id), - controller: _controller, focusNode: _focusNode, - enableInteractiveSelection: !widget.documentSelectionDragging, - undoController: widget.undoController, - minLines: 1, - maxLines: 1, - inputFormatters: const [_SingleLineTableCellFormatter()], - style: textStyle, - textAlign: textAlign, - selectionHeightStyle: - BusyMarkDocumentTextGeometry.selectionHeightStyle, - selectionWidthStyle: - BusyMarkDocumentTextGeometry.selectionWidthStyle, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hoverColor: BusyMarkLinuxPalette.transparent, - hintText: widget.header - ? context.l10n.tableHeaderHint - : context.l10n.tableCellHint, - hintStyle: textStyle.copyWith( - color: colors.mutedForeground, + controller: _controller, + builder: (context, caretScrollController) => TextField( + readOnly: DocumentReadOnlyScope.of(context), + key: ValueKey(cell.id), + controller: _controller, + focusNode: _focusNode, + // A table cell owns its caret/range, including when a menu + // or dialog returns focus. Explicit Select All is handled + // by the editor's cell/document keyboard commands. + selectAllOnFocus: false, + scrollController: caretScrollController, + enableInteractiveSelection: + !widget.documentSelectionDragging, + undoController: widget.undoController, + minLines: 1, + maxLines: 1, + inputFormatters: const [_SingleLineTableCellFormatter()], + style: textStyle, + textAlign: textAlign, + selectionHeightStyle: + BusyMarkDocumentTextGeometry.selectionHeightStyle, + selectionWidthStyle: + BusyMarkDocumentTextGeometry.selectionWidthStyle, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hoverColor: BusyMarkLinuxPalette.transparent, + hintText: widget.header + ? context.l10n.tableHeaderHint + : context.l10n.tableCellHint, + hintStyle: textStyle.copyWith( + color: colors.mutedForeground, + ), + contentPadding: EdgeInsets.zero, ), - contentPadding: EdgeInsets.zero, + contextMenuBuilder: widget.suppressContextMenu + ? (context, editableTextState) => + const SizedBox.shrink() + : (context, editableTextState) => + buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: context.l10n.aiRefineWithAi, + onCut: widget.onCut, + onCopy: widget.onCopy, + onPaste: widget.onPaste, + onPastePlainText: widget.onPastePlainText, + readPasteAvailability: + widget.readPasteAvailability, + onCopyPlainText: widget.onCopyPlainText, + readSpellingItems: widget.spellingMenuReader, + onCheckSpelling: widget.onCheckSpelling, + ), + onTap: () { + final onCellFocused = widget.onCellFocused; + if (onCellFocused == null) { + widget.onFocused(); + } else { + onCellFocused(cell.id); + } + }, + onChanged: (value) { + if (_sourceEditing) { + widget.onSourceChanged(cell.id, value); + } else { + widget.onChanged(cell.id, value); + } + }, ), - contextMenuBuilder: widget.suppressContextMenu - ? (context, editableTextState) => const SizedBox.shrink() - : (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: context.l10n.aiRefineWithAi, - onCut: widget.onCut, - onCopy: widget.onCopy, - onPaste: widget.onPaste, - onPastePlainText: widget.onPastePlainText, - readPasteAvailability: - widget.readPasteAvailability, - onCopyPlainText: widget.onCopyPlainText, - readSpellingItems: widget.spellingMenuReader, - onCheckSpelling: widget.onCheckSpelling, - ), - onTap: () { - final onCellFocused = widget.onCellFocused; - if (onCellFocused == null) { - widget.onFocused(); - } else { - onCellFocused(cell.id); - } - }, - onChanged: (value) { - if (_sourceEditing) { - widget.onSourceChanged(cell.id, value); - } else { - widget.onChanged(cell.id, value); - } - }, ), ), ), diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 0159079d..c4606b42 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -2573,36 +2573,43 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } + bool hasParagraphBreakEdit(String blockId, String text) { + final block = blockById(blockId); + return block != null && + _shouldSplitNewlines(block.kind) && + _paragraphBreakEdit( + _normalizeLineEndings(block.plainText), + _normalizeLineEndings(text), + ).breaks.isNotEmpty; + } + BusyWysiwygTextSplitResult? replaceBlockTextWithParagraphs( String blockId, String text, int cursorOffset, { bool preserveTextWhitespace = false, bool allowListExit = true, + bool replaceWholeBlock = false, }) { final block = blockById(blockId); - if (block == null || !_shouldSplitNewlines(block.kind)) { + if (block == null || + !_canStructurallyEdit(blockId) || + !_shouldSplitNewlines(block.kind)) { return null; } final normalizedText = _normalizeLineEndings(text); final previousText = _normalizeLineEndings(block.plainText); - final editStart = _commonPrefixLength(previousText, normalizedText); - final editSuffix = _commonSuffixLength( - previousText, - normalizedText, - editStart, - ); - final editEnd = normalizedText.length - editSuffix; - final paragraphBreaks = [ - for (final match in '\n'.allMatches(normalizedText)) - if (match.start >= editStart && match.start < editEnd) match, - ]; + final edit = _paragraphBreakEdit(previousText, normalizedText); + final paragraphBreaks = edit.breaks; if (paragraphBreaks.isEmpty) { return null; } if (allowListExit && _isListItemKind(block.kind) && - normalizedText.trim().isEmpty) { + normalizedText.trim().isEmpty && + _canExitEmptyListItem( + _blockWithEditedText(block, normalizedText.replaceAll('\n', '')), + )) { if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); @@ -2620,59 +2627,46 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ? 0 : paragraphBreaks[focusIndex - 1].end; final focusOffset = safeOffset - focusStart; - final ranges = _remapRangesForTextEdit( - oldText: block.plainText, - newText: normalizedText, - ranges: busyInlineStyleRanges(block.inlines), + // Rebuild once, including semantic objects, then partition the actual + // inline tree. Text/style ranges alone cannot represent empty-alt images. + final retainRight = _retainsOriginalAfterLeadingBreak( + block, + paragraphBreaks.first.start, + normalizedText.substring(paragraphBreaks.first.end), ); - final replacements = []; + var remaining = _blockWithEditedText( + block, + normalizedText, + leadingAnchorsAfterEdit: retainRight, + ).inlines; + final parts = >[]; var partStart = 0; - for (var index = 0; index <= paragraphBreaks.length; index++) { - final partEnd = index < paragraphBreaks.length - ? paragraphBreaks[index].start - : normalizedText.length; - final part = normalizedText.substring(partStart, partEnd); - final inlines = _inlinesFromStyleRangesWithHardBreaks( - part, - _styleRangesForSlice(ranges, partStart, partEnd), + for (final lineBreak in paragraphBreaks) { + final length = lineBreak.start - partStart; + final partition = _partitionInlinesForReplacement( + remaining, + length, + length + 1, ); - if (index == 0) { - replacements.add( - block.copyWith( - inlines: inlines, - attributes: _attributesForText( - _sourceBackedSplitAttributes(block.attributes), - block.kind, - part, - preserveTextWhitespace: preserveTextWhitespace, - ), - preserveRaw: false, - dirty: true, - ), - ); - } else { - final splitKind = _splitKindFor(block.kind); - replacements.add( - BusyBlock( - id: _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)), - kind: splitKind, - inlines: inlines, - attributes: _attributesForText( - _sourceBackedSplitAttributes( - _splitAttributesFor(block, splitKind, orderedOffset: index), - ), - splitKind, - part, - preserveTextWhitespace: preserveTextWhitespace, - ), - dirty: true, - ), - ); - } - if (index < paragraphBreaks.length) { - partStart = paragraphBreaks[index].end; - } - } + parts.add(partition.before); + remaining = partition.after; + partStart = lineBreak.end; + } + parts.add(remaining); + // A whole textual heading replaced by newlines is a replacement, not an + // insertion before an untouched heading. Prefix replacements retain it. + final replacedHeading = + block.kind == BusyBlockKind.heading && + (replaceWholeBlock || + previousText.isNotEmpty && edit.prefix == 0 && edit.suffix == 0); + final replacements = _blocksForSplit( + replacedHeading + ? block.copyWith(kind: BusyBlockKind.paragraph, attributes: const {}) + : block, + parts, + retainRight: retainRight && !replacedHeading, + preserveTextWhitespace: preserveTextWhitespace, + ); _document = _document.copyWith( blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), ); @@ -3026,6 +3020,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void _insertHardBreaks(String blockId, int offset, {required int count}) { + if (!_canStructurallyEdit(blockId)) return; _replaceBlock(blockId, (block) { final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); @@ -3037,7 +3032,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { replacementLength: count, ); return block.copyWith( - inlines: _inlinesFromStyleRangesWithHardBreaks(nextText, ranges), + inlines: _restoreSemanticInlineAnchors( + originalInlines: block.inlines, + oldText: text, + newText: nextText, + insertion: (offset: safeOffset, length: count), + rebuiltInlines: _inlinesFromStyleRangesWithHardBreaks( + nextText, + ranges, + ), + ), preserveRaw: false, dirty: true, ); @@ -3827,66 +3831,144 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { bool recognizeUrls = false, }) { final block = blockById(blockId); - if (block == null) { + if (block == null || !_canStructurallyEdit(blockId)) { return null; } final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); - final leftText = text.substring(0, safeOffset); final rightText = text.substring(safeOffset); final inlinePartition = _partitionInlinesForReplacement( block.inlines, safeOffset, safeOffset, ); - final nextBlockId = _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)); - final nextKind = _splitKindFor(block.kind); - final currentBlock = BusyBlock( - id: block.id, - kind: block.kind, - inlines: inlinePartition.before.isEmpty - ? _textInlines('') - : recognizeUrls && _supportsBareUrlRecognition(block) - ? _recognizeBareUrls(inlinePartition.before, mode: _document.mode) - : inlinePartition.before, - children: block.children, - attributes: _attributesForText( - _sourceBackedSplitAttributes(block.attributes), - block.kind, - leftText, - ), - dirty: true, - ); - final nextBlock = BusyBlock( - id: nextBlockId, - kind: nextKind, - inlines: inlinePartition.after.isEmpty - ? _textInlines('') - : inlinePartition.after, - attributes: _attributesForText( - _sourceBackedSplitAttributes( - _splitAttributesFor(block, nextKind, orderedOffset: 1), - ), - nextKind, - rightText, - ), - dirty: true, + final retainRight = _retainsOriginalAfterLeadingBreak( + block, + safeOffset, + rightText, ); + final before = retainRight ? _textInlines('') : inlinePartition.before; + final replacements = _blocksForSplit(block, [ + recognizeUrls && _supportsBareUrlRecognition(block) + ? _recognizeBareUrls(before, mode: _document.mode) + : before, + retainRight ? block.inlines : inlinePartition.after, + ], retainRight: retainRight); _document = _document.copyWith( - blocks: _replaceBlockWithMany(_document.blocks, blockId, [ - currentBlock, - nextBlock, - ]), + blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), ); notifyListeners(); - return nextBlockId; + return replacements.last.id; } - BusyWysiwygTextSplitResult? applyEnterAt(String blockId, int offset) { + /// The retained fragment owns the original editing identity, authored + /// attributes and subtree. A leading split inserts a sibling *before* an + /// existing heading/list parent; other splits retain the original on the left. + List _blocksForSplit( + BusyBlock block, + List> parts, { + required bool retainRight, + bool preserveTextWhitespace = false, + }) { + var retained = 0; + if (retainRight) { + retained = 1; + while (retained < parts.length - 1 && + parts[retained].every(_isEmptyTextInline)) { + retained++; + } + } + return [ + for (var index = 0; index < parts.length; index++) + BusyBlock( + id: index == retained + ? block.id + : _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)), + kind: index == retained ? block.kind : _splitKindFor(block.kind), + inlines: parts[index].isEmpty ? _textInlines('') : parts[index], + children: index == retained ? block.children : const [], + attributes: _attributesForText( + _sourceBackedSplitAttributes( + index == retained + ? block.kind == BusyBlockKind.orderedListItem + ? { + ...block.attributes, + 'marker': _incrementOrderedMarker( + block.attributes['marker'], + index, + ), + } + : block.attributes + : _splitAttributesFor( + block, + _splitKindFor(block.kind), + orderedOffset: index, + ), + ), + index == retained ? block.kind : _splitKindFor(block.kind), + parts[index].map((inline) => inline.plainText).join(), + preserveTextWhitespace: preserveTextWhitespace, + ), + dirty: true, + ), + ]; + } + + bool _canStructurallyEdit(String blockId) { + final path = _actualElementPath(_document.blocks, blockId); + return path.isNotEmpty && + !path.any( + (b) => + b.isSourceProtected || + b.isSourceOnly || + b.isGenerated || + b.preserveRaw || + b.kind == BusyBlockKind.table, + ) && + !_isReadOnlySelectionEndpoint(path.last); + } + + BusyWysiwygTextSplitResult? applyEnterAt( + String blockId, + int offset, { + int? selectionEnd, + }) { final block = blockById(blockId); - if (block == null) { + if (block == null || !_canStructurallyEdit(blockId)) { return null; } + final start = offset.clamp(0, block.plainText.length).toInt(); + final end = (selectionEnd ?? start) + .clamp(start, block.plainText.length) + .toInt(); + if (end > start) { + // Stage both operations: rejection of list exit must not publish the + // selection deletion, change the field, or create an undo entry. + final staged = BusyMarkWysiwygDocumentController(document: _document); + try { + staged.updateBlockText( + blockId, + block.plainText.replaceRange(start, end, ''), + ); + if (block.kind == BusyBlockKind.heading && + start == 0 && + end == block.plainText.length) { + staged._replaceBlock( + blockId, + (b) => + b.copyWith(kind: BusyBlockKind.paragraph, attributes: const {}), + ); + } + final result = staged.applyEnterAt(blockId, start); + if (result != null) { + _document = staged.document; + notifyListeners(); + } + return result; + } finally { + staged.dispose(); + } + } if (_document.isXmlTopic && busyMarkIsWritersideContainer(block) && WritersideEditingAdapter.titleElements.contains( @@ -3923,7 +4005,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { offset: safeOffset + 1, ); } - if (_isListItemKind(block.kind) && block.plainText.trim().isEmpty) { + if (_isListItemKind(block.kind) && _canExitEmptyListItem(block)) { if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); @@ -3976,16 +4058,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } BusyWysiwygTextSplitResult? applyBackspaceAtStart(String blockId) { - final path = elementPath(blockId); + if (!_canStructurallyEdit(blockId)) return null; + final path = _actualElementPath(_document.blocks, blockId); final owner = path.length < 2 ? null : path[path.length - 2]; - final native = owner?.attributes['busymark-list-form'] == 'markdown'; - final blocks = native ? owner!.children : _document.blocks; + final blocks = owner?.children ?? _document.blocks; void publish(List members) { _document = _document.copyWith( - blocks: native + blocks: owner != null ? _replaceInBlocks( _document.blocks, - owner!.id, + owner.id, (b) => b.copyWith(children: members, dirty: true), ) : members, @@ -3998,6 +4080,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final previous = blocks[index - 1]; final current = blocks[index]; + if (!_canStructurallyEdit(previous.id)) return null; + if (_isSafelyRemovableParagraph(previous)) { + // Removing a sibling invalidates the source gap even when the retained + // heading itself is unchanged. A committed Writerside heading is clean; + // retaining its old span would let source patching keep the deleted gap. + final retained = _withoutSourceSpan(current, dirty: true); + publish([...blocks.take(index - 1), retained, ...blocks.skip(index + 1)]); + notifyListeners(); + return BusyWysiwygTextSplitResult(blockId: current.id, offset: 0); + } if (!_isMergeableTextBlock(previous) || !_isMergeableTextBlock(current)) { return null; } @@ -4676,8 +4768,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final firstBlock = blockById(firstBlockId); final selectedIds = removedBlockIds.toSet(); final destinationBlockFullySelected = - replacementScope == BusyWysiwygReplacementScope.documentRange && firstBlock != null && + (replacementScope == BusyWysiwygReplacementScope.documentRange || + firstBlock.kind == BusyBlockKind.heading && + replacementText.contains(RegExp(r'[\r\n]'))) && selectedIds.contains(firstBlockId) && firstStartOffset <= 0 && (firstBlockId != lastBlockId || @@ -5380,6 +5474,11 @@ Map _splitAttributesFor( BusyBlockKind kind, { int orderedOffset = 0, }) { + // New paragraphs do not own any of their heading's authored properties or + // projection/source bindings. The retained heading keeps the complete map. + if (block.kind == BusyBlockKind.heading && kind == BusyBlockKind.paragraph) { + return {}; + } final attributes = {...block.attributes}; if (kind != BusyBlockKind.heading) { attributes.remove('level'); @@ -5943,6 +6042,40 @@ BusyBlockKind _splitKindFor(BusyBlockKind kind) { }; } +bool _isEmptyTextInline(BusyInline inline) => + inline.kind == BusyInlineKind.text && + inline.text.isEmpty && + inline.attributes.isEmpty && + inline.children.every(_isEmptyTextInline); + +bool _retainsOriginalAfterLeadingBreak( + BusyBlock block, + int offset, + String rightText, +) => + offset == 0 && + (block.kind == BusyBlockKind.heading || _isListItemKind(block.kind)) && + (block.plainText.isNotEmpty && rightText.isNotEmpty || + block.children.isNotEmpty || + busyInlineSemanticAnchors(block.inlines).isNotEmpty); + +bool _canExitEmptyListItem(BusyBlock block) => + block.plainText.trim().isEmpty && + block.children.isEmpty && + block.inlines.every( + (inline) => + inline.kind == BusyInlineKind.text && + inline.attributes.isEmpty && + inline.children.isEmpty, + ) && + WritersideEditingAdapter.sourceAttributes(block).isEmpty; + +bool _isSafelyRemovableParagraph(BusyBlock block) => + block.kind == BusyBlockKind.paragraph && + block.children.isEmpty && + block.inlines.every(_isEmptyTextInline) && + WritersideEditingAdapter.sourceAttributes(block).isEmpty; + String _newBlockPrefixFor(BusyBlockKind kind) { return switch (kind) { BusyBlockKind.unorderedListItem => 'unordered-list-item', @@ -6287,6 +6420,7 @@ BusyBlock _blockWithEditedText( Iterable activeInlineKinds = const [], bool preserveTextWhitespace = false, bool normalizePastedFormatting = false, + bool leadingAnchorsAfterEdit = false, }) { final oldText = block.plainText; final oldRanges = busyInlineStyleRanges(block.inlines); @@ -6311,6 +6445,7 @@ BusyBlock _blockWithEditedText( oldText: oldText, newText: nextText, rebuiltInlines: rebuiltInlines, + leadingAnchorsAfterEdit: leadingAnchorsAfterEdit, ); if (normalizePastedFormatting) { final prefix = _commonPrefixLength(oldText, nextText); @@ -6338,24 +6473,56 @@ String _normalizeLineEndings(String text) { return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); } +({int prefix, int suffix, List breaks}) _paragraphBreakEdit( + String oldText, + String newText, +) { + final prefix = _commonPrefixLength(oldText, newText); + final suffix = _commonSuffixLength(oldText, newText, prefix); + return ( + prefix: prefix, + suffix: suffix, + breaks: [ + for (final match in '\n'.allMatches(newText)) + if (match.start >= prefix && match.start < newText.length - suffix) + match, + ], + ); +} + List _restoreSemanticInlineAnchors({ required List originalInlines, required String oldText, required String newText, required List rebuiltInlines, + bool leadingAnchorsAfterEdit = false, + ({int offset, int length})? insertion, }) { final anchors = busyInlineSemanticAnchors(originalInlines); if (anchors.isEmpty) { return rebuiltInlines; } - final prefix = _commonPrefixLength(oldText, newText); - final suffix = _commonSuffixLength(oldText, newText, prefix); - final oldEditEnd = oldText.length - suffix; - final newEditEnd = newText.length - suffix; - final delta = newText.length - oldText.length; + final int prefix; + final int oldEditEnd; + final int newEditEnd; + final int delta; + if (insertion != null) { + // Repeated characters (especially existing hard breaks) make a text diff + // ambiguous. Commands with a known insertion must retain that position. + prefix = oldEditEnd = insertion.offset; + newEditEnd = insertion.offset + insertion.length; + delta = insertion.length; + } else { + prefix = _commonPrefixLength(oldText, newText); + final suffix = _commonSuffixLength(oldText, newText, prefix); + oldEditEnd = oldText.length - suffix; + newEditEnd = newText.length - suffix; + delta = newText.length - oldText.length; + } var result = rebuiltInlines; for (final anchor in anchors) { final offset = switch (anchor.offset) { + 0 when leadingAnchorsAfterEdit && prefix == 0 => newEditEnd, final value when value <= prefix => value, final value when value >= oldEditEnd => value + delta, _ => newEditEnd, @@ -6363,7 +6530,10 @@ List _restoreSemanticInlineAnchors({ final partition = _partitionInlinesForReplacement(result, offset, offset); result = [...partition.before, anchor.inline, ...partition.after]; } - return result; + // Reinsertion splits enclosing wrappers. Rejoin equivalent adjacent runs so + // serialization does not emit invalid repeated emphasis delimiters around + // zero-width objects (and thereby lose their formatting on reparse). + return _mergeAdjacentInlineStyles(result); } BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 2bad3006..d80e3cf4 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,8 +1,10 @@ +import '../document_edit_history.dart'; import '../../app/command_shortcuts.dart'; import 'package:flutter/foundation.dart' show listEquals; import '../../writerside/writerside_effective_properties.dart'; import '../../writerside/writerside_model.dart'; import '../link_destination.dart'; +import '../editor_focus_restoration.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; import 'dart:convert'; @@ -150,6 +152,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 +219,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; @@ -232,6 +240,7 @@ class BusyMarkWysiwygEditorState extends State { final _textUndoControllers = {}; final _focusNodes = {}; final _tableCellControllers = {}; + final _modelTextSelections = {}; final _tableCellFocusNodes = {}; final _tableCellKeys = {}; final _spellingEditableKeys = {}; @@ -242,6 +251,21 @@ class BusyMarkWysiwygEditorState extends State { final _redoStack = []; final _itemScrollController = ItemScrollController(); final _itemPositionsListener = ItemPositionsListener.create(); + ScrollPosition? get _documentScrollPosition { + for (final key in _blockKeys.values) { + final context = key.currentContext; + if (context == null) continue; + final scrollable = Scrollable.maybeOf(context, axis: Axis.vertical); + if (scrollable != null) return scrollable.position; + } + return null; + } + + String? _initialEditingBlockId; + double? _initialEditingOffset; + double? _viewportWidth; + String? _viewportSource; + String? _viewportSourceHash; List<({int itemIndex, DocumentOutlineHeading heading})> _viewportOutlineStops = const []; List _viewportBlockIds = const []; @@ -290,7 +314,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 +338,6 @@ class BusyMarkWysiwygEditorState extends State { double? _preferredVerticalCaretX; final _pendingInlineKindsByBlockId = >{}; int _preserveSelectionFocusCallbacks = 0; - bool _internalChange = false; bool _initialFocusScheduled = false; var _toolbarVisible = true; var _documentGeneration = 0; @@ -318,6 +347,11 @@ class BusyMarkWysiwygEditorState extends State { double? _viewportHeight; late final _WysiwygClipboardInsertionTarget _historyInsertionTarget; _PendingTextFocus? _pendingTextFocus; + Object? _focusRestoreRequest; + Object? _sessionRestoreRequest; + FocusNode? _lastNonRootFocus; + VoidCallback? _retrySelectionExtentFocus; + VoidCallback? _cancelSelectionExtentFocus; String get _documentId => widget.documentId ?? widget.document.filePath; @@ -388,7 +422,7 @@ class BusyMarkWysiwygEditorState extends State { } } - void restoreSpellingFocus() => _restoreEditingFocusAfterFrame(); + void restoreSpellingFocus() => _restoreEditingFocusAfterFrame(passive: true); /// Applies one already-checked occurrence as a single structured edit. /// @@ -584,10 +618,16 @@ 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, ); + _lastNonRootFocus = FocusManager.instance.primaryFocus; + FocusManager.instance.addListener(_handleSelectionFocusOwnerChanged); _documentController = BusyMarkWysiwygDocumentController( document: widget.document, )..addListener(_handleDocumentControllerChanged); @@ -619,10 +659,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 +707,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; @@ -663,6 +738,7 @@ class BusyMarkWysiwygEditorState extends State { @override void dispose() { + FocusManager.instance.removeListener(_handleSelectionFocusOwnerChanged); _releaseSelectionDrag(rebuild: false); _pendingTextFocus = null; widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); @@ -693,6 +769,7 @@ class BusyMarkWysiwygEditorState extends State { } void _resetPerDocumentState() { + _inputSelections.clear(); _lastInspectorSelection = null; _propertiesTargetId = null; _videoDraftTarget = null; @@ -713,6 +790,7 @@ class BusyMarkWysiwygEditorState extends State { focusNode.dispose(); } _textControllers.clear(); + _modelTextSelections.clear(); _tableCellControllers.clear(); _textUndoControllers.clear(); _focusNodes.clear(); @@ -961,17 +1039,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; }, ), @@ -983,6 +1057,7 @@ class BusyMarkWysiwygEditorState extends State { _viewportHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : null; + _viewportWidth = constraints.maxWidth; final documentLayout = _documentLayout; final showProperties = _documentController.document.isWriterside && @@ -999,6 +1074,8 @@ class BusyMarkWysiwygEditorState extends State { children: [ Positioned.fill( child: Listener( + onPointerSignal: (_) => _cancelSessionRestore(), + onPointerPanZoomStart: (_) => _cancelSessionRestore(), onPointerDown: _handleDocumentSelectionContextPointerDown, onPointerUp: _handleDocumentSelectionContextPointerUp, onPointerCancel: @@ -1010,22 +1087,40 @@ class BusyMarkWysiwygEditorState extends State { _clearBlockSelection(); _focusActiveOrFirstBlock(); }, - child: Focus( - focusNode: _selectionFocusNode, - child: ScrollablePositionedList.builder( - key: const ValueKey('wysiwyg-document-scroll'), - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - padding: documentLayout.scrollPadding, - itemCount: renderEntries.length, - itemBuilder: (context, index) => _buildRenderEntry( - context, - renderEntries[index], - documentLayout: documentLayout, - first: index == 0, - selectedBlockIds: selectedBlockIds, - selectionRangesByBlockId: - selectionRangesByBlockId, + child: BusyMarkEditorFocusScope( + documentId: _documentId, + child: Focus( + focusNode: _selectionFocusNode, + child: ScrollablePositionedList.builder( + key: const ValueKey('wysiwyg-document-scroll'), + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + padding: documentLayout.scrollPadding, + itemCount: renderEntries.length, + initialScrollIndex: math.max( + 0, + _initialEditingOffset != null && + (_initialEditingOffset! < 0 || + _initialEditingOffset! > + constraints.maxHeight) + ? renderEntries.indexWhere( + (entry) => _renderEntryContainsBlock( + entry, + _initialEditingBlockId ?? '', + ), + ) + : 0, + ), + itemBuilder: (context, index) => + _buildRenderEntry( + context, + renderEntries[index], + documentLayout: documentLayout, + first: index == 0, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: + selectionRangesByBlockId, + ), ), ), ), @@ -1046,12 +1141,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: + GlobalObjectKey< + _DocumentSelectionTextInputState + >(selection), + onKeyEvent: _handleDocumentSelectionKeyEvent, + onEditingValue: _documentSelectionInputHandler( + selection, + ), ), ), ), @@ -1074,9 +1175,12 @@ class BusyMarkWysiwygEditorState extends State { onPlacementChanged: widget.onToolbarPlacementChanged, onDirectionChanged: widget.onToolbarDirectionChanged, child: BusyMarkWysiwygToolbar( - onMenuCanceled: () { + onMenuCanceled: (menuContext) { if (_isDialogTargetCurrent(authoringGuard)) { - _restoreEditingFocusAfterFrame(); + _restoreEditingFocusAfterFrame( + passive: true, + menuContext: menuContext, + ); } }, axis: widget.toolbarDirection._axis, @@ -1292,18 +1396,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 +2242,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,7 +2382,9 @@ class BusyMarkWysiwygEditorState extends State { } for (final id in _textControllers.keys.toList()) { if (!ids.contains(id)) { + _inputSelections.remove(id); _textControllers.remove(id)?.dispose(); + _modelTextSelections.remove(id); } } for (final entry in _tableCellControllers.entries) { @@ -2293,6 +2395,7 @@ class BusyMarkWysiwygEditorState extends State { } for (final id in _tableCellControllers.keys.toList()) { if (!cellIds.contains(id)) { + _inputSelections.remove(id); _tableCellControllers.remove(id)?.dispose(); } } @@ -2420,6 +2523,7 @@ class BusyMarkWysiwygEditorState extends State { String targetId, TextEditingController controller, ) { + _rememberModelTextSelection(targetId, controller); if (_documentController.document.isWriterside && _activeBlockId == targetId && mounted) { @@ -2429,6 +2533,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,11 +2549,23 @@ class BusyMarkWysiwygEditorState extends State { !controller.selection.isValid) { return; } - if (controller.selection.extentOffset != continuous.caret) { + if (controller.selection != + TextSelection.collapsed(offset: continuous.caret)) { _continuousTextEdit = null; } } + void _rememberModelTextSelection( + String targetId, + TextEditingController controller, + ) { + if (identical(_textControllers[targetId], controller) && + controller.text == _documentController.blockText(targetId) && + controller.selection.isValid) { + _modelTextSelections[targetId] = controller.selection; + } + } + FocusNode _tableCellFocusNodeFor(String tableBlockId, BusyBlock cell) { final focusNode = _tableCellFocusNodes.putIfAbsent( cell.id, @@ -2790,6 +2912,7 @@ class BusyMarkWysiwygEditorState extends State { } BusyDocument _historySnapshot() { + _reportLiveHistorySession(); final markdown = _documentController.markdown; return _documentController.document.copyWith( blocks: [ @@ -2820,6 +2943,7 @@ class BusyMarkWysiwygEditorState extends State { void _recordUndoSnapshot([BusyDocument? previousDocument]) { if (widget.useExternalUndoHistory) { + if (previousDocument == null) _reportLiveHistorySession(); return; } final snapshot = previousDocument ?? _historySnapshot(); @@ -2938,23 +3062,31 @@ class BusyMarkWysiwygEditorState extends State { return; } final controller = _textControllers[blockId]; + final undoSnapshot = widget.useExternalUndoHistory + ? null + : _historySnapshot(); + _reportTextHistorySession(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); + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); + _emitMarkdown(undoGroup: undoGroup, reportEditingSession: true); return; } final offset = @@ -2963,18 +3095,40 @@ class BusyMarkWysiwygEditorState extends State { final pendingInlineKinds = _pendingInlineKindsByBlockId[blockId]?.toList(growable: false) ?? const []; + final paragraphBreakEdit = _documentController.hasParagraphBreakEdit( + blockId, + value, + ); + final previousSelection = _modelTextSelections[blockId]; final splitResult = _documentController.replaceBlockTextWithParagraphs( blockId, value, offset, preserveTextWhitespace: origin == _WysiwygTextEditOrigin.paste, allowListExit: origin != _WysiwygTextEditOrigin.paste, + replaceWholeBlock: + previousSelection != null && + !previousSelection.isCollapsed && + previousSelection.start == 0 && + previousSelection.end == oldText.length, ); if (splitResult != null) { + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); _emitMarkdown(undoGroup: undoGroup); _focusBlockAfterFrame(splitResult.blockId, offset: splitResult.offset); return; } + if (paragraphBreakEdit) { + // Null means rejection here, not an ordinary single-block edit. Do not + // let the platform text-input fallback bypass structural protection. + if (controller != null) { + controller.value = TextEditingValue( + text: oldText, + selection: previousSelection ?? TextSelection.collapsed(offset: 0), + ); + } + return; + } _documentController.updateBlockText( blockId, value, @@ -2982,10 +3136,12 @@ class BusyMarkWysiwygEditorState extends State { preserveTextWhitespace: origin == _WysiwygTextEditOrigin.paste, normalizePastedFormatting: origin == _WysiwygTextEditOrigin.paste, ); + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); + if (controller != null) _rememberModelTextSelection(blockId, controller); if (value.isNotEmpty) { _pendingInlineKindsByBlockId.remove(blockId); } - _emitMarkdown(undoGroup: undoGroup); + _emitMarkdown(undoGroup: undoGroup, reportEditingSession: true); } String _replacementTextForFieldEdit(String oldText, String newText) { @@ -3071,7 +3227,11 @@ class BusyMarkWysiwygEditorState extends State { if (currentText == accepted) { return; } - _recordUndoSnapshot(); + final previousSelection = _inputSelections[cellId]; + final undoSnapshot = widget.useExternalUndoHistory + ? null + : _historySnapshot(); + _reportTextHistorySession(cellId, previousSelection); if (markdownSource) { _documentController.updateTableCellMarkdownSource( tableBlockId, @@ -3081,9 +3241,12 @@ class BusyMarkWysiwygEditorState extends State { } else { _documentController.updateTableCellText(tableBlockId, cellId, accepted); } + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); _emitMarkdown( + reportEditingSession: true, undoGroup: _undoGroupForTextEdit( targetId: cellId, + previousSelection: previousSelection, oldText: currentText, newText: accepted, selection: controller?.selection, @@ -3238,14 +3401,17 @@ class BusyMarkWysiwygEditorState extends State { } _initialFocusScheduled = true; final generation = _documentGeneration; + final request = _sessionRestoreRequest; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || generation != _documentGeneration) { + if (!mounted || + generation != _documentGeneration || + !identical(request, _sessionRestoreRequest)) { return; } final firstPlainBlock = _focusableBlocks() .where((block) => !busyMarkWysiwygBlockContainsMath(block)) .firstOrNull; - if (firstPlainBlock == null) { + if (firstPlainBlock == null || !_mayRestoreEditingFocus()) { _inspectorRestorePending = false; _scheduleSessionReport(); return; @@ -3257,7 +3423,54 @@ class BusyMarkWysiwygEditorState extends State { }); } - void _scheduleSessionRestore(WysiwygEditorSessionState session) { + void _cancelSessionRestore() { + _sessionRestoreRequest = null; + _inspectorRestorePending = false; + } + + bool _mayRestoreEditingFocus({BuildContext? menuContext}) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) { + return false; + } + final owner = FocusManager.instance.primaryFocus; + final ownerContext = owner?.context; + if (ownerContext == null || !ownerContext.mounted) return true; + final ownerRoute = ModalRoute.of(ownerContext); + // Root/view scopes have no route or competing control; a recreated editor + // may initialize focus there. A dialog's route scope is a different owner. + if (owner is FocusScopeNode && ownerRoute == null) return true; + if (ownerRoute != ModalRoute.of(context)) return false; + if (menuContext?.mounted == true) { + // Returning from this menu's own trigger is ordinary cancellation, not + // permission for a different toolbar control to return focus as well. + var isTrigger = identical(ownerContext, menuContext); + if (!isTrigger) { + ownerContext.visitAncestorElements((element) { + isTrigger = identical(element, menuContext); + return !isTrigger; + }); + } + if (isTrigger) return true; + } + return owner is FocusScopeNode || + owner == _selectionFocusNode || + (owner != null && + BusyMarkEditorFocusRestoration.ownsDocumentFocus( + owner, + _documentId, + )) || + ownerContext + .findAncestorStateOfType<_DocumentSelectionTextInputState>() != + null; + } + + void _scheduleSessionRestore( + WysiwygEditorSessionState session, { + bool restoreSelectionImmediately = false, + }) { + final request = Object(); + _sessionRestoreRequest = request; _inspectorRestorePending = true; if (session.activeBlockId == null && session.anchorBlockId == null && @@ -3314,17 +3527,13 @@ class BusyMarkWysiwygEditorState extends State { extentOffset: session.extentOffset, viewportBlockId: locate(session.viewportBlockId), viewportAlignment: session.viewportAlignment, + viewportOffset: session.viewportOffset, + viewportEditingOffset: session.viewportEditingOffset, + viewportLayoutSignature: session.viewportLayoutSignature, ); } - _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 +3565,7 @@ class BusyMarkWysiwygEditorState extends State { .clamp(0, controller.text.length) .toInt(), ); + applied = true; } } else { final start = _clampDocumentPosition( @@ -3375,13 +3585,96 @@ 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(); + final restoreFocus = _mayRestoreEditingFocus(); + if (restoreFocus && _hasBlockSelection) { + _selectionFocusNode.requestFocus(); + } else if (restoreFocus) { + final focus = _activeCellId == null + ? _focusNodes[_activeBlockId] + : _tableCellFocusNodes[_activeCellId]; + focus?.requestFocus(); + } + if (applied) immediatelyRestored = _captureSessionState(); + } + _initialFocusScheduled = true; + _initialEditingBlockId = session.activeBlockId; + _initialEditingOffset = session.viewportEditingOffset; + final generation = _documentGeneration; + final documentId = _documentId; + final navigation = widget.scrollRequest; + final gesture = _selectionGestureGeneration; + final focusOwner = FocusManager.instance.primaryFocus; + final selectionAtRequest = _activeTextTarget()?.controller.selection; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !identical(request, _sessionRestoreRequest) || + generation != _documentGeneration || + documentId != _documentId || + navigation != widget.scrollRequest || + (!restoreSelectionImmediately && + gesture != _selectionGestureGeneration) || + (!restoreSelectionImmediately && + selectionAtRequest != null && + selectionAtRequest != + _activeTextTarget()?.controller.selection) || + (FocusManager.instance.primaryFocus != focusOwner && + FocusManager.instance.primaryFocus is! FocusScopeNode && + FocusManager.instance.primaryFocus?.context?.mounted == true && + FocusManager.instance.primaryFocus?.context + ?.findAncestorStateOfType() != + this)) { + if (mounted && identical(request, _sessionRestoreRequest)) { + _cancelSessionRestore(); + } + return; + } + _sessionRestoreRequest = null; + 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(); + final savedEditingOffset = session.viewportEditingOffset; + final editingOffset = _editingBlockViewportOffset(); + if (savedEditingOffset != null && + savedEditingOffset.isFinite && + editingOffset != null && + session.viewportLayoutSignature == _viewportLayoutSignature()) { + final position = _documentScrollPosition; + if (position != null) { + position.jumpTo(position.pixels + editingOffset - savedEditingOffset); + } + } else if (viewportBlockId != null) { + var alignment = session.viewportAlignment.isFinite + ? session.viewportAlignment.clamp(0.0, 1.0).toDouble() + : 0.0; + var offset = session.viewportOffset.isFinite + ? math.max(0.0, session.viewportOffset) + : 0.0; final firstViewportBlockId = _editorRenderEntries( _documentController.document.blocks, ).firstOrNull?.block.id; @@ -3391,31 +3684,38 @@ class BusyMarkWysiwygEditorState extends State { viewportHeight > 0) { // ItemPosition includes the leading scroll padding for item zero, // while jumpTo applies that padding again during restoration. - alignment = math.max( - 0, - alignment - _documentLayout.scrollPadding.top / viewportHeight, - ); + final leading = alignment * viewportHeight - offset; + final padding = _documentLayout.scrollPadding.top; + alignment = math.max(0, (leading - padding) / viewportHeight); + offset = math.max(0, padding - leading); + } + if (_jumpToBlock(viewportBlockId, alignment: alignment) && offset > 0) { + // jumpTo's alignment stays in its documented range. Apply the pixel + // offset to the actual document ScrollPosition in the same operation, + // before the next layout/paint; never jump then compensate in a later + // frame. Layout clamps against the current document's real bounds. + _documentScrollPosition?.jumpTo(offset); } - _jumpToBlock(viewportBlockId, alignment: alignment); } final activeCellId = _activeCellId; + final restoreFocus = _mayRestoreEditingFocus(); if (_hasBlockSelection) { _collapseFieldSelections(); // Keep command focus until the restored extent's editable field or // table input client is mounted, preserving the document range. - _selectionFocusNode.requestFocus(); + if (restoreFocus) _selectionFocusNode.requestFocus(); _activeBlockId = _documentSelection!.extent.blockId; _activeCellId = null; - _scheduleDocumentSelectionExtentFocus(); + if (restoreFocus) _scheduleDocumentSelectionExtentFocus(); _scheduleSessionReport(); - } else if (activeCellId != null) { + } else if (restoreFocus && activeCellId != null) { final controller = _tableCellControllers[activeCellId]; final focusNode = _tableCellFocusNodes[activeCellId]; if (controller != null && focusNode != null) { focusNode.requestFocus(); } - } else { - _focusActiveOrFirstBlock(); + } else if (restoreFocus) { + _focusActiveOrFirstBlock(reveal: false); } if (mounted) { setState(() {}); @@ -3507,12 +3807,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 +3827,56 @@ class BusyMarkWysiwygEditorState extends State { report(targetDocumentId, session); } + void _reportTextHistorySession(String targetId, TextSelection? selection) { + if (!widget.useExternalUndoHistory) { + 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, + viewportOffset: session.viewportOffset, + viewportEditingOffset: session.viewportEditingOffset, + viewportLayoutSignature: session.viewportLayoutSignature, + 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) { @@ -3538,6 +3889,82 @@ class BusyMarkWysiwygEditorState extends State { return _sessionSourceSignature; } + double? _editingBlockViewportOffset() { + final block = _blockKeys[_activeBlockId]?.currentContext + ?.findRenderObject(); + final viewport = _propertyHoverViewportKey.currentContext + ?.findRenderObject(); + if (block is! RenderBox || + !block.hasSize || + viewport is! RenderBox || + !viewport.hasSize) { + return null; + } + final painted = block.localToGlobal(Offset.zero, ancestor: viewport).dy; + if (painted.isFinite) return painted; + // Kept-alive sliver children outside the layout cache have a zero paint + // transform. Use the viewport's supported logical reveal coordinates, not + // that non-invertible transform, to retain their content position. + final documentViewport = RenderAbstractViewport.maybeOf(block); + final position = _documentScrollPosition; + final viewportBox = documentViewport is RenderBox + ? documentViewport as RenderBox + : null; + if (documentViewport == null || viewportBox == null || position == null) { + return null; + } + final logical = + viewportBox.localToGlobal(Offset.zero, ancestor: viewport).dy + + documentViewport.getOffsetToReveal(block, 0).offset - + position.pixels; + return logical.isFinite ? logical : null; + } + + String _viewportLayoutSignature() { + final source = + _documentController.document.source ?? _documentController.markdown; + if (_viewportSource != source) { + _viewportSource = source; + _viewportSourceHash = sha256.convert(utf8.encode(source)).toString(); + } + final layout = _documentLayout; + final style = busyMarkDocumentBodyTextStyle(context); + final scaler = MediaQuery.textScalerOf(context); + final fontGeometry = [ + style.fontFamily, + style.fontFamilyFallback, + style.fontSize, + style.fontWeight?.value, + style.fontStyle?.index, + style.letterSpacing, + style.wordSpacing, + style.height, + style.textBaseline?.index, + style.leadingDistribution?.index, + style.locale?.toLanguageTag(), + [ + for (final feature in style.fontFeatures ?? const []) + [feature.feature, feature.value], + ], + [ + for (final variation in style.fontVariations ?? const []) + [variation.axis, variation.value], + ], + scaler.scale(style.fontSize ?? 14), + for (var level = 1; level <= 6; level++) + scaler.scale( + busyMarkDocumentHeadingTextStyle(context, level).fontSize!, + ), + ]; + return sha256 + .convert( + utf8.encode( + '$_viewportSourceHash|$_viewportWidth|$_viewportHeight|${layout.maxContentWidth}|${layout.minimumInsets}|${MediaQuery.devicePixelRatioOf(context)}|${Directionality.of(context)}|${jsonEncode(fontGeometry)}', + ), + ) + .toString(); + } + WysiwygEditorSessionState _captureSessionState() { String? anchorBlockId; String? extentBlockId; @@ -3572,6 +3999,7 @@ class BusyMarkWysiwygEditorState extends State { } String? viewportBlockId; var viewportAlignment = 0.0; + var viewportOffset = 0.0; final positions = _itemPositionsListener.itemPositions.value .where( @@ -3584,6 +4012,10 @@ class BusyMarkWysiwygEditorState extends State { positions.first.index < _viewportBlockIds.length) { viewportBlockId = _viewportBlockIds[positions.first.index]; viewportAlignment = positions.first.itemLeadingEdge.clamp(0.0, 1.0); + viewportOffset = math.max( + 0, + -positions.first.itemLeadingEdge * (_viewportHeight ?? 0), + ); } return WysiwygEditorSessionState( activeBlockId: _activeBlockId, @@ -3594,6 +4026,9 @@ class BusyMarkWysiwygEditorState extends State { extentOffset: extentOffset, viewportBlockId: viewportBlockId, viewportAlignment: viewportAlignment, + viewportOffset: viewportOffset, + viewportEditingOffset: _editingBlockViewportOffset(), + viewportLayoutSignature: _viewportLayoutSignature(), sourceSignature: _writersideSessionSourceSignature(), sourceBindings: { if (_documentController.document.isWriterside) @@ -3731,6 +4166,7 @@ class BusyMarkWysiwygEditorState extends State { ); if (matchStart >= 0) { _activeBlockId = target.id; + BusyMarkEditorFocusRestoration.reveal(_focusNodeFor(target)); _focusNodeFor(target).requestFocus(); controller.selection = TextSelection( baseOffset: matchStart, @@ -3944,6 +4380,9 @@ class BusyMarkWysiwygEditorState extends State { _setActiveTableCell(target.outerBlockId, cellId); final controller = _tableCellControllers[cellId]; final focusNode = _tableCellFocusNodes[cellId]; + if (focusNode != null) { + BusyMarkEditorFocusRestoration.reveal(focusNode); + } focusNode?.requestFocus(); if (controller != null) { controller.selection = TextSelection( @@ -3970,6 +4409,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllers[block.id]; if (controller != null && _focusNodes[block.id] != null) { _setActiveBlock(block.id); + BusyMarkEditorFocusRestoration.reveal(_focusNodes[block.id]!); _focusNodes[block.id]!.requestFocus(); controller.selection = TextSelection( baseOffset: target.visibleStart @@ -4060,7 +4500,10 @@ class BusyMarkWysiwygEditorState extends State { return null; } - void _focusActiveOrFirstBlock({int? initialSelectionOffset}) { + void _focusActiveOrFirstBlock({ + int? initialSelectionOffset, + bool reveal = true, + }) { final blocks = _focusableBlocks(); final activeBlock = _activeBlockId == null ? null @@ -4104,6 +4547,7 @@ class BusyMarkWysiwygEditorState extends State { _activeBlockId = blockId; _activeCellId = null; if (!focusNode.hasFocus) { + if (reveal) BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); } final selection = controller.selection; @@ -4122,6 +4566,15 @@ class BusyMarkWysiwygEditorState extends State { } final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; + if (DocumentReadOnlyScope.of(context) && + { + LogicalKeyboardKey.enter, + LogicalKeyboardKey.backspace, + LogicalKeyboardKey.delete, + LogicalKeyboardKey.tab, + }.contains(key)) { + return KeyEventResult.ignored; + } final commands = BusyMarkCommandRegistryScope.read(context) ?? BusyMarkCommandCatalog.metadata; @@ -4201,9 +4654,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 +4662,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_undoEditorChange()) { - widget.onUndo?.call(); - } + _traverseDocumentHistory(redo: false); return KeyEventResult.handled; } if (widget.onOpenSearch != null && @@ -4331,16 +4780,16 @@ class BusyMarkWysiwygEditorState extends State { .clamp(start, controller.text.length) .toInt(); final activeInlineKinds = _activeInlineKindsAt(blockId, start); - _recordUndoSnapshot(); - _documentController.updateBlockText( + final undoSnapshot = _historySnapshot(); + final result = _documentController.applyEnterAt( blockId, - controller.text.replaceRange(start, end, ''), - activeInlineKinds: activeInlineKinds, + start, + selectionEnd: end, ); - final result = _documentController.applyEnterAt(blockId, start); if (result == null) { - return KeyEventResult.ignored; + return KeyEventResult.handled; } + _recordUndoSnapshot(undoSnapshot); if (_documentController.blockText(result.blockId).isEmpty) { _setPendingInlineKinds(result.blockId, activeInlineKinds); } @@ -4369,11 +4818,12 @@ class BusyMarkWysiwygEditorState extends State { : LogicalKeyboardKey.arrowRight; if (key == LogicalKeyboardKey.enter) { final activeInlineKinds = _activeInlineKindsAt(blockId, offset); - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.applyEnterAt(blockId, offset); if (result == null) { - return KeyEventResult.ignored; + return KeyEventResult.handled; } + _recordUndoSnapshot(undoSnapshot); if (_documentController.blockText(result.blockId).isEmpty) { _setPendingInlineKinds(result.blockId, activeInlineKinds); } @@ -4382,11 +4832,12 @@ class BusyMarkWysiwygEditorState extends State { return KeyEventResult.handled; } if (key == LogicalKeyboardKey.backspace && offset == 0) { - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.applyBackspaceAtStart(blockId); if (result == null) { return KeyEventResult.ignored; } + _recordUndoSnapshot(undoSnapshot); _emitMarkdown(); _focusBlockAfterFrame(result.blockId, offset: result.offset); return KeyEventResult.handled; @@ -4480,9 +4931,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 +4939,7 @@ class BusyMarkWysiwygEditorState extends State { event, keyboard, )) { - if (widget.useExternalUndoHistory || !_redoEditorChange()) { - widget.onRedo?.call(); - } + _traverseDocumentHistory(redo: true); return KeyEventResult.handled; } if (widget.onOpenSearch != null && @@ -4547,6 +4994,7 @@ class BusyMarkWysiwygEditorState extends State { controller.selection = TextSelection.collapsed( offset: backwards ? controller.text.length : 0, ); + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); return KeyEventResult.handled; } @@ -4631,6 +5079,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllerFor(nextBlock); final focusNode = _focusNodeFor(nextBlock); _activeBlockId = nextBlock.id; + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); final offset = desiredOffset is _MoveToBlockEnd ? controller.text.length @@ -5014,6 +5463,7 @@ class BusyMarkWysiwygEditorState extends State { if (_documentSelection != null) { setState(() => _documentSelection = null); } + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection( baseOffset: anchor.offset, @@ -5030,6 +5480,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllerFor(extentBlock); final focusNode = _focusNodeFor(extentBlock); if (!_selectionFocusNode.hasPrimaryFocus) { + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); } controller.selection = TextSelection.collapsed( @@ -5140,6 +5591,7 @@ class BusyMarkWysiwygEditorState extends State { } final focusNode = _focusNodeFor(block); final controller = _textControllerFor(block); + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection.collapsed( offset: offset.clamp(0, controller.text.length).toInt(), @@ -5170,6 +5622,7 @@ class BusyMarkWysiwygEditorState extends State { if (controller == null || focusNode == null) { return; } + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection.collapsed( offset: offset.clamp(0, controller.text.length).toInt(), @@ -5338,15 +5791,57 @@ class BusyMarkWysiwygEditorState extends State { } void _applyAsyncToolbarCommand(Future Function() command) { - unawaited(command().whenComplete(_restoreEditingFocusAfterFrame)); + unawaited( + command().whenComplete(() { + // The surface may have finished releasing after a different control + // acquired focus. Completion is not a new request to return to editing. + _restoreEditingFocusAfterFrame(passive: true); + }), + ); } - void _restoreEditingFocusAfterFrame() { + void _restoreEditingFocusAfterFrame({ + bool passive = false, + BuildContext? menuContext, + }) { + // Passive completions must not capture a newer owner as authorization. + // Purposeful editing/navigation callers retain their existing behavior. + if (!mounted || + (passive && !_mayRestoreEditingFocus(menuContext: menuContext))) { + return; + } final documentId = _documentId; + final generation = _documentGeneration; + final request = Object(); + _focusRestoreRequest = request; + final gesture = _selectionGestureGeneration; + final selection = _documentSelection; + final target = _activeTextTarget(); + final value = target?.controller.value; + final owner = FocusManager.instance.primaryFocus; + final navigation = widget.scrollRequest; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _documentId != documentId) { + if (!mounted) return; + // A newer post-frame focus request may not be primaryFocus yet. + FocusManager.instance.applyFocusChangesIfNeeded(); + final currentOwner = FocusManager.instance.primaryFocus; + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (!mounted || + (lifecycle != null && lifecycle != AppLifecycleState.resumed) || + _documentId != documentId || + generation != _documentGeneration || + !identical(request, _focusRestoreRequest) || + gesture != _selectionGestureGeneration || + !identical(selection, _documentSelection) || + navigation != widget.scrollRequest || + target?.controller.value != value || + (passive && !_mayRestoreEditingFocus(menuContext: menuContext)) || + (currentOwner != owner && + currentOwner != target?.focusNode && + currentOwner != FocusManager.instance.rootScope)) { return; } + _focusRestoreRequest = null; if (_documentSelection != null) { _selectionFocusNode.requestFocus(); return; @@ -5574,7 +6069,10 @@ class BusyMarkWysiwygEditorState extends State { ), ); if (inserted == null || inserted.trim().isEmpty) { - if (_isDialogTargetCurrent(guard)) _restoreEditingFocusAfterFrame(); + // Canceling the surface is not a new request to return to editing. + if (_isDialogTargetCurrent(guard)) { + _restoreEditingFocusAfterFrame(passive: true); + } return; } if (!mounted) return; @@ -5847,13 +6345,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); } } @@ -6925,6 +7419,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyHardBreakCommand() { + if (DocumentReadOnlyScope.of(context)) return; final target = _activeTextTarget(); if (target == null || _activeCellId != null) { return; @@ -6943,6 +7438,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyBlankLineCommand() { + if (DocumentReadOnlyScope.of(context)) return; final target = _activeTextTarget(); if (target == null || _activeCellId != null) { return; @@ -7434,51 +7930,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 +7974,7 @@ class BusyMarkWysiwygEditorState extends State { void _emitMarkdown({ String? undoGroup, + bool reportEditingSession = false, WysiwygEditorSessionState? beforeSpellingSession, WysiwygEditorSessionState? afterSpellingSession, }) { @@ -7497,7 +7986,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 +8020,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 { @@ -8139,6 +8708,7 @@ class BusyMarkWysiwygEditorState extends State { } void _handleDocumentSelectionContextPointerDown(PointerDownEvent event) { + _cancelSessionRestore(); _documentSelectionContextPointer = null; _documentSelectionContextSnapshot = null; if (event.buttons != kSecondaryMouseButton || !_hasBlockSelection) { @@ -8356,19 +8926,117 @@ class BusyMarkWysiwygEditorState extends State { } void _scheduleDocumentSelectionExtentFocus() { + _cancelSelectionExtentFocus?.call(); final selection = _documentSelection; if (selection == null) return; + final manager = FocusManager.instance; + manager.applyFocusChangesIfNeeded(); + final inactive = + WidgetsBinding.instance.lifecycleState != null && + WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed; + final owner = inactive && manager.primaryFocus == manager.rootScope + ? _lastNonRootFocus + : manager.primaryFocus; + final ownerContext = owner?.context; + final editorOwnsFocus = + ownerContext?.mounted == true && + identical( + ownerContext!.findAncestorStateOfType(), + this, + ); + if (!editorOwnsFocus && (inactive || !_mayRestoreEditingFocus())) { + _preserveSelectionFocusCallbacks = 0; + return; + } final gesture = _selectionGestureGeneration; final generation = _documentGeneration; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - gesture == _selectionGestureGeneration && - generation == _documentGeneration && - identical(selection, _documentSelection)) { + final documentId = _documentId; + final navigation = widget.scrollRequest; + var canceled = false; + var framePending = false; + final extentNode = _focusNodes[selection.extent.blockId]; + final releaseTarget = extentNode == null + ? null + : BusyMarkEditorFocusRestoration.retainPendingTarget(extentNode); + bool isCurrent() => + mounted && + !canceled && + gesture == _selectionGestureGeneration && + generation == _documentGeneration && + documentId == _documentId && + navigation == widget.scrollRequest && + identical(selection, _documentSelection); + bool owns(FocusNode? current) => + current == owner || + current == _selectionFocusNode || + current == _focusNodes[selection.extent.blockId] || + current == + GlobalObjectKey<_DocumentSelectionTextInputState>( + selection, + ).currentState?._focusNode; + void cancel() { + if (canceled) return; + canceled = true; + _retrySelectionExtentFocus = null; + _cancelSelectionExtentFocus = null; + releaseTarget?.call(); + } + + void schedule() { + if (!isCurrent()) { + cancel(); + return; + } + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) { + // Keep the handoff only while the desktop has suspended its owner. + // FocusManager's real restoration wakes it; no timer/frame retry does. + if (manager.primaryFocus != manager.rootScope && + !owns(manager.primaryFocus)) { + cancel(); + } + return; + } + if (!owns(manager.primaryFocus) || !_mayRestoreEditingFocus()) { + cancel(); + return; + } + if (framePending) return; + framePending = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + framePending = false; + if (!isCurrent()) { + cancel(); + return; + } + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return; + manager.applyFocusChangesIfNeeded(); + if (!isCurrent() || + !owns(manager.primaryFocus) || + !_mayRestoreEditingFocus()) { + cancel(); + return; + } + cancel(); _focusDocumentSelectionExtent(); _preserveSelectionFocusCallbacks = 0; - } - }); + }); + WidgetsBinding.instance.scheduleFrame(); + } + + _cancelSelectionExtentFocus = cancel; + _retrySelectionExtentFocus = schedule; + schedule(); + } + + void _handleSelectionFocusOwnerChanged() { + final owner = FocusManager.instance.primaryFocus; + if (owner != null && owner != FocusManager.instance.rootScope) { + // Root focus during inactivity is not evidence of editor ownership. + _lastNonRootFocus = owner; + } + _retrySelectionExtentFocus?.call(); } bool _collapseEmptyDocumentSelection() { @@ -8379,6 +9047,7 @@ class BusyMarkWysiwygEditorState extends State { } void _releaseSelectionDrag({bool rebuild = true}) { + _cancelSelectionExtentFocus?.call(); final active = _selectionPointer != null; _selectionPointer = null; _pointerSelectionAnchor = null; @@ -8557,8 +9226,9 @@ class BusyMarkWysiwygEditorState extends State { } void _focusDocumentSelectionExtent() { - final extent = _documentSelection?.extent; - if (extent == null) { + final selection = _documentSelection; + final extent = selection?.extent; + if (extent == null || !_mayRestoreEditingFocus()) { return; } final block = _documentController.blockById(extent.blockId); @@ -8567,7 +9237,21 @@ class BusyMarkWysiwygEditorState extends State { } if (!_isFocusableTextBlock(block)) { _setActiveBlock(block.id); - _selectionFocusNode.requestFocus(); + final input = GlobalObjectKey<_DocumentSelectionTextInputState>( + selection!, + ).currentState; + // The same guarded handoff owns table input too; the child must not + // request focus independently after a newer control/lifecycle change. + if (input != null) { + input._focusNode.requestFocus(); + } else { + _selectionFocusNode.requestFocus(); + // Only a table mounts the auxiliary text input. Other non-editable + // endpoints retain command focus without waiting for a missing field. + if (block.kind == BusyBlockKind.table) { + _scheduleDocumentSelectionExtentFocus(); + } + } _scheduleSessionReport(); return; } @@ -9376,12 +10060,10 @@ class BusyMarkWysiwygEditorState extends State { class _DocumentSelectionTextInput extends StatefulWidget { const _DocumentSelectionTextInput({ super.key, - required this.isCurrent, required this.onEditingValue, required this.onKeyEvent, }); - final bool Function() isCurrent; final bool Function(TextEditingValue) onEditingValue; final KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent; @@ -9403,9 +10085,6 @@ class _DocumentSelectionTextInputState void initState() { super.initState(); _controller.addListener(_handleEditingValue); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.isCurrent()) _focusNode.requestFocus(); - }); } void _handleEditingValue() { @@ -9639,16 +10318,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/editor/wysiwyg/wysiwyg_session_state.dart b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart index 8a468983..137c88fe 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart @@ -8,6 +8,9 @@ class WysiwygEditorSessionState { this.extentOffset = 0, this.viewportBlockId, this.viewportAlignment = 0, + this.viewportOffset = 0, + this.viewportEditingOffset, + this.viewportLayoutSignature, this.sourceSignature, this.sourceBindings = const {}, }); @@ -21,6 +24,16 @@ class WysiwygEditorSessionState { final String? viewportBlockId; final double viewportAlignment; + /// Logical pixels clipped from the leading edge of [viewportBlockId]. Old + /// sessions without this field retain their original alignment semantics. + final double viewportOffset; + + /// The retained editing block's leading edge relative to the viewport. This + /// lets an unchanged session mount that one editable and scroll naturally, + /// preserving its input client even when the reading anchor is far away. + final double? viewportEditingOffset; + final String? viewportLayoutSignature; + // Writerside authoring IDs can differ after an exact saved source is reparsed. // Bind caret/viewport locations to that representation, never to an ordinal. final String? sourceSignature; @@ -36,6 +49,11 @@ class WysiwygEditorSessionState { 'extentOffset': extentOffset, 'viewportBlockId': viewportBlockId, 'viewportAlignment': viewportAlignment, + if (viewportOffset != 0) 'viewportOffset': viewportOffset, + if (viewportEditingOffset != null) + 'viewportEditingOffset': viewportEditingOffset, + if (viewportLayoutSignature != null) + 'viewportLayoutSignature': viewportLayoutSignature, if (sourceSignature != null) 'sourceSignature': sourceSignature, if (sourceBindings.isNotEmpty) 'sourceBindings': { @@ -59,6 +77,10 @@ class WysiwygEditorSessionState { extentOffset: (json['extentOffset'] as num?)?.toInt() ?? 0, viewportBlockId: json['viewportBlockId']?.toString(), viewportAlignment: (json['viewportAlignment'] as num?)?.toDouble() ?? 0, + viewportOffset: (json['viewportOffset'] as num?)?.toDouble() ?? 0, + viewportEditingOffset: (json['viewportEditingOffset'] as num?) + ?.toDouble(), + viewportLayoutSignature: json['viewportLayoutSignature'] as String?, sourceSignature: json['sourceSignature'] as String?, sourceBindings: { if (json['sourceBindings'] case final Map bindings) diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 7bee489a..97a153b7 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -69,7 +69,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final ValueChanged? onAdmonitionCommand; final ValueChanged? onWritersideInsert; final bool Function(BusyWritersideInsertCommand)? isWritersideInsertEnabled; - final VoidCallback? onMenuCanceled; + final ValueChanged? onMenuCanceled; final ValueChanged onInlineCommand; final VoidCallback onLinkCommand; final VoidCallback? onInlineMathCommand; diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index 45ffe977..ae5d8027 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -98,8 +98,14 @@ class _LocalHistoryComparisonViewState currentSourceVersion: matchingBuffer?.revision ?? cachedNote?.revision, currentSource: matchingBuffer?.text ?? cachedNote?.content, currentPath: document.currentPath, + sourceGeneration: history.comparisonRevision, + deleted: document.deleted, ); if (_request != request) { + if (_request?.documentId != request.documentId || + _request?.revisionId != request.revisionId) { + _recoveryError = null; + } _request = request; _future = _buildSnapshot(document, revision, request); } @@ -134,10 +140,11 @@ class _LocalHistoryComparisonViewState onClose: () => ref .read(localHistoryControllerProvider.notifier) .clearComparison(), - onRestoreAll: actionsAreCurrent && data.canRestore - ? () => _restore(document, revision) + onRestoreAll: !_recovering && actionsAreCurrent && data.canRestore + ? () => _restore(document, revision, comparison: data.comparison) : null, - onRestoreChange: actionsAreCurrent && data.canRestoreChange + onRestoreChange: + !_recovering && actionsAreCurrent && data.canRestoreChange ? (change) => _restore( document, revision, @@ -146,9 +153,13 @@ class _LocalHistoryComparisonViewState ) : null, onRestoreOriginal: - document.remoteNote != null && actionsAreCurrent && data.missing + !_recovering && + document.remoteNote != null && + actionsAreCurrent && + data.missing ? () => unawaited(_recoverRemote(document, revision)) - : actionsAreCurrent && + : !_recovering && + actionsAreCurrent && data.missing && (document.currentPath ?? revision.summary.historicalPath) != null @@ -159,7 +170,10 @@ class _LocalHistoryComparisonViewState ) : null, onRestoreNewLocation: - document.remoteNote == null && actionsAreCurrent && data.missing + !_recovering && + document.remoteNote == null && + actionsAreCurrent && + data.missing ? () => _chooseRestoreLocation(document, revision) : null, ); @@ -217,15 +231,53 @@ class _LocalHistoryComparisonViewState LocalHistoryRevision revision, { SourceComparison? comparison, SourceComparisonChange? change, - }) async { - await ref - .read(workspaceControllerProvider.notifier) - .restoreLocalHistoryRevision( - document: document, - revision: revision, - comparison: comparison, - change: change, + }) => _runRestore(() async { + final controller = ref.read(workspaceControllerProvider.notifier); + final workspace = ref.read(workspaceControllerProvider).workspace; + final path = document.currentPath; + final needsWorkspaceNavigation = + controller.localHistoryBufferForDocument(document, revision) == null && + (document.remoteNote != null + ? workspace?.nextcloudAccountId != document.remoteNote!.accountId + : path != null && + (workspace == null || + workspace.isRemote || + !(p.equals(workspace.rootPath, path) || + p.isWithin(workspace.rootPath, path)))); + if (needsWorkspaceNavigation && + (!await confirmSafeToContinue(context, ref) || !mounted)) { + return null; + } + return controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + comparison: comparison, + change: change, + ); + }); + + Future _runRestore(Future Function() operation) async { + if (_recovering) return; + setState(() { + _recovering = true; + _recoveryError = null; + }); + try { + final restored = await operation(); + if (mounted && restored == false) { + setState( + () => _recoveryError = context.l10n.localHistoryRestoreUnavailable, ); + } + } on Object { + if (mounted) { + setState( + () => _recoveryError = context.l10n.localHistoryRestoreUnavailable, + ); + } + } finally { + if (mounted) setState(() => _recovering = false); + } } Future _chooseRestoreLocation( @@ -298,11 +350,11 @@ class _LocalHistoryComparisonViewState LocalHistoryDocument document, LocalHistoryRevision revision, String path, - ) async { - if (!await confirmSafeToContinue(context, ref) || !mounted) return; + ) => _runRestore(() async { + if (!await confirmSafeToContinue(context, ref) || !mounted) return null; final controller = ref.read(workspaceControllerProvider.notifier); final exists = await controller.localHistoryRestorePathExists(path); - if (!mounted) return; + if (!mounted) return null; var overwrite = false; if (exists) { overwrite = @@ -326,15 +378,15 @@ class _LocalHistoryComparisonViewState ), ) ?? false; - if (!overwrite || !mounted) return; + if (!overwrite || !mounted) return null; } - await controller.restoreMissingLocalHistoryRevision( + return controller.restoreMissingLocalHistoryRevision( document: document, revision: revision, destinationPath: path, overwriteExisting: overwrite, ); - } + }); } class _ComparisonRequest { @@ -346,6 +398,8 @@ class _ComparisonRequest { required this.currentSourceVersion, required this.currentSource, required this.currentPath, + required this.sourceGeneration, + required this.deleted, }); final String documentId; @@ -355,6 +409,8 @@ class _ComparisonRequest { final int? currentSourceVersion; final String? currentSource; final String? currentPath; + final int sourceGeneration; + final bool deleted; @override bool operator ==(Object other) => @@ -365,7 +421,9 @@ class _ComparisonRequest { other.currentBufferId == currentBufferId && other.currentSourceVersion == currentSourceVersion && other.currentSource == currentSource && - other.currentPath == currentPath; + other.currentPath == currentPath && + other.sourceGeneration == sourceGeneration && + other.deleted == deleted; @override int get hashCode => Object.hash( @@ -376,6 +434,8 @@ class _ComparisonRequest { currentSourceVersion, currentSource, currentPath, + sourceGeneration, + deleted, ); } diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index bf3df938..5b4b9842 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -64,6 +64,7 @@ class LocalHistoryState { const LocalHistoryState({ this.snapshot = const LocalHistorySnapshot(), this.searchRevision = 0, + this.comparisonRevision = 0, this.loading = false, this.selectedDocumentId, this.selectedRevisionId, @@ -75,6 +76,7 @@ class LocalHistoryState { final LocalHistorySnapshot snapshot; final int searchRevision; + final int comparisonRevision; final bool loading; final String? selectedDocumentId; final String? selectedRevisionId; @@ -95,6 +97,7 @@ class LocalHistoryState { LocalHistoryState copyWith({ LocalHistorySnapshot? snapshot, int? searchRevision, + int? comparisonRevision, bool? loading, Object? selectedDocumentId = _unset, Object? selectedRevisionId = _unset, @@ -105,6 +108,7 @@ class LocalHistoryState { }) => LocalHistoryState( snapshot: snapshot ?? this.snapshot, searchRevision: searchRevision ?? this.searchRevision, + comparisonRevision: comparisonRevision ?? this.comparisonRevision, loading: loading ?? this.loading, selectedDocumentId: identical(selectedDocumentId, _unset) ? this.selectedDocumentId @@ -583,7 +587,12 @@ class LocalHistoryController extends Notifier { Future refresh() async { final generation = ++_loadGeneration; - if (ref.mounted) state = state.copyWith(loading: true); + if (ref.mounted) { + state = state.copyWith( + loading: true, + comparisonRevision: state.comparisonRevision + 1, + ); + } try { // Defaults are presentation state while settings hydrate. In particular, // the default retention age must not delete history before a longer saved @@ -700,6 +709,142 @@ 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, + ) => _observeSourceReplacement( + previous, + current, + LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + + /// Retains each displaced remote version independently of the coalesced + /// checkpoint for the incoming source, including rapid successive refreshes. + void observeAuthoritativeReplacement( + DocumentBuffer previous, + DocumentBuffer current, + ) => _observeSourceReplacement( + previous, + current, + LocalHistoryCaptureReason.externalChange, + ); + + void _observeSourceReplacement( + DocumentBuffer previous, + DocumentBuffer current, + LocalHistoryCaptureReason reason, + ) { + 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 == reason && + _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:replacement:${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: reason, + 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( @@ -4048,6 +4193,11 @@ class LocalHistoryController extends Notifier { String? documentIdForBuffer(String bufferId) => _documentIdsByBuffer[bufferId]; + /// Invalidates closed-file comparisons after filesystem notifications. + void invalidateCurrentSourceComparisons() { + state = state.copyWith(comparisonRevision: state.comparisonRevision + 1); + } + /// Restores the stable history identity recorded with an editor session. /// /// The store snapshot is authoritative: stale session metadata cannot bind @@ -4989,16 +5139,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 +5982,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 +6029,12 @@ 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:', + 'history-capture:replacement:', + ].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. @@ -6106,20 +6271,49 @@ class LocalHistoryController extends Notifier { .where((r) => documents.containsKey(r.documentId)) .toList() ..sort((a, b) => b.capturedAt.compareTo(a.capturedAt)); - for (final summary in summaries) { + const batchSize = 32; + const maximumBatchBytes = 8 * 1024 * 1024; + for (var offset = 0; offset < summaries.length;) { if (!current()) return null; - // Empty scoped queries can use summaries without reading any bodies. - final revision = needle.isEmpty - ? null - : await _store.readRevision(summary.id); + var end = offset + 1; + var bytes = summaries[offset].storageBytes; + while (end < summaries.length && end - offset < batchSize) { + final nextBytes = summaries[end].storageBytes; + if (bytes + nextBytes > maximumBatchBytes) break; + bytes += nextBytes; + end++; + } + final batch = summaries.sublist(offset, end); + offset = end; + final store = _store; + final batchReader = store is LocalHistoryBatchReader + ? store as LocalHistoryBatchReader + : null; + final revisions = needle.isEmpty || batchReader == null + ? {} + : { + for (final revision in await batchReader.readRevisions( + batch.map((r) => r.id), + )) + revision.summary.id: revision, + }; if (!current()) return null; - if (needle.isEmpty || - revision?.source.toLowerCase().contains(needle) == true) { - matches.add( - LocalHistoryRevisionMatch(documents[summary.documentId]!, summary), - ); - if (matches.length > maximumResults) return page(truncated: true); - onProgress?.call(page()); + for (final summary in batch) { + // Empty scoped queries can use summaries without reading any bodies. + final revision = needle.isEmpty + ? null + : batchReader != null + ? revisions[summary.id] + : await store.readRevision(summary.id); + if (!current()) return null; + if (needle.isEmpty || + revision?.source.toLowerCase().contains(needle) == true) { + matches.add( + LocalHistoryRevisionMatch(documents[summary.documentId]!, summary), + ); + if (matches.length > maximumResults) return page(truncated: true); + onProgress?.call(page()); + } } } return current() ? page() : null; 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..9c1cd8bc 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -119,7 +119,15 @@ abstract interface class LocalHistoryStore { Future clearAllOnce({required String operationId}) => clearAll(); } -class FileLocalHistoryStore implements LocalHistoryStore { +/// Reads a bounded group under one store lock and one authoritative index. +abstract interface class LocalHistoryBatchReader { + Future> readRevisions( + Iterable revisionIds, + ); +} + +class FileLocalHistoryStore + implements LocalHistoryStore, LocalHistoryBatchReader { FileLocalHistoryStore({ Future Function()? rootDirectory, String Function()? createId, @@ -203,17 +211,25 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (index.tombstones.contains(captureId)) { throw const LocalHistoryClearConflict(); } - final existing = index.revisions + final indexed = index.revisions .where((revision) => revision.id == captureId) .firstOrNull; + final orphan = indexed == null + ? await _findUnindexedCapture(root, captureId) + : null; + final existing = indexed ?? orphan?.revision.summary; if (existing != null) { - final document = index.documents - .where((candidate) => candidate.id == existing.documentId) - .firstOrNull; - final revision = await _readRevisionFile( - _revisionFile(root, existing.documentId, existing.id), - expected: existing, - ); + final document = + index.documents + .where((candidate) => candidate.id == existing.documentId) + .firstOrNull ?? + orphan?.document; + final revision = + orphan?.revision ?? + await _readRevisionFile( + _revisionFile(root, existing.documentId, existing.id), + expected: existing, + ); if (document == null || existing.checksum != checksum || revision == null || @@ -231,7 +247,76 @@ class FileLocalHistoryStore implements LocalHistoryStore { when target.documentId != document.id) { throw const LocalHistoryReconciliationConflict(); } + if (orphan != null) { + // Finish only the exact retained capture whose record committed + // before its index. Preserve later authoritative identity changes. + // The controller may retry a saved capture as a checkpoint. Its + // stable identity, source and format still describe the accepted + // capture; retain the original reason stored in the record. + if (orphan.document.remoteNote != request.remoteNote || + (request.path != null && + existing.historicalPath != request.path && + !orphan.document.historicalPaths.any( + (path) => p.equals(path, request.path!), + ))) { + throw const LocalHistoryStorageException( + 'Local History capture identity collision.', + ); + } + if (existing.storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + final path = document.currentPath; + if (!document.deleted && + path != null && + index.documents.any( + (candidate) => + candidate.id != document.id && + _ownsActivePath(candidate, path), + )) { + throw const LocalHistoryReconciliationConflict(); + } + _requireCaptureStillAccepted(request); + index = index.withCapture(document, existing); + } _requireCaptureStillAccepted(request); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + root, + index, + document, + existing, + request, + policy, + indexBeforeRecovery: orphan == null ? null : originalIndex, + ); + } + if (orphan != null) { + await _publishIndex(root, index); + if (!_captureStillAccepted(request)) { + await _publishIndex( + root, + originalIndex.copyWith( + tombstones: {...originalIndex.tombstones, captureId}, + ), + ); + await _deleteFileBestEffort( + _revisionFile(root, existing.documentId, captureId), + ); + throw const LocalHistoryCaptureCancelled(); + } + await _applyRetention( + root, + index, + policy, + captureId, + request.capturedAt.toUtc(), + ); + _requireCaptureStillAccepted(request); + } return LocalHistoryCaptureResult( document: document, revision: existing, @@ -275,18 +360,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,17 +402,11 @@ class FileLocalHistoryStore implements LocalHistoryStore { } return LocalHistoryCaptureResult( document: document, - revision: adjacent, + revision: candidate, deduplicated: true, ); } } - final estimatedBytes = utf8.encode(request.source).length + 2048; - if (estimatedBytes > policy.maximumBytes) { - throw const LocalHistoryStorageException( - 'The revision is larger than the Local History storage limit.', - ); - } final revisionId = request.captureId ?? _safeNewId(); var summary = LocalHistoryRevisionSummary( id: revisionId, @@ -319,9 +420,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { ? request.path : identity.path, ); + final encoded = _revisionJson(document, summary, request); + final storageBytes = utf8.encode(encoded).length; + if (storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } final target = _revisionFile(root, document.id, revisionId); await target.parent.create(recursive: true); - final encoded = _revisionJson(document, summary, request); _requireCaptureStillAccepted(request); await _publishNewFile(target, encoded); if (!_captureStillAccepted(request)) { @@ -334,16 +441,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: storageBytes); index = index.withCapture(document, summary); // A complete revision exists before the index points to it. await _publishIndex(root, index); @@ -369,6 +467,88 @@ class FileLocalHistoryStore implements LocalHistoryStore { }); } + Future _reuseTraversalProtection( + Directory root, + _Index index, + LocalHistoryDocument document, + LocalHistoryRevisionSummary previous, + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, { + _Index? indexBeforeRecovery, + }) async { + if (previous.storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + var renewed = _renewedTraversalProtection(previous, request.capturedAt); + final target = _revisionFile(root, document.id, previous.id); + final previousRecord = identical(renewed, previous) + ? null + : await target.readAsString(); + Future cancelRenewal({required bool indexPublished}) async { + if (indexBeforeRecovery != null) { + await _publishIndex( + root, + indexBeforeRecovery.copyWith( + tombstones: {...indexBeforeRecovery.tombstones, previous.id}, + ), + ); + await _deleteFileBestEffort(target); + } else { + if (previousRecord != null) { + await _publishReplacingFile(target, previousRecord); + } + if (indexPublished) await _publishIndex(root, index); + } + } + + _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. + final encoded = _revisionJson(document, renewed, request); + final bytes = utf8.encode(encoded).length; + if (bytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + await _publishReplacingFile(target, encoded); + renewed = renewed.copyWith(storageBytes: bytes); + } + if (!_captureStillAccepted(request)) { + await cancelRenewal(indexPublished: false); + 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)) { + await cancelRenewal(indexPublished: true); + 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 { @@ -388,6 +568,32 @@ class FileLocalHistoryStore implements LocalHistoryStore { }), ); + @override + Future> readRevisions( + Iterable revisionIds, + ) => _serialized( + (root) => _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final summaries = { + for (final revision in index.revisions) revision.id: revision, + }; + final documents = index.documents.map((document) => document.id).toSet(); + final result = []; + for (final id in revisionIds) { + final summary = summaries[id]; + if (summary == null || !documents.contains(summary.documentId)) { + continue; + } + final revision = await _readRevisionFile( + _revisionFile(root, summary.documentId, id), + expected: summary, + ); + if (revision != null) result.add(revision); + } + return result; + }), + ); + @override Future prune(LocalHistoryPolicy policy, DateTime now) => _serialized( (root) => _withFileLock(root, () async { @@ -931,7 +1137,12 @@ class FileLocalHistoryStore implements LocalHistoryStore { final file = File(p.join(root.path, 'index.json')); try { if (!await file.exists()) { - return repair ? await _rebuildIndex(root) : const _Index(); + if (!repair) return const _Index(); + final rebuilt = await _rebuildIndex(root); + if (rebuilt.revisions.isNotEmpty || rebuilt.tombstones.isNotEmpty) { + await _publishIndex(root, rebuilt); + } + return rebuilt; } final json = jsonDecode(await file.readAsString()); if (json is! Map) throw const FormatException('Invalid history index'); @@ -939,7 +1150,29 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (map['version'] != formatVersion) { throw UnsupportedLocalHistoryFormat(map['version']); } - return _Index.fromJson(map); + var index = _Index.fromJson(map); + final tombstones = await _readIndexTombstones( + root, + indexed: index.tombstones, + ); + index = index.copyWith( + tombstones: tombstones, + revisions: index.revisions + .where((revision) => !tombstones.contains(revision.id)) + .toList(growable: false), + ); + final identities = await _readDocumentIdentities(root); + if (identities == null) { + // Migrate a legacy store before its rebuildable index can be lost. + await _publishDocumentIdentities(root, index.documents); + return index; + } + return index.copyWith( + documents: identities.values.toList(growable: false), + revisions: index.revisions + .where((revision) => identities.containsKey(revision.documentId)) + .toList(growable: false), + ); } on UnsupportedLocalHistoryFormat { rethrow; } on Object { @@ -954,11 +1187,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { Future<_Index> _rebuildIndex(Directory root) async { final existing = await _readIndexTombstones(root); + final identities = await _readDocumentIdentities(root); final documents = {}; final revisions = []; final directory = Directory(p.join(root.path, 'revisions')); if (!await directory.exists()) return _Index(tombstones: existing); - await for (final entity in directory.list(recursive: true)) { + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { if (entity is! File || p.extension(entity.path) != '.json') continue; final relative = p.relative(entity.path, from: directory.path); final parts = p.split(relative); @@ -971,41 +1208,40 @@ class FileLocalHistoryStore implements LocalHistoryStore { continue; } try { - final map = (jsonDecode(await entity.readAsString()) as Map) - .cast(); - if (map['version'] != formatVersion) continue; - final document = LocalHistoryDocument.fromJson( - (map['document'] as Map).cast(), - ); - final summary = LocalHistoryRevisionSummary.fromJson( - (map['revision'] as Map).cast(), - ); - if (document.id != documentId || - summary.id != revisionId || - summary.documentId != documentId) { - continue; - } - final source = map['source']; - if (source is! String || sourceChecksum(source) != summary.checksum) { - continue; + final record = await _readRevisionRecord(entity); + if (record == null) continue; + final document = identities?[documentId] ?? record.document; + // An authoritative journal also records absence after clear/retention. + if (identities != null && !identities.containsKey(documentId)) continue; + final previous = documents[documentId]; + if (previous == null || + !document.updatedAt.isBefore(previous.updatedAt)) { + documents[documentId] = document; } - 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(record.revision.summary); } on Object { // A malformed record cannot invalidate independently intact records. } } + // Legacy records have no independent identity authority. Resolve duplicate + // path claims deterministically instead of exposing two live owners. + if (identities == null) { + final ordered = documents.values.toList() + ..sort((a, b) { + final order = b.updatedAt.compareTo(a.updatedAt); + return order != 0 ? order : a.id.compareTo(b.id); + }); + final owners = []; + for (final document in ordered) { + final path = document.currentPath; + if (document.deleted || path == null) continue; + if (owners.any((owner) => p.equals(owner, path))) { + documents[document.id] = document.copyWith(deleted: true); + } else { + owners.add(path); + } + } + } return _Index( documents: documents.values.toList(growable: false), revisions: revisions, @@ -1013,8 +1249,103 @@ class FileLocalHistoryStore implements LocalHistoryStore { ); } - Future> _readIndexTombstones(Directory root) async { - final result = {}; + Future?> _readDocumentIdentities( + Directory root, + ) async { + final file = File(p.join(root.path, 'documents.json')); + if (!await file.exists()) return null; + final map = (jsonDecode(await file.readAsString()) as Map) + .cast(); + if (map['version'] != formatVersion) { + throw UnsupportedLocalHistoryFormat(map['version']); + } + final documents = (map['documents'] as List).map( + (value) => + LocalHistoryDocument.fromJson((value as Map).cast()), + ); + return {for (final document in documents) document.id: document}; + } + + Future _publishDocumentIdentities( + Directory root, + List documents, + ) async { + final file = File(p.join(root.path, 'documents.json')); + final content = const JsonEncoder.withIndent(' ').convert({ + 'version': formatVersion, + 'documents': [for (final document in documents) document.toJson()], + }); + if (!await _journalMatches(file, content)) { + await _publishReplacingFile(file, content); + } + } + + Future<_RevisionRecord?> _findUnindexedCapture( + Directory root, + String captureId, + ) async { + final directory = Directory(p.join(root.path, 'revisions')); + if (!await directory.exists()) return null; + _RevisionRecord? found; + await for (final entity in directory.list(followLinks: false)) { + if (entity is! Directory || !_validId(p.basename(entity.path))) continue; + final file = File(p.join(entity.path, '$captureId.json')); + if (!await file.exists()) continue; + final record = await _readRevisionRecord(file); + if (record == null || found != null) { + throw const LocalHistoryStorageException( + 'Local History capture identity collision.', + ); + } + found = record; + } + return found; + } + + Future<_RevisionRecord?> _readRevisionRecord(File file) async { + try { + final map = (jsonDecode(await file.readAsString()) as Map) + .cast(); + if (map['version'] != formatVersion) { + throw UnsupportedLocalHistoryFormat(map['version']); + } + final document = LocalHistoryDocument.fromJson( + (map['document'] as Map).cast(), + ); + final summary = LocalHistoryRevisionSummary.fromJson( + (map['revision'] as Map).cast(), + ); + final source = map['source']; + if (document.id != p.basename(file.parent.path) || + summary.id != p.basenameWithoutExtension(file.path) || + summary.documentId != document.id || + source is! String || + source.length != summary.sourceLength || + sourceChecksum(source) != summary.checksum) { + return null; + } + return _RevisionRecord( + document: document, + revision: LocalHistoryRevision( + summary: summary.copyWith(storageBytes: await file.length()), + source: source, + format: TextFormatMetadata.fromJson( + (map['format'] as Map).cast(), + ), + ), + ); + } on UnsupportedLocalHistoryFormat { + rethrow; + } on Object { + return null; + } + } + + Future> _readIndexTombstones( + Directory root, { + Set? indexed, + }) async { + final result = {...?indexed}; final journal = File(p.join(root.path, 'tombstones.json')); try { final values = jsonDecode(await journal.readAsString()); @@ -1024,6 +1355,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { } on Object { // A damaged journal does not make intact revision records unreadable. } + if (indexed != null) return result; final file = File(p.join(root.path, 'index.json')); try { final map = (jsonDecode(await file.readAsString()) as Map) @@ -1042,34 +1374,21 @@ class FileLocalHistoryStore implements LocalHistoryStore { File file, { required LocalHistoryRevisionSummary expected, }) async { - try { - final map = (jsonDecode(await file.readAsString()) as Map) - .cast(); - if (map['version'] != formatVersion) { - throw UnsupportedLocalHistoryFormat(map['version']); - } - final summary = LocalHistoryRevisionSummary.fromJson( - (map['revision'] as Map).cast(), - ); - final source = map['source']; - if (summary.id != expected.id || - summary.documentId != expected.documentId || - source is! String || - sourceChecksum(source) != expected.checksum) { - return null; - } - return LocalHistoryRevision( - summary: expected, - source: source, - format: TextFormatMetadata.fromJson( - (map['format'] as Map).cast(), - ), - ); - } on UnsupportedLocalHistoryFormat { - rethrow; - } on Object { + final record = await _readRevisionRecord(file); + if (record == null || + record.revision.summary.id != expected.id || + record.document.id != expected.documentId || + record.revision.summary.checksum != expected.checksum || + record.revision.summary.sourceLength != expected.sourceLength || + record.revision.summary.capturedAt != expected.capturedAt || + record.revision.summary.reason != expected.reason) { return null; } + return LocalHistoryRevision( + summary: expected, + source: record.revision.source, + format: record.revision.format, + ); } Future _applyRetention( @@ -1080,8 +1399,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 +1409,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; } @@ -1101,10 +1422,17 @@ class FileLocalHistoryStore implements LocalHistoryStore { final retainedDocumentIds = { for (final revision in revisions) revision.documentId, }; - final documents = [ - for (final document in index.documents) - if (retainedDocumentIds.contains(document.id)) document, - ]; + final knownRevisionIds = { + for (final revision in index.revisions) revision.id, + ...index.tombstones, + }; + final documents = []; + for (final document in index.documents) { + if (retainedDocumentIds.contains(document.id) || + await _hasUnindexedRevision(root, document.id, knownRevisionIds)) { + documents.add(document); + } + } if (removed.isEmpty && documents.length == index.documents.length) { return; } @@ -1126,6 +1454,26 @@ class FileLocalHistoryStore implements LocalHistoryStore { // identical index afterward adds two flushes without another state change. } + Future _hasUnindexedRevision( + Directory root, + String documentId, + Set knownRevisionIds, + ) async { + final directory = Directory(p.join(root.path, 'revisions', documentId)); + if (!await directory.exists()) return false; + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File || p.extension(entity.path) != '.json') continue; + final id = p.basenameWithoutExtension(entity.path); + if (!_validId(id) || knownRevisionIds.contains(id)) continue; + // An interrupted capture can still own independently retained work. + // Preserve its document's authoritative rename/deletion metadata until + // that record is indexed or explicitly cleared, even with no other + // indexed revisions left after retention. + if (await _readRevisionRecord(entity) != null) return true; + } + return false; + } + Future _publishIndex(Directory root, _Index index) async { final journal = File(p.join(root.path, 'tombstones.json')); final tombstones = const JsonEncoder.withIndent( @@ -1137,6 +1485,9 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (!await _journalMatches(journal, tombstones)) { await _publishReplacingFile(journal, tombstones); } + // Identity changes may have no content revision. Keep them independently + // durable before publishing the disposable revision index. + await _publishDocumentIdentities(root, index.documents); await _publishReplacingFile( File(p.join(root.path, 'index.json')), const JsonEncoder.withIndent(' ').convert(index.toJson()), @@ -1226,6 +1577,13 @@ class FileLocalHistoryStore implements LocalHistoryStore { } } +class _RevisionRecord { + const _RevisionRecord({required this.document, required this.revision}); + + final LocalHistoryDocument document; + final LocalHistoryRevision revision; +} + class _ClearIntent { const _ClearIntent({required this.operationId, this.documentId, this.path}); @@ -1390,6 +1748,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 +2101,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 +2157,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 +2218,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 +2247,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 +2257,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/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index eeb5bdf4..8834eb6e 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -820,7 +820,13 @@ class BusyMarkMarkdownSerializer { } return source.toString(); } - final line = content.isEmpty ? marker : '$marker $content'; + // The pinned Markdown checkbox syntax requires whitespace after ']'. + // Without it an empty editable task reparses as an ordinary list item. + final line = contentPrefix != null && text.isEmpty + ? '$marker $contentPrefix ' + : content.isEmpty + ? marker + : '$marker $content'; if (children.isEmpty) return line; final source = StringBuffer(line); for (final child in children) { @@ -898,6 +904,13 @@ class BusyMarkMarkdownSerializer { bool _requiresTextWhitespacePreservation(BusyBlock block) { return block.attributes[busyMarkPreserveTextWhitespaceAttribute] == 'true' || + block.kind == BusyBlockKind.taskListItem && + block.inlines.every( + (inline) => + inline.kind == BusyInlineKind.text && + inline.text.isEmpty && + inline.children.isEmpty, + ) || block.children.any(_requiresTextWhitespacePreservation); } diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 16748153..6f6b75d1 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -1649,7 +1649,9 @@ class MarkdownAstAdapter { if (child is md.Element && child.tag == 'input') { return child.attributes['checked'] == 'true'; } - if (child is md.Element && child.children != null) { + // markdown's task marker belongs directly to this li or its caption p. + // Descendant lists/containers own their own checkboxes. + if (child is md.Element && child.tag == 'p' && child.children != null) { final nested = _taskChecked(child.children!); if (nested != null) { return nested; @@ -1664,7 +1666,9 @@ class MarkdownAstAdapter { for (final child in children) if (child is md.Element && child.tag == 'input') ...const [] - else if (child is md.Element && child.children != null) + else if (child is md.Element && + child.tag == 'p' && + child.children != null) md.Element(child.tag, _withoutTaskCheckbox(child.children!)) ..attributes.addAll(child.attributes) else 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..d50660c5 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 @@ -14822,6 +14880,14 @@ bool _sameWysiwygSession( left.extentBlockId == right.extentBlockId && left.extentOffset == right.extentOffset && left.viewportBlockId == right.viewportBlockId && + left.viewportLayoutSignature == right.viewportLayoutSignature && + (left.viewportEditingOffset == right.viewportEditingOffset || + (left.viewportEditingOffset != null && + right.viewportEditingOffset != null && + (left.viewportEditingOffset! - right.viewportEditingOffset!) + .abs() < + 0.1)) && + (left.viewportOffset - right.viewportOffset).abs() < 0.1 && (left.viewportAlignment - right.viewportAlignment).abs() < 0.001; } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index b6e86dfb..7b0f0cde 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show mapEquals, setEquals; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; @@ -487,22 +488,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; @@ -528,12 +513,17 @@ class WorkspaceController extends Notifier { if (note == null) { // Repository deletes only after the remote outcome is established. // An editor revision that arrived meanwhile retains its tab for recovery. - if (buffer.isDirty) buffers.add(buffer); + if (buffer.isDirty) { + buffers.add(buffer); + } else { + _settleRemovedRemoteHistory(buffer); + } continue; } if (note.syncState == NoteSyncState.deletedRemotely && !note.hasPendingChanges && !buffer.isDirty) { + _settleRemovedRemoteHistory(buffer); continue; } if (buffer.isDirty) { @@ -542,32 +532,33 @@ class WorkspaceController extends Notifier { buffer.text, ); 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, - ), - ); + final updated = 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, + ); + _localHistory.observeAuthoritativeReplacement(buffer, updated); + buffers.add(updated); } 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, - ), - ); + final updated = 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, + ); + _localHistory.observeAuthoritativeReplacement(buffer, updated); + buffers.add(updated); } } final activeId = buffers.any((b) => b.id == state.activeBufferId) @@ -614,6 +605,21 @@ class WorkspaceController extends Notifier { } } + void _settleRemovedRemoteHistory(DocumentBuffer buffer) { + unawaited( + _localHistory.flushBuffer(buffer).then((settled) async { + if (!ref.mounted || + state.documentBuffers.any((current) => current.id == buffer.id)) { + return; + } + await _localHistory.handleBufferClosed( + buffer.id, + historySettled: settled, + ); + }), + ); + } + Future openNextcloudWorkspace( String accountId, { List? restoredTabs, @@ -902,20 +908,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) { @@ -2792,6 +2798,7 @@ class WorkspaceController extends Notifier { } if (!_fileMonitorOperationIsCurrent(workspaceGeneration)) return; _workspaceDiskRevision++; + _localHistory.invalidateCurrentSourceComparisons(); _reconciledNotifications = null; final matching = state.documentBuffers.where((buffer) { final path = buffer.filePath; @@ -3126,17 +3133,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 +3814,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; @@ -4765,7 +4774,8 @@ class WorkspaceController extends Notifier { } Future deleteWorkspaceEntity(String path) async { - if (!await _protectWorkspaceEntityBeforeDelete(path)) return false; + final protection = await _protectWorkspaceEntityBeforeDelete(path); + if (protection == null) return false; String? historyOperationId; var committed = false; try { @@ -4774,6 +4784,15 @@ class WorkspaceController extends Notifier { path: path, recursive: true, filesystemOperation: () async { + if (!await _isLocalHistoryLossProtectionCurrent(protection) || + !setEquals( + await _localHistoryDeletionPaths(path), + protection.paths, + )) { + throw const LocalHistoryStorageException( + 'The files changed while Local History was protecting deletion.', + ); + } _authorizeRemovedBuffers(p.normalize(path), workspace); await _service.deleteEntity(workspace, path); _removalAuthorizations[p.normalize(path)]?.committed = true; @@ -4800,53 +4819,59 @@ class WorkspaceController extends Notifier { } } - Future _protectWorkspaceEntityBeforeDelete(String path) async { - final historyPolicy = _localHistory.policy; - if (!historyPolicy.recordingEnabled || historyPolicy.excludes(path)) { - return true; - } - final workspace = state.workspace; - if (workspace == null) return false; - final protectedPaths = {}; - for (final buffer in state.documentBuffers) { - final filePath = buffer.filePath; - if (filePath == null || - !_isEligibleLocalHistoryPath(filePath) || - historyPolicy.excludes(filePath) || - !(p.equals(filePath, path) || p.isWithin(path, filePath))) { - continue; - } - if (!await _localHistory.captureBeforeLoss( - LocalHistoryBufferSnapshot.fromBuffer(buffer), + Future<_LocalHistoryLossProtection?> _protectWorkspaceEntityBeforeDelete( + String path, + ) async { + if (state.workspace == null) return null; + try { + return await _protectLocalHistoryPaths( + await _localHistoryDeletionPaths(path), LocalHistoryCaptureReason.beforeDelete, - )) { - return false; - } - protectedPaths.add(p.normalize(filePath)); + ); + } on Object { + return null; } - for (final file in workspace.files) { - final filePath = file.absolutePath; - if (protectedPaths.contains(p.normalize(filePath)) || - !_isEligibleLocalHistoryPath(filePath) || - historyPolicy.excludes(filePath) || - !(p.equals(filePath, path) || p.isWithin(path, filePath))) { - continue; - } - try { - final load = await _service.loadTextWithSnapshot(filePath); - if (!await _localHistory.capturePathBeforeLoss( - path: filePath, - text: load.text, - format: load.format, - reason: LocalHistoryCaptureReason.beforeDelete, + } + + Future> _localHistoryDeletionPaths(String path) async { + final policy = _localHistory.policy; + if (!policy.recordingEnabled || policy.excludes(path)) return {}; + final paths = {}; + void include(String candidate) { + if (_isEligibleLocalHistoryPath(candidate) && + !policy.excludes(candidate)) { + paths.add(p.normalize(candidate)); + } + } + + // Enumerate the current filesystem, including files created since the last + // workspace refresh. Open buffers are protected even if already missing. + final type = await FileSystemEntity.type(path, followLinks: false); + if (type == FileSystemEntityType.directory) { + final directories = [Directory(path)]; + while (directories.isNotEmpty) { + await for (final entity in directories.removeLast().list( + followLinks: false, )) { - return false; + if (policy.excludes(entity.path)) continue; + if (entity is Directory) { + directories.add(entity); + } else if (entity is File) { + include(entity.path); + } } - } on Object { - return false; } + } else if (type == FileSystemEntityType.file) { + include(path); } - return true; + for (final buffer in state.documentBuffers) { + final filePath = buffer.filePath; + if (filePath != null && + (p.equals(filePath, path) || p.isWithin(path, filePath))) { + include(filePath); + } + } + return paths; } Future moveWritersideTocEntry({ @@ -5285,9 +5310,10 @@ class WorkspaceController extends Notifier { final deleting = request.analysis.mode == WritersideTopicRemovalMode.safeDeleteFile; final deletedPath = p.normalize(request.analysis.topicPath); - if (deleting && !await _protectWorkspaceEntityBeforeDelete(deletedPath)) { - return null; - } + final protection = deleting + ? await _protectWorkspaceEntityBeforeDelete(deletedPath) + : null; + if (deleting && protection == null) return null; WritersideTopicRemovalResult? result; String? historyOperationId; var historyCommitted = false; @@ -5308,11 +5334,20 @@ class WorkspaceController extends Notifier { ? await _localHistory.runStagedPathDeletion( path: deletedPath, recursive: false, - filesystemOperation: () => _service.applyWritersideTopicRemoval( - workspace, - request, - validateBeforeCommit: validate, - ), + filesystemOperation: () async { + if (!await _isLocalHistoryLossProtectionCurrent( + protection!, + )) { + throw const LocalHistoryStorageException( + 'The topic changed while Local History was protecting deletion.', + ); + } + return _service.applyWritersideTopicRemoval( + workspace, + request, + validateBeforeCommit: validate, + ); + }, didCommit: (value) => value?.deletedFile == true, onOperationStaged: (value) => historyOperationId = value, ) @@ -6194,7 +6229,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 +6249,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 +6266,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 +6285,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 +6302,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 +6315,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 +6361,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 +6371,8 @@ class WorkspaceController extends Notifier { _updateActiveText( text, sourceBufferId: sourceBufferId, + expectedSource: expectedSource, + expectedRevision: expectedRevision, sourceFilePath: sourceFilePath, rebuildPreview: _activeModeShowsPreview, previousSelection: previousSelection, @@ -6347,6 +6387,8 @@ class WorkspaceController extends Notifier { String text, { required BusyDocument document, String? sourceBufferId, + String? expectedSource, + int? expectedRevision, String? sourceFilePath, String? undoGroup, WysiwygEditorSessionState? previousWysiwygState, @@ -6355,6 +6397,8 @@ class WorkspaceController extends Notifier { _updateActiveText( text, sourceBufferId: sourceBufferId, + expectedSource: expectedSource, + expectedRevision: expectedRevision, sourceFilePath: sourceFilePath, rebuildPreview: false, liveOutline: document.outline, @@ -6466,6 +6510,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 +6527,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; @@ -7632,6 +7682,17 @@ class WorkspaceController extends Notifier { return true; } + LocalHistoryDocument _authoritativeLocalHistoryDocument( + LocalHistoryDocument document, + ) => + ref + .read(localHistoryControllerProvider) + .snapshot + .documents + .where((candidate) => candidate.id == document.id) + .firstOrNull ?? + document; + Future restoreLocalHistoryRevision({ required LocalHistoryDocument document, required LocalHistoryRevision revision, @@ -7639,20 +7700,44 @@ class WorkspaceController extends Notifier { SourceComparisonChange? change, }) async { if (revision.summary.documentId != document.id) return false; - final path = document.currentPath ?? revision.summary.historicalPath; + document = _authoritativeLocalHistoryDocument(document); + if (comparison != null) { + final current = await localHistoryCurrentSource(document, revision); + if (current.id != comparison.currentInput.id || + current.version != comparison.currentInput.version || + current.source != comparison.currentInput.source) { + return false; + } + } + final path = document.deleted ? null : document.currentPath; var buffer = localHistoryBufferForDocument(document, revision); + if (buffer != null && + !buffer.isRemote && + !document.deleted && + path != null && + (buffer.filePath == null || !p.equals(buffer.filePath!, path))) { + return false; + } if (buffer == null && document.remoteNote != null) { + final reference = document.remoteNote!; + if (state.workspace?.nextcloudAccountId != reference.accountId && + !await openNextcloudWorkspace(reference.accountId)) { + return false; + } if (!await openNextcloudNote(document.remoteNote!.localId)) return false; - buffer = state.activeBuffer; + buffer = localHistoryBufferForDocument(document, revision); } if (buffer == null && path != null && await _service.pathExists(path)) { - if (!await _openActiveFile(path)) return false; - buffer = state.activeBuffer; + if (!await _openLocalHistoryRestoreDestination(path)) return false; + buffer = localHistoryBufferForDocument(document, revision); } else if (buffer != null && state.activeBufferId != buffer.id) { if (!await activateDocumentBuffer(buffer.id)) return false; buffer = state.activeBuffer; } if (buffer == null || (buffer.isRemote && buffer.readonly)) return false; + if (comparison != null && buffer.text != comparison.currentInput.source) { + return false; + } final targetId = buffer.id; final targetPath = buffer.filePath; final targetRevision = buffer.revision; @@ -7665,7 +7750,14 @@ class WorkspaceController extends Notifier { comparison: comparison, change: change, ); - if (nextText == null || nextText == targetText) return nextText != null; + if (nextText == null) return false; + if (!buffer.isRemote) { + nextText = _withFinalNewlinePolicy( + nextText, + buffer.format.hasFinalNewline, + ); + } + if (nextText == targetText) return true; if (buffer.remoteNote != null) { try { nextText = await (await _ensureNotesRepository()) @@ -7686,19 +7778,41 @@ class WorkspaceController extends Notifier { )) { return false; } + // A peer can move or clear this lineage while protection is pending. + // Read durable authority after protection, before changing the buffer. + // Keeping acceptance before this await also preserves Clear cancellation. + LocalHistoryDocument? protectedDocument; + try { + final snapshot = await _localHistory.readRecoveryHistory(); + protectedDocument = snapshot.documents + .where((candidate) => candidate.id == document.id) + .firstOrNull; + } on Object { + return false; + } + if (protectedDocument == null || + protectedDocument.currentPath != document.currentPath || + protectedDocument.deleted != document.deleted || + protectedDocument.remoteNote != document.remoteNote) { + return false; + } final current = state.activeBuffer; if (current == null || current.id != targetId || current.filePath != targetPath || current.revision != targetRevision || - current.text != targetText) { + current.text != targetText || + (current.isRemote && current.readonly)) { return false; } final nextSelection = change == null ? TextSelection.collapsed(offset: nextText.length) : TextSelection( - baseOffset: change.currentRange.start, - extentOffset: change.currentRange.start + change.oldText.length, + baseOffset: math.min(nextText.length, change.currentRange.start), + extentOffset: math.min( + nextText.length, + change.currentRange.start + change.oldText.length, + ), ); final restored = current .edited( @@ -7740,6 +7854,7 @@ class WorkspaceController extends Notifier { canRestore: false, ); } + document = _authoritativeLocalHistoryDocument(document); final buffer = localHistoryBufferForDocument(document, revision); if (buffer != null) { return LocalHistoryCurrentSourceSnapshot( @@ -7764,7 +7879,7 @@ class WorkspaceController extends Notifier { ); } } - final path = document.currentPath ?? revision.summary.historicalPath; + final path = document.deleted ? null : document.currentPath; if (path != null && await _service.pathExists(path)) { final loaded = await _service.loadTextWithSnapshot(path); return LocalHistoryCurrentSourceSnapshot( @@ -7847,7 +7962,15 @@ class WorkspaceController extends Notifier { } return false; } - if (destination.text == revision.source) { + // Keep destination formatting while making the editor's canonical source + // agree with the bytes that Save will produce under that policy. + final restoredText = createdSnapshot != null + ? revision.source + : _withFinalNewlinePolicy( + revision.source, + destination.format.hasFinalNewline, + ); + if (destination.text == restoredText) { return true; } final target = _LocalHistoryRestoreTarget( @@ -7897,13 +8020,17 @@ class WorkspaceController extends Notifier { .firstOrNull!; final restored = current .edited( - revision.source, + restoredText, previousSelection: current.editorState.selection, - nextSelection: TextSelection.collapsed( - offset: revision.source.length, - ), + nextSelection: TextSelection.collapsed(offset: restoredText.length), ) - .copyWith(format: current.format); + .copyWith( + format: createdSnapshot == null + ? current.format + : revision.format.copyWith( + hasFinalNewline: restoredText.endsWith('\n'), + ), + ); _localHistory.observeEdit(current, restored); state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, restored), @@ -7986,27 +8113,24 @@ class WorkspaceController extends Notifier { LocalHistoryRevision revision, ) { if (revision.summary.documentId != document.id) return null; + document = _authoritativeLocalHistoryDocument(document); if (document.remoteNote case final reference?) { return state.documentBuffers .where((buffer) => buffer.remoteNote == reference) .firstOrNull; } - final boundBufferId = _localHistory.bufferIdForDocument(document.id); - if (boundBufferId != null) { - final bound = state.documentBuffers - .where((buffer) => buffer.id == boundBufferId) - .firstOrNull; - if (bound != null) return bound; - } - final paths = { - document.currentPath, - revision.summary.historicalPath, - }.whereType(); - for (final path in paths) { - final pathBuffer = state.bufferForPath(path); - if (pathBuffer != null) return pathBuffer; - } - return null; + final bound = state.documentBuffers + .where( + (buffer) => + _localHistory.documentIdForBuffer(buffer.id) == document.id, + ) + .firstOrNull; + if (bound != null) return bound; + if (document.deleted || document.currentPath == null) return null; + final pathBuffer = state.bufferForPath(document.currentPath!); + if (pathBuffer == null) return null; + final owner = _localHistory.documentIdForBuffer(pathBuffer.id); + return owner == null || owner == document.id ? pathBuffer : null; } Future _rollbackMissingHistoryDestination( @@ -8035,40 +8159,84 @@ class WorkspaceController extends Notifier { Future protectPathsBeforeExternalReplacement( Iterable paths, ) async { - for (final path in paths.toSet()) { - if (!_isEligibleLocalHistoryPath(path)) continue; + final protection = await _protectLocalHistoryPaths( + paths, + LocalHistoryCaptureReason.beforeDiscard, + ); + return protection != null && + await _isLocalHistoryLossProtectionCurrent(protection); + } + + Future<_LocalHistoryLossProtection?> _protectLocalHistoryPaths( + Iterable paths, + LocalHistoryCaptureReason reason, + ) async { + final policy = _localHistory.policy; + final protection = _LocalHistoryLossProtection(state.workspace?.id); + if (!policy.recordingEnabled) return protection; + for (final path in paths.map(p.normalize).toSet()) { + if (!_isEligibleLocalHistoryPath(path) || policy.excludes(path)) continue; + protection.paths.add(path); final buffer = state.bufferForPath(path); if (buffer != null) { if (!await _localHistory.captureBeforeLoss( LocalHistoryBufferSnapshot.fromBuffer(buffer), - LocalHistoryCaptureReason.beforeDiscard, + reason, )) { - return false; + return null; } - if (!_bufferOperationTargetIsCurrent(buffer)) return false; - continue; + protection.buffers.add(buffer); + if (!_bufferOperationTargetIsCurrent(buffer)) return null; } try { final loaded = await _service.loadTextWithSnapshot(path); - if (!await _localHistory.capturePathBeforeLoss( - path: path, - text: loaded.text, - format: loaded.format, - reason: LocalHistoryCaptureReason.beforeDiscard, - )) { - return false; - } - if (await _service.fileChangedSince(path, loaded.snapshot)) { - return false; + // Capture the actual disk source even when a stale or dirty editor is + // open. These are independent versions of the same document lineage. + if ((buffer == null || + buffer.text != loaded.text || + !mapEquals(buffer.format.toJson(), loaded.format.toJson())) && + !await _localHistory.capturePathBeforeLoss( + path: path, + text: loaded.text, + format: loaded.format, + reason: reason, + )) { + return null; } + protection.diskSnapshots[path] = loaded.snapshot; } on FileSystemException { - // A path already absent from the working tree has no current source - // version to protect. + // Only absence is safe to skip. An unreadable file still has content + // that must not be destroyed under a successful protection result. + if (await _service.pathExists(path)) return null; + protection.diskSnapshots[path] = null; + } on WorkspaceFileDecodingException catch (error) { + // Unsupported bytes need no history revision, but their classification + // must remain current. A fresh read here could already be valid text. + protection.diskSnapshots[path] = error.snapshot; } on FormatException { - // Binary or invalid text content is outside Local History's scope. + // Without the failed read's snapshot, protection cannot establish that + // the unsupported source stayed unchanged before replacement. + return null; } } - return true; + return await _isLocalHistoryLossProtectionCurrent(protection) + ? protection + : null; + } + + Future _isLocalHistoryLossProtectionCurrent( + _LocalHistoryLossProtection protection, + ) async { + for (final entry in protection.diskSnapshots.entries) { + if (entry.value == null + ? await _service.pathExists(entry.key) + : await _service.fileChangedSince(entry.key, entry.value)) { + return false; + } + } + return ref.mounted && + state.workspace?.id == protection.workspaceId && + protection.buffers.every(_bufferOperationTargetIsCurrent); } String? _restoredRegionText({ @@ -8136,12 +8304,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 +8346,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 +8476,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)) { @@ -9296,6 +9467,15 @@ class _WorkspaceRefreshBufferReplacement { final DocumentBuffer replacement; } +class _LocalHistoryLossProtection { + _LocalHistoryLossProtection(this.workspaceId); + + final String? workspaceId; + final paths = {}; + final buffers = []; + final diskSnapshots = {}; +} + class _LocalHistoryRestoreTarget { const _LocalHistoryRestoreTarget({ required this.workspaceId, @@ -9356,7 +9536,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/lib/src/workspace/workspace_file_snapshot.dart b/lib/src/workspace/workspace_file_snapshot.dart index a8193eee..7f610be5 100644 --- a/lib/src/workspace/workspace_file_snapshot.dart +++ b/lib/src/workspace/workspace_file_snapshot.dart @@ -35,6 +35,15 @@ class WorkspaceFileSnapshot { } } +/// Carries the snapshot of the exact bytes that could not be decoded. +/// Destructive operations can validate that source without retaining it as text. +class WorkspaceFileDecodingException extends FormatException { + WorkspaceFileDecodingException(FormatException cause, this.snapshot) + : super(cause.message, cause.source, cause.offset); + + final WorkspaceFileSnapshot snapshot; +} + class WorkspaceFileLoad { const WorkspaceFileLoad({ required this.text, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index d6ab1d2b..6d624ce2 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -1195,10 +1195,16 @@ class WorkspaceService { final file = File(path); final bytes = await file.readAsBytes(); final stat = await file.stat(); - final decoded = decodeUtf8Document(bytes); + final snapshot = _snapshotFromBytes(stat, bytes); + final DecodedUtf8Document decoded; + try { + decoded = decodeUtf8Document(bytes); + } on FormatException catch (error) { + throw WorkspaceFileDecodingException(error, snapshot); + } return WorkspaceFileLoad( text: decoded.text, - snapshot: _snapshotFromBytes(stat, bytes), + snapshot: snapshot, format: decoded.format, ); } diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index de562c23..a5a1c4fd 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -88,6 +88,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 677032fa..180d9241 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.6.3 +version: 0.7.0 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 16eef9ed..c330ec48 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.6.3" +version: "0.7.0" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/fixtures/structural_editing/header_test_text.md b/test/fixtures/structural_editing/header_test_text.md new file mode 100644 index 00000000..bb068b64 --- /dev/null +++ b/test/fixtures/structural_editing/header_test_text.md @@ -0,0 +1 @@ +## About the job diff --git a/test/fixtures/structural_editing/heading-properties.md b/test/fixtures/structural_editing/heading-properties.md new file mode 100644 index 00000000..00f62398 --- /dev/null +++ b/test/fixtures/structural_editing/heading-properties.md @@ -0,0 +1,5 @@ +# Topic + +## About the job {id="job" collapsible="true" default-state="expanded"} + +Body. diff --git a/test/fixtures/structural_editing/inline-consecutive-breaks.md b/test/fixtures/structural_editing/inline-consecutive-breaks.md new file mode 100644 index 00000000..afa39718 --- /dev/null +++ b/test/fixtures/structural_editing/inline-consecutive-breaks.md @@ -0,0 +1,4 @@ +**a![](first.png)![](second.png)** +
+
+*![](third.png)b* diff --git a/test/fixtures/structural_editing/inline-existing-break.md b/test/fixtures/structural_editing/inline-existing-break.md new file mode 100644 index 00000000..75b2ff3a --- /dev/null +++ b/test/fixtures/structural_editing/inline-existing-break.md @@ -0,0 +1,2 @@ +a\ +![](first.png)b diff --git a/test/fixtures/structural_editing/inline-objects.md b/test/fixtures/structural_editing/inline-objects.md new file mode 100644 index 00000000..1121301a --- /dev/null +++ b/test/fixtures/structural_editing/inline-objects.md @@ -0,0 +1 @@ +**a![](first.png)![](second.png)b**![](third.png)c diff --git a/test/fixtures/structural_editing/lists.md b/test/fixtures/structural_editing/lists.md new file mode 100644 index 00000000..af5a5d6f --- /dev/null +++ b/test/fixtures/structural_editing/lists.md @@ -0,0 +1,11 @@ +- Parent + - Child + - Grandchild + +1. Ordered parent + 1. Ordered child + 1. Ordered grandchild + +- [ ] Task parent + - [x] Task child + - [ ] Task grandchild diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 451e5d75..a0639fd9 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -53,6 +53,7 @@ import 'package:busymark/src/editor/source_highlighter.dart' show BusyMarkSourceEditingController; import 'package:busymark/src/editor/source/source_read_only_view.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/feedback/presentation/feedback_dialog.dart'; @@ -1327,6 +1328,442 @@ void main() { } }); + testWidgets( + 'heading boundary survives workspace undo, view switching, save and reopen', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'busymark-heading-boundary-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + const original = '## About the job\n'; + final file = File('${root.path}/header_test_text.md') + ..writeAsStringSync(original); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(file.path), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _waitForSearchCondition( + tester, + () => find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty, + ); + await tester.pumpAndSettle(); + final workspace = container.read(workspaceControllerProvider.notifier); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + final edited = container.read(workspaceControllerProvider).activeBuffer!; + final savedSource = edited.text; + expect(edited.editorState.undoState.undo, hasLength(1)); + expect(edited.isDirty, isTrue); + expect(savedSource, contains('## About the job')); + expect(savedSource, isNot(contains('## \n'))); + final kept = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + expect(kept.focusNode!.hasFocus, isTrue); + expect( + kept.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(container.read(workspaceControllerProvider).activeText, original); + 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); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + // External history restores source through the production parser. Blank + // paragraphs remain transient; redo must preserve the heading, not invent + // a semantic paragraph that save/reopen intentionally does not retain. + expect( + find.descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text.isEmpty == true, + ), + ), + findsNothing, + ); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + final parsed = const MarkdownParser() + .parse(filePath: file.path, source: savedSource) + .busyDocument; + expect(parsed.blocks.single.kind, BusyBlockKind.heading); + expect(parsed.blocks.single.attributes['level'], '2'); + expect(parsed.blocks.single.plainText, 'About the job'); + } + bool? saved; + unawaited(workspace.saveActive().then((value) => saved = value)); + await _waitForSearchCondition(tester, () => saved != null); + expect(saved, isTrue); + await tester.pumpAndSettle(); + expect(file.readAsStringSync(), savedSource); + bool? closed; + unawaited( + workspace.closeActiveOpenFileTab().then((value) => closed = value), + ); + await _waitForSearchCondition(tester, () => closed != null); + expect(closed, isTrue); + unawaited(workspace.openPath(file.path)); + await _waitForSearchCondition( + tester, + () => find + .widgetWithText(TextField, 'About the job') + .evaluate() + .isNotEmpty, + ); + await tester.pumpAndSettle(); + final reopened = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(reopened.text, savedSource); + expect(reopened.isDirty, isFalse); + expect( + tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document + .blocks + .single + .kind, + BusyBlockKind.heading, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + for (final count in [1, 2]) { + testWidgets( + 'inline break anchors survive workspace history, save and reopen count=$count', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'busymark-inline-break-anchors-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + const original = + '**a![](first.png)![](second.png) \n![](third.png)![](fourth.png)b**\n'; + final file = File('${root.path}/breaks.md') + ..writeAsStringSync(original); + const parser = MarkdownParser(); + BusyBlock parsed(String source) => parser + .parse(filePath: file.path, source: source) + .busyDocument + .blocks + .single; + final before = parsed(original); + expect(before.plainText, 'a\nb'); + final expected = [ + for (final anchor in busyInlineSemanticAnchors(before.inlines)) + [ + anchor.offset <= 1 ? anchor.offset : anchor.offset + count, + _inlineAnchorTree(anchor.inline), + ], + ]; + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + automaticSpelling: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(file.path), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _waitForSearchCondition( + tester, + () => find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty, + ); + await tester.pumpAndSettle(); + final workspace = container.read(workspaceControllerProvider.notifier); + final field = tester.widget( + find.widgetWithText(TextField, 'a\nb'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + if (count == 1) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } else { + final button = find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == BusyMarkGlyphs.blankLine, + ); + await tester.ensureVisible(button); + await tester.tap(button); + } + await tester.pumpAndSettle(); + final edited = container + .read(workspaceControllerProvider) + .activeBuffer!; + final savedSource = edited.text; + expect(edited.editorState.undoState.undo, hasLength(1)); + expect(_inlineAnchorPositions(parsed(savedSource)), expected); + expect(field.controller!.text, 'a${'\n' * (count + 1)}b'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + TextSelection.collapsed(offset: 1 + count), + ); + final dynamic editorState = tester.state( + find.byType(BusyMarkWysiwygEditor), + ); + expect(editorState.debugUndoSnapshotCount, 0); + expect(workspace.undoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + original, + ); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + _inlineAnchorPositions(before), + ); + expect(workspace.redoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + expected, + ); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + expect(_inlineAnchorPositions(parsed(savedSource)), expected); + } + bool? saved; + unawaited(workspace.saveActive().then((value) => saved = value)); + await _waitForSearchCondition(tester, () => saved != null); + expect(saved, isTrue); + expect(file.readAsStringSync(), savedSource); + bool? closed; + unawaited( + workspace.closeActiveOpenFileTab().then((value) => closed = value), + ); + await _waitForSearchCondition(tester, () => closed != null); + expect(closed, isTrue); + unawaited(workspace.openPath(file.path)); + await _waitForSearchCondition( + tester, + () => find + .widgetWithText(TextField, 'a${'\n' * (count + 1)}b') + .evaluate() + .isNotEmpty, + ); + await tester.pumpAndSettle(); + final reopened = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(reopened.text, savedSource); + expect(reopened.isDirty, isFalse); + expect(_inlineAnchorPositions(parsed(reopened.text)), expected); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + expected, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + testWidgets('workspace coalesces pixel viewport sessions without edits', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService('Reading target.'), + ), + startupPathProvider.overrideWithValue('/tmp/viewport-session.md'), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _pumpUntilFound(tester, find.byType(BusyMarkWysiwygEditor)); + await tester.pumpAndSettle(); + final editor = tester.widget( + find.byType(BusyMarkWysiwygEditor), + ); + final before = container.read(workspaceControllerProvider).activeBuffer!; + final saved = before.editorState.wysiwygState; + WysiwygEditorSessionState pixels(double offset) => + WysiwygEditorSessionState( + activeBlockId: saved.activeBlockId, + activeCellId: saved.activeCellId, + anchorBlockId: saved.anchorBlockId, + anchorOffset: saved.anchorOffset, + extentBlockId: saved.extentBlockId, + extentOffset: saved.extentOffset, + viewportBlockId: saved.viewportBlockId, + viewportAlignment: saved.viewportAlignment, + viewportOffset: offset, + viewportEditingOffset: -offset, + viewportLayoutSignature: saved.viewportLayoutSignature, + sourceSignature: saved.sourceSignature, + sourceBindings: saved.sourceBindings, + ); + final first = pixels(2300.25); + editor.onSessionChanged!(editor.documentId!, first); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState, + same(first), + ); + editor.onSessionChanged!(editor.documentId!, pixels(2300.26)); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState, + same(first), + ); + final second = pixels(2300.5); + editor.onSessionChanged!(editor.documentId!, second); + final after = container.read(workspaceControllerProvider).activeBuffer!; + expect(after.editorState.wysiwygState, same(second)); + expect(after.text, before.text); + expect(after.dirty, before.dirty); + expect(after.editorState.undoState, same(before.editorState.undoState)); + }); + testWidgets( 'workspace routes paste shortcuts only to editable document views', (tester) async { @@ -9263,6 +9700,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,26 +10912,1141 @@ code expect(service.savedText, '# Edited Introduction\n'); }); - testWidgets('Source groups typing and deletion undo with historical carets', ( - tester, - ) async { - const source = 'abc'; - const startupPath = '/tmp/source-grouped-undo.md'; - final settingsStore = _MemorySettingsStore() + 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: DocumentViewModePreference.source, + documentViewMode: mode, ) .toJson(); - const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(service), + 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 { + const source = 'abc'; + const startupPath = '/tmp/source-grouped-undo.md'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), startupPathProvider.overrideWithValue(startupPath), ], ); @@ -17787,6 +19345,19 @@ void _expectHeaderGeometry(WidgetTester tester) { } } +List _inlineAnchorPositions(BusyBlock block) => [ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + [anchor.offset, _inlineAnchorTree(anchor.inline)], +]; + +Object _inlineAnchorTree(BusyInline inline) => [ + inline.kind, + inline.text, + inline.destination, + inline.attributes, + [for (final child in inline.children) _inlineAnchorTree(child)], +]; + Future _pumpUntilFound(WidgetTester tester, Finder finder) async { for (var i = 0; i < 20 && finder.evaluate().isEmpty; i += 1) { await tester.runAsync( @@ -18744,6 +20315,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 +20324,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/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index e33e9edc..9977ea03 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -13,7 +13,73 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../support/desktop_focus.dart'; + void main() { + _delayedModalFocusRegressions(); + testWidgets('delayed modal barrier release respects a newer focus owner', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/focus-modal-barrier'); + final release = Completer(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'initialize') return true; + if (call.method == 'setModalBarrierDepth' && call.arguments == 0) { + await release.future; + } + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final service = LinuxHeaderBarService(channel: channel); + await service.initialize(); + final previous = FocusNode(); + final newer = FocusNode(); + addTearDown(previous.dispose); + addTearDown(newer.dispose); + late BuildContext host; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + host = context; + return Scaffold( + body: Column( + children: [ + TextField(focusNode: previous), + TextField(focusNode: newer), + ], + ), + ); + }, + ), + ), + ); + previous.requestFocus(); + await tester.pumpAndSettle(); + final result = showBusyMarkModalDialog( + host, + headerBarService: service, + builder: (_) => const Text('Modal focus test'), + ); + await tester.pumpAndSettle(); + Navigator.of(host, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + release.complete(); + await result; + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + }); + testWidgets( 'modal text input preserves local editing when an application binding overlaps it', (tester) async { @@ -527,6 +593,146 @@ class _EditorShortcutIntent extends Intent { const _EditorShortcutIntent(); } +void _delayedModalFocusRegressions() { + for (final scenario in [ + 'inactive editor', + 'inactive newer owner', + 'resumed newer owner', + 'newer owner before close', + 'newer owner then scope', + 'disposed host', + 'ordinary return', + ]) { + testWidgets('deferred modal focus respects $scenario', (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _GatedFocusBarrierService(); + addTearDown(barrier.dispose); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final previous = FocusNode(debugLabel: 'Previous editor'); + final newer = FocusNode(debugLabel: 'Newer search control'); + final text = TextEditingController(text: 'search'); + addTearDown(previous.dispose); + addTearDown(newer.dispose); + addTearDown(text.dispose); + late BuildContext host; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + host = context; + return Scaffold( + body: Column( + children: [ + TextField(focusNode: previous), + TextField(focusNode: newer, controller: text), + ], + ), + ); + }, + ), + ), + ); + previous.requestFocus(); + await tester.pumpAndSettle(); + final dialog = showBusyMarkModalDialog( + host, + headerBarService: barrier, + builder: (_) => const AlertDialog(content: Text('Dismiss focus race')), + ); + await tester.pumpAndSettle(); + Navigator.of(host, rootNavigator: true).pop(); + if (scenario == 'newer owner before close') { + newer.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + if (scenario.contains('newer')) { + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + final inactive = scenario.startsWith('inactive'); + if (inactive || scenario == 'resumed newer owner') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + if (!inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + } + if (scenario == 'newer owner then scope') { + newer.unfocus(); + await tester.pumpAndSettle(); + } + if (scenario == 'disposed host') { + await tester.pumpWidget(const SizedBox()); + } + final owner = FocusManager.instance.primaryFocus; + barrier.release.complete(); + await dialog; + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(FocusManager.instance.primaryFocus, same(owner)); + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(scenario.contains('newer') ? newer : previous), + ); + } + if (scenario.contains('newer') && scenario != 'newer owner then scope') { + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(text.text, 'search!'); + } + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + } +} + +class _GatedFocusBarrierService extends LinuxHeaderBarService { + _GatedFocusBarrierService() + : super(channel: const MethodChannel('com.busymark.test/deferred-focus')); + + final release = Completer(); + bool releaseStarted = false; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + class _FailingModalBarrierService extends LinuxHeaderBarService { final transitions = []; var _failNextShow = true; diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index cebc3d7a..8262facd 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -42,6 +42,13 @@ import 'package:yaru/yaru.dart'; import '../support/memory_rich_clipboard.dart'; +Iterable _structuralBlocks(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _structuralBlocks(block.children); + } +} + void main() { const parser = MarkdownParser(); @@ -750,6 +757,69 @@ void main() {} expect(reparsedParent.children.single.plainText, 'Changed'); }); + for (final mode in MarkdownMode.values) { + for (final marker in ['-', '3.', '- [ ]']) { + for (final loose in [false, true]) { + test( + 'nested task ownership survives parent edits $mode $marker loose=$loose', + () { + final indent = marker == '3.' ? ' ' : ' '; + final source = + '$marker Parent\n${loose ? '\n' : ''}$indent- [x] Child\n$indent - [ ] Grandchild\n'; + final document = parser + .parse(filePath: 'tasks.md', source: source, mode: mode) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + void check(BusyDocument value, String parentText) { + final blocks = _structuralBlocks(value.blocks).toList(); + final parent = blocks.singleWhere( + (b) => b.plainText == parentText, + ); + expect( + parent.kind, + marker == '3.' + ? BusyBlockKind.orderedListItem + : marker == '-' + ? BusyBlockKind.unorderedListItem + : BusyBlockKind.taskListItem, + ); + final child = _structuralBlocks( + parent.children, + ).singleWhere((b) => b.plainText == 'Child'); + final grandchild = _structuralBlocks( + child.children, + ).singleWhere((b) => b.plainText == 'Grandchild'); + expect(child.kind, BusyBlockKind.taskListItem); + expect(child.attributes['task'], 'true'); + expect(grandchild.kind, BusyBlockKind.taskListItem); + expect(grandchild.attributes['task'], 'false'); + } + + check(controller.document, 'Parent'); + final parent = _structuralBlocks( + controller.document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + controller.updateBlockText(parent.id, 'Changed'); + final saved = controller.markdown; + expect(saved, contains('[x] Child')); + expect(saved, contains('[ ] Grandchild')); + controller.rebaseCommittedSource(saved); + check(controller.document, 'Changed'); + check( + parser + .parse(filePath: 'tasks.md', source: saved, mode: mode) + .busyDocument, + 'Changed', + ); + }, + ); + } + } + } + test('WYSIWYG nested blockquote edit preserves surrounding source', () { const source = 'Before spacing\n\n' @@ -5710,6 +5780,196 @@ void main() {} }, ); + test( + 'heading leading Enter and Backspace are inverse for generated and explicit H1-H6', + () { + for (var level = 1; level <= 6; level++) { + for (final explicit in [false, true]) { + final source = + 'Intro\n\n${'#' * level} About the job${explicit ? ' {id="job"}' : ''}\n'; + final document = parser + .parse(filePath: 'heading.md', source: source) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final inserted = controller.applyEnterAt(heading.id, 0); + expect(inserted?.blockId, heading.id); + expect(controller.document.blocks[1].kind, BusyBlockKind.paragraph); + final removed = controller.applyBackspaceAtStart(heading.id); + expect(removed?.blockId, heading.id); + expect(removed?.offset, 0); + expect( + controller.document.blocks.last.attributes, + heading.attributes, + ); + expect(controller.document.blocks.last.inlines, heading.inlines); + expect(controller.markdown, source); + } + } + }, + ); + + test( + 'empty heading Enter retains the established empty-heading/end behavior', + () { + final document = parser + .parse(filePath: 'heading.md', source: '##\n') + .busyDocument; + final heading = document.blocks.single; + expect(heading.kind, BusyBlockKind.heading); + expect(heading.plainText, isEmpty); + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + controller.applyEnterAt(heading.id, 0); + expect(controller.document.blocks.map((b) => b.kind), [ + BusyBlockKind.heading, + BusyBlockKind.paragraph, + ]); + expect(controller.document.blocks.first.id, heading.id); + }, + ); + + test('newline input into an empty heading retains its end-split policy', () { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'heading.md', source: '##\n') + .busyDocument, + ); + addTearDown(controller.dispose); + final heading = controller.document.blocks.single; + controller.replaceBlockTextWithParagraphs(heading.id, '\nAbout', 6); + expect(controller.document.blocks.first.id, heading.id); + expect(controller.document.blocks.first.kind, BusyBlockKind.heading); + expect(controller.document.blocks.first.plainText, isEmpty); + expect(controller.document.blocks.last.kind, BusyBlockKind.paragraph); + expect(controller.document.blocks.last.plainText, 'About'); + }); + + test( + 'leading zero-width heading objects stay in the retained heading for both split paths', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: '## **![](first.png)![](second.png)About**\n', + ) + .busyDocument; + final heading = document.blocks.single; + expect(busyInlineSemanticAnchors(heading.inlines).map((a) => a.offset), [ + 0, + 0, + ], reason: heading.plainText); + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs(heading.id, '\n\nAbout', 2); + } else { + controller.applyEnterAt(heading.id, 0); + } + final kept = controller.document.blocks.last; + expect(kept.id, heading.id); + expect(kept.kind, BusyBlockKind.heading); + final anchors = busyInlineSemanticAnchors(kept.inlines); + expect(anchors.map((a) => a.offset), [0, 0]); + expect( + anchors.map((a) => a.inline.kind), + everyElement(BusyInlineKind.strong), + ); + expect( + anchors.expand((a) => a.inline.children).map((i) => i.destination), + ['first.png', 'second.png'], + ); + expect( + controller.document.blocks + .take(controller.document.blocks.length - 1) + .every((b) => busyInlineSemanticAnchors(b.inlines).isEmpty), + isTrue, + ); + expect(controller.markdown, contains('## **![](first.png)')); + } + }, + ); + + test( + 'Backspace does not treat a semantic-only paragraph as removable whitespace', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: 'a![](image.png)b\n\n## Heading\n', + ) + .busyDocument; + final paragraph = document.blocks.first; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + controller.updateBlockText(paragraph.id, ''); + expect(controller.blockById(paragraph.id)!.plainText, isEmpty); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, paragraph.id); + expect(controller.markdown, contains('![](image.png)')); + expect(controller.document.blocks.single.kind, BusyBlockKind.paragraph); + }, + ); + + test('nested Backspace does not cross a structural owner boundary', () { + final document = parser + .parse(filePath: 'heading.md', source: 'Outside\n\n> ## Heading\n') + .busyDocument; + final heading = document.blocks.last.children.single; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + expect(controller.applyBackspaceAtStart(heading.id), isNull); + expect(controller.markdown, 'Outside\n\n> ## Heading\n'); + }); + + test( + 'empty task items retain checkbox semantics after Enter and source rebinding', + () { + for (final mode in MarkdownMode.values) { + final document = parser + .parse(filePath: 'tasks.md', mode: mode, source: '- [ ] Parent\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final parent = controller.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.taskListItem, + orElse: () => controller.document.blocks.single.children.single, + ); + final next = controller.applyEnterAt( + parent.id, + parent.plainText.length, + )!; + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'tasks.md', source: saved, mode: mode) + .busyDocument; + final tasks = _structuralBlocks( + reopened.blocks, + ).where((b) => b.kind == BusyBlockKind.taskListItem).toList(); + expect(tasks, hasLength(2), reason: '$mode $saved'); + expect(tasks.last.plainText, isEmpty); + controller.rebaseCommittedSource(saved); + expect( + controller.blockById(parent.id)?.kind, + BusyBlockKind.taskListItem, + ); + expect( + controller.blockById(next.blockId)?.kind, + BusyBlockKind.taskListItem, + ); + } + }, + ); + test('WYSIWYG Enter splits one block into separate Markdown paragraphs', () { final parsed = parser.parse(filePath: 'topic.md', source: 'FirstSecond\n'); final controller = BusyMarkWysiwygDocumentController( 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_controller_test.dart b/test/src/local_history_controller_test.dart index 33ece3d5..3f3de584 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -22,6 +22,75 @@ import 'package:intl/intl.dart' show DateFormat; import 'package:path/path.dart' as p; void main() { + test( + 'production-store content search reads revisions in bounded batches', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-history-search-', + ); + addTearDown(() => root.delete(recursive: true)); + var locks = 0; + final store = FileLocalHistoryStore.testing( + rootDirectory: () async => root, + acquireLock: (file) async { + locks++; + await file.lock(FileLock.exclusive); + }, + ); + for (var i = 0; i < 96; i++) { + await store.capture( + LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: 'version $i', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1).add(Duration(seconds: i)), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final history = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + await history.refresh(); + locks = 0; + final missing = await history.findRetained(query: 'absent content'); + expect(missing!.matches, isEmpty); + expect(locks, 3, reason: 'One authoritative index read per 32 revisions'); + final matches = await history.findRetained(query: 'version 95'); + expect( + matches!.matches.whereType(), + hasLength(1), + ); + for (final day in [2, 3]) { + await store.capture( + LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: '$day' * (5 * 1024 * 1024), + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, day), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + await history.refresh(); + locks = 0; + expect( + (await history.findRetained(query: 'absent content'))!.matches, + isEmpty, + ); + expect( + locks, + greaterThan(3), + reason: 'Large revisions must not accumulate in a single content batch', + ); + }, + ); + test( 'failed opened baseline retries its original source without an edit', () async { diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index aa2bd855..c123fd8b 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,888 @@ void main() { maximumBytes: 16 * 1024 * 1024, ); + for (final reopen in [false, true]) { + for (final newDocument in [false, true]) { + test('unindexed capture retries after publication failure ' + '(reopen=$reopen, newDocument=$newDocument)', () async { + const captureId = 'interrupted_capture_0001'; + var fail = false; + final tested = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () { + if (fail && + Directory(p.join(root.path, 'revisions')) + .listSync(recursive: true) + .whereType() + .any( + (file) => p.basename(file.path) == '$captureId.json', + )) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + ); + } + return 'retry_identity_${++ids}'; + }, + ); + await tested.capture(request('retained', DateTime.utc(2026)), policy); + final pending = request( + 'protected', + DateTime.utc(2026, 1, 2), + path: newDocument ? '/workspace/new.md' : '/workspace/guide.md', + captureId: captureId, + force: true, + reason: LocalHistoryCaptureReason.beforeDelete, + ); + fail = true; + await expectLater( + tested.capture(pending, policy), + throwsA(isA()), + ); + expect(fail, isFalse); + final LocalHistoryStore retried = reopen + ? FileLocalHistoryStore(rootDirectory: () async => root) + : tested; + expect(await retried.readRevision(captureId), isNull); + final recovered = await retried.capture(pending, policy); + expect(recovered.revision!.id, captureId); + expect(recovered.deduplicated, isTrue); + expect((await retried.readRevision(captureId))!.source, pending.source); + expect((await retried.load()).revisions, hasLength(2)); + }); + } + } + + test( + 'follow-up orphan traversal validates renewal before retention', + () async { + final safe = await store.capture( + request('safe source', DateTime.utc(2026)), + policy, + ); + const captureId = 'near_limit_traversal_0001'; + final captured = await store.capture( + request( + '', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', captured.document.id, '$captureId.json'), + ); + final record = jsonDecode(await file.readAsString()) as Map; + final summary = record['revision'] as Map; + const encoder = JsonEncoder.withIndent(' '); + final targetBytes = policy.maximumBytes - 20; + var length = targetBytes - utf8.encode(encoder.convert(record)).length; + late String source; + late String encoded; + while (true) { + source = 'X' * length; + record['source'] = source; + summary['sourceLength'] = length; + summary['checksum'] = sourceChecksum(source); + encoded = encoder.convert(record); + final difference = targetBytes - utf8.encode(encoded).length; + if (difference == 0) break; + length += difference; + } + await file.writeAsString(encoded); + expect(await file.length(), 16777196); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + final originalIndex = await indexFile.readAsString(); + final journal = File(p.join(root.path, 'tombstones.json')); + final originalTombstones = await journal.readAsString(); + final retry = request( + source, + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + expect( + (await store.readRevision(safe.revision!.id))!.source, + 'safe source', + ); + + await expectLater( + store.capture(retry, policy), + throwsA(isA()), + ); + expect( + (await store.readRevision(safe.revision!.id))?.source, + 'safe source', + ); + expect(await store.readRevision(captureId), isNull); + expect(await indexFile.readAsString(), originalIndex); + expect(await journal.readAsString(), originalTombstones); + expect(await file.readAsString() == encoded, isTrue); + + final recovered = await store.capture( + retry, + LocalHistoryPolicy(maximumBytes: policy.maximumBytes + 8192), + ); + expect(recovered.revision!.id, captureId); + expect(recovered.revision!.protectedAt, retry.capturedAt); + expect((await store.readRevision(captureId))!.source == source, isTrue); + expect( + (await store.readRevision(safe.revision!.id))!.source, + 'safe source', + ); + expect((await store.load()).revisions, hasLength(2)); + }, + ); + + for (final afterIndex in [false, true]) { + test('follow-up cancelled orphan renewal stays retired ' + '(afterIndex=$afterIndex)', () async { + final safe = await store.capture( + request('safe', DateTime.utc(2026)), + policy, + ); + const captureId = 'cancelled_orphan_renewal_0001'; + final original = await store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', original.document.id, '$captureId.json'), + ); + final originalRecord = await file.readAsBytes(); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + bool accepted() { + if (afterIndex) { + return !((jsonDecode(indexFile.readAsStringSync()) + as Map)['revisions'] + as List) + .any((dynamic revision) => revision['id'] == captureId); + } + return (jsonDecode(file.readAsStringSync()) + as Map)['revision']['protectedAt'] == + null; + } + + await expectLater( + store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + commitGuard: accepted, + ), + policy, + ), + throwsA(isA()), + ); + expect(await file.exists(), isFalse); + expect((await store.readRevision(safe.revision!.id))!.source, 'safe'); + await expectLater( + store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ), + throwsA(isA()), + ); + // An interrupted cleanup must not allow index repair to revive it. + await file.writeAsBytes(originalRecord); + await indexFile.writeAsString('{damaged'); + expect((await store.load()).revisions.map((revision) => revision.id), [ + safe.revision!.id, + ]); + }); + } + + test( + 'follow-up orphan renewal retries after index publication failure', + () async { + final safe = await store.capture( + request('safe', DateTime.utc(2026)), + policy, + ); + const captureId = 'orphan_renewal_retry_0001'; + final original = await store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', original.document.id, '$captureId.json'), + ); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + var fail = true; + final tested = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () { + if (fail && + (jsonDecode(file.readAsStringSync()) + as Map)['revision']['protectedAt'] != + null) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + ); + } + return 'renewal_retry_identity_${++ids}'; + }, + ); + final retry = request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + await expectLater( + tested.capture(retry, policy), + throwsA(isA()), + ); + expect(fail, isFalse); + expect(await tested.readRevision(captureId), isNull); + expect((await tested.readRevision(safe.revision!.id))!.source, 'safe'); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.capture(retry, policy); + expect(recovered.revision!.id, captureId); + expect(recovered.revision!.protectedAt, retry.capturedAt); + expect((await reopened.readRevision(captureId))!.source, 'protected'); + expect((await reopened.load()).revisions, hasLength(2)); + }, + ); + + for (final damage in ['source', 'format', 'document', 'version']) { + test( + 'unindexed retry validates record $damage before publication', + () async { + const captureId = 'unindexed_validation_0001'; + final pending = request( + 'protected', + DateTime.utc(2026), + captureId: captureId, + ); + final captured = await store.capture(pending, policy); + final file = File( + p.join( + root.path, + 'revisions', + captured.document.id, + '$captureId.json', + ), + ); + final map = jsonDecode(await file.readAsString()) as Map; + switch (damage) { + case 'source': + map['source'] = 'damaged'; + case 'format': + map['format']['hasUtf8Bom'] = true; + case 'document': + map['document']['id'] = 'other_identity_0001'; + case 'version': + map['version'] = 999; + } + await file.writeAsString(jsonEncode(map)); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + index['revisions'] = []; + await indexFile.writeAsString(jsonEncode(index)); + final before = await indexFile.readAsString(); + await expectLater( + store.capture(pending, policy), + throwsA( + damage == 'version' + ? isA() + : isA(), + ), + ); + expect(await indexFile.readAsString(), before); + }, + ); + } + + test('orphan retry still respects a later clear barrier', () async { + const captureId = 'unindexed_clear_0000001'; + final acceptedAt = DateTime.now().toUtc().subtract( + const Duration(seconds: 1), + ); + final pending = LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: 'protected', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026), + reason: LocalHistoryCaptureReason.beforeDelete, + captureId: captureId, + acceptedAt: acceptedAt, + acceptedClearEpoch: '', + ); + final captured = await store.capture(pending, policy); + final file = File( + p.join(root.path, 'revisions', captured.document.id, '$captureId.json'), + ); + final bytes = await file.readAsBytes(); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + index['revisions'] = []; + await indexFile.writeAsString(jsonEncode(index)); + await store.clearDocument(captured.document.id); + await file.parent.create(recursive: true); + await file.writeAsBytes(bytes); + await expectLater( + store.capture(pending, policy), + throwsA(isA()), + ); + await indexFile.writeAsString('{damaged'); + expect((await store.load()).revisions, isEmpty); + }); + + for (final missing in [false, true]) { + test('index recovery preserves authoritative paths and deleted identities ' + '(missing=$missing)', () async { + final original = await store.capture( + request('original', DateTime.utc(2026), path: '/workspace/A.md'), + policy, + ); + await store.remapPath('/workspace/A.md', '/workspace/B.md'); + final replacement = await store.capture( + request( + 'replacement', + DateTime.utc(2026, 1, 2), + path: '/workspace/A.md', + ), + policy, + ); + await store.markDeleted('/workspace/B.md', recursive: false); + final untitled = await store.capture( + LocalHistoryCaptureRequest( + displayName: 'Untitled', + source: 'draft', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026), + reason: LocalHistoryCaptureReason.saved, + untitled: true, + ), + policy, + ); + await store.promoteUntitledDocument( + documentId: untitled.document.id, + destinationPath: '/workspace/C.md', + displayName: 'C.md', + updatedAt: DateTime.utc(2026, 1, 3), + ); + final index = File(p.join(root.path, 'index.json')); + if (missing) { + await index.delete(); + } else { + await index.writeAsString('{damaged'); + } + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.load(); + final byId = { + for (final document in recovered.documents) document.id: document, + }; + expect(byId[original.document.id]!.currentPath, '/workspace/B.md'); + expect(byId[original.document.id]!.deleted, isTrue); + expect(byId[replacement.document.id]!.currentPath, '/workspace/A.md'); + expect(byId[replacement.document.id]!.deleted, isFalse); + expect(byId[untitled.document.id]!.currentPath, '/workspace/C.md'); + expect(byId[untitled.document.id]!.untitled, isFalse); + expect( + await reopened.resolvePathTargets('/workspace/A.md', recursive: false), + hasLength(1), + ); + }); + } + + test( + 'retention preserves renamed identity metadata for an unindexed capture', + () async { + final original = await store.capture( + request('old', DateTime.utc(2026), path: '/workspace/A.md'), + policy, + ); + const captureId = 'unindexed_retention_0001'; + final pending = LocalHistoryCaptureRequest( + documentId: original.document.id, + path: '/workspace/A.md', + displayName: 'A.md', + source: 'protected source', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 2), + reason: LocalHistoryCaptureReason.beforeDelete, + captureId: captureId, + ); + await store.capture(pending, policy); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + await store.remapPath('/workspace/A.md', '/workspace/B.md'); + await store.prune(policy, DateTime.utc(2026, 1, 31, 12)); + expect((await store.load()).revisions, isEmpty); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.capture(pending, policy); + expect(recovered.document.id, original.document.id); + expect(recovered.document.currentPath, '/workspace/B.md'); + expect((await reopened.readRevision(captureId))!.source, pending.source); + }, + ); + + test('legacy identity metadata migrates before index recovery', () async { + await store.capture(request('original', DateTime.utc(2026)), policy); + await store.remapPath('/workspace/guide.md', '/workspace/moved.md'); + await File(p.join(root.path, 'documents.json')).delete(); + await store.load(); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + expect( + (await store.load()).documents.single.currentPath, + '/workspace/moved.md', + ); + }); + + test( + 'JSON expansion cannot evict retained history with an oversized record', + () async { + await store.capture(request('retained', DateTime.utc(2026)), policy); + await expectLater( + store.capture( + request('"' * (9 * 1024 * 1024), DateTime.utc(2026, 1, 2)), + policy, + ), + throwsA(isA()), + ); + await store.prune(policy, DateTime.utc(2026, 1, 2)); + final retained = (await store.load()).revisions.single; + expect((await store.readRevision(retained.id))!.source, 'retained'); + final files = Directory( + p.join(root.path, 'revisions'), + ).listSync(recursive: true).whereType(); + expect(files, hasLength(1)); + }, + ); + + test( + 'oversized traversal reuse cannot evict other revisions after a limit reduction', + () async { + final source = '"' * (9 * 1024 * 1024); + final large = await store.capture( + request(source, DateTime.utc(2026)), + const LocalHistoryPolicy(), + ); + final safe = await store.capture( + request('safe', DateTime.utc(2026, 1, 2)), + const LocalHistoryPolicy(), + ); + await expectLater( + store.capture( + request( + source, + DateTime.utc(2026), + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + captureId: large.revision!.id, + ), + policy, + ), + throwsA(isA()), + ); + expect((await store.load()).revisions, hasLength(2)); + expect((await store.readRevision(safe.revision!.id))!.source, 'safe'); + }, + ); + + test( + 'batch reads share a lock and validate the live revision index', + () async { + var locks = 0; + final tested = FileLocalHistoryStore.testing( + rootDirectory: () async => root, + acquireLock: (file) async { + locks++; + await file.lock(FileLock.exclusive); + }, + ); + final first = await tested.capture( + request('first', DateTime.utc(2026)), + policy, + ); + final second = await tested.capture( + request( + 'second', + DateTime.utc(2026, 1, 2), + path: '/workspace/second.md', + ), + policy, + ); + locks = 0; + final batch = await tested.readRevisions([ + second.revision!.id, + first.revision!.id, + ]); + expect(batch.map((revision) => revision.source), ['second', 'first']); + expect(locks, 1); + await tested.clearDocument(first.document.id); + expect( + (await tested.readRevisions([ + first.revision!.id, + second.revision!.id, + ])).map((revision) => revision.source), + ['second'], + ); + }, + ); + + 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))); @@ -1181,9 +2065,8 @@ void main() { final bytes = await revision.readAsBytes(); final before = ids; await store.prune(policy, DateTime.utc(2020, 2, 1)); - // Only the tombstone and index staging identities are allocated: their - // publication precedes cleanup and needs no identical second transaction. - expect(ids - before, 2); + // Tombstones, document identities and the index commit before cleanup. + expect(ids - before, 3); expect(await revision.exists(), isFalse); expect( jsonDecode( @@ -1210,9 +2093,9 @@ void main() { request('current', DateTime.utc(2020, 2, 1)), policy, ); - // Revision identity/stage and changed index, then the retained journal - // and index. The initial capture reuses the unchanged tombstone journal. - expect(ids - before, 5); + // Revision identity/stage, changed document identities and index, then + // retention's tombstone journal and index. Unchanged journals are reused. + expect(ids - before, 6); final snapshot = await store.load(); expect(snapshot.revisions.map((r) => r.id), [current.revision!.id]); expect( diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 3a15c517..d741f53c 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:async'; +import 'dart:convert'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; @@ -36,6 +37,1249 @@ void main() { if (await root.exists()) await root.delete(recursive: true); }); + test( + 'renamed history restores its identity without claiming a replacement path', + () async { + final original = p.join(root.path, 'A.md'); + final renamed = p.join(root.path, 'B.md'); + await File(original).writeAsString('original'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, original, 'historical'); + final revision = (await store.readRevision(captured.revision!.id))!; + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(original); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect( + await h.controller.renameWorkspaceEntity(original, 'B.md'), + isTrue, + ); + expect( + await h.controller.closeDocumentBuffer(h.state.activeBuffer!.id), + isTrue, + ); + await File(original).writeAsString('replacement A'); + expect(await h.controller.openActiveFile(original), isTrue); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + + // Exercise a retained document object from before the rename as well. + final current = await h.controller.localHistoryCurrentSource( + captured.document, + revision, + ); + expect(current.kind, LocalHistoryCurrentSourceKind.disk); + expect(current.source, 'original'); + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isTrue, + ); + expect(h.state.activeBuffer!.filePath, renamed); + expect(h.state.bufferForPath(original)!.text, 'replacement A'); + expect(await h.controller.saveActive(), isTrue); + expect(await File(renamed).readAsString(), 'historical'); + expect(await File(original).readAsString(), 'replacement A'); + }, + ); + + for (final duringCapture in [false, true]) { + test( + 'restore rejects a stale buffer after a peer changes its authoritative path ' + '(duringCapture=$duringCapture)', + () async { + final original = p.join(root.path, 'A.md'); + final moved = p.join(root.path, 'B.md'); + await File(original).writeAsString('original source'); + final store = _MutatingProtectionStore(); + final captured = await _capture(store, original, 'historical source'); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(original); + final revision = (await store.readRevision(captured.revision!.id))!; + Future move() async { + await File(original).rename(moved); + await File(original).writeAsString('replacement source'); + await store.remapPath(original, moved); + } + + if (duringCapture) { + store.afterCapture = (request) async { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + store.afterCapture = null; + await move(); + } + }; + } else { + await move(); + } + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isFalse, + ); + expect(h.state.activeText, 'original source'); + expect(await File(original).readAsString(), 'replacement source'); + expect(await File(moved).readAsString(), 'original source'); + }, + ); + } + + for (final overwrite in [false, true]) { + for (final finalNewline in [false, true]) { + test('restore and Save agree with destination formatting ' + '(overwrite=$overwrite, finalNewline=$finalNewline)', () async { + final path = p.join(root.path, 'destination.md'); + final original = finalNewline ? 'current\n' : 'current'; + await File( + path, + ).writeAsBytes([0xef, 0xbb, 0xbf, ...original.codeUnits]); + final source = finalNewline ? 'historical' : 'historical\n\n'; + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + overwrite ? p.join(root.path, 'missing.md') : path, + source, + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + expect( + overwrite + ? await h.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: path, + overwriteExisting: true, + ) + : await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isTrue, + ); + final expected = finalNewline ? 'historical\n' : 'historical'; + expect(h.state.activeText, expected); + expect(h.state.activeBuffer!.format.hasFinalNewline, finalNewline); + expect(await h.controller.saveActive(), isTrue); + final loaded = await const WorkspaceService().loadTextWithSnapshot( + path, + ); + expect(loaded.text, expected); + expect(h.state.activeText, loaded.text); + expect(h.state.activeBuffer!.lastSavedText, loaded.text); + expect(h.state.activeBuffer!.isDirty, isFalse); + expect(loaded.format.hasUtf8Bom, isTrue); + }); + } + } + + for (final deletion in [false, true]) { + for (final dirty in [false, true]) { + test('protection retains fresh disk and editor versions ' + '(deletion=$deletion, dirty=$dirty)', () async { + final path = p.join(root.path, 'protected.md'); + await File(path).writeAsString('opened source'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + if (dirty) h.controller.updateActiveText('unsaved source'); + await File(path).writeAsString('unreported external source'); + expect( + deletion + ? await h.controller.deleteWorkspaceEntity(path) + : await h.controller.protectPathsBeforeExternalReplacement([ + path, + ]), + isTrue, + ); + final snapshot = await store.load(); + final sources = await Future.wait( + snapshot.revisions.map( + (r) async => (await store.readRevision(r.id))!.source, + ), + ); + expect( + sources, + containsAll([ + 'unreported external source', + dirty ? 'unsaved source' : 'opened source', + ]), + ); + expect(await File(path).exists(), !deletion); + }); + } + } + + test( + 'recursive deletion protects files created since workspace discovery', + () async { + final folder = Directory(p.join(root.path, 'folder')); + await folder.create(); + await File(p.join(folder.path, 'known.md')).writeAsString('known'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + final unseen = p.join(folder.path, 'unseen.md'); + await File(unseen).writeAsString('newly created'); + expect(await h.controller.deleteWorkspaceEntity(folder.path), isTrue); + final snapshot = await store.load(); + final revision = snapshot.revisions.singleWhere( + (r) => r.historicalPath == unseen, + ); + expect((await store.readRevision(revision.id))!.source, 'newly created'); + }, + ); + + for (final deletion in [false, true]) { + test( + 'protection rejects disk changes during capture (deletion=$deletion)', + () async { + final path = p.join(root.path, 'racing.md'); + await File(path).writeAsString('editor source'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + await File(path).writeAsString('protected disk source'); + store.afterCapture = (request) async { + if (request.source == 'protected disk source') { + store.afterCapture = null; + await File(path).writeAsString('later external source'); + } + }; + expect( + deletion + ? await h.controller.deleteWorkspaceEntity(path) + : await h.controller.protectPathsBeforeExternalReplacement([ + path, + ]), + isFalse, + ); + expect(await File(path).readAsString(), 'later external source'); + }, + ); + } + + test( + 'deletion rechecks protected disk at the filesystem commit boundary', + () async { + final path = p.join(root.path, 'racing-delete.md'); + await File(path).writeAsString('protected source'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + store.beforePathOperation = () => + File(path).writeAsString('later source'); + expect(await h.controller.deleteWorkspaceEntity(path), isFalse); + expect(await File(path).readAsString(), 'later source'); + }, + ); + + test( + 'retained protective work recovers an unindexed record with a later retry time', + () async { + final path = p.join(root.path, 'retry.md'); + await File(path).writeAsString('current source'); + final historyRoot = Directory(p.join(root.path, 'history')); + var fail = false; + var ids = 0; + final store = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + createId: () { + if (fail && + Directory(p.join(historyRoot.path, 'revisions')) + .listSync(recursive: true) + .whereType() + .any( + (file) => + (jsonDecode(file.readAsStringSync()) + as Map)['revision']['reason'] == + 'beforeRestore', + )) { + fail = false; + throw const FileSystemException( + 'Injected publication failure', + '', + OSError('', 11), + ); + } + return 'retry_identity_${++ids}'; + }, + ); + final captured = await _capture(store, path, 'historical source'); + var now = DateTime.utc(2026, 1, 1); + final h = await _harness( + store, + fileMonitor: _ControlledFileMonitor(), + clock: () => now = now.add(const Duration(seconds: 1)), + ); + await h.controller.openPath(path); + final revision = (await store.readRevision(captured.revision!.id))!; + fail = true; + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isFalse, + ); + expect(fail, isFalse); + expect(h.state.activeText, 'current source'); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final recovered = (await store.load()).revisions.singleWhere( + (r) => r.reason == LocalHistoryCaptureReason.beforeRestore, + ); + expect( + (await store.readRevision(recovered.id))!.source, + 'current source', + ); + }, + ); + + for (final restart in [false, true]) { + test('follow-up saved capture recovers through controller retry ' + '(restart=$restart)', () async { + final path = p.join(root.path, 'saved-retry.md'); + await File(path).writeAsString('baseline'); + final historyRoot = Directory(p.join(root.path, 'history')); + var fail = false; + var ids = 0; + final store = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + createId: () { + if (fail && + Directory( + p.join(historyRoot.path, 'revisions'), + ).listSync(recursive: true).whereType().any((file) { + final record = jsonDecode(file.readAsStringSync()) as Map; + return record['revision']['reason'] == 'saved' && + record['source'] == 'saved source'; + })) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + '', + OSError('', 11), + ); + } + return 'saved_retry_identity_${++ids}'; + }, + ); + final h = await _harness( + store, + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + await h.controller.openPath(path); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final documentId = history.documentIdForBuffer(h.state.activeBuffer!.id); + h.controller.updateActiveText('saved source'); + fail = true; + expect(await h.controller.saveActive(), isTrue); + expect(fail, isFalse); + expect(await File(path).readAsString(), 'saved source'); + final pending = history.pendingSnapshotForBuffer( + h.state.activeBuffer!.id, + )!; + final captureId = pending.captureId!; + expect(await store.readRevision(captureId), isNull); + + if (restart) { + await history.handleBufferClosed( + h.state.activeBuffer!.id, + historySettled: false, + ); + final retained = history.retainedDetachedCaptures + .map( + (capture) => LocalHistoryRetainedCapture.fromJson( + (jsonDecode(jsonEncode(capture.toJson())) as Map) + .cast(), + ), + ) + .toList(); + expect(retained, hasLength(1)); + h.container.dispose(); + final reopened = await _harness( + FileLocalHistoryStore(rootDirectory: () async => historyRoot), + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + final retry = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + retry.restoreRetainedDetachedCaptures(retained); + expect(await retry.flushAll(const []), isTrue); + expect(await retry.flushAll(const []), isTrue); + expect(retry.retainedDetachedCaptures, isEmpty); + } else { + expect(await history.flushBuffer(h.state.activeBuffer!), isTrue); + expect(await history.flushBuffer(h.state.activeBuffer!), isTrue); + expect( + history.pendingSnapshotForBuffer(h.state.activeBuffer!.id), + isNull, + ); + } + final recovered = (await store.readRevision(captureId))!; + expect(recovered.source, 'saved source'); + expect(recovered.summary.reason, LocalHistoryCaptureReason.saved); + expect(recovered.summary.documentId, documentId); + expect( + (await store.load()).revisions.where( + (revision) => revision.id == captureId, + ), + hasLength(1), + ); + }); + } + + for (final changeDuringStaging in [false, true]) { + test('follow-up deletion validates initially undecodable files ' + '(changeDuringStaging=$changeDuringStaging)', () async { + final anchor = File(p.join(root.path, 'anchor.md')); + await anchor.writeAsString('anchor'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + final path = p.join(root.path, 'invalid.md'); + await File(path).writeAsBytes([0xff, 0xfe]); + if (changeDuringStaging) { + store.beforePathOperation = () async { + store.beforePathOperation = null; + await File(path).writeAsString('new valid source'); + }; + } + expect( + await h.controller.deleteWorkspaceEntity(path), + !changeDuringStaging, + ); + if (changeDuringStaging) { + expect(await File(path).readAsString(), 'new valid source'); + // A fresh operation can protect and delete the now-supported text. + expect(await h.controller.deleteWorkspaceEntity(path), isTrue); + final captured = (await store.load()).revisions.singleWhere( + (r) => r.historicalPath == path, + ); + expect( + (await store.readRevision(captured.id))!.source, + 'new valid source', + ); + } + expect(await File(path).exists(), isFalse); + }); + } + + test( + 'follow-up replacement protection detects reclassification during decoding', + () async { + final anchor = File(p.join(root.path, 'anchor.md')); + await anchor.writeAsString('anchor'); + final path = p.join(root.path, 'invalid.md'); + final service = _ReplaceUndecodableFileService(path); + final h = await _harness( + MemoryLocalHistoryStore(), + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await h.controller.openPath(root.path); + await File(path).writeAsBytes([0xff, 0xfe]); + expect( + await h.controller.protectPathsBeforeExternalReplacement([path]), + isFalse, + ); + expect(service.replaced, isTrue); + expect(await File(path).readAsString(), 'new valid source'); + }, + ); + + 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); @@ -804,7 +2048,7 @@ void main() { ); final restored = harness.state.activeBuffer!; expect(restored.filePath, destination); - expect(restored.text, revision.source); + expect(restored.text, revision.source.trimRight()); expect(restored.isDirty, isTrue); expect(restored.format.hasUtf8Bom, isTrue); expect(restored.format.lineEnding, DocumentLineEnding.crlf); @@ -814,7 +2058,7 @@ void main() { expect(harness.controller.undoActiveBuffer(), isTrue); expect(harness.state.activeText, 'Original destination\nSecond line'); expect(harness.controller.redoActiveBuffer(), isTrue); - expect(harness.state.activeText, revision.source); + expect(harness.state.activeText, revision.source.trimRight()); }, ); @@ -6022,6 +7266,174 @@ void main() { }, ); + testWidgets( + 'comparison reports unsuccessful restoration and retains the editor', + (tester) async { + final path = p.join(root.path, 'failed-restore.md'); + final h = (await tester.runAsync(() async { + await File(path).writeAsString('current source'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'historical source'); + final h = await _harness( + _FailingProtectiveStore(store), + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.selectDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return h; + }))!; + await tester.pumpWidget( + UncontrolledProviderScope( + container: h.container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: LocalHistoryComparisonView()), + ), + ), + ); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of( + tester.element(find.byType(LocalHistoryComparisonView)), + ); + final restore = find.byWidgetPredicate( + (widget) => + widget is IconButton && + widget.tooltip == l10n.localHistoryRestoreRevision, + ); + expect(tester.widget(restore).onPressed, isNotNull); + await tester.runAsync(() async { + tester.widget(restore).onPressed!(); + await _waitFor( + () => + h.container.read(localHistoryControllerProvider).warning?.kind == + LocalHistoryWarningKind.capture, + ); + await h.controller.flushPersistence(); + }); + await tester.pumpAndSettle(); + expect(h.state.activeText, 'current source'); + expect(find.text(l10n.localHistoryRestoreUnavailable), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + for (final notification in [false, true]) { + testWidgets('closed-file comparison reloads disk and missing-file actions ' + '(notification=$notification)', (tester) async { + final path = p.join(root.path, 'closed.md'); + final anchor = p.join(root.path, 'anchor.md'); + final monitor = _ControlledFileMonitor(); + final currentInputs = []; + final h = (await tester.runAsync(() async { + await File(path).writeAsString('disk original'); + await File(anchor).writeAsString('anchor'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'historical'); + final h = await _harness( + store, + fileMonitor: monitor, + comparisonComputer: (old, current) async { + currentInputs.add(current); + return compareSource(old, current); + }, + ); + await h.controller.openPath(anchor); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.inspectRetainedDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return h; + }))!; + await tester.runAsync(() async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: h.container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: LocalHistoryComparisonView()), + ), + ), + ); + await _waitFor(() => currentInputs.isNotEmpty); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, 'disk original'); + final history = h.container.read(localHistoryControllerProvider.notifier); + await tester.runAsync(() async { + await File(path).writeAsString('disk replacement'); + if (notification) { + final version = h.container + .read(localHistoryControllerProvider) + .comparisonRevision; + monitor.emitChange(path); + await _waitFor( + () => + h.container + .read(localHistoryControllerProvider) + .comparisonRevision > + version, + ); + } else { + await history.refresh(); + } + await tester.pump(); + await _waitFor(() => currentInputs.last.source == 'disk replacement'); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, 'disk replacement'); + await tester.runAsync(() async { + await File(path).delete(); + if (notification) { + final version = h.container + .read(localHistoryControllerProvider) + .comparisonRevision; + monitor.emitDeletion(path); + await _waitFor( + () => + h.container + .read(localHistoryControllerProvider) + .comparisonRevision > + version, + ); + } else { + await history.refresh(); + } + await tester.pump(); + await _waitFor(() => currentInputs.last.source.isEmpty); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, isEmpty); + final l10n = AppLocalizations.of( + tester.element(find.byType(LocalHistoryComparisonView)), + ); + expect( + find.text(l10n.localHistoryRestoreOriginalLocation), + findsOneWidget, + ); + expect(find.text(l10n.localHistoryRestoreNewLocation), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets( 'comparison renders styled intraline spans without framework errors', (tester) async { @@ -6332,6 +7744,63 @@ Future<_Harness> _harness( return _Harness(container, controller); } +class _ReplaceUndecodableFileService extends WorkspaceService { + _ReplaceUndecodableFileService(this.path); + + final String path; + var replaced = false; + + @override + Future loadTextWithSnapshot(String path) async { + try { + return await super.loadTextWithSnapshot(path); + } on FormatException { + if (p.equals(path, this.path) && !replaced) { + replaced = true; + await File(path).writeAsString('new valid source'); + } + rethrow; + } + } +} + +class _MutatingProtectionStore extends MemoryLocalHistoryStore { + Future Function(LocalHistoryCaptureRequest request)? afterCapture; + Future Function()? beforePathOperation; + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + final result = await super.capture(request, policy); + await afterCapture?.call(request); + return result; + } + + @override + Future runPathReconciliation({ + required LocalHistoryPathReconciliationKind kind, + required String sourcePath, + String? destinationPath, + required bool recursive, + Iterable? preparedTargets, + required Future Function(List targets) operation, + required bool Function(T result) didCommit, + }) async { + await beforePathOperation?.call(); + return super.runPathReconciliation( + kind: kind, + sourcePath: sourcePath, + destinationPath: destinationPath, + recursive: recursive, + preparedTargets: preparedTargets, + operation: operation, + didCommit: didCommit, + ); + } +} + class _UnavailableCaptureStore extends MemoryLocalHistoryStore { var available = false; var protectiveAttempts = 0; @@ -6527,6 +7996,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 +8797,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 +8946,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..fc063357 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,220 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + test( + 'follow-up remote restore rejects a read-only transition during protection', + () async { + var remote = serverNote(1, content: 'current source', etag: 'unchanged'); + var writes = 0; + final store = _BlockingRestoreHistoryStore(); + addTearDown(() { + if (!store.release.isCompleted) store.release.complete(); + }); + final h = await _Harness.create( + seedLocal: false, + historyStore: store, + client: MockClient((request) async { + if (request.method != 'GET') writes++; + 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(); + final before = h.state.activeBuffer!; + final captured = await store.capture( + LocalHistoryCaptureRequest( + remoteNote: before.remoteNote, + displayName: before.displayName, + source: 'historical source', + format: before.format, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final restoring = h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ); + await store.started.future; + remote = serverNote( + 1, + content: 'current source', + etag: 'unchanged', + readonly: true, + ); + await h.controller.refreshNextcloudNotes(); + expect(h.state.activeBuffer!.readonly, isTrue); + expect(h.state.activeBuffer!.revision, before.revision); + expect(h.state.activeText, before.text); + store.release.complete(); + expect(await restoring, isFalse); + expect(h.state.activeBuffer!.readonly, isTrue); + expect(h.state.activeBuffer!.revision, before.revision); + expect(h.state.activeText, before.text); + expect( + h.state.activeBuffer!.editorState.undoState, + same(before.editorState.undoState), + ); + expect( + h.repository.noteById(before.remoteNote!.localId)!.content, + before.text, + ); + expect(writes, 0); + }, + ); + + for (final flushBetween in [false, true]) { + test('remote refresh retains displaced and incoming versions after close ' + '(flushBetween=$flushBetween)', () async { + var remote = serverNote(1, content: 'R0', etag: 'v0'); + final h = await _Harness.create( + seedLocal: false, + client: MockClient( + (request) async => 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); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + for (var version = 1; version <= 2; version++) { + remote = serverNote(1, content: 'R$version', etag: 'v$version'); + await h.controller.refreshNextcloudNotes(); + expect(h.state.activeText, 'R$version'); + if (flushBetween) { + expect(await history.flushAll(h.state.documentBuffers), isTrue); + } + } + expect( + await h.controller.closeDocumentBuffer(h.state.activeBuffer!.id), + isTrue, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final store = h.container.read(localHistoryStoreProvider); + final snapshot = await store.load(); + final sources = await Future.wait( + snapshot.revisions.map( + (r) async => (await store.readRevision(r.id))!.source, + ), + ); + expect(sources, containsAll(['R0', 'R1', 'R2'])); + expect(snapshot.documents, hasLength(1)); + }); + } + + for (final dirty in [false, true]) { + test( + 'remote history restore navigates accounts safely from local workspace ' + '(dirty=$dirty)', + () async { + final h = await _Harness.create(); + addTearDown(h.dispose); + await h.controller.openNextcloudWorkspace(_accountId); + final buffer = h.state.activeBuffer!; + final store = h.container.read(localHistoryStoreProvider); + final captured = await store.capture( + LocalHistoryCaptureRequest( + remoteNote: buffer.remoteNote, + displayName: buffer.displayName, + source: 'retained remote source\n', + format: buffer.format, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final localPath = p.join(h.root.path, 'local.md'); + await File(localPath).writeAsString('local source'); + await h.controller.openPath(localPath); + if (dirty) h.controller.updateActiveText('unsaved local source'); + final current = await h.controller.localHistoryCurrentSource( + captured.document, + revision, + ); + expect(current.canRestore, isTrue); + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + !dirty, + ); + if (dirty) { + expect(h.state.activeBuffer!.filePath, localPath); + expect(h.state.activeText, 'unsaved local source'); + } else { + expect(h.state.workspace!.nextcloudAccountId, _accountId); + expect(h.state.activeBuffer!.remoteNote, buffer.remoteNote); + expect(h.state.activeText, revision.source); + expect( + h.repository.noteById(buffer.remoteNote!.localId)!.content, + revision.source, + ); + } + expect(await File(localPath).readAsString(), 'local source'); + }, + ); + } + + 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 { @@ -546,6 +760,7 @@ class _Harness { bool seedLocal = true, bool deferPersistence = false, Duration? persistenceDelay, + LocalHistoryStore? historyStore, }) async { final directory = root ?? @@ -593,7 +808,9 @@ class _Harness { const Duration(hours: 1), ), localSettingsStoreProvider.overrideWithValue(_Settings()), - localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + localHistoryStoreProvider.overrideWithValue( + historyStore ?? MemoryLocalHistoryStore(), + ), nextcloudNotesRepositoryProvider.overrideWith( (ref) async => repository, ), @@ -626,6 +843,23 @@ class _Harness { } } +class _BlockingRestoreHistoryStore extends MemoryLocalHistoryStore { + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return super.capture(request, policy); + } +} + class _Monitor extends WorkspaceFileMonitor { int starts = 0; @override diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index cbcb5814..30f22718 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'; @@ -55,8 +56,492 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; +import '../support/desktop_focus.dart'; + +import '../support/document_history_harness.dart'; void main() { + for (final wrap in [false, true]) { + testWidgets( + 'merged Source document swap retains focus scroll protection wrap=$wrap', + (tester) async { + enableDesktopFocusLifecycle(tester); + final key = GlobalKey(); + final source = List.generate( + 180, + (i) => 'line $i ${'reading words ' * 60}', + ).join('\n'); + final edits = <(String, String)>[]; + Widget host(String id) => MaterialApp( + home: Scaffold( + body: BusyMarkSourceEditor( + key: key, + text: source, + documentId: id, + filePath: '$id.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: wrap, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onCloseSearch: () {}, + onChanged: (text, _) => edits.add((id, text)), + ), + ), + ); + await tester.pumpWidget(host('first')); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + final oldFocus = tester + .widget(find.byType(TextField)) + .focusNode; + await tester.pumpWidget(host('second')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode, isNot(same(oldFocus))); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pumpAndSettle(); + expect(tester.testTextInput.hasAnyClients, isTrue); + field.scrollController!.jumpTo(1800); + final horizontal = find.byKey( + const ValueKey('source-horizontal-scroll-view'), + ); + ScrollController? horizontalController; + if (!wrap) { + horizontalController = tester + .widget(horizontal) + .controller; + horizontalController!.jumpTo(500); + } + await tester.pumpAndSettle(); + final vertical = field.scrollController!.offset; + final left = horizontalController?.offset; + expect(vertical, greaterThan(1000)); + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(field.scrollController!.offset, closeTo(vertical, 0.1)); + if (left != null) { + expect(horizontalController!.offset, closeTo(left, 0.1)); + } + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 5), + ); + }, + ); + expect(edits, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: source.replaceRange(5, 5, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(edits, [('second', source.replaceRange(5, 5, 'Z'))]); + expect(field.scrollController!.offset, lessThan(100)); + if (left != null) expect(horizontalController!.offset, lessThan(100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + testWidgets( + 'new Source scrolling takes precedence over pending session restoration', + (tester) async { + double? requested; + tester.binding.addPostFrameCallback((_) { + final position = tester + .widget(find.byType(TextField)) + .scrollController! + .position; + position.pointerScroll(600); + requested = position.pixels; + }); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: List.generate(300, (i) => 'Line $i for reading').join('\n'), + filePath: '/source-pending.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onChanged: (_, _) => fail('Scroll must not edit'), + onCloseSearch: () {}, + initialScrollOffset: 1200, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(requested, 600); + final field = tester.widget(find.byType(TextField)); + expect(field.scrollController!.offset, closeTo(requested!, 0.1)); + }, + ); + + for (final wrap in [true, false]) { + for (final composing in [false, true]) { + testWidgets( + 'desktop Source return preserves direct scroll wrap=$wrap composing=$composing', + (tester) async { + enableDesktopFocusLifecycle(tester); + final source = List.generate( + 150, + (i) => 'line $i ${'words ' * 100}', + ).join('\n'); + var edits = 0; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: source, + filePath: '/disposable-focus.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: wrap, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onChanged: (_, _) => edits++, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 8, + extentOffset: 3, + ); + await tester.pumpAndSettle(); + if (composing) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: source, + selection: const TextSelection.collapsed(offset: 5), + composing: const TextRange(start: 0, end: 9), + ), + ); + await tester.pumpAndSettle(); + } + field.scrollController!.jumpTo(1800); + final horizontal = tester + .widget( + find.byKey(const ValueKey('source-horizontal-scroll-view')), + ) + .controller!; + if (!wrap) horizontal.jumpTo(400); + await tester.pumpAndSettle(); + final verticalOffset = field.scrollController!.offset; + final horizontalOffset = horizontal.offset; + final value = field.controller!.value; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect( + field.scrollController!.offset, + closeTo(verticalOffset, 0.1), + ); + expect(horizontal.offset, closeTo(horizontalOffset, 0.1)); + expect(field.controller!.selection, value.selection); + }, + ); + expect(edits, 0); + expect(field.controller!.text, source); + tester.testTextInput.updateEditingValue( + value.copyWith( + text: source.replaceRange(3, 8, 'Z'), + selection: const TextSelection.collapsed(offset: 4), + composing: TextRange.empty, + ), + ); + await tester.pumpAndSettle(); + expect(edits, 1); + expect(field.scrollController!.offset, lessThan(100)); + expect(horizontal.offset, lessThan(100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + + 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 +9197,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/spelling_workspace_test.dart b/test/src/spelling_workspace_test.dart index cf4baf83..edaa692a 100644 --- a/test/src/spelling_workspace_test.dart +++ b/test/src/spelling_workspace_test.dart @@ -38,6 +38,7 @@ import 'package:path/path.dart' as p; import '../support/spelling_test_bundle.dart'; import '../support/widget_async.dart'; +import '../support/desktop_focus.dart'; void main() { final l10n = AppLocalizationsEn(); @@ -79,6 +80,145 @@ void main() { } }); + for (final newerOwner in [false, true]) { + testWidgets( + 'delayed spelling review return respects newerOwner=$newerOwner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _ReviewFocusBarrier(); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + barrier.dispose(); + }); + const original = 'helo world\n'; + final harness = await _pumpWorkspace( + tester, + source: original, + headerBarService: barrier, + ); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.isNotEmpty, + ); + final field = find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == 'helo world', + ); + await tester.tap(field, pointer: 1401); + final body = tester.widget(field); + await tester.sendKeyEvent(LogicalKeyboardKey.f7); + final dialog = find.byType(BusyMarkSpellingReviewDialog); + await _until(tester, () => dialog.evaluate().isNotEmpty); + final selection = body.controller!.selection; + final before = harness.workspace.activeBuffer!; + final newer = FocusNode(debugLabel: 'Search after spelling review'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + }); + await tester.tap( + find.descendant(of: dialog, matching: find.text(l10n.close)), + pointer: 1402, + ); + await _until( + tester, + () => barrier.releaseStarted && dialog.evaluate().isEmpty, + ); + if (newerOwner) { + entry = OverlayEntry( + builder: (_) => Positioned( + left: 1000, + top: 30, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + } + final after = harness.workspace.activeBuffer!; + expect(after.text, original); + expect(after.revision, before.revision); + expect(after.dirty, before.dirty); + expect( + after.editorState.undoState.undo, + before.editorState.undoState.undo, + ); + expect( + after.editorState.undoState.redo, + before.editorState.undoState.redo, + ); + expect(body.controller!.selection, selection); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = body.controller!.value; + final text = newerOwner + ? 'Search!' + : value.text.replaceRange(selection.start, selection.end, 'Z'); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: newerOwner ? 7 : selection.start + 1, + ), + ), + ); + await tester.pumpAndSettle(); + if (newerOwner) { + expect(search.text, 'Search!'); + expect(harness.workspace.activeText, original); + expect(FocusManager.instance.primaryFocus, same(newer)); + } else { + expect(harness.workspace.activeText, '$text\n'); + harness.controller.undoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == original); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.any( + (word) => + word.run.snapshot.contentRevision == + harness.workspace.activeBuffer!.revision, + ), + ); + } + if (entry != null) { + entry.remove(); + await tester.pump(); + entry.dispose(); + entry = null; + } + await tester.pumpWidget(const SizedBox.shrink()); + // Cancel post-undo preparation before the fake-clock invariants, but + // retire the native worker in the real async zone like provider teardown. + await tester.runAsync(() async => harness.spelling.dispose()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + testWidgets( 'Writerside list hard break supports underlines and spelling review', (tester) async { @@ -1575,6 +1715,7 @@ Future<_Harness> _pumpWorkspace( bool automaticSpelling = true, SpellingDictionaryFileDownload? downloadFile, SpellingCoordinatorStarter? coordinatorStarter, + LinuxHeaderBarService? headerBarService, }) async { await tester.binding.setSurfaceSize(const Size(1400, 1000)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -1629,7 +1770,9 @@ Future<_Harness> _pumpWorkspace( documentRecoveryStoreProvider.overrideWithValue( MemoryDocumentRecoveryStore(), ), - linuxHeaderBarServiceProvider.overrideWithValue(_HeaderBar()), + linuxHeaderBarServiceProvider.overrideWithValue( + headerBarService ?? _HeaderBar(), + ), systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), spellingSessionControllerProvider.overrideWith((ref) => spelling), ], @@ -1763,6 +1906,19 @@ class _HeaderBar extends LinuxHeaderBarService { Stream get actions => const Stream.empty(); } +class _ReviewFocusBarrier extends _HeaderBar { + final release = Completer(); + bool releaseStarted = false; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + Future _copyDictionaryDownload({ required Uri source, required File destination, diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 6870d3c2..46e9e618 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.6\.3$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.7\.0$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.6\.3"$', multiLine: true)), + contains(RegExp(r'^version: "0\.7\.0"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,7 +57,7 @@ void main() { ), ), ); - expect(metainfo, contains('(); + bool releaseStarted = false; + bool releaseFinished = false; + + @override + bool get isAvailable => true; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + releaseFinished = true; + } + } +} + BusyDocument _document(bool xml, {String? source}) => xml ? const WritersideEditingAdapter().parseXml( filePath: 'a.topic', @@ -692,6 +716,220 @@ void main() { await tester.pumpAndSettle(); } + for (final xml in [false, true]) { + for (final menuName in ['Writerside', 'Text style']) { + for (final range in [false, true]) { + for (final owner in ['editor', 'trigger', 'newer', 'other trigger']) { + testWidgets( + '${xml ? "XML" : "Markdown"} delayed toolbar menu $menuName range=$range owner=$owner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final result = Completer(); + final newer = FocusNode(debugLabel: 'Search during menu cleanup'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + if (!result.isCompleted) result.complete(null); + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + }); + final paragraphs = [ + 'Text', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 16}'.trimRight(), + ]; + final original = xml + ? '${paragraphs.map((p) => '

$p

').join()}
' + : '# Title\n\n${paragraphs.join('\n\n')}\n'; + final document = _document(xml, source: original); + var saved = original; + final changes = []; + final key = GlobalKey(); + await mount( + tester, + BusyMarkWysiwygEditor( + key: key, + document: document, + onSourceChanged: (_, source) { + saved = source; + changes.add(source); + }, + ), + openProperties: false, + ); + await tester.tap(_textField('Text'), pointer: 1301); + final body = tester.widget(_textField('Text')); + final selection = range + ? const TextSelection(baseOffset: 4, extentOffset: 1) + : const TextSelection.collapsed(offset: 2); + body.controller!.selection = selection; + await tester.pumpAndSettle(); + final position = Scrollable.of( + tester.element(_textField('Text')), + axis: Axis.vertical, + ).position; + position.jumpTo(2500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final reading = + _contentFields().evaluate().firstWhere((e) { + final text = (e.widget as TextField).controller!.text; + final y = tester.getTopLeft(find.byWidget(e.widget)).dy; + return text.startsWith('Reading ') && + y > 100 && + y < 650; + }).widget + as TextField; + final readingText = reading.controller!.text; + final readingTop = tester.getTopLeft(_textField(readingText)).dy; + final generation = key.currentState!.spellingDocumentGeneration; + Finder menu(String name) => find.byWidgetPredicate( + (w) => w is BusyMarkHeaderPopupMenuButton && w.tooltip == name, + ); + FocusNode trigger(String name) { + final focus = tester.widget( + find + .descendant( + of: menu(name), + matching: find.byWidgetPredicate((w) => w is Focus), + ) + .last, + ); + return Focus.of(tester.element(find.byWidget(focus.child))); + } + + var presented = false; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method != 'show') return true; + presented = true; + return result.future; + }); + await tester.tap(menu(menuName), pointer: 1302); + await tester.pumpAndSettle(); + expect(presented, isTrue); + FocusNode expected = body.focusNode!; + switch (owner) { + case 'editor': + FocusManager.instance.primaryFocus?.unfocus(); + case 'trigger': + trigger(menuName).requestFocus(); + case 'other trigger': + expected = trigger( + menuName == 'Writerside' ? 'Text style' : 'Writerside', + ); + expected.requestFocus(); + case 'newer': + entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + expected = newer; + newer.requestFocus(); + } + FocusManager.instance.applyFocusChangesIfNeeded(); + if (owner == 'trigger') { + expect( + FocusManager.instance.primaryFocus, + same(trigger(menuName)), + ); + } else if (owner == 'newer' || owner == 'other trigger') { + expect(FocusManager.instance.primaryFocus, same(expected)); + } + // This is the same document, not the stale-document test above. + expect(key.currentState!.widget.document, same(document)); + expect(key.currentState!.spellingDocumentGeneration, generation); + result.complete(null); + for (var frame = 0; frame < 30; frame++) { + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect(FocusManager.instance.primaryFocus, same(expected)); + expect(position.pixels, closeTo(pixels, 0.1)); + expect( + tester.getTopLeft(_textField(readingText)).dy, + closeTo(readingTop, 0.1), + ); + } + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + if (owner == 'other trigger') return; + expect(tester.testTextInput.hasAnyClients, isTrue); + final text = owner == 'newer' + ? 'Search!' + : 'Text'.replaceRange(selection.start, selection.end, '!'); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: owner == 'newer' ? 7 : selection.start + 1, + ), + ), + ); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(expected)); + if (owner == 'newer') { + expect(search.text, 'Search!'); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Search!!', + selection: TextSelection.collapsed(offset: 8), + ), + ); + await tester.pumpAndSettle(); + expect(search.text, 'Search!!'); + expect(FocusManager.instance.primaryFocus, same(newer)); + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(position.pixels, closeTo(pixels, 0.1)); + } else { + expect(saved, original.replaceFirst('Text', text)); + expect(changes, hasLength(1)); + expect(key.currentState!.debugUndoSnapshotCount, 1); + expect( + tester.getTopLeft(_textField(text)).dy, + inInclusiveRange(0, 850), + ); + final edited = saved; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + 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); + await tester.pumpAndSettle(); + expect(saved, edited); + } + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + } + } + for (final xml in [false, true]) { testWidgets( '${xml ? 'XML' : 'Markdown'} canceled native menu restores editing focus and selection', @@ -806,6 +1044,186 @@ void main() { ); } } + for (final xml in [false, true]) { + for (final label in ['UI Control', 'File / Path']) { + for (final newerOwner in [false, true]) { + testWidgets( + '${xml ? "XML" : "Markdown"} delayed semantic cancellation $label newerOwner=$newerOwner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _GatedSemanticDialogBarrier(); + final newer = FocusNode(debugLabel: 'Search after semantic dialog'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + barrier.dispose(); + }); + final paragraphs = [ + 'Text', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 16}'.trimRight(), + ]; + final original = xml + ? '${paragraphs.map((p) => '

$p

').join()}
' + : '# Title\n\n${paragraphs.join('\n\n')}\n'; + var saved = original; + final changes = []; + final key = GlobalKey(); + await mount( + tester, + BusyMarkWysiwygEditor( + key: key, + document: _document(xml, source: original), + headerBarService: barrier, + onSourceChanged: (_, source) { + saved = source; + changes.add(source); + }, + ), + openProperties: false, + ); + await tester.tap(_textField('Text'), pointer: 1201); + final body = tester.widget(_textField('Text')); + const selection = TextSelection.collapsed(offset: 2); + body.controller!.selection = selection; + await tester.pumpAndSettle(); + final position = Scrollable.of( + tester.element(_textField('Text')), + axis: Axis.vertical, + ).position; + position.jumpTo(2500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final reading = + _contentFields().evaluate().firstWhere((e) { + final text = (e.widget as TextField).controller!.text; + final y = tester.getTopLeft(find.byWidget(e.widget)).dy; + return text.startsWith('Reading ') && y > 100 && y < 650; + }).widget + as TextField; + final readingText = reading.controller!.text; + final readingTop = tester.getTopLeft(_textField(readingText)).dy; + final generation = key.currentState!.spellingDocumentGeneration; + expect(key.currentState!.debugUndoSnapshotCount, 0); + labels.add(label); + await tester.tap( + find.byKey(const ValueKey('wysiwyg-writerside')), + pointer: 1202, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('wysiwyg-semantic-text')), + findsOneWidget, + ); + await tester.tap(find.text('Cancel'), pointer: 1203); + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + expect( + find.byKey(const ValueKey('wysiwyg-semantic-text')), + findsNothing, + ); + if (newerOwner) { + entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + expect(key.currentState!.spellingDocumentGeneration, generation); + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + // A gated release can finish after the route animation has + // stopped. Drive the render phase so post-frame restoration + // really runs, rather than only advancing the fake clock. + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + expect(position.pixels, closeTo(pixels, 0.1)); + expect( + tester.getTopLeft(_textField(readingText)).dy, + closeTo(readingTop, 0.1), + ); + } + expect(barrier.releaseFinished, isTrue); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(tester.testTextInput.hasAnyClients, isTrue); + // Use the existing IME connection; enterText would refocus and + // conceal a cancellation callback that stole the newer owner. + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: newerOwner ? 'Search!' : 'Te!xt', + selection: TextSelection.collapsed(offset: newerOwner ? 7 : 3), + ), + ); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + if (newerOwner) { + expect(search.text, 'Search!'); + expect(saved, original); + expect(changes, isEmpty); + expect(body.controller!.selection, selection); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(position.pixels, closeTo(pixels, 0.1)); + } else { + expect(saved, original.replaceFirst('Text', 'Te!xt')); + expect(changes, hasLength(1)); + expect(key.currentState!.debugUndoSnapshotCount, 1); + expect( + tester.getTopLeft(_textField('Te!xt')).dy, + inInclusiveRange(0, 850), + ); + final edited = saved; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + 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); + await tester.pumpAndSettle(); + expect(saved, edited); + } + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + } for (final xml in [true, false]) { for (final pair in const [ ('UI Control', 'control'), @@ -848,6 +1266,8 @@ void main() { await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(saved, original); + expect(body.focusNode!.hasPrimaryFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); expect( body.controller!.selection, const TextSelection.collapsed(offset: 2), diff --git a/test/src/writerside_native_list_inspector_test.dart b/test/src/writerside_native_list_inspector_test.dart index ebb3d241..a7f828cb 100644 --- a/test/src/writerside_native_list_inspector_test.dart +++ b/test/src/writerside_native_list_inspector_test.dart @@ -29,6 +29,75 @@ List lists(BusyDocument d) => walk(d.blocks).where((b) => b.attributes['element'] == 'list').toList(); void main() { + for (final marker in ['-', '3.', '- [ ]']) { + for (final clear in [false, true]) { + test( + 'leading Enter retains native $marker parent ownership after publication clear=$clear', + () { + final indent = marker == '3.' ? ' ' : ' '; + final c = BusyMarkWysiwygDocumentController( + document: parse( + '$marker Parent\n$indent- Child\n$indent - Grandchild\n\n{id="owner"}\n', + ), + ); + addTearDown(c.dispose); + final parent = walk( + c.document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + final childIds = walk(parent.children).map((b) => b.id).toList(); + if (clear) { + c.updateBlockText(parent.id, ''); + c.rebaseCommittedSource(c.markdown); + } + final result = c.applyEnterAt(parent.id, 0); + expect(result?.blockId, parent.id); + final published = c.markdown; + c.rebaseCommittedSource(published); + expect(c.markdown, published); + final retained = c.blockById(parent.id)!; + expect(retained.kind, parent.kind); + if (marker == '3.') expect(retained.attributes['marker'], '4.'); + expect( + walk( + retained.children, + ).where((b) => childIds.contains(b.id)).map((b) => b.id), + childIds, + ); + final owner = c.elementPath(parent.id).reversed.skip(1).first; + expect(owner.attributes['busymark-list-form'], 'markdown'); + expect(owner.attributes['id'], 'owner'); + expect(owner.children.first.id, isNot(parent.id)); + expect(owner.children.first.children, isEmpty); + final reopened = parse(published); + expect( + walk(reopened.blocks).where((b) => b.plainText == 'Child'), + hasLength(1), + ); + expect( + walk(reopened.blocks).where((b) => b.plainText == 'Grandchild'), + hasLength(1), + ); + }, + ); + } + } + + test('an authored empty paragraph is not discarded by heading Backspace', () { + final c = BusyMarkWysiwygDocumentController( + document: parse('# Topic\n\nClear\n{id="authored"}\n\n## Heading\n'), + ); + addTearDown(c.dispose); + final paragraph = c.document.blocks.singleWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + final heading = c.document.blocks.last; + c.updateBlockText(paragraph.id, ''); + final result = c.applyBackspaceAtStart(heading.id); + expect(result?.blockId, paragraph.id); + expect(c.blockById(paragraph.id)!.attributes['id'], 'authored'); + expect(c.markdown, contains('id="authored"')); + }); + test( 'block lookup follows edits and replacement revisions with reused IDs', () { diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 1c8c2117..2afc1e0e 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -1113,6 +1113,169 @@ void main() { await tester.pump(); } + for (final entryPath in ['plain paste', 'text input']) { + testWidgets( + 'whole-heading $entryPath newline replacement with identical suffix is not a prepend', + (tester) async { + const original = '## About the job\n'; + BusyDocument? live; + var source = original; + await mount( + tester, + 'heading-identical-suffix', + original, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 13, + ); + await tester.pump(); + if (entryPath == 'plain paste') { + systemData = {'text': '\nAbout the job'}; + await key(tester, LogicalKeyboardKey.keyV, shift: true); + } else { + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\nAbout the job', + selection: TextSelection.collapsed(offset: 14), + ), + ); + } + await tester.pumpAndSettle(); + expect( + live!.blocks.map((b) => b.kind), + everyElement(BusyBlockKind.paragraph), + ); + expect(source, isNot(contains('##'))); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(source, original); + }, + ); + + for (final end in [0, 5, 13]) { + testWidgets( + 'heading boundary $entryPath replaces prefix $end without losing structure', + (tester) async { + const original = '## About the job {id="job"}\n'; + var source = original; + BusyDocument? live; + var publications = 0; + await mount(tester, 'heading-boundary', original, (value) { + source = value; + publications++; + }, onDocumentChanged: (value) => live = value); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = TextSelection( + baseOffset: 0, + extentOffset: end, + ); + await tester.pump(); + if (entryPath == 'plain paste') { + systemData = {'text': '\n'}; + await key(tester, LogicalKeyboardKey.keyV, shift: true); + } else { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: '\n${'About the job'.substring(end)}', + selection: const TextSelection.collapsed(offset: 1), + ), + ); + } + await tester.pumpAndSettle(); + expect(publications, 1); + expect(live!.blocks.first.kind, BusyBlockKind.paragraph); + if (end < 13) { + final heading = live!.blocks.last; + expect(heading.kind, BusyBlockKind.heading); + expect(heading.attributes['id'], 'job'); + expect(heading.plainText, 'About the job'.substring(end)); + final target = tester.widget( + find.widgetWithText(TextField, heading.plainText), + ); + expect(target.focusNode!.hasFocus, isTrue); + expect( + target.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: 'X${heading.plainText}', + selection: const TextSelection.collapsed(offset: 1), + ), + ); + await tester.pumpAndSettle(); + expect(source, contains('## X${heading.plainText}')); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + } else { + expect( + live!.blocks.where((b) => b.kind == BusyBlockKind.heading), + isEmpty, + ); + } + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(source, original); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + expect(live!.blocks.first.kind, BusyBlockKind.paragraph); + expect( + live!.blocks.last.kind, + end < 13 ? BusyBlockKind.heading : BusyBlockKind.paragraph, + ); + }, + ); + } + } + + testWidgets( + 'accepted replacement does not leave stale full-heading selection context', + (tester) async { + var source = '## About the job\n'; + BusyDocument? live; + await mount( + tester, + 'heading-selection-context', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 13, + ); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Other the job', + selection: TextSelection.collapsed(offset: 13), + ), + ); + await tester.pumpAndSettle(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Other the job\n', + selection: TextSelection.collapsed(offset: 14), + ), + ); + await tester.pumpAndSettle(); + expect(live!.blocks.first.kind, BusyBlockKind.heading); + expect(live!.blocks.first.plainText, 'Other the job'); + expect(source, startsWith('## Other the job\n')); + }, + ); + for (final plainText in [false, true]) { testWidgets( 'text-only URL paste then ordinary Enter recognizes a live link (plainText=$plainText)', diff --git a/test/src/wysiwyg_selection_lifecycle_test.dart b/test/src/wysiwyg_selection_lifecycle_test.dart index cb7a6cef..2aa34250 100644 --- a/test/src/wysiwyg_selection_lifecycle_test.dart +++ b/test/src/wysiwyg_selection_lifecycle_test.dart @@ -4,17 +4,27 @@ import 'dart:convert'; import 'package:busymark/src/clipboard/clipboard_insertion.dart'; import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:busymark/src/visualization/visualization_providers.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../support/memory_rich_clipboard.dart'; +import '../support/desktop_focus.dart'; import '../support/wysiwyg_editable_harness.dart'; const _table = @@ -56,6 +66,28 @@ class _DelayedClipboard extends MemoryRichClipboard { } } +class _LifecycleMathHost implements WebRenderHost { + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async => { + 'results': [ + for (final expression in expressions) + { + 'id': expression['id'], + 'svg': + '', + 'width': 20, + 'height': 14, + 'depth': 2, + }, + ], + }; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Finder _field(String text) => find.byWidgetPredicate((w) => w is TextField && w.controller?.text == text); @@ -90,19 +122,29 @@ Future _mount( MemoryRichClipboard clipboard, { WysiwygEditorSessionState session = const WysiwygEditorSessionState(), bool externalHistory = false, + bool renderMath = false, BusyMarkClipboardInsertionRegistry? registry, + LinuxHeaderBarService? headerBarService, }) async { await tester.binding.setSurfaceSize(const Size(1000, 1100)); addTearDown(() => tester.binding.setSurfaceSize(null)); final key = GlobalKey(); await tester.pumpWidget( - WysiwygEditableHarness( - key: key, - source: source, - clipboard: clipboard, - session: session, - externalHistory: externalHistory, - insertionRegistry: registry, + ProviderScope( + overrides: [ + if (headerBarService != null) + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + if (renderMath) + webRenderHostProvider.overrideWithValue(_LifecycleMathHost()), + ], + child: WysiwygEditableHarness( + key: key, + source: source, + clipboard: clipboard, + session: session, + externalHistory: externalHistory, + insertionRegistry: registry, + ), ), ); await tester.pumpAndSettle(); @@ -250,7 +292,1481 @@ void _expectTableGeometry(WidgetTester tester) { } } +void _deferredExtentFocusRegressions() { + for (final table in [false, true]) { + testWidgets( + 'deferred extent preserves new reading geometry and input table=$table', + (tester) async { + enableDesktopFocusLifecycle(tester); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + final source = + '${table ? _surrounded : '$_before\n\nMiddle selected.\n\n$_after\n'}\n${[for (var i = 0; i < 80; i++) 'Reading $i ${'words ' * 12}'.trimRight()].join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, _before, 2), + pointer: 1101, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret(tester, table ? 'End!' : _after, table ? 4 : 5), + ); + await tester.pumpAndSettle(); + final saved = state.session; + final position = tester + .state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + await gesture.up(); + position.jumpTo(2500); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < 90; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + expect(position.pixels, closeTo(2500, 0.1)); + } + final anchorText = tester + .widgetList(find.byType(TextField)) + .where((w) => w.controller!.text.startsWith('Reading ')) + .map((w) => w.controller!.text) + .firstWhere( + (text) => + tester.getTopLeft(_field(text)).dy > 100 && + tester.getTopLeft(_field(text)).dy < 900, + ); + final anchorTop = tester.getTopLeft(_field(anchorText)).dy; + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(position.pixels, closeTo(2500, 0.1)); + expect( + tester.getTopLeft(_field(anchorText)).dy, + closeTo(anchorTop, 0.1), + ); + } + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .length, + lessThan(state.document.blocks.length), + ); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.anchorOffset, saved.anchorOffset); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, saved.extentOffset); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.source, startsWith('BeZ')); + final changedText = state.document.blocks.first.plainText; + expect(_caret(tester, changedText, 3).dy, inInclusiveRange(0, 1100)); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, source); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + testWidgets( + 'deferred toolbar restoration honors a pending newer focus request', + (tester) async { + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final newer = FocusNode(debugLabel: 'Pending search focus'); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: newer)), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + tester.binding.addPostFrameCallback((_) => newer.requestFocus()); + state.editorKey.currentState!.restoreEditingFocus(); + tester.binding.scheduleFrame(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + expect(state.changes, isEmpty); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + newer.dispose(); + }, + ); + for (final inactive in [false, true]) { + testWidgets( + 'deferred toolbar dialog completion preserves newer owner inactive=$inactive', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _DelayedSelectionBarrier(); + addTearDown(barrier.dispose); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final paragraphs = [ + 'Original editing target.', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 12}'.trimRight(), + ]; + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + headerBarService: barrier, + ); + final target = tester.widget(_field(paragraphs.first)); + target.controller!.selection = const TextSelection( + baseOffset: 8, + extentOffset: 2, + ); + await tester.pumpAndSettle(); + final position = tester + .state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(3500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final other = FocusNode( + debugLabel: 'Search selected during dialog release', + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + final link = find.byWidgetPredicate( + (w) => w is BusyMarkHeaderIconButton && w.icon == BusyMarkGlyphs.link, + ); + await tester.ensureVisible(link); + await tester.tap(link, pointer: 1102); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('wysiwyg-link-destination-field')), + findsOneWidget, + ); + Navigator.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + rootNavigator: true, + ).pop(); + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + other.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(other)); + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + } + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(inactive ? FocusManager.instance.rootScope : other), + ); + expect(position.pixels, closeTo(pixels, 0.1)); + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + expect(FocusManager.instance.primaryFocus, same(other)); + expect( + target.controller!.selection, + const TextSelection(baseOffset: 8, extentOffset: 2), + ); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + for (final (table, reverse) in [ + for (final table in [false, true]) + for (final reverse in [false, true]) (table, reverse), + ]) { + for (final scenario in [ + 'newer owner', + 'inactive', + 'inactive cancel', + 'inactive disposal', + 'newer owner inactive', + 'lifecycle round trip', + 'newer owner then scope', + 'document replacement', + 'disposal', + 'ordinary completion', + 'new caret', + ]) { + testWidgets( + 'deferred extent focus table=$table reverse=$reverse respects $scenario', + (tester) async { + enableDesktopFocusLifecycle(tester); + addTearDown(() { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final original = table + ? _surrounded + : '$_before\n\nMiddle selected.\n\n$_after\n'; + final state = await _mount(tester, original, MemoryRichClipboard()); + final other = FocusNode(debugLabel: 'Newer search owner'); + final text = TextEditingController(text: 'search'); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + top: 0, + left: 0, + width: 200, + height: 40, + child: Material( + child: TextField(focusNode: other, controller: text), + ), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + final gesture = await tester.startGesture( + _caret( + tester, + reverse ? (table ? 'End!' : _after) : _before, + reverse ? (table ? 4 : 5) : 2, + ), + pointer: 1103, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret( + tester, + reverse ? _before : (table ? 'End!' : _after), + reverse ? 2 : (table ? 4 : 5), + ), + ); + await tester.pumpAndSettle(); + final selection = state.session; + expect( + selection.anchorBlockId, + reverse + ? state.document.blocks[table ? 1 : 2].id + : state.document.blocks.first.id, + ); + expect(selection.extentBlockId, isNot(selection.anchorBlockId)); + if (scenario == 'inactive cancel') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + await gesture.cancel(); + } else { + await gesture.up(); + } + // Deliver a real focus change before the pending post-frame handoff. + if (scenario.contains('newer')) { + other.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + expect(FocusManager.instance.primaryFocus, same(other)); + } + final inactive = scenario.contains('inactive'); + if (inactive || scenario == 'lifecycle round trip') { + if (scenario != 'inactive cancel') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + } + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + if (!inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + } + } + if (scenario == 'newer owner then scope') { + other.unfocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } + if (scenario == 'document replacement') { + state.replaceSource('Replacement document.\n', id: 'replacement'); + } + if (scenario == 'new caret') { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + if (scenario.contains('disposal')) { + entry.remove(); + await tester.pumpWidget(const SizedBox()); + } + final expectedOwner = FocusManager.instance.primaryFocus; + for ( + var frame = 0; + frame < (scenario == 'inactive cancel' ? 90 : 30); + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + if (inactive || scenario.contains('newer')) { + expect(FocusManager.instance.primaryFocus, same(expectedOwner)); + } + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + if (scenario.contains('newer') && + scenario != 'newer owner then scope') { + expect(FocusManager.instance.primaryFocus, same(other)); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(text.text, 'search!'); + } else if (scenario == 'inactive' || + scenario == 'inactive cancel' || + scenario == 'lifecycle round trip' || + scenario == 'ordinary completion') { + expect(state.session.anchorBlockId, selection.anchorBlockId); + expect(state.session.anchorOffset, selection.anchorOffset); + expect(state.session.extentBlockId, selection.extentBlockId); + expect(state.session.extentOffset, selection.extentOffset); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.source, startsWith('${_before.substring(0, 2)}Z')); + } else if (scenario == 'new caret') { + expect(_selected(tester), isEmpty); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.document.blocks.first.plainText, _before); + } + if (!scenario.contains('disposal')) entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + text.dispose(); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } +} + +class _DelayedSelectionBarrier extends LinuxHeaderBarService { + _DelayedSelectionBarrier() + : super( + channel: const MethodChannel( + 'com.busymark.test/selection-modal-release', + ), + ); + final release = Completer(); + bool releaseStarted = false; + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + void main() { + _deferredExtentFocusRegressions(); + for (final externalHistory in [false, true]) { + for (final reverse in [false, true]) { + testWidgets( + 'non-editable selection endpoint settles and accepts paste external=$externalHistory reverse=$reverse', + (tester) async { + const original = 'First\n\n---\n'; + final document = WysiwygEditableHarnessState.parser + .parse(filePath: '/editable.md', source: original) + .busyDocument; + final saved = _documentSession(document, reverse: reverse); + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + final state = await _mount( + tester, + original, + MemoryRichClipboard(), + session: saved, + externalHistory: externalHistory, + registry: registry, + ); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.anchorOffset, saved.anchorOffset); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, saved.extentOffset); + if (!reverse) { + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'BusyMark WYSIWYG document selection', + ); + } + final top = tester.getTopLeft(_field('First')); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.getTopLeft(_field('First')), top); + } + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.source, original); + expect(state.changes, isEmpty); + expect(state.undoSources, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + expect( + await registry.paste( + BusyMarkClipboardPayload( + id: 'non-editable-endpoint', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.text, + text: 'Replacement', + ), + mode: BusyMarkPasteMode.plainText, + ), + ClipboardPasteResult.inserted, + ); + await tester.pumpAndSettle(); + expect(state.source, 'Replacement\n'); + expect(state.changes, hasLength(1)); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'ReplacementX', + selection: TextSelection.collapsed(offset: 12), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, 'ReplacementX\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, 'Replacement\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, original); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, 'Replacement\n'); + }, + ); + } + } + testWidgets('Select All ending at a rule settles and accepts command paste', ( + tester, + ) async { + const original = 'First\n\n---\n'; + final clipboard = MemoryRichClipboard() + ..data = const RichClipboardData(text: 'Replacement'); + final state = await _mount(tester, original, clipboard); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + expect(state.source, original); + expect(state.changes, isEmpty); + expect(state.session.extentBlockId, state.document.blocks.last.id); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'BusyMark WYSIWYG document selection', + ); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect(state.source, 'Replacement\n'); + expect(state.changes, hasLength(1)); + expect(tester.testTextInput.hasAnyClients, isTrue); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, original); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, 'Replacement\n'); + }); + testWidgets( + 'short table input after desktop return reveals the original cell', + (tester) async { + enableDesktopFocusLifecycle(tester); + final source = + 'Before.\n\n| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}').join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final field = tester.widget(_field('Focus cell')); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pumpAndSettle(); + final scrollable = Scrollable.of( + tester.element(_field('Focus cell')), + axis: Axis.vertical, + ); + // Beyond the package's two-viewport cache, not only outside the screen. + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final reading = scrollable.position.pixels; + for (var cycle = 0; cycle < 4; cycle++) { + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: cycle == 2 ? 90 : 1, + verifyViewport: () => + expect(scrollable.position.pixels, closeTo(reading, 0.1)), + ); + } + expect(state.session.viewportEditingOffset, lessThan(-4000)); + expect(() => jsonEncode(state.session.toJson()), returnsNormally); + final readingField = find + .byWidgetPredicate( + (w) => + w is TextField && + w.controller?.text.startsWith('Reading ') == true, + ) + .evaluate() + .firstWhere((e) { + final top = tester.getTopLeft(find.byWidget(e.widget)).dy; + return top > 100 && top < 900; + }); + final readingText = (readingField.widget as TextField).controller!.text; + final readingTop = tester.getTopLeft(_field(readingText)).dy; + state.recreate( + WysiwygEditorSessionState.fromJson(state.session.toJson()), + ); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(_field(readingText)).dy, + closeTo(readingTop, 0.1), + ); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Focusz cell', + selection: TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, contains('| Focusz cell |')); + expect(_caret(tester, 'Focusz cell', 6).dy, inInclusiveRange(0, 1100)); + expect( + Scrollable.of( + tester.element(_field('Focusz cell')), + axis: Axis.vertical, + ).position.pixels, + lessThan(reading - 100), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets('queued toolbar restoration respects application suspension', ( + tester, + ) async { + enableDesktopFocusLifecycle(tester); + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field('Original target.')); + state.editorKey.currentState!.restoreEditingFocus(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(field.focusNode)); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets( + 'table Tab deliberately reveals the next cell after passive restoration', + (tester) async { + enableDesktopFocusLifecycle(tester); + final cells = List.generate(12, (i) => 'Cell $i ${'wide ' * 20}'.trim()); + final table = + '|${List.generate(12, (i) => 'Head $i').join('|')}|\n' + '|${List.filled(12, '---').join('|')}|\n' + '|${cells.join('|')}|\n'; + final source = + '$table\n${List.generate(100, (i) => 'Reading $i ${'words ' * 30}').join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final first = tester.widget(_field(cells.first)); + final next = tester.widget(_field(cells[1])); + final nextEditable = _editable(tester, _field(cells[1])); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pumpAndSettle(); + final vertical = Scrollable.of( + tester.element(_field(cells.first)), + axis: Axis.vertical, + ).position; + final horizontal = next.scrollController!.position; + expect(horizontal.maxScrollExtent, greaterThan(100)); + // Compare the intentional reveal with a fresh Tab, rather than assuming + // that the framework's caret/pixel rounding necessarily chooses zero. + horizontal.jumpTo(horizontal.maxScrollExtent); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + final freshHorizontal = horizontal.pixels; + first.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + horizontal.jumpTo(horizontal.maxScrollExtent); + vertical.jumpTo(2000); + await tester.pumpAndSettle(); + final reading = vertical.pixels; + final cellReading = horizontal.pixels; + await pumpDesktopFocusCycle( + tester, + target: first.focusNode!, + verifyViewport: () { + expect(vertical.pixels, closeTo(reading, 0.1)); + expect(horizontal.pixels, closeTo(cellReading, 0.1)); + }, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(next.focusNode)); + expect(nextEditable.mounted, isTrue); + final caret = nextEditable.renderEditable.localToGlobal( + nextEditable.renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + expect(caret.dx, inInclusiveRange(0, 1000)); + expect(caret.dy, inInclusiveRange(0, 1100)); + expect(horizontal.pixels, closeTo(freshHorizontal, 0.1)); + expect(state.changes, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + for (final inactive in [false, true]) { + testWidgets( + 'document refresh preserves an existing competing focus owner inactive=$inactive', + (tester) async { + enableDesktopFocusLifecycle(tester); + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final saved = state.session; + final other = FocusNode( + debugLabel: 'Existing search or inspector input', + ); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 0, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + other.requestFocus(); + await tester.pumpAndSettle(); + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + } + state.replaceSource('Replacement target.\n', restoredSession: saved); + await tester.pumpAndSettle(); + if (inactive) { + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + expect(FocusManager.instance.primaryFocus, same(other)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search continues', + selection: TextSelection.collapsed(offset: 16), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, isEmpty); + expect(state.source, 'Replacement target.\n'); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final navigation in [false, true]) { + testWidgets( + 'new input wins while passive reveal is pending navigation=$navigation', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.idle(); + if (navigation) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 4), + ); + expect( + _caret(tester, paragraphs.first, 4).dy, + inInclusiveRange(0, 1100), + ); + } else { + scrollable.position.pointerScroll(200); + final offset = scrollable.position.pixels; + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + } + } + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final nested in [false, true]) { + testWidgets( + 'session retains partial tall paragraph geometry nested=$nested', + (tester) async { + final text = List.generate( + 500, + (i) => 'paragraph words $i for reading', + ).join(' '); + final source = nested ? '> $text\n\nAfter.\n' : '$text\n\nAfter.\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(2300.25); + await tester.pumpAndSettle(); + final top = tester.getTopLeft(_field(text)).dy; + expect(top, lessThan(-2000)); + state.recreate( + WysiwygEditorSessionState.fromJson(state.session.toJson()), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(_field(text)).dy, closeTo(top, 0.1)); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + } + + for (final reverse in [false, true]) { + testWidgets( + 'desktop cross-block selection retains endpoints reverse=$reverse and replaces with composition', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final original = '${paragraphs.join('\n\n')}\n'; + final state = await _mount(tester, original, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, paragraphs[reverse ? 2 : 0], reverse ? 9 : 5), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret(tester, paragraphs[reverse ? 0 : 2], reverse ? 5 : 9), + ); + await gesture.up(); + await tester.pumpAndSettle(); + final session = state.session; + final targetText = paragraphs[reverse ? 0 : 2]; + final field = tester.widget(_field(targetText)); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(state.session.anchorBlockId, session.anchorBlockId); + expect(state.session.extentBlockId, session.extentBlockId); + expect(state.session.anchorOffset, session.anchorOffset); + expect(state.session.extentOffset, session.extentOffset); + }, + ); + expect(state.changes, isEmpty); + final caret = field.controller!.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: targetText.replaceRange(caret, caret, 'ni'), + selection: TextSelection.collapsed(offset: caret + 2), + composing: TextRange(start: caret, end: caret + 2), + ), + ); + await tester.pumpAndSettle(); + final expected = + '${paragraphs.first.substring(0, 5)}你${paragraphs[2].substring(9)}'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: expected, + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + '${[expected, ...paragraphs.skip(3)].join('\n\n')}\n', + ); + expect(_caret(tester, expected, 6).dy, inInclusiveRange(0, 1100)); + final changed = state.source; + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, isNot(changed)); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, changed); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + testWidgets('dismissed dialog preserves a table cell selection', ( + tester, + ) async { + final state = await _mount(tester, _table, MemoryRichClipboard()); + final field = tester.widget(_field('Body text')); + field.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + field.controller!.selection = const TextSelection( + baseOffset: 7, + extentOffset: 2, + ); + await tester.pumpAndSettle(); + final dialog = showBusyMarkModalDialog( + tester.element(find.byType(BusyMarkWysiwygEditor)), + builder: (_) => const AlertDialog(content: Text('Cancel cell dialog')), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await dialog; + expect( + field.controller!.selection, + const TextSelection(baseOffset: 7, extentOffset: 2), + ); + expect(field.focusNode!.hasFocus, isTrue); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('desktop math source remains mounted across prolonged inactivity', ( + tester, + ) async { + enableDesktopFocusLifecycle(tester); + const math = r'Focus $x^2$ target'; + final state = await _mount( + tester, + '$math\n\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}'.trimRight()).join('\n\n')}\n', + MemoryRichClipboard(), + renderMath: true, + ); + tester + .widget(find.byType(ScrollablePositionedList)) + .itemScrollController! + .jumpTo(index: 0); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey( + ValueKey('wysiwyg-rendered-math-${state.document.blocks.first.id}'), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget(_field(math)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(field.focusNode!.context?.mounted, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + }, + ); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('queued editor focus restoration respects a newer focus owner', ( + tester, + ) async { + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final other = FocusNode( + debugLabel: 'Competing search or inspector control', + ); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 0, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + state.editorKey.currentState!.restoreEditingFocus(); + other.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(other)); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + expect(state.changes, isEmpty); + }); + + testWidgets( + 'session recreation preserves reading with an offscreen caret and immediate input', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 5, + ); + await tester.pumpAndSettle(); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final index = list.itemPositionsNotifier!.itemPositions.value + .firstWhere((p) => p.itemLeadingEdge > 0 && p.itemLeadingEdge < 0.8) + .index; + final top = tester.getTopLeft(_field(paragraphs[index])).dy; + final saved = WysiwygEditorSessionState.fromJson(state.session.toJson()); + state.recreate(saved); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(_field(paragraphs[index])).dy, + closeTo(top, 0.1), + ); + expect(state.session.anchorOffset, 10); + expect(state.session.extentOffset, 5); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: paragraphs.first.replaceRange(5, 10, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + startsWith(paragraphs.first.replaceRange(5, 10, 'Z')), + ); + expect(state.changes, hasLength(1)); + }, + ); + + testWidgets( + 'canceled nested menu preserves the offscreen caret and viewport', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + final menu = showBusyMarkMenu( + context: tester.element(find.byType(BusyMarkWysiwygEditor)), + anchorRect: const Rect.fromLTWH(100, 100, 10, 10), + focusFirst: true, + items: const [ + BusyMarkSubmenuItem( + label: 'Choices', + items: [PopupMenuItem(value: 'edit', child: Text('Edit'))], + ), + ], + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + } + expect(await menu, isNull); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + expect(state.changes, isEmpty); + }, + ); + + for (final kind in ['list', 'code', 'table']) { + testWidgets( + 'desktop $kind preserves selection and inner and document scroll', + (tester) async { + enableDesktopFocusLifecycle(tester); + final text = 'Focus target ${'long text ' * 60}'.trimRight(); + final first = switch (kind) { + 'list' => '- $text\n', + 'code' => '```text\n$text\n```\n', + _ => '| Header | Other |\n| --- | --- |\n| $text | Body |\n', + }; + final source = + '$first\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}'.trimRight()).join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final field = tester.widget(_field(text)); + field.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 4, + ); + await tester.pumpAndSettle(); + if (kind == 'table') field.scrollController!.jumpTo(150); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + final innerOffset = field.scrollController!.offset; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(field.scrollController!.offset, closeTo(innerOffset, 0.1)); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 10, extentOffset: 4), + ); + }, + ); + expect(state.changes, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text.replaceRange(4, 10, 'Z'), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, contains(text.replaceRange(4, 10, 'Z'))); + expect(state.changes, hasLength(1)); + expect(scrollable.position.pixels, lessThan(offset - 100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final below in [false, true]) { + for (final inactiveFrames in [1, 90]) { + testWidgets( + 'desktop return preserves offscreen caret below=$below inactive=$inactiveFrames', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final targetIndex = below ? 70 : 0; + list.itemScrollController!.jumpTo(index: targetIndex); + await tester.pumpAndSettle(); + final field = tester.widget( + _field(paragraphs[targetIndex]), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed( + offset: 5, + ); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo( + scrollable.position.pixels + (below ? -5000 : 5000), + ); + await tester.pumpAndSettle(); + final readingIndex = list.itemPositionsNotifier!.itemPositions.value + .firstWhere( + (p) => p.itemLeadingEdge > 0 && p.itemLeadingEdge < 0.8, + ) + .index; + final top = tester.getTopLeft(_field(paragraphs[readingIndex])).dy; + final selection = field.controller!.selection; + final saved = state.session; + for (var cycle = 0; cycle < 3; cycle++) { + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: inactiveFrames, + verifyViewport: () { + expect(_field(paragraphs[readingIndex]), findsOneWidget); + expect( + tester.getTopLeft(_field(paragraphs[readingIndex])).dy, + closeTo(top, 0.1), + ); + expect(field.controller!.selection, selection); + }, + ); + } + expect(state.session.activeBlockId, saved.activeBlockId); + expect(state.changes, isEmpty); + expect(state.undoSources, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: paragraphs[targetIndex].replaceRange(5, 5, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect( + state.source, + contains(paragraphs[targetIndex].replaceRange(5, 5, 'Z')), + ); + final caret = _caret( + tester, + paragraphs[targetIndex].replaceRange(5, 5, 'Z'), + 6, + ); + expect(caret.dy, inInclusiveRange(0, 1100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + + testWidgets( + 'dismissed dialog preserves the offscreen editing target and viewport', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'words ' * 20}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 4, + ); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final pixels = scrollable.position.pixels; + final result = showBusyMarkModalDialog( + tester.element(find.byType(BusyMarkWysiwygEditor)), + builder: (context) => + const AlertDialog(content: Text('Cancel this dialog')), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(pixels, 0.1)); + } + await result; + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 10, extentOffset: 4), + ); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + + testWidgets('session preserves position well inside an oversized block', ( + tester, + ) async { + final text = List.generate( + 180, + (i) => 'line $i has enough text for the viewport', + ).join('\n'); + final state = await _mount( + tester, + '```text\n$text\n```\n\nAfter.\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(text)); + field.focusNode!.unfocus(); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(2300); + await tester.pumpAndSettle(); + final top = tester.getTopLeft(_field(text)).dy; + expect(top, lessThan(-1000)); + final saved = WysiwygEditorSessionState.fromJson(state.session.toJson()); + state.recreate(saved); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(_field(text)).dy, closeTo(top, 0.1)); + expect(state.changes, isEmpty); + }); + + 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..27428123 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,352 @@ 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 table in [false, true]) { + testWidgets( + 'merged text history preserves every clipped viewport anchor table=$table', + (tester) async { + final history = await DocumentHistoryHarness.create(); + addTearDown(history.dispose); + final text = table + ? 'Focus cell' + : List.generate(180, (i) => 'line $i for reading').join('\n'); + final source = table + ? '| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n${List.generate(100, (i) => '| row $i | reading |').join('\n')}\n\nAfter.\n' + : '```text\n$text\n```\n\nAfter.\n'; + history.controller.updateActiveText(source); + var document = parser + .parse(filePath: 'viewport.md', source: source) + .busyDocument; + final key = GlobalKey(); + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + documentId: history.buffer.id, + useExternalUndoHistory: true, + readEditBase: history.readBase, + readSessionState: () => history.buffer.editorState.wysiwygState, + onUndo: history.controller.undoActiveBuffer, + onRedo: history.controller.redoActiveBuffer, + onSessionChanged: (id, session) => + history.controller.updateDocumentEditorState( + id, + history.buffer.editorState.copyWith(wysiwygState: session), + ), + onDocumentChanged: (value) => document = value, + onSourceChanged: (_, _) {}, + onTransactionalSourceChanged: (_, value, group) => + history.controller.updateActiveWysiwygText( + value, + document: document, + undoGroup: group, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == text, + ), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 4); + await tester.pumpAndSettle(); + Scrollable.of( + field.focusNode!.context!, + axis: Axis.vertical, + ).position.jumpTo(2300); + await tester.pumpAndSettle(); + final reading = history.buffer.editorState.wysiwygState; + expect(reading.viewportOffset, greaterThan(1000)); + expect(reading.viewportEditingOffset, lessThan(-1000)); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text.replaceRange(4, 4, 'Z'), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + // Inspect the authoritative pre-edit entry before any parent/frame restore. + final before = + history.buffer.editorState.undoState.undo.last.wysiwygState; + expect(before.anchorOffset, 4); + expect(before.viewportOffset, reading.viewportOffset); + expect(before.viewportEditingOffset, reading.viewportEditingOffset); + expect(before.viewportLayoutSignature, reading.viewportLayoutSignature); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + test( + 'viewport pixel anchors round trip while older sessions remain readable', + () { + final old = WysiwygEditorSessionState.fromJson({ + 'viewportBlockId': 'old', + 'viewportAlignment': 0.2, + }); + expect(old.viewportOffset, 0); + expect(old.viewportEditingOffset, isNull); + const session = WysiwygEditorSessionState( + activeBlockId: 'edit', + viewportBlockId: 'reading', + viewportOffset: 2300.25, + viewportEditingOffset: -5000.5, + viewportLayoutSignature: 'layout', + sourceSignature: 'source', + sourceBindings: { + 'reading': (start: 4, end: 9, kind: 'paragraph', tag: 'p'), + }, + ); + final restored = WysiwygEditorSessionState.fromJson(session.toJson()); + expect(restored.toJson(), session.toJson()); + expect(restored.viewportOffset, 2300.25); + expect(restored.viewportEditingOffset, -5000.5); + expect(restored.sourceBindings, session.sourceBindings); + }, + ); + + 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..6ae1c69b 100644 --- a/test/src/wysiwyg_structured_regression_test.dart +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -1,4 +1,5 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/assets/document_media_context.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; @@ -18,6 +19,8 @@ import '../support/memory_rich_clipboard.dart'; void main() { const parser = MarkdownParser(); + _structuralBoundaryRegressions(parser); + _hardBreakAnchorRegressions(parser); test( 'formatting a URL suffix retains the complete live link and round trips', @@ -1489,6 +1492,7 @@ Body. key: key, document: document, useExternalUndoHistory: true, + undoClock: () => Duration.zero, onSourceChanged: (_, _) {}, onTransactionalSourceChanged: (_, value, group) { groups.add(group); @@ -1498,6 +1502,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'); @@ -1543,6 +1553,1246 @@ Body. }); } +void _hardBreakAnchorRegressions(MarkdownParser parser) { + group('explicit line-break anchors', () { + const fixtures = [ + ( + name: 'existing hard break', + source: 'a \n![](first.png)b\n', + text: 'a\nb', + objects: 1, + ), + ( + name: 'consecutive hard breaks', + source: 'a\n
\n
\n![](first.png)b\n', + text: 'a\n\nb', + objects: 1, + ), + ( + name: 'objects before at and after the caret', + source: + '![](first.png)a![](second.png) \n![](third.png)![](fourth.png)b![](last.png)\n', + text: 'a\nb', + objects: 5, + ), + ( + name: 'formatted objects after existing break', + source: '**a \n![](first.png)![](second.png)b**![](third.png)c\n', + text: 'a\nbc', + objects: 3, + ), + ( + name: 'formatted objects at shared offsets on either side', + source: + '**a![](first.png)![](second.png)** \n*![](third.png)![](fourth.png)b*\n', + text: 'a\nb', + objects: 4, + ), + ]; + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.gfm, + MarkdownMode.writersideMarkdown, + ]) { + for (final fixture in fixtures) { + for (final count in [1, 2]) { + for (var offset = 1; offset < fixture.text.length; offset++) { + test( + '${mode.name} ${fixture.name} count=$count offset=$offset', + () { + final document = parser + .parse( + filePath: 'breaks.md', + source: fixture.source, + mode: mode, + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, fixture.text); + final anchors = _individualImageAnchors(block); + expect(anchors, hasLength(fixture.objects)); + final safeOffset = offset.clamp(0, fixture.text.length); + final expected = [ + for (final anchor in anchors) + [ + anchor.offset <= safeOffset + ? anchor.offset + : anchor.offset + count, + _inlineSignatures([anchor.inline]), + ], + ]; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (count == 1) { + controller.insertHardBreak(block.id, offset); + } else { + controller.insertBlankLine(block.id, offset); + } + final expectedText = fixture.text.replaceRange( + safeOffset, + safeOffset, + '\n' * count, + ); + void check(BusyBlock edited) { + expect(edited.plainText, expectedText); + expect(_anchoredInlineSignatures(edited), expected); + } + + check(controller.document.blocks.single); + final saved = controller.markdown; + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + check(controller.document.blocks.single); + final reopened = parser + .parse(filePath: 'breaks.md', source: saved, mode: mode) + .busyDocument; + check(reopened.blocks.single); + }, + ); + } + } + } + } + + for (final count in [1, 2]) { + testWidgets('toolbar count=$count publishes once and restores history', ( + tester, + ) async { + const original = '**a \n![](first.png)![](second.png)b**\n'; + final document = parser + .parse(filePath: 'breaks.md', source: original) + .busyDocument; + final before = document.blocks.single; + final originalAnchors = _anchoredInlineSignatures(before); + final expected = [ + for (final anchor in _individualImageAnchors(before)) + [ + anchor.offset + count, + _inlineSignatures([anchor.inline]), + ], + ]; + BusyDocument live = document; + var source = original; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'a\nb'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + final button = find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == + (count == 1 + ? BusyMarkGlyphs.hardBreak + : BusyMarkGlyphs.blankLine), + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(publications, 1); + final editedSource = source; + expect(_anchoredInlineSignatures(live.blocks.single), expected); + expect(field.controller!.text, 'a${'\n' * (count + 1)}b'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + TextSelection.collapsed(offset: 1 + count), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 1, + ); + expect( + _anchoredInlineSignatures( + parser + .parse(filePath: 'breaks.md', source: editedSource) + .busyDocument + .blocks + .single, + ), + expected, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, original); + expect(_anchoredInlineSignatures(live.blocks.single), originalAnchors); + 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); + await tester.pumpAndSettle(); + expect(source, editedSource); + expect(_anchoredInlineSignatures(live.blocks.single), expected); + }); + } + }); +} + +List _anchoredInlineSignatures(BusyBlock block) => [ + for (final anchor in _individualImageAnchors(block)) + [ + anchor.offset, + _inlineSignatures([anchor.inline]), + ], +]; + +List _individualImageAnchors(BusyBlock block) { + final anchors = []; + var offset = 0; + void visit(BusyInline inline, List ancestors) { + if (inline.kind == BusyInlineKind.image) { + var wrapped = inline; + for (final ancestor in ancestors.reversed) { + wrapped = ancestor.copyWith(text: '', children: [wrapped]); + } + anchors.add(BusyInlineSemanticAnchor(offset: offset, inline: wrapped)); + offset += inline.plainText.length; + } else if (inline.children.isNotEmpty) { + for (final child in inline.children) { + visit(child, [...ancestors, inline]); + } + } else { + offset += inline.plainText.length; + } + } + + for (final inline in block.inlines) { + visit(inline, const []); + } + return anchors; +} + +void _structuralBoundaryRegressions(MarkdownParser parser) { + group('structural boundaries', () { + test( + 'ordinary edits preserve formatting around zero-width inline objects on reopen', + () { + final document = parser + .parse( + filePath: 'image.md', + source: '**a![](first.png)![](second.png)b**![](third.png)c\n', + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, 'abc'); + final expected = busyInlineSemanticAnchors( + block.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(); + expect(expected, hasLength(3)); + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.updateBlockText(block.id, 'abcX'); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'image.md', source: saved) + .busyDocument + .blocks + .single; + expect(reopened.plainText, 'abcX', reason: saved); + expect( + busyInlineSemanticAnchors( + reopened.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(), + expected, + reason: saved, + ); + }, + ); + + test( + 'Writerside Backspace inverse after source publication removes the inserted paragraph source', + () { + const source = + '# Topic\n\n## About the job {id="job" collapsible="true" default-state="expanded"}\n\nBody.\n'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + addTearDown(controller.dispose); + final heading = controller.document.blocks.singleWhere( + (b) => b.plainText == 'About the job', + ); + final entered = controller.applyEnterAt(heading.id, 0)!; + expect(entered.blockId, heading.id); + controller.rebaseCommittedSource(controller.markdown); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, heading.id); + expect( + controller.document.blocks.where( + (b) => b.kind == BusyBlockKind.paragraph && b.plainText.isEmpty, + ), + isEmpty, + ); + expect(controller.markdown, source); + controller.rebaseCommittedSource(controller.markdown); + expect(controller.markdown, source); + expect( + controller.blockById(heading.id)!.attributes['collapsible'], + 'true', + ); + }, + ); + + for (final icon in [BusyMarkGlyphs.hardBreak, BusyMarkGlyphs.blankLine]) { + testWidgets('read-only line-break toolbar $icon cannot create history', ( + tester, + ) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + DocumentReadOnlyScope( + readOnly: true, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + final button = find.byWidgetPredicate( + (w) => w is BusyMarkHeaderIconButton && w.icon == icon, + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(publications, 0); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + expect(field.controller!.text, 'About the job'); + }); + } + + testWidgets( + 'read-only Enter cannot create structural history or disturb focus', + (tester) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + DocumentReadOnlyScope( + readOnly: true, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 0); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 1), + ); + }, + ); + + test('table cells reject document-block splitting and sibling merging', () { + final document = parser + .parse( + filePath: 'table.md', + source: '| A | B |\n| --- | --- |\n| one | two |\n', + ) + .busyDocument; + final cell = document.blocks.single.children.last.children.last; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + final before = controller.markdown; + expect(controller.applyBackspaceAtStart(cell.id), isNull); + expect(controller.applyEnterAt(cell.id, 0), isNull); + expect(controller.markdown, before); + }); + + for (final scope in BusyWysiwygReplacementScope.values) { + for (final end in [5, 13]) { + test( + 'range replacement newline scope=$scope end=$end is position-aware', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: '## About the job {id="job"}\n', + ) + .busyDocument; + final heading = document.blocks.single; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final result = controller.replaceTextSelectionWithText( + firstBlockId: heading.id, + firstStartOffset: 0, + lastBlockId: heading.id, + lastEndOffset: end, + removedBlockIds: [heading.id], + replacementText: '\n', + activeInlineKinds: const [], + preserveTextWhitespace: true, + replacementScope: scope, + ); + expect(result, isNotNull); + if (end == 13) { + expect( + controller.document.blocks.map((b) => b.kind), + everyElement(BusyBlockKind.paragraph), + ); + } else { + expect(controller.document.blocks.last.id, heading.id); + expect( + controller.document.blocks.last.kind, + BusyBlockKind.heading, + ); + expect(controller.document.blocks.last.plainText, ' the job'); + } + }, + ); + } + } + + testWidgets( + 'rejected text-input list exit cannot fall back to a local newline', + (tester) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'Parent'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 6, + ); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\n', + selection: TextSelection.collapsed(offset: 1), + ), + ); + await tester.pumpAndSettle(); + expect(publications, 0); + expect(field.controller!.text, 'Parent'); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + }, + ); + + for (final input in [true, false]) { + testWidgets( + 'merged rejected structural ${input ? "input" : "backspace"} preserves an existing Redo', + (tester) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + var source = document.source!; + var publications = 0; + final key = GlobalKey(); + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'Parent'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed( + offset: 6, + ); + await tester.pumpAndSettle(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'ParentX', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(field.controller!.text, 'Parent'); + final before = source; + final count = publications; + final undoCount = key.currentState!.debugUndoSnapshotCount; + field.controller!.selection = input + ? const TextSelection(baseOffset: 0, extentOffset: 6) + : const TextSelection.collapsed(offset: 0); + await tester.pump(); + if (input) { + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\n', + selection: TextSelection.collapsed(offset: 1), + ), + ); + } else { + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + } + await tester.pumpAndSettle(); + expect(source, before); + expect(publications, count); + expect(key.currentState!.debugUndoSnapshotCount, undoCount); + 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); + await tester.pumpAndSettle(); + expect(source, contains('ParentX')); + expect(field.controller!.text, 'ParentX'); + }, + ); + } + + for (final selectedEnd in [5, 15]) { + testWidgets( + 'local selection Enter at heading prefix $selectedEnd is one edit', + (tester) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + final heading = document.blocks.single; + BusyDocument? live; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(ValueKey('wysiwyg-field-heading.md-${heading.id}')), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = TextSelection( + baseOffset: 0, + extentOffset: selectedEnd.clamp(0, heading.plainText.length), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 1); + if (selectedEnd < heading.plainText.length) { + expect(live!.blocks.last.id, heading.id); + expect(live!.blocks.last.kind, BusyBlockKind.heading); + expect( + live!.blocks.last.plainText, + heading.plainText.substring(selectedEnd), + ); + } else { + expect( + live!.blocks.where((b) => b.kind == BusyBlockKind.heading), + isEmpty, + ); + } + }, + ); + } + + testWidgets('rejected Enter over attributed sole list member is atomic', ( + tester, + ) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + final member = _blocksDepthFirst( + document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + BusyDocument? live; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(ValueKey('wysiwyg-field-topic.md-${member.id}')), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 6, + ); + await tester.pump(); + final beforeUndo = + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount; + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 0); + expect(live, isNull); + expect(field.controller!.text, 'Parent'); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + beforeUndo, + ); + // A later edit publishes the model too: do not mask a staged deletion. + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Parent!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect( + _blocksDepthFirst(live!.blocks).where((b) => b.plainText == 'Parent!'), + hasLength(1), + ); + }); + + test( + 'property publication followed by nested split preserves source and edited properties', + () { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '# Topic\n\n> AlphaBeta\n\n{id="quote"}\n', + ) + .busyDocument, + ); + addTearDown(controller.dispose); + final quote = controller.document.blocks.last; + final paragraph = quote.children.single; + expect( + controller.updateWritersideProperty(quote.id, 'id', 'changed'), + isTrue, + ); + final committed = controller.markdown; + controller.rebaseCommittedSource(committed); + controller.applyEnterAt(paragraph.id, 5); + final saved = controller.markdown; + expect(saved, contains('> Alpha\n>\n> Beta')); + expect(saved, contains('id="changed"')); + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + }, + ); + + test( + 'protected and generated blocks reject direct structural operations', + () { + final parsed = parser + .parse(filePath: 'heading.md', source: '## About\n') + .busyDocument; + for (final block in [ + parsed.blocks.single.copyWith(isSourceProtected: true), + parsed.blocks.single.copyWith(isSourceOnly: true), + parsed.blocks.single.copyWith(isGenerated: true), + parsed.blocks.single.copyWith(preserveRaw: true), + ]) { + final controller = BusyMarkWysiwygDocumentController( + document: parsed.copyWith(blocks: [block]), + ); + addTearDown(controller.dispose); + final before = controller.document; + expect(controller.applyEnterAt(block.id, 0), isNull); + expect( + controller.replaceBlockTextWithParagraphs(block.id, '\nAbout', 1), + isNull, + ); + expect(controller.document, same(before)); + } + }, + ); + + test('semantic-only list item cannot exit and discard its object', () { + final document = parser + .parse(filePath: 'list.md', source: '- ![](image.png)\n') + .busyDocument; + final item = document.blocks.single; + expect(item.plainText, isEmpty); + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs(item.id, '\n', 1); + } else { + controller.applyEnterAt(item.id, 0); + } + expect(controller.markdown, contains('![](image.png)')); + expect( + busyInlineSemanticAnchors(controller.blockById(item.id)!.inlines), + hasLength(1), + ); + } + }); + + for (final later in [false, true]) { + for (final mode in MarkdownMode.values) { + for (var level = 1; level <= 6; level++) { + for (final path in ['Enter', 'text input', 'paste']) { + test( + '$mode H$level start via $path retains heading identity later=$later', + () { + final document = parser + .parse( + filePath: 'heading.md', + mode: mode, + source: + '${later ? 'Introduction\n\n' : ''}${'#' * level} **About** ![](image.png)the job {id="job"}\n', + ) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final result = path == 'Enter' + ? controller.applyEnterAt(heading.id, 0) + : controller.replaceBlockTextWithParagraphs( + heading.id, + '\n${heading.plainText}', + 1, + allowListExit: path != 'paste', + preserveTextWhitespace: path == 'paste', + ); + expect(result?.blockId, heading.id); + expect(result?.offset, 0); + expect( + controller.document.blocks + .skip(later ? 1 : 0) + .map((b) => b.kind), + [BusyBlockKind.paragraph, BusyBlockKind.heading], + ); + final kept = controller.blockById(heading.id)!; + expect(kept.attributes['level'], '$level'); + expect(kept.attributes['id'], 'job'); + expect(kept.attributes['generatedId'], 'false'); + expect( + _inlineSignatures(kept.inlines), + _inlineSignatures(heading.inlines), + ); + final paragraph = controller.document.blocks[later ? 1 : 0]; + expect(paragraph.id, isNot(heading.id)); + expect(paragraph.plainText, isEmpty); + expect(busyInlineSemanticAnchors(paragraph.inlines), isEmpty); + expect(paragraph.attributes, isNot(contains('id'))); + final saved = controller.markdown; + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + final reopened = parser + .parse(filePath: 'heading.md', source: saved, mode: mode) + .busyDocument; + expect(reopened.blocks.last.kind, BusyBlockKind.heading); + expect(reopened.blocks.last.attributes['level'], '$level'); + expect( + _inlineSignatures(reopened.blocks.last.inlines), + _inlineSignatures(heading.inlines), + ); + }, + ); + } + } + } + } + + for (final mode in MarkdownMode.values) { + for (var level = 1; level <= 6; level++) { + for (final offset in [3, 6]) { + test( + '$mode H$level heading middle/end $offset keeps existing split semantics', + () { + final document = parser + .parse( + filePath: 'heading.md', + mode: mode, + source: '${'#' * level} abcdef\n', + ) + .busyDocument; + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final heading = document.blocks.single; + final result = input + ? controller.replaceBlockTextWithParagraphs( + heading.id, + 'abcdef'.replaceRange(offset, offset, '\n'), + offset + 1, + ) + : controller.applyEnterAt(heading.id, offset); + expect(controller.document.blocks.first.id, heading.id); + expect( + controller.document.blocks.first.kind, + BusyBlockKind.heading, + ); + expect( + controller.document.blocks.first.plainText, + 'abcdef'.substring(0, offset), + ); + expect( + controller.document.blocks.last.kind, + BusyBlockKind.paragraph, + ); + expect( + controller.document.blocks.last.plainText, + 'abcdef'.substring(offset), + ); + expect(result?.blockId, controller.document.blocks.last.id); + } + }, + ); + } + } + } + + for (final offset in [0, 3, 6]) { + test('Writerside heading attributes stay with heading at $offset', () { + for (final input in [false, true]) { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: + '# Topic\n\n## abcdef {id="section" collapsible="true" default-state="expanded"}\n', + ) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs( + heading.id, + 'abcdef'.replaceRange(offset, offset, '\n'), + offset + 1, + ); + } else { + controller.applyEnterAt(heading.id, offset); + } + final paragraph = controller.document.blocks.singleWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + for (final key in [ + 'id', + 'level', + 'generatedId', + 'collapsible', + 'default-state', + 'busymark-writerside-source-attributes', + 'busymark-property-edits', + ]) { + expect(paragraph.attributes, isNot(contains(key)), reason: key); + } + final saved = controller.markdown; + expect('collapsible='.allMatches(saved), hasLength(1)); + expect('default-state='.allMatches(saved), hasLength(1)); + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + final reopened = parser + .parse( + filePath: 'topic.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final section = reopened.blocks.singleWhere( + (b) => b.attributes['id'] == 'section', + ); + expect(section.kind, BusyBlockKind.heading); + expect(section.attributes['collapsible'], 'true'); + expect(section.attributes['default-state'], 'expanded'); + } + }); + } + + for (final nested in [false, true]) { + test( + 'Backspace removes only the empty preceding paragraph nested=$nested', + () { + final source = nested + ? '> Clear\n>\n> ## **About** ![](image.png)job {id="job"}\n' + : 'Clear\n\n## **About** ![](image.png)job {id="job"}\n'; + final document = parser + .parse(filePath: 'heading.md', source: source) + .busyDocument; + final siblings = nested + ? document.blocks.single.children + : document.blocks; + final paragraph = siblings.first; + final heading = siblings.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.updateBlockText(paragraph.id, ''); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, heading.id); + expect(result?.offset, 0); + final kept = controller.blockById(heading.id)!; + expect(kept.kind, BusyBlockKind.heading); + expect(kept.attributes['id'], 'job'); + expect( + _inlineSignatures(kept.inlines), + _inlineSignatures(heading.inlines), + ); + expect(controller.blockById(paragraph.id), isNull); + final reopened = parser + .parse(filePath: 'heading.md', source: controller.markdown) + .busyDocument; + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.kind == BusyBlockKind.heading), + hasLength(1), + ); + }, + ); + } + + for (final marker in ['-', '1.', '- [ ]']) { + for (final input in [false, true]) { + for (final clear in [false, true]) { + test( + 'list $marker start input=$input clear=$clear retains parent subtree', + () { + final indent = marker == '1.' ? ' ' : ' '; + final document = parser + .parse( + filePath: 'list.md', + source: + '$marker Parent\n$indent- Child\n$indent - Grandchild\n', + ) + .busyDocument; + final parent = document.blocks.single; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (clear) controller.updateBlockText(parent.id, ''); + final result = input + ? controller.replaceBlockTextWithParagraphs( + parent.id, + '\n${clear ? '' : parent.plainText}', + 1, + ) + : controller.applyEnterAt(parent.id, 0); + expect(result?.blockId, parent.id); + expect(controller.document.blocks.last.id, parent.id); + expect(controller.document.blocks.first.children, isEmpty); + final kept = controller.blockById(parent.id)!; + expect(kept.kind, parent.kind); + expect(kept.children.single.id, parent.children.single.id); + expect(_blocksDepthFirst(kept.children).map((b) => b.plainText), [ + 'Child', + 'Grandchild', + ]); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'list.md', source: saved) + .busyDocument; + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.plainText == 'Child'), + hasLength(1), + reason: saved, + ); + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.plainText == 'Grandchild'), + hasLength(1), + reason: saved, + ); + expect( + reopened.blocks.last.children.single.plainText, + 'Child', + reason: saved, + ); + }, + ); + } + } + } + + for (final operation in ['newline', 'hard break', 'blank line']) { + for (final offset in [0, 1, 2, 3]) { + test('$operation preserves ordered inline objects at $offset', () { + final document = parser + .parse( + filePath: 'image.md', + source: '**a![](first.png)![](second.png)b**![](third.png)c\n', + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, 'abc'); + expect( + busyInlineStyleRanges( + block.inlines, + ).where((r) => r.kind == BusyInlineKind.strong), + isNotEmpty, + ); + final expected = busyInlineSemanticAnchors( + block.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(); + expect(expected, hasLength(3)); + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + switch (operation) { + case 'newline': + controller.replaceBlockTextWithParagraphs( + block.id, + block.plainText.replaceRange(offset, offset, '\n'), + offset + 1, + allowListExit: false, + ); + case 'hard break': + controller.insertHardBreak(block.id, offset); + case 'blank line': + controller.insertBlankLine(block.id, offset); + } + final objects = [ + for (final b in controller.document.blocks) + ...busyInlineSemanticAnchors( + b.inlines, + ).map((a) => _inlineSignatures([a.inline])), + ]; + expect(objects, expected); + var precedingText = 0; + final actualOffsets = []; + for (final fragment in controller.document.blocks) { + actualOffsets.addAll( + busyInlineSemanticAnchors( + fragment.inlines, + ).map((a) => precedingText + a.offset), + ); + precedingText += + fragment.plainText.length + (operation == 'newline' ? 1 : 0); + } + final insertedLength = operation == 'blank line' ? 2 : 1; + expect( + actualOffsets, + busyInlineSemanticAnchors(block.inlines).map( + (a) => a.offset <= offset ? a.offset : a.offset + insertedLength, + ), + ); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'image.md', source: saved) + .busyDocument; + expect( + [ + for (final b in reopened.blocks) + ...busyInlineSemanticAnchors( + b.inlines, + ).map((a) => _inlineSignatures([a.inline])), + ], + expected, + reason: saved, + ); + }); + } + } + + testWidgets( + 'physical Enter at heading start keeps focus, publishes once and undoes', + (tester) async { + final document = parser + .parse( + filePath: 'header_test_text.md', + source: '## About the job\n', + ) + .busyDocument; + final heading = document.blocks.single; + var publications = 0; + var source = document.source!; + BusyDocument? live; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey( + ValueKey('wysiwyg-field-header_test_text.md-${heading.id}'), + ), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 1); + expect(live!.blocks.map((b) => b.kind), [ + BusyBlockKind.paragraph, + BusyBlockKind.heading, + ]); + final kept = tester.widget( + find.byKey( + ValueKey('wysiwyg-field-header_test_text.md-${heading.id}'), + ), + ); + expect(kept.focusNode!.hasFocus, isTrue); + expect( + kept.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + expect(source, contains('## About the job')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, '## About the job\n'); + expect(live!.blocks, hasLength(1)); + 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); + await tester.pumpAndSettle(); + expect(live!.blocks.map((b) => b.kind), [ + BusyBlockKind.paragraph, + BusyBlockKind.heading, + ]); + }, + ); + }); +} + +List _inlineSignatures(List inlines) => [ + for (final inline in inlines) + [ + inline.kind, + inline.text, + inline.destination, + inline.attributes, + _inlineSignatures(inline.children), + ], +]; + Iterable _blocksDepthFirst(Iterable blocks) sync* { for (final block in blocks) { yield block; diff --git a/test/support/desktop_focus.dart b/test/support/desktop_focus.dart new file mode 100644 index 00000000..349a58f7 --- /dev/null +++ b/test/support/desktop_focus.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exercise the desktop FocusManager, including in bindings created before the +/// test's platform override. Never replace this with an explicit requestFocus. +void enableDesktopFocusLifecycle(WidgetTester tester) { + expect(defaultTargetPlatform, TargetPlatform.linux); + // ignore: invalid_use_of_visible_for_testing_member + FocusManager.instance.listenToApplicationLifecycleChangesIfSupported(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); +} + +Future pumpDesktopFocusCycle( + WidgetTester tester, { + required FocusNode target, + int inactiveFrames = 1, + required VoidCallback verifyViewport, +}) async { + expect(FocusManager.instance.primaryFocus, same(target)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < inactiveFrames; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + verifyViewport(); + } + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + verifyViewport(); + } + expect(FocusManager.instance.primaryFocus, same(target)); + expect(tester.testTextInput.hasAnyClients, isTrue); +} 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/deferred_focus_native.py b/tools/deferred_focus_native.py new file mode 100644 index 00000000..8feda7a3 --- /dev/null +++ b/tools/deferred_focus_native.py @@ -0,0 +1,65 @@ +#!/usr/bin/python3 +"""Run focus races on an owned desktop with profiles isolated before D-Bus. + +Usage: python3 tools/deferred_focus_native.py BINARY NEW_OUTPUT BACKEND MODE +MODE: deferred-dialog, deferred-selection, deferred-selection-table, or editor. +The existing compositor driver performs the actual native input/lifecycle work. +""" +import json +import os +from pathlib import Path +import subprocess +import sys + + +def run(binary, output, backend, mode): + if backend not in ["x11", "wayland"] or mode not in [ + "deferred-dialog", "deferred-selection", "deferred-selection-table", "editor" + ]: + raise ValueError("Unsupported native focus scenario") + if mode.startswith("deferred-selection") and backend != "x11": + raise ValueError("Native selection gesture requires the owned X11 window") + output = Path(output).resolve() + if len(os.fsencode(output / "desktop/runtime/focus-acceptance")) >= 108: + raise ValueError("Use a shorter output path for the Wayland Unix socket") + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, name in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / name + directory.mkdir(mode=0o700) + env[key] = str(directory) + for key in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "SESSION_MANAGER", + "GNOME_KEYRING_PID", "GNOME_KEYRING_CONTROL", "SSH_AUTH_SOCK", "GPG_AGENT_INFO"]: + env.pop(key, None) + command = [ + "/usr/bin/dbus-run-session", "--", "/usr/bin/python3", + "tools/focus_restoration_native.py", str(Path(binary).resolve()), + str(output / "desktop"), backend, mode, "alt-tab", + ] + with (output / "supervisor.log").open("w") as log: + supervisor = subprocess.Popen( + ["/usr/bin/python3", "tools/run_owned_fixture.py", "--report", + str(output / "ownership.json"), "--duration", "240", "--", *command], + env=env, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT) + try: + status = supervisor.wait(timeout=260) + finally: + if supervisor.poll() is None: + supervisor.stdin.write(b"stop\n") + supervisor.stdin.flush() + supervisor.wait(timeout=15) + supervisor.stdin.close() + result_file = output / "desktop/probe/result.json" + result = json.loads(result_file.read_text()) if result_file.exists() else None + passed = status == 0 and result is not None and all( + value for group in result.values() for value in group.values()) + print(json.dumps({"backend": backend, "mode": mode, "exitCode": status, + "passed": passed, "result": result})) + if not passed: + raise RuntimeError("Native focus acceptance failed; inspect retained output") + + +if __name__ == "__main__": + run(*sys.argv[1:]) 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) +'''; diff --git a/tools/focus_restoration_native.py b/tools/focus_restoration_native.py new file mode 100644 index 00000000..82c056e3 --- /dev/null +++ b/tools/focus_restoration_native.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Run the focus probe on an owned GNOME headless desktop, never the user's. + +Usage: dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py + BUNDLE_EXECUTABLE NEW_OUTPUT_DIRECTORY x11|wayland MODE + [full|alt-tab|minimize] [markdown|writerside] +MODE: editor, source, split, table, deferred-dialog, deferred-selection, + deferred-selection-table (selection gestures require XWayland). +Requires GNOME Mutter RemoteDesktop input support and system PyGObject. +""" +import ctypes as C +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time + +import gi +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + + +def wait_for(predicate, timeout=45): + end = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= end: + raise RuntimeError("Native prerequisite timed out") + time.sleep(0.05) + + +def companion(): + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + window = Gtk.Window(title="Focus acceptance companion") + window.set_default_size(400, 200) + window.add(Gtk.Label(label="Disposable focus acceptance application")) + window.connect("destroy", Gtk.main_quit) + window.show_all() + Gtk.main() + + +def run(binary, output, backend, mode, transitions="full", document_format="markdown"): + parent = os.getppid() + if Path(f"/proc/{parent}/exe").resolve().name != "dbus-run-session": + raise RuntimeError("Run under a dedicated dbus-run-session, never the host session bus") + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, child in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / child + directory.mkdir(mode=0o700) + env[key] = str(directory) + env["GDK_BACKEND"] = backend + env["WAYLAND_DISPLAY"] = "focus-acceptance" + env["XDG_SESSION_TYPE"] = "wayland" + keyring = output / "runtime/keyring" + keyring.mkdir(mode=0o700) + env["GNOME_KEYRING_CONTROL"] = str(keyring) + for key in ("GNOME_KEYRING_PID", "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER"): + env.pop(key, None) + env.pop("DISPLAY", None) + env.pop("XAUTHORITY", None) + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + broker = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetConnectionUnixProcessID", GLib.Variant("(s)", ("org.freedesktop.DBus",)), + None, Gio.DBusCallFlags.NONE, 10000, None).unpack()[0] + broker_parent = re.search(r"^PPid:\s+(\d+)$", Path(f"/proc/{broker}/status").read_text(), re.M) + if broker_parent is None or int(broker_parent[1]) != parent: + raise RuntimeError("The D-Bus broker is not owned by this dbus-run-session") + + def update_activation_environment(): + # The private broker predates this Python process's isolated profile. + # D-Bus-activated desktop helpers must inherit it too, not the host's. + values = {key: env.get(key, "") for key in ( + "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", + "XDG_STATE_HOME", "GDK_BACKEND", "WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", + "XDG_SESSION_TYPE", "GNOME_KEYRING_CONTROL", "GNOME_KEYRING_PID", + "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER")} + bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "UpdateActivationEnvironment", GLib.Variant("(a{ss})", (values,)), + None, Gio.DBusCallFlags.NONE, 10000, None) + + update_activation_environment() + processes = [] + logs = [] + actions = [] + + def launch(command, name): + log = (output / f"{name}.log").open("w") + logs.append(log) + process = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + processes.append(process) + return process + + try: + compositor = launch(["gnome-shell", "--headless", "--wayland", "--virtual-monitor=1280x960", + "--wayland-display=focus-acceptance"], "compositor") + wait_for(lambda: (output / "runtime/focus-acceptance").exists() or compositor.poll() is not None) + if compositor.poll() is not None: + raise RuntimeError("Disposable compositor exited; see compositor.log") + wait_for(lambda: "Using public X11 display" in (output / "compositor.log").read_text()) + env["DISPLAY"] = re.search(r"Using public X11 display (:[0-9]+)", (output / "compositor.log").read_text())[1] + auth = list((output / "runtime").glob(".mutter-Xwaylandauth.*")) + if auth: + env["XAUTHORITY"] = str(auth[0]) + update_activation_environment() + wait_for(lambda: bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", GLib.Variant("(s)", ("org.gnome.Mutter.RemoteDesktop",)), None, Gio.DBusCallFlags.NONE, 1000, None).unpack()[0], timeout=30) + + def call(path, interface, method, parameters=None): + return bus.call_sync("org.gnome.Mutter.RemoteDesktop", path, interface, method, + parameters, None, Gio.DBusCallFlags.NONE, 10000, None) + + session = call("/org/gnome/Mutter/RemoteDesktop", "org.gnome.Mutter.RemoteDesktop", "CreateSession").unpack()[0] + interface = "org.gnome.Mutter.RemoteDesktop.Session" + call(session, interface, "Start") + xml = bus.call_sync("org.gnome.Mutter.RemoteDesktop", session, "org.freedesktop.DBus.Introspectable", "Introspect", None, None, Gio.DBusCallFlags.NONE, 1000, None).unpack()[0] + (output / "remote-input-interface.xml").write_text(xml) + + def key(code, pressed): + call(session, interface, "NotifyKeyboardKeycode", GLib.Variant("(ub)", (code, pressed))) + + def chord(modifier, code): + key(modifier, True) + key(code, True) + key(code, False) + key(modifier, False) + actions.append({"time": time.monotonic(), "keys": [modifier, code]}) + time.sleep(0.7) + + launch(["/usr/bin/python3", str(Path(__file__).resolve()), "--companion"], "companion") + time.sleep(1) + text = "Focus target is the original insertion location.\n\n" + text += "| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n\n" + text += "\n\n".join((f"Reading paragraph {i} " + "reading words " * 25).rstrip() for i in range(160)) + "\n" + if document_format == "writerside": + text = '

Focus target is the original insertion location.

' + ''.join(f'

Reading paragraph {i} ' + 'reading words ' * 25 + '

' for i in range(160)) + '
' + fixture = output / ("disposable.topic" if document_format == "writerside" else "disposable.md") + fixture.write_text(text) + probe = output / "probe" + probe.mkdir() + app = launch([str(Path(binary).resolve()), str(fixture), str(probe), mode], "application") + wait_for(lambda: any((probe / name).exists() for name in + ["ready", "dialog-ready", "selection-ready", "activation-needed", "done"]) or app.poll() is not None) + if (probe / "activation-needed").exists(): + chord(56, 15) # Wayland may not activate a subprocess on mapping. + wait_for(lambda: any((probe / name).exists() for name in + ["ready", "dialog-ready", "selection-ready", "done"]) or app.poll() is not None) + if (probe / "dialog-ready").exists(): + # Deterministic production Navigator dismissal; focus switching and + # subsequent input still come from the actual native GTK embedder. + (probe / "dismiss-dialog").touch() + actions.append({"time": time.monotonic(), "programmatic-dialog-dismissal": True}) + if (probe / "selection-ready").exists(): + if backend != "x11": + raise RuntimeError("Native selection gesture currently requires the owned X11 window") + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is None: + raise RuntimeError("Missing owned selection window") + points = json.loads((probe / "selection-gesture.json").read_text()) + start, end = points["start"], points["end"] + if not all(0 < p[0] < window.get_width() and 0 < p[1] < window.get_height() for p in [start, end]): + raise RuntimeError("Selection point outside owned window") + _, origin_x, origin_y = window.get_origin() + # Exclude GTK's invisible CSD shadow as well as any header. + actual, fmt, count, remaining = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong() + data = C.POINTER(C.c_ubyte)() + desktop.x.XGetWindowProperty(desktop.display, window.get_xid(), desktop.atom("_GTK_FRAME_EXTENTS"), 0, 4, 0, 0, C.byref(actual), C.byref(fmt), C.byref(count), C.byref(remaining), C.byref(data)) + try: + if count.value != 4 or fmt.value != 32: + raise RuntimeError("Missing owned GTK frame extents") + left, right, top, bottom = C.cast(data, C.POINTER(C.c_ulong))[:4] + finally: + if data: + desktop.x.XFree(data) + header = window.get_height() - bottom - points["viewHeight"] + if not 0 <= header < 120: + raise RuntimeError(f"Unexpected native header height: {header}") + (output / "native-selection.json").write_text(json.dumps({ + "origin": [origin_x, origin_y], "header": header, + "size": [window.get_width(), window.get_height()], "points": points})) + picture = desktop.Gdk.pixbuf_get_from_window(window, 0, 0, window.get_width(), window.get_height()) + if picture is not None: + picture.savev(str(output / "native-selection.png"), "png", [], []) + xtest = C.CDLL("libXtst.so.6") + xtest.XTestFakeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_ulong] + xtest.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + def pointer(point=None, pressed=None): + current = desktop.window(app.pid, require_mapped=True, require_normal=True) + if current is None or current.get_xid() != window.get_xid(): + raise RuntimeError("Owned selection window changed") + accepted = xtest.XTestFakeMotionEvent(desktop.display, 0, int(origin_x + left + point[0]), int(origin_y + header + point[1]), 0) if point is not None else xtest.XTestFakeButtonEvent(desktop.display, 1, pressed, 0) + if not accepted: + raise RuntimeError("Owned desktop rejected selection input") + desktop.x.XFlush(desktop.display) + pointer(start) + pointer(pressed=True) + time.sleep(0.2) + pointer(end) + time.sleep(0.2) + pointer(pressed=False) + actions.append({"time": time.monotonic(), "native-selection": mode, "points": points}) + finally: + desktop.dispose() + wait_for(lambda: (probe / "ready").exists() or (probe / "done").exists() or app.poll() is not None) + if not (probe / "ready").exists(): + raise RuntimeError("Application did not reach focus acceptance; see application.log/probe/error.txt") + def lifecycle(expected): + state = probe / "lifecycle" + wait_for(lambda: state.exists() and state.read_text() == expected) + + lifecycle("resumed") + for interval in [0.7, 0.7, 15, 0.7]: + chord(56, 15) # Real Alt-Tab through Mutter, not a framework lifecycle call. + lifecycle("inactive") + time.sleep(interval) + chord(56, 15) + lifecycle("resumed") + if transitions == "minimize": + chord(125, 35) # Real GNOME Super-H minimize. + time.sleep(1) + chord(56, 15) # Real Alt-Tab restores the minimized application. + if transitions == "full": + chord(125, 35) # GNOME Super-H minimize. + time.sleep(1) + if backend == "x11": + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is not None: + # Some disposable desktops have no Super-H binding. + # Send the native WM iconify request to this owned XID. + window.iconify() + desktop.Gdk.Display.get_default().flush() + actions.append({"time": time.monotonic(), "native-iconify": True}) + # Observe real native unmapping, not merely focus loss. + wait_for(lambda: desktop.window(app.pid, require_mapped=False, require_normal=True) is not None + and desktop.window(app.pid, require_mapped=True, require_normal=True) is None) + actions.append({"time": time.monotonic(), "minimized-unmapped": True}) + finally: + desktop.dispose() + (probe / "restore-window").touch() + wait_for(lambda: (probe / "window-restored").exists() or (probe / "done").exists()) + lifecycle("resumed") + if transitions == "full" and backend == "x11": + # Switch away again, then reactivate only by clicking native chrome. + chord(56, 15) + lifecycle("inactive") + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is None: + raise RuntimeError("Missing owned application window") + _, origin_x, origin_y = window.get_origin() + call(session, interface, "NotifyPointerMotionRelative", GLib.Variant("(dd)", (-3000., -3000.))) + call(session, interface, "NotifyPointerMotionRelative", GLib.Variant("(dd)", (float(origin_x + window.get_width() / 2), float(origin_y + 16)))) + call(session, interface, "NotifyPointerButton", GLib.Variant("(ib)", (272, True))) + call(session, interface, "NotifyPointerButton", GLib.Variant("(ib)", (272, False))) + actions.append({"time": time.monotonic(), "chrome-reactivation": True}) + lifecycle("resumed") + finally: + desktop.dispose() + elif transitions == "full": + chord(56, 15) + chord(56, 15) + time.sleep(1) + (probe / "returned").touch() + wait_for(lambda: (probe / "type-ready").exists() or (probe / "done").exists()) + key(44, True) # Real native Z key; no tapping/refocusing the editable. + key(44, False) + time.sleep(0.5) + (probe / "typed").touch() + wait_for(lambda: (probe / "done").exists()) + (output / "actions.json").write_text(json.dumps({"backend": backend, "coverage": transitions, "documentFormat": document_format, "actions": actions}, indent=2)) + result = json.loads((probe / "result.json").read_text()) + if transitions == "minimize": + frames = json.loads((probe / "frames.json").read_text()) + if not any(frame["lifecycle"] == "hidden" for frame in frames): + raise RuntimeError("Minimize did not reach the hidden lifecycle state") + print(json.dumps(result)) + if not all(value for group in result.values() for value in group.values()): + raise RuntimeError("Native acceptance assertions failed") + if mode.startswith("deferred-"): + wait_for(lambda: app.poll() is not None, timeout=30) + if app.returncode != 0: + raise RuntimeError(f"Native application exit failed: {app.returncode}") + actions.append({"time": time.monotonic(), "naturalApplicationExit": app.returncode}) + (output / "actions.json").write_text(json.dumps({"backend": backend, "coverage": transitions, "documentFormat": document_format, "actions": actions}, indent=2)) + finally: + for process in reversed(processes): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=8) + for log in logs: + log.close() + + +if __name__ == "__main__": + if sys.argv[1:] == ["--companion"]: + companion() + else: + run(*sys.argv[1:]) diff --git a/tools/focus_restoration_smoke.dart b/tools/focus_restoration_smoke.dart new file mode 100644 index 00000000..ff05e128 --- /dev/null +++ b/tools/focus_restoration_smoke.dart @@ -0,0 +1,524 @@ +// Native focus acceptance entry point. Use only disposable, synthetic files and +// an isolated desktop/profile. All lifecycle events and text input come from +// the real GTK embedder. Measurements contain geometry and counts, never text. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/main.dart' as application; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:window_manager/window_manager.dart'; + +Future main(List arguments) async { + if (arguments.length != 3) { + throw ArgumentError( + 'DISPOSABLE_DOCUMENT OUTPUT_DIRECTORY ' + 'editor|source|split|table|deferred-dialog|' + 'deferred-selection|deferred-selection-table', + ); + } + final binding = _Binding(); + await application.main([arguments[0]]); + final output = Directory(arguments[1]); + await output.create(recursive: true); + unawaited(_Driver(binding, output, arguments[2]).run()); +} + +class _Binding extends WidgetsFlutterBinding { + final root = GlobalKey(); + @override + Widget wrapWithDefaultView(Widget rootWidget) => + super.wrapWithDefaultView(RepaintBoundary(key: root, child: rootWidget)); +} + +class _Driver with WidgetsBindingObserver { + _Driver(this.binding, this.output, this.mode); + final _Binding binding; + final Directory output; + final String mode; + final frames = >[]; + TextField? target; + TextField? documentTarget; + _DelayedNativeBarrier? barrier; + OverlayEntry? competingEntry; + FocusNode? competingFocus; + TextEditingController? competingController; + PointerRoute? selectionRoute; + bool get deferred => mode.startsWith('deferred-'); + ScrollPosition? position; + bool recording = false; + bool finished = false; + int phase = 0; + Element? readingAnchor; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + File('${output.path}/lifecycle').writeAsStringSync(state.name); + final pending = barrier; + if (state != AppLifecycleState.resumed && + pending?.releaseStarted == true && + !pending!.release.isCompleted) { + pending.release.complete(); + } + } + + double? topOf(Element? element) { + final render = element?.mounted == true + ? element!.findRenderObject() + : null; + if (render is! RenderBox || !render.hasSize) return null; + final top = render.localToGlobal(Offset.zero).dy; + return top.isFinite ? top : null; + } + + ScrollPosition? get currentPosition { + if (mode == 'source' || mode == 'split') { + return target?.scrollController?.hasClients == true + ? target!.scrollController!.position + : null; + } + final context = (documentTarget ?? target)?.focusNode?.context; + return context == null + ? null + : Scrollable.maybeOf(context, axis: Axis.vertical)?.position; + } + + List elements(bool Function(Widget) predicate, [Element? root]) { + final result = []; + void visit(Element e) { + if (predicate(e.widget)) result.add(e); + e.visitChildren(visit); + } + + final context = binding.root.currentContext; + if (context != null) visit(root ?? context as Element); + return result; + } + + Future waitFor(FutureOr Function() condition) async { + final end = DateTime.now().add(const Duration(seconds: 45)); + while (!await condition()) { + if (DateTime.now().isAfter(end)) { + throw StateError('Native prerequisite timed out in phase $phase'); + } + await Future.delayed(const Duration(milliseconds: 40)); + } + } + + Future signal(String value) => + File('${output.path}/$value').writeAsString('').then((_) {}); + Future proceed(String value) => + waitFor(() => File('${output.path}/$value').existsSync()); + + Map measure(Duration stamp) => { + 'frameUs': stamp.inMicroseconds, + 'phase': phase, + 'lifecycle': binding.lifecycleState?.name, + 'focus': identical(FocusManager.instance.primaryFocus, target?.focusNode), + 'rootFocus': identical( + FocusManager.instance.primaryFocus, + FocusManager.instance.rootScope, + ), + 'attached': target?.focusNode?.context?.mounted == true, + 'pixels': position?.pixels, + 'currentPixels': currentPosition?.pixels, + 'samePosition': identical(position, currentPosition), + 'readingAnchorY': topOf(readingAnchor), + 'targetY': topOf(target?.focusNode?.context as Element?), + 'base': target?.controller?.selection.baseOffset, + 'extent': target?.controller?.selection.extentOffset, + }; + + Future prepareDeferred(ProviderContainer container) async { + if (!await windowManager.isFocused()) await signal('activation-needed'); + await waitFor(windowManager.isFocused); + documentTarget = target; + final focus = competingFocus = FocusNode( + debugLabel: 'Native competing control', + ); + final controller = competingController = TextEditingController(); + final context = elements((w) => w is BusyMarkWysiwygEditor).single; + competingEntry = OverlayEntry( + builder: (_) => Positioned( + left: 700, + top: 30, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: focus, controller: controller), + ), + ), + ); + Overlay.of(context).insert(competingEntry!); + await waitFor(() => focus.context?.mounted == true); + if (mode == 'deferred-dialog') { + final gate = barrier = _DelayedNativeBarrier(); + unawaited( + showBusyMarkModalDialog( + context, + headerBarService: gate, + builder: (_) => + const AlertDialog(content: Text('Native deferred focus check')), + ), + ); + await waitFor(() => elements((w) => w is AlertDialog).isNotEmpty); + // Building the route does not mean its fallback has focus or that GTK + // has activated the new window. Start the race only after both. + await waitFor(() { + final primary = FocusManager.instance.primaryFocus; + final dialog = elements((w) => w is AlertDialog).single; + return binding.lifecycleState == AppLifecycleState.resumed && + primary is! FocusScopeNode && + primary?.context?.mounted == true && + ModalRoute.of(primary!.context!) == ModalRoute.of(dialog); + }); + await signal('dialog-ready'); + await proceed('dismiss-dialog'); + if (!context.mounted) throw StateError('Dialog host detached'); + Navigator.of(context, rootNavigator: true).pop(); + await waitFor(() => gate.releaseStarted); + focus.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } else { + final first = elements( + (w) => w is TextField && w.controller == documentTarget!.controller, + ).single; + final last = elements( + (w) => + w is TextField && + (mode == 'deferred-selection-table' + ? w.controller?.text == 'Body' + : w.controller?.text.startsWith('Reading paragraph 0 ') == + true), + ).single; + Offset caret(Element element, int offset) { + final editable = + (elements((w) => w is EditableText, element).single + as StatefulElement) + .state + as EditableTextState; + return editable.renderEditable.localToGlobal( + editable.renderEditable + .getLocalRectForCaret(TextPosition(offset: offset)) + .center, + ); + } + + final start = caret(first, 5); + final end = caret(last, mode == 'deferred-selection-table' ? 4 : 5); + await File('${output.path}/selection-gesture.json').writeAsString( + jsonEncode({ + 'start': [start.dx, start.dy], + 'end': [end.dx, end.dy], + 'viewHeight': binding.renderViews.single.size.height, + }), + ); + var completed = false; + void route(PointerEvent event) { + if (event is! PointerUpEvent || completed) return; + final session = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState; + unawaited( + File('${output.path}/pointer-up.json').writeAsString( + jsonEncode({ + 'position': [event.position.dx, event.position.dy], + 'crossBlock': session.anchorBlockId != session.extentBlockId, + }), + ), + ); + if (session.anchorBlockId == null || + session.anchorBlockId == session.extentBlockId) { + return; + } + completed = true; + // A newer control takes focus in the same real native pointer-up turn, + // before the editor's queued post-frame handoff can run. + focus.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + unawaited(signal('selection-finished')); + } + + selectionRoute = route; + binding.pointerRouter.addGlobalRoute(route); + await signal('selection-ready'); + await proceed('selection-finished'); + binding.pointerRouter.removeGlobalRoute(route); + selectionRoute = null; + } + await Future.delayed(const Duration(milliseconds: 300)); + if (!identical(FocusManager.instance.primaryFocus, focus)) { + throw StateError( + 'Deferred editor callback stole the newer native control', + ); + } + target = + elements((w) => w is TextField && w.focusNode == focus).single.widget + as TextField; + } + + Future run() async { + try { + await waitFor( + () => elements( + (w) => w is BusyMarkWysiwygEditor || w is BusyMarkSourceEditor, + ).isNotEmpty, + ); + final editor = elements( + (w) => w is BusyMarkWysiwygEditor || w is BusyMarkSourceEditor, + ).first; + final container = ProviderScope.containerOf(editor, listen: false); + final workspace = container.read(workspaceControllerProvider.notifier); + if (mode == 'source' || mode == 'split') { + workspace.updateActiveEditorMode( + mode == 'source' + ? DocumentViewModePreference.source + : DocumentViewModePreference.split, + ); + await waitFor( + () => elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + ); + } + await Future.delayed(const Duration(milliseconds: 600)); + final fieldElements = elements( + (w) => w is TextField && w.controller != null, + ); + final fieldElement = mode == 'table' + ? fieldElements.singleWhere( + (e) => (e.widget as TextField).controller!.text == 'Focus cell', + ) + : (mode == 'source' || mode == 'split') + ? elements( + (w) => w is TextField, + elements((w) => w is BusyMarkSourceEditor).single, + ).single + : fieldElements.firstWhere( + (e) => (e.widget as TextField).controller!.text.startsWith( + 'Focus target', + ), + ); + target = fieldElement.widget as TextField; + target!.focusNode!.requestFocus(); + target!.controller!.selection = const TextSelection.collapsed(offset: 5); + await Future.delayed(const Duration(milliseconds: 500)); + if (mode == 'source' || mode == 'split') { + position = target!.scrollController!.position; + } else { + final list = elements((w) => w is ScrollablePositionedList).single; + position = + (elements((w) => w is Scrollable, list).first as StatefulElement) + .state + is ScrollableState + ? ((elements((w) => w is Scrollable, list).first as StatefulElement) + .state + as ScrollableState) + .position + : null; + } + if (position == null) throw StateError('Missing production viewport'); + if (deferred) await prepareDeferred(container); + position!.jumpTo(position!.pixels + 1900); + await Future.delayed(const Duration(milliseconds: 500)); + if (mode != 'source' && mode != 'split') { + readingAnchor = + elements( + (w) => + w is TextField && + w.controller?.text.startsWith('Reading paragraph') == + true, + ) + .where( + (element) => + topOf(element) != null && + topOf(element)! > 100 && + topOf(element)! < 800, + ) + .firstOrNull; + if (readingAnchor == null) { + throw StateError('Missing visible reading anchor'); + } + } + final readingTop = topOf(readingAnchor); + final original = container + .read(workspaceControllerProvider) + .activeBuffer!; + final source = original.text; + final undoCount = original.editorState.undoState.undo.length; + final redoCount = original.editorState.undoState.redo.length; + final revision = workspace.editRevision; + final selection = target!.controller!.selection; + final documentSelection = documentTarget?.controller!.selection; + final savedRange = original.editorState.wysiwygState; + final insertionOffset = mode == 'source' || mode == 'split' + ? selection.extentOffset + : source.indexOf(target!.controller!.text) + selection.extentOffset; + if (insertionOffset < selection.extentOffset) { + throw StateError( + 'Synthetic target does not have an authored source position', + ); + } + final expectedInput = source.replaceRange( + insertionOffset, + insertionOffset, + 'z', + ); + final offset = position!.pixels; + binding.addPersistentFrameCallback((stamp) { + if (recording && !finished) frames.add(measure(stamp)); + }); + recording = true; + binding.addObserver(this); + didChangeAppLifecycleState(binding.lifecycleState!); + // The supervisor performs real window switches, a prolonged interval, + // minimize/restore, and window-chrome reactivation before signalling. + await signal('ready'); + var restoredWindow = false; + await waitFor(() { + if (!restoredWindow && + File('${output.path}/restore-window').existsSync()) { + restoredWindow = true; + unawaited(() async { + await windowManager.restore(); + await windowManager.show(); + await windowManager.focus(); + await signal('window-restored'); + }()); + } + return File('${output.path}/returned').existsSync(); + }); + await Future.delayed(const Duration(milliseconds: 600)); + final current = container.read(workspaceControllerProvider).activeBuffer!; + final passiveChecks = { + 'viewport': frames.every( + (f) => + f['currentPixels'] is num && + ((f['currentPixels'] as num).toDouble() - offset).abs() < 0.1, + ), + 'readingGeometry': + readingAnchor == null || + frames.every( + (f) => + f['readingAnchorY'] is num && + ((f['readingAnchorY'] as num).toDouble() - readingTop!) + .abs() < + 0.1, + ), + 'suspended': frames.any( + (f) => f['lifecycle'] != 'resumed' && f['focus'] == false, + ), + 'focus': identical( + FocusManager.instance.primaryFocus, + target!.focusNode, + ), + 'selection': target!.controller!.selection == selection, + 'bytes': current.text == source, + 'undo': current.editorState.undoState.undo.length == undoCount, + 'redo': current.editorState.undoState.redo.length == redoCount, + 'dirty': current.dirty == original.dirty, + 'revision': workspace.editRevision == revision, + if (deferred) + 'inactiveRootFocus': frames + .where((f) => f['lifecycle'] != 'resumed') + .every((f) => f['rootFocus'] == true), + if (deferred) + 'originalSelection': + documentTarget!.controller!.selection == documentSelection, + if (deferred) + 'documentRange': + ( + current.editorState.wysiwygState.anchorBlockId, + current.editorState.wysiwygState.anchorOffset, + current.editorState.wysiwygState.extentBlockId, + current.editorState.wysiwygState.extentOffset, + ) == + ( + savedRange.anchorBlockId, + savedRange.anchorOffset, + savedRange.extentBlockId, + savedRange.extentOffset, + ), + }; + phase = 1; + await File( + '${output.path}/passive.json', + ).writeAsString(jsonEncode(passiveChecks)); + await signal('type-ready'); + await proceed('typed'); + await Future.delayed(const Duration(milliseconds: 600)); + final edited = container.read(workspaceControllerProvider).activeBuffer!; + final targetTop = topOf(target?.focusNode?.context as Element?); + final inputChecks = deferred + ? { + 'newControlInput': competingController!.text == 'z', + 'documentUnchanged': edited.text == source, + 'readingUnchanged': + (currentPosition!.pixels - offset).abs() < 0.1, + } + : { + 'inputAtOriginalTarget': edited.text == expectedInput, + 'reveal': + (currentPosition?.pixels ?? double.infinity) < offset - 100, + 'targetVisible': + targetTop != null && targetTop >= 0 && targetTop < 900, + }; + await File('${output.path}/result.json').writeAsString( + jsonEncode({'passive': passiveChecks, 'input': inputChecks}), + ); + await File( + '${output.path}/frames.json', + ).writeAsString(jsonEncode(frames)); + finished = true; + await signal('done'); + } catch (error, stack) { + finished = true; + await File('${output.path}/error.txt').writeAsString('$error\n$stack'); + await signal('done'); + } finally { + binding.removeObserver(this); + if (selectionRoute case final route?) { + binding.pointerRouter.removeGlobalRoute(route); + } + competingEntry?.remove(); + competingEntry?.dispose(); + if (competingEntry != null) { + binding.scheduleFrame(); + await binding.endOfFrame; + } + competingFocus?.dispose(); + competingController?.dispose(); + barrier?.dispose(); + if (deferred && File('${output.path}/result.json').existsSync()) { + await windowManager.close(); + } + } + } +} + +class _DelayedNativeBarrier extends LinuxHeaderBarService { + _DelayedNativeBarrier() + : super( + channel: const MethodChannel('com.busymark.test/deferred-native-focus'), + ); + final release = Completer(); + bool releaseStarted = false; + @override + Future setModalBarrierDepth(int depth) async { + await LinuxHeaderBarService.instance.setModalBarrierDepth(depth); + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} diff --git a/tools/structural_editing_native.py b/tools/structural_editing_native.py new file mode 100644 index 00000000..b1da6811 --- /dev/null +++ b/tools/structural_editing_native.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Structural-edit acceptance on an owned Xvfb desktop and private profile. + +Usage: /usr/bin/python3 tools/structural_editing_native.py BINARY NEW_OUTPUT [inline-breaks] +The --key/--click modes are called only by that binary for its own native window. +""" +import ctypes as C +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +def input_event(kind, target, arguments): + if os.environ.get("BUSYMARK_STRUCTURAL_ACCEPTANCE") != "1" or int(target) != os.getppid(): + raise RuntimeError("Only the owning disposable application may inject input") + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(int(target), require_normal=True) + if window is None: + raise RuntimeError("No mapped window belonging to the caller") + xid = window.get_xid() + x = desktop.x + x.XSetInputFocus.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_ulong] + x.XSetInputFocus(desktop.display, xid, 1, 0) + xtest = C.CDLL("libXtst.so.6") + if kind == "--key": + 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 + xtest.XTestFakeKeyEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + codes = [x.XKeysymToKeycode(desktop.display, x.XStringToKeysym(s.encode())) for s in arguments] + if not codes or not all(codes): + raise RuntimeError("Invalid native key chord") + for code, pressed in [(c, 1) for c in codes] + [(c, 0) for c in reversed(codes)]: + if desktop.window(int(target), require_normal=True).get_xid() != xid: + raise RuntimeError("Owned window changed during input") + if not xtest.XTestFakeKeyEvent(desktop.display, code, pressed, 0): + raise RuntimeError("XTest rejected keyboard input") + else: + px, py = map(float, arguments) + if not 0 < px < window.get_width() or not 0 < py < window.get_height(): + raise RuntimeError("Pointer target is outside the owned window") + _, ox, oy = window.get_origin() + xtest.XTestFakeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_ulong] + xtest.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + if not xtest.XTestFakeMotionEvent(desktop.display, 0, round(ox + px), round(oy + py), 0): + raise RuntimeError("XTest rejected pointer motion") + for pressed in [1, 0]: + if not xtest.XTestFakeButtonEvent(desktop.display, 1, pressed, 0): + raise RuntimeError("XTest rejected pointer input") + x.XFlush(desktop.display) + finally: + desktop.dispose() + + +def run(binary, output, scenario=None): + if scenario not in [None, "inline-breaks"]: + raise ValueError("Unsupported structural acceptance scenario") + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, name in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / name + directory.mkdir(mode=0o700) + env[key] = str(directory) + for key in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "GNOME_KEYRING_PID", + "GNOME_KEYRING_CONTROL", "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER"]: + env.pop(key, None) + env.update(GDK_BACKEND="x11", BUSYMARK_STRUCTURAL_ACCEPTANCE="1") + env.pop("BUSYMARK_STRUCTURAL_SCENARIO", None) + if scenario: + env["BUSYMARK_STRUCTURAL_SCENARIO"] = scenario + fixtures = output / "fixtures" + shutil.copytree("test/fixtures/structural_editing", fixtures / "markdown") + for name in ["first.png", "second.png", "third.png"]: + shutil.copyfile("test/fixtures/writerside/basic_project/images/logo.png", fixtures / "markdown" / name) + shutil.copytree("test/fixtures/writerside/basic_project", fixtures / "writerside") + shutil.copyfile(fixtures / "markdown/heading-properties.md", fixtures / "writerside/topics/structure.md") + probe = output / "probe" + probe.mkdir() + # Isolation is established before creating the broker, so activated helpers + # receive the private profile too. Neither the host display nor bus is used. + command = ["/usr/bin/dbus-run-session", "--", "/usr/bin/xvfb-run", "-a", "-s", + "-screen 0 1440x1000x24", str(Path(binary).resolve()), str(fixtures), str(probe)] + with (output / "application.log").open("w") as log: + supervisor = subprocess.Popen( + ["/usr/bin/python3", "tools/run_owned_fixture.py", "--report", str(output / "ownership.json"), + "--duration", "240", "--", *command], env=env, stdin=subprocess.PIPE, + stdout=log, stderr=subprocess.STDOUT) + try: + exit_code = supervisor.wait(timeout=260) + finally: + if supervisor.poll() is None: + supervisor.stdin.write(b"stop\n") + supervisor.stdin.flush() + supervisor.wait(timeout=15) + supervisor.stdin.close() + result = json.loads((probe / "result.json").read_text()) + print(json.dumps({"backend": "X11/Xvfb", "exitCode": exit_code, **result})) + if exit_code or not result["passed"]: + raise RuntimeError("Native structural acceptance failed; inspect retained output") + + +if __name__ == "__main__": + if sys.argv[1:2] in [["--key"], ["--click"]]: + input_event(sys.argv[1], sys.argv[2], sys.argv[3:]) + else: + run(*sys.argv[1:]) diff --git a/tools/structural_editing_smoke.dart b/tools/structural_editing_smoke.dart new file mode 100644 index 00000000..8d41c560 --- /dev/null +++ b/tools/structural_editing_smoke.dart @@ -0,0 +1,684 @@ +// Native acceptance entry point: production app, editors, source publication, +// history and saving. Only disposable fixtures/profiles may be used. +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/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +Future main(List arguments) async { + if (arguments.length != 2 || + Platform.environment['BUSYMARK_STRUCTURAL_ACCEPTANCE'] != '1') { + throw ArgumentError( + 'Use structural_editing_native.py with a disposable desktop', + ); + } + final binding = _Binding(); + final fixtures = Directory(arguments[0]); + final fixture = + Platform.environment['BUSYMARK_STRUCTURAL_SCENARIO'] == 'inline-breaks' + ? 'inline-existing-break.md' + : 'header_test_text.md'; + await application.main(['${fixtures.path}/markdown/$fixture']); + unawaited(_Driver(binding, fixtures, Directory(arguments[1])).run()); +} + +class _Binding extends WidgetsFlutterBinding { + final root = GlobalKey(); + @override + Widget wrapWithDefaultView(Widget rootWidget) => + super.wrapWithDefaultView(RepaintBoundary(key: root, child: rootWidget)); +} + +class _Driver { + _Driver(this.binding, this.fixtures, this.output); + final _Binding binding; + final Directory fixtures; + final Directory output; + final checks = []; + late ProviderContainer container; + WorkspaceController get workspace => + container.read(workspaceControllerProvider.notifier); + String get source => container.read(workspaceControllerProvider).activeText; + + List elements(bool Function(Widget) predicate) { + final found = []; + void visit(Element element) { + if (predicate(element.widget)) found.add(element); + element.visitChildren(visit); + } + + final root = binding.root.currentContext; + if (root != null) visit(root as Element); + return found; + } + + Future waitFor(bool Function() condition, String label) async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Timed out: $label'); + } + await Future.delayed(const Duration(milliseconds: 40)); + } + } + + Future settle() async { + await Future.delayed(const Duration(milliseconds: 250)); + binding.scheduleFrame(); + await binding.endOfFrame; + } + + void check(bool value, String label) { + if (!value) throw StateError(label); + checks.add(label); + stdout.writeln('structural acceptance: $label'); + } + + Future native(List chord) async { + final result = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--key', + '$pid', + ...chord, + ]); + if (result.exitCode != 0) { + throw StateError('Native input: ${result.stderr}'); + } + await settle(); + } + + TextField field(String text) => elements( + (w) => w is TextField && w.controller?.text == text, + ).map((e) => e.widget as TextField).single; + BusyBlock block(String id) => elements( + (w) => w is BusyMarkWysiwygBlockField && w.block.id == id, + ).map((e) => (e.widget as BusyMarkWysiwygBlockField).block).single; + BusyBlock namedBlock(String text) => elements( + (w) => w is BusyMarkWysiwygBlockField && w.block.plainText == text, + ).map((e) => (e.widget as BusyMarkWysiwygBlockField).block).single; + + Future focus( + String text, { + int offset = 0, + bool selectAll = false, + }) async { + final target = field(text); + target.focusNode!.requestFocus(); + target.controller!.selection = selectAll + ? TextSelection(baseOffset: 0, extentOffset: text.length) + : TextSelection.collapsed(offset: offset); + await settle(); + return target; + } + + Future open(String path) async { + if (!await workspace.openActiveFile(path)) { + throw StateError('Could not open fixture within its workspace: $path'); + } + workspace.updateActiveEditorMode(DocumentViewModePreference.editor); + await waitFor( + () => + elements( + (w) => w is BusyMarkWysiwygEditor && w.document.filePath == path, + ).isNotEmpty && + container.read(workspaceControllerProvider).activeBuffer?.filePath == + path, + 'open $path', + ); + await settle(); + } + + Future capture(String name) async { + final boundary = + binding.root.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + await File( + '${output.path}/$name.png', + ).writeAsBytes(bytes.buffer.asUint8List()); + image.dispose(); + } + + Future saveAndReopen(String name) async { + final saved = source; + final file = File( + container.read(workspaceControllerProvider).activeBuffer!.filePath!, + ); + await native(['Control_L', 's']); + await waitFor( + () => + file.readAsStringSync() == saved && + !container.read(workspaceControllerProvider).activeBuffer!.isDirty, + '$name native save', + ); + await File( + '${output.path}/$name-saved${file.path.endsWith('.topic') ? '.topic' : '.md'}', + ).writeAsString(file.readAsStringSync()); + final deadline = DateTime.now().add(const Duration(seconds: 30)); + final closeObservations = >[]; + while (true) { + final captured = container + .read(workspaceControllerProvider) + .activeBuffer!; + if (captured.text != saved || + captured.isDirty || + captured.filePath != file.path) { + throw StateError('$name saved buffer changed before close'); + } + if (await workspace.closeActiveOpenFileTab()) break; + final current = container.read(workspaceControllerProvider).activeBuffer; + closeObservations.add({ + 'sameBufferSnapshot': identical(captured, current), + 'sameId': current?.id == captured.id, + 'sameRevision': current?.revision == captured.revision, + 'sameBytes': current?.text == saved, + 'dirty': current?.isDirty, + }); + // A session report can replace the captured immutable buffer during the + // real History flush. Respect that rejection; retry only unchanged saved + // content after another actual frame, never a changed target or edit. + if (current?.id != captured.id || + current?.text != saved || + current?.isDirty != false || + identical(captured, current) || + DateTime.now().isAfter(deadline)) { + await File( + '${output.path}/$name-close.json', + ).writeAsString(jsonEncode(closeObservations)); + throw StateError( + '$name close rejected without an unchanged replacement snapshot', + ); + } + await settle(); + } + await File( + '${output.path}/$name-close.json', + ).writeAsString(jsonEncode(closeObservations)); + check(true, '$name close saved buffer'); + await open(file.path); + check(source == saved, '$name reopened bytes'); + } + + Future heading(String path, String name) async { + await open(path); + final original = source; + final target = await focus('About the job'); + final before = namedBlock('About the job'); + if (name == 'writerside') { + check( + before.attributes['collapsible'] == 'true' && + before.attributes['default-state'] == 'expanded', + 'Writerside authored fixture mode', + ); + } + final undo = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + await native(['Return']); + final kept = namedBlock('About the job'); + check( + kept.id == before.id && + kept.kind == BusyBlockKind.heading && + kept.attributes['level'] == '2', + '$name heading identity and level', + ); + for (final key in ['id', 'generatedId', 'collapsible', 'default-state']) { + check( + kept.attributes[key] == before.attributes[key], + '$name heading $key', + ); + } + check( + field('About the job').focusNode == target.focusNode && + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 0, + '$name caret and focus', + ); + check( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undo + 1, + '$name one undo operation', + ); + check( + elements( + (w) => + w is BusyMarkWysiwygBlockField && + w.block.kind == BusyBlockKind.paragraph && + w.block.plainText.isEmpty && + !w.block.attributes.containsKey('collapsible'), + ).isNotEmpty, + '$name ordinary preceding paragraph', + ); + await capture('$name-enter'); + await native(['BackSpace']); + check(source == original, '$name immediate Backspace inverse source'); + check( + namedBlock('About the job').id == before.id, + '$name immediate Backspace inverse identity', + ); + await native(['Return']); + final entered = source; + await native(['Control_L', 'z']); + check(source == original, '$name undo'); + await native(['Control_L', 'Shift_L', 'z']); + check( + source == entered && + namedBlock('About the job').kind == BusyBlockKind.heading, + '$name redo source and heading', + ); + check( + elements( + (w) => + w is BusyMarkWysiwygBlockField && + w.block.kind == BusyBlockKind.paragraph && + w.block.plainText.isEmpty, + ).isEmpty, + '$name redo respects transient blank-paragraph lifecycle', + ); + await native(['Control_L', 'z']); + await native(['Return']); + await native(['z']); + check( + source.contains('## zAbout the job'), + '$name native typing in original heading', + ); + final saved = source; + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await settle(); + check(source == saved, '$name ${mode.name} source'); + } + await saveAndReopen(name); + final reopened = namedBlock('zAbout the job'); + check( + reopened.kind == BusyBlockKind.heading && + reopened.attributes['level'] == '2' && + reopened.attributes['collapsible'] == + before.attributes['collapsible'], + '$name reopened heading properties', + ); + } + + Future lists() async { + var sequence = 0; + for (final label in ['Parent', 'Ordered parent', 'Task parent']) { + for (final clear in [false, true]) { + final name = 'list-${sequence++}'; + final file = await File( + '${fixtures.path}/markdown/lists.md', + ).copy('${fixtures.path}/markdown/$name.md'); + await open(file.path); + final before = namedBlock(label); + final target = await focus(label, selectAll: clear); + if (clear) { + await native(['BackSpace']); + check( + block(before.id).plainText.isEmpty, + '$name cleared only parent caption', + ); + } + await native(['Return']); + final parent = block(before.id); + check( + parent.kind == before.kind && + _descendants(parent).map((b) => b.id).join(',') == + _descendants(before).map((b) => b.id).join(','), + '$name parent/subtree identity', + ); + check( + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 0, + '$name focus retained', + ); + await native(['z']); + check( + block(before.id).plainText == 'z${clear ? '' : label}', + '$name original parent receives input', + ); + await saveAndReopen(name); + final reparsed = const MarkdownParser() + .parse(filePath: file.path, source: source) + .busyDocument; + final reopenedParent = _walk( + reparsed.blocks, + ).singleWhere((b) => b.plainText == 'z${clear ? '' : label}'); + check( + _descendants(reopenedParent).map((b) => b.plainText).join('\u0000') == + _descendants(before).map((b) => b.plainText).join('\u0000'), + '$name reopened subtree stays with parent in order', + ); + if (label == 'Task parent') { + check( + _walk( + reparsed.blocks, + ).where((b) => b.kind == BusyBlockKind.taskListItem).length == + 4, + '$name empty task keeps checkbox semantics', + ); + } + for (final child in [ + 'Child', + 'Grandchild', + 'Ordered child', + 'Ordered grandchild', + 'Task child', + 'Task grandchild', + ]) { + check( + _walk(reparsed.blocks).where((b) => b.plainText == child).length == + 1, + '$name $child exactly once after reopening', + ); + } + await capture(name); + } + } + } + + Future images() async { + final file = '${fixtures.path}/markdown/inline-objects.md'; + await open(file); + final before = source; + final expected = _objects(namedBlock('abc')); + await focus('abc', offset: 1); + await native(['Shift_L', 'Return']); + check( + _objects(namedBlock('a\nbc')) == expected, + 'image Shift-Enter preserves objects and formatting', + ); + check( + field('a\nbc').focusNode!.hasFocus && + field('a\nbc').controller!.selection.extentOffset == 2, + 'image hard-break caret and focus', + ); + await native(['Control_L', 'z']); + check(source == before, 'image hard-break undo'); + final current = container + .read(appSettingsControllerProvider) + .shortcutOverrides; + final configured = await container + .read(keymapControllerProvider.notifier) + .update( + 'editor.hardLineBreak', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: current, + ); + check( + configured.status == KeymapUpdateStatus.saved, + 'configured hard-break shortcut', + ); + await focus('abc', offset: 1); + await native(['F8']); + check( + _objects(namedBlock('a\nbc')) == expected, + 'configured hard-break preserves objects', + ); + await native(['Control_L', 'z']); + await focus('abc', offset: 1); + final button = elements( + (w) => + w is BusyMarkHeaderIconButton && w.icon == BusyMarkGlyphs.blankLine, + ).single; + final render = button.findRenderObject()! as RenderBox; + final point = render.localToGlobal(render.size.center(Offset.zero)); + final click = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--click', + '$pid', + '${point.dx}', + '${point.dy}', + ]); + if (click.exitCode != 0) throw StateError('${click.stderr}'); + await settle(); + check( + _objects(namedBlock('a\n\nbc')) == expected, + 'native blank-line toolbar preserves objects', + ); + check( + field('a\n\nbc').focusNode!.hasFocus && + field('a\n\nbc').controller!.selection.extentOffset == 3, + 'image blank-line caret and focus', + ); + await saveAndReopen('images'); + check( + _objects(namedBlock('a\n\nbc')) == expected, + 'saved/reopened objects retain order, destinations and formatting', + ); + await capture('images'); + } + + Future inlineBreaks() async { + for (final writerside in [false, true]) { + final directory = writerside + ? '${fixtures.path}/writerside/topics' + : '${fixtures.path}/markdown'; + await workspace.openPath( + writerside ? '${fixtures.path}/writerside' : directory, + ); + for (final fixture in [ + 'inline-existing-break', + 'inline-consecutive-breaks', + ]) { + for (final count in [1, 2]) { + final name = + '${writerside ? 'writerside' : 'markdown'}-$fixture-$count'; + final original = await File( + '${fixtures.path}/markdown/$fixture.md', + ).readAsString(); + final file = File('$directory/$name.md'); + await file.writeAsString(original); + await open(file.path); + final mode = writerside + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark; + final before = const MarkdownParser() + .parse(filePath: file.path, source: original, mode: mode) + .busyDocument + .blocks + .single; + check( + (elements((w) => w is BusyMarkWysiwygEditor).single.widget + as BusyMarkWysiwygEditor) + .document + .mode == + mode, + '$name production document mode', + ); + final expectedText = before.plainText.replaceRange( + 1, + 1, + '\n' * count, + ); + final expected = jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(before.inlines)) + [ + anchor.offset <= 1 ? anchor.offset : anchor.offset + count, + _inline(anchor.inline), + ], + ]); + final target = await focus(before.plainText, offset: 1); + final undo = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + if (count == 1) { + await native(['Shift_L', 'Return']); + } else { + final button = elements( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == BusyMarkGlyphs.blankLine, + ).single; + final render = button.findRenderObject()! as RenderBox; + final point = render.localToGlobal(render.size.center(Offset.zero)); + final click = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--click', + '$pid', + '${point.dx}', + '${point.dy}', + ]); + if (click.exitCode != 0) throw StateError('${click.stderr}'); + await settle(); + } + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name exact live object positions, attributes and formatting', + ); + check( + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 1 + count, + '$name caret and focus', + ); + check( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undo + 1, + '$name one undo operation', + ); + final edited = source; + await native(['Control_L', 'z']); + check(source == original, '$name undo bytes'); + check( + _positionedObjects(namedBlock(before.plainText)) == + _positionedObjects(before), + '$name undo object positions and formatting', + ); + await native(['Control_L', 'Shift_L', 'z']); + check(source == edited, '$name redo bytes'); + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name redo object positions and formatting', + ); + await saveAndReopen(name); + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name saved/reopened object positions and formatting', + ); + await capture(name); + } + } + } + } + + Future run() async { + var passed = false; + String? error; + try { + await waitFor( + () => elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty, + 'production app startup', + ); + container = ProviderScope.containerOf( + elements((w) => w is BusyMarkWysiwygEditor).single, + ); + final settings = container.read(appSettingsControllerProvider.notifier); + await settings.setAutoSave(false); + await settings.setAutomaticSpelling(false); + await settings.setDocumentViewMode(DocumentViewModePreference.editor); + if (Platform.environment['BUSYMARK_STRUCTURAL_SCENARIO'] == + 'inline-breaks') { + await inlineBreaks(); + } else { + await heading( + '${fixtures.path}/markdown/header_test_text.md', + 'markdown', + ); + // openPath selects a workspace; openActiveFile opens a topic within it. + // Keep the project context that determines Writerside Markdown mode. + await workspace.openPath('${fixtures.path}/writerside'); + await heading( + '${fixtures.path}/writerside/topics/structure.md', + 'writerside', + ); + await workspace.openPath('${fixtures.path}/markdown'); + await lists(); + await images(); + } + passed = true; + } catch (exception, stack) { + error = '$exception\n$stack'; + } + await File('${output.path}/result.json').writeAsString( + const JsonEncoder.withIndent( + ' ', + ).convert({'passed': passed, 'checks': checks, 'error': error}), + ); + // Exit through the production close listener with all test edits saved. + if (!passed) { + stderr.writeln(error); + if (checks.isNotEmpty) { + await File('${output.path}/failure-source.md').writeAsString(source); + } + exit(1); + } + await windowManager.close(); + } +} + +Iterable _walk(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _walk(block.children); + } +} + +Iterable _descendants(BusyBlock block) => _walk(block.children); +String _positionedObjects(BusyBlock block) => jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + [anchor.offset, _inline(anchor.inline)], +]); +String _objects(BusyBlock block) => jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + _inline(anchor.inline), +]); +Object _inline(BusyInline inline) => [ + inline.kind.name, + inline.text, + inline.destination, + inline.attributes, + [for (final child in inline.children) _inline(child)], +]; diff --git a/tools/test_official_flutter_workflow.py b/tools/test_official_flutter_workflow.py index aeb4b0ac..0640b96b 100644 --- a/tools/test_official_flutter_workflow.py +++ b/tools/test_official_flutter_workflow.py @@ -148,7 +148,7 @@ def test_local_repack_resolves_sdk_names_and_paths_before_building(self): def test_pull_requests_cover_the_integrated_release_and_main(self): triggers = self.workflow.get('on', self.workflow.get(True)) - self.assertTrue({'main', 'release/v0.6.3'}.issubset(triggers['pull_request']['branches'])) + self.assertTrue({'main', 'release/v0.7.0'}.issubset(triggers['pull_request']['branches'])) self.assertIn('main', triggers['push']['branches']) for event in ['pull_request', 'push']: self.assertNotIn('paths', triggers[event])