From 0f7b613f112621d4e2dca3c318e68975676e5b90 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 8 Sep 2026 21:44:15 -0700 Subject: [PATCH 01/27] Bump version to v0.4.0 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 10 +++++----- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index ca81e724..e3a1300e 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.3.5'; +const busyMarkAppVersion = '0.4.0'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index f69c6ca8..1d5e6c1c 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -87,6 +87,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index db68b71c..864833f2 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.3.5 +version: 0.4.0 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 337033cd..efe9580e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.3.5" +version: "0.4.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/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 12e7ae0c..81537a27 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\.3\.5$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.4\.0$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.3\.5"$', multiLine: true)), + contains(RegExp(r'^version: "0\.4\.0"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,9 +57,9 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Wed, 9 Sep 2026 11:19:09 -0700 Subject: [PATCH 02/27] Implemented Clipboard History and persistent Local History end-to-end --- README.md | 10 +- docs/clipboard-fix-plan.md | 47 +- docs/history-architecture.md | 137 +++ docs/history.md | 87 ++ lib/l10n/app_ar.arb | 78 +- lib/l10n/app_de.arb | 78 +- lib/l10n/app_en.arb | 90 ++ lib/l10n/app_es.arb | 78 +- lib/l10n/app_et.arb | 78 +- lib/l10n/app_fa.arb | 78 +- lib/l10n/app_fr.arb | 78 +- lib/l10n/app_hi.arb | 78 +- lib/l10n/app_id.arb | 78 +- lib/l10n/app_it.arb | 78 +- lib/l10n/app_ja.arb | 78 +- lib/l10n/app_ko.arb | 78 +- lib/l10n/app_nb.arb | 78 +- lib/l10n/app_nl.arb | 78 +- lib/l10n/app_pl.arb | 78 +- lib/l10n/app_pt.arb | 78 +- lib/l10n/app_pt_BR.arb | 78 +- lib/l10n/app_ru.arb | 78 +- lib/l10n/app_tr.arb | 78 +- lib/l10n/app_uk.arb | 78 +- lib/l10n/app_vi.arb | 78 +- lib/l10n/app_zh.arb | 78 +- lib/l10n/app_zh_CN.arb | 78 +- lib/l10n/generated/app_localizations.dart | 456 +++++++++ lib/l10n/generated/app_localizations_ar.dart | 272 ++++++ lib/l10n/generated/app_localizations_de.dart | 281 ++++++ lib/l10n/generated/app_localizations_en.dart | 273 ++++++ lib/l10n/generated/app_localizations_es.dart | 281 ++++++ lib/l10n/generated/app_localizations_et.dart | 278 ++++++ lib/l10n/generated/app_localizations_fa.dart | 273 ++++++ lib/l10n/generated/app_localizations_fr.dart | 282 ++++++ lib/l10n/generated/app_localizations_hi.dart | 276 ++++++ lib/l10n/generated/app_localizations_id.dart | 273 ++++++ lib/l10n/generated/app_localizations_it.dart | 283 ++++++ lib/l10n/generated/app_localizations_ja.dart | 262 +++++ lib/l10n/generated/app_localizations_ko.dart | 262 +++++ lib/l10n/generated/app_localizations_nb.dart | 277 ++++++ lib/l10n/generated/app_localizations_nl.dart | 282 ++++++ lib/l10n/generated/app_localizations_pl.dart | 280 ++++++ lib/l10n/generated/app_localizations_pt.dart | 563 +++++++++++ lib/l10n/generated/app_localizations_ru.dart | 280 ++++++ lib/l10n/generated/app_localizations_tr.dart | 280 ++++++ lib/l10n/generated/app_localizations_uk.dart | 280 ++++++ lib/l10n/generated/app_localizations_vi.dart | 275 ++++++ lib/l10n/generated/app_localizations_zh.dart | 516 ++++++++++ lib/src/app/app_settings.dart | 136 +++ lib/src/app/busymark_app.dart | 82 ++ lib/src/app/busymark_main_menu.dart | 24 + lib/src/app/command_registry.dart | 24 + .../clipboard_history_controller.dart | 289 ++++++ .../clipboard/clipboard_history_panel.dart | 486 ++++++++++ lib/src/clipboard/clipboard_insertion.dart | 73 ++ lib/src/clipboard/clipboard_models.dart | 271 ++++++ lib/src/comparison/source_comparison.dart | 461 +++++++++ lib/src/editor/editor_text_context_menu.dart | 23 + lib/src/editor/source/source_editor.dart | 258 ++++- .../wysiwyg/wysiwyg_clipboard_fragment.dart | 7 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 444 ++++++++- lib/src/git/presentation/git_sidebar_tab.dart | 9 + .../local_history_comparison_view.dart | 690 +++++++++++++ .../local_history_controller.dart | 721 ++++++++++++++ .../local_history/local_history_models.dart | 283 ++++++ .../local_history/local_history_panel.dart | 428 ++++++++ .../local_history/local_history_store.dart | 916 ++++++++++++++++++ .../platform/header_bar_configuration.dart | 9 + .../platform/linux_header_bar_service.dart | 6 + lib/src/platform/rich_clipboard_service.dart | 110 ++- .../presentation/settings_screen.dart | 149 +++ .../presentation/welcome_screen.dart | 7 + .../presentation/workspace_screen.dart | 222 ++++- lib/src/workspace/workspace_controller.dart | 512 +++++++++- lib/src/workspace/workspace_tabs.dart | 35 +- linux/runner/my_application.cc | 30 + linux/runner/rich_clipboard_host.cc | 24 +- .../clipboard-cross-process.json | 1 + .../clipboard-plain-replacement.json | 1 + .../history-runtime/clipboard-roundtrip.json | 1 + .../markdown-clipboard-history-1280x800.png | Bin 0 -> 132470 bytes .../markdown-comparison-1920x1080.png | Bin 0 -> 176463 bytes .../markdown-deleted-comparison-1920x1080.png | Bin 0 -> 106890 bytes .../markdown-deleted-recovered-1920x1080.png | Bin 0 -> 129567 bytes .../markdown-fragment-restored-1920x1080.png | Bin 0 -> 180851 bytes .../markdown-local-history-1280x800.png | Bin 0 -> 117688 bytes .../history-runtime/markdown-report.json | 23 + .../writerside-clipboard-history-1280x800.png | Bin 0 -> 149065 bytes .../writerside-comparison-1920x1080.png | Bin 0 -> 175238 bytes ...writerside-fragment-restored-1920x1080.png | Bin 0 -> 180021 bytes .../writerside-local-history-1280x800.png | Bin 0 -> 133698 bytes .../history-runtime/writerside-report.json | 17 + test/src/app_settings_test.dart | 32 + test/src/app_smoke_test.dart | 2 +- test/src/clipboard_history_test.dart | 364 +++++++ test/src/header_bar_configuration_test.dart | 12 + test/src/linux_header_bar_service_test.dart | 20 +- test/src/local_history_controller_test.dart | 235 +++++ test/src/local_history_store_test.dart | 446 +++++++++ test/src/local_history_workspace_test.dart | 466 +++++++++ test/src/source_comparison_test.dart | 123 +++ test/src/source_editor_widget_test.dart | 193 ++++ test/src/wysiwyg_ai_test.dart | 3 + test/src/wysiwyg_clipboard_test.dart | 26 +- tools/clipboard_smoke.dart | 71 +- tools/history_visual_smoke.dart | 484 +++++++++ 107 files changed, 17970 insertions(+), 151 deletions(-) create mode 100644 docs/history-architecture.md create mode 100644 docs/history.md create mode 100644 lib/src/clipboard/clipboard_history_controller.dart create mode 100644 lib/src/clipboard/clipboard_history_panel.dart create mode 100644 lib/src/clipboard/clipboard_insertion.dart create mode 100644 lib/src/clipboard/clipboard_models.dart create mode 100644 lib/src/comparison/source_comparison.dart create mode 100644 lib/src/local_history/local_history_comparison_view.dart create mode 100644 lib/src/local_history/local_history_controller.dart create mode 100644 lib/src/local_history/local_history_models.dart create mode 100644 lib/src/local_history/local_history_panel.dart create mode 100644 lib/src/local_history/local_history_store.dart create mode 100644 test-results/history-runtime/clipboard-cross-process.json create mode 100644 test-results/history-runtime/clipboard-plain-replacement.json create mode 100644 test-results/history-runtime/clipboard-roundtrip.json create mode 100644 test-results/history-runtime/markdown-clipboard-history-1280x800.png create mode 100644 test-results/history-runtime/markdown-comparison-1920x1080.png create mode 100644 test-results/history-runtime/markdown-deleted-comparison-1920x1080.png create mode 100644 test-results/history-runtime/markdown-deleted-recovered-1920x1080.png create mode 100644 test-results/history-runtime/markdown-fragment-restored-1920x1080.png create mode 100644 test-results/history-runtime/markdown-local-history-1280x800.png create mode 100644 test-results/history-runtime/markdown-report.json create mode 100644 test-results/history-runtime/writerside-clipboard-history-1280x800.png create mode 100644 test-results/history-runtime/writerside-comparison-1920x1080.png create mode 100644 test-results/history-runtime/writerside-fragment-restored-1920x1080.png create mode 100644 test-results/history-runtime/writerside-local-history-1280x800.png create mode 100644 test-results/history-runtime/writerside-report.json create mode 100644 test/src/clipboard_history_test.dart create mode 100644 test/src/local_history_controller_test.dart create mode 100644 test/src/local_history_store_test.dart create mode 100644 test/src/local_history_workspace_test.dart create mode 100644 test/src/source_comparison_test.dart create mode 100644 tools/history_visual_smoke.dart diff --git a/README.md b/README.md index 2326f36b..e6093f7f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Markdown and Writerside editor for Linux. * **Technical documentation** — Local rendering of Mermaid, PlantUML, D2, fenced OpenAPI specifications, and MathJax mathematical expressions. * **PDF and HTML publishing** — Export Markdown documents and Writerside instances to configurable PDF or portable offline HTML, with controls for layout, typography, tables of contents, heading numbering, and HTML styling. * **Git integration** — Review changes and diffs, stage and unstage files, commit, create and switch branches, fetch, pull, push, inspect file and project history, compare historical versions, and restore earlier file versions. +* **Clipboard and Local History** — Reuse source, rich-text, and image fragments during the current session; compare and restore persistent on-device document revisions independently of Git. * **AI-assisted editing** — Optional Ollama, OpenAI, and Gemini integration with explicit edit scope and shared context, proposal review before changes are applied, and AI-assisted Git commit-message drafting. * **Workspace reliability** — Detect files changed outside BusyMark, recover unsaved documents, restore previous workspace sessions, manage remote-image permissions, and protect Git operations behind workspace trust. @@ -116,6 +117,13 @@ BusyMark supports Ollama, OpenAI, and Google Gemini. Proposed edits are presente See [AI editing](docs/local-ai.md) for configuration, privacy, provider behavior, and security information. +## History and recovery + +See [Clipboard History and Local History](docs/history.md) for collection scope, +limits, retention, restoration, exclusions, and how these tools differ from +undo, crash recovery, and Git. Implementation and verification details are in +[the history architecture note](docs/history-architecture.md). + ## Installation Install BusyMark from the Snap Store: @@ -128,7 +136,7 @@ The Snap uses strict confinement. See [Snap confinement notes](docs/snap-confine ## Run from source -BusyMark currently uses Flutter 3.47.0. +BusyMark currently uses Flutter 3.47.2. Install the required Linux development packages: diff --git a/docs/clipboard-fix-plan.md b/docs/clipboard-fix-plan.md index c40bbf0e..559776fa 100644 --- a/docs/clipboard-fix-plan.md +++ b/docs/clipboard-fix-plan.md @@ -3,21 +3,23 @@ Status: implemented for the Linux desktop host. Editor copy previously published only plain text. Formatting stayed in a private -cache inside one Editor widget, so a fresh Editor or another application could -not retrieve it. Copy now publishes three representations in one GTK clipboard -operation, and ordinary paste uses the richest supported representation. +cache inside one Editor widget. Copy now publishes interoperable text and HTML +plus an opaque BusyMark ownership token in one GTK clipboard operation, and +ordinary paste uses the richest safely recognized representation. | Representation | Purpose | | --- | --- | -| `application/x-busymark-fragment+json` | Preserve BusyMark structure, inline formatting, task states, tables, and Writerside content between instances. | +| `application/x-busymark-token` | Resolve immutable structured content only inside the BusyMark process/session that created it. | | `text/html` | Preserve headings, emphasis, links, lists, tables, and code in compatible external applications. Tasks have visible checked/unchecked symbols. | | Standard GTK text targets | Support text-only destinations and Paste without formatting. | ## Implementation -- `rich_clipboard_service.dart` serializes competing writes and reads the system - clipboard without a private formatting cache. A native write publishes all - formats together; it is never followed by a plain write that would erase them. +- `rich_clipboard_service.dart` serializes competing writes and coherent reads. + It keeps a bounded, process-session map from opaque tokens to immutable rich + fragments; unknown tokens never deserialize internal object graphs. A native + write publishes all formats together and is never followed by a plain write + that would erase them. - `rich_clipboard_host.cc` owns immutable payload bytes independently of the originating widget. Reads are asynchronous and reject data from an owner that changes during the read. Transfers are limited to 16 MiB. The host advertises @@ -25,8 +27,9 @@ operation, and ordinary paste uses the richest supported representation. [`gtk_clipboard_set_can_store`](https://docs.gtk.org/gtk3/method.Clipboard.set_can_store.html). GTK's existing application shutdown performs the storage request. - `wysiwyg_clipboard_fragment.dart` uses a versioned, bounded, validated JSON - schema. Block IDs are regenerated during insertion. Relative links are rebased - and resolved local media paths are carried between documents/projects. + schema inside the owning process. Block IDs are regenerated during insertion. + Relative links are rebased and retained local media bytes are ingested for the + destination document when a Clipboard History item is inserted. - `wysiwyg_clipboard_html.dart` exports semantic HTML directly from the copied blocks. On import it normalizes supported CSS emphasis and checkbox state, strips executable markup and unsafe URLs, and uses the existing HTML adapter. @@ -52,17 +55,24 @@ operation, and ordinary paste uses the richest supported representation. - Both reported files, `issues.md` and `writerside-support.md`, passed a read-only codec and insertion probe. Heading, task, table, blockquote, and code content survived; exported HTML also imported successfully. -- Separate compiled BusyMark processes exchanged JSON, HTML, and plain text on - isolated X11 and headless GNOME Wayland displays. A subsequent Flutter plain - copy cleared the rich formats. +- Separate compiled BusyMark processes exchanged interoperable HTML and plain + text on isolated X11. The foreign opaque token deliberately did not resolve, + while a same-process round trip resolved its rich fragment. A subsequent + Flutter plain copy cleared the rich formats. - LibreOffice Writer pasted a Heading 1 paragraph, bold text, and visible checked and unchecked task states from the compiled Linux implementation. The native probe entrypoint is `tools/clipboard_smoke.dart`. Build it with -`flutter build linux --debug -t tools/clipboard_smoke.dart`, then run `write`, -`read`, or `plain` with the same temporary output directory on an isolated display. -Keep `write` running until the reads complete; creating a `stop` file in that -directory closes it. Rebuild with the default entrypoint afterward. +`flutter build linux --debug -t tools/clipboard_smoke.dart`. Run `roundtrip` to +verify same-process token resolution. Then run `write` and, while it owns the +clipboard, run `read` from a second process to verify interoperable HTML plus +non-resolution of the foreign token. Run `plain` afterward to verify stale rich +representations are gone. Use the same temporary output directory on an isolated +display; creating a `stop` file in that directory closes the writer. Rebuild with +the default entrypoint afterward. Under `xvfb-run`, set `GDK_BACKEND=x11` +explicitly so an inherited `WAYLAND_DISPLAY` cannot select an unrelated Wayland +connection. Wayland native behavior requires a compositor-driven run with real +input focus; the isolated automated probe in this checkout verifies X11. ## Interoperability limits @@ -71,7 +81,8 @@ clipboard manager and the formats it retains. That exit scenario remains a manua check. Source-widget disposal does not discard clipboard payloads. External applications choose which offered format to consume; plain-text applications will still paste plain text. Writerside-specific structures remain editable -through the BusyMark format and use readable HTML fallbacks externally. Local -media references point to existing files and do not bundle their bytes. +through the owning session's immutable payload and use readable HTML fallbacks +externally. Clipboard History packages supported local media bytes within its +stated budget. Hosts without the Linux method channel currently fall back to plain text. diff --git a/docs/history-architecture.md b/docs/history-architecture.md new file mode 100644 index 00000000..b83a761a --- /dev/null +++ b/docs/history-architecture.md @@ -0,0 +1,137 @@ +# History implementation notes + +## Clipboard ownership and insertion + +`RichClipboardService` owns the current BusyMark rich payload for the process +session, independently of `ClipboardHistoryController`. Every publication +creates an opaque UUID token and publishes the token, interoperable text, and +HTML together through the native boundary. Tokens resolve only through the +service's bounded immutable payload map; unknown, malformed, lost, or stale +tokens are external data. Text equality is never proof of ownership, and +clearing retained history does not invalidate the current rich clipboard. + +The Linux runner uses `application/x-busymark-token` alongside GTK text and +HTML targets and reports an owner generation for coherent reads. The shared +Dart boundary falls back only to Flutter's interoperable plain text when the +native method is unavailable. This checkout has no `windows/` runner, so no +Windows channel registration or registered-format host can be built or +runtime-verified here; generating a new runner would expand this work into an +unrelated platform-stack rewrite. + +Retained `BusyMarkClipboardPayload` objects have UUID identities, acquisition +and origin metadata, exact text/source/HTML/rich/image representations, a +SHA-256 equivalence fingerprint, and UTF-8/image-byte accounting. The policy +defaults are 100 entries, 64 MiB total payload bytes, and 8 MiB for decoded +thumbnails. Deduplication compares every meaningful representation. The +insertion registry holds only the latest mounted editable surface, uses +identity-safe unregistering, and requires each adapter to capture and +revalidate document ID, buffer revision, mode, selection generation, and +target lifetime around asynchronous work. + +Copy/cut capture happens only after successful publication. A cut publishes, +then revalidates its selection before one deletion transaction. External +content is retained only after a successful document paste. History insertion +uses the normal Source or WYSIWYG transaction and asset-ingestion paths. + +## Local History capture and identity + +`LocalHistoryController` observes document buffers and schedules a fixed +checkpoint window per changed buffer. Capture events are baseline, explicit +saved content, automatic checkpoint, before reload, before discard, before +restore, before delete, and observed external change. Pending buffers are +flushed on close, workspace replacement, and awaited shutdown. Autosave writes +do not themselves create Saved entries. + +History identity is stable independently of widget and transient buffer IDs. +Known file and directory moves remap current and descendant paths while +retaining historical paths. A first save of an untitled document continues its +lineage. Named-file Save As retains the source lineage separately and starts or +appends to the destination lineage; an overwritten destination is protected +before publication. + +## Store and retention + +Production revisions live in `local_history` beneath the platform application +support directory. Tests inject either an isolated directory or the memory +store. Store format version 1 uses a small index, independently recoverable +checksummed full-source JSON revision records, and an independent tombstone +journal. IDs are validated and all record paths are constructed under the +history root. + +Writes are serialized in-process and guarded by an exclusive file lock across +processes. A full record is staged and published on the same filesystem before +its index entry. Index recovery scans intact records and observes tombstones so +retention and explicit clears cannot resurrect deleted data. Unknown future +versions fail explicitly. Retention runs only after a successful capture, +counts actual record lengths, protects the new/protective record, and defaults +to 30 days and 512 MiB. + +## Comparison and restore safety + +`SourceComparisonInput` identifies immutable sources by ID, version, label, +and text. The comparison uses unique patience anchors plus bounded local LCS +and intraline refinement in Dart UTF-16 offsets. Separated edits are retained. +When an unanchored comparison exceeds the work bound, the result is labeled +simplified and exact region restore is disabled. + +Local History has its own `WorkspaceTabKind.localHistory`. The current side is +the live unsaved buffer when present, then a fresh disk load, or an explicit +missing-file state. Async results remain tied to both source versions. Restore +captures the target identity/path/revision/selection and exact range, persists +a protective snapshot, revalidates after awaits, and applies one +`DocumentBuffer.edited` transaction without replacing format metadata or the +undo stack. + +## Verification workflow + +Use the repository-pinned Flutter/Dart toolchain: + +```bash +flutter gen-l10n +dart format lib test/src +flutter analyze +flutter test +flutter build linux --release +flutter build linux --debug -t tools/clipboard_smoke.dart +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + roundtrip OUTPUT_DIRECTORY +# For inter-process ownership, run `write OUTPUT_DIRECTORY` in the background, +# wait for write.ready, then run `read OUTPUT_DIRECTORY` on the same X display. +# Stop the writer by creating OUTPUT_DIRECTORY/stop, then run +# `plain OUTPUT_DIRECTORY` to verify that an external plain-text owner does not +# inherit stale BusyMark formats. + +# Native production-controller visual exercise (use disposable workspaces): +flutter build linux --debug -t tools/history_visual_smoke.dart +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + markdown WORKSPACE PRIMARY_FILE DELETE_FILE OUTPUT_DIRECTORY +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + writerside WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY +``` + +When a probe is launched through `xvfb-run`, explicitly set +`GDK_BACKEND=x11`; a stale inherited `WAYLAND_DISPLAY` can otherwise make GTK +select Wayland, where clipboard publication requires a compositor input serial +that a headless probe does not have. The visual target writes PNGs and a JSON +report, and exits nonzero if any production clipboard, capture, comparison, +fragment-restore, deletion, or recovery check fails. + +The current checkout is pinned to Flutter 3.47.2 / Dart 3.13.2. Available +native validation is Linux-only because the repository contains no Windows +runner or Windows plugin registration path. + +Final verification for this implementation used the commands above plus the +cross-process writer/reader sequence on one X11 display. Localization +generation and formatting completed cleanly, `flutter analyze` reported no +issues, all 1,563 executed tests passed (with 12 existing intentional skips), +and the Linux release bundle built successfully. The compiled clipboard probe +reported: + +- same-process: text, HTML, and session-owned rich resolution all passed; +- cross-process: text and HTML passed, and the foreign token did not resolve; +- external plain replacement: text passed with no stale HTML or rich payload. + +The production-controller visual target completed both its Markdown light/en +scenario and its Writerside dark/ar scenario at 1280×800 and 1920×1080. Its +Markdown run also deleted, compared, and recovered a document. Reports and +captured frames are retained under `test-results/history-runtime/`. diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 00000000..6561e9ba --- /dev/null +++ b/docs/history.md @@ -0,0 +1,87 @@ +# Clipboard History and Local History + +BusyMark provides two separate history tools. Clipboard History helps you +reuse copied material during the current application session. Local History +keeps source revisions on this device so that earlier document content can be +compared and restored. + +## Clipboard History + +Open **Clipboard History** from the sidebar selector, main menu, command +palette, or a document editor's context menu. The panel stays open while you +move between documents and selections. Select an item to preview it, then use +**Paste**, **Paste as Plain Text**, Enter, or double-click to insert it at the +latest visible editable selection. Escape returns focus to the editor. + +BusyMark collects successful copy and cut operations from document editors, +plus supported content copied in another application after it is successfully +pasted into a BusyMark document. The current system clipboard can be refreshed +and previewed without adding it to the retained list. Search examines the full +text, not only the row excerpt. + +History can contain exact Source selections, BusyMark rich fragments, and +supported clipboard images. An image is retained as bytes; it is added to a +document's assets only when pasted. Rich fragments retain a source form for +cross-mode insertion. Unavailable local references are not silently resolved +against an unrelated destination. + +Clipboard History is memory-only and is discarded when BusyMark exits. It +keeps at most 100 entries and 64 MiB in total, evicting the oldest entries +first. An item larger than the total budget can still be copied or pasted but +is not retained. **Clear Clipboard History** clears BusyMark's list, not the +operating-system clipboard. Collection can be disabled under +**Settings → History** without clearing existing session entries. + +## Local History + +Open **Local History** from the sidebar selector, command palette, document +tab or editor context menu, or a file-tree context menu. **Find in Local +History…** also finds closed, renamed, deleted, and untitled documents by +stored name, path, and revision content, even when their original workspace is +not open. + +BusyMark records complete source text and its encoding/line-ending policy. A +baseline is captured when an eligible existing document is first tracked. +Later revisions record successful explicit saves, 60-second automatic +checkpoint windows, and content protected before known reload, discard, +restore, delete, or external-change operations. The checkpoint deadline is +not postponed by continued typing, and the ordinary 1.5-second autosave does +not create a revision per write. Unchanged adjacent content is deduplicated. + +Selecting a revision opens a read-only source comparison against the unsaved +editor buffer when available, otherwise a fresh disk snapshot. Missing files +are identified explicitly. You can copy selected source, restore the whole +revision, or restore an exact change when the comparison is current and exact. +Each restore is one ordinary undoable document edit and follows the existing +autosave setting. Before a restore, BusyMark must persist a protective snapshot +of the current content. If recording is disabled, the path is excluded, or the +capture fails, the restore leaves the document unchanged. + +For a deleted file, use **Restore to Original Location** or **Restore to New +Location…**. Existing destinations require the normal overwrite decision. +Cancelling or failing recovery does not remove the retained revision. + +Local History defaults to 30-day retention and 512 MiB total storage. Either +limit may remove older entries sooner. Under **Settings → History**, recording, +the checkpoint interval, age, storage limit, and exclusions are configurable. +Each exclusion is one absolute file or directory path; a directory excludes +all descendants using the host platform's path rules. Relative and blank lines +are ignored. Disabling recording stops new automatic and protective captures +but leaves existing revisions available until retention or explicit clearing. + +Clearing one document's history or all Local History permanently removes only +stored revisions. It does not delete project files, Git data, recovery data, or +the system clipboard. + +## What each recovery tool means + +- **Undo/redo** is the current document's bounded in-memory edit sequence. +- **Crash recovery** protects the newest unsaved buffers after an interrupted + session; it is not a revision timeline. +- **Local History** is a timestamped, on-device source revision timeline. It is + not encrypted, a backup, or an audit log. +- **Git File History and Project History** remain repository history. Local + History never creates commits, refs, or sidecar repositories. + +Clipboard and Local History content is not sent to AI providers, telemetry, or +diagnostic logs by these features. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 6353995b..b162d19d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "اترك عرضًا وارتفاعًا للمحتوى لا يقلان عن 20 مم بعد الهوامش.", "exportInvalidColor": "أدخل لونًا بصيغة #RRGGBB.", "exportInvalidCss": "اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.", - "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}." + "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}.", + "clipboardHistory": "سجل النسخ واللصق", + "clipboardHistoryDescription": "افتح سجل النسخ واللصق الخاص بالجلسة.", + "clipboardCurrent": "النسخة واللصق الحالية", + "clipboardCurrentExternal": "النسخة واللصق من تطبيق آخر", + "clipboardDestination": "الصق في {name}", + "clipboardSessionOnly": "مخزن فقط لهذه جلسة BusyMark.", + "clipboardNoItems": "سجل النسخ واللصق فارغ.", + "clipboardNoMatches": "لا توجد عناصر في سجل النسخ واللصق تطابق هذا البحث.", + "clipboardDisabled": "مجموعة سجل النسخ واللصق معطلة في الإعدادات.", + "clipboardUnavailable": "محتوى النسخة واللصق الحالي غير متاح أو غير مدعوم.", + "clipboardTooLarge": "هذا العنصر في النسخة واللصق كبير جدًا بحيث لا يمكن الاحتفاظ به.", + "clipboardPastePlainText": "الصق كنص عادي", + "clipboardClearAll": "مسح سجل النسخ واللصق", + "clipboardEntryText": "نص", + "clipboardEntryRichText": "نص غني", + "clipboardEntryImage": "صورة", + "clipboardOrigin": "تم نسخها من {name}", + "localHistory": "سجل محلي", + "localHistoryEllipsis": "سجل محلي…", + "localHistoryDescription": "اعرض النسخ والمراجعات المحلية المحفوظة والآلية للمستند الحالي.", + "findLocalHistoryEllipsis": "البحث في التاريخ المحلي…", + "findLocalHistoryDescription": "البحث في مستندات التاريخ المحلي ومحتوى الإصدارات.", + "localHistoryNoDocuments": "لا توجد مستندات لديها تاريخ محلي.", + "localHistoryNoRevisions": "هذا المستند ليس لديه إصدارات في التاريخ المحلي.", + "localHistoryNoMatches": "لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.", + "localHistorySearchHint": "البحث في محتوى الإصدار", + "localHistorySelectedRevision": "الإصدار المحدد", + "localHistoryCurrentEditor": "محتوى المحرر الحالي", + "localHistoryCurrentDisk": "المحتوى الحالي على القرص", + "localHistoryMissingFile": "الملف الأصلي مفقود.", + "localHistoryRestoreRevision": "استعادة الإصدار", + "localHistoryRestoreOriginalLocation": "استعادة إلى الموقع الأصلي", + "localHistoryRestoreNewLocation": "استعادة إلى موقع جديد…", + "localHistoryRestoreChange": "استعادة التغيير", + "localHistoryRestoreUnavailable": "لا يمكن استعادة هذا التغيير لأن المقارنة لم تعد حديثة.", + "localHistoryComparisonSimplified": "تم تبسيط هذا المقارنة الكبير. لا يمكن استعادة التغييرات الفردية.", + "localHistoryOlderChange": "تغيير الإصدار الأقدم: {text}", + "localHistoryCurrentChange": "تغيير المحتوى الحالي: {text}", + "localHistoryDeleted": "تم الحذف", + "localHistoryUntitled": "غير مُسمى", + "localHistoryClearDocument": "مسح سجل هذا المستند", + "localHistoryClearAll": "مسح السجل المحلي بالكامل", + "localHistoryClearDocumentTitle": "هل تريد مسح سجل المحلي؟", + "localHistoryClearAllTitle": "هل تريد مسح السجل المحلي؟", + "localHistoryClearConfirmation": "هذا يزيل بشكل دائم النسخ المحفوظة. لا يمكن التراجع عن هذه العملية.", + "localHistoryWarningIndexRebuilt": "تم تلف فهرس السجل المحلي وإعادة بنائه من النسخ السليمة.", + "localHistoryWarningUnsupportedFormat": "يستخدم هذا السجل المحلي تنسيقًا غير مدعوم {version}.", + "localHistoryWarningUnavailable": "السجل المحلي غير متاح: {detail}", + "localHistoryWarningRecordingDisabled": "تم تعطيل التسجيل أو تم استبعاد هذا المسار. قم بتفعيل التسجيل مرة أخرى قبل الاستعادة.", + "localHistoryWarningPathChange": "لم يتمكن السجل المحلي من تسجيل تغيير المسار: {detail}", + "localHistoryWarningDeletedPath": "لم يتمكن السجل المحلي من الاحتفاظ بالمسار المحذوف: {detail}", + "localHistoryWarningRevisionMissing": "النسخة المختارة مفقودة أو تالفة.", + "localHistoryWarningRevisionRead": "لا يمكن قراءة النسخة المختارة: {detail}", + "localHistoryWarningCapture": "لم يتمكن السجل المحلي من التقاط هذه النسخة: {detail}", + "localHistoryRevisionAt": "{reason}، {timestamp}", + "localHistoryReasonBaseline": "النسخة الأساسية", + "localHistoryReasonSaved": "محفوظ", + "localHistoryReasonAutomaticCheckpoint": "نقطة فحص تلقائية", + "localHistoryReasonBeforeReload": "قبل إعادة التحميل", + "localHistoryReasonBeforeDiscard": "قبل التخلص", + "localHistoryReasonBeforeRestore": "قبل الاستعادة", + "localHistoryReasonBeforeDelete": "قبل الحذف", + "localHistoryReasonExternalChange": "تغيير خارجي", + "settingsHistory": "سجل التغييرات", + "settingsClipboardHistoryTitle": "جمع سجل الحافظة", + "settingsClipboardHistoryDescription": "احتفظ بالمحتوى المنسوخ والمُلصق بنجاح لهذه الجلسة فقط في BusyMark.", + "settingsLocalHistoryTitle": "تسجيل سجل محلي", + "settingsLocalHistoryDescription": "حفظ إصدارات المستند على هذا الجهاز للتعافي والمقارنة.", + "settingsHistoryCheckpoint": "فترة التحقق التلقائي", + "settingsHistoryRetention": "مدة الاحتفاظ", + "settingsHistoryStorage": "الحد الأقصى للتخزين", + "settingsHistoryExcludedPaths": "مسارات مستبعدة", + "settingsHistoryExcludedPathsHint": "مسار مطلق واحد في كل سطر", + "settingsSecondsValue": "{value} ثوان", + "settingsDaysValue": "{value} أيام", + "settingsMebibytesValue": "{value} ميجابايت" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 27fdcb4a..a7934ba0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Nach Abzug der Ränder müssen mindestens 20 mm Inhaltsbreite und -höhe bleiben.", "exportInvalidColor": "Geben Sie eine Farbe als #RRGGBB ein.", "exportInvalidCss": "Wählen Sie eine lesbare UTF-8-CSS-Datei bis 256 KiB ohne Importe, Ressourcen-URLs, HTML sowie ausführbares oder maskiertes CSS.", - "exportInvalidRange": "{field}: Geben Sie eine Zahl zwischen {minimum} und {maximum} ein." + "exportInvalidRange": "{field}: Geben Sie eine Zahl zwischen {minimum} und {maximum} ein.", + "clipboardHistory": "Zwischenablage-Historie", + "clipboardHistoryDescription": "Öffne die Sitzungs-spezifische Zwischenablage-Historie.", + "clipboardCurrent": "Aktuelle Zwischenablage", + "clipboardCurrentExternal": "Aktuelle Zwischenablage aus einer anderen Anwendung", + "clipboardDestination": "Einfügen in {name}", + "clipboardSessionOnly": "Nur für diese BusyMark-Sitzung gespeichert.", + "clipboardNoItems": "Zwischenablage-Historie ist leer.", + "clipboardNoMatches": "Keine Zwischenablagen-Elemente passen zu dieser Suche.", + "clipboardDisabled": "Die Sammlung \"Zwischenablage-Historie\" ist in den Einstellungen deaktiviert.", + "clipboardUnavailable": "Der aktuelle Zwischenablage-Inhalt ist nicht verfügbar oder wird nicht unterstützt.", + "clipboardTooLarge": "Dieses Zwischenablagen-Element ist zu groß, um es zu speichern.", + "clipboardPastePlainText": "Einfügen als reinen Text", + "clipboardClearAll": "Löschen der Zwischenablage-Historie", + "clipboardEntryText": "Textinhalt", + "clipboardEntryRichText": "Reicher Text", + "clipboardEntryImage": "Bild", + "clipboardOrigin": "Kopiert aus {name}", + "localHistory": "Lokale Historie", + "localHistoryEllipsis": "Lokale Historie…", + "localHistoryDescription": "Zeige gespeicherte und automatische lokale Revisionen für das aktuelle Dokument.", + "findLocalHistoryEllipsis": "In Lokaler Historie suchen…", + "findLocalHistoryDescription": "Lokale Historie Dokumente und Revisionen durchsuchen.", + "localHistoryNoDocuments": "Es gibt noch keine Dokumente mit Lokaler Historie.", + "localHistoryNoRevisions": "Dieses Dokument hat keine Lokalen Historie Revisionen.", + "localHistoryNoMatches": "Keine Lokalen Historie Revisionen passen zu dieser Suche.", + "localHistorySearchHint": "Revisionen durchsuchen", + "localHistorySelectedRevision": "Ausgewählte Revision", + "localHistoryCurrentEditor": "Aktueller Editor Inhalt", + "localHistoryCurrentDisk": "Aktueller Inhalt auf der Festplatte", + "localHistoryMissingFile": "Die Originaldatei fehlt.", + "localHistoryRestoreRevision": "Revision Wiederherstellen", + "localHistoryRestoreOriginalLocation": "Zur Originalposition Wiederherstellen", + "localHistoryRestoreNewLocation": "Zur neuen Position Wiederherstellen…", + "localHistoryRestoreChange": "Änderung Wiederherstellen", + "localHistoryRestoreUnavailable": "Diese Änderung kann nicht wiederhergestellt werden, da der Vergleich nicht mehr aktuell ist.", + "localHistoryComparisonSimplified": "Dieser große Vergleich wurde vereinfacht. Einzelne Änderungen können nicht wiederhergestellt werden.", + "localHistoryOlderChange": "Ältere Revision Änderung: {text}", + "localHistoryCurrentChange": "Aktuelle Inhalt Änderung: {text}", + "localHistoryDeleted": "Gelöscht", + "localHistoryUntitled": "Unbenannt", + "localHistoryClearDocument": "Löschen Sie den Verlauf dieses Dokuments", + "localHistoryClearAll": "Löschen Sie den gesamten lokalen Verlauf", + "localHistoryClearDocumentTitle": "Verlauf dieses Dokuments löschen?", + "localHistoryClearAllTitle": "Verlauf dieses Dokuments löschen?", + "localHistoryClearConfirmation": "Dies entfernt dauerhaft gespeicherte Revisionen. Diese Aktion kann nicht rückgängig gemacht werden.", + "localHistoryWarningIndexRebuilt": "Der lokale Verlauf wurde beschädigt und aus intakten Revisionen neu aufgebaut.", + "localHistoryWarningUnsupportedFormat": "Dieser lokale Verlauf verwendet das nicht unterstützte Format {version}.", + "localHistoryWarningUnavailable": "Lokaler Verlauf ist nicht verfügbar: {detail}", + "localHistoryWarningRecordingDisabled": "Aufzeichnung ist deaktiviert oder dieser Pfad ist ausgeschlossen. Aktivieren Sie die Aufzeichnung erneut, bevor Sie wiederherstellen.", + "localHistoryWarningPathChange": "Lokaler Verlauf konnte die Pfadänderung nicht aufzeichnen: {detail}", + "localHistoryWarningDeletedPath": "Lokaler Verlauf konnte den gelöschten Pfad nicht speichern: {detail}", + "localHistoryWarningRevisionMissing": "Die ausgewählte Revision fehlt oder ist beschädigt.", + "localHistoryWarningRevisionRead": "Die ausgewählte Revision kann nicht gelesen werden: {detail}", + "localHistoryWarningCapture": "Lokaler Verlauf konnte diese Revision nicht erfassen: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Basisversion", + "localHistoryReasonSaved": "Gespeichert", + "localHistoryReasonAutomaticCheckpoint": "Automatische Checkpoint", + "localHistoryReasonBeforeReload": "Vor dem Neuladen", + "localHistoryReasonBeforeDiscard": "Vor dem Entsorgen", + "localHistoryReasonBeforeRestore": "Vor der Wiederherstellung", + "localHistoryReasonBeforeDelete": "Vor dem Löschen", + "localHistoryReasonExternalChange": "Externe Änderung", + "settingsHistory": "Verlauf", + "settingsClipboardHistoryTitle": "Verlauf der Zwischenablage sammeln", + "settingsClipboardHistoryDescription": "Behalten Sie den Inhalt, der erfolgreich kopiert und eingefügt wurde, nur für diese BusyMark-Sitzung.", + "settingsLocalHistoryTitle": "Lokalen Verlauf speichern", + "settingsLocalHistoryDescription": "Speichern Sie Dokumentversionen auf diesem Gerät zur Wiederherstellung und zum Vergleich.", + "settingsHistoryCheckpoint": "Automatische Checkpoint-Intervall", + "settingsHistoryRetention": "Aufbewahrungsdauer", + "settingsHistoryStorage": "Maximale Speicherkapazität", + "settingsHistoryExcludedPaths": "Ausschließlich Pfade", + "settingsHistoryExcludedPathsHint": "Ein absoluter Pfad pro Zeile", + "settingsSecondsValue": "{value} Sekunden", + "settingsDaysValue": "{value} Tage", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f4ba2a59..7efa62db 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2445,6 +2445,96 @@ "exportInvalidGeometry": "Leave at least 20 mm of content width and height after margins.", "exportInvalidColor": "Enter a color as #RRGGBB.", "exportInvalidCss": "Choose a readable UTF-8 .css file up to 256 KiB, without imports, resource URLs, HTML, or executable or escaped CSS.", + "clipboardHistory": "Clipboard History", + "clipboardHistoryDescription": "Open the session-local clipboard history.", + "clipboardCurrent": "Current clipboard", + "clipboardCurrentExternal": "Current clipboard from another application", + "clipboardDestination": "Paste into {name}", + "@clipboardDestination": {"placeholders": {"name": {"type": "String"}}}, + "clipboardSessionOnly": "Stored for this BusyMark session only.", + "clipboardNoItems": "Clipboard history is empty.", + "clipboardNoMatches": "No clipboard items match this search.", + "clipboardDisabled": "Clipboard History collection is disabled in Settings.", + "clipboardUnavailable": "The current clipboard content is unavailable or unsupported.", + "clipboardTooLarge": "This clipboard item is too large to retain.", + "clipboardPastePlainText": "Paste as Plain Text", + "clipboardClearAll": "Clear Clipboard History", + "clipboardEntryText": "Text", + "clipboardEntryRichText": "Rich text", + "clipboardEntryImage": "Image", + "clipboardOrigin": "Copied from {name}", + "@clipboardOrigin": {"placeholders": {"name": {"type": "String"}}}, + "localHistory": "Local History", + "localHistoryEllipsis": "Local History…", + "localHistoryDescription": "Show saved and automatic local revisions for the current document.", + "findLocalHistoryEllipsis": "Find in Local History…", + "findLocalHistoryDescription": "Search Local History documents and revision contents.", + "localHistoryNoDocuments": "No documents have Local History yet.", + "localHistoryNoRevisions": "This document has no Local History revisions.", + "localHistoryNoMatches": "No Local History revisions match this search.", + "localHistorySearchHint": "Search revision contents", + "localHistorySelectedRevision": "Selected revision", + "localHistoryCurrentEditor": "Current editor content", + "localHistoryCurrentDisk": "Current content on disk", + "localHistoryMissingFile": "The original file is missing.", + "localHistoryRestoreRevision": "Restore Revision", + "localHistoryRestoreOriginalLocation": "Restore to Original Location", + "localHistoryRestoreNewLocation": "Restore to New Location…", + "localHistoryRestoreChange": "Restore Change", + "localHistoryRestoreUnavailable": "This change cannot be restored because the comparison is no longer current.", + "localHistoryComparisonSimplified": "This large comparison is simplified. Restore individual changes is unavailable.", + "localHistoryOlderChange": "Older revision change: {text}", + "@localHistoryOlderChange": {"placeholders": {"text": {"type": "String"}}}, + "localHistoryCurrentChange": "Current content change: {text}", + "@localHistoryCurrentChange": {"placeholders": {"text": {"type": "String"}}}, + "localHistoryDeleted": "Deleted", + "localHistoryUntitled": "Untitled", + "localHistoryClearDocument": "Clear This Document’s History", + "localHistoryClearAll": "Clear All Local History", + "localHistoryClearDocumentTitle": "Clear document history?", + "localHistoryClearAllTitle": "Clear all Local History?", + "localHistoryClearConfirmation": "This permanently removes stored revisions. This action cannot be undone.", + "localHistoryWarningIndexRebuilt": "The Local History index was damaged and rebuilt from intact revisions.", + "localHistoryWarningUnsupportedFormat": "This Local History store uses unsupported format {version}.", + "@localHistoryWarningUnsupportedFormat": {"placeholders": {"version": {"type": "String"}}}, + "localHistoryWarningUnavailable": "Local History is unavailable: {detail}", + "@localHistoryWarningUnavailable": {"placeholders": {"detail": {"type": "String"}}}, + "localHistoryWarningRecordingDisabled": "Recording is disabled or this path is excluded. Re-enable recording before restoring.", + "localHistoryWarningPathChange": "Local History could not record the path change: {detail}", + "@localHistoryWarningPathChange": {"placeholders": {"detail": {"type": "String"}}}, + "localHistoryWarningDeletedPath": "Local History could not retain the deleted path: {detail}", + "@localHistoryWarningDeletedPath": {"placeholders": {"detail": {"type": "String"}}}, + "localHistoryWarningRevisionMissing": "The selected revision is missing or damaged.", + "localHistoryWarningRevisionRead": "The selected revision cannot be read: {detail}", + "@localHistoryWarningRevisionRead": {"placeholders": {"detail": {"type": "String"}}}, + "localHistoryWarningCapture": "Local History could not capture this revision: {detail}", + "@localHistoryWarningCapture": {"placeholders": {"detail": {"type": "String"}}}, + "localHistoryRevisionAt": "{reason} · {timestamp}", + "@localHistoryRevisionAt": {"placeholders": {"reason": {"type": "String"}, "timestamp": {"type": "String"}}}, + "localHistoryReasonBaseline": "Baseline", + "localHistoryReasonSaved": "Saved", + "localHistoryReasonAutomaticCheckpoint": "Automatic checkpoint", + "localHistoryReasonBeforeReload": "Before reload", + "localHistoryReasonBeforeDiscard": "Before discard", + "localHistoryReasonBeforeRestore": "Before restore", + "localHistoryReasonBeforeDelete": "Before delete", + "localHistoryReasonExternalChange": "External change", + "settingsHistory": "History", + "settingsClipboardHistoryTitle": "Collect Clipboard History", + "settingsClipboardHistoryDescription": "Keep copied and successfully pasted content for this BusyMark session only.", + "settingsLocalHistoryTitle": "Record Local History", + "settingsLocalHistoryDescription": "Store document revisions on this device for recovery and comparison.", + "settingsHistoryCheckpoint": "Automatic checkpoint interval", + "settingsHistoryRetention": "Retention age", + "settingsHistoryStorage": "Maximum storage", + "settingsHistoryExcludedPaths": "Excluded paths", + "settingsHistoryExcludedPathsHint": "One absolute path per line", + "settingsSecondsValue": "{value} seconds", + "@settingsSecondsValue": {"placeholders": {"value": {"type": "int"}}}, + "settingsDaysValue": "{value} days", + "@settingsDaysValue": {"placeholders": {"value": {"type": "int"}}}, + "settingsMebibytesValue": "{value} MiB", + "@settingsMebibytesValue": {"placeholders": {"value": {"type": "int"}}}, "exportInvalidRange": "{field}: enter a number between {minimum} and {maximum}.", "@exportInvalidRange": { "description": "Invalid numeric export setting.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index dd6fd8bf..eece5225 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Deje al menos 20 mm de ancho y alto de contenido después de los márgenes.", "exportInvalidColor": "Introduzca un color como #RRGGBB.", "exportInvalidCss": "Elija un archivo .css UTF-8 legible de hasta 256 KiB, sin importaciones, URL de recursos, HTML ni CSS ejecutable o escapado.", - "exportInvalidRange": "{field}: introduzca un número entre {minimum} y {maximum}." + "exportInvalidRange": "{field}: introduzca un número entre {minimum} y {maximum}.", + "clipboardHistory": "Historial de la Copia", + "clipboardHistoryDescription": "Abrir el historial de la copia local de la sesión.", + "clipboardCurrent": "Copia actual", + "clipboardCurrentExternal": "Copia actual de otra aplicación", + "clipboardDestination": "Pegar en {name}", + "clipboardSessionOnly": "Almacenado solo para esta sesión de BusyMark.", + "clipboardNoItems": "El historial de la copia está vacío.", + "clipboardNoMatches": "Ningún elemento de la copia coincide con esta búsqueda.", + "clipboardDisabled": "La colección de Historial de la Copia está deshabilitada en la configuración.", + "clipboardUnavailable": "El contenido de la copia actual no está disponible o no es compatible.", + "clipboardTooLarge": "Este elemento de la copia es demasiado grande para conservarlo.", + "clipboardPastePlainText": "Pegar como Texto Plano", + "clipboardClearAll": "Borrar el Historial de la Copia", + "clipboardEntryText": "Texto", + "clipboardEntryRichText": "Texto enriquecido", + "clipboardEntryImage": "Imagen", + "clipboardOrigin": "Copiado de {name}", + "localHistory": "Historial Local", + "localHistoryEllipsis": "Historial Local…", + "localHistoryDescription": "Mostrar las revisiones locales guardadas y automáticas para el documento actual.", + "findLocalHistoryEllipsis": "Buscar en el Historial Local…", + "findLocalHistoryDescription": "Buscar documentos y contenido de revisiones en el Historial Local.", + "localHistoryNoDocuments": "Ningún documento tiene Historial Local.", + "localHistoryNoRevisions": "Este documento no tiene revisiones en el Historial Local.", + "localHistoryNoMatches": "Ninguna revisión del Historial Local coincide con esta búsqueda.", + "localHistorySearchHint": "Buscar contenido de la revisión", + "localHistorySelectedRevision": "Revisión seleccionada", + "localHistoryCurrentEditor": "Contenido del editor actual", + "localHistoryCurrentDisk": "Contenido actual en el disco", + "localHistoryMissingFile": "El archivo original está ausente.", + "localHistoryRestoreRevision": "Restaurar Revisión", + "localHistoryRestoreOriginalLocation": "Restaurar a la Ubicación Original", + "localHistoryRestoreNewLocation": "Restaurar a una Nueva Ubicación…", + "localHistoryRestoreChange": "Restaurar Cambio", + "localHistoryRestoreUnavailable": "Este cambio no puede ser restaurado porque la comparación ya no es válida.", + "localHistoryComparisonSimplified": "Esta comparación grande se ha simplificado. Restaurar cambios individuales no está disponible.", + "localHistoryOlderChange": "Cambio de revisión anterior: {text}", + "localHistoryCurrentChange": "Cambio de contenido actual: {text}", + "localHistoryDeleted": "Eliminado", + "localHistoryUntitled": "Sin título", + "localHistoryClearDocument": "Borrar el historial de este documento", + "localHistoryClearAll": "Borrar todo el historial local", + "localHistoryClearDocumentTitle": "¿Borrar el historial del documento?", + "localHistoryClearAllTitle": "¿Borrar todo el historial local?", + "localHistoryClearConfirmation": "Esto elimina permanentemente las revisiones almacenadas. Esta acción no se puede deshacer.", + "localHistoryWarningIndexRebuilt": "El índice de historial local se dañó y se reconstruyó a partir de revisiones intactas.", + "localHistoryWarningUnsupportedFormat": "Esta tienda de historial local utiliza un formato no soportado {version}.", + "localHistoryWarningUnavailable": "El historial local no está disponible: {detail}", + "localHistoryWarningRecordingDisabled": "La grabación está deshabilitada o esta ruta está excluida. Re-habilite la grabación antes de restaurar.", + "localHistoryWarningPathChange": "El historial local no pudo registrar el cambio de ruta: {detail}", + "localHistoryWarningDeletedPath": "El historial local no pudo conservar la ruta eliminada: {detail}", + "localHistoryWarningRevisionMissing": "La revisión seleccionada está ausente o dañada.", + "localHistoryWarningRevisionRead": "La revisión seleccionada no se puede leer: {detail}", + "localHistoryWarningCapture": "El historial local no pudo capturar esta revisión: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Línea base", + "localHistoryReasonSaved": "Guardado", + "localHistoryReasonAutomaticCheckpoint": "Puntos de control automático", + "localHistoryReasonBeforeReload": "Antes de recargar", + "localHistoryReasonBeforeDiscard": "Antes de descartar", + "localHistoryReasonBeforeRestore": "Antes de restaurar", + "localHistoryReasonBeforeDelete": "Antes de eliminar", + "localHistoryReasonExternalChange": "Cambio externo", + "settingsHistory": "Historial", + "settingsClipboardHistoryTitle": "Recolectar Historial de Copia", + "settingsClipboardHistoryDescription": "Mantener el contenido copiado y pegado con éxito solo para esta sesión de BusyMark.", + "settingsLocalHistoryTitle": "Registrar Historial Local", + "settingsLocalHistoryDescription": "Almacenar revisiones del documento en este dispositivo para recuperación y comparación.", + "settingsHistoryCheckpoint": "Intervalo de punto de control automático", + "settingsHistoryRetention": "Edad de retención", + "settingsHistoryStorage": "Almacenamiento máximo", + "settingsHistoryExcludedPaths": "Rutas excluidas", + "settingsHistoryExcludedPathsHint": "Una ruta absoluta por línea", + "settingsSecondsValue": "{value} segundos", + "settingsDaysValue": "{value} días", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 775a56b5..e5acefb5 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -3604,5 +3604,81 @@ "exportInvalidGeometry": "Jäta pärast veeriseid sisule vähemalt 20 mm laiust ja kõrgust.", "exportInvalidColor": "Sisesta värv kujul #RRGGBB.", "exportInvalidCss": "Vali loetav UTF-8 .css-fail kuni 256 KiB, ilma importide, ressursside URL-ide, HTML-i ning käivitatava või paojadadega CSS-ita.", - "exportInvalidRange": "{field}: sisesta arv vahemikus {minimum} kuni {maximum}." + "exportInvalidRange": "{field}: sisesta arv vahemikus {minimum} kuni {maximum}.", + "clipboardHistory": "Kleepimiskredi ajalugu", + "clipboardHistoryDescription": "Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.", + "clipboardCurrent": "Praegune kleepimiskredi", + "clipboardCurrentExternal": "Praegune kleepimiskredi teistest rakendusest", + "clipboardDestination": "Kleepi {name}", + "clipboardSessionOnly": "Salvestatud ainult sellele BusyMark sessioonile.", + "clipboardNoItems": "Kleepimiskredi ajalugu on tühi.", + "clipboardNoMatches": "Ei leitud kleepimiskredi elemente, mis vastavad sellele otsingule.", + "clipboardDisabled": "Kleepimiskredi ajalugu on peatatud seadetes.", + "clipboardUnavailable": "Praegune kleepimiskredi sisu pole saadaval või toetatud.", + "clipboardTooLarge": "See kleepimiskredi element on liiga suur, et seda säilitada.", + "clipboardPastePlainText": "Kleepi kui tavatekst", + "clipboardClearAll": "Puhasta kleepimiskredi ajalugu", + "clipboardEntryText": "Tekst", + "clipboardEntryRichText": "Rikas tekst", + "clipboardEntryImage": "Pilt", + "clipboardOrigin": "Kleepitud {name}", + "localHistory": "Paikne ajalugu", + "localHistoryEllipsis": "Paikne ajalugu…", + "localHistoryDescription": "Näita salvestatud ja automaatseid paikseid versioone sellele dokumendile.", + "findLocalHistoryEllipsis": "Leia kohalikust ajaloost…", + "findLocalHistoryDescription": "Otsi kohalises ajaloos ja versioonide sisu.", + "localHistoryNoDocuments": "Mida ei ole kohalist ajaloos.", + "localHistoryNoRevisions": "Selle dokumendil pole kohalist ajaloos.", + "localHistoryNoMatches": "Ei leidunud kohalise ajalooga versioone.", + "localHistorySearchHint": "Otsi versioonide sisu", + "localHistorySelectedRevision": "Valitud versioon", + "localHistoryCurrentEditor": "Praegune redaktoori sisu", + "localHistoryCurrentDisk": "Praegune sisu faile", + "localHistoryMissingFile": "Algne fail puudub.", + "localHistoryRestoreRevision": "Taasta versioon", + "localHistoryRestoreOriginalLocation": "Taasta algsele asukohale", + "localHistoryRestoreNewLocation": "Taasta uuele asukohale…", + "localHistoryRestoreChange": "Taasta muudatus", + "localHistoryRestoreUnavailable": "Seda muudatust ei saa taastada, kuna võrdlemine pole enam kehtiv.", + "localHistoryComparisonSimplified": "See suur võrdlemine on lihtsustatud. Eraldi muudatuste taastamine pole võimalik.", + "localHistoryOlderChange": "Vana versiooni muudatus: {text}", + "localHistoryCurrentChange": "Praeguse versiooni muudatus: {text}", + "localHistoryDeleted": "Hävitatud", + "localHistoryUntitled": "Nimetamata", + "localHistoryClearDocument": "Kaustade ajaloo kustutada", + "localHistoryClearAll": "Kõik kohalikud ajaloo kustutada", + "localHistoryClearDocumentTitle": "Kas soovite kustutada dokumendi ajaloo?", + "localHistoryClearAllTitle": "Kas soovite kustutada kõik kohaliku aja?", + "localHistoryClearConfirmation": "See eemaldab püsivalt salvestatud versioonid. Seda toimingut ei saa tagasi pöörata.", + "localHistoryWarningIndexRebuilt": "Kohalik ajaloo indeks oli kahjustatud ja taastati tervete versioonide abil.", + "localHistoryWarningUnsupportedFormat": "See kohalik ajaloo salvestus kasutab tugimat format {version}.", + "localHistoryWarningUnavailable": "Kohalik ajaloo pole saadaval: {detail}", + "localHistoryWarningRecordingDisabled": "Salvestamine on välja lülitatud või see tee on eemaldatud. Lülitage salvestamine uuesti sisse enne taastamist.", + "localHistoryWarningPathChange": "Kohalik ajaloo ei suutnud salvestada teed: {detail}", + "localHistoryWarningDeletedPath": "Kohalik ajaloo ei suutnud säilitada kustutatud teed: {detail}", + "localHistoryWarningRevisionMissing": "Valitud versioon on kadunud või kahjustatud.", + "localHistoryWarningRevisionRead": "Valitud versiooni ei saa lugeda: {detail}", + "localHistoryWarningCapture": "Kohalik ajaloo ei suutnud seda versiooni salvestada: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Alusversioon", + "localHistoryReasonSaved": "Salvestatud", + "localHistoryReasonAutomaticCheckpoint": "Automaatne kontrollpunkt", + "localHistoryReasonBeforeReload": "Enne laadimist", + "localHistoryReasonBeforeDiscard": "Enne kõrvaldamist", + "localHistoryReasonBeforeRestore": "Enne taastamist", + "localHistoryReasonBeforeDelete": "Enne kustutamist", + "localHistoryReasonExternalChange": "Väline muudatus", + "settingsHistory": "Ajalugu", + "settingsClipboardHistoryTitle": "Salvesta klipi ajalugu", + "settingsClipboardHistoryDescription": "Hoiab salvestatud ja edukalt kopeeritud sisu ainult selle BusyMark sessiooni jaoks.", + "settingsLocalHistoryTitle": "Salvesta kohalik ajalugu", + "settingsLocalHistoryDescription": "Salvesta dokumendi versioonid selle seadmele taastamiseks ja võrdlemiseks.", + "settingsHistoryCheckpoint": "Automaatne kontrollpunkte intervall", + "settingsHistoryRetention": "Säilitamise aeg", + "settingsHistoryStorage": "Maksimaalne salvestamine", + "settingsHistoryExcludedPaths": "Exkludeeritud teed", + "settingsHistoryExcludedPathsHint": "Üks absoluutne tee per rida", + "settingsSecondsValue": "{value} sekundid", + "settingsDaysValue": "{value} päevad", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3bff95fe..a756ee4e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -3186,5 +3186,81 @@ "exportInvalidGeometry": "پس از حاشیه‌ها دست‌کم ۲۰ میلی‌متر عرض و ارتفاع برای محتوا باقی بگذارید.", "exportInvalidColor": "رنگ را به‌صورت #RRGGBB وارد کنید.", "exportInvalidCss": "یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.", - "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید." + "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید.", + "clipboardHistory": "تاریخچه کپی و پیست", + "clipboardHistoryDescription": "باز کردن تاریخچه کپی و پیست مخصوص جلسه.", + "clipboardCurrent": "کپی و پیست فعلی", + "clipboardCurrentExternal": "کپی و پیست از برنامه دیگر", + "clipboardDestination": "کپی و پیست در {name}", + "clipboardSessionOnly": "فقط برای این جلسه BusyMark ذخیره می شود.", + "clipboardNoItems": "تاریخچه کپی و پیست خالی است.", + "clipboardNoMatches": "هیچ آیتم کپی و پیستی با این جستجو مطابقت ندارد.", + "clipboardDisabled": "مجموعه تاریخچه کپی و پیست در تنظیمات غیرفعال است.", + "clipboardUnavailable": "محتوای کپی و پیست فعلی در دسترس نیست یا پشتیبانی نمی شود.", + "clipboardTooLarge": "این آیتم کپی شده خیلی بزرگ است و نمی‌توان آن را نگه داشت.", + "clipboardPastePlainText": "به عنوان متن ساده کپی کنید", + "clipboardClearAll": "تاریخچه کپی را پاک کنید", + "clipboardEntryText": "متن", + "clipboardEntryRichText": "متن غنی", + "clipboardEntryImage": "تصویر", + "clipboardOrigin": "کپی شده از {name}", + "localHistory": "تاریخچه محلی", + "localHistoryEllipsis": "تاریخچه محلی…", + "localHistoryDescription": "نسخه‌های ذخیره شده و خودکار محلی را برای سند فعلی نمایش دهید.", + "findLocalHistoryEllipsis": "در تاریخ محلی جستجو کنید...", + "findLocalHistoryDescription": "اسناد و محتوای نسخه‌های تاریخ محلی را جستجو کنید.", + "localHistoryNoDocuments": "هنوز هیچ سند دارای تاریخ محلی نیست.", + "localHistoryNoRevisions": "این سند دارای هیچ نسخه‌ای از تاریخ محلی نیست.", + "localHistoryNoMatches": "هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.", + "localHistorySearchHint": "محتوای نسخه را جستجو کنید", + "localHistorySelectedRevision": "نسخه انتخاب شده", + "localHistoryCurrentEditor": "محتوای فعلی ویرایشگر", + "localHistoryCurrentDisk": "محتوای فعلی روی دیسک", + "localHistoryMissingFile": "فایل اصلی وجود ندارد.", + "localHistoryRestoreRevision": "بازگرداندن نسخه", + "localHistoryRestoreOriginalLocation": "بازگرداندن به محل اصلی", + "localHistoryRestoreNewLocation": "بازگرداندن به محل جدید…", + "localHistoryRestoreChange": "بازگرداندن تغییر", + "localHistoryRestoreUnavailable": "این تغییر قابل بازگشت نیست زیرا مقایسه دیگر معتبر نیست.", + "localHistoryComparisonSimplified": "این مقایسه بزرگ ساده شده است. بازگرداندن تغییرات فردی امکان پذیر نیست.", + "localHistoryOlderChange": "تغییر در نسخه قدیمی: {text}", + "localHistoryCurrentChange": "تغییر در محتوا فعلی: {text}", + "localHistoryDeleted": "حذف شده", + "localHistoryUntitled": "نام‌گذاری نشده", + "localHistoryClearDocument": "پاک کردن تاریخچه این سند", + "localHistoryClearAll": "پاک کردن تمام تاریخچه محلی", + "localHistoryClearDocumentTitle": "پاک کردن تاریخچه سند؟", + "localHistoryClearAllTitle": "پاک کردن تمام تاریخچه محلی؟", + "localHistoryClearConfirmation": "این به طور دائمی تغییرات ذخیره شده را حذف می کند. این عمل قابل لغو نیست.", + "localHistoryWarningIndexRebuilt": "ایندکس تاریخچه محلی آسیب دیده و از تغییرات سالم بازسازی شده است.", + "localHistoryWarningUnsupportedFormat": "این فروشگاه تاریخچه محلی از فرمت پشتیبانی نشده {version} استفاده می کند.", + "localHistoryWarningUnavailable": "تاریخچه محلی در دسترس نیست: {detail}", + "localHistoryWarningRecordingDisabled": "ضبط غیرفعال است یا این مسیر حذف شده است. قبل از بازیابی، ضبط را دوباره فعال کنید.", + "localHistoryWarningPathChange": "تاریخچه محلی نمی تواند تغییر مسیر را ثبت کند: {detail}", + "localHistoryWarningDeletedPath": "تاریخ محلی نمی‌تواند مسیر حذف شده را حفظ کند: {detail}", + "localHistoryWarningRevisionMissing": "نسخه انتخاب شده وجود ندارد یا آسیب دیده است.", + "localHistoryWarningRevisionRead": "نمی‌توان نسخه انتخاب شده را خواند: {detail}", + "localHistoryWarningCapture": "تاریخ محلی نمی‌تواند این نسخه را ثبت کند: {detail}", + "localHistoryRevisionAt": "{reason}، {timestamp}", + "localHistoryReasonBaseline": "خط مبنا", + "localHistoryReasonSaved": "ذخیره شده", + "localHistoryReasonAutomaticCheckpoint": "نقطه تیک کنترل خودکار", + "localHistoryReasonBeforeReload": "قبل از بارگذاری مجدد", + "localHistoryReasonBeforeDiscard": "قبل از حذف", + "localHistoryReasonBeforeRestore": "قبل از بازیابی", + "localHistoryReasonBeforeDelete": "قبل از حذف", + "localHistoryReasonExternalChange": "تغییرات خارجی", + "settingsHistory": "تاریخچه", + "settingsClipboardHistoryTitle": "جمع آوری تاریخچه کپی شده", + "settingsClipboardHistoryDescription": "محتوای کپی شده و موفق به پیوستن را فقط برای این جلسه BusyMark نگه دارید.", + "settingsLocalHistoryTitle": "ثبت تاریخچه محلی", + "settingsLocalHistoryDescription": "ذخیره ویرایش های سند در این دستگاه برای بازیابی و مقایسه.", + "settingsHistoryCheckpoint": "فاصله زمانی خودکار", + "settingsHistoryRetention": "دوره نگهداری", + "settingsHistoryStorage": "حداکثر فضای ذخیره سازی", + "settingsHistoryExcludedPaths": "مسیرهای حذف شده", + "settingsHistoryExcludedPathsHint": "یک مسیر مطلق در هر خط", + "settingsSecondsValue": "{value} ثانیه", + "settingsDaysValue": "{value} روز", + "settingsMebibytesValue": "{value} مگابایت" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fa1c4bdf..17efdd3d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Laissez au moins 20 mm de largeur et de hauteur de contenu après les marges.", "exportInvalidColor": "Saisissez une couleur au format #RRGGBB.", "exportInvalidCss": "Choisissez un fichier .css UTF-8 lisible de 256 KiB maximum, sans imports, URL de ressources, HTML ni CSS exécutable ou échappé.", - "exportInvalidRange": "{field} : saisissez un nombre entre {minimum} et {maximum}." + "exportInvalidRange": "{field} : saisissez un nombre entre {minimum} et {maximum}.", + "clipboardHistory": "Historique de la presse-papiers", + "clipboardHistoryDescription": "Ouvrir l'historique de la presse-papiers local à la session.", + "clipboardCurrent": "Presse-papiers actuel", + "clipboardCurrentExternal": "Presse-papiers actuel provenant d'une autre application", + "clipboardDestination": "Coller dans {name}", + "clipboardSessionOnly": "Stocké uniquement pour cette session BusyMark.", + "clipboardNoItems": "L'historique de la presse-papiers est vide.", + "clipboardNoMatches": "Aucun élément de presse-papiers ne correspond à cette recherche.", + "clipboardDisabled": "La collection d'Historique de la presse-papiers est désactivée dans les paramètres.", + "clipboardUnavailable": "Le contenu actuel du presse-papiers n'est pas disponible ou non pris en charge.", + "clipboardTooLarge": "Cet élément du presse-papiers est trop grand pour être conservé.", + "clipboardPastePlainText": "Coller en texte brut", + "clipboardClearAll": "Effacer l'historique du presse-papiers", + "clipboardEntryText": "Texte", + "clipboardEntryRichText": "Texte enrichi", + "clipboardEntryImage": "Image du presse-papiers", + "clipboardOrigin": "Copié depuis {name}", + "localHistory": "Historique local", + "localHistoryEllipsis": "Historique local…", + "localHistoryDescription": "Afficher les révisions locales enregistrées et automatiques pour le document actuel.", + "findLocalHistoryEllipsis": "Rechercher dans l'Historique Local…", + "findLocalHistoryDescription": "Rechercher les documents et le contenu des révisions dans l'Historique Local.", + "localHistoryNoDocuments": "Aucun document n'a Historique Local.", + "localHistoryNoRevisions": "Ce document n'a pas de révisions dans l'Historique Local.", + "localHistoryNoMatches": "Aucune révision n'est trouvée dans cette recherche.", + "localHistorySearchHint": "Rechercher le contenu des révisions", + "localHistorySelectedRevision": "Révision sélectionnée", + "localHistoryCurrentEditor": "Contenu de l'éditeur actuel", + "localHistoryCurrentDisk": "Contenu actuel sur le disque", + "localHistoryMissingFile": "Le fichier original est manquant.", + "localHistoryRestoreRevision": "Restaurer la version", + "localHistoryRestoreOriginalLocation": "Restaurer à l'emplacement original", + "localHistoryRestoreNewLocation": "Restaurer à un nouvel emplacement…", + "localHistoryRestoreChange": "Restaurer la modification", + "localHistoryRestoreUnavailable": "Cette modification ne peut pas être restaurée car la comparaison n'est plus à jour.", + "localHistoryComparisonSimplified": "Cette comparaison importante est simplifiée. Restaurer des modifications individuelles n'est pas possible.", + "localHistoryOlderChange": "Modification de la version plus ancienne: {text}", + "localHistoryCurrentChange": "Modification du contenu actuel: {text}", + "localHistoryDeleted": "Supprimé", + "localHistoryUntitled": "Sans titre", + "localHistoryClearDocument": "Effacer l'historique de ce document", + "localHistoryClearAll": "Effacer l'historique local", + "localHistoryClearDocumentTitle": "Effacer l'historique du document ?", + "localHistoryClearAllTitle": "Effacer l'historique local ?", + "localHistoryClearConfirmation": "Cela supprime définitivement les révisions stockées. Cette action ne peut pas être annulée.", + "localHistoryWarningIndexRebuilt": "L'index de l'historique local a été endommagé et reconstruit à partir de révisions intactes.", + "localHistoryWarningUnsupportedFormat": "Ce magasin d'historique local utilise un format non pris en charge {version}.", + "localHistoryWarningUnavailable": "L'historique local n'est pas disponible : {detail}", + "localHistoryWarningRecordingDisabled": "L'enregistrement est désactivé ou ce chemin est exclu. Réactivez l'enregistrement avant de restaurer.", + "localHistoryWarningPathChange": "L'historique local n'a pas pu enregistrer le changement de chemin : {detail}", + "localHistoryWarningDeletedPath": "L'historique local n'a pas pu conserver le chemin supprimé: {detail}", + "localHistoryWarningRevisionMissing": "La révision sélectionnée est manquante ou corrompue.", + "localHistoryWarningRevisionRead": "La révision sélectionnée ne peut pas être lue: {detail}", + "localHistoryWarningCapture": "L'historique local n'a pas pu capturer cette révision: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Base", + "localHistoryReasonSaved": "Sauvegardé", + "localHistoryReasonAutomaticCheckpoint": "Point de contrôle automatique", + "localHistoryReasonBeforeReload": "Avant rechargement", + "localHistoryReasonBeforeDiscard": "Avant suppression", + "localHistoryReasonBeforeRestore": "Avant la restauration", + "localHistoryReasonBeforeDelete": "Avant la suppression", + "localHistoryReasonExternalChange": "Changement externe", + "settingsHistory": "Historique", + "settingsClipboardHistoryTitle": "Collecter l'historique du presse-papier", + "settingsClipboardHistoryDescription": "Conserver le contenu copié et correctement collé uniquement pour cette session BusyMark.", + "settingsLocalHistoryTitle": "Enregistrer l'historique local", + "settingsLocalHistoryDescription": "Stocker les révisions du document sur cet appareil pour la récupération et la comparaison.", + "settingsHistoryCheckpoint": "Intervalle de sauvegarde automatique", + "settingsHistoryRetention": "Période de conservation", + "settingsHistoryStorage": "Stockage maximal", + "settingsHistoryExcludedPaths": "Chemins exclus", + "settingsHistoryExcludedPathsHint": "Un chemin absolu par ligne", + "settingsSecondsValue": "{value} secondes", + "settingsDaysValue": "{value} jours", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 397b515c..0c4381ba 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "मार्जिन के बाद सामग्री के लिए कम से कम 20 mm चौड़ाई और ऊँचाई छोड़ें।", "exportInvalidColor": "रंग #RRGGBB के रूप में दर्ज करें।", "exportInvalidCss": "256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।", - "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।" + "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।", + "clipboardHistory": "क्लिपबोर्ड इतिहास", + "clipboardHistoryDescription": "सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।", + "clipboardCurrent": "वर्तमान क्लिपबोर्ड", + "clipboardCurrentExternal": "किसी अन्य एप्लिकेशन से वर्तमान क्लिपबोर्ड", + "clipboardDestination": "{name} में पेस्ट करें", + "clipboardSessionOnly": "केवल इस BusyMark सत्र के लिए संग्रहीत।", + "clipboardNoItems": "क्लिपबोर्ड इतिहास खाली है।", + "clipboardNoMatches": "कोई क्लिपबोर्ड आइटम इस खोज से मेल नहीं खाता।", + "clipboardDisabled": "क्लिपबोर्ड इतिहास संग्रह सेटिंग्स में अक्षम है।", + "clipboardUnavailable": "वर्तमान क्लिपबोर्ड सामग्री अनुपलब्ध या समर्थित नहीं है।", + "clipboardTooLarge": "यह क्लिपबोर्ड आइटम बहुत बड़ा है और इसे बनाए रखना संभव नहीं है।", + "clipboardPastePlainText": "सादे टेक्स्ट के रूप में पेस्ट करें", + "clipboardClearAll": "क्लिपबोर्ड इतिहास साफ़ करें", + "clipboardEntryText": "टेक्स्ट", + "clipboardEntryRichText": "रिच टेक्स्ट", + "clipboardEntryImage": "इमेज", + "clipboardOrigin": "{name} से कॉपी किया गया", + "localHistory": "स्थानीय इतिहास", + "localHistoryEllipsis": "स्थानीय इतिहास…", + "localHistoryDescription": "वर्तमान दस्तावेज़ के लिए सहेजे गए और स्वचालित स्थानीय संशोधनों को दिखाएं।", + "findLocalHistoryEllipsis": "स्थानीय इतिहास में खोजें...", + "findLocalHistoryDescription": "स्थानीय इतिहास में दस्तावेज़ और संशोधन सामग्री खोजें।", + "localHistoryNoDocuments": "अभी तक किसी भी दस्तावेज़ में स्थानीय इतिहास नहीं है।", + "localHistoryNoRevisions": "इस दस्तावेज़ में स्थानीय इतिहास में कोई संशोधन नहीं है।", + "localHistoryNoMatches": "यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।", + "localHistorySearchHint": "संशोधन सामग्री खोजें", + "localHistorySelectedRevision": "चयनित संशोधन", + "localHistoryCurrentEditor": "वर्तमान संपादक सामग्री", + "localHistoryCurrentDisk": "डिस्क पर वर्तमान सामग्री", + "localHistoryMissingFile": "मूल फ़ाइल गायब है।", + "localHistoryRestoreRevision": "पिछली स्थिति को पुनर्स्थापित करें", + "localHistoryRestoreOriginalLocation": "मूल स्थान पर पुनर्स्थापित करें", + "localHistoryRestoreNewLocation": "नए स्थान पर पुनर्स्थापित करें…", + "localHistoryRestoreChange": "परिवर्तन को पुनर्स्थापित करें", + "localHistoryRestoreUnavailable": "इस परिवर्तन को पुनर्स्थापित नहीं किया जा सकता क्योंकि तुलना अब अद्यतित नहीं है।", + "localHistoryComparisonSimplified": "यह बड़ा परिवर्तन सरलीकृत है। व्यक्तिगत परिवर्तनों को पुनर्स्थापित करना संभव नहीं है।", + "localHistoryOlderChange": "पुराने संस्करण में परिवर्तन: {text}", + "localHistoryCurrentChange": "वर्तमान सामग्री में परिवर्तन: {text}", + "localHistoryDeleted": "हटा दिया गया", + "localHistoryUntitled": "शीर्षकहीन", + "localHistoryClearDocument": "इस दस्तावेज़ का इतिहास साफ़ करें", + "localHistoryClearAll": "सभी स्थानीय इतिहास साफ़ करें", + "localHistoryClearDocumentTitle": "दस्तावेज़ का इतिहास साफ़ करना?", + "localHistoryClearAllTitle": "सभी स्थानीय इतिहास साफ़ करना?", + "localHistoryClearConfirmation": "यह स्थायी रूप से संग्रहीत संशोधनों को हटा देता है। इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", + "localHistoryWarningIndexRebuilt": "स्थानीय इतिहास इंडेक्स क्षतिग्रस्त था और बरकरार संशोधनों से फिर से बनाया गया था।", + "localHistoryWarningUnsupportedFormat": "यह स्थानीय इतिहास स्टोर समर्थित प्रारूप {version} का उपयोग करता है।", + "localHistoryWarningUnavailable": "स्थानीय इतिहास उपलब्ध नहीं: {detail}", + "localHistoryWarningRecordingDisabled": "रिकॉर्डिंग अक्षम है या यह पथ बाहर रखा गया है। पुनर्स्थापित करने से पहले रिकॉर्डिंग को फिर से सक्षम करें।", + "localHistoryWarningPathChange": "स्थानीय इतिहास ने पथ परिवर्तन रिकॉर्ड नहीं किया: {detail}", + "localHistoryWarningDeletedPath": "स्थानीय इतिहास को हटाए गए पथ को सहेजने में असमर्थ: {detail}", + "localHistoryWarningRevisionMissing": "चयनित संशोधन गायब है या क्षतिग्रस्त है।", + "localHistoryWarningRevisionRead": "चयनित संशोधन को नहीं पढ़ा जा सकता: {detail}", + "localHistoryWarningCapture": "स्थानीय इतिहास इस संशोधन को कैप्चर नहीं कर सका: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "बेसलाइन", + "localHistoryReasonSaved": "सहेजा गया", + "localHistoryReasonAutomaticCheckpoint": "स्वचालित चेकपॉइंट", + "localHistoryReasonBeforeReload": "रीलोड करने से पहले", + "localHistoryReasonBeforeDiscard": "त्यागने से पहले", + "localHistoryReasonBeforeRestore": "पुनर्स्थापना से पहले", + "localHistoryReasonBeforeDelete": "हटाने से पहले", + "localHistoryReasonExternalChange": "बाहरी परिवर्तन", + "settingsHistory": "इतिहास", + "settingsClipboardHistoryTitle": "क्लिपबोर्ड इतिहास एकत्र करें", + "settingsClipboardHistoryDescription": "केवल इस BusyMark सत्र के लिए कॉपी किए गए और सफलतापूर्वक पेस्ट किए गए सामग्री को बनाए रखें।", + "settingsLocalHistoryTitle": "स्थानीय इतिहास रिकॉर्ड करें", + "settingsLocalHistoryDescription": "पुनर्प्राप्ति और तुलना के लिए इस डिवाइस पर दस्तावेज़ के संस्करणों को सहेजें।", + "settingsHistoryCheckpoint": "स्वचालित चेकपॉइंट अंतराल", + "settingsHistoryRetention": "प्रतिधारण आयु", + "settingsHistoryStorage": "अधिकतम संग्रहण", + "settingsHistoryExcludedPaths": "बाहर किए गए पथ", + "settingsHistoryExcludedPathsHint": "प्रति पंक्ति एक पूर्ण पथ", + "settingsSecondsValue": "{value} सेकंड", + "settingsDaysValue": "{value} दिन", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9ee3a64a..8e175ea0 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "Sisakan lebar dan tinggi isi minimal 20 mm setelah margin.", "exportInvalidColor": "Masukkan warna sebagai #RRGGBB.", "exportInvalidCss": "Pilih berkas .css UTF-8 yang dapat dibaca hingga 256 KiB, tanpa impor, URL sumber daya, HTML, atau CSS yang dapat dieksekusi atau memakai escape.", - "exportInvalidRange": "{field}: masukkan angka antara {minimum} dan {maximum}." + "exportInvalidRange": "{field}: masukkan angka antara {minimum} dan {maximum}.", + "clipboardHistory": "Sejarah Tempel", + "clipboardHistoryDescription": "Buka riwayat tempel lokal sesi.", + "clipboardCurrent": "Tempel saat ini", + "clipboardCurrentExternal": "Tempel saat ini dari aplikasi lain", + "clipboardDestination": "Tempel ke {name}", + "clipboardSessionOnly": "Hanya disimpan untuk sesi BusyMark ini.", + "clipboardNoItems": "Riwayat tempel kosong.", + "clipboardNoMatches": "Tidak ada item tempel yang cocok dengan pencarian ini.", + "clipboardDisabled": "Koleksi Riwayat Tempel dinonaktifkan di Pengaturan.", + "clipboardUnavailable": "Konten tempel saat ini tidak tersedia atau tidak didukung.", + "clipboardTooLarge": "Item clipboard ini terlalu besar untuk disimpan.", + "clipboardPastePlainText": "Tempel sebagai Teks Biasa", + "clipboardClearAll": "Hapus Riwayat Clipboard", + "clipboardEntryText": "Teks", + "clipboardEntryRichText": "Teks kaya", + "clipboardEntryImage": "Gambar", + "clipboardOrigin": "Disalin dari {name}", + "localHistory": "Riwayat Lokal", + "localHistoryEllipsis": "Riwayat Lokal…", + "localHistoryDescription": "Tampilkan revisi lokal yang disimpan dan otomatis untuk dokumen saat ini.", + "findLocalHistoryEllipsis": "Cari di Sejarah Lokal…", + "findLocalHistoryDescription": "Cari dokumen dan isi revisi dalam Sejarah Lokal.", + "localHistoryNoDocuments": "Tidak ada dokumen yang memiliki Sejarah Lokal.", + "localHistoryNoRevisions": "Dokumen ini tidak memiliki revisi Sejarah Lokal.", + "localHistoryNoMatches": "Tidak ada revisi Sejarah Lokal yang cocok dengan pencarian ini.", + "localHistorySearchHint": "Cari isi revisi", + "localHistorySelectedRevision": "Revisi yang dipilih", + "localHistoryCurrentEditor": "Konten editor saat ini", + "localHistoryCurrentDisk": "Konten saat ini di disk", + "localHistoryMissingFile": "File asli hilang.", + "localHistoryRestoreRevision": "Kembalikan Revisi", + "localHistoryRestoreOriginalLocation": "Kembalikan ke Lokasi Asli", + "localHistoryRestoreNewLocation": "Kembalikan ke Lokasi Baru…", + "localHistoryRestoreChange": "Kembalikan Perubahan", + "localHistoryRestoreUnavailable": "Perubahan ini tidak dapat dikembalikan karena perbandingan tidak lagi berlaku.", + "localHistoryComparisonSimplified": "Perbandingan besar ini disederhanakan. Kembalikan perubahan individual tidak tersedia.", + "localHistoryOlderChange": "Perubahan revisi yang lebih lama: {text}", + "localHistoryCurrentChange": "Perubahan konten saat ini: {text}", + "localHistoryDeleted": "Dihapus", + "localHistoryUntitled": "Tanpa Judul", + "localHistoryClearDocument": "Hapus Riwayat Dokumen Ini", + "localHistoryClearAll": "Hapus Riwayat Lokal Seluruhnya", + "localHistoryClearDocumentTitle": "Hapus riwayat dokumen?", + "localHistoryClearAllTitle": "Hapus riwayat lokal seluruhnya?", + "localHistoryClearConfirmation": "Ini secara permanen menghapus revisi yang tersimpan. Tindakan ini tidak dapat dibatalkan.", + "localHistoryWarningIndexRebuilt": "Indeks Riwayat Lokal rusak dan dibangun kembali dari revisi yang utuh.", + "localHistoryWarningUnsupportedFormat": "Penyimpanan Riwayat Lokal menggunakan format yang tidak didukung {version}.", + "localHistoryWarningUnavailable": "Riwayat Lokal tidak tersedia: {detail}", + "localHistoryWarningRecordingDisabled": "Perekaman dinonaktifkan atau jalur ini dikecualikan. Aktifkan kembali perekaman sebelum memulihkan.", + "localHistoryWarningPathChange": "Riwayat Lokal tidak dapat mencatat perubahan jalur: {detail}", + "localHistoryWarningDeletedPath": "Sejarah Lokal tidak dapat menyimpan jalur yang dihapus: {detail}", + "localHistoryWarningRevisionMissing": "Revisi yang dipilih hilang atau rusak.", + "localHistoryWarningRevisionRead": "Revisi yang dipilih tidak dapat dibaca: {detail}", + "localHistoryWarningCapture": "Sejarah Lokal tidak dapat menangkap revisi ini: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Versi dasar", + "localHistoryReasonSaved": "Tersimpan", + "localHistoryReasonAutomaticCheckpoint": "Checkpoint otomatis", + "localHistoryReasonBeforeReload": "Sebelum memuat ulang", + "localHistoryReasonBeforeDiscard": "Sebelum membuang", + "localHistoryReasonBeforeRestore": "Sebelum pemulihan", + "localHistoryReasonBeforeDelete": "Sebelum menghapus", + "localHistoryReasonExternalChange": "Perubahan eksternal", + "settingsHistory": "Riwayat", + "settingsClipboardHistoryTitle": "Kumpulkan Riwayat Klip", + "settingsClipboardHistoryDescription": "Simpan konten yang disalin dan berhasil ditempel hanya untuk sesi BusyMark ini.", + "settingsLocalHistoryTitle": "Rekam Riwayat Lokal", + "settingsLocalHistoryDescription": "Simpan revisi dokumen pada perangkat ini untuk pemulihan dan perbandingan.", + "settingsHistoryCheckpoint": "Interval checkpoint otomatis", + "settingsHistoryRetention": "Masa simpan", + "settingsHistoryStorage": "Penyimpanan maksimum", + "settingsHistoryExcludedPaths": "Jalur yang dikecualikan", + "settingsHistoryExcludedPathsHint": "Satu jalur absolut per baris", + "settingsSecondsValue": "{value} detik", + "settingsDaysValue": "{value} hari", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 2cbee3d7..563b7bf5 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Lascia almeno 20 mm di larghezza e altezza del contenuto dopo i margini.", "exportInvalidColor": "Inserisci un colore come #RRGGBB.", "exportInvalidCss": "Scegli un file .css UTF-8 leggibile fino a 256 KiB, senza importazioni, URL di risorse, HTML o CSS eseguibile o con escape.", - "exportInvalidRange": "{field}: inserisci un numero tra {minimum} e {maximum}." + "exportInvalidRange": "{field}: inserisci un numero tra {minimum} e {maximum}.", + "clipboardHistory": "Cronologia della Copia", + "clipboardHistoryDescription": "Apri la cronologia della copia locale della sessione.", + "clipboardCurrent": "Copia corrente", + "clipboardCurrentExternal": "Copia corrente da un'altra applicazione", + "clipboardDestination": "Incolla in {name}", + "clipboardSessionOnly": "Memorizzata solo per questa sessione BusyMark.", + "clipboardNoItems": "La cronologia della copia è vuota.", + "clipboardNoMatches": "Nessun elemento della copia corrisponde a questa ricerca.", + "clipboardDisabled": "La collezione Cronologia della Copia è disabilitata nelle Impostazioni.", + "clipboardUnavailable": "Il contenuto della copia corrente non è disponibile o non è supportato.", + "clipboardTooLarge": "Questo elemento del segnalibro è troppo grande per essere conservato.", + "clipboardPastePlainText": "Incolla come testo semplice", + "clipboardClearAll": "Cancella la cronologia del segnalibro", + "clipboardEntryText": "Testo", + "clipboardEntryRichText": "Testo ricco", + "clipboardEntryImage": "Immagine", + "clipboardOrigin": "Copiato da {name}", + "localHistory": "Cronologia locale", + "localHistoryEllipsis": "Cronologia locale…", + "localHistoryDescription": "Mostra le revisioni locali salvate e automatiche per il documento corrente.", + "findLocalHistoryEllipsis": "Trova nella cronologia locale…", + "findLocalHistoryDescription": "Cerca documenti e contenuti delle revisioni nella cronologia locale.", + "localHistoryNoDocuments": "Nessun documento ha ancora una cronologia locale.", + "localHistoryNoRevisions": "Questo documento non ha revisioni nella cronologia locale.", + "localHistoryNoMatches": "Nessuna revisione corrisponde a questa ricerca.", + "localHistorySearchHint": "Cerca i contenuti delle revisioni", + "localHistorySelectedRevision": "Revisione selezionata", + "localHistoryCurrentEditor": "Contenuto dell'editor corrente", + "localHistoryCurrentDisk": "Contenuto corrente sul disco", + "localHistoryMissingFile": "Il file originale è mancante.", + "localHistoryRestoreRevision": "Ripristina Versione", + "localHistoryRestoreOriginalLocation": "Ripristina alla Posizione Originale", + "localHistoryRestoreNewLocation": "Ripristina alla Nuova Posizione…", + "localHistoryRestoreChange": "Ripristina Modifica", + "localHistoryRestoreUnavailable": "Questa modifica non può essere ripristinata perché il confronto non è più valido.", + "localHistoryComparisonSimplified": "Questo confronto ampio è semplificato. Il ripristino di singole modifiche non è disponibile.", + "localHistoryOlderChange": "Modifica di una versione precedente: {text}", + "localHistoryCurrentChange": "Modifica del contenuto corrente: {text}", + "localHistoryDeleted": "Eliminato", + "localHistoryUntitled": "Senza Titolo", + "localHistoryClearDocument": "Cancella la cronologia di questo documento", + "localHistoryClearAll": "Cancella tutta la cronologia locale", + "localHistoryClearDocumentTitle": "Cancella la cronologia del documento?", + "localHistoryClearAllTitle": "Cancella tutta la cronologia locale?", + "localHistoryClearConfirmation": "Questo rimuove permanentemente le revisioni memorizzate. Questa azione non può essere annullata.", + "localHistoryWarningIndexRebuilt": "L'indice della cronologia locale è stato danneggiato e ricostruito da revisioni intatte.", + "localHistoryWarningUnsupportedFormat": "Questo archivio della cronologia locale utilizza un formato non supportato {version}.", + "localHistoryWarningUnavailable": "La cronologia locale non è disponibile: {detail}", + "localHistoryWarningRecordingDisabled": "La registrazione è disabilitata o questo percorso è escluso. Riattiva la registrazione prima di ripristinare.", + "localHistoryWarningPathChange": "La cronologia locale non è riuscita a registrare la modifica del percorso: {detail}", + "localHistoryWarningDeletedPath": "La cronologia locale non è riuscita a conservare il percorso eliminato: {detail}", + "localHistoryWarningRevisionMissing": "La revisione selezionata è mancante o danneggiata.", + "localHistoryWarningRevisionRead": "La revisione selezionata non può essere letta: {detail}", + "localHistoryWarningCapture": "La cronologia locale non è riuscita a catturare questa revisione: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Versione iniziale", + "localHistoryReasonSaved": "Salvato", + "localHistoryReasonAutomaticCheckpoint": "Controllo automatico", + "localHistoryReasonBeforeReload": "Prima del ricaricamento", + "localHistoryReasonBeforeDiscard": "Prima di eliminare", + "localHistoryReasonBeforeRestore": "Prima del ripristino", + "localHistoryReasonBeforeDelete": "Prima della cancellazione", + "localHistoryReasonExternalChange": "Modifica esterna", + "settingsHistory": "Cronologia", + "settingsClipboardHistoryTitle": "Raccogli la cronologia della clipboard", + "settingsClipboardHistoryDescription": "Mantieni il contenuto copiato e incollato per questa sessione BusyMark.", + "settingsLocalHistoryTitle": "Registra la cronologia locale", + "settingsLocalHistoryDescription": "Salva le revisioni del documento su questo dispositivo per il recupero e il confronto.", + "settingsHistoryCheckpoint": "Intervallo di checkpoint automatico", + "settingsHistoryRetention": "Periodo di conservazione", + "settingsHistoryStorage": "Massimo spazio di archiviazione", + "settingsHistoryExcludedPaths": "Percorsi esclusi", + "settingsHistoryExcludedPathsHint": "Un percorso assoluto per riga", + "settingsSecondsValue": "{value} secondi", + "settingsDaysValue": "{value} giorni", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9ec4fc04..0d672e6c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "余白を除いたコンテンツの幅と高さをそれぞれ 20 mm 以上にしてください。", "exportInvalidColor": "色を #RRGGBB 形式で入力してください。", "exportInvalidCss": "読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。", - "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。" + "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。", + "clipboardHistory": "コピーボード履歴", + "clipboardHistoryDescription": "セッション固有のコピーボード履歴を開く。", + "clipboardCurrent": "現在のコピーボード", + "clipboardCurrentExternal": "別のアプリケーションからの現在のコピーボード", + "clipboardDestination": "{name} に貼り付け", + "clipboardSessionOnly": "このBusyMarkセッションのみに保存されます。", + "clipboardNoItems": "コピーボード履歴は空です。", + "clipboardNoMatches": "この検索に一致するコピーボードの項目はありません。", + "clipboardDisabled": "コピーボード履歴のコレクションは設定で無効になっています。", + "clipboardUnavailable": "現在のコピーボードの内容は利用できませんまたはサポートされていません。", + "clipboardTooLarge": "このクリップボードの項目は大きすぎて保持できません。", + "clipboardPastePlainText": "テキストとして貼り付け", + "clipboardClearAll": "クリップボードの履歴をクリア", + "clipboardEntryText": "テキスト", + "clipboardEntryRichText": "豊富なテキスト", + "clipboardEntryImage": "画像", + "clipboardOrigin": "{name} からコピー", + "localHistory": "ローカル履歴", + "localHistoryEllipsis": "ローカル履歴…", + "localHistoryDescription": "現在のドキュメントの保存されたおよび自動ローカルのバージョンを表示します。", + "findLocalHistoryEllipsis": "ローカル履歴で検索…", + "findLocalHistoryDescription": "ローカル履歴のドキュメントとリビジョンの内容を検索します。", + "localHistoryNoDocuments": "まだローカル履歴を持つドキュメントはありません。", + "localHistoryNoRevisions": "このドキュメントにはローカル履歴の修正はありません。", + "localHistoryNoMatches": "この検索に一致するローカル履歴の修正はありません。", + "localHistorySearchHint": "リビジョンの内容を検索", + "localHistorySelectedRevision": "選択されたリビジョン", + "localHistoryCurrentEditor": "現在の編集内容", + "localHistoryCurrentDisk": "ディスク上の現在の内容", + "localHistoryMissingFile": "元のファイルが見つかりません。", + "localHistoryRestoreRevision": "変更を復元", + "localHistoryRestoreOriginalLocation": "元の位置に復元", + "localHistoryRestoreNewLocation": "新しい位置に復元…", + "localHistoryRestoreChange": "変更を復元", + "localHistoryRestoreUnavailable": "この変更は復元できません。比較が最新ではありません。", + "localHistoryComparisonSimplified": "この大きな比較は簡略化されています。個別の変更の復元はできません。", + "localHistoryOlderChange": "以前の変更: {text}", + "localHistoryCurrentChange": "現在の変更: {text}", + "localHistoryDeleted": "削除", + "localHistoryUntitled": "タイトルなし", + "localHistoryClearDocument": "このドキュメントの履歴をクリアする", + "localHistoryClearAll": "すべてのローカル履歴をクリアする", + "localHistoryClearDocumentTitle": "ドキュメントの履歴をクリアしますか?", + "localHistoryClearAllTitle": "すべてのローカル履歴をクリアしますか?", + "localHistoryClearConfirmation": "これにより、保存されたバージョンが完全に削除されます。この操作は取り消せません。", + "localHistoryWarningIndexRebuilt": "ローカル履歴のインデックスが破損し、正常なバージョンから再構築されました。", + "localHistoryWarningUnsupportedFormat": "このローカル履歴ストアはサポートされていない形式 {version} を使用しています。", + "localHistoryWarningUnavailable": "ローカル履歴は利用できません: {detail}", + "localHistoryWarningRecordingDisabled": "録音が無効になっているか、このパスは除外されています。復元する前に録音を再度有効にしてください。", + "localHistoryWarningPathChange": "ローカル履歴がパスの変更を記録できませんでした: {detail}", + "localHistoryWarningDeletedPath": "ローカル履歴は削除されたパスを保持できません: {detail}", + "localHistoryWarningRevisionMissing": "選択されたバージョンが存在しないか破損しています。", + "localHistoryWarningRevisionRead": "選択されたバージョンを読み取ることができません: {detail}", + "localHistoryWarningCapture": "ローカル履歴は、このバージョンをキャプチャできません: {detail}", + "localHistoryRevisionAt": "{reason}・{timestamp}", + "localHistoryReasonBaseline": "ベースライン", + "localHistoryReasonSaved": "保存", + "localHistoryReasonAutomaticCheckpoint": "自動チェックポイント", + "localHistoryReasonBeforeReload": "リロード前", + "localHistoryReasonBeforeDiscard": "破棄前", + "localHistoryReasonBeforeRestore": "復元前", + "localHistoryReasonBeforeDelete": "削除前", + "localHistoryReasonExternalChange": "外部からの変更", + "settingsHistory": "履歴", + "settingsClipboardHistoryTitle": "コピー&ペースト履歴を収集", + "settingsClipboardHistoryDescription": "このBusyMarkセッションでのコピー&ペーストされたコンテンツのみを保持。", + "settingsLocalHistoryTitle": "ローカル履歴を記録", + "settingsLocalHistoryDescription": "このデバイスにドキュメントのバージョンを保存し、復元および比較。", + "settingsHistoryCheckpoint": "自動チェックポイント間隔", + "settingsHistoryRetention": "保管期間", + "settingsHistoryStorage": "最大ストレージ", + "settingsHistoryExcludedPaths": "除外されたパス", + "settingsHistoryExcludedPathsHint": "1行に1つの絶対パス", + "settingsSecondsValue": "{value} 秒", + "settingsDaysValue": "{value} 日", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7fabd224..cc0fb6de 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "여백을 제외한 콘텐츠 너비와 높이를 각각 20 mm 이상 확보하세요.", "exportInvalidColor": "#RRGGBB 형식으로 색상을 입력하세요.", "exportInvalidCss": "가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.", - "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요." + "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요.", + "clipboardHistory": "클립보드 기록", + "clipboardHistoryDescription": "세션별 클립보드 기록을 엽니다.", + "clipboardCurrent": "현재 클립보드", + "clipboardCurrentExternal": "다른 애플리케이션의 현재 클립보드", + "clipboardDestination": "{name}에 붙여넣기", + "clipboardSessionOnly": "이 BusyMark 세션에만 저장됩니다.", + "clipboardNoItems": "클립보드 기록이 비어 있습니다.", + "clipboardNoMatches": "이 검색과 일치하는 클립보드 항목이 없습니다.", + "clipboardDisabled": "클립보드 기록 컬렉션이 설정에서 비활성화되었습니다.", + "clipboardUnavailable": "현재 클립보드 내용은 사용할 수 없거나 지원되지 않습니다.", + "clipboardTooLarge": "이 클립보드 항목은 저장할 수 없을 정도로 큽니다.", + "clipboardPastePlainText": "일반 텍스트로 붙여넣기", + "clipboardClearAll": "클립보드 기록 지우기", + "clipboardEntryText": "텍스트", + "clipboardEntryRichText": "풍부한 텍스트", + "clipboardEntryImage": "이미지", + "clipboardOrigin": "{name}에서 복사됨", + "localHistory": "로컬 기록", + "localHistoryEllipsis": "로컬 기록…", + "localHistoryDescription": "현재 문서에 저장된 자동 로컬 변경 사항을 표시합니다.", + "findLocalHistoryEllipsis": "로컬 히스토리에서 찾기…", + "findLocalHistoryDescription": "로컬 히스토리 문서 및 수정 내용 검색.", + "localHistoryNoDocuments": "아직 로컬 히스토리가 있는 문서는 없습니다.", + "localHistoryNoRevisions": "이 문서는 로컬 히스토리 수정 사항이 없습니다.", + "localHistoryNoMatches": "이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.", + "localHistorySearchHint": "수정 내용 검색", + "localHistorySelectedRevision": "선택된 수정", + "localHistoryCurrentEditor": "현재 편집 내용", + "localHistoryCurrentDisk": "디스크에 있는 현재 내용", + "localHistoryMissingFile": "원본 파일이 없습니다.", + "localHistoryRestoreRevision": "이전 버전 복원", + "localHistoryRestoreOriginalLocation": "원래 위치로 복원", + "localHistoryRestoreNewLocation": "새로운 위치로 복원…", + "localHistoryRestoreChange": "변경 사항 복원", + "localHistoryRestoreUnavailable": "이 변경 사항은 더 이상 비교가 불가능합니다.", + "localHistoryComparisonSimplified": "이 큰 비교는 단순화되었습니다. 개별 변경 사항 복원은 불가능합니다.", + "localHistoryOlderChange": "이전 버전의 변경: {text}", + "localHistoryCurrentChange": "현재 콘텐츠의 변경: {text}", + "localHistoryDeleted": "삭제됨", + "localHistoryUntitled": "제목 없음", + "localHistoryClearDocument": "이 문서의 기록 삭제", + "localHistoryClearAll": "모든 로컬 기록 삭제", + "localHistoryClearDocumentTitle": "문서 기록 삭제?", + "localHistoryClearAllTitle": "모든 로컬 기록 삭제?", + "localHistoryClearConfirmation": "이로 인해 저장된 버전이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "localHistoryWarningIndexRebuilt": "로컬 기록 인덱스가 손상되어 정상적인 버전에서 재구성되었습니다.", + "localHistoryWarningUnsupportedFormat": "이 로컬 기록 저장소는 지원되지 않는 형식 {version}을 사용합니다.", + "localHistoryWarningUnavailable": "로컬 기록은 사용할 수 없습니다: {detail}", + "localHistoryWarningRecordingDisabled": "녹음이 비활성화되었거나 이 경로가 제외되었습니다. 복원하기 전에 녹음을 다시 활성화하십시오.", + "localHistoryWarningPathChange": "로컬 기록이 경로 변경을 기록할 수 없습니다: {detail}", + "localHistoryWarningDeletedPath": "로컬 히스토리에서 삭제된 경로를 저장할 수 없습니다: {detail}", + "localHistoryWarningRevisionMissing": "선택된 버전이 누락되었거나 손상되었습니다.", + "localHistoryWarningRevisionRead": "선택된 버전을 읽을 수 없습니다: {detail}", + "localHistoryWarningCapture": "로컬 히스토리가 이 버전을 캡처할 수 없습니다: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "기본 버전", + "localHistoryReasonSaved": "저장됨", + "localHistoryReasonAutomaticCheckpoint": "자동 체크포인트", + "localHistoryReasonBeforeReload": "재로드 전", + "localHistoryReasonBeforeDiscard": "버리기 전", + "localHistoryReasonBeforeRestore": "복원 전", + "localHistoryReasonBeforeDelete": "삭제 전", + "localHistoryReasonExternalChange": "외부 변경", + "settingsHistory": "버전 기록", + "settingsClipboardHistoryTitle": "클립보드 기록 수집", + "settingsClipboardHistoryDescription": "이 BusyMark 세션에만 복사 및 성공적으로 붙여넣은 콘텐츠를 유지합니다.", + "settingsLocalHistoryTitle": "로컬 버전 기록", + "settingsLocalHistoryDescription": "복구 및 비교를 위해 이 장치에 문서 버전을 저장합니다.", + "settingsHistoryCheckpoint": "자동 체크포인트 간격", + "settingsHistoryRetention": "보관 기간", + "settingsHistoryStorage": "최대 저장 공간", + "settingsHistoryExcludedPaths": "제외된 경로", + "settingsHistoryExcludedPathsHint": "한 줄에 하나의 절대 경로", + "settingsSecondsValue": "{value} 초", + "settingsDaysValue": "{value} 일", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index b7153c3d..0c2eb071 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "La minst 20 mm innholdsbredde og -høyde stå igjen etter margene.", "exportInvalidColor": "Skriv inn en farge som #RRGGBB.", "exportInvalidCss": "Velg en lesbar UTF-8 .css-fil opptil 256 KiB uten importer, ressurs-URL-er, HTML eller kjørbar eller escaped CSS.", - "exportInvalidRange": "{field}: skriv inn et tall mellom {minimum} og {maximum}." + "exportInvalidRange": "{field}: skriv inn et tall mellom {minimum} og {maximum}.", + "clipboardHistory": "Utklippshistorie", + "clipboardHistoryDescription": "Åpne historien for utklippet som er lokal for sesjonen.", + "clipboardCurrent": "Nåværende utklipp", + "clipboardCurrentExternal": "Nåværende utklipp fra en annen applikasjon", + "clipboardDestination": "Lim inn i {name}", + "clipboardSessionOnly": "Lagret kun for denne BusyMark-sesjonen.", + "clipboardNoItems": "Utklippshistorien er tom.", + "clipboardNoMatches": "Ingen utklippsposter samsvarer med denne søkingen.", + "clipboardDisabled": "Samlingen \"Utklippshistorie\" er deaktivert i Innstillinger.", + "clipboardUnavailable": "Innholdet i det nåværende utklippet er ikke tilgjengelig eller støttet.", + "clipboardTooLarge": "Denne utklippsteksten er for stor til å lagre.", + "clipboardPastePlainText": "Lim inn som ren tekst", + "clipboardClearAll": "Tøm utklippshistorie", + "clipboardEntryText": "Tekst", + "clipboardEntryRichText": "Rik tekst", + "clipboardEntryImage": "Bilde", + "clipboardOrigin": "Kopiert fra {name}", + "localHistory": "Lokal historie", + "localHistoryEllipsis": "Lokal historie…", + "localHistoryDescription": "Vis lagrede og automatiserte lokale versjoner for det nåværende dokumentet.", + "findLocalHistoryEllipsis": "Finn i Lokal Historikk…", + "findLocalHistoryDescription": "Søk etter dokumenter og innhold i Lokal Historikk.", + "localHistoryNoDocuments": "Ingen dokumenter har Lokal Historikk ennå.", + "localHistoryNoRevisions": "Dette dokumentet har ingen revisjoner i Lokal Historikk.", + "localHistoryNoMatches": "Ingen revisjoner matcher denne søken.", + "localHistorySearchHint": "Søk etter innhold i revisjon", + "localHistorySelectedRevision": "Valgt revisjon", + "localHistoryCurrentEditor": "Nåværende innhold i redigereren", + "localHistoryCurrentDisk": "Nåværende innhold på disken", + "localHistoryMissingFile": "Den opprinnelige filen mangler.", + "localHistoryRestoreRevision": "Gjenopprett Versjon", + "localHistoryRestoreOriginalLocation": "Gjenopprett til Opprinnelig Sted", + "localHistoryRestoreNewLocation": "Gjenopprett til Nytt Sted…", + "localHistoryRestoreChange": "Gjenopprett Endring", + "localHistoryRestoreUnavailable": "Denne endringen kan ikke gjenopprettes fordi sammenligningen er ikke lenger aktuell.", + "localHistoryComparisonSimplified": "Denne store sammenligningen er forenklet. Gjenopprett individuelle endringer er ikke tilgjengelig.", + "localHistoryOlderChange": "Eldre versjonsendring: {text}", + "localHistoryCurrentChange": "Nåværende innholdsendring: {text}", + "localHistoryDeleted": "Slettet", + "localHistoryUntitled": "Uten tittel", + "localHistoryClearDocument": "Tøm historikken for dette dokumentet", + "localHistoryClearAll": "Tøm all lokal historikk", + "localHistoryClearDocumentTitle": "Tøm dokumentets historikk?", + "localHistoryClearAllTitle": "Tøm all lokal historikk?", + "localHistoryClearConfirmation": "Dette sletter permanent lagrede versjoner. Denne handlingen kan ikke angres.", + "localHistoryWarningIndexRebuilt": "Lokal historikk indeksen ble skadet og gjenopprettet fra intakte versjoner.", + "localHistoryWarningUnsupportedFormat": "Denne lokale historikk-lagringen bruker et støttet format {version}.", + "localHistoryWarningUnavailable": "Lokal historikk er ikke tilgjengelig: {detail}", + "localHistoryWarningRecordingDisabled": "Opptak er deaktivert eller denne stien er utelatt. Aktiver opptak før gjenoppretting.", + "localHistoryWarningPathChange": "Lokal historikk kunne ikke registrere endringen av stien: {detail}", + "localHistoryWarningDeletedPath": "Lokal Historikk kunne ikke lagre den slettede stien: {detail}", + "localHistoryWarningRevisionMissing": "Den valgte versjonen mangler eller er skadet.", + "localHistoryWarningRevisionRead": "Den valgte versjonen kan ikke leses: {detail}", + "localHistoryWarningCapture": "Lokal Historikk kunne ikke fange opp denne versjonen: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Baselinje", + "localHistoryReasonSaved": "Lagret", + "localHistoryReasonAutomaticCheckpoint": "Automatisk sjekkpunkt", + "localHistoryReasonBeforeReload": "Før omlasting", + "localHistoryReasonBeforeDiscard": "Før avvisning", + "localHistoryReasonBeforeRestore": "Før gjenoppbygging", + "localHistoryReasonBeforeDelete": "Før sletting", + "localHistoryReasonExternalChange": "Ekstern endring", + "settingsHistory": "Historikk", + "settingsClipboardHistoryTitle": "Samle Historikk fra utklippstavlen", + "settingsClipboardHistoryDescription": "Behold kopiert og vellykket limt innhold kun for denne BusyMark-sesjonen.", + "settingsLocalHistoryTitle": "Lag lokal historikk", + "settingsLocalHistoryDescription": "Lagre dokumentversjoner på denne enheten for gjenoppretting og sammenligning.", + "settingsHistoryCheckpoint": "Automatisk sjekkpunktintervall", + "settingsHistoryRetention": "Bevaringsperiode", + "settingsHistoryStorage": "Maks lagringsplass", + "settingsHistoryExcludedPaths": "Udelte stier", + "settingsHistoryExcludedPathsHint": "Én absolutt sti per linje", + "settingsSecondsValue": "{value} sekunder", + "settingsDaysValue": "{value} dager", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 4b04b23c..951680ef 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "Laat na de marges minimaal 20 mm inhoudsbreedte en -hoogte over.", "exportInvalidColor": "Voer een kleur in als #RRGGBB.", "exportInvalidCss": "Kies een leesbaar UTF-8 .css-bestand tot 256 KiB zonder imports, bron-URL’s, HTML of uitvoerbare of ontsnapte CSS.", - "exportInvalidRange": "{field}: voer een getal tussen {minimum} en {maximum} in." + "exportInvalidRange": "{field}: voer een getal tussen {minimum} en {maximum} in.", + "clipboardHistory": "Historie van de klembord", + "clipboardHistoryDescription": "Open de klembordgeschiedenis per sessie.", + "clipboardCurrent": "Huidig klembord", + "clipboardCurrentExternal": "Huidig klembord van een ander programma", + "clipboardDestination": "Plakken in {name}", + "clipboardSessionOnly": "Alleen opgeslagen voor deze BusyMark sessie.", + "clipboardNoItems": "De klembordgeschiedenis is leeg.", + "clipboardNoMatches": "Geen klemborditems komen overeen met deze zoekopdracht.", + "clipboardDisabled": "De collectie \"Historie van het klembord\" is uitgeschakeld in de instellingen.", + "clipboardUnavailable": "De inhoud van het huidige klembord is niet beschikbaar of niet ondersteund.", + "clipboardTooLarge": "Dit item op het klembord is te groot om te bewaren.", + "clipboardPastePlainText": "Plak als gewone tekst", + "clipboardClearAll": "Maak de geschiedenis van het klembord leeg", + "clipboardEntryText": "Tekst", + "clipboardEntryRichText": "Rijke tekst", + "clipboardEntryImage": "Afbeelding", + "clipboardOrigin": "Geplakt van {name}", + "localHistory": "Lokale geschiedenis", + "localHistoryEllipsis": "Lokale geschiedenis…", + "localHistoryDescription": "Toon opgeslagen en automatische lokale revisies voor het huidige document.", + "findLocalHistoryEllipsis": "Zoeken in Lokale Geschiedenis…", + "findLocalHistoryDescription": "Zoek naar documenten en inhoud van revisies in de lokale geschiedenis.", + "localHistoryNoDocuments": "Er zijn nog geen documenten met Lokale Geschiedenis.", + "localHistoryNoRevisions": "Dit document heeft geen revisies van de Lokale Geschiedenis.", + "localHistoryNoMatches": "Geen revisies van de Lokale Geschiedenis komen overeen met deze zoekopdracht.", + "localHistorySearchHint": "Zoek naar inhoud van revisies", + "localHistorySelectedRevision": "Geselecteerde revisie", + "localHistoryCurrentEditor": "Huidige inhoud van de editor", + "localHistoryCurrentDisk": "Huidige inhoud op schijf", + "localHistoryMissingFile": "Het originele bestand ontbreekt.", + "localHistoryRestoreRevision": "Herstel Versie", + "localHistoryRestoreOriginalLocation": "Herstel naar Originele Locatie", + "localHistoryRestoreNewLocation": "Herstel naar Nieuwe Locatie…", + "localHistoryRestoreChange": "Herstel Wijziging", + "localHistoryRestoreUnavailable": "Deze wijziging kan niet worden hersteld omdat de vergelijking niet meer actueel is.", + "localHistoryComparisonSimplified": "Deze grote vergelijking is vereenvoudigd. Het herstellen van individuele wijzigingen is niet mogelijk.", + "localHistoryOlderChange": "Oudere versie wijziging: {text}", + "localHistoryCurrentChange": "Huidige inhoud wijziging: {text}", + "localHistoryDeleted": "Verwijderd", + "localHistoryUntitled": "Onbenoemd", + "localHistoryClearDocument": "Verwijder de geschiedenis van dit document", + "localHistoryClearAll": "Verwijder de lokale geschiedenis", + "localHistoryClearDocumentTitle": "Verwijder de documentgeschiedenis?", + "localHistoryClearAllTitle": "Verwijder de lokale geschiedenis?", + "localHistoryClearConfirmation": "Dit verwijdert permanent opgeslagen revisies. Deze actie kan niet worden teruggedraaid.", + "localHistoryWarningIndexRebuilt": "De index van de lokale geschiedenis is beschadigd en opnieuw gebouwd vanuit intacte revisies.", + "localHistoryWarningUnsupportedFormat": "Deze lokale geschiedenisopslag gebruikt een onondersteunde {version}-formaat.", + "localHistoryWarningUnavailable": "De lokale geschiedenis is niet beschikbaar: {detail}", + "localHistoryWarningRecordingDisabled": "Opnemen is uitgeschakeld of deze pad is uitgesloten. Schakel opnemen opnieuw in voordat u herstelt.", + "localHistoryWarningPathChange": "De lokale geschiedenis kon de padwijziging niet opslaan: {detail}", + "localHistoryWarningDeletedPath": "Lokale Geschiedenis kon de verwijderde pad niet opslaan: {detail}", + "localHistoryWarningRevisionMissing": "De geselecteerde versie ontbreekt of is beschadigd.", + "localHistoryWarningRevisionRead": "De geselecteerde versie kan niet worden gelezen: {detail}", + "localHistoryWarningCapture": "Lokale Geschiedenis kon deze versie niet vastleggen: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Basislijn", + "localHistoryReasonSaved": "Bewaard", + "localHistoryReasonAutomaticCheckpoint": "Automatische controlepunt", + "localHistoryReasonBeforeReload": "Voor herladen", + "localHistoryReasonBeforeDiscard": "Voor verwijderen", + "localHistoryReasonBeforeRestore": "Voordat herstellen", + "localHistoryReasonBeforeDelete": "Voordat verwijderen", + "localHistoryReasonExternalChange": "Externe wijziging", + "settingsHistory": "Geschiedenis", + "settingsClipboardHistoryTitle": "Verzamelen van de geschiedenis van de klembord", + "settingsClipboardHistoryDescription": "Behoud van gekopieerd en succesvol geplakt materiaal alleen voor deze BusyMark sessie.", + "settingsLocalHistoryTitle": "Opnemen van lokale geschiedenis", + "settingsLocalHistoryDescription": "Opslaan van documentversies op dit apparaat voor herstel en vergelijking.", + "settingsHistoryCheckpoint": "Automatische interval voor controlepunten", + "settingsHistoryRetention": "Bewaarperiode", + "settingsHistoryStorage": "Maximale opslag", + "settingsHistoryExcludedPaths": "Uitsluitingen", + "settingsHistoryExcludedPathsHint": "Eén absolute pad per regel", + "settingsSecondsValue": "{value} seconden", + "settingsDaysValue": "{value} dagen", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d6d3ae63..62b996f8 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3170,5 +3170,81 @@ "exportInvalidGeometry": "Po odjęciu marginesów pozostaw co najmniej 20 mm szerokości i wysokości treści.", "exportInvalidColor": "Wpisz kolor w formacie #RRGGBB.", "exportInvalidCss": "Wybierz czytelny plik .css UTF-8 do 256 KiB, bez importów, adresów zasobów, HTML oraz wykonywalnego CSS i sekwencji ucieczki.", - "exportInvalidRange": "{field}: wpisz liczbę między {minimum} a {maximum}." + "exportInvalidRange": "{field}: wpisz liczbę między {minimum} a {maximum}.", + "clipboardHistory": "Historia w Koszyku", + "clipboardHistoryDescription": "Otwórz historię w koszyku lokalną dla sesji.", + "clipboardCurrent": "Aktualny koszyk", + "clipboardCurrentExternal": "Aktualny koszyk z innego programu", + "clipboardDestination": "Wklej do {name}", + "clipboardSessionOnly": "Przechowywane tylko dla tej sesji BusyMark.", + "clipboardNoItems": "Historia w koszyku jest pusta.", + "clipboardNoMatches": "Żadne elementy w koszyku nie pasują do tego wyszukiwania.", + "clipboardDisabled": "Zbiór \"Historia w koszyku\" wyłączony w Ustawieniach.", + "clipboardUnavailable": "Aktualny zawartość koszyka niedostępna lub nieobsługiwana.", + "clipboardTooLarge": "Ten element schowka jest zbyt duży, aby go zapisać.", + "clipboardPastePlainText": "Wklej jako zwykły tekst", + "clipboardClearAll": "Wyczyść historię schowka", + "clipboardEntryText": "Tekst", + "clipboardEntryRichText": "Bogaty tekst", + "clipboardEntryImage": "Obraz", + "clipboardOrigin": "Skopiowano z {name}", + "localHistory": "Lokalna historia", + "localHistoryEllipsis": "Lokalna historia…", + "localHistoryDescription": "Pokaż zapisane i automatyczne lokalne wersje dla bieżącego dokumentu.", + "findLocalHistoryEllipsis": "Znajdź w Lokalnej Historii…", + "findLocalHistoryDescription": "Wyszukaj dokumenty i zawartość wersji w Lokalnej Historii.", + "localHistoryNoDocuments": "Żaden dokument nie ma Lokalnej Historii.", + "localHistoryNoRevisions": "Ten dokument nie ma wersji w Lokalnej Historii.", + "localHistoryNoMatches": "Żadne wersje nie pasują do tego wyszukiwania.", + "localHistorySearchHint": "Wyszukaj zawartość wersji", + "localHistorySelectedRevision": "Wybrana wersja", + "localHistoryCurrentEditor": "Aktualny zawartość edytora", + "localHistoryCurrentDisk": "Aktualna zawartość na dysku", + "localHistoryMissingFile": "Oryginalny plik nie istnieje.", + "localHistoryRestoreRevision": "Przywróć Wersję", + "localHistoryRestoreOriginalLocation": "Przywróć do Oryginalnej Lokalizacji", + "localHistoryRestoreNewLocation": "Przywróć do Nowej Lokalizacji…", + "localHistoryRestoreChange": "Przywróć Zmianę", + "localHistoryRestoreUnavailable": "Ta zmiana nie może być przywrócona, ponieważ porównanie nie jest już aktualne.", + "localHistoryComparisonSimplified": "To duże porównanie zostało uproszczone. Przywracanie pojedynczych zmian jest niedostępne.", + "localHistoryOlderChange": "Stara wersja zmiany: {text}", + "localHistoryCurrentChange": "Aktualna zmiana zawartości: {text}", + "localHistoryDeleted": "Usunięto", + "localHistoryUntitled": "Bez tytułu", + "localHistoryClearDocument": "Wyczyść historię tego dokumentu", + "localHistoryClearAll": "Wyczyść całą lokalną historię", + "localHistoryClearDocumentTitle": "Czy chcesz wyczyścić historię dokumentu?", + "localHistoryClearAllTitle": "Czy chcesz wyczyścić całą lokalną historię?", + "localHistoryClearConfirmation": "To trwale usuwa zapisane wersje. Ta akcja nie może być cofnięta.", + "localHistoryWarningIndexRebuilt": "Indeks Lokalnej Historii został uszkodzony i odtworzony z nieuszkodzonych wersji.", + "localHistoryWarningUnsupportedFormat": "Ta Lokalna Historia używa nieobsługiwanego formatu {version}.", + "localHistoryWarningUnavailable": "Lokalna Historia jest niedostępna: {detail}", + "localHistoryWarningRecordingDisabled": "Nagrywanie jest wyłączone lub ta ścieżka jest wykluczona. Włącz nagrywanie przed przywracaniem.", + "localHistoryWarningPathChange": "Lokalna Historia nie mogła zarejestrować zmiany ścieżki: {detail}", + "localHistoryWarningDeletedPath": "Lokalna historia nie mogła zachować usuniętego ścieżki: {detail}", + "localHistoryWarningRevisionMissing": "Wybrana wersja jest nieobecna lub uszkodzona.", + "localHistoryWarningRevisionRead": "Wybrana wersja nie może być odczytana: {detail}", + "localHistoryWarningCapture": "Lokalna historia nie mogła przechwycić tej wersji: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Podstawa", + "localHistoryReasonSaved": "Zapisano", + "localHistoryReasonAutomaticCheckpoint": "Automatyczny checkpoint", + "localHistoryReasonBeforeReload": "Przed ponownym załadowaniem", + "localHistoryReasonBeforeDiscard": "Przed wyrzuceniem", + "localHistoryReasonBeforeRestore": "Przed przywróceniem", + "localHistoryReasonBeforeDelete": "Przed usunięciem", + "localHistoryReasonExternalChange": "Zmiana z zewnątrz", + "settingsHistory": "Historia", + "settingsClipboardHistoryTitle": "Zapisywanie historii w schowku", + "settingsClipboardHistoryDescription": "Zachowaj skopiowany i pomyślnie wklejony zawartość tylko dla tej sesji BusyMark.", + "settingsLocalHistoryTitle": "Zapisywanie lokalnej historii", + "settingsLocalHistoryDescription": "Przechowywanie wersji dokumentu na tym urządzeniu w celu odzyskania i porównywania.", + "settingsHistoryCheckpoint": "Automatyczny interwał kontrolnego zapisu", + "settingsHistoryRetention": "Okres przechowywania", + "settingsHistoryStorage": "Maksymalna pojemność", + "settingsHistoryExcludedPaths": "Wykluczone ścieżki", + "settingsHistoryExcludedPathsHint": "Jedna absolutna ścieżka na linię", + "settingsSecondsValue": "{value} sekundy", + "settingsDaysValue": "{value} dni", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index ce9a07e6..e6df7f5e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Deixe pelo menos 20 mm de largura e altura de conteúdo após as margens.", "exportInvalidColor": "Introduza uma cor como #RRGGBB.", "exportInvalidCss": "Escolha um ficheiro .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", - "exportInvalidRange": "{field}: introduza um número entre {minimum} e {maximum}." + "exportInvalidRange": "{field}: introduza um número entre {minimum} e {maximum}.", + "clipboardHistory": "Histórico da Área de Transferência", + "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", + "clipboardCurrent": "Área de transferência atual", + "clipboardCurrentExternal": "Área de transferência de outro aplicativo", + "clipboardDestination": "Colar em {name}", + "clipboardSessionOnly": "Armazenado apenas para esta sessão BusyMark.", + "clipboardNoItems": "O histórico da área de transferência está vazio.", + "clipboardNoMatches": "Nenhum item da área de transferência corresponde a esta pesquisa.", + "clipboardDisabled": "A coleção de Histórico da Área de Transferência está desativada nas Configurações.", + "clipboardUnavailable": "O conteúdo da área de transferência atual não está disponível ou não é suportado.", + "clipboardTooLarge": "Este item na área de transferência é demasiado grande para manter.", + "clipboardPastePlainText": "Colar como Texto Simples", + "clipboardClearAll": "Limpar o Histórico da Área de Transferência", + "clipboardEntryText": "Texto", + "clipboardEntryRichText": "Texto formatado", + "clipboardEntryImage": "Imagem", + "clipboardOrigin": "Copiado de {name}", + "localHistory": "Histórico Local", + "localHistoryEllipsis": "Histórico Local…", + "localHistoryDescription": "Mostrar as revisões locais salvas e automáticas para o documento atual.", + "findLocalHistoryEllipsis": "Encontrar no Histórico Local…", + "findLocalHistoryDescription": "Procurar documentos e conteúdos de revisão no Histórico Local.", + "localHistoryNoDocuments": "Nenhum documento tem Histórico Local.", + "localHistoryNoRevisions": "Este documento não tem revisões no Histórico Local.", + "localHistoryNoMatches": "Nenhuma revisão corresponde à pesquisa.", + "localHistorySearchHint": "Procurar conteúdos de revisão", + "localHistorySelectedRevision": "Revisão selecionada", + "localHistoryCurrentEditor": "Conteúdo atual do editor", + "localHistoryCurrentDisk": "Conteúdo atual no disco", + "localHistoryMissingFile": "O ficheiro original está ausente.", + "localHistoryRestoreRevision": "Restaurar Versão", + "localHistoryRestoreOriginalLocation": "Restaurar para Localização Original", + "localHistoryRestoreNewLocation": "Restaurar para Nova Localização…", + "localHistoryRestoreChange": "Restaurar Alteração", + "localHistoryRestoreUnavailable": "Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.", + "localHistoryComparisonSimplified": "Esta alteração grande foi simplificada. Restaurar alterações individuais não está disponível.", + "localHistoryOlderChange": "Alteração de versão anterior: {text}", + "localHistoryCurrentChange": "Alteração de conteúdo atual: {text}", + "localHistoryDeleted": "Excluído", + "localHistoryUntitled": "Sem título", + "localHistoryClearDocument": "Limpar o Histórico deste Documento", + "localHistoryClearAll": "Limpar Todo o Histórico Local", + "localHistoryClearDocumentTitle": "Limpar o Histórico do Documento?", + "localHistoryClearAllTitle": "Limpar Todo o Histórico Local?", + "localHistoryClearConfirmation": "Isto elimina permanentemente as versões armazenadas. Esta ação não pode ser desfeita.", + "localHistoryWarningIndexRebuilt": "O índice do Histórico Local foi danificado e reconstruído a partir de versões intactas.", + "localHistoryWarningUnsupportedFormat": "Esta loja de Histórico Local utiliza um formato não suportado {version}.", + "localHistoryWarningUnavailable": "O Histórico Local não está disponível: {detail}", + "localHistoryWarningRecordingDisabled": "A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.", + "localHistoryWarningPathChange": "O Histórico Local não conseguiu gravar a alteração do caminho: {detail}", + "localHistoryWarningDeletedPath": "O Histórico Local não conseguiu reter o caminho eliminado: {detail}", + "localHistoryWarningRevisionMissing": "A revisão selecionada está ausente ou danificada.", + "localHistoryWarningRevisionRead": "A revisão selecionada não pode ser lida: {detail}", + "localHistoryWarningCapture": "O Histórico Local não conseguiu capturar esta revisão: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Linha Base", + "localHistoryReasonSaved": "Guardado", + "localHistoryReasonAutomaticCheckpoint": "Checkpoint automático", + "localHistoryReasonBeforeReload": "Antes de recarregar", + "localHistoryReasonBeforeDiscard": "Antes de descartar", + "localHistoryReasonBeforeRestore": "Antes de restaurar", + "localHistoryReasonBeforeDelete": "Antes de apagar", + "localHistoryReasonExternalChange": "Alteração externa", + "settingsHistory": "Histórico", + "settingsClipboardHistoryTitle": "Recolher Histórico da Área de Transferência", + "settingsClipboardHistoryDescription": "Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.", + "settingsLocalHistoryTitle": "Gravar Histórico Local", + "settingsLocalHistoryDescription": "Armazenar as versões do documento neste dispositivo para recuperação e comparação.", + "settingsHistoryCheckpoint": "Intervalo de ponto de controlo automático", + "settingsHistoryRetention": "Período de retenção", + "settingsHistoryStorage": "Armazenamento máximo", + "settingsHistoryExcludedPaths": "Caminhos excluídos", + "settingsHistoryExcludedPathsHint": "Um caminho absoluto por linha", + "settingsSecondsValue": "{value} segundos", + "settingsDaysValue": "{value} dias", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index c36c96da..09d1cf33 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -3144,5 +3144,81 @@ "exportInvalidGeometry": "Deixe pelo menos 20 mm de largura e altura de conteúdo após as margens.", "exportInvalidColor": "Insira uma cor como #RRGGBB.", "exportInvalidCss": "Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", - "exportInvalidRange": "{field}: insira um número entre {minimum} e {maximum}." + "exportInvalidRange": "{field}: insira um número entre {minimum} e {maximum}.", + "clipboardHistory": "Histórico da Área de Transferência", + "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", + "clipboardCurrent": "Área de transferência atual", + "clipboardCurrentExternal": "Área de transferência de outro aplicativo", + "clipboardDestination": "Colar em {name}", + "clipboardSessionOnly": "Armazenado apenas para esta sessão BusyMark.", + "clipboardNoItems": "O histórico da área de transferência está vazio.", + "clipboardNoMatches": "Nenhum item da área de transferência corresponde a esta pesquisa.", + "clipboardDisabled": "A coleção de Histórico da Área de Transferência está desativada nas Configurações.", + "clipboardUnavailable": "O conteúdo da área de transferência atual não está disponível ou não é suportado.", + "clipboardTooLarge": "Este item da área de transferência é muito grande para ser armazenado.", + "clipboardPastePlainText": "Colar como Texto Simples", + "clipboardClearAll": "Limpar o Histórico da Área de Transferência", + "clipboardEntryText": "Texto", + "clipboardEntryRichText": "Texto formatado", + "clipboardEntryImage": "Imagem", + "clipboardOrigin": "Copiado de {name}", + "localHistory": "Histórico Local", + "localHistoryEllipsis": "Histórico Local…", + "localHistoryDescription": "Mostrar revisões locais salvas e automáticas para o documento atual.", + "findLocalHistoryEllipsis": "Encontrar no Histórico Local…", + "findLocalHistoryDescription": "Pesquisar documentos e conteúdo de revisões no Histórico Local.", + "localHistoryNoDocuments": "Nenhum documento possui Histórico Local.", + "localHistoryNoRevisions": "Este documento não possui revisões no Histórico Local.", + "localHistoryNoMatches": "Nenhuma revisão no Histórico Local corresponde a esta pesquisa.", + "localHistorySearchHint": "Pesquisar conteúdo de revisões", + "localHistorySelectedRevision": "Revisão selecionada", + "localHistoryCurrentEditor": "Conteúdo atual do editor", + "localHistoryCurrentDisk": "Conteúdo atual no disco", + "localHistoryMissingFile": "O arquivo original está faltando.", + "localHistoryRestoreRevision": "Restaurar Versão", + "localHistoryRestoreOriginalLocation": "Restaurar para a Localização Original", + "localHistoryRestoreNewLocation": "Restaurar para a Nova Localização…", + "localHistoryRestoreChange": "Restaurar Alteração", + "localHistoryRestoreUnavailable": "Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.", + "localHistoryComparisonSimplified": "Esta comparação grande foi simplificada. Restaurar alterações individuais não está disponível.", + "localHistoryOlderChange": "Alteração da versão anterior: {text}", + "localHistoryCurrentChange": "Alteração do conteúdo atual: {text}", + "localHistoryDeleted": "Excluído", + "localHistoryUntitled": "Sem Título", + "localHistoryClearDocument": "Limpar o Histórico deste Documento", + "localHistoryClearAll": "Limpar todo o Histórico Local", + "localHistoryClearDocumentTitle": "Limpar o histórico do documento?", + "localHistoryClearAllTitle": "Limpar todo o Histórico Local?", + "localHistoryClearConfirmation": "Isso remove permanentemente as revisões armazenadas. Esta ação não pode ser desfeita.", + "localHistoryWarningIndexRebuilt": "O índice do Histórico Local foi danificado e reconstruído a partir de revisões íntegras.", + "localHistoryWarningUnsupportedFormat": "Esta loja de Histórico Local usa o formato não suportado {version}.", + "localHistoryWarningUnavailable": "Histórico Local não está disponível: {detail}", + "localHistoryWarningRecordingDisabled": "A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.", + "localHistoryWarningPathChange": "O Histórico Local não conseguiu gravar a alteração do caminho: {detail}", + "localHistoryWarningDeletedPath": "O Histórico Local não conseguiu reter o caminho excluído: {detail}", + "localHistoryWarningRevisionMissing": "A revisão selecionada está ausente ou corrompida.", + "localHistoryWarningRevisionRead": "A revisão selecionada não pode ser lida: {detail}", + "localHistoryWarningCapture": "O Histórico Local não conseguiu capturar esta revisão: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Linha de Base", + "localHistoryReasonSaved": "Salvo", + "localHistoryReasonAutomaticCheckpoint": "Checkpoint automático", + "localHistoryReasonBeforeReload": "Antes de recarregar", + "localHistoryReasonBeforeDiscard": "Antes de descartar", + "localHistoryReasonBeforeRestore": "Antes da restauração", + "localHistoryReasonBeforeDelete": "Antes da exclusão", + "localHistoryReasonExternalChange": "Mudança externa", + "settingsHistory": "Histórico", + "settingsClipboardHistoryTitle": "Coletar Histórico da Área de Transferência", + "settingsClipboardHistoryDescription": "Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.", + "settingsLocalHistoryTitle": "Gravar Histórico Local", + "settingsLocalHistoryDescription": "Armazenar revisões do documento neste dispositivo para recuperação e comparação.", + "settingsHistoryCheckpoint": "Intervalo de checkpoint automático", + "settingsHistoryRetention": "Tempo de retenção", + "settingsHistoryStorage": "Armazenamento máximo", + "settingsHistoryExcludedPaths": "Caminhos excluídos", + "settingsHistoryExcludedPathsHint": "Um caminho absoluto por linha", + "settingsSecondsValue": "{value} segundos", + "settingsDaysValue": "{value} dias", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c175e7db..537f3708 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3170,5 +3170,81 @@ "exportInvalidGeometry": "После вычета полей оставьте не менее 20 мм ширины и высоты содержимого.", "exportInvalidColor": "Введите цвет в формате #RRGGBB.", "exportInvalidCss": "Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.", - "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}." + "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}.", + "clipboardHistory": "История буфера обмена", + "clipboardHistoryDescription": "Открыть историю буфера обмена, специфичную для сеанса.", + "clipboardCurrent": "Текущий буфер обмена", + "clipboardCurrentExternal": "Текущий буфер обмена из другого приложения", + "clipboardDestination": "Вставить в {name}", + "clipboardSessionOnly": "Сохраняется только для текущего сеанса BusyMark.", + "clipboardNoItems": "История буфера обмена пуста.", + "clipboardNoMatches": "Нет элементов буфера обмена, соответствующих этому поиску.", + "clipboardDisabled": "Коллекция \"История буфера обмена\" отключена в настройках.", + "clipboardUnavailable": "Текущее содержимое буфера обмена недоступно или не поддерживается.", + "clipboardTooLarge": "Этот элемент буфера обмена слишком велик для хранения.", + "clipboardPastePlainText": "Вставить как обычный текст", + "clipboardClearAll": "Очистить историю буфера обмена", + "clipboardEntryText": "Текст", + "clipboardEntryRichText": "Форматированный текст", + "clipboardEntryImage": "Изображение", + "clipboardOrigin": "Скопировано из {name}", + "localHistory": "Локальная история", + "localHistoryEllipsis": "Локальная история…", + "localHistoryDescription": "Отобразить сохраненные и автоматические локальные версии для текущего документа.", + "findLocalHistoryEllipsis": "Найти в локальной истории…", + "findLocalHistoryDescription": "Поиск документов и содержимого версий в локальной истории.", + "localHistoryNoDocuments": "Ни один документ не имеет локальной истории.", + "localHistoryNoRevisions": "Этот документ не имеет версий в локальной истории.", + "localHistoryNoMatches": "Ни одна версия не соответствует этому поиску.", + "localHistorySearchHint": "Поиск содержимого версии", + "localHistorySelectedRevision": "Выбранная версия", + "localHistoryCurrentEditor": "Текущее содержимое редактора", + "localHistoryCurrentDisk": "Текущее содержимое на диске", + "localHistoryMissingFile": "Оригинальный файл отсутствует.", + "localHistoryRestoreRevision": "Восстановить версию", + "localHistoryRestoreOriginalLocation": "Восстановить в исходное местоположение", + "localHistoryRestoreNewLocation": "Восстановить в новое местоположение…", + "localHistoryRestoreChange": "Восстановить изменения", + "localHistoryRestoreUnavailable": "Эти изменения нельзя восстановить, так как сравнение больше не актуально.", + "localHistoryComparisonSimplified": "Это большое сравнение упрощено. Восстановление отдельных изменений недоступно.", + "localHistoryOlderChange": "Изменения в более старой версии: {text}", + "localHistoryCurrentChange": "Изменения в текущей версии: {text}", + "localHistoryDeleted": "Удалено", + "localHistoryUntitled": "Безымянный", + "localHistoryClearDocument": "Очистить историю этого документа", + "localHistoryClearAll": "Очистить всю локальную историю", + "localHistoryClearDocumentTitle": "Очистить историю документа?", + "localHistoryClearAllTitle": "Очистить всю локальную историю?", + "localHistoryClearConfirmation": "Это навсегда удаляет сохраненные версии. Это действие нельзя отменить.", + "localHistoryWarningIndexRebuilt": "Индекс локальной истории был поврежден и восстановлен из неповрежденных версий.", + "localHistoryWarningUnsupportedFormat": "Эта локальная история использует неподдерживаемый формат {version}.", + "localHistoryWarningUnavailable": "Локальная история недоступна: {detail}", + "localHistoryWarningRecordingDisabled": "Запись отключена или этот путь исключен. Включите запись перед восстановлением.", + "localHistoryWarningPathChange": "Локальная история не смогла записать изменение пути: {detail}", + "localHistoryWarningDeletedPath": "Локальная история не смогла сохранить удалённый путь: {detail}", + "localHistoryWarningRevisionMissing": "Выбранная версия отсутствует или повреждена.", + "localHistoryWarningRevisionRead": "Выбранную версию невозможно прочитать: {detail}", + "localHistoryWarningCapture": "Локальная история не смогла захватить эту версию: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Базовая версия", + "localHistoryReasonSaved": "Сохранено", + "localHistoryReasonAutomaticCheckpoint": "Автоматическая контрольная точка", + "localHistoryReasonBeforeReload": "Перед перезагрузкой", + "localHistoryReasonBeforeDiscard": "Перед удалением", + "localHistoryReasonBeforeRestore": "Перед восстановлением", + "localHistoryReasonBeforeDelete": "Перед удалением", + "localHistoryReasonExternalChange": "Внешнее изменение", + "settingsHistory": "История", + "settingsClipboardHistoryTitle": "Сохранить историю буфера обмена", + "settingsClipboardHistoryDescription": "Сохранять только скопированный и успешно вставленный контент для текущей сессии BusyMark.", + "settingsLocalHistoryTitle": "Записывать локальную историю", + "settingsLocalHistoryDescription": "Сохранять версии документа на этом устройстве для восстановления и сравнения.", + "settingsHistoryCheckpoint": "Интервал автоматического сохранения", + "settingsHistoryRetention": "Срок хранения", + "settingsHistoryStorage": "Максимальное хранилище", + "settingsHistoryExcludedPaths": "Исключенные пути", + "settingsHistoryExcludedPathsHint": "Один абсолютный путь в строке", + "settingsSecondsValue": "{value} секунды", + "settingsDaysValue": "{value} дни", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 069d6c18..eca8744c 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "Kenar boşluklarından sonra en az 20 mm içerik genişliği ve yüksekliği bırakın.", "exportInvalidColor": "#RRGGBB biçiminde bir renk girin.", "exportInvalidCss": "İçe aktarım, kaynak URL’si, HTML, çalıştırılabilir veya kaçışlı CSS içermeyen, okunabilir UTF-8 .css dosyası seçin (en fazla 256 KiB).", - "exportInvalidRange": "{field}: {minimum} ile {maximum} arasında bir sayı girin." + "exportInvalidRange": "{field}: {minimum} ile {maximum} arasında bir sayı girin.", + "clipboardHistory": "Kopyalama Geçmişi", + "clipboardHistoryDescription": "Oturuma özgü kopyalama geçmişini aç.", + "clipboardCurrent": "Mevcut kopyalama", + "clipboardCurrentExternal": "Başka bir uygulamadan mevcut kopyalama", + "clipboardDestination": "{name}'e yapıştır", + "clipboardSessionOnly": "Sadece bu BusyMark oturumu için saklanır.", + "clipboardNoItems": "Kopyalama geçmişi boş.", + "clipboardNoMatches": "Bu arama ile eşleşen kopyalama öğesi bulunamadı.", + "clipboardDisabled": "Kopyalama Geçmişi koleksiyonu Ayarlar'da devre dışıdır.", + "clipboardUnavailable": "Mevcut kopyalama içeriği mevcut değil veya desteklenmiyor.", + "clipboardTooLarge": "Bu kopyalanmış öğe, saklamak için çok büyüktür.", + "clipboardPastePlainText": "Temiz Metin Olarak Yapıştır", + "clipboardClearAll": "Kopyalama Geçmişini Temizle", + "clipboardEntryText": "Metin", + "clipboardEntryRichText": "Zengin Metin", + "clipboardEntryImage": "Resim", + "clipboardOrigin": "{name}'den Kopyalandı", + "localHistory": "Yerel Geçmiş", + "localHistoryEllipsis": "Yerel Geçmiş…", + "localHistoryDescription": "Mevcut belge için kaydedilmiş ve otomatik yerel sürümleri göster.", + "findLocalHistoryEllipsis": "Yerel Geçmişte Bul...", + "findLocalHistoryDescription": "Yerel Geçmişte belgeleri ve revizyon içeriğini arayın.", + "localHistoryNoDocuments": "Henüz herhangi bir belge Yerel Geçmişe sahip değil.", + "localHistoryNoRevisions": "Bu belge Yerel Geçmiş revizyonlarına sahip değil.", + "localHistoryNoMatches": "Bu arama ile eşleşen Yerel Geçmiş revizyonu yok.", + "localHistorySearchHint": "Revizyon içeriğini arayın", + "localHistorySelectedRevision": "Seçilen revizyon", + "localHistoryCurrentEditor": "Mevcut düzenleme içeriği", + "localHistoryCurrentDisk": "Disk üzerinde mevcut içerik", + "localHistoryMissingFile": "Orijinal dosya bulunmuyor.", + "localHistoryRestoreRevision": "Versiyonu Geri Yükle", + "localHistoryRestoreOriginalLocation": "Orijinal Konuma Geri Yükle", + "localHistoryRestoreNewLocation": "Yeni Konuma Geri Yükle…", + "localHistoryRestoreChange": "Değişikliği Geri Yükle", + "localHistoryRestoreUnavailable": "Bu değişiklik geri alınamaz çünkü karşılaştırma artık geçerli değil.", + "localHistoryComparisonSimplified": "Bu büyük karşılaştırma basitleştirildi. Tek tek değişiklikleri geri yüklemek mümkün değil.", + "localHistoryOlderChange": "Eski versiyon değişikliği: {text}", + "localHistoryCurrentChange": "Mevcut içerik değişikliği: {text}", + "localHistoryDeleted": "Silindi", + "localHistoryUntitled": "Başlımsız", + "localHistoryClearDocument": "Bu Dokümanın Geçmişini Temizle", + "localHistoryClearAll": "Tüm Yerel Geçmişi Temizle", + "localHistoryClearDocumentTitle": "Doküman Geçmişini Temizlemek İstemiyor Musunuz?", + "localHistoryClearAllTitle": "Tüm Yerel Geçmişi Temizlemek İstemiyor Musunuz?", + "localHistoryClearConfirmation": "Bu, kaydedilen tüm sürümleri kalıcı olarak kaldırır. Bu işlem geri alınamaz.", + "localHistoryWarningIndexRebuilt": "Yerel Geçmiş indeksi, sağlam sürümlerden yeniden oluşturuldu.", + "localHistoryWarningUnsupportedFormat": "Bu Yerel Geçmiş depolama, desteklenmeyen format {version} kullanır.", + "localHistoryWarningUnavailable": "Yerel Geçmiş kullanılabilir değil: {detail}", + "localHistoryWarningRecordingDisabled": "Kayıt devre dışı bırakılmış veya bu yol hariç tutulmuş. Geri yükleme yapmadan önce tekrar etkinleştirin.", + "localHistoryWarningPathChange": "Yerel Geçmiş, yol değişikliğini kaydetemedi: {detail}", + "localHistoryWarningDeletedPath": "Yerel Geçmiş, silinen yolu kaydetemedi: {detail}", + "localHistoryWarningRevisionMissing": "Seçilen sürüm eksik veya bozulmuş.", + "localHistoryWarningRevisionRead": "Seçilen sürüm okunamaz: {detail}", + "localHistoryWarningCapture": "Yerel Geçmiş, bu sürümü yakalamadı: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Temel Sürüm", + "localHistoryReasonSaved": "Kaydedildi", + "localHistoryReasonAutomaticCheckpoint": "Otomatik kontrol noktası", + "localHistoryReasonBeforeReload": "Yenilemeden önce", + "localHistoryReasonBeforeDiscard": "Atılmadan önce", + "localHistoryReasonBeforeRestore": "Restorasyon öncesi", + "localHistoryReasonBeforeDelete": "Silme öncesi", + "localHistoryReasonExternalChange": "Dış kaynak değişiklik", + "settingsHistory": "Geçmiş", + "settingsClipboardHistoryTitle": "Geçmişi Kopyala", + "settingsClipboardHistoryDescription": "Bu BusyMark oturumu için kopyalanan ve başarıyla yapıştırılan içeriği sakla.", + "settingsLocalHistoryTitle": "Yerel Geçmişi Kaydet", + "settingsLocalHistoryDescription": "Belgeleri kurtarma ve karşılaştırma için bu cihazda değişiklikleri sakla.", + "settingsHistoryCheckpoint": "Otomatik kontrol noktası aralığı", + "settingsHistoryRetention": "Saklama süresi", + "settingsHistoryStorage": "Maksimum depolama alanı", + "settingsHistoryExcludedPaths": "Hariç tutulan yollar", + "settingsHistoryExcludedPathsHint": "Bir mutlak yol her satırda", + "settingsSecondsValue": "{value} saniye", + "settingsDaysValue": "{value} gün", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 52313fec..c01a715b 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -3170,5 +3170,81 @@ "exportInvalidGeometry": "Після віднімання полів залиште щонайменше 20 мм ширини й висоти вмісту.", "exportInvalidColor": "Введіть колір у форматі #RRGGBB.", "exportInvalidCss": "Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.", - "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}." + "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}.", + "clipboardHistory": "Історія Вирізання", + "clipboardHistoryDescription": "Відкрити історію вирізання, прив'язану до сесії.", + "clipboardCurrent": "Поточне вирізання", + "clipboardCurrentExternal": "Поточне вирізання з іншого додатку", + "clipboardDestination": "Вставити в {name}", + "clipboardSessionOnly": "Зберігається лише для цієї сесії BusyMark.", + "clipboardNoItems": "Історія вирізання порожня.", + "clipboardNoMatches": "Жоден елемент вирізання не відповідає цьому пошуку.", + "clipboardDisabled": "Збірка \"Історія Вирізання\" вимкнена в Налаштуваннях.", + "clipboardUnavailable": "Поточний вміст вирізання недоступний або не підтримується.", + "clipboardTooLarge": "Цей елемент буфера обміну занадто великий для зберігання.", + "clipboardPastePlainText": "Вставити як звичайний текст", + "clipboardClearAll": "Очистити історію буфера обміну", + "clipboardEntryText": "Текст", + "clipboardEntryRichText": "Збагачений текст", + "clipboardEntryImage": "Зображення", + "clipboardOrigin": "Скопійовано з {name}", + "localHistory": "Локальна історія", + "localHistoryEllipsis": "Локальна історія…", + "localHistoryDescription": "Показати збережені та автоматичні локальні версії для поточної документа.", + "findLocalHistoryEllipsis": "Знайти в локальній історії…", + "findLocalHistoryDescription": "Пошук документів та вмісту версій у локальній історії.", + "localHistoryNoDocuments": "Жоден документ поки не має локальної історії.", + "localHistoryNoRevisions": "Цей документ не має версій локальної історії.", + "localHistoryNoMatches": "Жодна версія локальної історії не відповідає цьому пошуку.", + "localHistorySearchHint": "Пошук вмісту версій", + "localHistorySelectedRevision": "Обрана версія", + "localHistoryCurrentEditor": "Поточний вміст редактора", + "localHistoryCurrentDisk": "Поточний вміст на диску", + "localHistoryMissingFile": "Оригінальний файл відсутній.", + "localHistoryRestoreRevision": "Відновити версію", + "localHistoryRestoreOriginalLocation": "Відновити до початкової локації", + "localHistoryRestoreNewLocation": "Відновити до нової локації…", + "localHistoryRestoreChange": "Відновити зміни", + "localHistoryRestoreUnavailable": "Ці зміни не можуть бути відновлені, оскільки порівняння більше не актуальне.", + "localHistoryComparisonSimplified": "Це велике порівняння спрощено. Відновлення окремих змін недоступне.", + "localHistoryOlderChange": "Зміна в попередній версії: {text}", + "localHistoryCurrentChange": "Зміна в поточному вмісті: {text}", + "localHistoryDeleted": "Видалено", + "localHistoryUntitled": "Без назви", + "localHistoryClearDocument": "Очистити історію цього документа", + "localHistoryClearAll": "Очистити всю локальну історію", + "localHistoryClearDocumentTitle": "Очистити історію документа?", + "localHistoryClearAllTitle": "Очистити всю локальну історію?", + "localHistoryClearConfirmation": "Це постійно видаляє збережені версії. Цю дію неможливо скасувати.", + "localHistoryWarningIndexRebuilt": "Індекс локальної історії пошкоджено та відновлено з цілих версій.", + "localHistoryWarningUnsupportedFormat": "Ця локальна історія використовує непідтримуваний формат {version}.", + "localHistoryWarningUnavailable": "Локальна історія недоступна: {detail}", + "localHistoryWarningRecordingDisabled": "Запис вимкнено або цей шлях виключено. Увімкніть запис перед відновленням.", + "localHistoryWarningPathChange": "Локальна історія не змогла записати зміну шляху: {detail}", + "localHistoryWarningDeletedPath": "Локальна історія не змогла зберегти видалений шлях: {detail}", + "localHistoryWarningRevisionMissing": "Вибрана версія відсутня або пошкоджена.", + "localHistoryWarningRevisionRead": "Вибрану версію неможливо прочитати: {detail}", + "localHistoryWarningCapture": "Локальна історія не змогла зберегти цю версію: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Базова версія", + "localHistoryReasonSaved": "Збережено", + "localHistoryReasonAutomaticCheckpoint": "Автоматичний контрольний пункт", + "localHistoryReasonBeforeReload": "Перед перезавантаженням", + "localHistoryReasonBeforeDiscard": "Перед видаленням", + "localHistoryReasonBeforeRestore": "Перед відновленням", + "localHistoryReasonBeforeDelete": "Перед видаленням", + "localHistoryReasonExternalChange": "Зовнішня зміна", + "settingsHistory": "Історія", + "settingsClipboardHistoryTitle": "Зберегти історію, скопійовану в буфер обміну", + "settingsClipboardHistoryDescription": "Зберігати лише скопійований та успішно вставлений вміст для цієї сесії BusyMark.", + "settingsLocalHistoryTitle": "Записувати локальну історію", + "settingsLocalHistoryDescription": "Зберігати версії документа на цьому пристрої для відновлення та порівняння.", + "settingsHistoryCheckpoint": "Автоматичний інтервал збереження контрольної точки", + "settingsHistoryRetention": "Тривалість зберігання", + "settingsHistoryStorage": "Максимальне місце для зберігання", + "settingsHistoryExcludedPaths": "Виключені шляхи", + "settingsHistoryExcludedPathsHint": "Один абсолютний шлях на рядок", + "settingsSecondsValue": "{value} секунди", + "settingsDaysValue": "{value} дні", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 275741df..5157ecbd 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "Sau khi trừ lề, hãy để nội dung rộng và cao ít nhất 20 mm.", "exportInvalidColor": "Nhập màu theo dạng #RRGGBB.", "exportInvalidCss": "Chọn tệp .css UTF-8 đọc được tối đa 256 KiB, không có nhập tài nguyên, URL tài nguyên, HTML, CSS thực thi hoặc ký tự thoát.", - "exportInvalidRange": "{field}: nhập số từ {minimum} đến {maximum}." + "exportInvalidRange": "{field}: nhập số từ {minimum} đến {maximum}.", + "clipboardHistory": "Lịch sử sao chép", + "clipboardHistoryDescription": "Mở lịch sử sao chép cục bộ phiên.", + "clipboardCurrent": "Lịch sử sao chép hiện tại", + "clipboardCurrentExternal": "Lịch sử sao chép từ một ứng dụng khác", + "clipboardDestination": "Dán vào {name}", + "clipboardSessionOnly": "Chỉ lưu trong phiên BusyMark này.", + "clipboardNoItems": "Lịch sử sao chép trống.", + "clipboardNoMatches": "Không có mục sao chép nào khớp với tìm kiếm này.", + "clipboardDisabled": "Bộ sưu tập Lịch sử sao chép bị vô hiệu hóa trong Cài đặt.", + "clipboardUnavailable": "Nội dung sao chép hiện tại không khả dụng hoặc không được hỗ trợ.", + "clipboardTooLarge": "Mục này trong clipboard quá lớn để lưu.", + "clipboardPastePlainText": "Dán dưới dạng văn bản thuần túy", + "clipboardClearAll": "Xóa lịch sử clipboard", + "clipboardEntryText": "Văn bản", + "clipboardEntryRichText": "Văn bản phong phú", + "clipboardEntryImage": "Hình ảnh", + "clipboardOrigin": "Sao chép từ {name}", + "localHistory": "Lịch sử cục bộ", + "localHistoryEllipsis": "Lịch sử cục bộ…", + "localHistoryDescription": "Hiển thị các phiên bản cục bộ đã lưu và tự động cho tài liệu hiện tại.", + "findLocalHistoryEllipsis": "Tìm trong Lịch sử cục bộ…", + "findLocalHistoryDescription": "Tìm kiếm các tài liệu và nội dung phiên bản trong Lịch sử cục bộ.", + "localHistoryNoDocuments": "Chưa có tài liệu nào có Lịch sử cục bộ.", + "localHistoryNoRevisions": "Tài liệu này không có các phiên bản Lịch sử cục bộ.", + "localHistoryNoMatches": "Không có phiên bản nào trong Lịch sử cục bộ khớp với tìm kiếm này.", + "localHistorySearchHint": "Tìm kiếm nội dung phiên bản", + "localHistorySelectedRevision": "Phiên bản đã chọn", + "localHistoryCurrentEditor": "Nội dung trình soạn thảo hiện tại", + "localHistoryCurrentDisk": "Nội dung hiện tại trên ổ đĩa", + "localHistoryMissingFile": "Tệp gốc bị thiếu.", + "localHistoryRestoreRevision": "Khôi phục Phiên bản", + "localHistoryRestoreOriginalLocation": "Khôi phục về Vị trí Ban đầu", + "localHistoryRestoreNewLocation": "Khôi phục về Vị trí Mới…", + "localHistoryRestoreChange": "Khôi phục Thay đổi", + "localHistoryRestoreUnavailable": "Thay đổi này không thể khôi phục vì so sánh không còn hợp lệ.", + "localHistoryComparisonSimplified": "So sánh này rất lớn, khôi phục thay đổi riêng không khả thi.", + "localHistoryOlderChange": "Thay đổi phiên bản cũ: {text}", + "localHistoryCurrentChange": "Thay đổi nội dung hiện tại: {text}", + "localHistoryDeleted": "Xóa", + "localHistoryUntitled": "Không có tiêu đề", + "localHistoryClearDocument": "Xóa Lịch Sử Của Tài Liệu Này", + "localHistoryClearAll": "Xóa Lịch Sử Địa Phương", + "localHistoryClearDocumentTitle": "Xóa lịch sử tài liệu?", + "localHistoryClearAllTitle": "Xóa lịch sử địa phương?", + "localHistoryClearConfirmation": "Điều này sẽ xóa vĩnh viễn các phiên bản đã lưu. Hành động này không thể hoàn tác.", + "localHistoryWarningIndexRebuilt": "Chỉ mục Lịch Sử Địa Phương bị hỏng và được xây dựng lại từ các phiên bản còn nguyên vẹn.", + "localHistoryWarningUnsupportedFormat": "Kho Lịch Sử Địa Phương sử dụng định dạng không được hỗ trợ {version}.", + "localHistoryWarningUnavailable": "Lịch Sử Địa Phương không khả dụng: {detail}", + "localHistoryWarningRecordingDisabled": "Ghi âm bị tắt hoặc đường dẫn này bị loại trừ. Vui lòng bật lại ghi âm trước khi khôi phục.", + "localHistoryWarningPathChange": "Lịch Sử Địa Phương không thể ghi lại thay đổi đường dẫn: {detail}", + "localHistoryWarningDeletedPath": "Lịch sử cục bộ không thể lưu trữ đường dẫn đã xóa: {detail}", + "localHistoryWarningRevisionMissing": "Phiên bản đã chọn bị thiếu hoặc bị hỏng.", + "localHistoryWarningRevisionRead": "Phiên bản đã chọn không thể đọc: {detail}", + "localHistoryWarningCapture": "Lịch sử cục bộ không thể ghi lại phiên bản này: {detail}", + "localHistoryRevisionAt": "{reason} – {timestamp}", + "localHistoryReasonBaseline": "Mốc cơ sở", + "localHistoryReasonSaved": "Lưu", + "localHistoryReasonAutomaticCheckpoint": "Tự động lưu", + "localHistoryReasonBeforeReload": "Trước khi tải lại", + "localHistoryReasonBeforeDiscard": "Trước khi vứt", + "localHistoryReasonBeforeRestore": "Trước khi khôi phục", + "localHistoryReasonBeforeDelete": "Trước khi xóa", + "localHistoryReasonExternalChange": "Thay đổi từ bên ngoài", + "settingsHistory": "Lịch sử", + "settingsClipboardHistoryTitle": "Thu thập lịch sử sao chép", + "settingsClipboardHistoryDescription": "Giữ nội dung đã sao chép và dán thành công chỉ cho phiên BusyMark này.", + "settingsLocalHistoryTitle": "Ghi lại lịch sử cục bộ", + "settingsLocalHistoryDescription": "Lưu các phiên bản tài liệu trên thiết bị để khôi phục và so sánh.", + "settingsHistoryCheckpoint": "Khoảng thời gian kiểm tra tự động", + "settingsHistoryRetention": "Thời gian lưu", + "settingsHistoryStorage": "Dung lượng tối đa", + "settingsHistoryExcludedPaths": "Đường dẫn bị loại trừ", + "settingsHistoryExcludedPathsHint": "Một đường dẫn tuyệt đối trên mỗi dòng", + "settingsSecondsValue": "{value} giây", + "settingsDaysValue": "{value} ngày", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 80ad14f8..6ddf1cfb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "扣除边距后,请至少保留 20 mm 的内容宽度和高度。", "exportInvalidColor": "请输入 #RRGGBB 格式的颜色。", "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", - "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。" + "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", + "clipboardHistory": "剪贴板历史", + "clipboardHistoryDescription": "打开会话本地剪贴板历史。", + "clipboardCurrent": "当前剪贴板", + "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", + "clipboardDestination": "粘贴到 {name}", + "clipboardSessionOnly": "仅存储在本次 BusyMark 会话中。", + "clipboardNoItems": "剪贴板历史为空。", + "clipboardNoMatches": "没有匹配此搜索的剪贴板项目。", + "clipboardDisabled": "剪贴板历史集合在设置中已禁用。", + "clipboardUnavailable": "当前剪贴板内容不可用或不受支持。", + "clipboardTooLarge": "此剪贴板内容过大,无法保留。", + "clipboardPastePlainText": "作为纯文本粘贴", + "clipboardClearAll": "清除剪贴板历史", + "clipboardEntryText": "文本", + "clipboardEntryRichText": "丰富文本", + "clipboardEntryImage": "图片", + "clipboardOrigin": "复制自 {name}", + "localHistory": "本地历史", + "localHistoryEllipsis": "本地历史…", + "localHistoryDescription": "显示当前文档的已保存和自动本地修订。", + "findLocalHistoryEllipsis": "在本地历史中查找…", + "findLocalHistoryDescription": "搜索本地历史文档和修订内容。", + "localHistoryNoDocuments": "尚无文档具有本地历史。", + "localHistoryNoRevisions": "此文档没有本地历史修订。", + "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", + "localHistorySearchHint": "搜索修订内容", + "localHistorySelectedRevision": "已选择的修订", + "localHistoryCurrentEditor": "当前编辑器内容", + "localHistoryCurrentDisk": "磁盘上的当前内容", + "localHistoryMissingFile": "原始文件丢失。", + "localHistoryRestoreRevision": "恢复版本", + "localHistoryRestoreOriginalLocation": "恢复到原始位置", + "localHistoryRestoreNewLocation": "恢复到新位置…", + "localHistoryRestoreChange": "恢复更改", + "localHistoryRestoreUnavailable": "无法恢复此更改,因为比较已过时。", + "localHistoryComparisonSimplified": "此大型比较已简化。 无法恢复单个更改。", + "localHistoryOlderChange": "较旧的版本更改: {text}", + "localHistoryCurrentChange": "当前内容更改: {text}", + "localHistoryDeleted": "已删除", + "localHistoryUntitled": "未命名", + "localHistoryClearDocument": "清除此文档的历史", + "localHistoryClearAll": "清除所有本地历史", + "localHistoryClearDocumentTitle": "清除文档历史?", + "localHistoryClearAllTitle": "清除所有本地历史?", + "localHistoryClearConfirmation": "这将永久删除存储的修订。 此操作无法撤销。", + "localHistoryWarningIndexRebuilt": "本地历史索引已损坏并从完整修订中重建。", + "localHistoryWarningUnsupportedFormat": "此本地历史存储使用不受支持的格式 {version}。", + "localHistoryWarningUnavailable": "本地历史不可用: {detail}", + "localHistoryWarningRecordingDisabled": "录音已禁用或此路径已排除。 在恢复之前重新启用录音。", + "localHistoryWarningPathChange": "无法记录本地历史路径更改: {detail}", + "localHistoryWarningDeletedPath": "本地历史记录无法保留已删除的路径: {detail}", + "localHistoryWarningRevisionMissing": "所选版本丢失或损坏。", + "localHistoryWarningRevisionRead": "无法读取所选版本: {detail}", + "localHistoryWarningCapture": "本地历史记录无法捕获此版本: {detail}", + "localHistoryRevisionAt": "{reason}・{timestamp}", + "localHistoryReasonBaseline": "基线", + "localHistoryReasonSaved": "已保存", + "localHistoryReasonAutomaticCheckpoint": "自动检查点", + "localHistoryReasonBeforeReload": "在重新加载之前", + "localHistoryReasonBeforeDiscard": "在丢弃之前", + "localHistoryReasonBeforeRestore": "恢复前", + "localHistoryReasonBeforeDelete": "删除前", + "localHistoryReasonExternalChange": "外部修改", + "settingsHistory": "历史记录", + "settingsClipboardHistoryTitle": "收集剪贴板历史", + "settingsClipboardHistoryDescription": "只保留本次 BusyMark 会话中已复制并成功粘贴的内容。", + "settingsLocalHistoryTitle": "记录本地历史", + "settingsLocalHistoryDescription": "将文档修订存储在此设备上,以便恢复和比较。", + "settingsHistoryCheckpoint": "自动检查点间隔", + "settingsHistoryRetention": "保留期限", + "settingsHistoryStorage": "最大存储", + "settingsHistoryExcludedPaths": "排除路径", + "settingsHistoryExcludedPathsHint": "每行一个绝对路径", + "settingsSecondsValue": "{value} 秒", + "settingsDaysValue": "{value} 天", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 54c4eba7..392124ba 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -4703,5 +4703,81 @@ "exportInvalidGeometry": "扣除边距后,请至少保留 20 mm 的内容宽度和高度。", "exportInvalidColor": "请输入 #RRGGBB 格式的颜色。", "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", - "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。" + "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", + "clipboardHistory": "剪贴板历史", + "clipboardHistoryDescription": "打开会话本地剪贴板历史。", + "clipboardCurrent": "当前剪贴板", + "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", + "clipboardDestination": "粘贴到 {name}", + "clipboardSessionOnly": "仅存储在本次 BusyMark 会话中。", + "clipboardNoItems": "剪贴板历史为空。", + "clipboardNoMatches": "没有匹配此搜索的剪贴板项目。", + "clipboardDisabled": "剪贴板历史集合在设置中已禁用。", + "clipboardUnavailable": "当前剪贴板内容不可用或不受支持。", + "clipboardTooLarge": "此剪贴板内容过大,无法保留。", + "clipboardPastePlainText": "作为纯文本粘贴", + "clipboardClearAll": "清除剪贴板历史", + "clipboardEntryText": "文本", + "clipboardEntryRichText": "丰富文本", + "clipboardEntryImage": "图片", + "clipboardOrigin": "复制自 {name}", + "localHistory": "本地历史", + "localHistoryEllipsis": "本地历史…", + "localHistoryDescription": "显示当前文档的已保存和自动本地修订。", + "findLocalHistoryEllipsis": "在本地历史中查找…", + "findLocalHistoryDescription": "搜索本地历史文档和修订内容。", + "localHistoryNoDocuments": "尚无文档具有本地历史。", + "localHistoryNoRevisions": "此文档没有本地历史修订。", + "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", + "localHistorySearchHint": "搜索修订内容", + "localHistorySelectedRevision": "已选择的修订", + "localHistoryCurrentEditor": "当前编辑器内容", + "localHistoryCurrentDisk": "磁盘上的当前内容", + "localHistoryMissingFile": "原始文件丢失。", + "localHistoryRestoreRevision": "恢复版本", + "localHistoryRestoreOriginalLocation": "恢复到原始位置", + "localHistoryRestoreNewLocation": "恢复到新位置…", + "localHistoryRestoreChange": "恢复更改", + "localHistoryRestoreUnavailable": "无法恢复此更改,因为比较已过时。", + "localHistoryComparisonSimplified": "此大型比较已简化。 无法恢复单个更改。", + "localHistoryOlderChange": "较旧的版本更改: {text}", + "localHistoryCurrentChange": "当前内容更改: {text}", + "localHistoryDeleted": "已删除", + "localHistoryUntitled": "未命名", + "localHistoryClearDocument": "清除此文档的历史", + "localHistoryClearAll": "清除所有本地历史", + "localHistoryClearDocumentTitle": "清除文档历史?", + "localHistoryClearAllTitle": "清除所有本地历史?", + "localHistoryClearConfirmation": "这将永久删除存储的修订。 此操作无法撤销。", + "localHistoryWarningIndexRebuilt": "本地历史索引已损坏并从完整修订中重建。", + "localHistoryWarningUnsupportedFormat": "此本地历史存储使用不受支持的格式 {version}。", + "localHistoryWarningUnavailable": "本地历史不可用: {detail}", + "localHistoryWarningRecordingDisabled": "录音已禁用或此路径已排除。 在恢复之前重新启用录音。", + "localHistoryWarningPathChange": "无法记录本地历史路径更改: {detail}", + "localHistoryWarningDeletedPath": "本地历史记录无法保留已删除的路径: {detail}", + "localHistoryWarningRevisionMissing": "所选版本丢失或损坏。", + "localHistoryWarningRevisionRead": "无法读取所选版本: {detail}", + "localHistoryWarningCapture": "本地历史记录无法捕获此版本: {detail}", + "localHistoryRevisionAt": "{reason}・{timestamp}", + "localHistoryReasonBaseline": "基线", + "localHistoryReasonSaved": "已保存", + "localHistoryReasonAutomaticCheckpoint": "自动检查点", + "localHistoryReasonBeforeReload": "在重新加载之前", + "localHistoryReasonBeforeDiscard": "在丢弃之前", + "localHistoryReasonBeforeRestore": "恢复前", + "localHistoryReasonBeforeDelete": "删除前", + "localHistoryReasonExternalChange": "外部修改", + "settingsHistory": "历史记录", + "settingsClipboardHistoryTitle": "收集剪贴板历史", + "settingsClipboardHistoryDescription": "只保留本次 BusyMark 会话中已复制并成功粘贴的内容。", + "settingsLocalHistoryTitle": "记录本地历史", + "settingsLocalHistoryDescription": "将文档修订存储在此设备上,以便恢复和比较。", + "settingsHistoryCheckpoint": "自动检查点间隔", + "settingsHistoryRetention": "保留期限", + "settingsHistoryStorage": "最大存储", + "settingsHistoryExcludedPaths": "排除路径", + "settingsHistoryExcludedPathsHint": "每行一个绝对路径", + "settingsSecondsValue": "{value} 秒", + "settingsDaysValue": "{value} 天", + "settingsMebibytesValue": "{value} MiB" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1c883050..d86eed4b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -6557,6 +6557,462 @@ abstract class AppLocalizations { /// **'Choose a readable UTF-8 .css file up to 256 KiB, without imports, resource URLs, HTML, or executable or escaped CSS.'** String get exportInvalidCss; + /// No description provided for @clipboardHistory. + /// + /// In en, this message translates to: + /// **'Clipboard History'** + String get clipboardHistory; + + /// No description provided for @clipboardHistoryDescription. + /// + /// In en, this message translates to: + /// **'Open the session-local clipboard history.'** + String get clipboardHistoryDescription; + + /// No description provided for @clipboardCurrent. + /// + /// In en, this message translates to: + /// **'Current clipboard'** + String get clipboardCurrent; + + /// No description provided for @clipboardCurrentExternal. + /// + /// In en, this message translates to: + /// **'Current clipboard from another application'** + String get clipboardCurrentExternal; + + /// No description provided for @clipboardDestination. + /// + /// In en, this message translates to: + /// **'Paste into {name}'** + String clipboardDestination(String name); + + /// No description provided for @clipboardSessionOnly. + /// + /// In en, this message translates to: + /// **'Stored for this BusyMark session only.'** + String get clipboardSessionOnly; + + /// No description provided for @clipboardNoItems. + /// + /// In en, this message translates to: + /// **'Clipboard history is empty.'** + String get clipboardNoItems; + + /// No description provided for @clipboardNoMatches. + /// + /// In en, this message translates to: + /// **'No clipboard items match this search.'** + String get clipboardNoMatches; + + /// No description provided for @clipboardDisabled. + /// + /// In en, this message translates to: + /// **'Clipboard History collection is disabled in Settings.'** + String get clipboardDisabled; + + /// No description provided for @clipboardUnavailable. + /// + /// In en, this message translates to: + /// **'The current clipboard content is unavailable or unsupported.'** + String get clipboardUnavailable; + + /// No description provided for @clipboardTooLarge. + /// + /// In en, this message translates to: + /// **'This clipboard item is too large to retain.'** + String get clipboardTooLarge; + + /// No description provided for @clipboardPastePlainText. + /// + /// In en, this message translates to: + /// **'Paste as Plain Text'** + String get clipboardPastePlainText; + + /// No description provided for @clipboardClearAll. + /// + /// In en, this message translates to: + /// **'Clear Clipboard History'** + String get clipboardClearAll; + + /// No description provided for @clipboardEntryText. + /// + /// In en, this message translates to: + /// **'Text'** + String get clipboardEntryText; + + /// No description provided for @clipboardEntryRichText. + /// + /// In en, this message translates to: + /// **'Rich text'** + String get clipboardEntryRichText; + + /// No description provided for @clipboardEntryImage. + /// + /// In en, this message translates to: + /// **'Image'** + String get clipboardEntryImage; + + /// No description provided for @clipboardOrigin. + /// + /// In en, this message translates to: + /// **'Copied from {name}'** + String clipboardOrigin(String name); + + /// No description provided for @localHistory. + /// + /// In en, this message translates to: + /// **'Local History'** + String get localHistory; + + /// No description provided for @localHistoryEllipsis. + /// + /// In en, this message translates to: + /// **'Local History…'** + String get localHistoryEllipsis; + + /// No description provided for @localHistoryDescription. + /// + /// In en, this message translates to: + /// **'Show saved and automatic local revisions for the current document.'** + String get localHistoryDescription; + + /// No description provided for @findLocalHistoryEllipsis. + /// + /// In en, this message translates to: + /// **'Find in Local History…'** + String get findLocalHistoryEllipsis; + + /// No description provided for @findLocalHistoryDescription. + /// + /// In en, this message translates to: + /// **'Search Local History documents and revision contents.'** + String get findLocalHistoryDescription; + + /// No description provided for @localHistoryNoDocuments. + /// + /// In en, this message translates to: + /// **'No documents have Local History yet.'** + String get localHistoryNoDocuments; + + /// No description provided for @localHistoryNoRevisions. + /// + /// In en, this message translates to: + /// **'This document has no Local History revisions.'** + String get localHistoryNoRevisions; + + /// No description provided for @localHistoryNoMatches. + /// + /// In en, this message translates to: + /// **'No Local History revisions match this search.'** + String get localHistoryNoMatches; + + /// No description provided for @localHistorySearchHint. + /// + /// In en, this message translates to: + /// **'Search revision contents'** + String get localHistorySearchHint; + + /// No description provided for @localHistorySelectedRevision. + /// + /// In en, this message translates to: + /// **'Selected revision'** + String get localHistorySelectedRevision; + + /// No description provided for @localHistoryCurrentEditor. + /// + /// In en, this message translates to: + /// **'Current editor content'** + String get localHistoryCurrentEditor; + + /// No description provided for @localHistoryCurrentDisk. + /// + /// In en, this message translates to: + /// **'Current content on disk'** + String get localHistoryCurrentDisk; + + /// No description provided for @localHistoryMissingFile. + /// + /// In en, this message translates to: + /// **'The original file is missing.'** + String get localHistoryMissingFile; + + /// No description provided for @localHistoryRestoreRevision. + /// + /// In en, this message translates to: + /// **'Restore Revision'** + String get localHistoryRestoreRevision; + + /// No description provided for @localHistoryRestoreOriginalLocation. + /// + /// In en, this message translates to: + /// **'Restore to Original Location'** + String get localHistoryRestoreOriginalLocation; + + /// No description provided for @localHistoryRestoreNewLocation. + /// + /// In en, this message translates to: + /// **'Restore to New Location…'** + String get localHistoryRestoreNewLocation; + + /// No description provided for @localHistoryRestoreChange. + /// + /// In en, this message translates to: + /// **'Restore Change'** + String get localHistoryRestoreChange; + + /// No description provided for @localHistoryRestoreUnavailable. + /// + /// In en, this message translates to: + /// **'This change cannot be restored because the comparison is no longer current.'** + String get localHistoryRestoreUnavailable; + + /// No description provided for @localHistoryComparisonSimplified. + /// + /// In en, this message translates to: + /// **'This large comparison is simplified. Restore individual changes is unavailable.'** + String get localHistoryComparisonSimplified; + + /// No description provided for @localHistoryOlderChange. + /// + /// In en, this message translates to: + /// **'Older revision change: {text}'** + String localHistoryOlderChange(String text); + + /// No description provided for @localHistoryCurrentChange. + /// + /// In en, this message translates to: + /// **'Current content change: {text}'** + String localHistoryCurrentChange(String text); + + /// No description provided for @localHistoryDeleted. + /// + /// In en, this message translates to: + /// **'Deleted'** + String get localHistoryDeleted; + + /// No description provided for @localHistoryUntitled. + /// + /// In en, this message translates to: + /// **'Untitled'** + String get localHistoryUntitled; + + /// No description provided for @localHistoryClearDocument. + /// + /// In en, this message translates to: + /// **'Clear This Document’s History'** + String get localHistoryClearDocument; + + /// No description provided for @localHistoryClearAll. + /// + /// In en, this message translates to: + /// **'Clear All Local History'** + String get localHistoryClearAll; + + /// No description provided for @localHistoryClearDocumentTitle. + /// + /// In en, this message translates to: + /// **'Clear document history?'** + String get localHistoryClearDocumentTitle; + + /// No description provided for @localHistoryClearAllTitle. + /// + /// In en, this message translates to: + /// **'Clear all Local History?'** + String get localHistoryClearAllTitle; + + /// No description provided for @localHistoryClearConfirmation. + /// + /// In en, this message translates to: + /// **'This permanently removes stored revisions. This action cannot be undone.'** + String get localHistoryClearConfirmation; + + /// No description provided for @localHistoryWarningIndexRebuilt. + /// + /// In en, this message translates to: + /// **'The Local History index was damaged and rebuilt from intact revisions.'** + String get localHistoryWarningIndexRebuilt; + + /// No description provided for @localHistoryWarningUnsupportedFormat. + /// + /// In en, this message translates to: + /// **'This Local History store uses unsupported format {version}.'** + String localHistoryWarningUnsupportedFormat(String version); + + /// No description provided for @localHistoryWarningUnavailable. + /// + /// In en, this message translates to: + /// **'Local History is unavailable: {detail}'** + String localHistoryWarningUnavailable(String detail); + + /// No description provided for @localHistoryWarningRecordingDisabled. + /// + /// In en, this message translates to: + /// **'Recording is disabled or this path is excluded. Re-enable recording before restoring.'** + String get localHistoryWarningRecordingDisabled; + + /// No description provided for @localHistoryWarningPathChange. + /// + /// In en, this message translates to: + /// **'Local History could not record the path change: {detail}'** + String localHistoryWarningPathChange(String detail); + + /// No description provided for @localHistoryWarningDeletedPath. + /// + /// In en, this message translates to: + /// **'Local History could not retain the deleted path: {detail}'** + String localHistoryWarningDeletedPath(String detail); + + /// No description provided for @localHistoryWarningRevisionMissing. + /// + /// In en, this message translates to: + /// **'The selected revision is missing or damaged.'** + String get localHistoryWarningRevisionMissing; + + /// No description provided for @localHistoryWarningRevisionRead. + /// + /// In en, this message translates to: + /// **'The selected revision cannot be read: {detail}'** + String localHistoryWarningRevisionRead(String detail); + + /// No description provided for @localHistoryWarningCapture. + /// + /// In en, this message translates to: + /// **'Local History could not capture this revision: {detail}'** + String localHistoryWarningCapture(String detail); + + /// No description provided for @localHistoryRevisionAt. + /// + /// In en, this message translates to: + /// **'{reason} · {timestamp}'** + String localHistoryRevisionAt(String reason, String timestamp); + + /// No description provided for @localHistoryReasonBaseline. + /// + /// In en, this message translates to: + /// **'Baseline'** + String get localHistoryReasonBaseline; + + /// No description provided for @localHistoryReasonSaved. + /// + /// In en, this message translates to: + /// **'Saved'** + String get localHistoryReasonSaved; + + /// No description provided for @localHistoryReasonAutomaticCheckpoint. + /// + /// In en, this message translates to: + /// **'Automatic checkpoint'** + String get localHistoryReasonAutomaticCheckpoint; + + /// No description provided for @localHistoryReasonBeforeReload. + /// + /// In en, this message translates to: + /// **'Before reload'** + String get localHistoryReasonBeforeReload; + + /// No description provided for @localHistoryReasonBeforeDiscard. + /// + /// In en, this message translates to: + /// **'Before discard'** + String get localHistoryReasonBeforeDiscard; + + /// No description provided for @localHistoryReasonBeforeRestore. + /// + /// In en, this message translates to: + /// **'Before restore'** + String get localHistoryReasonBeforeRestore; + + /// No description provided for @localHistoryReasonBeforeDelete. + /// + /// In en, this message translates to: + /// **'Before delete'** + String get localHistoryReasonBeforeDelete; + + /// No description provided for @localHistoryReasonExternalChange. + /// + /// In en, this message translates to: + /// **'External change'** + String get localHistoryReasonExternalChange; + + /// No description provided for @settingsHistory. + /// + /// In en, this message translates to: + /// **'History'** + String get settingsHistory; + + /// No description provided for @settingsClipboardHistoryTitle. + /// + /// In en, this message translates to: + /// **'Collect Clipboard History'** + String get settingsClipboardHistoryTitle; + + /// No description provided for @settingsClipboardHistoryDescription. + /// + /// In en, this message translates to: + /// **'Keep copied and successfully pasted content for this BusyMark session only.'** + String get settingsClipboardHistoryDescription; + + /// No description provided for @settingsLocalHistoryTitle. + /// + /// In en, this message translates to: + /// **'Record Local History'** + String get settingsLocalHistoryTitle; + + /// No description provided for @settingsLocalHistoryDescription. + /// + /// In en, this message translates to: + /// **'Store document revisions on this device for recovery and comparison.'** + String get settingsLocalHistoryDescription; + + /// No description provided for @settingsHistoryCheckpoint. + /// + /// In en, this message translates to: + /// **'Automatic checkpoint interval'** + String get settingsHistoryCheckpoint; + + /// No description provided for @settingsHistoryRetention. + /// + /// In en, this message translates to: + /// **'Retention age'** + String get settingsHistoryRetention; + + /// No description provided for @settingsHistoryStorage. + /// + /// In en, this message translates to: + /// **'Maximum storage'** + String get settingsHistoryStorage; + + /// No description provided for @settingsHistoryExcludedPaths. + /// + /// In en, this message translates to: + /// **'Excluded paths'** + String get settingsHistoryExcludedPaths; + + /// No description provided for @settingsHistoryExcludedPathsHint. + /// + /// In en, this message translates to: + /// **'One absolute path per line'** + String get settingsHistoryExcludedPathsHint; + + /// No description provided for @settingsSecondsValue. + /// + /// In en, this message translates to: + /// **'{value} seconds'** + String settingsSecondsValue(int value); + + /// No description provided for @settingsDaysValue. + /// + /// In en, this message translates to: + /// **'{value} days'** + String settingsDaysValue(int value); + + /// No description provided for @settingsMebibytesValue. + /// + /// In en, this message translates to: + /// **'{value} MiB'** + String settingsMebibytesValue(int value); + /// Invalid numeric export setting. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index de018b1b..3c1449b8 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3927,6 +3927,278 @@ class AppLocalizationsAr extends AppLocalizations { String get exportInvalidCss => 'اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.'; + @override + String get clipboardHistory => 'سجل النسخ واللصق'; + + @override + String get clipboardHistoryDescription => + 'افتح سجل النسخ واللصق الخاص بالجلسة.'; + + @override + String get clipboardCurrent => 'النسخة واللصق الحالية'; + + @override + String get clipboardCurrentExternal => 'النسخة واللصق من تطبيق آخر'; + + @override + String clipboardDestination(String name) { + return 'الصق في $name'; + } + + @override + String get clipboardSessionOnly => 'مخزن فقط لهذه جلسة BusyMark.'; + + @override + String get clipboardNoItems => 'سجل النسخ واللصق فارغ.'; + + @override + String get clipboardNoMatches => + 'لا توجد عناصر في سجل النسخ واللصق تطابق هذا البحث.'; + + @override + String get clipboardDisabled => 'مجموعة سجل النسخ واللصق معطلة في الإعدادات.'; + + @override + String get clipboardUnavailable => + 'محتوى النسخة واللصق الحالي غير متاح أو غير مدعوم.'; + + @override + String get clipboardTooLarge => + 'هذا العنصر في النسخة واللصق كبير جدًا بحيث لا يمكن الاحتفاظ به.'; + + @override + String get clipboardPastePlainText => 'الصق كنص عادي'; + + @override + String get clipboardClearAll => 'مسح سجل النسخ واللصق'; + + @override + String get clipboardEntryText => 'نص'; + + @override + String get clipboardEntryRichText => 'نص غني'; + + @override + String get clipboardEntryImage => 'صورة'; + + @override + String clipboardOrigin(String name) { + return 'تم نسخها من $name'; + } + + @override + String get localHistory => 'سجل محلي'; + + @override + String get localHistoryEllipsis => 'سجل محلي…'; + + @override + String get localHistoryDescription => + 'اعرض النسخ والمراجعات المحلية المحفوظة والآلية للمستند الحالي.'; + + @override + String get findLocalHistoryEllipsis => 'البحث في التاريخ المحلي…'; + + @override + String get findLocalHistoryDescription => + 'البحث في مستندات التاريخ المحلي ومحتوى الإصدارات.'; + + @override + String get localHistoryNoDocuments => 'لا توجد مستندات لديها تاريخ محلي.'; + + @override + String get localHistoryNoRevisions => + 'هذا المستند ليس لديه إصدارات في التاريخ المحلي.'; + + @override + String get localHistoryNoMatches => + 'لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.'; + + @override + String get localHistorySearchHint => 'البحث في محتوى الإصدار'; + + @override + String get localHistorySelectedRevision => 'الإصدار المحدد'; + + @override + String get localHistoryCurrentEditor => 'محتوى المحرر الحالي'; + + @override + String get localHistoryCurrentDisk => 'المحتوى الحالي على القرص'; + + @override + String get localHistoryMissingFile => 'الملف الأصلي مفقود.'; + + @override + String get localHistoryRestoreRevision => 'استعادة الإصدار'; + + @override + String get localHistoryRestoreOriginalLocation => 'استعادة إلى الموقع الأصلي'; + + @override + String get localHistoryRestoreNewLocation => 'استعادة إلى موقع جديد…'; + + @override + String get localHistoryRestoreChange => 'استعادة التغيير'; + + @override + String get localHistoryRestoreUnavailable => + 'لا يمكن استعادة هذا التغيير لأن المقارنة لم تعد حديثة.'; + + @override + String get localHistoryComparisonSimplified => + 'تم تبسيط هذا المقارنة الكبير. لا يمكن استعادة التغييرات الفردية.'; + + @override + String localHistoryOlderChange(String text) { + return 'تغيير الإصدار الأقدم: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'تغيير المحتوى الحالي: $text'; + } + + @override + String get localHistoryDeleted => 'تم الحذف'; + + @override + String get localHistoryUntitled => 'غير مُسمى'; + + @override + String get localHistoryClearDocument => 'مسح سجل هذا المستند'; + + @override + String get localHistoryClearAll => 'مسح السجل المحلي بالكامل'; + + @override + String get localHistoryClearDocumentTitle => 'هل تريد مسح سجل المحلي؟'; + + @override + String get localHistoryClearAllTitle => 'هل تريد مسح السجل المحلي؟'; + + @override + String get localHistoryClearConfirmation => + 'هذا يزيل بشكل دائم النسخ المحفوظة. لا يمكن التراجع عن هذه العملية.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'تم تلف فهرس السجل المحلي وإعادة بنائه من النسخ السليمة.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'يستخدم هذا السجل المحلي تنسيقًا غير مدعوم $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'السجل المحلي غير متاح: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'تم تعطيل التسجيل أو تم استبعاد هذا المسار. قم بتفعيل التسجيل مرة أخرى قبل الاستعادة.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'لم يتمكن السجل المحلي من تسجيل تغيير المسار: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'لم يتمكن السجل المحلي من الاحتفاظ بالمسار المحذوف: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'النسخة المختارة مفقودة أو تالفة.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'لا يمكن قراءة النسخة المختارة: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'لم يتمكن السجل المحلي من التقاط هذه النسخة: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason، $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'النسخة الأساسية'; + + @override + String get localHistoryReasonSaved => 'محفوظ'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'نقطة فحص تلقائية'; + + @override + String get localHistoryReasonBeforeReload => 'قبل إعادة التحميل'; + + @override + String get localHistoryReasonBeforeDiscard => 'قبل التخلص'; + + @override + String get localHistoryReasonBeforeRestore => 'قبل الاستعادة'; + + @override + String get localHistoryReasonBeforeDelete => 'قبل الحذف'; + + @override + String get localHistoryReasonExternalChange => 'تغيير خارجي'; + + @override + String get settingsHistory => 'سجل التغييرات'; + + @override + String get settingsClipboardHistoryTitle => 'جمع سجل الحافظة'; + + @override + String get settingsClipboardHistoryDescription => + 'احتفظ بالمحتوى المنسوخ والمُلصق بنجاح لهذه الجلسة فقط في BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'تسجيل سجل محلي'; + + @override + String get settingsLocalHistoryDescription => + 'حفظ إصدارات المستند على هذا الجهاز للتعافي والمقارنة.'; + + @override + String get settingsHistoryCheckpoint => 'فترة التحقق التلقائي'; + + @override + String get settingsHistoryRetention => 'مدة الاحتفاظ'; + + @override + String get settingsHistoryStorage => 'الحد الأقصى للتخزين'; + + @override + String get settingsHistoryExcludedPaths => 'مسارات مستبعدة'; + + @override + String get settingsHistoryExcludedPathsHint => 'مسار مطلق واحد في كل سطر'; + + @override + String settingsSecondsValue(int value) { + return '$value ثوان'; + } + + @override + String settingsDaysValue(int value) { + return '$value أيام'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value ميجابايت'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: أدخل رقمًا بين $minimum و$maximum.'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 77dcf063..5382a5fe 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3922,6 +3922,287 @@ class AppLocalizationsDe extends AppLocalizations { String get exportInvalidCss => 'Wählen Sie eine lesbare UTF-8-CSS-Datei bis 256 KiB ohne Importe, Ressourcen-URLs, HTML sowie ausführbares oder maskiertes CSS.'; + @override + String get clipboardHistory => 'Zwischenablage-Historie'; + + @override + String get clipboardHistoryDescription => + 'Öffne die Sitzungs-spezifische Zwischenablage-Historie.'; + + @override + String get clipboardCurrent => 'Aktuelle Zwischenablage'; + + @override + String get clipboardCurrentExternal => + 'Aktuelle Zwischenablage aus einer anderen Anwendung'; + + @override + String clipboardDestination(String name) { + return 'Einfügen in $name'; + } + + @override + String get clipboardSessionOnly => + 'Nur für diese BusyMark-Sitzung gespeichert.'; + + @override + String get clipboardNoItems => 'Zwischenablage-Historie ist leer.'; + + @override + String get clipboardNoMatches => + 'Keine Zwischenablagen-Elemente passen zu dieser Suche.'; + + @override + String get clipboardDisabled => + 'Die Sammlung \"Zwischenablage-Historie\" ist in den Einstellungen deaktiviert.'; + + @override + String get clipboardUnavailable => + 'Der aktuelle Zwischenablage-Inhalt ist nicht verfügbar oder wird nicht unterstützt.'; + + @override + String get clipboardTooLarge => + 'Dieses Zwischenablagen-Element ist zu groß, um es zu speichern.'; + + @override + String get clipboardPastePlainText => 'Einfügen als reinen Text'; + + @override + String get clipboardClearAll => 'Löschen der Zwischenablage-Historie'; + + @override + String get clipboardEntryText => 'Textinhalt'; + + @override + String get clipboardEntryRichText => 'Reicher Text'; + + @override + String get clipboardEntryImage => 'Bild'; + + @override + String clipboardOrigin(String name) { + return 'Kopiert aus $name'; + } + + @override + String get localHistory => 'Lokale Historie'; + + @override + String get localHistoryEllipsis => 'Lokale Historie…'; + + @override + String get localHistoryDescription => + 'Zeige gespeicherte und automatische lokale Revisionen für das aktuelle Dokument.'; + + @override + String get findLocalHistoryEllipsis => 'In Lokaler Historie suchen…'; + + @override + String get findLocalHistoryDescription => + 'Lokale Historie Dokumente und Revisionen durchsuchen.'; + + @override + String get localHistoryNoDocuments => + 'Es gibt noch keine Dokumente mit Lokaler Historie.'; + + @override + String get localHistoryNoRevisions => + 'Dieses Dokument hat keine Lokalen Historie Revisionen.'; + + @override + String get localHistoryNoMatches => + 'Keine Lokalen Historie Revisionen passen zu dieser Suche.'; + + @override + String get localHistorySearchHint => 'Revisionen durchsuchen'; + + @override + String get localHistorySelectedRevision => 'Ausgewählte Revision'; + + @override + String get localHistoryCurrentEditor => 'Aktueller Editor Inhalt'; + + @override + String get localHistoryCurrentDisk => 'Aktueller Inhalt auf der Festplatte'; + + @override + String get localHistoryMissingFile => 'Die Originaldatei fehlt.'; + + @override + String get localHistoryRestoreRevision => 'Revision Wiederherstellen'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Zur Originalposition Wiederherstellen'; + + @override + String get localHistoryRestoreNewLocation => + 'Zur neuen Position Wiederherstellen…'; + + @override + String get localHistoryRestoreChange => 'Änderung Wiederherstellen'; + + @override + String get localHistoryRestoreUnavailable => + 'Diese Änderung kann nicht wiederhergestellt werden, da der Vergleich nicht mehr aktuell ist.'; + + @override + String get localHistoryComparisonSimplified => + 'Dieser große Vergleich wurde vereinfacht. Einzelne Änderungen können nicht wiederhergestellt werden.'; + + @override + String localHistoryOlderChange(String text) { + return 'Ältere Revision Änderung: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Aktuelle Inhalt Änderung: $text'; + } + + @override + String get localHistoryDeleted => 'Gelöscht'; + + @override + String get localHistoryUntitled => 'Unbenannt'; + + @override + String get localHistoryClearDocument => + 'Löschen Sie den Verlauf dieses Dokuments'; + + @override + String get localHistoryClearAll => 'Löschen Sie den gesamten lokalen Verlauf'; + + @override + String get localHistoryClearDocumentTitle => + 'Verlauf dieses Dokuments löschen?'; + + @override + String get localHistoryClearAllTitle => 'Verlauf dieses Dokuments löschen?'; + + @override + String get localHistoryClearConfirmation => + 'Dies entfernt dauerhaft gespeicherte Revisionen. Diese Aktion kann nicht rückgängig gemacht werden.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Der lokale Verlauf wurde beschädigt und aus intakten Revisionen neu aufgebaut.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Dieser lokale Verlauf verwendet das nicht unterstützte Format $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Lokaler Verlauf ist nicht verfügbar: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Aufzeichnung ist deaktiviert oder dieser Pfad ist ausgeschlossen. Aktivieren Sie die Aufzeichnung erneut, bevor Sie wiederherstellen.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Lokaler Verlauf konnte die Pfadänderung nicht aufzeichnen: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Lokaler Verlauf konnte den gelöschten Pfad nicht speichern: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Die ausgewählte Revision fehlt oder ist beschädigt.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Die ausgewählte Revision kann nicht gelesen werden: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Lokaler Verlauf konnte diese Revision nicht erfassen: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Basisversion'; + + @override + String get localHistoryReasonSaved => 'Gespeichert'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Automatische Checkpoint'; + + @override + String get localHistoryReasonBeforeReload => 'Vor dem Neuladen'; + + @override + String get localHistoryReasonBeforeDiscard => 'Vor dem Entsorgen'; + + @override + String get localHistoryReasonBeforeRestore => 'Vor der Wiederherstellung'; + + @override + String get localHistoryReasonBeforeDelete => 'Vor dem Löschen'; + + @override + String get localHistoryReasonExternalChange => 'Externe Änderung'; + + @override + String get settingsHistory => 'Verlauf'; + + @override + String get settingsClipboardHistoryTitle => + 'Verlauf der Zwischenablage sammeln'; + + @override + String get settingsClipboardHistoryDescription => + 'Behalten Sie den Inhalt, der erfolgreich kopiert und eingefügt wurde, nur für diese BusyMark-Sitzung.'; + + @override + String get settingsLocalHistoryTitle => 'Lokalen Verlauf speichern'; + + @override + String get settingsLocalHistoryDescription => + 'Speichern Sie Dokumentversionen auf diesem Gerät zur Wiederherstellung und zum Vergleich.'; + + @override + String get settingsHistoryCheckpoint => 'Automatische Checkpoint-Intervall'; + + @override + String get settingsHistoryRetention => 'Aufbewahrungsdauer'; + + @override + String get settingsHistoryStorage => 'Maximale Speicherkapazität'; + + @override + String get settingsHistoryExcludedPaths => 'Ausschließlich Pfade'; + + @override + String get settingsHistoryExcludedPathsHint => 'Ein absoluter Pfad pro Zeile'; + + @override + String settingsSecondsValue(int value) { + return '$value Sekunden'; + } + + @override + String settingsDaysValue(int value) { + return '$value Tage'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: Geben Sie eine Zahl zwischen $minimum und $maximum ein.'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 73313181..8ee5fbbe 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3916,6 +3916,279 @@ class AppLocalizationsEn extends AppLocalizations { String get exportInvalidCss => 'Choose a readable UTF-8 .css file up to 256 KiB, without imports, resource URLs, HTML, or executable or escaped CSS.'; + @override + String get clipboardHistory => 'Clipboard History'; + + @override + String get clipboardHistoryDescription => + 'Open the session-local clipboard history.'; + + @override + String get clipboardCurrent => 'Current clipboard'; + + @override + String get clipboardCurrentExternal => + 'Current clipboard from another application'; + + @override + String clipboardDestination(String name) { + return 'Paste into $name'; + } + + @override + String get clipboardSessionOnly => 'Stored for this BusyMark session only.'; + + @override + String get clipboardNoItems => 'Clipboard history is empty.'; + + @override + String get clipboardNoMatches => 'No clipboard items match this search.'; + + @override + String get clipboardDisabled => + 'Clipboard History collection is disabled in Settings.'; + + @override + String get clipboardUnavailable => + 'The current clipboard content is unavailable or unsupported.'; + + @override + String get clipboardTooLarge => 'This clipboard item is too large to retain.'; + + @override + String get clipboardPastePlainText => 'Paste as Plain Text'; + + @override + String get clipboardClearAll => 'Clear Clipboard History'; + + @override + String get clipboardEntryText => 'Text'; + + @override + String get clipboardEntryRichText => 'Rich text'; + + @override + String get clipboardEntryImage => 'Image'; + + @override + String clipboardOrigin(String name) { + return 'Copied from $name'; + } + + @override + String get localHistory => 'Local History'; + + @override + String get localHistoryEllipsis => 'Local History…'; + + @override + String get localHistoryDescription => + 'Show saved and automatic local revisions for the current document.'; + + @override + String get findLocalHistoryEllipsis => 'Find in Local History…'; + + @override + String get findLocalHistoryDescription => + 'Search Local History documents and revision contents.'; + + @override + String get localHistoryNoDocuments => 'No documents have Local History yet.'; + + @override + String get localHistoryNoRevisions => + 'This document has no Local History revisions.'; + + @override + String get localHistoryNoMatches => + 'No Local History revisions match this search.'; + + @override + String get localHistorySearchHint => 'Search revision contents'; + + @override + String get localHistorySelectedRevision => 'Selected revision'; + + @override + String get localHistoryCurrentEditor => 'Current editor content'; + + @override + String get localHistoryCurrentDisk => 'Current content on disk'; + + @override + String get localHistoryMissingFile => 'The original file is missing.'; + + @override + String get localHistoryRestoreRevision => 'Restore Revision'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Restore to Original Location'; + + @override + String get localHistoryRestoreNewLocation => 'Restore to New Location…'; + + @override + String get localHistoryRestoreChange => 'Restore Change'; + + @override + String get localHistoryRestoreUnavailable => + 'This change cannot be restored because the comparison is no longer current.'; + + @override + String get localHistoryComparisonSimplified => + 'This large comparison is simplified. Restore individual changes is unavailable.'; + + @override + String localHistoryOlderChange(String text) { + return 'Older revision change: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Current content change: $text'; + } + + @override + String get localHistoryDeleted => 'Deleted'; + + @override + String get localHistoryUntitled => 'Untitled'; + + @override + String get localHistoryClearDocument => 'Clear This Document’s History'; + + @override + String get localHistoryClearAll => 'Clear All Local History'; + + @override + String get localHistoryClearDocumentTitle => 'Clear document history?'; + + @override + String get localHistoryClearAllTitle => 'Clear all Local History?'; + + @override + String get localHistoryClearConfirmation => + 'This permanently removes stored revisions. This action cannot be undone.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'The Local History index was damaged and rebuilt from intact revisions.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'This Local History store uses unsupported format $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Local History is unavailable: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Recording is disabled or this path is excluded. Re-enable recording before restoring.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Local History could not record the path change: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Local History could not retain the deleted path: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'The selected revision is missing or damaged.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'The selected revision cannot be read: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Local History could not capture this revision: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason · $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Baseline'; + + @override + String get localHistoryReasonSaved => 'Saved'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Automatic checkpoint'; + + @override + String get localHistoryReasonBeforeReload => 'Before reload'; + + @override + String get localHistoryReasonBeforeDiscard => 'Before discard'; + + @override + String get localHistoryReasonBeforeRestore => 'Before restore'; + + @override + String get localHistoryReasonBeforeDelete => 'Before delete'; + + @override + String get localHistoryReasonExternalChange => 'External change'; + + @override + String get settingsHistory => 'History'; + + @override + String get settingsClipboardHistoryTitle => 'Collect Clipboard History'; + + @override + String get settingsClipboardHistoryDescription => + 'Keep copied and successfully pasted content for this BusyMark session only.'; + + @override + String get settingsLocalHistoryTitle => 'Record Local History'; + + @override + String get settingsLocalHistoryDescription => + 'Store document revisions on this device for recovery and comparison.'; + + @override + String get settingsHistoryCheckpoint => 'Automatic checkpoint interval'; + + @override + String get settingsHistoryRetention => 'Retention age'; + + @override + String get settingsHistoryStorage => 'Maximum storage'; + + @override + String get settingsHistoryExcludedPaths => 'Excluded paths'; + + @override + String get settingsHistoryExcludedPathsHint => 'One absolute path per line'; + + @override + String settingsSecondsValue(int value) { + return '$value seconds'; + } + + @override + String settingsDaysValue(int value) { + return '$value days'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: enter a number between $minimum and $maximum.'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index ac44a06e..da46b78a 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3925,6 +3925,287 @@ class AppLocalizationsEs extends AppLocalizations { String get exportInvalidCss => 'Elija un archivo .css UTF-8 legible de hasta 256 KiB, sin importaciones, URL de recursos, HTML ni CSS ejecutable o escapado.'; + @override + String get clipboardHistory => 'Historial de la Copia'; + + @override + String get clipboardHistoryDescription => + 'Abrir el historial de la copia local de la sesión.'; + + @override + String get clipboardCurrent => 'Copia actual'; + + @override + String get clipboardCurrentExternal => 'Copia actual de otra aplicación'; + + @override + String clipboardDestination(String name) { + return 'Pegar en $name'; + } + + @override + String get clipboardSessionOnly => + 'Almacenado solo para esta sesión de BusyMark.'; + + @override + String get clipboardNoItems => 'El historial de la copia está vacío.'; + + @override + String get clipboardNoMatches => + 'Ningún elemento de la copia coincide con esta búsqueda.'; + + @override + String get clipboardDisabled => + 'La colección de Historial de la Copia está deshabilitada en la configuración.'; + + @override + String get clipboardUnavailable => + 'El contenido de la copia actual no está disponible o no es compatible.'; + + @override + String get clipboardTooLarge => + 'Este elemento de la copia es demasiado grande para conservarlo.'; + + @override + String get clipboardPastePlainText => 'Pegar como Texto Plano'; + + @override + String get clipboardClearAll => 'Borrar el Historial de la Copia'; + + @override + String get clipboardEntryText => 'Texto'; + + @override + String get clipboardEntryRichText => 'Texto enriquecido'; + + @override + String get clipboardEntryImage => 'Imagen'; + + @override + String clipboardOrigin(String name) { + return 'Copiado de $name'; + } + + @override + String get localHistory => 'Historial Local'; + + @override + String get localHistoryEllipsis => 'Historial Local…'; + + @override + String get localHistoryDescription => + 'Mostrar las revisiones locales guardadas y automáticas para el documento actual.'; + + @override + String get findLocalHistoryEllipsis => 'Buscar en el Historial Local…'; + + @override + String get findLocalHistoryDescription => + 'Buscar documentos y contenido de revisiones en el Historial Local.'; + + @override + String get localHistoryNoDocuments => + 'Ningún documento tiene Historial Local.'; + + @override + String get localHistoryNoRevisions => + 'Este documento no tiene revisiones en el Historial Local.'; + + @override + String get localHistoryNoMatches => + 'Ninguna revisión del Historial Local coincide con esta búsqueda.'; + + @override + String get localHistorySearchHint => 'Buscar contenido de la revisión'; + + @override + String get localHistorySelectedRevision => 'Revisión seleccionada'; + + @override + String get localHistoryCurrentEditor => 'Contenido del editor actual'; + + @override + String get localHistoryCurrentDisk => 'Contenido actual en el disco'; + + @override + String get localHistoryMissingFile => 'El archivo original está ausente.'; + + @override + String get localHistoryRestoreRevision => 'Restaurar Revisión'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Restaurar a la Ubicación Original'; + + @override + String get localHistoryRestoreNewLocation => + 'Restaurar a una Nueva Ubicación…'; + + @override + String get localHistoryRestoreChange => 'Restaurar Cambio'; + + @override + String get localHistoryRestoreUnavailable => + 'Este cambio no puede ser restaurado porque la comparación ya no es válida.'; + + @override + String get localHistoryComparisonSimplified => + 'Esta comparación grande se ha simplificado. Restaurar cambios individuales no está disponible.'; + + @override + String localHistoryOlderChange(String text) { + return 'Cambio de revisión anterior: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Cambio de contenido actual: $text'; + } + + @override + String get localHistoryDeleted => 'Eliminado'; + + @override + String get localHistoryUntitled => 'Sin título'; + + @override + String get localHistoryClearDocument => + 'Borrar el historial de este documento'; + + @override + String get localHistoryClearAll => 'Borrar todo el historial local'; + + @override + String get localHistoryClearDocumentTitle => + '¿Borrar el historial del documento?'; + + @override + String get localHistoryClearAllTitle => '¿Borrar todo el historial local?'; + + @override + String get localHistoryClearConfirmation => + 'Esto elimina permanentemente las revisiones almacenadas. Esta acción no se puede deshacer.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'El índice de historial local se dañó y se reconstruyó a partir de revisiones intactas.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Esta tienda de historial local utiliza un formato no soportado $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'El historial local no está disponible: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'La grabación está deshabilitada o esta ruta está excluida. Re-habilite la grabación antes de restaurar.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'El historial local no pudo registrar el cambio de ruta: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'El historial local no pudo conservar la ruta eliminada: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'La revisión seleccionada está ausente o dañada.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'La revisión seleccionada no se puede leer: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'El historial local no pudo capturar esta revisión: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Línea base'; + + @override + String get localHistoryReasonSaved => 'Guardado'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Puntos de control automático'; + + @override + String get localHistoryReasonBeforeReload => 'Antes de recargar'; + + @override + String get localHistoryReasonBeforeDiscard => 'Antes de descartar'; + + @override + String get localHistoryReasonBeforeRestore => 'Antes de restaurar'; + + @override + String get localHistoryReasonBeforeDelete => 'Antes de eliminar'; + + @override + String get localHistoryReasonExternalChange => 'Cambio externo'; + + @override + String get settingsHistory => 'Historial'; + + @override + String get settingsClipboardHistoryTitle => 'Recolectar Historial de Copia'; + + @override + String get settingsClipboardHistoryDescription => + 'Mantener el contenido copiado y pegado con éxito solo para esta sesión de BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Registrar Historial Local'; + + @override + String get settingsLocalHistoryDescription => + 'Almacenar revisiones del documento en este dispositivo para recuperación y comparación.'; + + @override + String get settingsHistoryCheckpoint => + 'Intervalo de punto de control automático'; + + @override + String get settingsHistoryRetention => 'Edad de retención'; + + @override + String get settingsHistoryStorage => 'Almacenamiento máximo'; + + @override + String get settingsHistoryExcludedPaths => 'Rutas excluidas'; + + @override + String get settingsHistoryExcludedPathsHint => 'Una ruta absoluta por línea'; + + @override + String settingsSecondsValue(int value) { + return '$value segundos'; + } + + @override + String settingsDaysValue(int value) { + return '$value días'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: introduzca un número entre $minimum y $maximum.'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index d1109651..54bb25b5 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3887,6 +3887,284 @@ class AppLocalizationsEt extends AppLocalizations { String get exportInvalidCss => 'Vali loetav UTF-8 .css-fail kuni 256 KiB, ilma importide, ressursside URL-ide, HTML-i ning käivitatava või paojadadega CSS-ita.'; + @override + String get clipboardHistory => 'Kleepimiskredi ajalugu'; + + @override + String get clipboardHistoryDescription => + 'Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.'; + + @override + String get clipboardCurrent => 'Praegune kleepimiskredi'; + + @override + String get clipboardCurrentExternal => + 'Praegune kleepimiskredi teistest rakendusest'; + + @override + String clipboardDestination(String name) { + return 'Kleepi $name'; + } + + @override + String get clipboardSessionOnly => + 'Salvestatud ainult sellele BusyMark sessioonile.'; + + @override + String get clipboardNoItems => 'Kleepimiskredi ajalugu on tühi.'; + + @override + String get clipboardNoMatches => + 'Ei leitud kleepimiskredi elemente, mis vastavad sellele otsingule.'; + + @override + String get clipboardDisabled => + 'Kleepimiskredi ajalugu on peatatud seadetes.'; + + @override + String get clipboardUnavailable => + 'Praegune kleepimiskredi sisu pole saadaval või toetatud.'; + + @override + String get clipboardTooLarge => + 'See kleepimiskredi element on liiga suur, et seda säilitada.'; + + @override + String get clipboardPastePlainText => 'Kleepi kui tavatekst'; + + @override + String get clipboardClearAll => 'Puhasta kleepimiskredi ajalugu'; + + @override + String get clipboardEntryText => 'Tekst'; + + @override + String get clipboardEntryRichText => 'Rikas tekst'; + + @override + String get clipboardEntryImage => 'Pilt'; + + @override + String clipboardOrigin(String name) { + return 'Kleepitud $name'; + } + + @override + String get localHistory => 'Paikne ajalugu'; + + @override + String get localHistoryEllipsis => 'Paikne ajalugu…'; + + @override + String get localHistoryDescription => + 'Näita salvestatud ja automaatseid paikseid versioone sellele dokumendile.'; + + @override + String get findLocalHistoryEllipsis => 'Leia kohalikust ajaloost…'; + + @override + String get findLocalHistoryDescription => + 'Otsi kohalises ajaloos ja versioonide sisu.'; + + @override + String get localHistoryNoDocuments => 'Mida ei ole kohalist ajaloos.'; + + @override + String get localHistoryNoRevisions => + 'Selle dokumendil pole kohalist ajaloos.'; + + @override + String get localHistoryNoMatches => + 'Ei leidunud kohalise ajalooga versioone.'; + + @override + String get localHistorySearchHint => 'Otsi versioonide sisu'; + + @override + String get localHistorySelectedRevision => 'Valitud versioon'; + + @override + String get localHistoryCurrentEditor => 'Praegune redaktoori sisu'; + + @override + String get localHistoryCurrentDisk => 'Praegune sisu faile'; + + @override + String get localHistoryMissingFile => 'Algne fail puudub.'; + + @override + String get localHistoryRestoreRevision => 'Taasta versioon'; + + @override + String get localHistoryRestoreOriginalLocation => 'Taasta algsele asukohale'; + + @override + String get localHistoryRestoreNewLocation => 'Taasta uuele asukohale…'; + + @override + String get localHistoryRestoreChange => 'Taasta muudatus'; + + @override + String get localHistoryRestoreUnavailable => + 'Seda muudatust ei saa taastada, kuna võrdlemine pole enam kehtiv.'; + + @override + String get localHistoryComparisonSimplified => + 'See suur võrdlemine on lihtsustatud. Eraldi muudatuste taastamine pole võimalik.'; + + @override + String localHistoryOlderChange(String text) { + return 'Vana versiooni muudatus: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Praeguse versiooni muudatus: $text'; + } + + @override + String get localHistoryDeleted => 'Hävitatud'; + + @override + String get localHistoryUntitled => 'Nimetamata'; + + @override + String get localHistoryClearDocument => 'Kaustade ajaloo kustutada'; + + @override + String get localHistoryClearAll => 'Kõik kohalikud ajaloo kustutada'; + + @override + String get localHistoryClearDocumentTitle => + 'Kas soovite kustutada dokumendi ajaloo?'; + + @override + String get localHistoryClearAllTitle => + 'Kas soovite kustutada kõik kohaliku aja?'; + + @override + String get localHistoryClearConfirmation => + 'See eemaldab püsivalt salvestatud versioonid. Seda toimingut ei saa tagasi pöörata.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Kohalik ajaloo indeks oli kahjustatud ja taastati tervete versioonide abil.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'See kohalik ajaloo salvestus kasutab tugimat format $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Kohalik ajaloo pole saadaval: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Salvestamine on välja lülitatud või see tee on eemaldatud. Lülitage salvestamine uuesti sisse enne taastamist.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Kohalik ajaloo ei suutnud salvestada teed: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Kohalik ajaloo ei suutnud säilitada kustutatud teed: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Valitud versioon on kadunud või kahjustatud.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Valitud versiooni ei saa lugeda: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Kohalik ajaloo ei suutnud seda versiooni salvestada: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Alusversioon'; + + @override + String get localHistoryReasonSaved => 'Salvestatud'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Automaatne kontrollpunkt'; + + @override + String get localHistoryReasonBeforeReload => 'Enne laadimist'; + + @override + String get localHistoryReasonBeforeDiscard => 'Enne kõrvaldamist'; + + @override + String get localHistoryReasonBeforeRestore => 'Enne taastamist'; + + @override + String get localHistoryReasonBeforeDelete => 'Enne kustutamist'; + + @override + String get localHistoryReasonExternalChange => 'Väline muudatus'; + + @override + String get settingsHistory => 'Ajalugu'; + + @override + String get settingsClipboardHistoryTitle => 'Salvesta klipi ajalugu'; + + @override + String get settingsClipboardHistoryDescription => + 'Hoiab salvestatud ja edukalt kopeeritud sisu ainult selle BusyMark sessiooni jaoks.'; + + @override + String get settingsLocalHistoryTitle => 'Salvesta kohalik ajalugu'; + + @override + String get settingsLocalHistoryDescription => + 'Salvesta dokumendi versioonid selle seadmele taastamiseks ja võrdlemiseks.'; + + @override + String get settingsHistoryCheckpoint => 'Automaatne kontrollpunkte intervall'; + + @override + String get settingsHistoryRetention => 'Säilitamise aeg'; + + @override + String get settingsHistoryStorage => 'Maksimaalne salvestamine'; + + @override + String get settingsHistoryExcludedPaths => 'Exkludeeritud teed'; + + @override + String get settingsHistoryExcludedPathsHint => 'Üks absoluutne tee per rida'; + + @override + String settingsSecondsValue(int value) { + return '$value sekundid'; + } + + @override + String settingsDaysValue(int value) { + return '$value päevad'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: sisesta arv vahemikus $minimum kuni $maximum.'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index afb09527..290b7a18 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3933,6 +3933,279 @@ class AppLocalizationsFa extends AppLocalizations { String get exportInvalidCss => 'یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.'; + @override + String get clipboardHistory => 'تاریخچه کپی و پیست'; + + @override + String get clipboardHistoryDescription => + 'باز کردن تاریخچه کپی و پیست مخصوص جلسه.'; + + @override + String get clipboardCurrent => 'کپی و پیست فعلی'; + + @override + String get clipboardCurrentExternal => 'کپی و پیست از برنامه دیگر'; + + @override + String clipboardDestination(String name) { + return 'کپی و پیست در $name'; + } + + @override + String get clipboardSessionOnly => 'فقط برای این جلسه BusyMark ذخیره می شود.'; + + @override + String get clipboardNoItems => 'تاریخچه کپی و پیست خالی است.'; + + @override + String get clipboardNoMatches => + 'هیچ آیتم کپی و پیستی با این جستجو مطابقت ندارد.'; + + @override + String get clipboardDisabled => + 'مجموعه تاریخچه کپی و پیست در تنظیمات غیرفعال است.'; + + @override + String get clipboardUnavailable => + 'محتوای کپی و پیست فعلی در دسترس نیست یا پشتیبانی نمی شود.'; + + @override + String get clipboardTooLarge => + 'این آیتم کپی شده خیلی بزرگ است و نمی‌توان آن را نگه داشت.'; + + @override + String get clipboardPastePlainText => 'به عنوان متن ساده کپی کنید'; + + @override + String get clipboardClearAll => 'تاریخچه کپی را پاک کنید'; + + @override + String get clipboardEntryText => 'متن'; + + @override + String get clipboardEntryRichText => 'متن غنی'; + + @override + String get clipboardEntryImage => 'تصویر'; + + @override + String clipboardOrigin(String name) { + return 'کپی شده از $name'; + } + + @override + String get localHistory => 'تاریخچه محلی'; + + @override + String get localHistoryEllipsis => 'تاریخچه محلی…'; + + @override + String get localHistoryDescription => + 'نسخه‌های ذخیره شده و خودکار محلی را برای سند فعلی نمایش دهید.'; + + @override + String get findLocalHistoryEllipsis => 'در تاریخ محلی جستجو کنید...'; + + @override + String get findLocalHistoryDescription => + 'اسناد و محتوای نسخه‌های تاریخ محلی را جستجو کنید.'; + + @override + String get localHistoryNoDocuments => 'هنوز هیچ سند دارای تاریخ محلی نیست.'; + + @override + String get localHistoryNoRevisions => + 'این سند دارای هیچ نسخه‌ای از تاریخ محلی نیست.'; + + @override + String get localHistoryNoMatches => + 'هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.'; + + @override + String get localHistorySearchHint => 'محتوای نسخه را جستجو کنید'; + + @override + String get localHistorySelectedRevision => 'نسخه انتخاب شده'; + + @override + String get localHistoryCurrentEditor => 'محتوای فعلی ویرایشگر'; + + @override + String get localHistoryCurrentDisk => 'محتوای فعلی روی دیسک'; + + @override + String get localHistoryMissingFile => 'فایل اصلی وجود ندارد.'; + + @override + String get localHistoryRestoreRevision => 'بازگرداندن نسخه'; + + @override + String get localHistoryRestoreOriginalLocation => 'بازگرداندن به محل اصلی'; + + @override + String get localHistoryRestoreNewLocation => 'بازگرداندن به محل جدید…'; + + @override + String get localHistoryRestoreChange => 'بازگرداندن تغییر'; + + @override + String get localHistoryRestoreUnavailable => + 'این تغییر قابل بازگشت نیست زیرا مقایسه دیگر معتبر نیست.'; + + @override + String get localHistoryComparisonSimplified => + 'این مقایسه بزرگ ساده شده است. بازگرداندن تغییرات فردی امکان پذیر نیست.'; + + @override + String localHistoryOlderChange(String text) { + return 'تغییر در نسخه قدیمی: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'تغییر در محتوا فعلی: $text'; + } + + @override + String get localHistoryDeleted => 'حذف شده'; + + @override + String get localHistoryUntitled => 'نام‌گذاری نشده'; + + @override + String get localHistoryClearDocument => 'پاک کردن تاریخچه این سند'; + + @override + String get localHistoryClearAll => 'پاک کردن تمام تاریخچه محلی'; + + @override + String get localHistoryClearDocumentTitle => 'پاک کردن تاریخچه سند؟'; + + @override + String get localHistoryClearAllTitle => 'پاک کردن تمام تاریخچه محلی؟'; + + @override + String get localHistoryClearConfirmation => + 'این به طور دائمی تغییرات ذخیره شده را حذف می کند. این عمل قابل لغو نیست.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'ایندکس تاریخچه محلی آسیب دیده و از تغییرات سالم بازسازی شده است.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'این فروشگاه تاریخچه محلی از فرمت پشتیبانی نشده $version استفاده می کند.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'تاریخچه محلی در دسترس نیست: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'ضبط غیرفعال است یا این مسیر حذف شده است. قبل از بازیابی، ضبط را دوباره فعال کنید.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'تاریخچه محلی نمی تواند تغییر مسیر را ثبت کند: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'تاریخ محلی نمی‌تواند مسیر حذف شده را حفظ کند: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'نسخه انتخاب شده وجود ندارد یا آسیب دیده است.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'نمی‌توان نسخه انتخاب شده را خواند: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'تاریخ محلی نمی‌تواند این نسخه را ثبت کند: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason، $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'خط مبنا'; + + @override + String get localHistoryReasonSaved => 'ذخیره شده'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'نقطه تیک کنترل خودکار'; + + @override + String get localHistoryReasonBeforeReload => 'قبل از بارگذاری مجدد'; + + @override + String get localHistoryReasonBeforeDiscard => 'قبل از حذف'; + + @override + String get localHistoryReasonBeforeRestore => 'قبل از بازیابی'; + + @override + String get localHistoryReasonBeforeDelete => 'قبل از حذف'; + + @override + String get localHistoryReasonExternalChange => 'تغییرات خارجی'; + + @override + String get settingsHistory => 'تاریخچه'; + + @override + String get settingsClipboardHistoryTitle => 'جمع آوری تاریخچه کپی شده'; + + @override + String get settingsClipboardHistoryDescription => + 'محتوای کپی شده و موفق به پیوستن را فقط برای این جلسه BusyMark نگه دارید.'; + + @override + String get settingsLocalHistoryTitle => 'ثبت تاریخچه محلی'; + + @override + String get settingsLocalHistoryDescription => + 'ذخیره ویرایش های سند در این دستگاه برای بازیابی و مقایسه.'; + + @override + String get settingsHistoryCheckpoint => 'فاصله زمانی خودکار'; + + @override + String get settingsHistoryRetention => 'دوره نگهداری'; + + @override + String get settingsHistoryStorage => 'حداکثر فضای ذخیره سازی'; + + @override + String get settingsHistoryExcludedPaths => 'مسیرهای حذف شده'; + + @override + String get settingsHistoryExcludedPathsHint => 'یک مسیر مطلق در هر خط'; + + @override + String settingsSecondsValue(int value) { + return '$value ثانیه'; + } + + @override + String settingsDaysValue(int value) { + return '$value روز'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value مگابایت'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: عددی بین $minimum و $maximum وارد کنید.'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 2b210940..7e7e6440 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3922,6 +3922,288 @@ class AppLocalizationsFr extends AppLocalizations { String get exportInvalidCss => 'Choisissez un fichier .css UTF-8 lisible de 256 KiB maximum, sans imports, URL de ressources, HTML ni CSS exécutable ou échappé.'; + @override + String get clipboardHistory => 'Historique de la presse-papiers'; + + @override + String get clipboardHistoryDescription => + 'Ouvrir l\'historique de la presse-papiers local à la session.'; + + @override + String get clipboardCurrent => 'Presse-papiers actuel'; + + @override + String get clipboardCurrentExternal => + 'Presse-papiers actuel provenant d\'une autre application'; + + @override + String clipboardDestination(String name) { + return 'Coller dans $name'; + } + + @override + String get clipboardSessionOnly => + 'Stocké uniquement pour cette session BusyMark.'; + + @override + String get clipboardNoItems => 'L\'historique de la presse-papiers est vide.'; + + @override + String get clipboardNoMatches => + 'Aucun élément de presse-papiers ne correspond à cette recherche.'; + + @override + String get clipboardDisabled => + 'La collection d\'Historique de la presse-papiers est désactivée dans les paramètres.'; + + @override + String get clipboardUnavailable => + 'Le contenu actuel du presse-papiers n\'est pas disponible ou non pris en charge.'; + + @override + String get clipboardTooLarge => + 'Cet élément du presse-papiers est trop grand pour être conservé.'; + + @override + String get clipboardPastePlainText => 'Coller en texte brut'; + + @override + String get clipboardClearAll => 'Effacer l\'historique du presse-papiers'; + + @override + String get clipboardEntryText => 'Texte'; + + @override + String get clipboardEntryRichText => 'Texte enrichi'; + + @override + String get clipboardEntryImage => 'Image du presse-papiers'; + + @override + String clipboardOrigin(String name) { + return 'Copié depuis $name'; + } + + @override + String get localHistory => 'Historique local'; + + @override + String get localHistoryEllipsis => 'Historique local…'; + + @override + String get localHistoryDescription => + 'Afficher les révisions locales enregistrées et automatiques pour le document actuel.'; + + @override + String get findLocalHistoryEllipsis => 'Rechercher dans l\'Historique Local…'; + + @override + String get findLocalHistoryDescription => + 'Rechercher les documents et le contenu des révisions dans l\'Historique Local.'; + + @override + String get localHistoryNoDocuments => 'Aucun document n\'a Historique Local.'; + + @override + String get localHistoryNoRevisions => + 'Ce document n\'a pas de révisions dans l\'Historique Local.'; + + @override + String get localHistoryNoMatches => + 'Aucune révision n\'est trouvée dans cette recherche.'; + + @override + String get localHistorySearchHint => 'Rechercher le contenu des révisions'; + + @override + String get localHistorySelectedRevision => 'Révision sélectionnée'; + + @override + String get localHistoryCurrentEditor => 'Contenu de l\'éditeur actuel'; + + @override + String get localHistoryCurrentDisk => 'Contenu actuel sur le disque'; + + @override + String get localHistoryMissingFile => 'Le fichier original est manquant.'; + + @override + String get localHistoryRestoreRevision => 'Restaurer la version'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Restaurer à l\'emplacement original'; + + @override + String get localHistoryRestoreNewLocation => + 'Restaurer à un nouvel emplacement…'; + + @override + String get localHistoryRestoreChange => 'Restaurer la modification'; + + @override + String get localHistoryRestoreUnavailable => + 'Cette modification ne peut pas être restaurée car la comparaison n\'est plus à jour.'; + + @override + String get localHistoryComparisonSimplified => + 'Cette comparaison importante est simplifiée. Restaurer des modifications individuelles n\'est pas possible.'; + + @override + String localHistoryOlderChange(String text) { + return 'Modification de la version plus ancienne: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Modification du contenu actuel: $text'; + } + + @override + String get localHistoryDeleted => 'Supprimé'; + + @override + String get localHistoryUntitled => 'Sans titre'; + + @override + String get localHistoryClearDocument => + 'Effacer l\'historique de ce document'; + + @override + String get localHistoryClearAll => 'Effacer l\'historique local'; + + @override + String get localHistoryClearDocumentTitle => + 'Effacer l\'historique du document ?'; + + @override + String get localHistoryClearAllTitle => 'Effacer l\'historique local ?'; + + @override + String get localHistoryClearConfirmation => + 'Cela supprime définitivement les révisions stockées. Cette action ne peut pas être annulée.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'L\'index de l\'historique local a été endommagé et reconstruit à partir de révisions intactes.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Ce magasin d\'historique local utilise un format non pris en charge $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'L\'historique local n\'est pas disponible : $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'L\'enregistrement est désactivé ou ce chemin est exclu. Réactivez l\'enregistrement avant de restaurer.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'L\'historique local n\'a pas pu enregistrer le changement de chemin : $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'L\'historique local n\'a pas pu conserver le chemin supprimé: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'La révision sélectionnée est manquante ou corrompue.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'La révision sélectionnée ne peut pas être lue: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'L\'historique local n\'a pas pu capturer cette révision: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Base'; + + @override + String get localHistoryReasonSaved => 'Sauvegardé'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Point de contrôle automatique'; + + @override + String get localHistoryReasonBeforeReload => 'Avant rechargement'; + + @override + String get localHistoryReasonBeforeDiscard => 'Avant suppression'; + + @override + String get localHistoryReasonBeforeRestore => 'Avant la restauration'; + + @override + String get localHistoryReasonBeforeDelete => 'Avant la suppression'; + + @override + String get localHistoryReasonExternalChange => 'Changement externe'; + + @override + String get settingsHistory => 'Historique'; + + @override + String get settingsClipboardHistoryTitle => + 'Collecter l\'historique du presse-papier'; + + @override + String get settingsClipboardHistoryDescription => + 'Conserver le contenu copié et correctement collé uniquement pour cette session BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Enregistrer l\'historique local'; + + @override + String get settingsLocalHistoryDescription => + 'Stocker les révisions du document sur cet appareil pour la récupération et la comparaison.'; + + @override + String get settingsHistoryCheckpoint => + 'Intervalle de sauvegarde automatique'; + + @override + String get settingsHistoryRetention => 'Période de conservation'; + + @override + String get settingsHistoryStorage => 'Stockage maximal'; + + @override + String get settingsHistoryExcludedPaths => 'Chemins exclus'; + + @override + String get settingsHistoryExcludedPathsHint => 'Un chemin absolu par ligne'; + + @override + String settingsSecondsValue(int value) { + return '$value secondes'; + } + + @override + String settingsDaysValue(int value) { + return '$value jours'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field : saisissez un nombre entre $minimum et $maximum.'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 8c532cfc..25fe787f 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3881,6 +3881,282 @@ class AppLocalizationsHi extends AppLocalizations { String get exportInvalidCss => '256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।'; + @override + String get clipboardHistory => 'क्लिपबोर्ड इतिहास'; + + @override + String get clipboardHistoryDescription => + 'सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।'; + + @override + String get clipboardCurrent => 'वर्तमान क्लिपबोर्ड'; + + @override + String get clipboardCurrentExternal => + 'किसी अन्य एप्लिकेशन से वर्तमान क्लिपबोर्ड'; + + @override + String clipboardDestination(String name) { + return '$name में पेस्ट करें'; + } + + @override + String get clipboardSessionOnly => 'केवल इस BusyMark सत्र के लिए संग्रहीत।'; + + @override + String get clipboardNoItems => 'क्लिपबोर्ड इतिहास खाली है।'; + + @override + String get clipboardNoMatches => + 'कोई क्लिपबोर्ड आइटम इस खोज से मेल नहीं खाता।'; + + @override + String get clipboardDisabled => + 'क्लिपबोर्ड इतिहास संग्रह सेटिंग्स में अक्षम है।'; + + @override + String get clipboardUnavailable => + 'वर्तमान क्लिपबोर्ड सामग्री अनुपलब्ध या समर्थित नहीं है।'; + + @override + String get clipboardTooLarge => + 'यह क्लिपबोर्ड आइटम बहुत बड़ा है और इसे बनाए रखना संभव नहीं है।'; + + @override + String get clipboardPastePlainText => 'सादे टेक्स्ट के रूप में पेस्ट करें'; + + @override + String get clipboardClearAll => 'क्लिपबोर्ड इतिहास साफ़ करें'; + + @override + String get clipboardEntryText => 'टेक्स्ट'; + + @override + String get clipboardEntryRichText => 'रिच टेक्स्ट'; + + @override + String get clipboardEntryImage => 'इमेज'; + + @override + String clipboardOrigin(String name) { + return '$name से कॉपी किया गया'; + } + + @override + String get localHistory => 'स्थानीय इतिहास'; + + @override + String get localHistoryEllipsis => 'स्थानीय इतिहास…'; + + @override + String get localHistoryDescription => + 'वर्तमान दस्तावेज़ के लिए सहेजे गए और स्वचालित स्थानीय संशोधनों को दिखाएं।'; + + @override + String get findLocalHistoryEllipsis => 'स्थानीय इतिहास में खोजें...'; + + @override + String get findLocalHistoryDescription => + 'स्थानीय इतिहास में दस्तावेज़ और संशोधन सामग्री खोजें।'; + + @override + String get localHistoryNoDocuments => + 'अभी तक किसी भी दस्तावेज़ में स्थानीय इतिहास नहीं है।'; + + @override + String get localHistoryNoRevisions => + 'इस दस्तावेज़ में स्थानीय इतिहास में कोई संशोधन नहीं है।'; + + @override + String get localHistoryNoMatches => + 'यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।'; + + @override + String get localHistorySearchHint => 'संशोधन सामग्री खोजें'; + + @override + String get localHistorySelectedRevision => 'चयनित संशोधन'; + + @override + String get localHistoryCurrentEditor => 'वर्तमान संपादक सामग्री'; + + @override + String get localHistoryCurrentDisk => 'डिस्क पर वर्तमान सामग्री'; + + @override + String get localHistoryMissingFile => 'मूल फ़ाइल गायब है।'; + + @override + String get localHistoryRestoreRevision => 'पिछली स्थिति को पुनर्स्थापित करें'; + + @override + String get localHistoryRestoreOriginalLocation => + 'मूल स्थान पर पुनर्स्थापित करें'; + + @override + String get localHistoryRestoreNewLocation => 'नए स्थान पर पुनर्स्थापित करें…'; + + @override + String get localHistoryRestoreChange => 'परिवर्तन को पुनर्स्थापित करें'; + + @override + String get localHistoryRestoreUnavailable => + 'इस परिवर्तन को पुनर्स्थापित नहीं किया जा सकता क्योंकि तुलना अब अद्यतित नहीं है।'; + + @override + String get localHistoryComparisonSimplified => + 'यह बड़ा परिवर्तन सरलीकृत है। व्यक्तिगत परिवर्तनों को पुनर्स्थापित करना संभव नहीं है।'; + + @override + String localHistoryOlderChange(String text) { + return 'पुराने संस्करण में परिवर्तन: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'वर्तमान सामग्री में परिवर्तन: $text'; + } + + @override + String get localHistoryDeleted => 'हटा दिया गया'; + + @override + String get localHistoryUntitled => 'शीर्षकहीन'; + + @override + String get localHistoryClearDocument => 'इस दस्तावेज़ का इतिहास साफ़ करें'; + + @override + String get localHistoryClearAll => 'सभी स्थानीय इतिहास साफ़ करें'; + + @override + String get localHistoryClearDocumentTitle => 'दस्तावेज़ का इतिहास साफ़ करना?'; + + @override + String get localHistoryClearAllTitle => 'सभी स्थानीय इतिहास साफ़ करना?'; + + @override + String get localHistoryClearConfirmation => + 'यह स्थायी रूप से संग्रहीत संशोधनों को हटा देता है। इस कार्रवाई को पूर्ववत नहीं किया जा सकता।'; + + @override + String get localHistoryWarningIndexRebuilt => + 'स्थानीय इतिहास इंडेक्स क्षतिग्रस्त था और बरकरार संशोधनों से फिर से बनाया गया था।'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'यह स्थानीय इतिहास स्टोर समर्थित प्रारूप $version का उपयोग करता है।'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'स्थानीय इतिहास उपलब्ध नहीं: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'रिकॉर्डिंग अक्षम है या यह पथ बाहर रखा गया है। पुनर्स्थापित करने से पहले रिकॉर्डिंग को फिर से सक्षम करें।'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'स्थानीय इतिहास ने पथ परिवर्तन रिकॉर्ड नहीं किया: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'स्थानीय इतिहास को हटाए गए पथ को सहेजने में असमर्थ: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'चयनित संशोधन गायब है या क्षतिग्रस्त है।'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'चयनित संशोधन को नहीं पढ़ा जा सकता: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'स्थानीय इतिहास इस संशोधन को कैप्चर नहीं कर सका: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'बेसलाइन'; + + @override + String get localHistoryReasonSaved => 'सहेजा गया'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'स्वचालित चेकपॉइंट'; + + @override + String get localHistoryReasonBeforeReload => 'रीलोड करने से पहले'; + + @override + String get localHistoryReasonBeforeDiscard => 'त्यागने से पहले'; + + @override + String get localHistoryReasonBeforeRestore => 'पुनर्स्थापना से पहले'; + + @override + String get localHistoryReasonBeforeDelete => 'हटाने से पहले'; + + @override + String get localHistoryReasonExternalChange => 'बाहरी परिवर्तन'; + + @override + String get settingsHistory => 'इतिहास'; + + @override + String get settingsClipboardHistoryTitle => 'क्लिपबोर्ड इतिहास एकत्र करें'; + + @override + String get settingsClipboardHistoryDescription => + 'केवल इस BusyMark सत्र के लिए कॉपी किए गए और सफलतापूर्वक पेस्ट किए गए सामग्री को बनाए रखें।'; + + @override + String get settingsLocalHistoryTitle => 'स्थानीय इतिहास रिकॉर्ड करें'; + + @override + String get settingsLocalHistoryDescription => + 'पुनर्प्राप्ति और तुलना के लिए इस डिवाइस पर दस्तावेज़ के संस्करणों को सहेजें।'; + + @override + String get settingsHistoryCheckpoint => 'स्वचालित चेकपॉइंट अंतराल'; + + @override + String get settingsHistoryRetention => 'प्रतिधारण आयु'; + + @override + String get settingsHistoryStorage => 'अधिकतम संग्रहण'; + + @override + String get settingsHistoryExcludedPaths => 'बाहर किए गए पथ'; + + @override + String get settingsHistoryExcludedPathsHint => 'प्रति पंक्ति एक पूर्ण पथ'; + + @override + String settingsSecondsValue(int value) { + return '$value सेकंड'; + } + + @override + String settingsDaysValue(int value) { + return '$value दिन'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: $minimum से $maximum के बीच संख्या दर्ज करें।'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 95ac534c..f5bf3436 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -3896,6 +3896,279 @@ class AppLocalizationsId extends AppLocalizations { String get exportInvalidCss => 'Pilih berkas .css UTF-8 yang dapat dibaca hingga 256 KiB, tanpa impor, URL sumber daya, HTML, atau CSS yang dapat dieksekusi atau memakai escape.'; + @override + String get clipboardHistory => 'Sejarah Tempel'; + + @override + String get clipboardHistoryDescription => 'Buka riwayat tempel lokal sesi.'; + + @override + String get clipboardCurrent => 'Tempel saat ini'; + + @override + String get clipboardCurrentExternal => 'Tempel saat ini dari aplikasi lain'; + + @override + String clipboardDestination(String name) { + return 'Tempel ke $name'; + } + + @override + String get clipboardSessionOnly => 'Hanya disimpan untuk sesi BusyMark ini.'; + + @override + String get clipboardNoItems => 'Riwayat tempel kosong.'; + + @override + String get clipboardNoMatches => + 'Tidak ada item tempel yang cocok dengan pencarian ini.'; + + @override + String get clipboardDisabled => + 'Koleksi Riwayat Tempel dinonaktifkan di Pengaturan.'; + + @override + String get clipboardUnavailable => + 'Konten tempel saat ini tidak tersedia atau tidak didukung.'; + + @override + String get clipboardTooLarge => + 'Item clipboard ini terlalu besar untuk disimpan.'; + + @override + String get clipboardPastePlainText => 'Tempel sebagai Teks Biasa'; + + @override + String get clipboardClearAll => 'Hapus Riwayat Clipboard'; + + @override + String get clipboardEntryText => 'Teks'; + + @override + String get clipboardEntryRichText => 'Teks kaya'; + + @override + String get clipboardEntryImage => 'Gambar'; + + @override + String clipboardOrigin(String name) { + return 'Disalin dari $name'; + } + + @override + String get localHistory => 'Riwayat Lokal'; + + @override + String get localHistoryEllipsis => 'Riwayat Lokal…'; + + @override + String get localHistoryDescription => + 'Tampilkan revisi lokal yang disimpan dan otomatis untuk dokumen saat ini.'; + + @override + String get findLocalHistoryEllipsis => 'Cari di Sejarah Lokal…'; + + @override + String get findLocalHistoryDescription => + 'Cari dokumen dan isi revisi dalam Sejarah Lokal.'; + + @override + String get localHistoryNoDocuments => + 'Tidak ada dokumen yang memiliki Sejarah Lokal.'; + + @override + String get localHistoryNoRevisions => + 'Dokumen ini tidak memiliki revisi Sejarah Lokal.'; + + @override + String get localHistoryNoMatches => + 'Tidak ada revisi Sejarah Lokal yang cocok dengan pencarian ini.'; + + @override + String get localHistorySearchHint => 'Cari isi revisi'; + + @override + String get localHistorySelectedRevision => 'Revisi yang dipilih'; + + @override + String get localHistoryCurrentEditor => 'Konten editor saat ini'; + + @override + String get localHistoryCurrentDisk => 'Konten saat ini di disk'; + + @override + String get localHistoryMissingFile => 'File asli hilang.'; + + @override + String get localHistoryRestoreRevision => 'Kembalikan Revisi'; + + @override + String get localHistoryRestoreOriginalLocation => 'Kembalikan ke Lokasi Asli'; + + @override + String get localHistoryRestoreNewLocation => 'Kembalikan ke Lokasi Baru…'; + + @override + String get localHistoryRestoreChange => 'Kembalikan Perubahan'; + + @override + String get localHistoryRestoreUnavailable => + 'Perubahan ini tidak dapat dikembalikan karena perbandingan tidak lagi berlaku.'; + + @override + String get localHistoryComparisonSimplified => + 'Perbandingan besar ini disederhanakan. Kembalikan perubahan individual tidak tersedia.'; + + @override + String localHistoryOlderChange(String text) { + return 'Perubahan revisi yang lebih lama: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Perubahan konten saat ini: $text'; + } + + @override + String get localHistoryDeleted => 'Dihapus'; + + @override + String get localHistoryUntitled => 'Tanpa Judul'; + + @override + String get localHistoryClearDocument => 'Hapus Riwayat Dokumen Ini'; + + @override + String get localHistoryClearAll => 'Hapus Riwayat Lokal Seluruhnya'; + + @override + String get localHistoryClearDocumentTitle => 'Hapus riwayat dokumen?'; + + @override + String get localHistoryClearAllTitle => 'Hapus riwayat lokal seluruhnya?'; + + @override + String get localHistoryClearConfirmation => + 'Ini secara permanen menghapus revisi yang tersimpan. Tindakan ini tidak dapat dibatalkan.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Indeks Riwayat Lokal rusak dan dibangun kembali dari revisi yang utuh.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Penyimpanan Riwayat Lokal menggunakan format yang tidak didukung $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Riwayat Lokal tidak tersedia: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Perekaman dinonaktifkan atau jalur ini dikecualikan. Aktifkan kembali perekaman sebelum memulihkan.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Riwayat Lokal tidak dapat mencatat perubahan jalur: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Sejarah Lokal tidak dapat menyimpan jalur yang dihapus: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Revisi yang dipilih hilang atau rusak.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Revisi yang dipilih tidak dapat dibaca: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Sejarah Lokal tidak dapat menangkap revisi ini: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Versi dasar'; + + @override + String get localHistoryReasonSaved => 'Tersimpan'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Checkpoint otomatis'; + + @override + String get localHistoryReasonBeforeReload => 'Sebelum memuat ulang'; + + @override + String get localHistoryReasonBeforeDiscard => 'Sebelum membuang'; + + @override + String get localHistoryReasonBeforeRestore => 'Sebelum pemulihan'; + + @override + String get localHistoryReasonBeforeDelete => 'Sebelum menghapus'; + + @override + String get localHistoryReasonExternalChange => 'Perubahan eksternal'; + + @override + String get settingsHistory => 'Riwayat'; + + @override + String get settingsClipboardHistoryTitle => 'Kumpulkan Riwayat Klip'; + + @override + String get settingsClipboardHistoryDescription => + 'Simpan konten yang disalin dan berhasil ditempel hanya untuk sesi BusyMark ini.'; + + @override + String get settingsLocalHistoryTitle => 'Rekam Riwayat Lokal'; + + @override + String get settingsLocalHistoryDescription => + 'Simpan revisi dokumen pada perangkat ini untuk pemulihan dan perbandingan.'; + + @override + String get settingsHistoryCheckpoint => 'Interval checkpoint otomatis'; + + @override + String get settingsHistoryRetention => 'Masa simpan'; + + @override + String get settingsHistoryStorage => 'Penyimpanan maksimum'; + + @override + String get settingsHistoryExcludedPaths => 'Jalur yang dikecualikan'; + + @override + String get settingsHistoryExcludedPathsHint => 'Satu jalur absolut per baris'; + + @override + String settingsSecondsValue(int value) { + return '$value detik'; + } + + @override + String settingsDaysValue(int value) { + return '$value hari'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: masukkan angka antara $minimum dan $maximum.'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index dad2fc9e..28d2e8f2 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3918,6 +3918,289 @@ class AppLocalizationsIt extends AppLocalizations { String get exportInvalidCss => 'Scegli un file .css UTF-8 leggibile fino a 256 KiB, senza importazioni, URL di risorse, HTML o CSS eseguibile o con escape.'; + @override + String get clipboardHistory => 'Cronologia della Copia'; + + @override + String get clipboardHistoryDescription => + 'Apri la cronologia della copia locale della sessione.'; + + @override + String get clipboardCurrent => 'Copia corrente'; + + @override + String get clipboardCurrentExternal => + 'Copia corrente da un\'altra applicazione'; + + @override + String clipboardDestination(String name) { + return 'Incolla in $name'; + } + + @override + String get clipboardSessionOnly => + 'Memorizzata solo per questa sessione BusyMark.'; + + @override + String get clipboardNoItems => 'La cronologia della copia è vuota.'; + + @override + String get clipboardNoMatches => + 'Nessun elemento della copia corrisponde a questa ricerca.'; + + @override + String get clipboardDisabled => + 'La collezione Cronologia della Copia è disabilitata nelle Impostazioni.'; + + @override + String get clipboardUnavailable => + 'Il contenuto della copia corrente non è disponibile o non è supportato.'; + + @override + String get clipboardTooLarge => + 'Questo elemento del segnalibro è troppo grande per essere conservato.'; + + @override + String get clipboardPastePlainText => 'Incolla come testo semplice'; + + @override + String get clipboardClearAll => 'Cancella la cronologia del segnalibro'; + + @override + String get clipboardEntryText => 'Testo'; + + @override + String get clipboardEntryRichText => 'Testo ricco'; + + @override + String get clipboardEntryImage => 'Immagine'; + + @override + String clipboardOrigin(String name) { + return 'Copiato da $name'; + } + + @override + String get localHistory => 'Cronologia locale'; + + @override + String get localHistoryEllipsis => 'Cronologia locale…'; + + @override + String get localHistoryDescription => + 'Mostra le revisioni locali salvate e automatiche per il documento corrente.'; + + @override + String get findLocalHistoryEllipsis => 'Trova nella cronologia locale…'; + + @override + String get findLocalHistoryDescription => + 'Cerca documenti e contenuti delle revisioni nella cronologia locale.'; + + @override + String get localHistoryNoDocuments => + 'Nessun documento ha ancora una cronologia locale.'; + + @override + String get localHistoryNoRevisions => + 'Questo documento non ha revisioni nella cronologia locale.'; + + @override + String get localHistoryNoMatches => + 'Nessuna revisione corrisponde a questa ricerca.'; + + @override + String get localHistorySearchHint => 'Cerca i contenuti delle revisioni'; + + @override + String get localHistorySelectedRevision => 'Revisione selezionata'; + + @override + String get localHistoryCurrentEditor => 'Contenuto dell\'editor corrente'; + + @override + String get localHistoryCurrentDisk => 'Contenuto corrente sul disco'; + + @override + String get localHistoryMissingFile => 'Il file originale è mancante.'; + + @override + String get localHistoryRestoreRevision => 'Ripristina Versione'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Ripristina alla Posizione Originale'; + + @override + String get localHistoryRestoreNewLocation => + 'Ripristina alla Nuova Posizione…'; + + @override + String get localHistoryRestoreChange => 'Ripristina Modifica'; + + @override + String get localHistoryRestoreUnavailable => + 'Questa modifica non può essere ripristinata perché il confronto non è più valido.'; + + @override + String get localHistoryComparisonSimplified => + 'Questo confronto ampio è semplificato. Il ripristino di singole modifiche non è disponibile.'; + + @override + String localHistoryOlderChange(String text) { + return 'Modifica di una versione precedente: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Modifica del contenuto corrente: $text'; + } + + @override + String get localHistoryDeleted => 'Eliminato'; + + @override + String get localHistoryUntitled => 'Senza Titolo'; + + @override + String get localHistoryClearDocument => + 'Cancella la cronologia di questo documento'; + + @override + String get localHistoryClearAll => 'Cancella tutta la cronologia locale'; + + @override + String get localHistoryClearDocumentTitle => + 'Cancella la cronologia del documento?'; + + @override + String get localHistoryClearAllTitle => + 'Cancella tutta la cronologia locale?'; + + @override + String get localHistoryClearConfirmation => + 'Questo rimuove permanentemente le revisioni memorizzate. Questa azione non può essere annullata.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'L\'indice della cronologia locale è stato danneggiato e ricostruito da revisioni intatte.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Questo archivio della cronologia locale utilizza un formato non supportato $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'La cronologia locale non è disponibile: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'La registrazione è disabilitata o questo percorso è escluso. Riattiva la registrazione prima di ripristinare.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'La cronologia locale non è riuscita a registrare la modifica del percorso: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'La cronologia locale non è riuscita a conservare il percorso eliminato: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'La revisione selezionata è mancante o danneggiata.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'La revisione selezionata non può essere letta: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'La cronologia locale non è riuscita a catturare questa revisione: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Versione iniziale'; + + @override + String get localHistoryReasonSaved => 'Salvato'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Controllo automatico'; + + @override + String get localHistoryReasonBeforeReload => 'Prima del ricaricamento'; + + @override + String get localHistoryReasonBeforeDiscard => 'Prima di eliminare'; + + @override + String get localHistoryReasonBeforeRestore => 'Prima del ripristino'; + + @override + String get localHistoryReasonBeforeDelete => 'Prima della cancellazione'; + + @override + String get localHistoryReasonExternalChange => 'Modifica esterna'; + + @override + String get settingsHistory => 'Cronologia'; + + @override + String get settingsClipboardHistoryTitle => + 'Raccogli la cronologia della clipboard'; + + @override + String get settingsClipboardHistoryDescription => + 'Mantieni il contenuto copiato e incollato per questa sessione BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Registra la cronologia locale'; + + @override + String get settingsLocalHistoryDescription => + 'Salva le revisioni del documento su questo dispositivo per il recupero e il confronto.'; + + @override + String get settingsHistoryCheckpoint => 'Intervallo di checkpoint automatico'; + + @override + String get settingsHistoryRetention => 'Periodo di conservazione'; + + @override + String get settingsHistoryStorage => 'Massimo spazio di archiviazione'; + + @override + String get settingsHistoryExcludedPaths => 'Percorsi esclusi'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Un percorso assoluto per riga'; + + @override + String settingsSecondsValue(int value) { + return '$value secondi'; + } + + @override + String settingsDaysValue(int value) { + return '$value giorni'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: inserisci un numero tra $minimum e $maximum.'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index ec9de0d9..063bbf8e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -3786,6 +3786,268 @@ class AppLocalizationsJa extends AppLocalizations { String get exportInvalidCss => '読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。'; + @override + String get clipboardHistory => 'コピーボード履歴'; + + @override + String get clipboardHistoryDescription => 'セッション固有のコピーボード履歴を開く。'; + + @override + String get clipboardCurrent => '現在のコピーボード'; + + @override + String get clipboardCurrentExternal => '別のアプリケーションからの現在のコピーボード'; + + @override + String clipboardDestination(String name) { + return '$name に貼り付け'; + } + + @override + String get clipboardSessionOnly => 'このBusyMarkセッションのみに保存されます。'; + + @override + String get clipboardNoItems => 'コピーボード履歴は空です。'; + + @override + String get clipboardNoMatches => 'この検索に一致するコピーボードの項目はありません。'; + + @override + String get clipboardDisabled => 'コピーボード履歴のコレクションは設定で無効になっています。'; + + @override + String get clipboardUnavailable => '現在のコピーボードの内容は利用できませんまたはサポートされていません。'; + + @override + String get clipboardTooLarge => 'このクリップボードの項目は大きすぎて保持できません。'; + + @override + String get clipboardPastePlainText => 'テキストとして貼り付け'; + + @override + String get clipboardClearAll => 'クリップボードの履歴をクリア'; + + @override + String get clipboardEntryText => 'テキスト'; + + @override + String get clipboardEntryRichText => '豊富なテキスト'; + + @override + String get clipboardEntryImage => '画像'; + + @override + String clipboardOrigin(String name) { + return '$name からコピー'; + } + + @override + String get localHistory => 'ローカル履歴'; + + @override + String get localHistoryEllipsis => 'ローカル履歴…'; + + @override + String get localHistoryDescription => '現在のドキュメントの保存されたおよび自動ローカルのバージョンを表示します。'; + + @override + String get findLocalHistoryEllipsis => 'ローカル履歴で検索…'; + + @override + String get findLocalHistoryDescription => 'ローカル履歴のドキュメントとリビジョンの内容を検索します。'; + + @override + String get localHistoryNoDocuments => 'まだローカル履歴を持つドキュメントはありません。'; + + @override + String get localHistoryNoRevisions => 'このドキュメントにはローカル履歴の修正はありません。'; + + @override + String get localHistoryNoMatches => 'この検索に一致するローカル履歴の修正はありません。'; + + @override + String get localHistorySearchHint => 'リビジョンの内容を検索'; + + @override + String get localHistorySelectedRevision => '選択されたリビジョン'; + + @override + String get localHistoryCurrentEditor => '現在の編集内容'; + + @override + String get localHistoryCurrentDisk => 'ディスク上の現在の内容'; + + @override + String get localHistoryMissingFile => '元のファイルが見つかりません。'; + + @override + String get localHistoryRestoreRevision => '変更を復元'; + + @override + String get localHistoryRestoreOriginalLocation => '元の位置に復元'; + + @override + String get localHistoryRestoreNewLocation => '新しい位置に復元…'; + + @override + String get localHistoryRestoreChange => '変更を復元'; + + @override + String get localHistoryRestoreUnavailable => 'この変更は復元できません。比較が最新ではありません。'; + + @override + String get localHistoryComparisonSimplified => + 'この大きな比較は簡略化されています。個別の変更の復元はできません。'; + + @override + String localHistoryOlderChange(String text) { + return '以前の変更: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return '現在の変更: $text'; + } + + @override + String get localHistoryDeleted => '削除'; + + @override + String get localHistoryUntitled => 'タイトルなし'; + + @override + String get localHistoryClearDocument => 'このドキュメントの履歴をクリアする'; + + @override + String get localHistoryClearAll => 'すべてのローカル履歴をクリアする'; + + @override + String get localHistoryClearDocumentTitle => 'ドキュメントの履歴をクリアしますか?'; + + @override + String get localHistoryClearAllTitle => 'すべてのローカル履歴をクリアしますか?'; + + @override + String get localHistoryClearConfirmation => + 'これにより、保存されたバージョンが完全に削除されます。この操作は取り消せません。'; + + @override + String get localHistoryWarningIndexRebuilt => + 'ローカル履歴のインデックスが破損し、正常なバージョンから再構築されました。'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'このローカル履歴ストアはサポートされていない形式 $version を使用しています。'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'ローカル履歴は利用できません: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + '録音が無効になっているか、このパスは除外されています。復元する前に録音を再度有効にしてください。'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'ローカル履歴がパスの変更を記録できませんでした: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'ローカル履歴は削除されたパスを保持できません: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => '選択されたバージョンが存在しないか破損しています。'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return '選択されたバージョンを読み取ることができません: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'ローカル履歴は、このバージョンをキャプチャできません: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason・$timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'ベースライン'; + + @override + String get localHistoryReasonSaved => '保存'; + + @override + String get localHistoryReasonAutomaticCheckpoint => '自動チェックポイント'; + + @override + String get localHistoryReasonBeforeReload => 'リロード前'; + + @override + String get localHistoryReasonBeforeDiscard => '破棄前'; + + @override + String get localHistoryReasonBeforeRestore => '復元前'; + + @override + String get localHistoryReasonBeforeDelete => '削除前'; + + @override + String get localHistoryReasonExternalChange => '外部からの変更'; + + @override + String get settingsHistory => '履歴'; + + @override + String get settingsClipboardHistoryTitle => 'コピー&ペースト履歴を収集'; + + @override + String get settingsClipboardHistoryDescription => + 'このBusyMarkセッションでのコピー&ペーストされたコンテンツのみを保持。'; + + @override + String get settingsLocalHistoryTitle => 'ローカル履歴を記録'; + + @override + String get settingsLocalHistoryDescription => + 'このデバイスにドキュメントのバージョンを保存し、復元および比較。'; + + @override + String get settingsHistoryCheckpoint => '自動チェックポイント間隔'; + + @override + String get settingsHistoryRetention => '保管期間'; + + @override + String get settingsHistoryStorage => '最大ストレージ'; + + @override + String get settingsHistoryExcludedPaths => '除外されたパス'; + + @override + String get settingsHistoryExcludedPathsHint => '1行に1つの絶対パス'; + + @override + String settingsSecondsValue(int value) { + return '$value 秒'; + } + + @override + String settingsDaysValue(int value) { + return '$value 日'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: $minimum から $maximum の数値を入力してください。'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 89a028a0..0c149462 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -3782,6 +3782,268 @@ class AppLocalizationsKo extends AppLocalizations { String get exportInvalidCss => '가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.'; + @override + String get clipboardHistory => '클립보드 기록'; + + @override + String get clipboardHistoryDescription => '세션별 클립보드 기록을 엽니다.'; + + @override + String get clipboardCurrent => '현재 클립보드'; + + @override + String get clipboardCurrentExternal => '다른 애플리케이션의 현재 클립보드'; + + @override + String clipboardDestination(String name) { + return '$name에 붙여넣기'; + } + + @override + String get clipboardSessionOnly => '이 BusyMark 세션에만 저장됩니다.'; + + @override + String get clipboardNoItems => '클립보드 기록이 비어 있습니다.'; + + @override + String get clipboardNoMatches => '이 검색과 일치하는 클립보드 항목이 없습니다.'; + + @override + String get clipboardDisabled => '클립보드 기록 컬렉션이 설정에서 비활성화되었습니다.'; + + @override + String get clipboardUnavailable => '현재 클립보드 내용은 사용할 수 없거나 지원되지 않습니다.'; + + @override + String get clipboardTooLarge => '이 클립보드 항목은 저장할 수 없을 정도로 큽니다.'; + + @override + String get clipboardPastePlainText => '일반 텍스트로 붙여넣기'; + + @override + String get clipboardClearAll => '클립보드 기록 지우기'; + + @override + String get clipboardEntryText => '텍스트'; + + @override + String get clipboardEntryRichText => '풍부한 텍스트'; + + @override + String get clipboardEntryImage => '이미지'; + + @override + String clipboardOrigin(String name) { + return '$name에서 복사됨'; + } + + @override + String get localHistory => '로컬 기록'; + + @override + String get localHistoryEllipsis => '로컬 기록…'; + + @override + String get localHistoryDescription => '현재 문서에 저장된 자동 로컬 변경 사항을 표시합니다.'; + + @override + String get findLocalHistoryEllipsis => '로컬 히스토리에서 찾기…'; + + @override + String get findLocalHistoryDescription => '로컬 히스토리 문서 및 수정 내용 검색.'; + + @override + String get localHistoryNoDocuments => '아직 로컬 히스토리가 있는 문서는 없습니다.'; + + @override + String get localHistoryNoRevisions => '이 문서는 로컬 히스토리 수정 사항이 없습니다.'; + + @override + String get localHistoryNoMatches => '이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.'; + + @override + String get localHistorySearchHint => '수정 내용 검색'; + + @override + String get localHistorySelectedRevision => '선택된 수정'; + + @override + String get localHistoryCurrentEditor => '현재 편집 내용'; + + @override + String get localHistoryCurrentDisk => '디스크에 있는 현재 내용'; + + @override + String get localHistoryMissingFile => '원본 파일이 없습니다.'; + + @override + String get localHistoryRestoreRevision => '이전 버전 복원'; + + @override + String get localHistoryRestoreOriginalLocation => '원래 위치로 복원'; + + @override + String get localHistoryRestoreNewLocation => '새로운 위치로 복원…'; + + @override + String get localHistoryRestoreChange => '변경 사항 복원'; + + @override + String get localHistoryRestoreUnavailable => '이 변경 사항은 더 이상 비교가 불가능합니다.'; + + @override + String get localHistoryComparisonSimplified => + '이 큰 비교는 단순화되었습니다. 개별 변경 사항 복원은 불가능합니다.'; + + @override + String localHistoryOlderChange(String text) { + return '이전 버전의 변경: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return '현재 콘텐츠의 변경: $text'; + } + + @override + String get localHistoryDeleted => '삭제됨'; + + @override + String get localHistoryUntitled => '제목 없음'; + + @override + String get localHistoryClearDocument => '이 문서의 기록 삭제'; + + @override + String get localHistoryClearAll => '모든 로컬 기록 삭제'; + + @override + String get localHistoryClearDocumentTitle => '문서 기록 삭제?'; + + @override + String get localHistoryClearAllTitle => '모든 로컬 기록 삭제?'; + + @override + String get localHistoryClearConfirmation => + '이로 인해 저장된 버전이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.'; + + @override + String get localHistoryWarningIndexRebuilt => + '로컬 기록 인덱스가 손상되어 정상적인 버전에서 재구성되었습니다.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return '이 로컬 기록 저장소는 지원되지 않는 형식 $version을 사용합니다.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return '로컬 기록은 사용할 수 없습니다: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + '녹음이 비활성화되었거나 이 경로가 제외되었습니다. 복원하기 전에 녹음을 다시 활성화하십시오.'; + + @override + String localHistoryWarningPathChange(String detail) { + return '로컬 기록이 경로 변경을 기록할 수 없습니다: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return '로컬 히스토리에서 삭제된 경로를 저장할 수 없습니다: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => '선택된 버전이 누락되었거나 손상되었습니다.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return '선택된 버전을 읽을 수 없습니다: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return '로컬 히스토리가 이 버전을 캡처할 수 없습니다: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => '기본 버전'; + + @override + String get localHistoryReasonSaved => '저장됨'; + + @override + String get localHistoryReasonAutomaticCheckpoint => '자동 체크포인트'; + + @override + String get localHistoryReasonBeforeReload => '재로드 전'; + + @override + String get localHistoryReasonBeforeDiscard => '버리기 전'; + + @override + String get localHistoryReasonBeforeRestore => '복원 전'; + + @override + String get localHistoryReasonBeforeDelete => '삭제 전'; + + @override + String get localHistoryReasonExternalChange => '외부 변경'; + + @override + String get settingsHistory => '버전 기록'; + + @override + String get settingsClipboardHistoryTitle => '클립보드 기록 수집'; + + @override + String get settingsClipboardHistoryDescription => + '이 BusyMark 세션에만 복사 및 성공적으로 붙여넣은 콘텐츠를 유지합니다.'; + + @override + String get settingsLocalHistoryTitle => '로컬 버전 기록'; + + @override + String get settingsLocalHistoryDescription => + '복구 및 비교를 위해 이 장치에 문서 버전을 저장합니다.'; + + @override + String get settingsHistoryCheckpoint => '자동 체크포인트 간격'; + + @override + String get settingsHistoryRetention => '보관 기간'; + + @override + String get settingsHistoryStorage => '최대 저장 공간'; + + @override + String get settingsHistoryExcludedPaths => '제외된 경로'; + + @override + String get settingsHistoryExcludedPathsHint => '한 줄에 하나의 절대 경로'; + + @override + String settingsSecondsValue(int value) { + return '$value 초'; + } + + @override + String settingsDaysValue(int value) { + return '$value 일'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: $minimum에서 $maximum 사이의 숫자를 입력하세요.'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 406d4c42..cf272f03 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3888,6 +3888,283 @@ class AppLocalizationsNb extends AppLocalizations { String get exportInvalidCss => 'Velg en lesbar UTF-8 .css-fil opptil 256 KiB uten importer, ressurs-URL-er, HTML eller kjørbar eller escaped CSS.'; + @override + String get clipboardHistory => 'Utklippshistorie'; + + @override + String get clipboardHistoryDescription => + 'Åpne historien for utklippet som er lokal for sesjonen.'; + + @override + String get clipboardCurrent => 'Nåværende utklipp'; + + @override + String get clipboardCurrentExternal => + 'Nåværende utklipp fra en annen applikasjon'; + + @override + String clipboardDestination(String name) { + return 'Lim inn i $name'; + } + + @override + String get clipboardSessionOnly => 'Lagret kun for denne BusyMark-sesjonen.'; + + @override + String get clipboardNoItems => 'Utklippshistorien er tom.'; + + @override + String get clipboardNoMatches => + 'Ingen utklippsposter samsvarer med denne søkingen.'; + + @override + String get clipboardDisabled => + 'Samlingen \"Utklippshistorie\" er deaktivert i Innstillinger.'; + + @override + String get clipboardUnavailable => + 'Innholdet i det nåværende utklippet er ikke tilgjengelig eller støttet.'; + + @override + String get clipboardTooLarge => + 'Denne utklippsteksten er for stor til å lagre.'; + + @override + String get clipboardPastePlainText => 'Lim inn som ren tekst'; + + @override + String get clipboardClearAll => 'Tøm utklippshistorie'; + + @override + String get clipboardEntryText => 'Tekst'; + + @override + String get clipboardEntryRichText => 'Rik tekst'; + + @override + String get clipboardEntryImage => 'Bilde'; + + @override + String clipboardOrigin(String name) { + return 'Kopiert fra $name'; + } + + @override + String get localHistory => 'Lokal historie'; + + @override + String get localHistoryEllipsis => 'Lokal historie…'; + + @override + String get localHistoryDescription => + 'Vis lagrede og automatiserte lokale versjoner for det nåværende dokumentet.'; + + @override + String get findLocalHistoryEllipsis => 'Finn i Lokal Historikk…'; + + @override + String get findLocalHistoryDescription => + 'Søk etter dokumenter og innhold i Lokal Historikk.'; + + @override + String get localHistoryNoDocuments => + 'Ingen dokumenter har Lokal Historikk ennå.'; + + @override + String get localHistoryNoRevisions => + 'Dette dokumentet har ingen revisjoner i Lokal Historikk.'; + + @override + String get localHistoryNoMatches => 'Ingen revisjoner matcher denne søken.'; + + @override + String get localHistorySearchHint => 'Søk etter innhold i revisjon'; + + @override + String get localHistorySelectedRevision => 'Valgt revisjon'; + + @override + String get localHistoryCurrentEditor => 'Nåværende innhold i redigereren'; + + @override + String get localHistoryCurrentDisk => 'Nåværende innhold på disken'; + + @override + String get localHistoryMissingFile => 'Den opprinnelige filen mangler.'; + + @override + String get localHistoryRestoreRevision => 'Gjenopprett Versjon'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Gjenopprett til Opprinnelig Sted'; + + @override + String get localHistoryRestoreNewLocation => 'Gjenopprett til Nytt Sted…'; + + @override + String get localHistoryRestoreChange => 'Gjenopprett Endring'; + + @override + String get localHistoryRestoreUnavailable => + 'Denne endringen kan ikke gjenopprettes fordi sammenligningen er ikke lenger aktuell.'; + + @override + String get localHistoryComparisonSimplified => + 'Denne store sammenligningen er forenklet. Gjenopprett individuelle endringer er ikke tilgjengelig.'; + + @override + String localHistoryOlderChange(String text) { + return 'Eldre versjonsendring: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Nåværende innholdsendring: $text'; + } + + @override + String get localHistoryDeleted => 'Slettet'; + + @override + String get localHistoryUntitled => 'Uten tittel'; + + @override + String get localHistoryClearDocument => + 'Tøm historikken for dette dokumentet'; + + @override + String get localHistoryClearAll => 'Tøm all lokal historikk'; + + @override + String get localHistoryClearDocumentTitle => 'Tøm dokumentets historikk?'; + + @override + String get localHistoryClearAllTitle => 'Tøm all lokal historikk?'; + + @override + String get localHistoryClearConfirmation => + 'Dette sletter permanent lagrede versjoner. Denne handlingen kan ikke angres.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Lokal historikk indeksen ble skadet og gjenopprettet fra intakte versjoner.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Denne lokale historikk-lagringen bruker et støttet format $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Lokal historikk er ikke tilgjengelig: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Opptak er deaktivert eller denne stien er utelatt. Aktiver opptak før gjenoppretting.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Lokal historikk kunne ikke registrere endringen av stien: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Lokal Historikk kunne ikke lagre den slettede stien: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Den valgte versjonen mangler eller er skadet.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Den valgte versjonen kan ikke leses: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Lokal Historikk kunne ikke fange opp denne versjonen: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Baselinje'; + + @override + String get localHistoryReasonSaved => 'Lagret'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Automatisk sjekkpunkt'; + + @override + String get localHistoryReasonBeforeReload => 'Før omlasting'; + + @override + String get localHistoryReasonBeforeDiscard => 'Før avvisning'; + + @override + String get localHistoryReasonBeforeRestore => 'Før gjenoppbygging'; + + @override + String get localHistoryReasonBeforeDelete => 'Før sletting'; + + @override + String get localHistoryReasonExternalChange => 'Ekstern endring'; + + @override + String get settingsHistory => 'Historikk'; + + @override + String get settingsClipboardHistoryTitle => + 'Samle Historikk fra utklippstavlen'; + + @override + String get settingsClipboardHistoryDescription => + 'Behold kopiert og vellykket limt innhold kun for denne BusyMark-sesjonen.'; + + @override + String get settingsLocalHistoryTitle => 'Lag lokal historikk'; + + @override + String get settingsLocalHistoryDescription => + 'Lagre dokumentversjoner på denne enheten for gjenoppretting og sammenligning.'; + + @override + String get settingsHistoryCheckpoint => 'Automatisk sjekkpunktintervall'; + + @override + String get settingsHistoryRetention => 'Bevaringsperiode'; + + @override + String get settingsHistoryStorage => 'Maks lagringsplass'; + + @override + String get settingsHistoryExcludedPaths => 'Udelte stier'; + + @override + String get settingsHistoryExcludedPathsHint => 'Én absolutt sti per linje'; + + @override + String settingsSecondsValue(int value) { + return '$value sekunder'; + } + + @override + String settingsDaysValue(int value) { + return '$value dager'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: skriv inn et tall mellom $minimum og $maximum.'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 3c8f86f2..f967b024 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -3931,6 +3931,288 @@ class AppLocalizationsNl extends AppLocalizations { String get exportInvalidCss => 'Kies een leesbaar UTF-8 .css-bestand tot 256 KiB zonder imports, bron-URL’s, HTML of uitvoerbare of ontsnapte CSS.'; + @override + String get clipboardHistory => 'Historie van de klembord'; + + @override + String get clipboardHistoryDescription => + 'Open de klembordgeschiedenis per sessie.'; + + @override + String get clipboardCurrent => 'Huidig klembord'; + + @override + String get clipboardCurrentExternal => + 'Huidig klembord van een ander programma'; + + @override + String clipboardDestination(String name) { + return 'Plakken in $name'; + } + + @override + String get clipboardSessionOnly => + 'Alleen opgeslagen voor deze BusyMark sessie.'; + + @override + String get clipboardNoItems => 'De klembordgeschiedenis is leeg.'; + + @override + String get clipboardNoMatches => + 'Geen klemborditems komen overeen met deze zoekopdracht.'; + + @override + String get clipboardDisabled => + 'De collectie \"Historie van het klembord\" is uitgeschakeld in de instellingen.'; + + @override + String get clipboardUnavailable => + 'De inhoud van het huidige klembord is niet beschikbaar of niet ondersteund.'; + + @override + String get clipboardTooLarge => + 'Dit item op het klembord is te groot om te bewaren.'; + + @override + String get clipboardPastePlainText => 'Plak als gewone tekst'; + + @override + String get clipboardClearAll => 'Maak de geschiedenis van het klembord leeg'; + + @override + String get clipboardEntryText => 'Tekst'; + + @override + String get clipboardEntryRichText => 'Rijke tekst'; + + @override + String get clipboardEntryImage => 'Afbeelding'; + + @override + String clipboardOrigin(String name) { + return 'Geplakt van $name'; + } + + @override + String get localHistory => 'Lokale geschiedenis'; + + @override + String get localHistoryEllipsis => 'Lokale geschiedenis…'; + + @override + String get localHistoryDescription => + 'Toon opgeslagen en automatische lokale revisies voor het huidige document.'; + + @override + String get findLocalHistoryEllipsis => 'Zoeken in Lokale Geschiedenis…'; + + @override + String get findLocalHistoryDescription => + 'Zoek naar documenten en inhoud van revisies in de lokale geschiedenis.'; + + @override + String get localHistoryNoDocuments => + 'Er zijn nog geen documenten met Lokale Geschiedenis.'; + + @override + String get localHistoryNoRevisions => + 'Dit document heeft geen revisies van de Lokale Geschiedenis.'; + + @override + String get localHistoryNoMatches => + 'Geen revisies van de Lokale Geschiedenis komen overeen met deze zoekopdracht.'; + + @override + String get localHistorySearchHint => 'Zoek naar inhoud van revisies'; + + @override + String get localHistorySelectedRevision => 'Geselecteerde revisie'; + + @override + String get localHistoryCurrentEditor => 'Huidige inhoud van de editor'; + + @override + String get localHistoryCurrentDisk => 'Huidige inhoud op schijf'; + + @override + String get localHistoryMissingFile => 'Het originele bestand ontbreekt.'; + + @override + String get localHistoryRestoreRevision => 'Herstel Versie'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Herstel naar Originele Locatie'; + + @override + String get localHistoryRestoreNewLocation => 'Herstel naar Nieuwe Locatie…'; + + @override + String get localHistoryRestoreChange => 'Herstel Wijziging'; + + @override + String get localHistoryRestoreUnavailable => + 'Deze wijziging kan niet worden hersteld omdat de vergelijking niet meer actueel is.'; + + @override + String get localHistoryComparisonSimplified => + 'Deze grote vergelijking is vereenvoudigd. Het herstellen van individuele wijzigingen is niet mogelijk.'; + + @override + String localHistoryOlderChange(String text) { + return 'Oudere versie wijziging: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Huidige inhoud wijziging: $text'; + } + + @override + String get localHistoryDeleted => 'Verwijderd'; + + @override + String get localHistoryUntitled => 'Onbenoemd'; + + @override + String get localHistoryClearDocument => + 'Verwijder de geschiedenis van dit document'; + + @override + String get localHistoryClearAll => 'Verwijder de lokale geschiedenis'; + + @override + String get localHistoryClearDocumentTitle => + 'Verwijder de documentgeschiedenis?'; + + @override + String get localHistoryClearAllTitle => 'Verwijder de lokale geschiedenis?'; + + @override + String get localHistoryClearConfirmation => + 'Dit verwijdert permanent opgeslagen revisies. Deze actie kan niet worden teruggedraaid.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'De index van de lokale geschiedenis is beschadigd en opnieuw gebouwd vanuit intacte revisies.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Deze lokale geschiedenisopslag gebruikt een onondersteunde $version-formaat.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'De lokale geschiedenis is niet beschikbaar: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Opnemen is uitgeschakeld of deze pad is uitgesloten. Schakel opnemen opnieuw in voordat u herstelt.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'De lokale geschiedenis kon de padwijziging niet opslaan: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Lokale Geschiedenis kon de verwijderde pad niet opslaan: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'De geselecteerde versie ontbreekt of is beschadigd.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'De geselecteerde versie kan niet worden gelezen: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Lokale Geschiedenis kon deze versie niet vastleggen: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Basislijn'; + + @override + String get localHistoryReasonSaved => 'Bewaard'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Automatische controlepunt'; + + @override + String get localHistoryReasonBeforeReload => 'Voor herladen'; + + @override + String get localHistoryReasonBeforeDiscard => 'Voor verwijderen'; + + @override + String get localHistoryReasonBeforeRestore => 'Voordat herstellen'; + + @override + String get localHistoryReasonBeforeDelete => 'Voordat verwijderen'; + + @override + String get localHistoryReasonExternalChange => 'Externe wijziging'; + + @override + String get settingsHistory => 'Geschiedenis'; + + @override + String get settingsClipboardHistoryTitle => + 'Verzamelen van de geschiedenis van de klembord'; + + @override + String get settingsClipboardHistoryDescription => + 'Behoud van gekopieerd en succesvol geplakt materiaal alleen voor deze BusyMark sessie.'; + + @override + String get settingsLocalHistoryTitle => 'Opnemen van lokale geschiedenis'; + + @override + String get settingsLocalHistoryDescription => + 'Opslaan van documentversies op dit apparaat voor herstel en vergelijking.'; + + @override + String get settingsHistoryCheckpoint => + 'Automatische interval voor controlepunten'; + + @override + String get settingsHistoryRetention => 'Bewaarperiode'; + + @override + String get settingsHistoryStorage => 'Maximale opslag'; + + @override + String get settingsHistoryExcludedPaths => 'Uitsluitingen'; + + @override + String get settingsHistoryExcludedPathsHint => 'Eén absolute pad per regel'; + + @override + String settingsSecondsValue(int value) { + return '$value seconden'; + } + + @override + String settingsDaysValue(int value) { + return '$value dagen'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: voer een getal tussen $minimum en $maximum in.'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 9d4290c7..b11fce80 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3940,6 +3940,286 @@ class AppLocalizationsPl extends AppLocalizations { String get exportInvalidCss => 'Wybierz czytelny plik .css UTF-8 do 256 KiB, bez importów, adresów zasobów, HTML oraz wykonywalnego CSS i sekwencji ucieczki.'; + @override + String get clipboardHistory => 'Historia w Koszyku'; + + @override + String get clipboardHistoryDescription => + 'Otwórz historię w koszyku lokalną dla sesji.'; + + @override + String get clipboardCurrent => 'Aktualny koszyk'; + + @override + String get clipboardCurrentExternal => 'Aktualny koszyk z innego programu'; + + @override + String clipboardDestination(String name) { + return 'Wklej do $name'; + } + + @override + String get clipboardSessionOnly => + 'Przechowywane tylko dla tej sesji BusyMark.'; + + @override + String get clipboardNoItems => 'Historia w koszyku jest pusta.'; + + @override + String get clipboardNoMatches => + 'Żadne elementy w koszyku nie pasują do tego wyszukiwania.'; + + @override + String get clipboardDisabled => + 'Zbiór \"Historia w koszyku\" wyłączony w Ustawieniach.'; + + @override + String get clipboardUnavailable => + 'Aktualny zawartość koszyka niedostępna lub nieobsługiwana.'; + + @override + String get clipboardTooLarge => + 'Ten element schowka jest zbyt duży, aby go zapisać.'; + + @override + String get clipboardPastePlainText => 'Wklej jako zwykły tekst'; + + @override + String get clipboardClearAll => 'Wyczyść historię schowka'; + + @override + String get clipboardEntryText => 'Tekst'; + + @override + String get clipboardEntryRichText => 'Bogaty tekst'; + + @override + String get clipboardEntryImage => 'Obraz'; + + @override + String clipboardOrigin(String name) { + return 'Skopiowano z $name'; + } + + @override + String get localHistory => 'Lokalna historia'; + + @override + String get localHistoryEllipsis => 'Lokalna historia…'; + + @override + String get localHistoryDescription => + 'Pokaż zapisane i automatyczne lokalne wersje dla bieżącego dokumentu.'; + + @override + String get findLocalHistoryEllipsis => 'Znajdź w Lokalnej Historii…'; + + @override + String get findLocalHistoryDescription => + 'Wyszukaj dokumenty i zawartość wersji w Lokalnej Historii.'; + + @override + String get localHistoryNoDocuments => + 'Żaden dokument nie ma Lokalnej Historii.'; + + @override + String get localHistoryNoRevisions => + 'Ten dokument nie ma wersji w Lokalnej Historii.'; + + @override + String get localHistoryNoMatches => + 'Żadne wersje nie pasują do tego wyszukiwania.'; + + @override + String get localHistorySearchHint => 'Wyszukaj zawartość wersji'; + + @override + String get localHistorySelectedRevision => 'Wybrana wersja'; + + @override + String get localHistoryCurrentEditor => 'Aktualny zawartość edytora'; + + @override + String get localHistoryCurrentDisk => 'Aktualna zawartość na dysku'; + + @override + String get localHistoryMissingFile => 'Oryginalny plik nie istnieje.'; + + @override + String get localHistoryRestoreRevision => 'Przywróć Wersję'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Przywróć do Oryginalnej Lokalizacji'; + + @override + String get localHistoryRestoreNewLocation => 'Przywróć do Nowej Lokalizacji…'; + + @override + String get localHistoryRestoreChange => 'Przywróć Zmianę'; + + @override + String get localHistoryRestoreUnavailable => + 'Ta zmiana nie może być przywrócona, ponieważ porównanie nie jest już aktualne.'; + + @override + String get localHistoryComparisonSimplified => + 'To duże porównanie zostało uproszczone. Przywracanie pojedynczych zmian jest niedostępne.'; + + @override + String localHistoryOlderChange(String text) { + return 'Stara wersja zmiany: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Aktualna zmiana zawartości: $text'; + } + + @override + String get localHistoryDeleted => 'Usunięto'; + + @override + String get localHistoryUntitled => 'Bez tytułu'; + + @override + String get localHistoryClearDocument => 'Wyczyść historię tego dokumentu'; + + @override + String get localHistoryClearAll => 'Wyczyść całą lokalną historię'; + + @override + String get localHistoryClearDocumentTitle => + 'Czy chcesz wyczyścić historię dokumentu?'; + + @override + String get localHistoryClearAllTitle => + 'Czy chcesz wyczyścić całą lokalną historię?'; + + @override + String get localHistoryClearConfirmation => + 'To trwale usuwa zapisane wersje. Ta akcja nie może być cofnięta.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Indeks Lokalnej Historii został uszkodzony i odtworzony z nieuszkodzonych wersji.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Ta Lokalna Historia używa nieobsługiwanego formatu $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Lokalna Historia jest niedostępna: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Nagrywanie jest wyłączone lub ta ścieżka jest wykluczona. Włącz nagrywanie przed przywracaniem.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Lokalna Historia nie mogła zarejestrować zmiany ścieżki: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Lokalna historia nie mogła zachować usuniętego ścieżki: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Wybrana wersja jest nieobecna lub uszkodzona.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Wybrana wersja nie może być odczytana: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Lokalna historia nie mogła przechwycić tej wersji: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Podstawa'; + + @override + String get localHistoryReasonSaved => 'Zapisano'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Automatyczny checkpoint'; + + @override + String get localHistoryReasonBeforeReload => 'Przed ponownym załadowaniem'; + + @override + String get localHistoryReasonBeforeDiscard => 'Przed wyrzuceniem'; + + @override + String get localHistoryReasonBeforeRestore => 'Przed przywróceniem'; + + @override + String get localHistoryReasonBeforeDelete => 'Przed usunięciem'; + + @override + String get localHistoryReasonExternalChange => 'Zmiana z zewnątrz'; + + @override + String get settingsHistory => 'Historia'; + + @override + String get settingsClipboardHistoryTitle => 'Zapisywanie historii w schowku'; + + @override + String get settingsClipboardHistoryDescription => + 'Zachowaj skopiowany i pomyślnie wklejony zawartość tylko dla tej sesji BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Zapisywanie lokalnej historii'; + + @override + String get settingsLocalHistoryDescription => + 'Przechowywanie wersji dokumentu na tym urządzeniu w celu odzyskania i porównywania.'; + + @override + String get settingsHistoryCheckpoint => + 'Automatyczny interwał kontrolnego zapisu'; + + @override + String get settingsHistoryRetention => 'Okres przechowywania'; + + @override + String get settingsHistoryStorage => 'Maksymalna pojemność'; + + @override + String get settingsHistoryExcludedPaths => 'Wykluczone ścieżki'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Jedna absolutna ścieżka na linię'; + + @override + String settingsSecondsValue(int value) { + return '$value sekundy'; + } + + @override + String settingsDaysValue(int value) { + return '$value dni'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: wpisz liczbę między $minimum a $maximum.'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 01ee508b..79789ebd 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3945,6 +3945,287 @@ class AppLocalizationsPt extends AppLocalizations { String get exportInvalidCss => 'Escolha um ficheiro .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.'; + @override + String get clipboardHistory => 'Histórico da Área de Transferência'; + + @override + String get clipboardHistoryDescription => + 'Abrir o histórico da área de transferência local da sessão.'; + + @override + String get clipboardCurrent => 'Área de transferência atual'; + + @override + String get clipboardCurrentExternal => + 'Área de transferência de outro aplicativo'; + + @override + String clipboardDestination(String name) { + return 'Colar em $name'; + } + + @override + String get clipboardSessionOnly => + 'Armazenado apenas para esta sessão BusyMark.'; + + @override + String get clipboardNoItems => + 'O histórico da área de transferência está vazio.'; + + @override + String get clipboardNoMatches => + 'Nenhum item da área de transferência corresponde a esta pesquisa.'; + + @override + String get clipboardDisabled => + 'A coleção de Histórico da Área de Transferência está desativada nas Configurações.'; + + @override + String get clipboardUnavailable => + 'O conteúdo da área de transferência atual não está disponível ou não é suportado.'; + + @override + String get clipboardTooLarge => + 'Este item na área de transferência é demasiado grande para manter.'; + + @override + String get clipboardPastePlainText => 'Colar como Texto Simples'; + + @override + String get clipboardClearAll => 'Limpar o Histórico da Área de Transferência'; + + @override + String get clipboardEntryText => 'Texto'; + + @override + String get clipboardEntryRichText => 'Texto formatado'; + + @override + String get clipboardEntryImage => 'Imagem'; + + @override + String clipboardOrigin(String name) { + return 'Copiado de $name'; + } + + @override + String get localHistory => 'Histórico Local'; + + @override + String get localHistoryEllipsis => 'Histórico Local…'; + + @override + String get localHistoryDescription => + 'Mostrar as revisões locais salvas e automáticas para o documento atual.'; + + @override + String get findLocalHistoryEllipsis => 'Encontrar no Histórico Local…'; + + @override + String get findLocalHistoryDescription => + 'Procurar documentos e conteúdos de revisão no Histórico Local.'; + + @override + String get localHistoryNoDocuments => 'Nenhum documento tem Histórico Local.'; + + @override + String get localHistoryNoRevisions => + 'Este documento não tem revisões no Histórico Local.'; + + @override + String get localHistoryNoMatches => 'Nenhuma revisão corresponde à pesquisa.'; + + @override + String get localHistorySearchHint => 'Procurar conteúdos de revisão'; + + @override + String get localHistorySelectedRevision => 'Revisão selecionada'; + + @override + String get localHistoryCurrentEditor => 'Conteúdo atual do editor'; + + @override + String get localHistoryCurrentDisk => 'Conteúdo atual no disco'; + + @override + String get localHistoryMissingFile => 'O ficheiro original está ausente.'; + + @override + String get localHistoryRestoreRevision => 'Restaurar Versão'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Restaurar para Localização Original'; + + @override + String get localHistoryRestoreNewLocation => + 'Restaurar para Nova Localização…'; + + @override + String get localHistoryRestoreChange => 'Restaurar Alteração'; + + @override + String get localHistoryRestoreUnavailable => + 'Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.'; + + @override + String get localHistoryComparisonSimplified => + 'Esta alteração grande foi simplificada. Restaurar alterações individuais não está disponível.'; + + @override + String localHistoryOlderChange(String text) { + return 'Alteração de versão anterior: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Alteração de conteúdo atual: $text'; + } + + @override + String get localHistoryDeleted => 'Excluído'; + + @override + String get localHistoryUntitled => 'Sem título'; + + @override + String get localHistoryClearDocument => 'Limpar o Histórico deste Documento'; + + @override + String get localHistoryClearAll => 'Limpar Todo o Histórico Local'; + + @override + String get localHistoryClearDocumentTitle => + 'Limpar o Histórico do Documento?'; + + @override + String get localHistoryClearAllTitle => 'Limpar Todo o Histórico Local?'; + + @override + String get localHistoryClearConfirmation => + 'Isto elimina permanentemente as versões armazenadas. Esta ação não pode ser desfeita.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'O índice do Histórico Local foi danificado e reconstruído a partir de versões intactas.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Esta loja de Histórico Local utiliza um formato não suportado $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'O Histórico Local não está disponível: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'O Histórico Local não conseguiu gravar a alteração do caminho: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'O Histórico Local não conseguiu reter o caminho eliminado: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'A revisão selecionada está ausente ou danificada.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'A revisão selecionada não pode ser lida: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'O Histórico Local não conseguiu capturar esta revisão: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Linha Base'; + + @override + String get localHistoryReasonSaved => 'Guardado'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Checkpoint automático'; + + @override + String get localHistoryReasonBeforeReload => 'Antes de recarregar'; + + @override + String get localHistoryReasonBeforeDiscard => 'Antes de descartar'; + + @override + String get localHistoryReasonBeforeRestore => 'Antes de restaurar'; + + @override + String get localHistoryReasonBeforeDelete => 'Antes de apagar'; + + @override + String get localHistoryReasonExternalChange => 'Alteração externa'; + + @override + String get settingsHistory => 'Histórico'; + + @override + String get settingsClipboardHistoryTitle => + 'Recolher Histórico da Área de Transferência'; + + @override + String get settingsClipboardHistoryDescription => + 'Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Gravar Histórico Local'; + + @override + String get settingsLocalHistoryDescription => + 'Armazenar as versões do documento neste dispositivo para recuperação e comparação.'; + + @override + String get settingsHistoryCheckpoint => + 'Intervalo de ponto de controlo automático'; + + @override + String get settingsHistoryRetention => 'Período de retenção'; + + @override + String get settingsHistoryStorage => 'Armazenamento máximo'; + + @override + String get settingsHistoryExcludedPaths => 'Caminhos excluídos'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Um caminho absoluto por linha'; + + @override + String settingsSecondsValue(int value) { + return '$value segundos'; + } + + @override + String settingsDaysValue(int value) { + return '$value dias'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: introduza um número entre $minimum e $maximum.'; @@ -7888,6 +8169,288 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get exportInvalidCss => 'Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.'; + @override + String get clipboardHistory => 'Histórico da Área de Transferência'; + + @override + String get clipboardHistoryDescription => + 'Abrir o histórico da área de transferência local da sessão.'; + + @override + String get clipboardCurrent => 'Área de transferência atual'; + + @override + String get clipboardCurrentExternal => + 'Área de transferência de outro aplicativo'; + + @override + String clipboardDestination(String name) { + return 'Colar em $name'; + } + + @override + String get clipboardSessionOnly => + 'Armazenado apenas para esta sessão BusyMark.'; + + @override + String get clipboardNoItems => + 'O histórico da área de transferência está vazio.'; + + @override + String get clipboardNoMatches => + 'Nenhum item da área de transferência corresponde a esta pesquisa.'; + + @override + String get clipboardDisabled => + 'A coleção de Histórico da Área de Transferência está desativada nas Configurações.'; + + @override + String get clipboardUnavailable => + 'O conteúdo da área de transferência atual não está disponível ou não é suportado.'; + + @override + String get clipboardTooLarge => + 'Este item da área de transferência é muito grande para ser armazenado.'; + + @override + String get clipboardPastePlainText => 'Colar como Texto Simples'; + + @override + String get clipboardClearAll => 'Limpar o Histórico da Área de Transferência'; + + @override + String get clipboardEntryText => 'Texto'; + + @override + String get clipboardEntryRichText => 'Texto formatado'; + + @override + String get clipboardEntryImage => 'Imagem'; + + @override + String clipboardOrigin(String name) { + return 'Copiado de $name'; + } + + @override + String get localHistory => 'Histórico Local'; + + @override + String get localHistoryEllipsis => 'Histórico Local…'; + + @override + String get localHistoryDescription => + 'Mostrar revisões locais salvas e automáticas para o documento atual.'; + + @override + String get findLocalHistoryEllipsis => 'Encontrar no Histórico Local…'; + + @override + String get findLocalHistoryDescription => + 'Pesquisar documentos e conteúdo de revisões no Histórico Local.'; + + @override + String get localHistoryNoDocuments => + 'Nenhum documento possui Histórico Local.'; + + @override + String get localHistoryNoRevisions => + 'Este documento não possui revisões no Histórico Local.'; + + @override + String get localHistoryNoMatches => + 'Nenhuma revisão no Histórico Local corresponde a esta pesquisa.'; + + @override + String get localHistorySearchHint => 'Pesquisar conteúdo de revisões'; + + @override + String get localHistorySelectedRevision => 'Revisão selecionada'; + + @override + String get localHistoryCurrentEditor => 'Conteúdo atual do editor'; + + @override + String get localHistoryCurrentDisk => 'Conteúdo atual no disco'; + + @override + String get localHistoryMissingFile => 'O arquivo original está faltando.'; + + @override + String get localHistoryRestoreRevision => 'Restaurar Versão'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Restaurar para a Localização Original'; + + @override + String get localHistoryRestoreNewLocation => + 'Restaurar para a Nova Localização…'; + + @override + String get localHistoryRestoreChange => 'Restaurar Alteração'; + + @override + String get localHistoryRestoreUnavailable => + 'Esta alteração não pode ser restaurada porque a comparação não está mais atualizada.'; + + @override + String get localHistoryComparisonSimplified => + 'Esta comparação grande foi simplificada. Restaurar alterações individuais não está disponível.'; + + @override + String localHistoryOlderChange(String text) { + return 'Alteração da versão anterior: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Alteração do conteúdo atual: $text'; + } + + @override + String get localHistoryDeleted => 'Excluído'; + + @override + String get localHistoryUntitled => 'Sem Título'; + + @override + String get localHistoryClearDocument => 'Limpar o Histórico deste Documento'; + + @override + String get localHistoryClearAll => 'Limpar todo o Histórico Local'; + + @override + String get localHistoryClearDocumentTitle => + 'Limpar o histórico do documento?'; + + @override + String get localHistoryClearAllTitle => 'Limpar todo o Histórico Local?'; + + @override + String get localHistoryClearConfirmation => + 'Isso remove permanentemente as revisões armazenadas. Esta ação não pode ser desfeita.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'O índice do Histórico Local foi danificado e reconstruído a partir de revisões íntegras.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Esta loja de Histórico Local usa o formato não suportado $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Histórico Local não está disponível: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'A gravação está desativada ou este caminho está excluído. Reative a gravação antes de restaurar.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'O Histórico Local não conseguiu gravar a alteração do caminho: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'O Histórico Local não conseguiu reter o caminho excluído: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'A revisão selecionada está ausente ou corrompida.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'A revisão selecionada não pode ser lida: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'O Histórico Local não conseguiu capturar esta revisão: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Linha de Base'; + + @override + String get localHistoryReasonSaved => 'Salvo'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Checkpoint automático'; + + @override + String get localHistoryReasonBeforeReload => 'Antes de recarregar'; + + @override + String get localHistoryReasonBeforeDiscard => 'Antes de descartar'; + + @override + String get localHistoryReasonBeforeRestore => 'Antes da restauração'; + + @override + String get localHistoryReasonBeforeDelete => 'Antes da exclusão'; + + @override + String get localHistoryReasonExternalChange => 'Mudança externa'; + + @override + String get settingsHistory => 'Histórico'; + + @override + String get settingsClipboardHistoryTitle => + 'Coletar Histórico da Área de Transferência'; + + @override + String get settingsClipboardHistoryDescription => + 'Manter o conteúdo copiado e colado com sucesso apenas para esta sessão BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Gravar Histórico Local'; + + @override + String get settingsLocalHistoryDescription => + 'Armazenar revisões do documento neste dispositivo para recuperação e comparação.'; + + @override + String get settingsHistoryCheckpoint => 'Intervalo de checkpoint automático'; + + @override + String get settingsHistoryRetention => 'Tempo de retenção'; + + @override + String get settingsHistoryStorage => 'Armazenamento máximo'; + + @override + String get settingsHistoryExcludedPaths => 'Caminhos excluídos'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Um caminho absoluto por linha'; + + @override + String settingsSecondsValue(int value) { + return '$value segundos'; + } + + @override + String settingsDaysValue(int value) { + return '$value dias'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: insira um número entre $minimum e $maximum.'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 1bae9acf..cc85c506 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3941,6 +3941,286 @@ class AppLocalizationsRu extends AppLocalizations { String get exportInvalidCss => 'Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.'; + @override + String get clipboardHistory => 'История буфера обмена'; + + @override + String get clipboardHistoryDescription => + 'Открыть историю буфера обмена, специфичную для сеанса.'; + + @override + String get clipboardCurrent => 'Текущий буфер обмена'; + + @override + String get clipboardCurrentExternal => + 'Текущий буфер обмена из другого приложения'; + + @override + String clipboardDestination(String name) { + return 'Вставить в $name'; + } + + @override + String get clipboardSessionOnly => + 'Сохраняется только для текущего сеанса BusyMark.'; + + @override + String get clipboardNoItems => 'История буфера обмена пуста.'; + + @override + String get clipboardNoMatches => + 'Нет элементов буфера обмена, соответствующих этому поиску.'; + + @override + String get clipboardDisabled => + 'Коллекция \"История буфера обмена\" отключена в настройках.'; + + @override + String get clipboardUnavailable => + 'Текущее содержимое буфера обмена недоступно или не поддерживается.'; + + @override + String get clipboardTooLarge => + 'Этот элемент буфера обмена слишком велик для хранения.'; + + @override + String get clipboardPastePlainText => 'Вставить как обычный текст'; + + @override + String get clipboardClearAll => 'Очистить историю буфера обмена'; + + @override + String get clipboardEntryText => 'Текст'; + + @override + String get clipboardEntryRichText => 'Форматированный текст'; + + @override + String get clipboardEntryImage => 'Изображение'; + + @override + String clipboardOrigin(String name) { + return 'Скопировано из $name'; + } + + @override + String get localHistory => 'Локальная история'; + + @override + String get localHistoryEllipsis => 'Локальная история…'; + + @override + String get localHistoryDescription => + 'Отобразить сохраненные и автоматические локальные версии для текущего документа.'; + + @override + String get findLocalHistoryEllipsis => 'Найти в локальной истории…'; + + @override + String get findLocalHistoryDescription => + 'Поиск документов и содержимого версий в локальной истории.'; + + @override + String get localHistoryNoDocuments => + 'Ни один документ не имеет локальной истории.'; + + @override + String get localHistoryNoRevisions => + 'Этот документ не имеет версий в локальной истории.'; + + @override + String get localHistoryNoMatches => + 'Ни одна версия не соответствует этому поиску.'; + + @override + String get localHistorySearchHint => 'Поиск содержимого версии'; + + @override + String get localHistorySelectedRevision => 'Выбранная версия'; + + @override + String get localHistoryCurrentEditor => 'Текущее содержимое редактора'; + + @override + String get localHistoryCurrentDisk => 'Текущее содержимое на диске'; + + @override + String get localHistoryMissingFile => 'Оригинальный файл отсутствует.'; + + @override + String get localHistoryRestoreRevision => 'Восстановить версию'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Восстановить в исходное местоположение'; + + @override + String get localHistoryRestoreNewLocation => + 'Восстановить в новое местоположение…'; + + @override + String get localHistoryRestoreChange => 'Восстановить изменения'; + + @override + String get localHistoryRestoreUnavailable => + 'Эти изменения нельзя восстановить, так как сравнение больше не актуально.'; + + @override + String get localHistoryComparisonSimplified => + 'Это большое сравнение упрощено. Восстановление отдельных изменений недоступно.'; + + @override + String localHistoryOlderChange(String text) { + return 'Изменения в более старой версии: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Изменения в текущей версии: $text'; + } + + @override + String get localHistoryDeleted => 'Удалено'; + + @override + String get localHistoryUntitled => 'Безымянный'; + + @override + String get localHistoryClearDocument => 'Очистить историю этого документа'; + + @override + String get localHistoryClearAll => 'Очистить всю локальную историю'; + + @override + String get localHistoryClearDocumentTitle => 'Очистить историю документа?'; + + @override + String get localHistoryClearAllTitle => 'Очистить всю локальную историю?'; + + @override + String get localHistoryClearConfirmation => + 'Это навсегда удаляет сохраненные версии. Это действие нельзя отменить.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Индекс локальной истории был поврежден и восстановлен из неповрежденных версий.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Эта локальная история использует неподдерживаемый формат $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Локальная история недоступна: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Запись отключена или этот путь исключен. Включите запись перед восстановлением.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Локальная история не смогла записать изменение пути: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Локальная история не смогла сохранить удалённый путь: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Выбранная версия отсутствует или повреждена.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Выбранную версию невозможно прочитать: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Локальная история не смогла захватить эту версию: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Базовая версия'; + + @override + String get localHistoryReasonSaved => 'Сохранено'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Автоматическая контрольная точка'; + + @override + String get localHistoryReasonBeforeReload => 'Перед перезагрузкой'; + + @override + String get localHistoryReasonBeforeDiscard => 'Перед удалением'; + + @override + String get localHistoryReasonBeforeRestore => 'Перед восстановлением'; + + @override + String get localHistoryReasonBeforeDelete => 'Перед удалением'; + + @override + String get localHistoryReasonExternalChange => 'Внешнее изменение'; + + @override + String get settingsHistory => 'История'; + + @override + String get settingsClipboardHistoryTitle => 'Сохранить историю буфера обмена'; + + @override + String get settingsClipboardHistoryDescription => + 'Сохранять только скопированный и успешно вставленный контент для текущей сессии BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Записывать локальную историю'; + + @override + String get settingsLocalHistoryDescription => + 'Сохранять версии документа на этом устройстве для восстановления и сравнения.'; + + @override + String get settingsHistoryCheckpoint => 'Интервал автоматического сохранения'; + + @override + String get settingsHistoryRetention => 'Срок хранения'; + + @override + String get settingsHistoryStorage => 'Максимальное хранилище'; + + @override + String get settingsHistoryExcludedPaths => 'Исключенные пути'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Один абсолютный путь в строке'; + + @override + String settingsSecondsValue(int value) { + return '$value секунды'; + } + + @override + String settingsDaysValue(int value) { + return '$value дни'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: введите число от $minimum до $maximum.'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index d6a9c6da..ad22cf53 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -3889,6 +3889,286 @@ class AppLocalizationsTr extends AppLocalizations { String get exportInvalidCss => 'İçe aktarım, kaynak URL’si, HTML, çalıştırılabilir veya kaçışlı CSS içermeyen, okunabilir UTF-8 .css dosyası seçin (en fazla 256 KiB).'; + @override + String get clipboardHistory => 'Kopyalama Geçmişi'; + + @override + String get clipboardHistoryDescription => + 'Oturuma özgü kopyalama geçmişini aç.'; + + @override + String get clipboardCurrent => 'Mevcut kopyalama'; + + @override + String get clipboardCurrentExternal => + 'Başka bir uygulamadan mevcut kopyalama'; + + @override + String clipboardDestination(String name) { + return '$name\'e yapıştır'; + } + + @override + String get clipboardSessionOnly => + 'Sadece bu BusyMark oturumu için saklanır.'; + + @override + String get clipboardNoItems => 'Kopyalama geçmişi boş.'; + + @override + String get clipboardNoMatches => + 'Bu arama ile eşleşen kopyalama öğesi bulunamadı.'; + + @override + String get clipboardDisabled => + 'Kopyalama Geçmişi koleksiyonu Ayarlar\'da devre dışıdır.'; + + @override + String get clipboardUnavailable => + 'Mevcut kopyalama içeriği mevcut değil veya desteklenmiyor.'; + + @override + String get clipboardTooLarge => + 'Bu kopyalanmış öğe, saklamak için çok büyüktür.'; + + @override + String get clipboardPastePlainText => 'Temiz Metin Olarak Yapıştır'; + + @override + String get clipboardClearAll => 'Kopyalama Geçmişini Temizle'; + + @override + String get clipboardEntryText => 'Metin'; + + @override + String get clipboardEntryRichText => 'Zengin Metin'; + + @override + String get clipboardEntryImage => 'Resim'; + + @override + String clipboardOrigin(String name) { + return '$name\'den Kopyalandı'; + } + + @override + String get localHistory => 'Yerel Geçmiş'; + + @override + String get localHistoryEllipsis => 'Yerel Geçmiş…'; + + @override + String get localHistoryDescription => + 'Mevcut belge için kaydedilmiş ve otomatik yerel sürümleri göster.'; + + @override + String get findLocalHistoryEllipsis => 'Yerel Geçmişte Bul...'; + + @override + String get findLocalHistoryDescription => + 'Yerel Geçmişte belgeleri ve revizyon içeriğini arayın.'; + + @override + String get localHistoryNoDocuments => + 'Henüz herhangi bir belge Yerel Geçmişe sahip değil.'; + + @override + String get localHistoryNoRevisions => + 'Bu belge Yerel Geçmiş revizyonlarına sahip değil.'; + + @override + String get localHistoryNoMatches => + 'Bu arama ile eşleşen Yerel Geçmiş revizyonu yok.'; + + @override + String get localHistorySearchHint => 'Revizyon içeriğini arayın'; + + @override + String get localHistorySelectedRevision => 'Seçilen revizyon'; + + @override + String get localHistoryCurrentEditor => 'Mevcut düzenleme içeriği'; + + @override + String get localHistoryCurrentDisk => 'Disk üzerinde mevcut içerik'; + + @override + String get localHistoryMissingFile => 'Orijinal dosya bulunmuyor.'; + + @override + String get localHistoryRestoreRevision => 'Versiyonu Geri Yükle'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Orijinal Konuma Geri Yükle'; + + @override + String get localHistoryRestoreNewLocation => 'Yeni Konuma Geri Yükle…'; + + @override + String get localHistoryRestoreChange => 'Değişikliği Geri Yükle'; + + @override + String get localHistoryRestoreUnavailable => + 'Bu değişiklik geri alınamaz çünkü karşılaştırma artık geçerli değil.'; + + @override + String get localHistoryComparisonSimplified => + 'Bu büyük karşılaştırma basitleştirildi. Tek tek değişiklikleri geri yüklemek mümkün değil.'; + + @override + String localHistoryOlderChange(String text) { + return 'Eski versiyon değişikliği: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Mevcut içerik değişikliği: $text'; + } + + @override + String get localHistoryDeleted => 'Silindi'; + + @override + String get localHistoryUntitled => 'Başlımsız'; + + @override + String get localHistoryClearDocument => 'Bu Dokümanın Geçmişini Temizle'; + + @override + String get localHistoryClearAll => 'Tüm Yerel Geçmişi Temizle'; + + @override + String get localHistoryClearDocumentTitle => + 'Doküman Geçmişini Temizlemek İstemiyor Musunuz?'; + + @override + String get localHistoryClearAllTitle => + 'Tüm Yerel Geçmişi Temizlemek İstemiyor Musunuz?'; + + @override + String get localHistoryClearConfirmation => + 'Bu, kaydedilen tüm sürümleri kalıcı olarak kaldırır. Bu işlem geri alınamaz.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Yerel Geçmiş indeksi, sağlam sürümlerden yeniden oluşturuldu.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Bu Yerel Geçmiş depolama, desteklenmeyen format $version kullanır.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Yerel Geçmiş kullanılabilir değil: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Kayıt devre dışı bırakılmış veya bu yol hariç tutulmuş. Geri yükleme yapmadan önce tekrar etkinleştirin.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Yerel Geçmiş, yol değişikliğini kaydetemedi: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Yerel Geçmiş, silinen yolu kaydetemedi: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Seçilen sürüm eksik veya bozulmuş.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Seçilen sürüm okunamaz: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Yerel Geçmiş, bu sürümü yakalamadı: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Temel Sürüm'; + + @override + String get localHistoryReasonSaved => 'Kaydedildi'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Otomatik kontrol noktası'; + + @override + String get localHistoryReasonBeforeReload => 'Yenilemeden önce'; + + @override + String get localHistoryReasonBeforeDiscard => 'Atılmadan önce'; + + @override + String get localHistoryReasonBeforeRestore => 'Restorasyon öncesi'; + + @override + String get localHistoryReasonBeforeDelete => 'Silme öncesi'; + + @override + String get localHistoryReasonExternalChange => 'Dış kaynak değişiklik'; + + @override + String get settingsHistory => 'Geçmiş'; + + @override + String get settingsClipboardHistoryTitle => 'Geçmişi Kopyala'; + + @override + String get settingsClipboardHistoryDescription => + 'Bu BusyMark oturumu için kopyalanan ve başarıyla yapıştırılan içeriği sakla.'; + + @override + String get settingsLocalHistoryTitle => 'Yerel Geçmişi Kaydet'; + + @override + String get settingsLocalHistoryDescription => + 'Belgeleri kurtarma ve karşılaştırma için bu cihazda değişiklikleri sakla.'; + + @override + String get settingsHistoryCheckpoint => 'Otomatik kontrol noktası aralığı'; + + @override + String get settingsHistoryRetention => 'Saklama süresi'; + + @override + String get settingsHistoryStorage => 'Maksimum depolama alanı'; + + @override + String get settingsHistoryExcludedPaths => 'Hariç tutulan yollar'; + + @override + String get settingsHistoryExcludedPathsHint => 'Bir mutlak yol her satırda'; + + @override + String settingsSecondsValue(int value) { + return '$value saniye'; + } + + @override + String settingsDaysValue(int value) { + return '$value gün'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: $minimum ile $maximum arasında bir sayı girin.'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 76494aa2..9a9796a4 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3949,6 +3949,286 @@ class AppLocalizationsUk extends AppLocalizations { String get exportInvalidCss => 'Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.'; + @override + String get clipboardHistory => 'Історія Вирізання'; + + @override + String get clipboardHistoryDescription => + 'Відкрити історію вирізання, прив\'язану до сесії.'; + + @override + String get clipboardCurrent => 'Поточне вирізання'; + + @override + String get clipboardCurrentExternal => 'Поточне вирізання з іншого додатку'; + + @override + String clipboardDestination(String name) { + return 'Вставити в $name'; + } + + @override + String get clipboardSessionOnly => + 'Зберігається лише для цієї сесії BusyMark.'; + + @override + String get clipboardNoItems => 'Історія вирізання порожня.'; + + @override + String get clipboardNoMatches => + 'Жоден елемент вирізання не відповідає цьому пошуку.'; + + @override + String get clipboardDisabled => + 'Збірка \"Історія Вирізання\" вимкнена в Налаштуваннях.'; + + @override + String get clipboardUnavailable => + 'Поточний вміст вирізання недоступний або не підтримується.'; + + @override + String get clipboardTooLarge => + 'Цей елемент буфера обміну занадто великий для зберігання.'; + + @override + String get clipboardPastePlainText => 'Вставити як звичайний текст'; + + @override + String get clipboardClearAll => 'Очистити історію буфера обміну'; + + @override + String get clipboardEntryText => 'Текст'; + + @override + String get clipboardEntryRichText => 'Збагачений текст'; + + @override + String get clipboardEntryImage => 'Зображення'; + + @override + String clipboardOrigin(String name) { + return 'Скопійовано з $name'; + } + + @override + String get localHistory => 'Локальна історія'; + + @override + String get localHistoryEllipsis => 'Локальна історія…'; + + @override + String get localHistoryDescription => + 'Показати збережені та автоматичні локальні версії для поточної документа.'; + + @override + String get findLocalHistoryEllipsis => 'Знайти в локальній історії…'; + + @override + String get findLocalHistoryDescription => + 'Пошук документів та вмісту версій у локальній історії.'; + + @override + String get localHistoryNoDocuments => + 'Жоден документ поки не має локальної історії.'; + + @override + String get localHistoryNoRevisions => + 'Цей документ не має версій локальної історії.'; + + @override + String get localHistoryNoMatches => + 'Жодна версія локальної історії не відповідає цьому пошуку.'; + + @override + String get localHistorySearchHint => 'Пошук вмісту версій'; + + @override + String get localHistorySelectedRevision => 'Обрана версія'; + + @override + String get localHistoryCurrentEditor => 'Поточний вміст редактора'; + + @override + String get localHistoryCurrentDisk => 'Поточний вміст на диску'; + + @override + String get localHistoryMissingFile => 'Оригінальний файл відсутній.'; + + @override + String get localHistoryRestoreRevision => 'Відновити версію'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Відновити до початкової локації'; + + @override + String get localHistoryRestoreNewLocation => 'Відновити до нової локації…'; + + @override + String get localHistoryRestoreChange => 'Відновити зміни'; + + @override + String get localHistoryRestoreUnavailable => + 'Ці зміни не можуть бути відновлені, оскільки порівняння більше не актуальне.'; + + @override + String get localHistoryComparisonSimplified => + 'Це велике порівняння спрощено. Відновлення окремих змін недоступне.'; + + @override + String localHistoryOlderChange(String text) { + return 'Зміна в попередній версії: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Зміна в поточному вмісті: $text'; + } + + @override + String get localHistoryDeleted => 'Видалено'; + + @override + String get localHistoryUntitled => 'Без назви'; + + @override + String get localHistoryClearDocument => 'Очистити історію цього документа'; + + @override + String get localHistoryClearAll => 'Очистити всю локальну історію'; + + @override + String get localHistoryClearDocumentTitle => 'Очистити історію документа?'; + + @override + String get localHistoryClearAllTitle => 'Очистити всю локальну історію?'; + + @override + String get localHistoryClearConfirmation => + 'Це постійно видаляє збережені версії. Цю дію неможливо скасувати.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Індекс локальної історії пошкоджено та відновлено з цілих версій.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Ця локальна історія використовує непідтримуваний формат $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Локальна історія недоступна: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Запис вимкнено або цей шлях виключено. Увімкніть запис перед відновленням.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Локальна історія не змогла записати зміну шляху: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Локальна історія не змогла зберегти видалений шлях: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Вибрана версія відсутня або пошкоджена.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Вибрану версію неможливо прочитати: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Локальна історія не змогла зберегти цю версію: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Базова версія'; + + @override + String get localHistoryReasonSaved => 'Збережено'; + + @override + String get localHistoryReasonAutomaticCheckpoint => + 'Автоматичний контрольний пункт'; + + @override + String get localHistoryReasonBeforeReload => 'Перед перезавантаженням'; + + @override + String get localHistoryReasonBeforeDiscard => 'Перед видаленням'; + + @override + String get localHistoryReasonBeforeRestore => 'Перед відновленням'; + + @override + String get localHistoryReasonBeforeDelete => 'Перед видаленням'; + + @override + String get localHistoryReasonExternalChange => 'Зовнішня зміна'; + + @override + String get settingsHistory => 'Історія'; + + @override + String get settingsClipboardHistoryTitle => + 'Зберегти історію, скопійовану в буфер обміну'; + + @override + String get settingsClipboardHistoryDescription => + 'Зберігати лише скопійований та успішно вставлений вміст для цієї сесії BusyMark.'; + + @override + String get settingsLocalHistoryTitle => 'Записувати локальну історію'; + + @override + String get settingsLocalHistoryDescription => + 'Зберігати версії документа на цьому пристрої для відновлення та порівняння.'; + + @override + String get settingsHistoryCheckpoint => + 'Автоматичний інтервал збереження контрольної точки'; + + @override + String get settingsHistoryRetention => 'Тривалість зберігання'; + + @override + String get settingsHistoryStorage => 'Максимальне місце для зберігання'; + + @override + String get settingsHistoryExcludedPaths => 'Виключені шляхи'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Один абсолютний шлях на рядок'; + + @override + String settingsSecondsValue(int value) { + return '$value секунди'; + } + + @override + String settingsDaysValue(int value) { + return '$value дні'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: введіть число від $minimum до $maximum.'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 79d2c2ce..b95a9df7 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -3884,6 +3884,281 @@ class AppLocalizationsVi extends AppLocalizations { String get exportInvalidCss => 'Chọn tệp .css UTF-8 đọc được tối đa 256 KiB, không có nhập tài nguyên, URL tài nguyên, HTML, CSS thực thi hoặc ký tự thoát.'; + @override + String get clipboardHistory => 'Lịch sử sao chép'; + + @override + String get clipboardHistoryDescription => 'Mở lịch sử sao chép cục bộ phiên.'; + + @override + String get clipboardCurrent => 'Lịch sử sao chép hiện tại'; + + @override + String get clipboardCurrentExternal => + 'Lịch sử sao chép từ một ứng dụng khác'; + + @override + String clipboardDestination(String name) { + return 'Dán vào $name'; + } + + @override + String get clipboardSessionOnly => 'Chỉ lưu trong phiên BusyMark này.'; + + @override + String get clipboardNoItems => 'Lịch sử sao chép trống.'; + + @override + String get clipboardNoMatches => + 'Không có mục sao chép nào khớp với tìm kiếm này.'; + + @override + String get clipboardDisabled => + 'Bộ sưu tập Lịch sử sao chép bị vô hiệu hóa trong Cài đặt.'; + + @override + String get clipboardUnavailable => + 'Nội dung sao chép hiện tại không khả dụng hoặc không được hỗ trợ.'; + + @override + String get clipboardTooLarge => 'Mục này trong clipboard quá lớn để lưu.'; + + @override + String get clipboardPastePlainText => 'Dán dưới dạng văn bản thuần túy'; + + @override + String get clipboardClearAll => 'Xóa lịch sử clipboard'; + + @override + String get clipboardEntryText => 'Văn bản'; + + @override + String get clipboardEntryRichText => 'Văn bản phong phú'; + + @override + String get clipboardEntryImage => 'Hình ảnh'; + + @override + String clipboardOrigin(String name) { + return 'Sao chép từ $name'; + } + + @override + String get localHistory => 'Lịch sử cục bộ'; + + @override + String get localHistoryEllipsis => 'Lịch sử cục bộ…'; + + @override + String get localHistoryDescription => + 'Hiển thị các phiên bản cục bộ đã lưu và tự động cho tài liệu hiện tại.'; + + @override + String get findLocalHistoryEllipsis => 'Tìm trong Lịch sử cục bộ…'; + + @override + String get findLocalHistoryDescription => + 'Tìm kiếm các tài liệu và nội dung phiên bản trong Lịch sử cục bộ.'; + + @override + String get localHistoryNoDocuments => + 'Chưa có tài liệu nào có Lịch sử cục bộ.'; + + @override + String get localHistoryNoRevisions => + 'Tài liệu này không có các phiên bản Lịch sử cục bộ.'; + + @override + String get localHistoryNoMatches => + 'Không có phiên bản nào trong Lịch sử cục bộ khớp với tìm kiếm này.'; + + @override + String get localHistorySearchHint => 'Tìm kiếm nội dung phiên bản'; + + @override + String get localHistorySelectedRevision => 'Phiên bản đã chọn'; + + @override + String get localHistoryCurrentEditor => 'Nội dung trình soạn thảo hiện tại'; + + @override + String get localHistoryCurrentDisk => 'Nội dung hiện tại trên ổ đĩa'; + + @override + String get localHistoryMissingFile => 'Tệp gốc bị thiếu.'; + + @override + String get localHistoryRestoreRevision => 'Khôi phục Phiên bản'; + + @override + String get localHistoryRestoreOriginalLocation => + 'Khôi phục về Vị trí Ban đầu'; + + @override + String get localHistoryRestoreNewLocation => 'Khôi phục về Vị trí Mới…'; + + @override + String get localHistoryRestoreChange => 'Khôi phục Thay đổi'; + + @override + String get localHistoryRestoreUnavailable => + 'Thay đổi này không thể khôi phục vì so sánh không còn hợp lệ.'; + + @override + String get localHistoryComparisonSimplified => + 'So sánh này rất lớn, khôi phục thay đổi riêng không khả thi.'; + + @override + String localHistoryOlderChange(String text) { + return 'Thay đổi phiên bản cũ: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return 'Thay đổi nội dung hiện tại: $text'; + } + + @override + String get localHistoryDeleted => 'Xóa'; + + @override + String get localHistoryUntitled => 'Không có tiêu đề'; + + @override + String get localHistoryClearDocument => 'Xóa Lịch Sử Của Tài Liệu Này'; + + @override + String get localHistoryClearAll => 'Xóa Lịch Sử Địa Phương'; + + @override + String get localHistoryClearDocumentTitle => 'Xóa lịch sử tài liệu?'; + + @override + String get localHistoryClearAllTitle => 'Xóa lịch sử địa phương?'; + + @override + String get localHistoryClearConfirmation => + 'Điều này sẽ xóa vĩnh viễn các phiên bản đã lưu. Hành động này không thể hoàn tác.'; + + @override + String get localHistoryWarningIndexRebuilt => + 'Chỉ mục Lịch Sử Địa Phương bị hỏng và được xây dựng lại từ các phiên bản còn nguyên vẹn.'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return 'Kho Lịch Sử Địa Phương sử dụng định dạng không được hỗ trợ $version.'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return 'Lịch Sử Địa Phương không khả dụng: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + 'Ghi âm bị tắt hoặc đường dẫn này bị loại trừ. Vui lòng bật lại ghi âm trước khi khôi phục.'; + + @override + String localHistoryWarningPathChange(String detail) { + return 'Lịch Sử Địa Phương không thể ghi lại thay đổi đường dẫn: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return 'Lịch sử cục bộ không thể lưu trữ đường dẫn đã xóa: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => + 'Phiên bản đã chọn bị thiếu hoặc bị hỏng.'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return 'Phiên bản đã chọn không thể đọc: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return 'Lịch sử cục bộ không thể ghi lại phiên bản này: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason – $timestamp'; + } + + @override + String get localHistoryReasonBaseline => 'Mốc cơ sở'; + + @override + String get localHistoryReasonSaved => 'Lưu'; + + @override + String get localHistoryReasonAutomaticCheckpoint => 'Tự động lưu'; + + @override + String get localHistoryReasonBeforeReload => 'Trước khi tải lại'; + + @override + String get localHistoryReasonBeforeDiscard => 'Trước khi vứt'; + + @override + String get localHistoryReasonBeforeRestore => 'Trước khi khôi phục'; + + @override + String get localHistoryReasonBeforeDelete => 'Trước khi xóa'; + + @override + String get localHistoryReasonExternalChange => 'Thay đổi từ bên ngoài'; + + @override + String get settingsHistory => 'Lịch sử'; + + @override + String get settingsClipboardHistoryTitle => 'Thu thập lịch sử sao chép'; + + @override + String get settingsClipboardHistoryDescription => + 'Giữ nội dung đã sao chép và dán thành công chỉ cho phiên BusyMark này.'; + + @override + String get settingsLocalHistoryTitle => 'Ghi lại lịch sử cục bộ'; + + @override + String get settingsLocalHistoryDescription => + 'Lưu các phiên bản tài liệu trên thiết bị để khôi phục và so sánh.'; + + @override + String get settingsHistoryCheckpoint => 'Khoảng thời gian kiểm tra tự động'; + + @override + String get settingsHistoryRetention => 'Thời gian lưu'; + + @override + String get settingsHistoryStorage => 'Dung lượng tối đa'; + + @override + String get settingsHistoryExcludedPaths => 'Đường dẫn bị loại trừ'; + + @override + String get settingsHistoryExcludedPathsHint => + 'Một đường dẫn tuyệt đối trên mỗi dòng'; + + @override + String settingsSecondsValue(int value) { + return '$value giây'; + } + + @override + String settingsDaysValue(int value) { + return '$value ngày'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field: nhập số từ $minimum đến $maximum.'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 60422903..74199185 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -3721,6 +3721,264 @@ class AppLocalizationsZh extends AppLocalizations { String get exportInvalidCss => '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; + @override + String get clipboardHistory => '剪贴板历史'; + + @override + String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; + + @override + String get clipboardCurrent => '当前剪贴板'; + + @override + String get clipboardCurrentExternal => '来自其他应用程序的当前剪贴板'; + + @override + String clipboardDestination(String name) { + return '粘贴到 $name'; + } + + @override + String get clipboardSessionOnly => '仅存储在本次 BusyMark 会话中。'; + + @override + String get clipboardNoItems => '剪贴板历史为空。'; + + @override + String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; + + @override + String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; + + @override + String get clipboardUnavailable => '当前剪贴板内容不可用或不受支持。'; + + @override + String get clipboardTooLarge => '此剪贴板内容过大,无法保留。'; + + @override + String get clipboardPastePlainText => '作为纯文本粘贴'; + + @override + String get clipboardClearAll => '清除剪贴板历史'; + + @override + String get clipboardEntryText => '文本'; + + @override + String get clipboardEntryRichText => '丰富文本'; + + @override + String get clipboardEntryImage => '图片'; + + @override + String clipboardOrigin(String name) { + return '复制自 $name'; + } + + @override + String get localHistory => '本地历史'; + + @override + String get localHistoryEllipsis => '本地历史…'; + + @override + String get localHistoryDescription => '显示当前文档的已保存和自动本地修订。'; + + @override + String get findLocalHistoryEllipsis => '在本地历史中查找…'; + + @override + String get findLocalHistoryDescription => '搜索本地历史文档和修订内容。'; + + @override + String get localHistoryNoDocuments => '尚无文档具有本地历史。'; + + @override + String get localHistoryNoRevisions => '此文档没有本地历史修订。'; + + @override + String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; + + @override + String get localHistorySearchHint => '搜索修订内容'; + + @override + String get localHistorySelectedRevision => '已选择的修订'; + + @override + String get localHistoryCurrentEditor => '当前编辑器内容'; + + @override + String get localHistoryCurrentDisk => '磁盘上的当前内容'; + + @override + String get localHistoryMissingFile => '原始文件丢失。'; + + @override + String get localHistoryRestoreRevision => '恢复版本'; + + @override + String get localHistoryRestoreOriginalLocation => '恢复到原始位置'; + + @override + String get localHistoryRestoreNewLocation => '恢复到新位置…'; + + @override + String get localHistoryRestoreChange => '恢复更改'; + + @override + String get localHistoryRestoreUnavailable => '无法恢复此更改,因为比较已过时。'; + + @override + String get localHistoryComparisonSimplified => '此大型比较已简化。 无法恢复单个更改。'; + + @override + String localHistoryOlderChange(String text) { + return '较旧的版本更改: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return '当前内容更改: $text'; + } + + @override + String get localHistoryDeleted => '已删除'; + + @override + String get localHistoryUntitled => '未命名'; + + @override + String get localHistoryClearDocument => '清除此文档的历史'; + + @override + String get localHistoryClearAll => '清除所有本地历史'; + + @override + String get localHistoryClearDocumentTitle => '清除文档历史?'; + + @override + String get localHistoryClearAllTitle => '清除所有本地历史?'; + + @override + String get localHistoryClearConfirmation => '这将永久删除存储的修订。 此操作无法撤销。'; + + @override + String get localHistoryWarningIndexRebuilt => '本地历史索引已损坏并从完整修订中重建。'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return '此本地历史存储使用不受支持的格式 $version。'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return '本地历史不可用: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + '录音已禁用或此路径已排除。 在恢复之前重新启用录音。'; + + @override + String localHistoryWarningPathChange(String detail) { + return '无法记录本地历史路径更改: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return '本地历史记录无法保留已删除的路径: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => '所选版本丢失或损坏。'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return '无法读取所选版本: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return '本地历史记录无法捕获此版本: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason・$timestamp'; + } + + @override + String get localHistoryReasonBaseline => '基线'; + + @override + String get localHistoryReasonSaved => '已保存'; + + @override + String get localHistoryReasonAutomaticCheckpoint => '自动检查点'; + + @override + String get localHistoryReasonBeforeReload => '在重新加载之前'; + + @override + String get localHistoryReasonBeforeDiscard => '在丢弃之前'; + + @override + String get localHistoryReasonBeforeRestore => '恢复前'; + + @override + String get localHistoryReasonBeforeDelete => '删除前'; + + @override + String get localHistoryReasonExternalChange => '外部修改'; + + @override + String get settingsHistory => '历史记录'; + + @override + String get settingsClipboardHistoryTitle => '收集剪贴板历史'; + + @override + String get settingsClipboardHistoryDescription => + '只保留本次 BusyMark 会话中已复制并成功粘贴的内容。'; + + @override + String get settingsLocalHistoryTitle => '记录本地历史'; + + @override + String get settingsLocalHistoryDescription => '将文档修订存储在此设备上,以便恢复和比较。'; + + @override + String get settingsHistoryCheckpoint => '自动检查点间隔'; + + @override + String get settingsHistoryRetention => '保留期限'; + + @override + String get settingsHistoryStorage => '最大存储'; + + @override + String get settingsHistoryExcludedPaths => '排除路径'; + + @override + String get settingsHistoryExcludedPathsHint => '每行一个绝对路径'; + + @override + String settingsSecondsValue(int value) { + return '$value 秒'; + } + + @override + String settingsDaysValue(int value) { + return '$value 天'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field:请输入 $minimum 到 $maximum 之间的数字。'; @@ -7442,6 +7700,264 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get exportInvalidCss => '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; + @override + String get clipboardHistory => '剪贴板历史'; + + @override + String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; + + @override + String get clipboardCurrent => '当前剪贴板'; + + @override + String get clipboardCurrentExternal => '来自其他应用程序的当前剪贴板'; + + @override + String clipboardDestination(String name) { + return '粘贴到 $name'; + } + + @override + String get clipboardSessionOnly => '仅存储在本次 BusyMark 会话中。'; + + @override + String get clipboardNoItems => '剪贴板历史为空。'; + + @override + String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; + + @override + String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; + + @override + String get clipboardUnavailable => '当前剪贴板内容不可用或不受支持。'; + + @override + String get clipboardTooLarge => '此剪贴板内容过大,无法保留。'; + + @override + String get clipboardPastePlainText => '作为纯文本粘贴'; + + @override + String get clipboardClearAll => '清除剪贴板历史'; + + @override + String get clipboardEntryText => '文本'; + + @override + String get clipboardEntryRichText => '丰富文本'; + + @override + String get clipboardEntryImage => '图片'; + + @override + String clipboardOrigin(String name) { + return '复制自 $name'; + } + + @override + String get localHistory => '本地历史'; + + @override + String get localHistoryEllipsis => '本地历史…'; + + @override + String get localHistoryDescription => '显示当前文档的已保存和自动本地修订。'; + + @override + String get findLocalHistoryEllipsis => '在本地历史中查找…'; + + @override + String get findLocalHistoryDescription => '搜索本地历史文档和修订内容。'; + + @override + String get localHistoryNoDocuments => '尚无文档具有本地历史。'; + + @override + String get localHistoryNoRevisions => '此文档没有本地历史修订。'; + + @override + String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; + + @override + String get localHistorySearchHint => '搜索修订内容'; + + @override + String get localHistorySelectedRevision => '已选择的修订'; + + @override + String get localHistoryCurrentEditor => '当前编辑器内容'; + + @override + String get localHistoryCurrentDisk => '磁盘上的当前内容'; + + @override + String get localHistoryMissingFile => '原始文件丢失。'; + + @override + String get localHistoryRestoreRevision => '恢复版本'; + + @override + String get localHistoryRestoreOriginalLocation => '恢复到原始位置'; + + @override + String get localHistoryRestoreNewLocation => '恢复到新位置…'; + + @override + String get localHistoryRestoreChange => '恢复更改'; + + @override + String get localHistoryRestoreUnavailable => '无法恢复此更改,因为比较已过时。'; + + @override + String get localHistoryComparisonSimplified => '此大型比较已简化。 无法恢复单个更改。'; + + @override + String localHistoryOlderChange(String text) { + return '较旧的版本更改: $text'; + } + + @override + String localHistoryCurrentChange(String text) { + return '当前内容更改: $text'; + } + + @override + String get localHistoryDeleted => '已删除'; + + @override + String get localHistoryUntitled => '未命名'; + + @override + String get localHistoryClearDocument => '清除此文档的历史'; + + @override + String get localHistoryClearAll => '清除所有本地历史'; + + @override + String get localHistoryClearDocumentTitle => '清除文档历史?'; + + @override + String get localHistoryClearAllTitle => '清除所有本地历史?'; + + @override + String get localHistoryClearConfirmation => '这将永久删除存储的修订。 此操作无法撤销。'; + + @override + String get localHistoryWarningIndexRebuilt => '本地历史索引已损坏并从完整修订中重建。'; + + @override + String localHistoryWarningUnsupportedFormat(String version) { + return '此本地历史存储使用不受支持的格式 $version。'; + } + + @override + String localHistoryWarningUnavailable(String detail) { + return '本地历史不可用: $detail'; + } + + @override + String get localHistoryWarningRecordingDisabled => + '录音已禁用或此路径已排除。 在恢复之前重新启用录音。'; + + @override + String localHistoryWarningPathChange(String detail) { + return '无法记录本地历史路径更改: $detail'; + } + + @override + String localHistoryWarningDeletedPath(String detail) { + return '本地历史记录无法保留已删除的路径: $detail'; + } + + @override + String get localHistoryWarningRevisionMissing => '所选版本丢失或损坏。'; + + @override + String localHistoryWarningRevisionRead(String detail) { + return '无法读取所选版本: $detail'; + } + + @override + String localHistoryWarningCapture(String detail) { + return '本地历史记录无法捕获此版本: $detail'; + } + + @override + String localHistoryRevisionAt(String reason, String timestamp) { + return '$reason・$timestamp'; + } + + @override + String get localHistoryReasonBaseline => '基线'; + + @override + String get localHistoryReasonSaved => '已保存'; + + @override + String get localHistoryReasonAutomaticCheckpoint => '自动检查点'; + + @override + String get localHistoryReasonBeforeReload => '在重新加载之前'; + + @override + String get localHistoryReasonBeforeDiscard => '在丢弃之前'; + + @override + String get localHistoryReasonBeforeRestore => '恢复前'; + + @override + String get localHistoryReasonBeforeDelete => '删除前'; + + @override + String get localHistoryReasonExternalChange => '外部修改'; + + @override + String get settingsHistory => '历史记录'; + + @override + String get settingsClipboardHistoryTitle => '收集剪贴板历史'; + + @override + String get settingsClipboardHistoryDescription => + '只保留本次 BusyMark 会话中已复制并成功粘贴的内容。'; + + @override + String get settingsLocalHistoryTitle => '记录本地历史'; + + @override + String get settingsLocalHistoryDescription => '将文档修订存储在此设备上,以便恢复和比较。'; + + @override + String get settingsHistoryCheckpoint => '自动检查点间隔'; + + @override + String get settingsHistoryRetention => '保留期限'; + + @override + String get settingsHistoryStorage => '最大存储'; + + @override + String get settingsHistoryExcludedPaths => '排除路径'; + + @override + String get settingsHistoryExcludedPathsHint => '每行一个绝对路径'; + + @override + String settingsSecondsValue(int value) { + return '$value 秒'; + } + + @override + String settingsDaysValue(int value) { + return '$value 天'; + } + + @override + String settingsMebibytesValue(int value) { + return '$value MiB'; + } + @override String exportInvalidRange(String field, String minimum, String maximum) { return '$field:请输入 $minimum 到 $maximum 之间的数字。'; diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index cbcc9209..0fa4544e 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -86,6 +86,12 @@ class AppSettings { required this.editorToolbarDirection, required this.autoSave, required this.validateOnEdit, + required this.clipboardHistoryEnabled, + required this.localHistoryRecordingEnabled, + required this.localHistoryCheckpointSeconds, + required this.localHistoryRetentionDays, + required this.localHistoryMaximumStorageMiB, + required this.localHistoryExcludedPaths, required this.aiProviderPreference, required this.aiOllamaEndpoint, required this.aiOllamaModel, @@ -119,6 +125,12 @@ class AppSettings { editorToolbarDirection: EditorToolbarDirection.horizontal, autoSave: true, validateOnEdit: true, + clipboardHistoryEnabled: true, + localHistoryRecordingEnabled: true, + localHistoryCheckpointSeconds: 60, + localHistoryRetentionDays: 30, + localHistoryMaximumStorageMiB: 512, + localHistoryExcludedPaths: [], aiProviderPreference: AiProviderPreference.disabled, aiOllamaEndpoint: 'http://127.0.0.1:11434', aiOllamaModel: '', @@ -176,6 +188,33 @@ class AppSettings { autoSave: json['autoSave'] as bool? ?? defaults.autoSave, validateOnEdit: json['validateOnEdit'] as bool? ?? defaults.validateOnEdit, + clipboardHistoryEnabled: + json['clipboardHistoryEnabled'] as bool? ?? + defaults.clipboardHistoryEnabled, + localHistoryRecordingEnabled: + json['localHistoryRecordingEnabled'] as bool? ?? + defaults.localHistoryRecordingEnabled, + localHistoryCheckpointSeconds: _boundedInt( + json['localHistoryCheckpointSeconds'], + defaults.localHistoryCheckpointSeconds, + 10, + 3600, + ), + localHistoryRetentionDays: _boundedInt( + json['localHistoryRetentionDays'], + defaults.localHistoryRetentionDays, + 1, + 3650, + ), + localHistoryMaximumStorageMiB: _boundedInt( + json['localHistoryMaximumStorageMiB'], + defaults.localHistoryMaximumStorageMiB, + 16, + 4096, + ), + localHistoryExcludedPaths: _historyExclusionPathListFromJson( + json['localHistoryExcludedPaths'], + ), aiProviderPreference: _enumFromName( AiProviderPreference.values, json['aiProviderPreference'], @@ -242,6 +281,12 @@ class AppSettings { final EditorToolbarDirection editorToolbarDirection; final bool autoSave; final bool validateOnEdit; + final bool clipboardHistoryEnabled; + final bool localHistoryRecordingEnabled; + final int localHistoryCheckpointSeconds; + final int localHistoryRetentionDays; + final int localHistoryMaximumStorageMiB; + final List localHistoryExcludedPaths; final AiProviderPreference aiProviderPreference; final String aiOllamaEndpoint; final String aiOllamaModel; @@ -277,6 +322,12 @@ class AppSettings { 'editorToolbarDirection': editorToolbarDirection.name, 'autoSave': autoSave, 'validateOnEdit': validateOnEdit, + 'clipboardHistoryEnabled': clipboardHistoryEnabled, + 'localHistoryRecordingEnabled': localHistoryRecordingEnabled, + 'localHistoryCheckpointSeconds': localHistoryCheckpointSeconds, + 'localHistoryRetentionDays': localHistoryRetentionDays, + 'localHistoryMaximumStorageMiB': localHistoryMaximumStorageMiB, + 'localHistoryExcludedPaths': localHistoryExcludedPaths, 'aiProviderPreference': aiProviderPreference.name, 'aiOllamaEndpoint': aiOllamaEndpoint, 'aiOllamaModel': aiOllamaModel, @@ -351,6 +402,12 @@ class AppSettings { EditorToolbarDirection? editorToolbarDirection, bool? autoSave, bool? validateOnEdit, + bool? clipboardHistoryEnabled, + bool? localHistoryRecordingEnabled, + int? localHistoryCheckpointSeconds, + int? localHistoryRetentionDays, + int? localHistoryMaximumStorageMiB, + List? localHistoryExcludedPaths, AiProviderPreference? aiProviderPreference, String? aiOllamaEndpoint, String? aiOllamaModel, @@ -386,6 +443,18 @@ class AppSettings { editorToolbarDirection ?? this.editorToolbarDirection, autoSave: autoSave ?? this.autoSave, validateOnEdit: validateOnEdit ?? this.validateOnEdit, + clipboardHistoryEnabled: + clipboardHistoryEnabled ?? this.clipboardHistoryEnabled, + localHistoryRecordingEnabled: + localHistoryRecordingEnabled ?? this.localHistoryRecordingEnabled, + localHistoryCheckpointSeconds: + localHistoryCheckpointSeconds ?? this.localHistoryCheckpointSeconds, + localHistoryRetentionDays: + localHistoryRetentionDays ?? this.localHistoryRetentionDays, + localHistoryMaximumStorageMiB: + localHistoryMaximumStorageMiB ?? this.localHistoryMaximumStorageMiB, + localHistoryExcludedPaths: + localHistoryExcludedPaths ?? this.localHistoryExcludedPaths, aiProviderPreference: aiProviderPreference ?? this.aiProviderPreference, aiOllamaEndpoint: aiOllamaEndpoint ?? this.aiOllamaEndpoint, aiOllamaModel: aiOllamaModel ?? this.aiOllamaModel, @@ -580,6 +649,51 @@ class AppSettingsController extends Notifier { return _mutate((settings) => settings.copyWith(validateOnEdit: enabled)); } + Future setClipboardHistoryEnabled(bool enabled) { + return _mutate( + (settings) => settings.copyWith(clipboardHistoryEnabled: enabled), + ); + } + + Future setLocalHistoryRecordingEnabled(bool enabled) { + return _mutate( + (settings) => settings.copyWith(localHistoryRecordingEnabled: enabled), + ); + } + + Future setLocalHistoryCheckpointSeconds(int seconds) { + return _mutate( + (settings) => settings.copyWith( + localHistoryCheckpointSeconds: seconds.clamp(10, 3600), + ), + ); + } + + Future setLocalHistoryRetentionDays(int days) { + return _mutate( + (settings) => + settings.copyWith(localHistoryRetentionDays: days.clamp(1, 3650)), + ); + } + + Future setLocalHistoryMaximumStorageMiB(int mebibytes) { + return _mutate( + (settings) => settings.copyWith( + localHistoryMaximumStorageMiB: mebibytes.clamp(16, 4096), + ), + ); + } + + Future setLocalHistoryExcludedPaths(Iterable paths) { + final normalized = { + for (final path in paths) + if (_normalizedHistoryExclusionPath(path) case final value?) value, + }.toList()..sort(); + return _mutate( + (settings) => settings.copyWith(localHistoryExcludedPaths: normalized), + ); + } + Future setAiProviderPreference(AiProviderPreference preference) { return _mutate( (settings) => settings.copyWith(aiProviderPreference: preference), @@ -863,6 +977,28 @@ List _workspacePathListFromJson(Object? value) { return paths; } +int _boundedInt(Object? value, int fallback, int minimum, int maximum) { + final parsed = value is num ? value.toInt() : int.tryParse('$value'); + return (parsed ?? fallback).clamp(minimum, maximum); +} + +List _historyExclusionPathListFromJson(Object? value) { + if (value is! List) return const []; + final paths = { + for (final item in value) + if (_normalizedHistoryExclusionPath(item?.toString()) case final path?) + path, + }.toList()..sort(); + return paths; +} + +String? _normalizedHistoryExclusionPath(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + if (!p.isAbsolute(trimmed)) return null; + return p.normalize(trimmed); +} + List _gitWorkspacePathListFromJson(Object? value) { if (value is! List) { return const []; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 3a8bcaa5..b05ccd55 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -11,8 +11,10 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../../l10n/generated/app_localizations.dart'; import '../export/workspace_export_ui.dart'; +import '../clipboard/clipboard_history_controller.dart'; import '../git/application/git_controller.dart'; import '../platform/linux_header_bar_service.dart'; +import '../local_history/local_history_controller.dart'; import '../workspace/workspace_controller.dart'; import '../workspace/workspace_model.dart'; import '../workspace/presentation/welcome_screen.dart'; @@ -52,6 +54,9 @@ final busyMarkCommandRegistryProvider = Provider(( BusyMarkCommandIds.closeTab: const _CloseTabIntent(), BusyMarkCommandIds.closeAllTabs: const _CloseAllTabsIntent(), BusyMarkCommandIds.toggleSidebar: const _ToggleSidebarIntent(), + BusyMarkCommandIds.clipboardHistory: const _OpenClipboardHistoryIntent(), + BusyMarkCommandIds.localHistory: const _OpenLocalHistoryIntent(), + BusyMarkCommandIds.findLocalHistory: const _FindLocalHistoryIntent(), BusyMarkCommandIds.viewEditor: const _DocumentViewModeIntent( DocumentViewModePreference.editor, ), @@ -82,6 +87,12 @@ final busyMarkCommandRegistryProvider = Provider(( canExportWorkspace(ref.read(workspaceControllerProvider)), BusyMarkCommandIds.search: () => ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.clipboardHistory: () => + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.localHistory: () => + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.findLocalHistory: () => + ref.read(workspaceControllerProvider).workspace != null, }, ); }); @@ -276,6 +287,35 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), + _OpenClipboardHistoryIntent: + CallbackAction<_OpenClipboardHistoryIntent>( + onInvoke: (intent) { + ref + .read( + clipboardHistoryOpenRequestProvider.notifier, + ) + .request(); + return null; + }, + ), + _OpenLocalHistoryIntent: + CallbackAction<_OpenLocalHistoryIntent>( + onInvoke: (intent) { + ref + .read(localHistoryOpenRequestProvider.notifier) + .request(); + return null; + }, + ), + _FindLocalHistoryIntent: + CallbackAction<_FindLocalHistoryIntent>( + onInvoke: (intent) { + ref + .read(localHistoryFindRequestProvider.notifier) + .request(); + return null; + }, + ), _NextTabIntent: CallbackAction<_NextTabIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -613,6 +653,13 @@ class BusyMarkApp extends ConsumerWidget { gitState: gitState, documentBuffers: workspaceState.documentBuffers, activeBufferId: workspaceState.activeBufferId, + localHistoryRevisionId: ref + .read(localHistoryControllerProvider) + .selectedRevisionId, + localHistoryDocumentName: ref + .read(localHistoryControllerProvider) + .selectedDocument + ?.displayName, ); if (tabs.length < 2) { return; @@ -640,6 +687,13 @@ class BusyMarkApp extends ConsumerWidget { gitState: gitState, documentBuffers: workspaceState.documentBuffers, activeBufferId: workspaceState.activeBufferId, + localHistoryRevisionId: ref + .read(localHistoryControllerProvider) + .selectedRevisionId, + localHistoryDocumentName: ref + .read(localHistoryControllerProvider) + .selectedDocument + ?.displayName, ); final activeIndex = activeWorkspaceTabIndex(tabs); if (activeIndex < 0) { @@ -669,6 +723,13 @@ class BusyMarkApp extends ConsumerWidget { gitState: gitState, documentBuffers: workspaceState.documentBuffers, activeBufferId: workspaceState.activeBufferId, + localHistoryRevisionId: ref + .read(localHistoryControllerProvider) + .selectedRevisionId, + localHistoryDocumentName: ref + .read(localHistoryControllerProvider) + .selectedDocument + ?.displayName, ).isEmpty) { return; } @@ -686,15 +747,19 @@ class BusyMarkApp extends ConsumerWidget { final gitController = ref.read(gitControllerProvider.notifier); switch (tab.kind) { case WorkspaceTabKind.file: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); await ref .read(workspaceControllerProvider.notifier) .activateDocumentBuffer(tab.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); if (tab.path.isEmpty) { return; } await gitController.activateDiffFile(tab.path); + case WorkspaceTabKind.localHistory: + return; } } @@ -712,6 +777,8 @@ class BusyMarkApp extends ConsumerWidget { } else { gitController.closeDiffFile(tab.path); } + case WorkspaceTabKind.localHistory: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); } } @@ -817,6 +884,9 @@ class BusyMarkApp extends ConsumerWidget { back: material.backButtonTooltip, backShortcut: shortcut(BusyMarkCommandIds.back), save: label(BusyMarkCommandIds.save), + clipboardHistory: label(BusyMarkCommandIds.clipboardHistory), + localHistory: label(BusyMarkCommandIds.localHistory), + findLocalHistory: label(BusyMarkCommandIds.findLocalHistory), export: label(BusyMarkCommandIds.export), exportShortcut: shortcut(BusyMarkCommandIds.export), exportGtkAccelerator: accelerator(BusyMarkCommandIds.export), @@ -1148,6 +1218,18 @@ class _OpenSearchIntent extends Intent { const _OpenSearchIntent(); } +class _OpenClipboardHistoryIntent extends Intent { + const _OpenClipboardHistoryIntent(); +} + +class _OpenLocalHistoryIntent extends Intent { + const _OpenLocalHistoryIntent(); +} + +class _FindLocalHistoryIntent extends Intent { + const _FindLocalHistoryIntent(); +} + class _ToggleSidebarIntent extends Intent { const _ToggleSidebarIntent(); } diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 1e6a842e..3bac3e6b 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -12,6 +12,9 @@ import 'window_control_service.dart'; enum BusyMarkMainMenuAction { export, generateMarkdownToc, + clipboardHistory, + localHistory, + findLocalHistory, fullScreen, settings, keyboardShortcuts, @@ -71,6 +74,21 @@ class BusyMarkMainMenuButton extends ConsumerWidget { trailingCheck: true, mutuallyExclusive: false, ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.clipboardHistory, + label: command(BusyMarkCommandIds.clipboardHistory).label(context), + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.localHistory, + label: command(BusyMarkCommandIds.localHistory).label(context), + icon: BusyMarkGlyphs.documentHistory, + ), + BusyMarkPopupMenuItem( + value: BusyMarkMainMenuAction.findLocalHistory, + label: command(BusyMarkCommandIds.findLocalHistory).label(context), + icon: BusyMarkGlyphs.search, + ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.settings, label: command(BusyMarkCommandIds.settings).label(context), @@ -112,6 +130,12 @@ class BusyMarkMainMenuButton extends ConsumerWidget { final commandId = switch (action) { BusyMarkMainMenuAction.export => BusyMarkCommandIds.export, BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen, + BusyMarkMainMenuAction.clipboardHistory => + BusyMarkCommandIds.clipboardHistory, + BusyMarkMainMenuAction.localHistory => + BusyMarkCommandIds.localHistory, + BusyMarkMainMenuAction.findLocalHistory => + BusyMarkCommandIds.findLocalHistory, BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, BusyMarkMainMenuAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index 3813b741..cc1d6057 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -276,6 +276,9 @@ abstract final class BusyMarkCommandIds { static const sidebarToc = 'sidebar.toc'; static const sidebarOutline = 'sidebar.outline'; static const sidebarGit = 'sidebar.git'; + static const clipboardHistory = 'sidebar.clipboardHistory'; + static const localHistory = 'history.local'; + static const findLocalHistory = 'history.findLocal'; static const treeDeleteSelection = 'tree.deleteSelection'; } @@ -381,6 +384,27 @@ abstract final class BusyMarkCommandCatalog { description: (context) => context.l10n.shortcutDeleteTreeItemDescription, ), + command( + id: BusyMarkCommandIds.clipboardHistory, + label: (context) => context.l10n.clipboardHistory, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.application, + description: (context) => context.l10n.clipboardHistoryDescription, + ), + command( + id: BusyMarkCommandIds.localHistory, + label: (context) => context.l10n.localHistoryEllipsis, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.application, + description: (context) => context.l10n.localHistoryDescription, + ), + command( + id: BusyMarkCommandIds.findLocalHistory, + label: (context) => context.l10n.findLocalHistoryEllipsis, + category: (context) => context.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.application, + description: (context) => context.l10n.findLocalHistoryDescription, + ), ]; return BusyMarkCommandRegistry(commands); } diff --git a/lib/src/clipboard/clipboard_history_controller.dart b/lib/src/clipboard/clipboard_history_controller.dart new file mode 100644 index 00000000..5eee4711 --- /dev/null +++ b/lib/src/clipboard/clipboard_history_controller.dart @@ -0,0 +1,289 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +import '../app/app_settings.dart'; +import '../assets/asset_input_service.dart'; +import '../platform/rich_clipboard_service.dart'; +import 'clipboard_insertion.dart'; +import 'clipboard_models.dart'; + +class ClipboardHistoryState { + const ClipboardHistoryState({ + this.entries = const [], + this.currentClipboard, + this.refreshing = false, + this.clipboardAvailable = true, + this.lastRetentionResult, + }); + + final List entries; + final BusyMarkClipboardPayload? currentClipboard; + final bool refreshing; + final bool clipboardAvailable; + final ClipboardRetentionResult? lastRetentionResult; + + int get retainedBytes => + entries.fold(0, (total, entry) => total + entry.accountedBytes); + + ClipboardHistoryState copyWith({ + List? entries, + Object? currentClipboard = _unset, + bool? refreshing, + bool? clipboardAvailable, + Object? lastRetentionResult = _unset, + }) { + return ClipboardHistoryState( + entries: entries ?? this.entries, + currentClipboard: identical(currentClipboard, _unset) + ? this.currentClipboard + : currentClipboard as BusyMarkClipboardPayload?, + refreshing: refreshing ?? this.refreshing, + clipboardAvailable: clipboardAvailable ?? this.clipboardAvailable, + lastRetentionResult: identical(lastRetentionResult, _unset) + ? this.lastRetentionResult + : lastRetentionResult as ClipboardRetentionResult?, + ); + } +} + +const Object _unset = Object(); + +final richClipboardServiceProvider = Provider( + (ref) => busyMarkRichClipboardService, +); + +final clipboardAssetInputServiceProvider = Provider( + (ref) => busyMarkAssetInputService, +); + +final clipboardHistoryPolicyProvider = Provider( + (ref) => const ClipboardHistoryPolicy(), +); + +final clipboardInsertionRegistryProvider = + ChangeNotifierProvider( + (ref) => BusyMarkClipboardInsertionRegistry(), + ); + +final clipboardHistoryControllerProvider = + NotifierProvider( + ClipboardHistoryController.new, + ); + +final clipboardHistoryOpenRequestProvider = + NotifierProvider( + ClipboardHistoryOpenRequestController.new, + ); + +class ClipboardHistoryOpenRequestController extends Notifier { + @override + int build() => 0; + + void request() => state++; +} + +class ClipboardHistoryController extends Notifier { + late RichClipboardService _clipboard; + late AssetInputService _assetInput; + late ClipboardHistoryPolicy _policy; + var _refreshGeneration = 0; + + @override + ClipboardHistoryState build() { + _clipboard = ref.read(richClipboardServiceProvider); + _assetInput = ref.read(clipboardAssetInputServiceProvider); + _policy = ref.read(clipboardHistoryPolicyProvider); + return const ClipboardHistoryState(); + } + + bool get collectionEnabled => + ref.read(appSettingsControllerProvider).clipboardHistoryEnabled; + + ClipboardRetentionResult retain(BusyMarkClipboardCapture capture) { + if (!collectionEnabled) { + state = state.copyWith( + lastRetentionResult: ClipboardRetentionResult.disabled, + ); + return ClipboardRetentionResult.disabled; + } + if (!capture.isSupported) return ClipboardRetentionResult.oversized; + final payload = _payload(capture); + if (payload.accountedBytes > _policy.maximumBytes) { + state = state.copyWith( + lastRetentionResult: ClipboardRetentionResult.oversized, + ); + return ClipboardRetentionResult.oversized; + } + final existing = state.entries + .where((entry) => entry.equivalentTo(payload)) + .firstOrNull; + final newest = existing ?? payload; + final retained = [ + newest, + for (final entry in state.entries) + if (!identical(entry, existing)) entry, + ]; + var retainedBytes = retained.fold( + 0, + (total, entry) => total + entry.accountedBytes, + ); + while (retained.length > _policy.maximumEntries || + retainedBytes > _policy.maximumBytes) { + retainedBytes -= retained.removeLast().accountedBytes; + } + final result = existing == null + ? ClipboardRetentionResult.retained + : ClipboardRetentionResult.deduplicated; + state = state.copyWith( + entries: List.unmodifiable(retained), + lastRetentionResult: result, + ); + return result; + } + + void remove(String id) { + state = state.copyWith( + entries: List.unmodifiable( + state.entries.where((entry) => entry.id != id), + ), + lastRetentionResult: null, + ); + } + + void clear() { + state = state.copyWith(entries: const [], lastRetentionResult: null); + } + + Future refreshCurrentClipboard() async { + final generation = ++_refreshGeneration; + state = state.copyWith(refreshing: true); + final first = await _clipboard.read(); + if (generation != _refreshGeneration || !ref.mounted) return; + + BusyMarkClipboardCapture? capture; + if (first.richFragment != null || first.sourceText != null) { + capture = BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: first.text, + sourceText: first.sourceText, + html: first.html, + richFragment: first.richFragment, + mediaBytes: first.mediaBytes, + mediaComplete: first.mediaComplete, + origin: first.origin, + external: !first.sessionOwned, + ); + } else if (first.text != null) { + capture = BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: first.text, + sourceText: first.text, + origin: first.origin, + external: !first.sessionOwned, + ); + } else { + final image = await _readCurrentImage(); + final second = await _clipboard.read(); + if (generation != _refreshGeneration || !ref.mounted) return; + // An image read uses the existing asset boundary. Re-read the rich + // boundary and discard the image if ownership changed in between. + if (image != null && first.sameExternalIdentity(second)) { + capture = BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: image.bytes, + imageMimeType: image.mimeType, + imageDisplayName: image.name, + external: true, + ); + } + } + state = state.copyWith( + currentClipboard: capture == null ? null : _payload(capture), + refreshing: false, + clipboardAvailable: capture != null, + ); + } + + /// Makes a transient external item retained only after a successful paste. + void retainCurrentAfterPaste(BusyMarkClipboardPayload payload) { + if (!payload.external) return; + retain( + BusyMarkClipboardCapture( + kind: payload.kind, + text: payload.text, + sourceText: payload.sourceText, + html: payload.html, + richFragment: payload.richFragment, + mediaBytes: payload.mediaBytes, + mediaComplete: payload.mediaComplete, + imageBytes: payload.imageBytes, + imageMimeType: payload.imageMimeType, + imageDisplayName: payload.imageDisplayName, + imageWidth: payload.imageWidth, + imageHeight: payload.imageHeight, + origin: payload.origin, + external: true, + ), + ); + } + + BusyMarkClipboardPayload _payload(BusyMarkClipboardCapture capture) { + return BusyMarkClipboardPayload( + id: const Uuid().v4(), + acquiredAt: DateTime.now(), + kind: capture.kind, + text: capture.text, + sourceText: capture.sourceText, + html: capture.html, + richFragment: capture.richFragment, + imageBytes: capture.imageBytes, + imageMimeType: capture.imageMimeType, + imageDisplayName: capture.imageDisplayName, + imageWidth: capture.imageWidth, + imageHeight: capture.imageHeight, + origin: capture.origin, + mediaBytes: capture.mediaBytes, + mediaComplete: capture.mediaComplete, + external: capture.external, + ); + } + + Future<({Uint8List bytes, String mimeType, String name})?> + _readCurrentImage() async { + final png = await _assetInput.readClipboardImagePng(); + if (png != null && png.isNotEmpty && png.length <= _policy.maximumBytes) { + return (bytes: png, mimeType: 'image/png', name: 'clipboard-image.png'); + } + final files = await _assetInput.readClipboardImageFiles(); + if (files.isEmpty) return null; + final path = files.first; + try { + final file = File(path); + final size = await file.length(); + if (size <= 0 || size > _policy.maximumBytes) return null; + final bytes = await file.readAsBytes(); + final extension = p.extension(path).toLowerCase(); + final mimeType = switch (extension) { + '.jpg' || '.jpeg' => 'image/jpeg', + '.gif' => 'image/gif', + '.webp' => 'image/webp', + '.bmp' => 'image/bmp', + '.svg' => 'image/svg+xml', + _ => 'image/png', + }; + return (bytes: bytes, mimeType: mimeType, name: p.basename(path)); + } on FileSystemException { + return null; + } + } +} + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart new file mode 100644 index 00000000..c1a9adda --- /dev/null +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -0,0 +1,486 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app/busymark_design.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/busymark_search_field.dart'; +import '../app/localization.dart'; +import 'clipboard_history_controller.dart'; +import 'clipboard_models.dart'; + +class ClipboardHistoryPanel extends ConsumerStatefulWidget { + const ClipboardHistoryPanel({this.onEscape, super.key}); + + final VoidCallback? onEscape; + + @override + ConsumerState createState() => + _ClipboardHistoryPanelState(); +} + +class _ClipboardHistoryPanelState extends ConsumerState { + final _searchController = TextEditingController(); + final _listFocusNode = FocusNode(debugLabel: 'Clipboard History list'); + String? _selectedId; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited( + ref + .read(clipboardHistoryControllerProvider.notifier) + .refreshCurrentClipboard(), + ); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + _listFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(clipboardHistoryControllerProvider); + final controller = ref.read(clipboardHistoryControllerProvider.notifier); + final registry = ref.watch(clipboardInsertionRegistryProvider); + final query = _searchController.text.toLowerCase(); + final current = state.currentClipboard; + final visible = [ + if (current != null && _matches(current, query)) current, + for (final entry in state.entries) + if ((current == null || !current.equivalentTo(entry)) && + _matches(entry, query)) + entry, + ]; + if (visible.isNotEmpty && + !visible.any((entry) => entry.id == _selectedId)) { + _selectedId = visible.first.id; + } + final target = registry.target; + final canPaste = target != null && target.editable; + + return Focus( + focusNode: _listFocusNode, + onKeyEvent: (_, event) => _handleKey(event, visible, canPaste), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + ), + child: BusyMarkSearchField( + controller: _searchController, + hintText: context.l10n.search, + onChanged: (_) => setState(() {}), + onEscape: widget.onEscape, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.xs, + ), + child: Text( + target == null + ? context.l10n.clipboardSessionOnly + : context.l10n.clipboardDestination(target.documentName), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), + child: Row( + children: [ + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel, + onPressed: state.refreshing + ? null + : controller.refreshCurrentClipboard, + icon: state.refreshing + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(BusyMarkGlyphs.refresh), + ), + const Spacer(), + IconButton( + tooltip: context.l10n.clipboardClearAll, + onPressed: state.entries.isEmpty ? null : controller.clear, + icon: const Icon(BusyMarkGlyphs.clearAll), + ), + ], + ), + ), + if (!controller.collectionEnabled) + _PanelNotice( + icon: BusyMarkGlyphs.info, + text: context.l10n.clipboardDisabled, + ) + else if (state.lastRetentionResult == + ClipboardRetentionResult.oversized) + _PanelNotice( + icon: BusyMarkGlyphs.warning, + text: context.l10n.clipboardTooLarge, + ), + Expanded( + child: visible.isEmpty + ? _PanelEmpty( + text: query.isNotEmpty + ? context.l10n.clipboardNoMatches + : state.clipboardAvailable + ? context.l10n.clipboardNoItems + : context.l10n.clipboardUnavailable, + ) + : ListView.builder( + itemCount: visible.length, + itemBuilder: (context, index) { + final payload = visible[index]; + final isCurrent = identical(payload, current); + return _ClipboardEntryTile( + payload: payload, + current: isCurrent, + selected: payload.id == _selectedId, + canPaste: canPaste, + onSelect: () { + setState(() => _selectedId = payload.id); + _listFocusNode.requestFocus(); + }, + onPaste: () => _paste(payload, plainText: false), + onPastePlain: payload.hasMeaningfulTextRepresentation + ? () => _paste(payload, plainText: true) + : null, + onRemove: isCurrent + ? null + : () => controller.remove(payload.id), + ); + }, + ), + ), + ], + ), + ); + } + + bool _matches(BusyMarkClipboardPayload payload, String query) { + if (query.isEmpty) return true; + return [ + payload.text, + payload.sourceText, + payload.imageDisplayName, + payload.origin?.documentName, + payload.origin?.documentPath, + ].whereType().any((value) => value.toLowerCase().contains(query)); + } + + KeyEventResult _handleKey( + KeyEvent event, + List visible, + bool canPaste, + ) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onEscape?.call(); + return KeyEventResult.handled; + } + if (visible.isEmpty) return KeyEventResult.ignored; + final index = visible.indexWhere((entry) => entry.id == _selectedId); + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + final delta = event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1; + setState(() { + _selectedId = + visible[(index < 0 ? 0 : index + delta).clamp( + 0, + visible.length - 1, + )] + .id; + }); + return KeyEventResult.handled; + } + final selected = index < 0 ? visible.first : visible[index]; + if (event.logicalKey == LogicalKeyboardKey.enter && canPaste) { + unawaited(_paste(selected, plainText: false)); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.delete && + ref + .read(clipboardHistoryControllerProvider) + .entries + .any((entry) => entry.id == selected.id)) { + ref.read(clipboardHistoryControllerProvider.notifier).remove(selected.id); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + Future _paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + final result = await ref + .read(clipboardInsertionRegistryProvider) + .paste(payload, plainText: plainText); + if (result == ClipboardPasteResult.inserted && payload.external) { + ref + .read(clipboardHistoryControllerProvider.notifier) + .retainCurrentAfterPaste(payload); + } + } +} + +class _ClipboardEntryTile extends StatelessWidget { + const _ClipboardEntryTile({ + required this.payload, + required this.current, + required this.selected, + required this.canPaste, + required this.onSelect, + required this.onPaste, + required this.onPastePlain, + required this.onRemove, + }); + + final BusyMarkClipboardPayload payload; + final bool current; + final bool selected; + final bool canPaste; + final VoidCallback onSelect; + final VoidCallback onPaste; + final VoidCallback? onPastePlain; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final kindLabel = switch (payload.kind) { + BusyMarkClipboardContentKind.text => l10n.clipboardEntryText, + BusyMarkClipboardContentKind.richText => l10n.clipboardEntryRichText, + BusyMarkClipboardContentKind.image => l10n.clipboardEntryImage, + }; + final origin = payload.origin; + final tooltip = origin == null + ? null + : '${l10n.clipboardOrigin(origin.documentName)}\n' + '${origin.documentPath ?? ''}'; + return Semantics( + selected: selected, + button: true, + label: '$kindLabel ${_timestamp(context, payload.acquiredAt)}', + child: InkWell( + onTap: onSelect, + onDoubleTap: canPaste ? onPaste : null, + child: Container( + color: selected + ? Theme.of(context).colorScheme.primaryContainer + : null, + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + BusyMarkSpacing.xs, + BusyMarkSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + payload.kind == BusyMarkClipboardContentKind.image + ? BusyMarkGlyphs.image + : BusyMarkGlyphs.copy, + size: 16, + ), + const SizedBox(width: BusyMarkSpacing.xs), + Expanded( + child: Text( + current && payload.external + ? l10n.clipboardCurrentExternal + : current + ? l10n.clipboardCurrent + : kindLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + Text( + _timestamp(context, payload.acquiredAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: BusyMarkSpacing.xs), + Tooltip( + message: tooltip ?? '', + child: _ClipboardPreview(payload, expanded: selected), + ), + if (origin != null) ...[ + const SizedBox(height: BusyMarkSpacing.xs), + Text( + l10n.clipboardOrigin(origin.documentName), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: l10n.paste, + onPressed: canPaste ? onPaste : null, + icon: const Icon(BusyMarkGlyphs.paste, size: 18), + ), + if (onPastePlain != null) + IconButton( + tooltip: l10n.clipboardPastePlainText, + onPressed: canPaste ? onPastePlain : null, + icon: const Icon(BusyMarkGlyphs.text, size: 18), + ), + if (onRemove != null) + IconButton( + tooltip: l10n.removeAction, + onPressed: onRemove, + icon: const Icon(BusyMarkGlyphs.delete, size: 18), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _ClipboardPreview extends StatefulWidget { + const _ClipboardPreview(this.payload, {required this.expanded}); + + final BusyMarkClipboardPayload payload; + final bool expanded; + + @override + State<_ClipboardPreview> createState() => _ClipboardPreviewState(); +} + +class _ClipboardPreviewState extends State<_ClipboardPreview> { + ImageProvider? _image; + + @override + void didUpdateWidget(covariant _ClipboardPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.payload.id != widget.payload.id) { + _image?.evict(); + _image = null; + } + } + + @override + void dispose() { + _image?.evict(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final payload = widget.payload; + final bytes = payload.imageBytes; + if (bytes != null && widget.expanded) { + final image = _image ??= ResizeImage.resizeIfNeeded( + 512, + 512, + MemoryImage(bytes), + ); + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 128), + child: Image( + // At most one selected preview is decoded, and both dimensions are + // bounded. RGBA decoding therefore stays well below the policy's + // separate 8 MiB thumbnail-cache budget. + image: image, + fit: BoxFit.contain, + errorBuilder: (_, _, _) { + return const Center(child: Icon(BusyMarkGlyphs.imageMissing)); + }, + ), + ); + } + if (bytes != null) { + final prior = _image; + _image = null; + if (prior != null) unawaited(prior.evict()); + return Text( + payload.imageDisplayName ?? context.l10n.clipboardEntryImage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + return Text( + payload.preferredSourceText ?? payload.text ?? '', + maxLines: widget.expanded ? 6 : 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ); + } +} + +class _PanelNotice extends StatelessWidget { + const _PanelNotice({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.md), + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text(text, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ), + ); +} + +class _PanelEmpty extends StatelessWidget { + const _PanelEmpty({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Text(text, textAlign: TextAlign.center), + ), + ); +} + +String _timestamp(BuildContext context, DateTime timestamp) { + final local = timestamp.toLocal(); + final material = MaterialLocalizations.of(context); + return '${material.formatShortDate(local)} ${material.formatTimeOfDay(TimeOfDay.fromDateTime(local))}'; +} diff --git a/lib/src/clipboard/clipboard_insertion.dart b/lib/src/clipboard/clipboard_insertion.dart new file mode 100644 index 00000000..ad19ee90 --- /dev/null +++ b/lib/src/clipboard/clipboard_insertion.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'clipboard_models.dart'; + +abstract interface class BusyMarkClipboardInsertionTarget { + String get documentId; + String get documentName; + String? get documentPath; + bool get editable; + + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }); + + void requestEditorFocus(); +} + +/// Holds only the currently mounted document surface. Registrations remove +/// themselves by identity, so a retiring editor cannot unregister its +/// replacement or leave a disposed controller reachable from session state. +class BusyMarkClipboardInsertionRegistry extends ChangeNotifier { + BusyMarkClipboardInsertionTarget? _target; + bool _notificationScheduled = false; + bool _disposed = false; + + BusyMarkClipboardInsertionTarget? get target => _target; + + void register(BusyMarkClipboardInsertionTarget target) { + if (identical(_target, target)) return; + _target = target; + _scheduleNotification(); + } + + void unregister(BusyMarkClipboardInsertionTarget target) { + if (!identical(_target, target)) return; + _target = null; + _scheduleNotification(); + } + + void _scheduleNotification() { + if (_notificationScheduled || _disposed) return; + _notificationScheduled = true; + scheduleMicrotask(() { + _notificationScheduled = false; + if (!_disposed) notifyListeners(); + }); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future paste( + BusyMarkClipboardPayload payload, { + bool plainText = false, + }) async { + final captured = _target; + if (captured == null || !captured.editable) { + return ClipboardPasteResult.unavailable; + } + final result = await captured.paste(payload, plainText: plainText); + if (result == ClipboardPasteResult.inserted && + identical(_target, captured)) { + captured.requestEditorFocus(); + } + return result; + } +} diff --git a/lib/src/clipboard/clipboard_models.dart b/lib/src/clipboard/clipboard_models.dart new file mode 100644 index 00000000..a9cb66b5 --- /dev/null +++ b/lib/src/clipboard/clipboard_models.dart @@ -0,0 +1,271 @@ +import 'dart:convert'; +import 'dart:typed_data' show BytesBuilder, Uint8List; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +enum BusyMarkClipboardContentKind { text, richText, image } + +@immutable +class BusyMarkClipboardOrigin { + const BusyMarkClipboardOrigin({ + required this.documentId, + required this.documentName, + this.documentPath, + }); + + final String documentId; + final String documentName; + final String? documentPath; +} + +/// An immutable, source-independent clipboard snapshot. +/// +/// [sourceText] is the representation inserted into Source mode. [text] is +/// the interoperable plain-text representation. A rich fragment is an opaque, +/// validated BusyMark schema and is never reconstructed from plain-text +/// equality. +@immutable +class BusyMarkClipboardPayload { + BusyMarkClipboardPayload({ + required this.id, + required this.acquiredAt, + required this.kind, + this.text, + this.sourceText, + this.html, + this.richFragment, + Uint8List? imageBytes, + this.imageMimeType, + this.imageDisplayName, + this.imageWidth, + this.imageHeight, + this.origin, + Map mediaBytes = const {}, + this.mediaComplete = true, + this.external = false, + }) : imageBytes = imageBytes == null ? null : Uint8List.fromList(imageBytes), + mediaBytes = Map.unmodifiable({ + for (final entry in mediaBytes.entries) + entry.key: Uint8List.fromList(entry.value), + }), + fingerprint = _fingerprint( + kind: kind, + text: text, + sourceText: sourceText, + html: html, + richFragment: richFragment, + imageBytes: imageBytes, + imageMimeType: imageMimeType, + imageDisplayName: imageDisplayName, + imageWidth: imageWidth, + imageHeight: imageHeight, + origin: origin, + mediaBytes: mediaBytes, + mediaComplete: mediaComplete, + ), + accountedBytes = _accountedBytes( + text: text, + sourceText: sourceText, + html: html, + richFragment: richFragment, + imageBytes: imageBytes, + imageMimeType: imageMimeType, + imageDisplayName: imageDisplayName, + imageWidth: imageWidth, + imageHeight: imageHeight, + origin: origin, + mediaBytes: mediaBytes, + ); + + final String id; + final DateTime acquiredAt; + final BusyMarkClipboardContentKind kind; + final String? text; + final String? sourceText; + final String? html; + final String? richFragment; + final Uint8List? imageBytes; + final String? imageMimeType; + final String? imageDisplayName; + final int? imageWidth; + final int? imageHeight; + final BusyMarkClipboardOrigin? origin; + final Map mediaBytes; + final bool mediaComplete; + final bool external; + final String fingerprint; + final int accountedBytes; + + bool get hasMeaningfulTextRepresentation => + sourceText != null || text != null; + + String? get preferredSourceText => sourceText ?? text; + + bool equivalentTo(BusyMarkClipboardPayload other) => + fingerprint == other.fingerprint && + kind == other.kind && + text == other.text && + sourceText == other.sourceText && + html == other.html && + richFragment == other.richFragment && + imageMimeType == other.imageMimeType && + imageDisplayName == other.imageDisplayName && + imageWidth == other.imageWidth && + imageHeight == other.imageHeight && + origin?.documentId == other.origin?.documentId && + origin?.documentPath == other.origin?.documentPath && + mediaComplete == other.mediaComplete && + _sameMediaBytes(mediaBytes, other.mediaBytes) && + listEquals(imageBytes, other.imageBytes); + + static String _fingerprint({ + required BusyMarkClipboardContentKind kind, + required String? text, + required String? sourceText, + required String? html, + required String? richFragment, + required Uint8List? imageBytes, + required String? imageMimeType, + required String? imageDisplayName, + required int? imageWidth, + required int? imageHeight, + required BusyMarkClipboardOrigin? origin, + required Map mediaBytes, + required bool mediaComplete, + }) { + final bytes = BytesBuilder(copy: false); + void addText(String label, String? value) { + bytes.add(utf8.encode('$label\u0000${value ?? ''}\u0000')); + } + + addText('kind', kind.name); + addText('text', text); + addText('source', sourceText); + addText('html', html); + addText('fragment', richFragment); + addText('mime', imageMimeType); + addText('name', imageDisplayName); + addText('width', imageWidth?.toString()); + addText('height', imageHeight?.toString()); + addText('origin-document', origin?.documentId); + addText('origin-path', origin?.documentPath); + addText('media-complete', mediaComplete.toString()); + final mediaKeys = mediaBytes.keys.toList()..sort(); + for (final key in mediaKeys) { + addText('media-key', key); + bytes.add(mediaBytes[key]!); + } + if (imageBytes != null) bytes.add(imageBytes); + return sha256.convert(bytes.takeBytes()).toString(); + } + + static int _accountedBytes({ + required String? text, + required String? sourceText, + required String? html, + required String? richFragment, + required Uint8List? imageBytes, + required String? imageMimeType, + required String? imageDisplayName, + required int? imageWidth, + required int? imageHeight, + required BusyMarkClipboardOrigin? origin, + required Map mediaBytes, + }) { + var result = imageBytes?.lengthInBytes ?? 0; + for (final value in [ + text, + sourceText, + html, + richFragment, + imageMimeType, + imageDisplayName, + imageWidth?.toString(), + imageHeight?.toString(), + origin?.documentId, + origin?.documentName, + origin?.documentPath, + ]) { + if (value != null) result += utf8.encode(value).length; + } + for (final entry in mediaBytes.entries) { + result += utf8.encode(entry.key).length + entry.value.lengthInBytes; + } + return result; + } +} + +@immutable +class BusyMarkClipboardCapture { + const BusyMarkClipboardCapture({ + required this.kind, + this.text, + this.sourceText, + this.html, + this.richFragment, + this.imageBytes, + this.imageMimeType, + this.imageDisplayName, + this.imageWidth, + this.imageHeight, + this.origin, + this.mediaBytes = const {}, + this.mediaComplete = true, + this.external = false, + }); + + final BusyMarkClipboardContentKind kind; + final String? text; + final String? sourceText; + final String? html; + final String? richFragment; + final Uint8List? imageBytes; + final String? imageMimeType; + final String? imageDisplayName; + final int? imageWidth; + final int? imageHeight; + final BusyMarkClipboardOrigin? origin; + final Map mediaBytes; + final bool mediaComplete; + final bool external; + + bool get isSupported => switch (kind) { + BusyMarkClipboardContentKind.text => text != null || sourceText != null, + BusyMarkClipboardContentKind.richText => + mediaComplete && + (richFragment != null || sourceText != null || text != null), + BusyMarkClipboardContentKind.image => + imageBytes != null && imageBytes!.isNotEmpty, + }; +} + +bool _sameMediaBytes( + Map left, + Map right, +) { + if (left.length != right.length) return false; + for (final entry in left.entries) { + if (!listEquals(entry.value, right[entry.key])) return false; + } + return true; +} + +@immutable +class ClipboardHistoryPolicy { + const ClipboardHistoryPolicy({ + this.maximumEntries = 100, + this.maximumBytes = 64 * 1024 * 1024, + this.maximumThumbnailBytes = 8 * 1024 * 1024, + }) : assert(maximumEntries > 0), + assert(maximumBytes > 0), + assert(maximumThumbnailBytes > 0); + + final int maximumEntries; + final int maximumBytes; + final int maximumThumbnailBytes; +} + +enum ClipboardRetentionResult { retained, deduplicated, disabled, oversized } + +enum ClipboardPasteResult { inserted, staleTarget, unsupported, unavailable } diff --git a/lib/src/comparison/source_comparison.dart b/lib/src/comparison/source_comparison.dart new file mode 100644 index 00000000..5f035a7e --- /dev/null +++ b/lib/src/comparison/source_comparison.dart @@ -0,0 +1,461 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; + +@immutable +class SourceComparisonInput { + const SourceComparisonInput({ + required this.id, + required this.version, + required this.label, + required this.source, + }); + + final String id; + final int version; + final String label; + final String source; +} + +@immutable +class SourceComparisonRange { + const SourceComparisonRange(this.start, this.end) + : assert(start >= 0), + assert(end >= start); + + final int start; + final int end; +} + +@immutable +class SourceComparisonChange { + const SourceComparisonChange({ + required this.oldRange, + required this.currentRange, + required this.oldText, + required this.currentText, + required this.oldIntralineRange, + required this.currentIntralineRange, + required this.exact, + }); + + final SourceComparisonRange oldRange; + final SourceComparisonRange currentRange; + final String oldText; + final String currentText; + final SourceComparisonRange oldIntralineRange; + final SourceComparisonRange currentIntralineRange; + final bool exact; +} + +@immutable +class SourceComparison { + const SourceComparison({ + required this.oldInput, + required this.currentInput, + required this.changes, + required this.simplified, + }); + + final SourceComparisonInput oldInput; + final SourceComparisonInput currentInput; + final List changes; + final bool simplified; + + bool stillMatches(SourceComparisonInput old, SourceComparisonInput current) => + old.id == oldInput.id && + old.version == oldInput.version && + current.id == currentInput.id && + current.version == currentInput.version; +} + +SourceComparison compareSource( + SourceComparisonInput oldInput, + SourceComparisonInput currentInput, { + int maximumLcsCells = 2000000, +}) { + final oldLines = _sourceLines(oldInput.source); + final currentLines = _sourceLines(currentInput.source); + final operations = <_DiffOperation>[]; + _diffSegment( + oldLines, + 0, + oldLines.length, + currentLines, + 0, + currentLines.length, + operations, + maximumLcsCells, + ); + final changes = _changesFromOperations(operations); + return SourceComparison( + oldInput: oldInput, + currentInput: currentInput, + changes: List.unmodifiable(changes), + simplified: changes.any((change) => !change.exact), + ); +} + +class _SourceLine { + const _SourceLine(this.text, this.start, this.end); + + final String text; + final int start; + final int end; +} + +List<_SourceLine> _sourceLines(String source) { + if (source.isEmpty) return const []; + final result = <_SourceLine>[]; + var start = 0; + while (start < source.length) { + final newline = source.indexOf('\n', start); + final end = newline < 0 ? source.length : newline + 1; + result.add(_SourceLine(source.substring(start, end), start, end)); + start = end; + } + return result; +} + +enum _OperationKind { equal, delete, insert } + +class _DiffOperation { + const _DiffOperation(this.kind, this.line, {this.exact = true}); + + final _OperationKind kind; + final _SourceLine line; + final bool exact; +} + +void _diffSegment( + List<_SourceLine> oldLines, + int oldStart, + int oldEnd, + List<_SourceLine> currentLines, + int currentStart, + int currentEnd, + List<_DiffOperation> output, + int maximumLcsCells, +) { + while (oldStart < oldEnd && + currentStart < currentEnd && + oldLines[oldStart].text == currentLines[currentStart].text) { + output.add(_DiffOperation(_OperationKind.equal, oldLines[oldStart])); + oldStart++; + currentStart++; + } + var suffix = 0; + while (oldStart < oldEnd - suffix && + currentStart < currentEnd - suffix && + oldLines[oldEnd - suffix - 1].text == + currentLines[currentEnd - suffix - 1].text) { + suffix++; + } + oldEnd -= suffix; + currentEnd -= suffix; + if (oldStart == oldEnd || currentStart == currentEnd) { + for (var index = oldStart; index < oldEnd; index++) { + output.add(_DiffOperation(_OperationKind.delete, oldLines[index])); + } + for (var index = currentStart; index < currentEnd; index++) { + output.add(_DiffOperation(_OperationKind.insert, currentLines[index])); + } + } else { + final anchors = _patienceAnchors( + oldLines, + oldStart, + oldEnd, + currentLines, + currentStart, + currentEnd, + ); + if (anchors.isNotEmpty) { + var previousOld = oldStart; + var previousCurrent = currentStart; + for (final anchor in anchors) { + _diffSegment( + oldLines, + previousOld, + anchor.oldIndex, + currentLines, + previousCurrent, + anchor.currentIndex, + output, + maximumLcsCells, + ); + output.add( + _DiffOperation(_OperationKind.equal, oldLines[anchor.oldIndex]), + ); + previousOld = anchor.oldIndex + 1; + previousCurrent = anchor.currentIndex + 1; + } + _diffSegment( + oldLines, + previousOld, + oldEnd, + currentLines, + previousCurrent, + currentEnd, + output, + maximumLcsCells, + ); + } else if ((oldEnd - oldStart) * (currentEnd - currentStart) <= + maximumLcsCells) { + _boundedLcs( + oldLines, + oldStart, + oldEnd, + currentLines, + currentStart, + currentEnd, + output, + ); + } else { + for (var index = oldStart; index < oldEnd; index++) { + output.add( + _DiffOperation(_OperationKind.delete, oldLines[index], exact: false), + ); + } + for (var index = currentStart; index < currentEnd; index++) { + output.add( + _DiffOperation( + _OperationKind.insert, + currentLines[index], + exact: false, + ), + ); + } + } + } + for (var index = 0; index < suffix; index++) { + output.add(_DiffOperation(_OperationKind.equal, oldLines[oldEnd + index])); + } +} + +class _Anchor { + const _Anchor(this.oldIndex, this.currentIndex); + + final int oldIndex; + final int currentIndex; +} + +List<_Anchor> _patienceAnchors( + List<_SourceLine> oldLines, + int oldStart, + int oldEnd, + List<_SourceLine> currentLines, + int currentStart, + int currentEnd, +) { + final oldCounts = {}; + final oldPositions = {}; + for (var index = oldStart; index < oldEnd; index++) { + final text = oldLines[index].text; + oldCounts[text] = (oldCounts[text] ?? 0) + 1; + oldPositions[text] = index; + } + final currentCounts = {}; + final currentPositions = {}; + for (var index = currentStart; index < currentEnd; index++) { + final text = currentLines[index].text; + currentCounts[text] = (currentCounts[text] ?? 0) + 1; + currentPositions[text] = index; + } + final candidates = <_Anchor>[]; + for (final entry in oldPositions.entries) { + if (oldCounts[entry.key] == 1 && currentCounts[entry.key] == 1) { + candidates.add(_Anchor(entry.value, currentPositions[entry.key]!)); + } + } + candidates.sort((left, right) => left.oldIndex.compareTo(right.oldIndex)); + if (candidates.isEmpty) return const []; + + final tails = []; + final tailCandidate = []; + final previous = List.filled(candidates.length, -1); + for (var index = 0; index < candidates.length; index++) { + final value = candidates[index].currentIndex; + var low = 0; + var high = tails.length; + while (low < high) { + final middle = (low + high) >> 1; + if (tails[middle] < value) { + low = middle + 1; + } else { + high = middle; + } + } + if (low > 0) previous[index] = tailCandidate[low - 1]; + if (low == tails.length) { + tails.add(value); + tailCandidate.add(index); + } else { + tails[low] = value; + tailCandidate[low] = index; + } + } + final result = <_Anchor>[]; + var index = tailCandidate.last; + while (index >= 0) { + result.add(candidates[index]); + index = previous[index]; + } + return result.reversed.toList(growable: false); +} + +void _boundedLcs( + List<_SourceLine> oldLines, + int oldStart, + int oldEnd, + List<_SourceLine> currentLines, + int currentStart, + int currentEnd, + List<_DiffOperation> output, +) { + final oldCount = oldEnd - oldStart; + final currentCount = currentEnd - currentStart; + final width = currentCount + 1; + final table = Uint32List((oldCount + 1) * width); + for (var old = oldCount - 1; old >= 0; old--) { + for (var current = currentCount - 1; current >= 0; current--) { + final cell = old * width + current; + table[cell] = + oldLines[oldStart + old].text == + currentLines[currentStart + current].text + ? table[(old + 1) * width + current + 1] + 1 + : math.max( + table[(old + 1) * width + current], + table[old * width + current + 1], + ); + } + } + var old = 0; + var current = 0; + while (old < oldCount && current < currentCount) { + final oldLine = oldLines[oldStart + old]; + final currentLine = currentLines[currentStart + current]; + if (oldLine.text == currentLine.text) { + output.add(_DiffOperation(_OperationKind.equal, oldLine)); + old++; + current++; + } else if (table[(old + 1) * width + current] >= + table[old * width + current + 1]) { + output.add(_DiffOperation(_OperationKind.delete, oldLine)); + old++; + } else { + output.add(_DiffOperation(_OperationKind.insert, currentLine)); + current++; + } + } + while (old < oldCount) { + output.add( + _DiffOperation(_OperationKind.delete, oldLines[oldStart + old++]), + ); + } + while (current < currentCount) { + output.add( + _DiffOperation( + _OperationKind.insert, + currentLines[currentStart + current++], + ), + ); + } +} + +List _changesFromOperations( + List<_DiffOperation> operations, +) { + final result = []; + var oldOffset = 0; + var currentOffset = 0; + var index = 0; + while (index < operations.length) { + final operation = operations[index]; + if (operation.kind == _OperationKind.equal) { + oldOffset += operation.line.text.length; + currentOffset += operation.line.text.length; + index++; + continue; + } + final oldStart = oldOffset; + final currentStart = currentOffset; + final old = StringBuffer(); + final current = StringBuffer(); + var exact = true; + while (index < operations.length && + operations[index].kind != _OperationKind.equal) { + final changed = operations[index++]; + exact = exact && changed.exact; + if (changed.kind == _OperationKind.delete) { + old.write(changed.line.text); + oldOffset += changed.line.text.length; + } else { + current.write(changed.line.text); + currentOffset += changed.line.text.length; + } + } + final oldText = old.toString(); + final currentText = current.toString(); + final intraline = _intralineRanges(oldText, currentText); + result.add( + SourceComparisonChange( + oldRange: SourceComparisonRange(oldStart, oldOffset), + currentRange: SourceComparisonRange(currentStart, currentOffset), + oldText: oldText, + currentText: currentText, + oldIntralineRange: intraline.oldRange, + currentIntralineRange: intraline.currentRange, + exact: exact, + ), + ); + } + return result; +} + +({SourceComparisonRange oldRange, SourceComparisonRange currentRange}) +_intralineRanges(String oldText, String currentText) { + var prefix = 0; + final limit = math.min(oldText.length, currentText.length); + while (prefix < limit && + oldText.codeUnitAt(prefix) == currentText.codeUnitAt(prefix)) { + prefix++; + } + prefix = _safeUtf16Boundary(oldText, prefix, backwards: true); + prefix = math.min( + prefix, + _safeUtf16Boundary(currentText, prefix, backwards: true), + ); + var oldSuffix = oldText.length; + var currentSuffix = currentText.length; + while (oldSuffix > prefix && + currentSuffix > prefix && + oldText.codeUnitAt(oldSuffix - 1) == + currentText.codeUnitAt(currentSuffix - 1)) { + oldSuffix--; + currentSuffix--; + } + oldSuffix = _safeUtf16Boundary(oldText, oldSuffix, backwards: false); + currentSuffix = _safeUtf16Boundary( + currentText, + currentSuffix, + backwards: false, + ); + return ( + oldRange: SourceComparisonRange(prefix, oldSuffix), + currentRange: SourceComparisonRange(prefix, currentSuffix), + ); +} + +int _safeUtf16Boundary(String value, int offset, {required bool backwards}) { + if (offset <= 0 || offset >= value.length) return offset; + final before = value.codeUnitAt(offset - 1); + final after = value.codeUnitAt(offset); + final splitsSurrogate = + before >= 0xd800 && + before <= 0xdbff && + after >= 0xdc00 && + after <= 0xdfff; + if (!splitsSurrogate) return offset; + return backwards ? offset - 1 : offset + 1; +} diff --git a/lib/src/editor/editor_text_context_menu.dart b/lib/src/editor/editor_text_context_menu.dart index c9e24bc8..218d8c2a 100644 --- a/lib/src/editor/editor_text_context_menu.dart +++ b/lib/src/editor/editor_text_context_menu.dart @@ -13,6 +13,7 @@ Widget buildBusyMarkEditorTextContextMenu( VoidCallback? onRefineWithAi, VoidCallback? onCut, VoidCallback? onCopy, + VoidCallback? onPaste, VoidCallback? onCopyPlainText, List> additionalItems = const [], }) { @@ -22,6 +23,7 @@ Widget buildBusyMarkEditorTextContextMenu( onRefineWithAi: onRefineWithAi, onCut: onCut, onCopy: onCopy, + onPaste: onPaste, onCopyPlainText: onCopyPlainText, additionalItems: additionalItems, ); @@ -34,6 +36,7 @@ class _BusyMarkEditorTextContextMenu extends StatefulWidget { required this.onRefineWithAi, required this.onCut, required this.onCopy, + required this.onPaste, required this.onCopyPlainText, required this.additionalItems, }); @@ -43,6 +46,7 @@ class _BusyMarkEditorTextContextMenu extends StatefulWidget { final VoidCallback? onRefineWithAi; final VoidCallback? onCut; final VoidCallback? onCopy; + final VoidCallback? onPaste; final VoidCallback? onCopyPlainText; final List> additionalItems; @@ -114,6 +118,7 @@ class _BusyMarkEditorTextContextMenuState final callback = switch (item.type) { ContextMenuButtonType.cut => widget.onCut ?? item.onPressed, ContextMenuButtonType.copy => widget.onCopy ?? item.onPressed, + ContextMenuButtonType.paste => widget.onPaste ?? item.onPressed, _ => item.onPressed, }; if (_commandIdFor(item.type) case final commandId?) { @@ -165,6 +170,24 @@ class _BusyMarkEditorTextContextMenuState ), ); } + items.add(const PopupMenuDivider(height: BusyMarkSpacing.sm)); + for (final entry in [ + (BusyMarkCommandIds.clipboardHistory, BusyMarkGlyphs.copy), + (BusyMarkCommandIds.localHistory, BusyMarkGlyphs.documentHistory), + ]) { + final command = commands[entry.$1]; + if (command != null) { + items.add( + BusyMarkPopupMenuItem( + value: () => unawaited(commands.execute(entry.$1)), + label: command.label(context), + icon: entry.$2, + shortcut: command.shortcut?.label, + enabled: command.enabled(), + ), + ); + } + } items.addAll(widget.additionalItems); return items; } diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 9dec96e5..04be8b88 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import 'package:yaru/yaru.dart'; import '../../ai/ai_models.dart'; @@ -14,7 +15,10 @@ import '../../app/busymark_shortcuts.dart'; import '../../app/busymark_toast.dart'; import '../../app/command_registry.dart'; import '../../app/localization.dart'; +import '../../clipboard/clipboard_insertion.dart'; +import '../../clipboard/clipboard_models.dart'; import '../../core/diagnostic.dart'; +import '../../platform/rich_clipboard_service.dart'; import '../../search/search_replace_service.dart'; import '../document_text_geometry.dart'; import '../editor_text_context_menu.dart'; @@ -81,6 +85,9 @@ class BusyMarkSourceEditor extends StatefulWidget { this.onSessionChanged, this.autocompleteContext = const SourceAutocompleteContext(), this.onSymbolAction, + this.clipboardService, + this.clipboardInsertionRegistry, + this.onClipboardCaptured, }); final String text; @@ -110,6 +117,9 @@ class BusyMarkSourceEditor extends StatefulWidget { final BusyMarkSourceSessionChanged? onSessionChanged; final SourceAutocompleteContext autocompleteContext; final void Function(SourceSymbolAction action, int offset)? onSymbolAction; + final RichClipboardService? clipboardService; + final BusyMarkClipboardInsertionRegistry? clipboardInsertionRegistry; + final ValueChanged? onClipboardCaptured; @override State createState() => BusyMarkSourceEditorState(); @@ -142,6 +152,10 @@ class BusyMarkSourceEditorState extends State { bool _sessionPublicationScheduled = false; final _undoSessionId = ++_sourceEditorUndoSessionSequence; var _undoGroupSequence = 0; + late final _SourceClipboardInsertionTarget _historyInsertionTarget; + + RichClipboardService get _clipboard => + widget.clipboardService ?? busyMarkRichClipboardService; @override void initState() { @@ -150,6 +164,8 @@ class BusyMarkSourceEditorState extends State { text: widget.text, language: widget.language, ); + _historyInsertionTarget = _SourceClipboardInsertionTarget(this); + widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); _scrollController = ScrollController(); _horizontalScrollController = ScrollController(); @@ -166,6 +182,11 @@ class BusyMarkSourceEditorState extends State { @override void didUpdateWidget(covariant BusyMarkSourceEditor oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardInsertionRegistry != + widget.clipboardInsertionRegistry) { + oldWidget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); + widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); + } if (widget.text != oldWidget.text || widget.searchOptions != oldWidget.searchOptions || widget.searchReplacement != oldWidget.searchReplacement) { @@ -212,6 +233,7 @@ class BusyMarkSourceEditorState extends State { @override void dispose() { + widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); _searchDebounce?.cancel(); _foldRefreshDebounce?.cancel(); _searchWorker.dispose(); @@ -372,6 +394,26 @@ class BusyMarkSourceEditorState extends State { widget.onOpenSearch(); return KeyEventResult.handled; } + if (commands.shortcutAccepts( + BusyMarkCommandIds.textCopy, + event, + keyboard, + )) { + unawaited(_copyOrCutSource(cut: false)); + return KeyEventResult.handled; + } + if (commands.shortcutAccepts(BusyMarkCommandIds.textCut, event, keyboard)) { + unawaited(_copyOrCutSource(cut: true)); + return KeyEventResult.handled; + } + if (commands.shortcutAccepts( + BusyMarkCommandIds.textPaste, + event, + keyboard, + )) { + unawaited(_pasteFromSystemClipboard()); + return KeyEventResult.handled; + } if (commands.shortcutAccepts( BusyMarkCommandIds.textUndo, event, @@ -495,8 +537,25 @@ class BusyMarkSourceEditorState extends State { BusyMarkContextCommandAction( isCommandEnabled: (commandId) => !_hasActiveComposition && - commandId.startsWith('editor.'), + (commandId.startsWith('editor.') || + { + BusyMarkCommandIds.textCopy, + BusyMarkCommandIds.textCut, + BusyMarkCommandIds.textPaste, + }.contains(commandId)), onCommand: (commandId) { + if (commandId == BusyMarkCommandIds.textCopy) { + unawaited(_copyOrCutSource(cut: false)); + return; + } + if (commandId == BusyMarkCommandIds.textCut) { + unawaited(_copyOrCutSource(cut: true)); + return; + } + if (commandId == BusyMarkCommandIds.textPaste) { + unawaited(_pasteFromSystemClipboard()); + return; + } final name = commandId.substring( 'editor.'.length, ); @@ -595,6 +654,12 @@ class BusyMarkSourceEditorState extends State { onRefineWithAi: widget.onAiEdit == null ? null : () => unawaited(_runAiEdit()), + onCopy: () => + unawaited(_copyOrCutSource(cut: false)), + onCut: () => + unawaited(_copyOrCutSource(cut: true)), + onPaste: () => + unawaited(_pasteFromSystemClipboard()), ), onChanged: (_) => _handleSourceChanged(), ), @@ -1415,15 +1480,7 @@ class BusyMarkSourceEditorState extends State { unawaited(_runAiEdit()); break; case BusyMarkEditorShortcutAction.copyPlainText: - final value = _fullEditingValue(); - final selection = value.selection; - if (selection.isValid && !selection.isCollapsed) { - unawaited( - Clipboard.setData( - ClipboardData(text: selection.textInside(value.text)), - ), - ); - } + unawaited(_copyOrCutSource(cut: false)); break; case BusyMarkEditorShortcutAction.bold: _applyInlineCommand(SourceInlineCommand.bold); @@ -1543,6 +1600,139 @@ class BusyMarkSourceEditorState extends State { } } + _SourceClipboardOperationTarget _captureClipboardTarget() => + _SourceClipboardOperationTarget( + documentId: widget.documentId ?? widget.filePath ?? '', + filePath: widget.filePath, + text: _controller.fullText, + selection: _controller.fullSelection, + ); + + bool _isClipboardTargetCurrent(_SourceClipboardOperationTarget target) => + mounted && + target.documentId == (widget.documentId ?? widget.filePath ?? '') && + target.filePath == widget.filePath && + target.text == _controller.fullText && + target.selection == _controller.fullSelection; + + Future _copyOrCutSource({required bool cut}) async { + if (_hasActiveComposition) return false; + final target = _captureClipboardTarget(); + final selection = target.selection; + if (!selection.isValid || selection.isCollapsed) return false; + final start = selection.start.clamp(0, target.text.length).toInt(); + final end = selection.end.clamp(start, target.text.length).toInt(); + final selected = target.text.substring(start, end); + final success = await _clipboard.write( + RichClipboardData( + text: selected, + sourceText: selected, + origin: _clipboardOrigin, + ), + ); + if (!success) { + if (mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.clipboardCopyFailed, + ); + } + return false; + } + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: selected, + sourceText: selected, + origin: _clipboardOrigin, + ), + ); + if (cut && _isClipboardTargetCurrent(target)) { + _applyFullEditingValue( + TextEditingValue( + text: target.text.replaceRange(start, end, ''), + selection: TextSelection.collapsed(offset: start), + ), + ); + } + return true; + } + + Future _pasteFromSystemClipboard() async { + if (_hasActiveComposition) return ClipboardPasteResult.staleTarget; + final target = _captureClipboardTarget(); + final data = await _clipboard.read(); + if (!_isClipboardTargetCurrent(target)) { + return ClipboardPasteResult.staleTarget; + } + final text = data.sourceText ?? data.text; + if (text == null) return ClipboardPasteResult.unsupported; + final result = _insertClipboardText(target, text); + if (result == ClipboardPasteResult.inserted && !data.sessionOwned) { + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: data.richFragment == null + ? BusyMarkClipboardContentKind.text + : BusyMarkClipboardContentKind.richText, + text: data.text, + sourceText: text, + html: data.html, + richFragment: data.richFragment, + origin: _clipboardOrigin, + external: true, + ), + ); + } + return result; + } + + Future _pasteHistoryPayload( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + if (_hasActiveComposition || + payload.kind == BusyMarkClipboardContentKind.image) { + return ClipboardPasteResult.unsupported; + } + final target = _captureClipboardTarget(); + final text = plainText ? payload.text : payload.preferredSourceText; + if (text == null) return ClipboardPasteResult.unsupported; + return _insertClipboardText(target, text); + } + + ClipboardPasteResult _insertClipboardText( + _SourceClipboardOperationTarget target, + String text, + ) { + if (!_isClipboardTargetCurrent(target)) { + return ClipboardPasteResult.staleTarget; + } + final selection = target.selection.isValid + ? target.selection + : TextSelection.collapsed(offset: target.text.length); + final start = selection.start.clamp(0, target.text.length).toInt(); + final end = selection.end.clamp(start, target.text.length).toInt(); + _applyFullEditingValue( + TextEditingValue( + text: target.text.replaceRange(start, end, text), + selection: TextSelection.collapsed(offset: start + text.length), + ), + ); + return ClipboardPasteResult.inserted; + } + + BusyMarkClipboardOrigin get _clipboardOrigin { + final path = widget.filePath; + final documentId = widget.documentId ?? path ?? 'untitled'; + return BusyMarkClipboardOrigin( + documentId: documentId, + documentName: path == null || p.basename(path).isEmpty + ? documentId + : p.basename(path), + documentPath: path, + ); + } + void _applyInlineCommand(SourceInlineCommand command) { _applyFullEditingValue( SourceCommands.applyInlineCommand( @@ -2479,6 +2669,54 @@ class _SourceSessionSnapshot { } } +class _SourceClipboardOperationTarget { + const _SourceClipboardOperationTarget({ + required this.documentId, + required this.filePath, + required this.text, + required this.selection, + }); + + final String documentId; + final String? filePath; + final String text; + final TextSelection selection; +} + +class _SourceClipboardInsertionTarget + implements BusyMarkClipboardInsertionTarget { + const _SourceClipboardInsertionTarget(this.state); + + final BusyMarkSourceEditorState state; + + @override + String get documentId => + state.widget.documentId ?? state.widget.filePath ?? 'untitled'; + + @override + String get documentName { + final path = state.widget.filePath; + return path == null || p.basename(path).isEmpty + ? documentId + : p.basename(path); + } + + @override + String? get documentPath => state.widget.filePath; + + @override + bool get editable => state.mounted && !state._hasActiveComposition; + + @override + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) => state._pasteHistoryPayload(payload, plainText: plainText); + + @override + void requestEditorFocus() => state._focusNode.requestFocus(); +} + class _SourceEditorShortcutIntent extends Intent { const _SourceEditorShortcutIntent(this.action); diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index 7f83f52e..ceb537d5 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -135,7 +135,10 @@ class WysiwygClipboardFragment { } /// Rebase authored references, without opening or fetching clipboard URLs. - WysiwygClipboardFragment rebase(String targetPath) { + WysiwygClipboardFragment rebase( + String targetPath, { + Map mediaDestinations = const {}, + }) { if (sourcePath.isEmpty || targetPath.isEmpty || p.equals(sourcePath, targetPath)) { @@ -144,6 +147,8 @@ class WysiwygClipboardFragment { String? destination(String? value, {bool media = false}) { if (value == null || value.isEmpty) return value; if (media) { + final retained = mediaDestinations[value]; + if (retained != null) return retained; final resolved = mediaPaths[value]; if (resolved != null && p.isAbsolute(resolved)) { return Uri(path: resolved).toString(); diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 48683671..3c79bee4 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -15,6 +15,8 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../ai/ai_models.dart'; import '../../assets/asset_ingestion_service.dart'; import '../../assets/asset_input_service.dart'; +import '../../clipboard/clipboard_insertion.dart'; +import '../../clipboard/clipboard_models.dart'; import '../../app/app_settings.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; @@ -81,6 +83,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.assetIngestionService = const AssetIngestionService(), this.assetInputService, this.clipboardService, + this.clipboardInsertionRegistry, + this.onClipboardCaptured, this.onAssetSaveRequired, this.allowRemoteImages = false, this.onRemoteImageBlocked, @@ -120,6 +124,8 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final AssetIngestionService assetIngestionService; final AssetInputService? assetInputService; final RichClipboardService? clipboardService; + final BusyMarkClipboardInsertionRegistry? clipboardInsertionRegistry; + final ValueChanged? onClipboardCaptured; final VoidCallback? onAssetSaveRequired; final bool allowRemoteImages; final VoidCallback? onRemoteImageBlocked; @@ -190,6 +196,7 @@ class _BusyMarkWysiwygEditorState extends State { var _documentGeneration = 0; bool _sessionReportScheduled = false; double? _viewportHeight; + late final _WysiwygClipboardInsertionTarget _historyInsertionTarget; String get _documentId => widget.documentId ?? widget.document.filePath; @@ -212,6 +219,8 @@ class _BusyMarkWysiwygEditorState extends State { _documentController = BusyMarkWysiwygDocumentController( document: widget.document, )..addListener(_handleDocumentControllerChanged); + _historyInsertionTarget = _WysiwygClipboardInsertionTarget(this); + widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); _listenForDroppedAssets(); _itemPositionsListener.itemPositions.addListener( _handleVisibleItemsChanged, @@ -226,6 +235,11 @@ class _BusyMarkWysiwygEditorState extends State { @override void didUpdateWidget(covariant BusyMarkWysiwygEditor oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.clipboardInsertionRegistry != + widget.clipboardInsertionRegistry) { + oldWidget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); + widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); + } if (oldWidget.assetInputService != widget.assetInputService) { unawaited(_assetDropSubscription?.cancel()); _listenForDroppedAssets(); @@ -262,6 +276,7 @@ class _BusyMarkWysiwygEditorState extends State { @override void dispose() { + widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); unawaited(_assetDropSubscription?.cancel()); _itemPositionsListener.itemPositions.removeListener( _handleVisibleItemsChanged, @@ -4049,9 +4064,9 @@ class _BusyMarkWysiwygEditorState extends State { final target = _captureClipboardTarget(); final data = await _clipboard.read(); if (!_isClipboardTargetCurrent(target)) return; - var fragment = data.fragment == null + var fragment = data.richFragment == null ? null - : WysiwygClipboardFragment.decode(data.fragment!); + : WysiwygClipboardFragment.decode(data.richFragment!); fragment ??= data.html == null ? null : const WysiwygClipboardHtml().decode( @@ -4065,6 +4080,7 @@ class _BusyMarkWysiwygEditorState extends State { data.text ?? fragment.documentBlocks.map(_copyTextForBlock).join('\n\n'), )) { + _retainExternalClipboardData(data, fragment: fragment); return; } } @@ -4076,11 +4092,18 @@ class _BusyMarkWysiwygEditorState extends State { filePath, AssetIngestionOrigin.clipboardImageFile, reportInvalidImage: false, + clipboardTarget: target, )) { + if (!data.sessionOwned) { + unawaited(_retainExternalImageFile(filePath)); + } return; } if (_isClipboardTargetCurrent(target)) { - await _pastePlainTextIntoActiveBlock(textOverride: text); + final inserted = await _pastePlainTextIntoActiveBlock( + textOverride: text, + ); + if (inserted) _retainExternalClipboardData(data); } return; } @@ -4091,18 +4114,75 @@ class _BusyMarkWysiwygEditorState extends State { await _ingestExternalImageFile( clipboardFiles.first, AssetIngestionOrigin.clipboardImageFile, + clipboardTarget: target, )) { + unawaited(_retainExternalImageFile(clipboardFiles.first)); return; } final png = await assetInput.readClipboardImagePng(); if (!_isClipboardTargetCurrent(target)) return; if (png != null && png.isNotEmpty) { - await _ingestExternalImageBytes( + final inserted = await _ingestExternalImageBytes( png, suggestedFileName: 'screenshot-${DateTime.now().millisecondsSinceEpoch}.png', origin: AssetIngestionOrigin.screenshotPaste, + clipboardTarget: target, + ); + if (inserted) { + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: png, + imageMimeType: 'image/png', + imageDisplayName: + 'screenshot-${DateTime.now().millisecondsSinceEpoch}.png', + origin: _clipboardOrigin, + external: true, + ), + ); + } + } + } + + void _retainExternalClipboardData( + RichClipboardData data, { + WysiwygClipboardFragment? fragment, + }) { + if (data.sessionOwned) return; + final text = data.text; + final source = fragment?.markdown ?? data.sourceText ?? text; + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: fragment == null + ? BusyMarkClipboardContentKind.text + : BusyMarkClipboardContentKind.richText, + text: text, + sourceText: source, + html: data.html, + richFragment: data.richFragment, + origin: _clipboardOrigin, + external: true, + ), + ); + } + + Future _retainExternalImageFile(String path) async { + try { + final bytes = await File(path).readAsBytes(); + if (!mounted) return; + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: bytes, + imageMimeType: _clipboardImageMimeType(path), + imageDisplayName: p.basename(path), + origin: _clipboardOrigin, + external: true, + ), ); + } on FileSystemException { + // The paste already succeeded. History retention is best effort. } } @@ -4151,24 +4231,24 @@ class _BusyMarkWysiwygEditorState extends State { return true; } - Future _pastePlainTextIntoActiveBlock({String? textOverride}) async { + Future _pastePlainTextIntoActiveBlock({String? textOverride}) async { final captured = _captureClipboardTarget(); final text = textOverride ?? await _clipboard.readPlainText(); if (!_isClipboardTargetCurrent(captured) || text == null || text.isEmpty) { - return; + return false; } final target = _activeTextTarget(); if (target == null) { - return; + return false; } final blockId = target.targetId; final controller = target.controller; if (_activeCellId != null) { _replaceActiveTableCellSelection(text); - return; + return true; } if (_hasBlockSelection && _replaceDocumentSelectionWithText(text)) { - return; + return true; } final currentText = controller.text; final selection = controller.selection.isValid @@ -4192,6 +4272,7 @@ class _BusyMarkWysiwygEditorState extends State { blockId, nextText, ); + return true; } bool _replaceActiveTableCellSelection( @@ -4681,6 +4762,7 @@ class _BusyMarkWysiwygEditorState extends State { String sourcePath, AssetIngestionOrigin origin, { bool reportInvalidImage = true, + _ClipboardTarget? clipboardTarget, }) async { try { final asset = await widget.assetIngestionService.ingestFile( @@ -4688,14 +4770,14 @@ class _BusyMarkWysiwygEditorState extends State { request: _assetIngestionRequest, origin: origin, ); - await _requestAltAndInsertAsset( + return await _requestAltAndInsertAsset( asset, suggestedAlt: p.basenameWithoutExtension(sourcePath), + clipboardTarget: clipboardTarget, ); - return true; } on AssetSaveRequiredException { widget.onAssetSaveRequired?.call(); - return true; + return false; } on AssetIngestionException catch (error) { if (reportInvalidImage && mounted) { BusyMarkToastOverlay.show( @@ -4714,7 +4796,9 @@ class _BusyMarkWysiwygEditorState extends State { Uint8List bytes, { required String suggestedFileName, required AssetIngestionOrigin origin, + _ClipboardTarget? clipboardTarget, }) async { + final suggestedAlt = context.l10n.image; try { final asset = await widget.assetIngestionService.ingestBytes( bytes: bytes, @@ -4722,11 +4806,14 @@ class _BusyMarkWysiwygEditorState extends State { request: _assetIngestionRequest, origin: origin, ); - await _requestAltAndInsertAsset(asset, suggestedAlt: 'Screenshot'); - return true; + return await _requestAltAndInsertAsset( + asset, + suggestedAlt: suggestedAlt, + clipboardTarget: clipboardTarget, + ); } on AssetSaveRequiredException { widget.onAssetSaveRequired?.call(); - return true; + return false; } on AssetIngestionException catch (error) { if (mounted) { BusyMarkToastOverlay.show( @@ -4739,12 +4826,16 @@ class _BusyMarkWysiwygEditorState extends State { } } - Future _requestAltAndInsertAsset( + Future _requestAltAndInsertAsset( IngestedAsset asset, { required String suggestedAlt, + _ClipboardTarget? clipboardTarget, }) async { - if (!mounted) { - return; + if (!mounted || + (clipboardTarget != null && + !_isClipboardTargetCurrent(clipboardTarget))) { + await _deleteUncommittedClipboardAsset(asset); + return false; } final fallbackAltText = context.l10n.image; final target = _captureDialogTarget(); @@ -4755,20 +4846,18 @@ class _BusyMarkWysiwygEditorState extends State { initialAlt: suggestedAlt, submitLabel: context.l10n.insert, ); - if (!_isDialogTargetCurrent(target) || result == null) { - if (!asset.reusedExisting) { - try { - await File(asset.absolutePath).delete(); - } on FileSystemException { - // A cancelled dialog should not make the editor unusable. - } - } - return; + if (!_isDialogTargetCurrent(target) || + (clipboardTarget != null && + !_isClipboardTargetCurrent(clipboardTarget)) || + result == null) { + await _deleteUncommittedClipboardAsset(asset); + return false; } final blockId = _activeBlockId; final controller = blockId == null ? null : _textControllers[blockId]; if (blockId == null || controller == null) { - return; + await _deleteUncommittedClipboardAsset(asset); + return false; } final selection = controller.selection.isValid ? controller.selection @@ -4783,6 +4872,17 @@ class _BusyMarkWysiwygEditorState extends State { fallbackAltText: fallbackAltText, ); _emitMarkdown(); + return true; + } + + Future _deleteUncommittedClipboardAsset(IngestedAsset asset) async { + if (asset.reusedExisting) return; + try { + await File(asset.absolutePath).delete(); + } on FileSystemException { + // Cancellation cleanup must not make the editor unusable. Existing and + // shared assets are never removed through this path. + } } Future<_TableDialogResult?> _showTableDialog( @@ -5628,6 +5728,17 @@ class _BusyMarkWysiwygEditorState extends State { BusyMarkCommandIds.editorRefineWithAi, BusyMarkGlyphs.ai, ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + item( + _DocumentSelectionMenuAction.clipboardHistory, + BusyMarkCommandIds.clipboardHistory, + BusyMarkGlyphs.copy, + ), + item( + _DocumentSelectionMenuAction.localHistory, + BusyMarkCommandIds.localHistory, + BusyMarkGlyphs.documentHistory, + ), ], ); if (!mounted || action == null || snapshot != _documentSelection) { @@ -5646,6 +5757,10 @@ class _BusyMarkWysiwygEditorState extends State { _selectWholeDocumentText(); case _DocumentSelectionMenuAction.refineWithAi: unawaited(_runAiEdit()); + case _DocumentSelectionMenuAction.clipboardHistory: + unawaited(commands.execute(BusyMarkCommandIds.clipboardHistory)); + case _DocumentSelectionMenuAction.localHistory: + unawaited(commands.execute(BusyMarkCommandIds.localHistory)); } } @@ -5996,9 +6111,7 @@ class _BusyMarkWysiwygEditorState extends State { if (allRanges.isEmpty) return false; final ranges = documentSelection ? _withoutEmptySelectionEndpoints(allRanges) - : allRanges - .where((range) => _copyTextForRange(range).trim().isNotEmpty) - .toList(); + : allRanges.where((range) => range.start != range.end).toList(); if (ranges.isEmpty) return false; final text = ranges.map(_copyTextForRange).join('\n\n'); if (text.isEmpty) return false; @@ -6016,9 +6129,7 @@ class _BusyMarkWysiwygEditorState extends State { if (allRanges.isEmpty) return false; final ranges = documentSelection ? _withoutEmptySelectionEndpoints(allRanges) - : allRanges - .where((range) => _copyTextForRange(range).trim().isNotEmpty) - .toList(); + : allRanges.where((range) => range.start != range.end).toList(); if (ranges.isEmpty) return false; final target = _captureClipboardTarget(); final blocks = _clipboardBlocksForRanges(ranges); @@ -6047,7 +6158,19 @@ class _BusyMarkWysiwygEditorState extends State { } Future _writePlainClipboard(String text) async { - final success = await _clipboard.write(RichClipboardData(text: text)); + final success = await _clipboard.write( + RichClipboardData(text: text, sourceText: text, origin: _clipboardOrigin), + ); + if (success) { + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: text, + sourceText: text, + origin: _clipboardOrigin, + ), + ); + } if (!mounted || success) return; BusyMarkToastOverlay.show( context, @@ -6061,21 +6184,45 @@ class _BusyMarkWysiwygEditorState extends State { required VoidCallback onWritten, }) async { var success = false; + String? html; + String? encodedFragment; + var retainedMedia = (bytes: const {}, complete: true); try { - String? html; try { html = const WysiwygClipboardHtml().encode(fragment); } on FormatException { // Large selections can still transfer their native structure and text. } + encodedFragment = fragment.encode(); + retainedMedia = await _readClipboardMediaBytes(fragment); success = await _clipboard.write( - RichClipboardData(text: text, html: html, fragment: fragment.encode()), + RichClipboardData( + text: text, + html: html, + sourceText: fragment.markdown, + richFragment: encodedFragment, + origin: _clipboardOrigin, + mediaBytes: retainedMedia.bytes, + mediaComplete: retainedMedia.complete, + ), ); } on FormatException { success = false; } if (!mounted) return; if (success) { + widget.onClipboardCaptured?.call( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: text, + sourceText: fragment.markdown, + html: html, + richFragment: encodedFragment, + origin: _clipboardOrigin, + mediaBytes: retainedMedia.bytes, + mediaComplete: retainedMedia.complete, + ), + ); onWritten(); } else { BusyMarkToastOverlay.show( @@ -6085,6 +6232,143 @@ class _BusyMarkWysiwygEditorState extends State { } } + BusyMarkClipboardOrigin get _clipboardOrigin => BusyMarkClipboardOrigin( + documentId: _documentId, + documentName: p.basename(_documentController.document.filePath).isEmpty + ? _documentId + : p.basename(_documentController.document.filePath), + documentPath: _documentController.document.filePath.isEmpty + ? null + : _documentController.document.filePath, + ); + + Future _pasteHistoryPayload( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + final captured = _captureClipboardTarget(); + if (payload.kind == BusyMarkClipboardContentKind.image) { + final bytes = payload.imageBytes; + if (bytes == null || bytes.isEmpty) { + return ClipboardPasteResult.unsupported; + } + final inserted = await _ingestExternalImageBytes( + bytes, + suggestedFileName: payload.imageDisplayName ?? 'clipboard-image.png', + origin: AssetIngestionOrigin.screenshotPaste, + clipboardTarget: captured, + ); + return inserted + ? ClipboardPasteResult.inserted + : ClipboardPasteResult.unsupported; + } + if (!_isClipboardTargetCurrent(captured)) { + return ClipboardPasteResult.staleTarget; + } + if (!plainText && payload.richFragment != null) { + final decoded = WysiwygClipboardFragment.decode(payload.richFragment!); + if (decoded != null) { + var fragment = decoded.rebase(_documentController.document.filePath); + var ingestedAssets = const []; + if (decoded.mediaPaths.isNotEmpty) { + if (!payload.mediaComplete) return ClipboardPasteResult.unsupported; + final prepared = await _prepareRetainedClipboardMedia( + decoded, + payload, + captured, + ); + if (prepared == null) return ClipboardPasteResult.staleTarget; + fragment = prepared.fragment; + ingestedAssets = prepared.assets; + } + if (!_isClipboardTargetCurrent(captured)) { + await _deleteUncommittedClipboardAssets(ingestedAssets); + return ClipboardPasteResult.staleTarget; + } + final inserted = _pasteStyledClipboardIntoActiveBlock( + fragment.blocks, + payload.text ?? + fragment.documentBlocks.map(_copyTextForBlock).join('\n\n'), + ); + if (!inserted) { + await _deleteUncommittedClipboardAssets(ingestedAssets); + } + return inserted + ? ClipboardPasteResult.inserted + : ClipboardPasteResult.unsupported; + } + } + final text = payload.text ?? payload.sourceText; + if (text == null) return ClipboardPasteResult.unsupported; + final inserted = await _pastePlainTextIntoActiveBlock(textOverride: text); + return inserted + ? ClipboardPasteResult.inserted + : ClipboardPasteResult.staleTarget; + } + + Future<({WysiwygClipboardFragment fragment, List assets})?> + _prepareRetainedClipboardMedia( + WysiwygClipboardFragment fragment, + BusyMarkClipboardPayload payload, + _ClipboardTarget target, + ) async { + final destinations = {}; + final assets = []; + try { + for (final entry in fragment.mediaPaths.entries) { + final bytes = payload.mediaBytes[entry.key]; + if (bytes == null || bytes.isEmpty) { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + final asset = await widget.assetIngestionService.ingestBytes( + bytes: bytes, + suggestedFileName: p.basename(entry.value), + request: _assetIngestionRequest, + origin: AssetIngestionOrigin.clipboardImageFile, + ); + assets.add(asset); + if (!_isClipboardTargetCurrent(target)) { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + destinations[entry.key] = asset.markdownPath; + } + return ( + fragment: fragment.rebase( + _documentController.document.filePath, + mediaDestinations: destinations, + ), + assets: List.unmodifiable(assets), + ); + } on AssetSaveRequiredException { + await _deleteUncommittedClipboardAssets(assets); + widget.onAssetSaveRequired?.call(); + return null; + } on AssetIngestionException catch (error) { + await _deleteUncommittedClipboardAssets(assets); + if (mounted) { + BusyMarkToastOverlay.show( + context, + message: error.message, + priority: BusyMarkToastPriority.high, + ); + } + return null; + } on FileSystemException { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + } + + Future _deleteUncommittedClipboardAssets( + Iterable assets, + ) async { + for (final asset in assets) { + await _deleteUncommittedClipboardAsset(asset); + } + } + List _clipboardBlocksForRanges( List<_SelectedTextRange> ranges, ) { @@ -6190,6 +6474,34 @@ class _BusyMarkWysiwygEditorState extends State { return resolved; } + Future<({Map bytes, bool complete})> + _readClipboardMediaBytes(WysiwygClipboardFragment fragment) async { + if (fragment.mediaPaths.isEmpty) { + return (bytes: const {}, complete: true); + } + final maximumBytes = const ClipboardHistoryPolicy().maximumBytes; + final result = {}; + var total = 0; + for (final entry in fragment.mediaPaths.entries) { + try { + final file = File(entry.value); + final length = await file.length(); + if (length <= 0 || total + length > maximumBytes) { + return (bytes: const {}, complete: false); + } + final bytes = await file.readAsBytes(); + if (bytes.length != length) { + return (bytes: const {}, complete: false); + } + result[entry.key] = bytes; + total += bytes.length; + } on FileSystemException { + return (bytes: const {}, complete: false); + } + } + return (bytes: Map.unmodifiable(result), complete: true); + } + _ClipboardTarget _captureClipboardTarget() => _ClipboardTarget( document: _captureDialogTarget(), documentId: _documentId, @@ -6525,6 +6837,62 @@ class _ClipboardTarget { final TextSelection? textSelection; } +class _WysiwygClipboardInsertionTarget + implements BusyMarkClipboardInsertionTarget { + const _WysiwygClipboardInsertionTarget(this.state); + + final _BusyMarkWysiwygEditorState state; + + @override + String get documentId => state._documentId; + + @override + String get documentName { + final path = state._documentController.document.filePath; + return p.basename(path).isEmpty ? documentId : p.basename(path); + } + + @override + String? get documentPath { + final path = state._documentController.document.filePath; + return path.isEmpty ? null : path; + } + + @override + bool get editable => state.mounted; + + @override + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) => state._pasteHistoryPayload(payload, plainText: plainText); + + @override + void requestEditorFocus() { + final target = state._activeTextTarget(); + final blockId = target?.targetId ?? state._activeBlockId; + if (blockId != null) { + state._focusBlockAfterFrame( + blockId, + offset: + target?.controller.selection.extentOffset ?? + target?.controller.text.length ?? + 0, + ); + } + } +} + +String _clipboardImageMimeType(String path) => + switch (p.extension(path).toLowerCase()) { + '.jpg' || '.jpeg' => 'image/jpeg', + '.gif' => 'image/gif', + '.webp' => 'image/webp', + '.bmp' => 'image/bmp', + '.svg' => 'image/svg+xml', + _ => 'image/png', + }; + enum _DocumentSelectionMenuAction { cut, copy, @@ -6532,6 +6900,8 @@ enum _DocumentSelectionMenuAction { paste, selectAll, refineWithAi, + clipboardHistory, + localHistory, } class _ContinuousTextEdit { diff --git a/lib/src/git/presentation/git_sidebar_tab.dart b/lib/src/git/presentation/git_sidebar_tab.dart index 8a5a89ce..dc127242 100644 --- a/lib/src/git/presentation/git_sidebar_tab.dart +++ b/lib/src/git/presentation/git_sidebar_tab.dart @@ -150,6 +150,7 @@ class GitSidebarTab extends ConsumerWidget { onSelectCommit: controller.selectFileHistoryCommit, onRestoreVersion: () => _confirmRestoreVersion( context, + ref, controller, hasUnsavedEditorChanges, ), @@ -208,6 +209,7 @@ class GitSidebarTab extends ConsumerWidget { Future _confirmRestoreVersion( BuildContext context, + WidgetRef ref, GitController controller, bool hasUnsavedEditorChanges, ) async { @@ -240,6 +242,13 @@ class GitSidebarTab extends ConsumerWidget { if (confirmed != true || !context.mounted) { return; } + final path = ref.read(gitControllerProvider).fileHistory.currentPath; + if (path == null || + !await ref + .read(workspaceControllerProvider.notifier) + .protectPathsBeforeExternalReplacement([path])) { + return; + } if (await controller.restoreSelectedFileVersion()) { await onAfterWorkspaceFilesChanged(); } diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart new file mode 100644 index 00000000..13793097 --- /dev/null +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -0,0 +1,690 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/busymark_dialogs.dart'; +import '../app/busymark_design.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/localization.dart'; +import '../comparison/source_comparison.dart'; +import '../workspace/document_buffer.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import '../workspace/workspace_safety.dart'; +import 'local_history_controller.dart'; +import 'local_history_models.dart'; + +class LocalHistoryComparisonView extends ConsumerStatefulWidget { + const LocalHistoryComparisonView({super.key}); + + @override + ConsumerState createState() => + _LocalHistoryComparisonViewState(); +} + +class _LocalHistoryComparisonViewState + extends ConsumerState { + Future<_ComparisonSnapshot?>? _future; + String? _key; + + @override + Widget build(BuildContext context) { + final history = ref.watch(localHistoryControllerProvider); + final workspace = ref.watch(workspaceControllerProvider); + final document = history.selectedDocument; + final revision = history.selectedRevision; + if (document == null || revision == null) { + return const SizedBox.shrink(); + } + final matchingBuffer = _bufferForDocument(workspace, document, revision); + final key = [ + revision.summary.id, + matchingBuffer?.id, + matchingBuffer?.revision, + matchingBuffer?.text.hashCode, + document.currentPath, + ].join(':'); + if (_key != key) { + _key = key; + _future = _buildSnapshot(document, revision); + } + return FutureBuilder<_ComparisonSnapshot?>( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Text(snapshot.error.toString()), + ), + ); + } + final data = snapshot.data; + if (data == null) { + return const Center(child: CircularProgressIndicator()); + } + return _ComparisonBody( + snapshot: data, + onClose: () => ref + .read(localHistoryControllerProvider.notifier) + .clearComparison(), + onRestoreAll: data.canRestore + ? () => _restore(document, revision) + : null, + onRestoreChange: data.canRestoreChange + ? (change) => _restore( + document, + revision, + comparison: data.comparison, + change: change, + ) + : null, + onRestoreOriginal: + data.missing && + (document.currentPath ?? revision.summary.historicalPath) != + null + ? () => _restoreMissing( + document, + revision, + document.currentPath ?? revision.summary.historicalPath!, + ) + : null, + onRestoreNewLocation: data.missing + ? () => _chooseRestoreLocation(document, revision) + : null, + ); + }, + ); + } + + DocumentBuffer? _bufferForDocument( + WorkspaceState state, + LocalHistoryDocument document, + LocalHistoryRevision revision, + ) { + final paths = { + document.currentPath, + revision.summary.historicalPath, + }.whereType(); + for (final path in paths) { + final buffer = state.bufferForPath(path); + if (buffer != null) return buffer; + } + return null; + } + + Future<_ComparisonSnapshot?> _buildSnapshot( + LocalHistoryDocument document, + LocalHistoryRevision revision, + ) async { + final editorLabel = context.l10n.localHistoryCurrentEditor; + final diskLabel = context.l10n.localHistoryCurrentDisk; + final missingLabel = context.l10n.localHistoryMissingFile; + final revisionLabel = _revisionLabel(context, revision.summary); + final current = await ref + .read(workspaceControllerProvider.notifier) + .localHistoryCurrentSource(document, revision); + final currentLabel = switch (current.kind) { + LocalHistoryCurrentSourceKind.editor => editorLabel, + LocalHistoryCurrentSourceKind.disk => diskLabel, + LocalHistoryCurrentSourceKind.missing => missingLabel, + }; + if (!mounted) return null; + final oldInput = SourceComparisonInput( + id: revision.summary.id, + version: revision.summary.capturedAt.microsecondsSinceEpoch, + label: revisionLabel, + source: revision.source, + ); + final currentInput = SourceComparisonInput( + id: current.id, + version: current.version, + label: currentLabel, + source: current.source, + ); + final comparison = await Future( + () => compareSource(oldInput, currentInput), + ); + return _ComparisonSnapshot( + comparison: comparison, + missing: current.kind == LocalHistoryCurrentSourceKind.missing, + canRestore: current.canRestore, + canRestoreChange: + current.kind == LocalHistoryCurrentSourceKind.editor && + current.canRestore, + ); + } + + Future _restore( + LocalHistoryDocument document, + LocalHistoryRevision revision, { + SourceComparison? comparison, + SourceComparisonChange? change, + }) async { + await ref + .read(workspaceControllerProvider.notifier) + .restoreLocalHistoryRevision( + document: document, + revision: revision, + comparison: comparison, + change: change, + ); + } + + Future _chooseRestoreLocation( + LocalHistoryDocument document, + LocalHistoryRevision revision, + ) async { + final historicalPath = + document.currentPath ?? revision.summary.historicalPath; + final extension = p.extension(historicalPath ?? ''); + final location = await getSaveLocation( + acceptedTypeGroups: [ + XTypeGroup( + label: context.l10n.source, + extensions: extension.isEmpty ? null : [extension.substring(1)], + mimeTypes: extension.isEmpty ? const ['text/plain'] : null, + ), + ], + suggestedName: p.basename(historicalPath ?? document.displayName), + initialDirectory: historicalPath == null + ? null + : p.dirname(historicalPath), + confirmButtonText: context.l10n.localHistoryRestoreRevision, + ); + if (location != null && mounted) { + await _restoreMissing(document, revision, location.path); + } + } + + Future _restoreMissing( + LocalHistoryDocument document, + LocalHistoryRevision revision, + String path, + ) async { + if (!await confirmSafeToContinue(context, ref) || !mounted) return; + final controller = ref.read(workspaceControllerProvider.notifier); + final exists = await controller.localHistoryRestorePathExists(path); + if (!mounted) return; + var overwrite = false; + if (exists) { + overwrite = + await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (dialogContext) => BusyMarkDialogShell( + title: dialogContext.l10n.overwrite, + actions: [ + BusyMarkDialogButton( + label: dialogContext.l10n.cancel, + onPressed: () => Navigator.pop(dialogContext, false), + ), + BusyMarkDialogButton( + label: dialogContext.l10n.overwrite, + destructive: true, + onPressed: () => Navigator.pop(dialogContext, true), + ), + ], + children: [Text(dialogContext.l10n.errorPathAlreadyExists(path))], + ), + ) ?? + false; + if (!overwrite || !mounted) return; + } + await controller.restoreMissingLocalHistoryRevision( + document: document, + revision: revision, + destinationPath: path, + overwriteExisting: overwrite, + ); + } +} + +class _ComparisonSnapshot { + const _ComparisonSnapshot({ + required this.comparison, + required this.missing, + required this.canRestore, + required this.canRestoreChange, + }); + + final SourceComparison comparison; + final bool missing; + final bool canRestore; + final bool canRestoreChange; +} + +class _ComparisonBody extends StatelessWidget { + const _ComparisonBody({ + required this.snapshot, + required this.onClose, + required this.onRestoreAll, + required this.onRestoreChange, + required this.onRestoreOriginal, + required this.onRestoreNewLocation, + }); + + final _ComparisonSnapshot snapshot; + final VoidCallback onClose; + final VoidCallback? onRestoreAll; + final ValueChanged? onRestoreChange; + final VoidCallback? onRestoreOriginal; + final VoidCallback? onRestoreNewLocation; + + @override + Widget build(BuildContext context) { + final comparison = snapshot.comparison; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: BusyMarkSurfaceColors.of(context).headerbarFlat, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.sm, + ), + child: Row( + children: [ + const Icon(BusyMarkGlyphs.documentHistory), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + comparison.oldInput.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + tooltip: context.l10n.localHistoryRestoreRevision, + onPressed: onRestoreAll, + icon: const Icon(BusyMarkGlyphs.history), + ), + IconButton( + tooltip: context.l10n.close, + onPressed: onClose, + icon: const Icon(BusyMarkGlyphs.windowClose), + ), + ], + ), + ), + ), + if (snapshot.missing) + _ComparisonNotice( + text: context.l10n.localHistoryMissingFile, + actions: [ + if (onRestoreOriginal != null) + OutlinedButton( + onPressed: onRestoreOriginal, + child: Text(context.l10n.localHistoryRestoreOriginalLocation), + ), + if (onRestoreNewLocation != null) + OutlinedButton( + onPressed: onRestoreNewLocation, + child: Text(context.l10n.localHistoryRestoreNewLocation), + ), + ], + ), + if (comparison.simplified) + _ComparisonNotice( + text: context.l10n.localHistoryComparisonSimplified, + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 900) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _SourcePane( + label: comparison.oldInput.label, + source: comparison.oldInput.source, + changes: comparison.changes, + removed: true, + ), + ), + VerticalDivider( + width: 1, + color: BusyMarkSurfaceColors.of(context).subtleBorder, + ), + Expanded( + child: _SourcePane( + label: comparison.currentInput.label, + source: comparison.currentInput.source, + changes: comparison.changes, + removed: false, + ), + ), + ], + ); + } + return _UnifiedComparisonPane(comparison: comparison); + }, + ), + ), + if (comparison.changes.isNotEmpty) + SizedBox( + height: 54, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + itemCount: comparison.changes.length, + separatorBuilder: (_, _) => + const SizedBox(width: BusyMarkSpacing.sm), + itemBuilder: (context, index) { + final change = comparison.changes[index]; + return OutlinedButton.icon( + onPressed: change.exact && onRestoreChange != null + ? () => onRestoreChange!(change) + : null, + icon: const Icon(BusyMarkGlyphs.undo, size: 16), + label: Text( + '${context.l10n.localHistoryRestoreChange} ${index + 1}', + ), + ); + }, + ), + ), + ], + ); + } +} + +class _UnifiedComparisonPane extends StatelessWidget { + const _UnifiedComparisonPane({required this.comparison}); + + final SourceComparison comparison; + + @override + Widget build(BuildContext context) { + final changes = [...comparison.changes] + ..sort((a, b) => a.currentRange.start.compareTo(b.currentRange.start)); + final rows = []; + var currentOffset = 0; + for (final change in changes) { + final unchangedEnd = change.currentRange.start.clamp( + currentOffset, + comparison.currentInput.source.length, + ); + if (unchangedEnd > currentOffset) { + rows.add( + _UnifiedSourceChunk( + source: comparison.currentInput.source.substring( + currentOffset, + unchangedEnd, + ), + ), + ); + } + if (change.oldText.isNotEmpty) { + rows.add( + _UnifiedSourceChunk( + marker: '−', + label: comparison.oldInput.label, + source: change.oldText, + background: Theme.of(context).colorScheme.errorContainer, + emphasis: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.32), + intraline: change.oldIntralineRange, + ), + ); + } + if (change.currentText.isNotEmpty) { + rows.add( + _UnifiedSourceChunk( + marker: '+', + label: comparison.currentInput.label, + source: change.currentText, + background: Theme.of(context).colorScheme.primaryContainer, + emphasis: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.32), + intraline: change.currentIntralineRange, + ), + ); + } + currentOffset = change.currentRange.end.clamp( + currentOffset, + comparison.currentInput.source.length, + ); + } + if (currentOffset < comparison.currentInput.source.length) { + rows.add( + _UnifiedSourceChunk( + source: comparison.currentInput.source.substring(currentOffset), + ), + ); + } + if (rows.isEmpty) { + rows.add(_UnifiedSourceChunk(source: comparison.currentInput.source)); + } + return ListView( + padding: const EdgeInsets.all(BusyMarkSpacing.md), + children: rows, + ); + } +} + +class _UnifiedSourceChunk extends StatelessWidget { + const _UnifiedSourceChunk({ + required this.source, + this.marker, + this.label, + this.background, + this.emphasis, + this.intraline = const SourceComparisonRange(0, 0), + }); + + final String source; + final String? marker; + final String? label; + final Color? background; + final Color? emphasis; + final SourceComparisonRange intraline; + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'); + final start = intraline.start.clamp(0, source.length); + final end = intraline.end.clamp(start, source.length); + final text = SelectableText.rich( + TextSpan( + style: style?.copyWith(backgroundColor: background), + children: [ + if (start > 0) TextSpan(text: source.substring(0, start)), + if (end > start) + TextSpan( + text: source.substring(start, end), + style: TextStyle(backgroundColor: emphasis), + ), + if (end < source.length) TextSpan(text: source.substring(end)), + ], + ), + ); + if (marker == null || label == null) return text; + return Semantics( + label: '$label: $source', + child: DecoratedBox( + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide( + color: marker == '−' + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + ), + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: BusyMarkSpacing.sm, + bottom: BusyMarkSpacing.xs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: Text( + marker!, + style: style?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: BusyMarkSpacing.xs), + Expanded(child: text), + ], + ), + ), + ), + ); + } +} + +class _SourcePane extends StatelessWidget { + const _SourcePane({ + required this.label, + required this.source, + required this.changes, + required this.removed, + }); + + final String label; + final String source; + final List changes; + final bool removed; + + @override + Widget build(BuildContext context) { + final body = Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.md), + child: SelectableText.rich( + TextSpan( + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + children: _spans(context), + ), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Text(label, style: Theme.of(context).textTheme.labelLarge), + ), + Expanded(child: SingleChildScrollView(child: body)), + ], + ); + } + + List _spans(BuildContext context) { + final sorted = [...changes] + ..sort((a, b) { + final left = removed ? a.oldRange : a.currentRange; + final right = removed ? b.oldRange : b.currentRange; + return left.start.compareTo(right.start); + }); + final result = []; + var offset = 0; + for (final change in sorted) { + final range = removed ? change.oldRange : change.currentRange; + final start = range.start.clamp(offset, source.length); + final end = range.end.clamp(start, source.length); + if (start > offset) { + result.add(TextSpan(text: source.substring(offset, start))); + } + if (end > start) { + final intraline = removed + ? change.oldIntralineRange + : change.currentIntralineRange; + final text = source.substring(start, end); + final intralineStart = intraline.start.clamp(0, text.length); + final intralineEnd = intraline.end.clamp(intralineStart, text.length); + final base = removed + ? Theme.of(context).colorScheme.errorContainer + : Theme.of(context).colorScheme.primaryContainer; + final emphasis = removed + ? Theme.of(context).colorScheme.error.withValues(alpha: 0.32) + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.32); + result.add( + TextSpan( + // A semantic replacement label requires non-null text. Empty text + // keeps copied source exact while the children retain styling. + text: '', + style: TextStyle(backgroundColor: base), + semanticsLabel: removed + ? context.l10n.localHistoryOlderChange(text) + : context.l10n.localHistoryCurrentChange(text), + children: [ + if (intralineStart > 0) + TextSpan(text: text.substring(0, intralineStart)), + if (intralineEnd > intralineStart) + TextSpan( + text: text.substring(intralineStart, intralineEnd), + style: TextStyle(backgroundColor: emphasis), + ), + if (intralineEnd < text.length) + TextSpan(text: text.substring(intralineEnd)), + ], + ), + ); + } + offset = end; + } + if (offset < source.length) { + result.add(TextSpan(text: source.substring(offset))); + } + if (result.isEmpty) result.add(TextSpan(text: source)); + return result; + } +} + +class _ComparisonNotice extends StatelessWidget { + const _ComparisonNotice({required this.text, this.actions = const []}); + + final String text; + final List actions; + + @override + Widget build(BuildContext context) => Container( + color: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Row( + children: [ + const Icon(BusyMarkGlyphs.info, size: 18), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(text), + if (actions.isNotEmpty) ...[ + const SizedBox(height: BusyMarkSpacing.sm), + Wrap(spacing: BusyMarkSpacing.sm, children: actions), + ], + ], + ), + ), + ], + ), + ); +} + +String _revisionLabel( + BuildContext context, + LocalHistoryRevisionSummary revision, +) { + final local = revision.capturedAt.toLocal(); + final material = MaterialLocalizations.of(context); + return '${context.l10n.localHistorySelectedRevision} · ' + '${material.formatFullDate(local)} ' + '${material.formatTimeOfDay(TimeOfDay.fromDateTime(local))}'; +} diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart new file mode 100644 index 00000000..da24ec98 --- /dev/null +++ b/lib/src/local_history/local_history_controller.dart @@ -0,0 +1,721 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_settings.dart'; +import '../workspace/document_buffer.dart'; +import '../workspace/text_format_metadata.dart'; +import 'local_history_models.dart'; +import 'local_history_store.dart'; + +typedef LocalHistoryClock = DateTime Function(); +typedef LocalHistoryTimerFactory = + Timer Function(Duration delay, void Function() callback); + +final localHistoryClockProvider = Provider( + (ref) => DateTime.now, +); + +final localHistoryTimerFactoryProvider = Provider( + (ref) => Platform.environment.containsKey('FLUTTER_TEST') + ? (delay, callback) => _InactiveLocalHistoryTimer() + : (delay, callback) => Timer(delay, callback), +); + +class _InactiveLocalHistoryTimer implements Timer { + @override + void cancel() {} + + @override + bool get isActive => false; + + @override + int get tick => 0; +} + +final localHistoryStoreProvider = Provider( + (ref) => Platform.environment.containsKey('FLUTTER_TEST') + ? MemoryLocalHistoryStore() + : FileLocalHistoryStore(), +); + +final localHistoryControllerProvider = + NotifierProvider( + LocalHistoryController.new, + ); + +final localHistoryOpenRequestProvider = + NotifierProvider( + LocalHistoryOpenRequestController.new, + ); + +final localHistoryFindRequestProvider = + NotifierProvider( + LocalHistoryOpenRequestController.new, + ); + +class LocalHistoryOpenRequestController extends Notifier { + @override + int build() => 0; + + void request() => state++; +} + +class LocalHistoryState { + const LocalHistoryState({ + this.snapshot = const LocalHistorySnapshot(), + this.loading = false, + this.selectedDocumentId, + this.selectedRevisionId, + this.selectedRevision, + this.searchQuery = '', + this.searching = false, + this.searchMatches = const {}, + this.documentSearchMatches = const {}, + this.findingDocuments = false, + this.warning, + }); + + final LocalHistorySnapshot snapshot; + final bool loading; + final String? selectedDocumentId; + final String? selectedRevisionId; + final LocalHistoryRevision? selectedRevision; + final String searchQuery; + final bool searching; + final Set searchMatches; + final Set documentSearchMatches; + final bool findingDocuments; + final LocalHistoryWarning? warning; + + LocalHistoryDocument? get selectedDocument => snapshot.documents + .where((document) => document.id == selectedDocumentId) + .firstOrNull; + + List get selectedRevisions => + selectedDocumentId == null + ? const [] + : snapshot.revisionsFor(selectedDocumentId!); + + bool revisionVisible(LocalHistoryRevisionSummary revision) => + searchQuery.isEmpty || searchMatches.contains(revision.id); + + LocalHistoryState copyWith({ + LocalHistorySnapshot? snapshot, + bool? loading, + Object? selectedDocumentId = _unset, + Object? selectedRevisionId = _unset, + Object? selectedRevision = _unset, + String? searchQuery, + bool? searching, + Set? searchMatches, + Set? documentSearchMatches, + bool? findingDocuments, + Object? warning = _unset, + }) => LocalHistoryState( + snapshot: snapshot ?? this.snapshot, + loading: loading ?? this.loading, + selectedDocumentId: identical(selectedDocumentId, _unset) + ? this.selectedDocumentId + : selectedDocumentId as String?, + selectedRevisionId: identical(selectedRevisionId, _unset) + ? this.selectedRevisionId + : selectedRevisionId as String?, + selectedRevision: identical(selectedRevision, _unset) + ? this.selectedRevision + : selectedRevision as LocalHistoryRevision?, + searchQuery: searchQuery ?? this.searchQuery, + searching: searching ?? this.searching, + searchMatches: searchMatches ?? this.searchMatches, + documentSearchMatches: documentSearchMatches ?? this.documentSearchMatches, + findingDocuments: findingDocuments ?? this.findingDocuments, + warning: identical(warning, _unset) + ? this.warning + : warning as LocalHistoryWarning?, + ); +} + +enum LocalHistoryWarningKind { + indexRebuilt, + unsupportedFormat, + unavailable, + recordingDisabled, + pathChange, + deletedPath, + revisionMissing, + revisionRead, + capture, +} + +class LocalHistoryWarning { + const LocalHistoryWarning(this.kind, {this.detail}); + + final LocalHistoryWarningKind kind; + final String? detail; +} + +const Object _unset = Object(); + +class LocalHistoryBufferSnapshot { + const LocalHistoryBufferSnapshot({ + required this.bufferId, + required this.displayName, + required this.text, + required this.format, + required this.revision, + this.path, + this.untitled = false, + }); + + factory LocalHistoryBufferSnapshot.fromBuffer(DocumentBuffer buffer) => + LocalHistoryBufferSnapshot( + bufferId: buffer.id, + displayName: buffer.displayName, + text: buffer.text, + format: buffer.format, + revision: buffer.revision, + path: buffer.filePath, + untitled: buffer.isUntitled, + ); + + final String bufferId; + final String displayName; + final String text; + final TextFormatMetadata format; + final int revision; + final String? path; + final bool untitled; +} + +class LocalHistoryController extends Notifier { + late LocalHistoryStore _store; + late LocalHistoryClock _clock; + late LocalHistoryTimerFactory _timerFactory; + final _documentIdsByBuffer = {}; + final _pending = {}; + final _checkpointTimers = {}; + final _bufferQueues = >{}; + var _loadGeneration = 0; + var _searchGeneration = 0; + + @override + LocalHistoryState build() { + _store = ref.read(localHistoryStoreProvider); + _clock = ref.read(localHistoryClockProvider); + _timerFactory = ref.read(localHistoryTimerFactoryProvider); + ref.listen(appSettingsControllerProvider, (previous, next) { + if (!next.localHistoryRecordingEnabled) _cancelCheckpoints(); + }); + ref.onDispose(_cancelCheckpoints); + Future.microtask(refresh); + return const LocalHistoryState(loading: true); + } + + LocalHistoryPolicy get policy { + final settings = ref.read(appSettingsControllerProvider); + return LocalHistoryPolicy( + recordingEnabled: settings.localHistoryRecordingEnabled, + checkpointInterval: Duration( + seconds: settings.localHistoryCheckpointSeconds, + ), + retentionAge: Duration(days: settings.localHistoryRetentionDays), + maximumBytes: settings.localHistoryMaximumStorageMiB * 1024 * 1024, + excludedPaths: settings.localHistoryExcludedPaths, + ); + } + + Future refresh() async { + final generation = ++_loadGeneration; + if (ref.mounted) state = state.copyWith(loading: true); + try { + await _store.prune(policy, _clock()); + final snapshot = await _store.load(); + if (!ref.mounted || generation != _loadGeneration) return; + final selectedDocumentId = state.selectedDocumentId; + state = state.copyWith( + snapshot: snapshot, + loading: false, + selectedDocumentId: + selectedDocumentId != null && + snapshot.documents.any( + (document) => document.id == selectedDocumentId, + ) + ? selectedDocumentId + : null, + warning: snapshot.warning == null + ? null + : const LocalHistoryWarning(LocalHistoryWarningKind.indexRebuilt), + ); + } on UnsupportedLocalHistoryFormat catch (error) { + if (!ref.mounted || generation != _loadGeneration) return; + state = state.copyWith( + loading: false, + warning: LocalHistoryWarning( + LocalHistoryWarningKind.unsupportedFormat, + detail: error.version?.toString(), + ), + ); + } on Object catch (error) { + if (!ref.mounted || generation != _loadGeneration) return; + state = state.copyWith( + loading: false, + warning: LocalHistoryWarning( + LocalHistoryWarningKind.unavailable, + detail: error.toString(), + ), + ); + } + } + + Future observeOpened(DocumentBuffer buffer) async { + final snapshot = LocalHistoryBufferSnapshot.fromBuffer(buffer); + if (snapshot.untitled && snapshot.text.isEmpty) return; + await _enqueue(snapshot.bufferId, () async { + if (_documentIdsByBuffer.containsKey(snapshot.bufferId)) return; + await _capture(snapshot, LocalHistoryCaptureReason.baseline); + }); + } + + void observeEdit(DocumentBuffer previous, DocumentBuffer current) { + if (!policy.recordingEnabled || policy.excludes(current.filePath)) return; + final previousSnapshot = LocalHistoryBufferSnapshot.fromBuffer(previous); + final currentSnapshot = LocalHistoryBufferSnapshot.fromBuffer(current); + unawaited( + _enqueue(current.id, () async { + if (!ref.mounted) return; + if (!_documentIdsByBuffer.containsKey(current.id) && + !(previousSnapshot.untitled && previousSnapshot.text.isEmpty)) { + await _capture(previousSnapshot, LocalHistoryCaptureReason.baseline); + } + if (!ref.mounted) return; + _pending[current.id] = currentSnapshot; + _checkpointTimers.putIfAbsent( + current.id, + () => _timerFactory(policy.checkpointInterval, () { + _checkpointTimers.remove(current.id); + if (!ref.mounted) return; + final latest = _pending.remove(current.id); + if (latest != null) { + unawaited( + _enqueue( + current.id, + () => _capture( + latest, + LocalHistoryCaptureReason.automaticCheckpoint, + ), + ), + ); + } + }), + ); + }), + ); + } + + Future captureSaved(LocalHistoryBufferSnapshot snapshot) async { + return _enqueue( + snapshot.bufferId, + () => _capture(snapshot, LocalHistoryCaptureReason.saved), + ); + } + + Future captureProtective( + LocalHistoryBufferSnapshot snapshot, + LocalHistoryCaptureReason reason, + ) async { + if (!policy.recordingEnabled || policy.excludes(snapshot.path)) { + _setWarning(LocalHistoryWarningKind.recordingDisabled); + return false; + } + return _enqueue( + snapshot.bufferId, + () => _capture(snapshot, reason, force: true), + ); + } + + /// Lifecycle replacements remain usable when recording was deliberately + /// disabled or excluded. When recording applies, a failed protective write + /// blocks the destructive replacement. + Future captureBeforeLoss( + LocalHistoryBufferSnapshot snapshot, + LocalHistoryCaptureReason reason, + ) async { + if (!policy.recordingEnabled || policy.excludes(snapshot.path)) return true; + return _enqueue( + snapshot.bufferId, + () => _capture(snapshot, reason, force: true), + ); + } + + Future captureSavedAs( + LocalHistoryBufferSnapshot source, + String destinationPath, { + required bool destinationExisted, + }) async { + final destination = LocalHistoryBufferSnapshot( + bufferId: source.bufferId, + displayName: p.basename(destinationPath), + text: source.text, + format: source.format, + revision: source.revision, + path: destinationPath, + ); + if (source.untitled && !destinationExisted) { + return _enqueue( + source.bufferId, + () => _capture(destination, LocalHistoryCaptureReason.saved), + ); + } + final captured = await _enqueue( + source.bufferId, + () => _capture( + destination, + LocalHistoryCaptureReason.saved, + ignoreBinding: true, + ), + ); + if (captured) { + final document = state.snapshot.documents + .where( + (candidate) => + candidate.currentPath != null && + p.equals(candidate.currentPath!, destinationPath), + ) + .firstOrNull; + if (document != null) _documentIdsByBuffer[source.bufferId] = document.id; + } + return captured; + } + + Future capturePath({ + required String path, + required String text, + required TextFormatMetadata format, + required LocalHistoryCaptureReason reason, + bool force = true, + }) { + return _capture( + LocalHistoryBufferSnapshot( + bufferId: 'path:${p.normalize(path)}', + displayName: p.basename(path), + text: text, + format: format, + revision: 0, + path: path, + ), + reason, + force: force, + ignoreBinding: true, + ); + } + + Future capturePathBeforeLoss({ + required String path, + required String text, + required TextFormatMetadata format, + required LocalHistoryCaptureReason reason, + }) { + if (!policy.recordingEnabled || policy.excludes(path)) { + return Future.value(true); + } + return capturePath( + path: path, + text: text, + format: format, + reason: reason, + force: true, + ); + } + + Future flushBuffer(DocumentBuffer buffer) async { + _checkpointTimers.remove(buffer.id)?.cancel(); + final pending = _pending.remove(buffer.id); + if (pending != null) { + await _enqueue( + buffer.id, + () => _capture( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.automaticCheckpoint, + ), + ); + } + } + + Future flushAll(Iterable buffers) async { + for (final buffer in buffers) { + await flushBuffer(buffer); + } + while (_bufferQueues.isNotEmpty) { + await Future.wait(_bufferQueues.values.toList(growable: false)); + } + } + + Future remapPath(String sourcePath, String destinationPath) async { + try { + await _store.remapPath(sourcePath, destinationPath); + await refresh(); + } on Object catch (error) { + _setWarning(LocalHistoryWarningKind.pathChange, error.toString()); + } + } + + Future markDeleted(String path, {required bool recursive}) async { + try { + await _store.markDeleted(path, recursive: recursive); + await refresh(); + } on Object catch (error) { + _setWarning(LocalHistoryWarningKind.deletedPath, error.toString()); + } + } + + Future selectDocumentForBuffer(DocumentBuffer buffer) async { + await observeOpened(buffer); + final documentId = _documentIdsByBuffer[buffer.id]; + if (!ref.mounted) return; + if (documentId == null) { + await refresh(); + final byPath = state.snapshot.documents + .where( + (document) => + buffer.filePath != null && + document.currentPath != null && + p.equals(document.currentPath!, buffer.filePath!), + ) + .firstOrNull; + if (byPath != null) { + selectDocument(byPath.id); + } else { + state = state.copyWith(findingDocuments: false); + } + } else { + selectDocument(documentId); + } + } + + void selectDocument(String documentId) { + if (!state.snapshot.documents.any( + (document) => document.id == documentId, + )) { + return; + } + state = state.copyWith( + selectedDocumentId: documentId, + selectedRevisionId: null, + selectedRevision: null, + searchQuery: '', + searchMatches: const {}, + documentSearchMatches: const {}, + findingDocuments: false, + ); + } + + /// Opens the store-wide discovery surface used by Find in Local History. + /// No current editor is implied: closed, renamed, untitled, and deleted + /// documents remain first-class results. + void beginDocumentSearch() { + _searchGeneration++; + state = state.copyWith( + selectedDocumentId: null, + selectedRevisionId: null, + selectedRevision: null, + searchQuery: '', + searching: false, + searchMatches: const {}, + documentSearchMatches: const {}, + findingDocuments: true, + ); + } + + Future selectDocumentForPath(String path) async { + await refresh(); + if (!ref.mounted) return; + final normalized = p.normalize(path); + final document = state.snapshot.documents.where((candidate) { + final current = candidate.currentPath; + return (current != null && p.equals(current, normalized)) || + candidate.historicalPaths.any( + (historical) => p.equals(historical, normalized), + ); + }).firstOrNull; + if (document != null) selectDocument(document.id); + } + + Future selectRevision(String revisionId) async { + final documentId = state.selectedDocumentId; + final summary = state.snapshot.revisions + .where((revision) => revision.id == revisionId) + .firstOrNull; + if (documentId == null || summary?.documentId != documentId) return; + final generation = ++_loadGeneration; + state = state.copyWith( + selectedRevisionId: revisionId, + selectedRevision: null, + loading: true, + ); + try { + final revision = await _store.readRevision(revisionId); + if (!ref.mounted || generation != _loadGeneration) return; + state = state.copyWith( + selectedRevision: revision, + loading: false, + warning: revision == null + ? const LocalHistoryWarning(LocalHistoryWarningKind.revisionMissing) + : null, + ); + } on Object catch (error) { + if (!ref.mounted || generation != _loadGeneration) return; + state = state.copyWith( + loading: false, + warning: LocalHistoryWarning( + LocalHistoryWarningKind.revisionRead, + detail: error.toString(), + ), + ); + } + } + + void clearComparison() { + state = state.copyWith(selectedRevisionId: null, selectedRevision: null); + } + + Future search(String query) async { + final normalized = query; + final generation = ++_searchGeneration; + state = state.copyWith( + searchQuery: normalized, + searching: normalized.isNotEmpty, + searchMatches: const {}, + documentSearchMatches: const {}, + ); + if (normalized.isEmpty) return; + final documentId = state.selectedDocumentId; + final summaries = documentId == null + ? state.snapshot.revisions + : state.snapshot.revisionsFor(documentId); + final matches = {}; + final documentMatches = {}; + final needle = normalized.toLowerCase(); + if (state.findingDocuments) { + for (final document in state.snapshot.documents) { + if (document.displayName.toLowerCase().contains(needle) || + (document.currentPath?.toLowerCase().contains(needle) ?? false) || + document.historicalPaths.any( + (path) => path.toLowerCase().contains(needle), + )) { + documentMatches.add(document.id); + } + } + } + for (final summary in summaries) { + final revision = await _store.readRevision(summary.id); + if (!ref.mounted || generation != _searchGeneration) return; + if (revision?.source.toLowerCase().contains(needle) == true) { + matches.add(summary.id); + documentMatches.add(summary.documentId); + } + } + if (!ref.mounted || generation != _searchGeneration) return; + state = state.copyWith( + searching: false, + searchMatches: matches, + documentSearchMatches: documentMatches, + ); + } + + Future clearDocument(String documentId) async { + await _store.clearDocument(documentId); + _documentIdsByBuffer.removeWhere((_, value) => value == documentId); + await refresh(); + } + + Future clearAll() async { + _cancelCheckpoints(); + _documentIdsByBuffer.clear(); + await _store.clearAll(); + await refresh(); + } + + Future _capture( + LocalHistoryBufferSnapshot snapshot, + LocalHistoryCaptureReason reason, { + bool force = false, + bool ignoreBinding = false, + }) async { + final currentPolicy = policy; + if (!currentPolicy.recordingEnabled || + currentPolicy.excludes(snapshot.path)) { + return false; + } + if (snapshot.untitled && + snapshot.text.isEmpty && + reason == LocalHistoryCaptureReason.baseline) { + return true; + } + try { + final result = await _store.capture( + LocalHistoryCaptureRequest( + documentId: ignoreBinding + ? null + : _documentIdsByBuffer[snapshot.bufferId], + path: snapshot.path, + displayName: snapshot.displayName, + source: snapshot.text, + format: snapshot.format, + capturedAt: _clock().toUtc(), + reason: reason, + untitled: snapshot.untitled, + force: force, + ), + currentPolicy, + ); + if (!ignoreBinding) { + _documentIdsByBuffer[snapshot.bufferId] = result.document.id; + } + if (ref.mounted) { + final loaded = await _store.load(); + if (ref.mounted) { + state = state.copyWith(snapshot: loaded, warning: null); + } + } + return true; + } on Object catch (error) { + _setWarning(LocalHistoryWarningKind.capture, error.toString()); + return false; + } + } + + Future _enqueue(String bufferId, Future Function() operation) { + final prior = _bufferQueues[bufferId] ?? Future.value(); + final result = prior.then((_) => operation()); + late final Future tail; + tail = result.then((_) {}, onError: (_, _) {}).whenComplete(() { + if (identical(_bufferQueues[bufferId], tail)) { + _bufferQueues.remove(bufferId); + } + }); + _bufferQueues[bufferId] = tail; + return result; + } + + void _cancelCheckpoints() { + for (final timer in _checkpointTimers.values) { + timer.cancel(); + } + _checkpointTimers.clear(); + _pending.clear(); + } + + void _setWarning(LocalHistoryWarningKind kind, [String? detail]) { + if (ref.mounted) { + state = state.copyWith( + warning: LocalHistoryWarning(kind, detail: detail), + ); + } + } +} + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart new file mode 100644 index 00000000..e2f05773 --- /dev/null +++ b/lib/src/local_history/local_history_models.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../workspace/text_format_metadata.dart'; + +enum LocalHistoryCaptureReason { + baseline, + saved, + automaticCheckpoint, + beforeReload, + beforeDiscard, + beforeRestore, + beforeDelete, + externalChange, +} + +@immutable +class LocalHistoryPolicy { + const LocalHistoryPolicy({ + this.recordingEnabled = true, + this.checkpointInterval = const Duration(seconds: 60), + this.retentionAge = const Duration(days: 30), + this.maximumBytes = 512 * 1024 * 1024, + this.excludedPaths = const [], + this.pathContext, + }); + + final bool recordingEnabled; + final Duration checkpointInterval; + final Duration retentionAge; + final int maximumBytes; + final List excludedPaths; + final p.Context? pathContext; + + void validate() { + if (checkpointInterval < const Duration(seconds: 10) || + checkpointInterval > const Duration(hours: 1)) { + throw ArgumentError.value(checkpointInterval, 'checkpointInterval'); + } + if (retentionAge < const Duration(days: 1) || + retentionAge > const Duration(days: 3650)) { + throw ArgumentError.value(retentionAge, 'retentionAge'); + } + if (maximumBytes < 16 * 1024 * 1024 || + maximumBytes > 4 * 1024 * 1024 * 1024) { + throw ArgumentError.value(maximumBytes, 'maximumBytes'); + } + } + + bool excludes(String? path) { + if (path == null || path.isEmpty) return false; + final paths = pathContext ?? p.context; + final normalized = paths.normalize(paths.absolute(path)); + for (final excluded in excludedPaths) { + final root = paths.normalize(paths.absolute(excluded)); + if (paths.equals(root, normalized) || paths.isWithin(root, normalized)) { + return true; + } + } + return false; + } +} + +@immutable +class LocalHistoryDocument { + const LocalHistoryDocument({ + required this.id, + required this.displayName, + required this.updatedAt, + this.currentPath, + this.historicalPaths = const [], + this.deleted = false, + this.untitled = false, + }); + + final String id; + final String displayName; + final String? currentPath; + final List historicalPaths; + final DateTime updatedAt; + final bool deleted; + final bool untitled; + + LocalHistoryDocument copyWith({ + String? displayName, + Object? currentPath = _unset, + List? historicalPaths, + DateTime? updatedAt, + bool? deleted, + bool? untitled, + }) => LocalHistoryDocument( + id: id, + displayName: displayName ?? this.displayName, + currentPath: identical(currentPath, _unset) + ? this.currentPath + : currentPath as String?, + historicalPaths: historicalPaths ?? this.historicalPaths, + updatedAt: updatedAt ?? this.updatedAt, + deleted: deleted ?? this.deleted, + untitled: untitled ?? this.untitled, + ); + + Map toJson() => { + 'id': id, + 'displayName': displayName, + 'currentPath': currentPath, + 'historicalPaths': historicalPaths, + 'updatedAt': updatedAt.toUtc().toIso8601String(), + 'deleted': deleted, + 'untitled': untitled, + }; + + factory LocalHistoryDocument.fromJson(Map json) { + return LocalHistoryDocument( + id: _requiredId(json['id']), + displayName: json['displayName']?.toString() ?? '', + currentPath: json['currentPath']?.toString(), + historicalPaths: + (json['historicalPaths'] as List?)?.whereType().toList( + growable: false, + ) ?? + const [], + updatedAt: DateTime.parse(json['updatedAt'].toString()).toUtc(), + deleted: json['deleted'] as bool? ?? false, + untitled: json['untitled'] as bool? ?? false, + ); + } +} + +@immutable +class LocalHistoryRevisionSummary { + const LocalHistoryRevisionSummary({ + required this.id, + required this.documentId, + required this.capturedAt, + required this.reason, + required this.checksum, + required this.storageBytes, + required this.sourceLength, + this.historicalPath, + }); + + final String id; + final String documentId; + final DateTime capturedAt; + final LocalHistoryCaptureReason reason; + final String checksum; + final int storageBytes; + final int sourceLength; + final String? historicalPath; + + Map toJson() => { + 'id': id, + 'documentId': documentId, + 'capturedAt': capturedAt.toUtc().toIso8601String(), + 'reason': reason.name, + 'checksum': checksum, + 'storageBytes': storageBytes, + 'sourceLength': sourceLength, + 'historicalPath': historicalPath, + }; + + factory LocalHistoryRevisionSummary.fromJson(Map json) { + return LocalHistoryRevisionSummary( + id: _requiredId(json['id']), + documentId: _requiredId(json['documentId']), + capturedAt: DateTime.parse(json['capturedAt'].toString()).toUtc(), + reason: LocalHistoryCaptureReason.values.byName( + json['reason'].toString(), + ), + checksum: json['checksum'].toString(), + storageBytes: (json['storageBytes'] as num?)?.toInt() ?? 0, + sourceLength: (json['sourceLength'] as num?)?.toInt() ?? 0, + historicalPath: json['historicalPath']?.toString(), + ); + } +} + +@immutable +class LocalHistoryRevision { + const LocalHistoryRevision({ + required this.summary, + required this.source, + required this.format, + }); + + final LocalHistoryRevisionSummary summary; + final String source; + final TextFormatMetadata format; + + bool get isIntact => sourceChecksum(source) == summary.checksum; +} + +@immutable +class LocalHistorySnapshot { + const LocalHistorySnapshot({ + this.documents = const [], + this.revisions = const [], + this.warning, + }); + + final List documents; + final List revisions; + final String? warning; + + List revisionsFor(String documentId) => [ + for (final revision in revisions) + if (revision.documentId == documentId) revision, + ]..sort((left, right) => right.capturedAt.compareTo(left.capturedAt)); +} + +@immutable +class LocalHistoryCaptureRequest { + const LocalHistoryCaptureRequest({ + required this.displayName, + required this.source, + required this.format, + required this.capturedAt, + required this.reason, + this.documentId, + this.path, + this.untitled = false, + this.force = false, + }); + + final String? documentId; + final String? path; + final String displayName; + final String source; + final TextFormatMetadata format; + final DateTime capturedAt; + final LocalHistoryCaptureReason reason; + final bool untitled; + final bool force; +} + +@immutable +class LocalHistoryCaptureResult { + const LocalHistoryCaptureResult({ + required this.document, + this.revision, + this.deduplicated = false, + }); + + final LocalHistoryDocument document; + final LocalHistoryRevisionSummary? revision; + final bool deduplicated; +} + +String sourceChecksum(String source) => + sha256.convert(utf8.encode(source)).toString(); + +const Object _unset = Object(); + +String _requiredId(Object? value) { + final id = value?.toString() ?? ''; + if (!RegExp(r'^[A-Za-z0-9_-]{8,80}$').hasMatch(id)) { + throw const FormatException('Invalid Local History identity'); + } + return id; +} + +class UnsupportedLocalHistoryFormat implements Exception { + const UnsupportedLocalHistoryFormat(this.version); + + final Object? version; + + @override + String toString() => 'Unsupported Local History format $version'; +} + +class LocalHistoryStorageException implements Exception { + const LocalHistoryStorageException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => message; +} diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart new file mode 100644 index 00000000..d9f1679c --- /dev/null +++ b/lib/src/local_history/local_history_panel.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../app/busymark_design.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/busymark_search_field.dart'; +import '../app/busymark_dialogs.dart'; +import '../app/localization.dart'; +import '../workspace/workspace_controller.dart'; +import 'local_history_controller.dart'; +import 'local_history_models.dart'; + +class LocalHistoryPanel extends ConsumerStatefulWidget { + const LocalHistoryPanel({this.focusSearchRequest = 0, super.key}); + + final int focusSearchRequest; + + @override + ConsumerState createState() => _LocalHistoryPanelState(); +} + +class _LocalHistoryPanelState extends ConsumerState { + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _selectActive()); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _selectActive() async { + if (ref.read(localHistoryControllerProvider).findingDocuments) return; + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) { + await ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(buffer); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(localHistoryControllerProvider); + final controller = ref.read(localHistoryControllerProvider.notifier); + final documents = [...state.snapshot.documents] + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + final visibleDocuments = + state.findingDocuments && state.searchQuery.isNotEmpty + ? documents + .where( + (document) => state.documentSearchMatches.contains(document.id), + ) + .toList(growable: false) + : documents; + final selected = state.selectedDocument; + final visibleRevisions = state.selectedRevisions + .where(state.revisionVisible) + .toList(growable: false); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + BusyMarkSpacing.md, + BusyMarkSpacing.xs, + ), + child: Row( + children: [ + Expanded( + child: PopupMenuButton( + tooltip: context.l10n.localHistory, + enabled: documents.isNotEmpty, + onSelected: controller.selectDocument, + itemBuilder: (context) => [ + for (final document in documents) + PopupMenuItem( + value: document.id, + child: _DocumentLabel(document: document), + ), + ], + child: InputDecorator( + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + ), + child: Row( + children: [ + const Icon(BusyMarkGlyphs.documentHistory, size: 18), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: selected == null + ? Text(context.l10n.localHistoryNoDocuments) + : _DocumentLabel(document: selected), + ), + const Icon(BusyMarkGlyphs.downArrow, size: 16), + ], + ), + ), + ), + ), + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel, + onPressed: state.loading ? null : controller.refresh, + icon: state.loading + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(BusyMarkGlyphs.refresh), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.xs, + ), + child: BusyMarkSearchField( + controller: _searchController, + focusRequest: widget.focusSearchRequest, + hintText: context.l10n.localHistorySearchHint, + onChanged: (value) => unawaited(controller.search(value)), + ), + ), + if (state.warning != null) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(BusyMarkGlyphs.warning, size: 18), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Text( + localizeLocalHistoryWarning(context, state.warning!), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + Expanded( + child: state.findingDocuments + ? visibleDocuments.isEmpty + ? _HistoryEmpty( + text: documents.isEmpty + ? context.l10n.localHistoryNoDocuments + : context.l10n.localHistoryNoMatches, + ) + : ListView.builder( + itemCount: visibleDocuments.length, + itemBuilder: (context, index) { + final document = visibleDocuments[index]; + final path = + document.currentPath ?? + document.historicalPaths.lastOrNull; + return Semantics( + button: true, + child: ListTile( + leading: Icon( + document.deleted + ? BusyMarkGlyphs.delete + : BusyMarkGlyphs.documentHistory, + ), + title: Text(document.displayName), + subtitle: path == null + ? null + : Text( + path, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: document.deleted + ? Text(context.l10n.localHistoryDeleted) + : null, + onTap: () => + controller.selectDocument(document.id), + ), + ); + }, + ) + : selected == null + ? _HistoryEmpty(text: context.l10n.localHistoryNoDocuments) + : visibleRevisions.isEmpty + ? _HistoryEmpty( + text: state.searchQuery.isEmpty + ? context.l10n.localHistoryNoRevisions + : context.l10n.localHistoryNoMatches, + ) + : ListView.builder( + itemCount: visibleRevisions.length, + itemBuilder: (context, index) { + final revision = visibleRevisions[index]; + final previous = index == 0 + ? null + : visibleRevisions[index - 1]; + final startsDate = + previous == null || + !_sameDate(previous.capturedAt, revision.capturedAt); + final reason = _reason(context, revision.reason); + final timestamp = DateFormat.yMd( + Localizations.localeOf(context).toLanguageTag(), + ).add_Hms().format(revision.capturedAt.toLocal()); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (startsDate) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.md, + BusyMarkSpacing.md, + BusyMarkSpacing.xs, + ), + child: Text( + MaterialLocalizations.of( + context, + ).formatFullDate(revision.capturedAt.toLocal()), + style: Theme.of(context).textTheme.labelMedium, + ), + ), + Semantics( + selected: revision.id == state.selectedRevisionId, + button: true, + label: context.l10n.localHistoryRevisionAt( + reason, + timestamp, + ), + excludeSemantics: true, + child: ListTile( + selected: revision.id == state.selectedRevisionId, + leading: const Icon(BusyMarkGlyphs.history), + title: Text(reason), + subtitle: Text(timestamp), + onTap: () => unawaited( + controller.selectRevision(revision.id), + ), + ), + ), + ], + ); + }, + ), + ), + if (selected != null) + Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: PopupMenuButton<_ClearAction>( + tooltip: context.l10n.actions, + onSelected: (action) => _confirmClear(action, selected.id), + itemBuilder: (context) => [ + PopupMenuItem( + value: _ClearAction.document, + child: Text(context.l10n.localHistoryClearDocument), + ), + PopupMenuItem( + value: _ClearAction.all, + child: Text(context.l10n.localHistoryClearAll), + ), + ], + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Icon(BusyMarkGlyphs.menuHorizontal), + const SizedBox(width: BusyMarkSpacing.xs), + Text(context.l10n.actions), + ], + ), + ), + ), + ], + ); + } + + Future _confirmClear(_ClearAction action, String documentId) async { + final confirmed = await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text( + action == _ClearAction.document + ? dialogContext.l10n.localHistoryClearDocumentTitle + : dialogContext.l10n.localHistoryClearAllTitle, + ), + content: Text(dialogContext.l10n.localHistoryClearConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(dialogContext.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final controller = ref.read(localHistoryControllerProvider.notifier); + if (action == _ClearAction.document) { + await controller.clearDocument(documentId); + } else { + await controller.clearAll(); + } + } +} + +enum _ClearAction { document, all } + +class _DocumentLabel extends StatelessWidget { + const _DocumentLabel({required this.document}); + + final LocalHistoryDocument document; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: + document.currentPath ?? document.historicalPaths.lastOrNull ?? '', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + document.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (document.deleted) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + Text( + context.l10n.localHistoryDeleted, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ); + } +} + +class _HistoryEmpty extends StatelessWidget { + const _HistoryEmpty({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Text(text, textAlign: TextAlign.center), + ), + ); +} + +String _reason(BuildContext context, LocalHistoryCaptureReason reason) { + final l10n = context.l10n; + return switch (reason) { + LocalHistoryCaptureReason.baseline => l10n.localHistoryReasonBaseline, + LocalHistoryCaptureReason.saved => l10n.localHistoryReasonSaved, + LocalHistoryCaptureReason.automaticCheckpoint => + l10n.localHistoryReasonAutomaticCheckpoint, + LocalHistoryCaptureReason.beforeReload => + l10n.localHistoryReasonBeforeReload, + LocalHistoryCaptureReason.beforeDiscard => + l10n.localHistoryReasonBeforeDiscard, + LocalHistoryCaptureReason.beforeRestore => + l10n.localHistoryReasonBeforeRestore, + LocalHistoryCaptureReason.beforeDelete => + l10n.localHistoryReasonBeforeDelete, + LocalHistoryCaptureReason.externalChange => + l10n.localHistoryReasonExternalChange, + }; +} + +bool _sameDate(DateTime left, DateTime right) { + final localLeft = left.toLocal(); + final localRight = right.toLocal(); + return localLeft.year == localRight.year && + localLeft.month == localRight.month && + localLeft.day == localRight.day; +} + +String localizeLocalHistoryWarning( + BuildContext context, + LocalHistoryWarning warning, +) { + final l10n = context.l10n; + final detail = warning.detail ?? ''; + return switch (warning.kind) { + LocalHistoryWarningKind.indexRebuilt => + l10n.localHistoryWarningIndexRebuilt, + LocalHistoryWarningKind.unsupportedFormat => + l10n.localHistoryWarningUnsupportedFormat(detail), + LocalHistoryWarningKind.unavailable => l10n.localHistoryWarningUnavailable( + detail, + ), + LocalHistoryWarningKind.recordingDisabled => + l10n.localHistoryWarningRecordingDisabled, + LocalHistoryWarningKind.pathChange => l10n.localHistoryWarningPathChange( + detail, + ), + LocalHistoryWarningKind.deletedPath => l10n.localHistoryWarningDeletedPath( + detail, + ), + LocalHistoryWarningKind.revisionMissing => + l10n.localHistoryWarningRevisionMissing, + LocalHistoryWarningKind.revisionRead => + l10n.localHistoryWarningRevisionRead(detail), + LocalHistoryWarningKind.capture => l10n.localHistoryWarningCapture(detail), + }; +} diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart new file mode 100644 index 00000000..f9ca8c93 --- /dev/null +++ b/lib/src/local_history/local_history_store.dart @@ -0,0 +1,916 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +import 'local_history_models.dart'; +import '../workspace/text_format_metadata.dart'; + +abstract interface class LocalHistoryStore { + Future load(); + + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ); + + Future readRevision(String revisionId); + + Future prune(LocalHistoryPolicy policy, DateTime now); + + Future remapPath(String sourcePath, String destinationPath); + + Future markDeleted(String path, {required bool recursive}); + + Future clearDocument(String documentId); + + Future clearAll(); +} + +class FileLocalHistoryStore implements LocalHistoryStore { + FileLocalHistoryStore({ + Future Function()? rootDirectory, + String Function()? createId, + }) : _rootDirectory = rootDirectory ?? _defaultRootDirectory, + _createId = createId ?? const Uuid().v4; + + static const formatVersion = 1; + final Future Function() _rootDirectory; + final String Function() _createId; + Future _queue = Future.value(); + static final Map> _rootQueues = {}; + + static Future _defaultRootDirectory() async { + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, 'local_history')); + } + + @override + Future load() => + _serialized((root) => _withFileLock(root, () => _loadUnlocked(root))); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) => _serialized((root) async { + policy.validate(); + return _withFileLock(root, () async { + var index = await _loadIndexUnlocked(root, repair: true); + var document = _resolveDocument(index.documents, request); + document ??= LocalHistoryDocument( + id: _safeNewId(), + displayName: request.displayName, + currentPath: request.path, + historicalPaths: request.path == null ? const [] : [request.path!], + updatedAt: request.capturedAt.toUtc(), + untitled: request.untitled, + ); + document = _updatedDocument(document, request); + final checksum = sourceChecksum(request.source); + final adjacent = index.revisions + .where((revision) => revision.documentId == document!.id) + .fold( + null, + (latest, revision) => + latest == null || revision.capturedAt.isAfter(latest.capturedAt) + ? revision + : latest, + ); + if (!request.force && adjacent?.checksum == checksum) { + index = index.withDocument(document); + await _publishIndex(root, index); + return LocalHistoryCaptureResult( + document: document, + revision: adjacent, + 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 = _safeNewId(); + var summary = LocalHistoryRevisionSummary( + id: revisionId, + documentId: document.id, + capturedAt: request.capturedAt.toUtc(), + reason: request.reason, + checksum: checksum, + storageBytes: 0, + sourceLength: request.source.length, + historicalPath: request.path, + ); + final target = _revisionFile(root, document.id, revisionId); + await target.parent.create(recursive: true); + final encoded = _revisionJson(document, summary, request); + await _publishNewFile(target, encoded); + 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, + ); + index = index.withCapture(document, summary); + // A complete revision exists before the index points to it. + await _publishIndex(root, index); + final retained = await _applyRetention( + root, + index, + policy, + revisionId, + request.capturedAt.toUtc(), + ); + if (!identical(retained, index)) await _publishIndex(root, retained); + return LocalHistoryCaptureResult(document: document, revision: summary); + }); + }); + + @override + Future readRevision(String revisionId) => _serialized( + (root) => _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final summary = index.revisions + .where((candidate) => candidate.id == revisionId) + .firstOrNull; + if (summary == null) return null; + final document = index.documents + .where((candidate) => candidate.id == summary.documentId) + .firstOrNull; + if (document == null) return null; + return _readRevisionFile( + _revisionFile(root, document.id, summary.id), + expected: summary, + ); + }), + ); + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) => _serialized( + (root) => _withFileLock(root, () async { + policy.validate(); + final index = await _loadIndexUnlocked(root, repair: true); + final retained = await _applyRetention( + root, + index, + policy, + null, + now.toUtc(), + ); + if (!identical(retained, index)) await _publishIndex(root, retained); + }), + ); + + @override + Future remapPath(String sourcePath, String destinationPath) => + _mutateDocuments((document) { + final current = document.currentPath; + if (current == null) return document; + final mapped = _remap(current, sourcePath, destinationPath); + if (mapped == null) return document; + return document.copyWith( + currentPath: mapped, + displayName: p.basename(mapped), + historicalPaths: _uniquePaths([ + ...document.historicalPaths, + current, + mapped, + ]), + deleted: false, + updatedAt: DateTime.now().toUtc(), + ); + }); + + @override + Future markDeleted(String path, {required bool recursive}) => + _mutateDocuments((document) { + final current = document.currentPath; + if (current == null || + !(p.equals(current, path) || + (recursive && p.isWithin(path, current)))) { + return document; + } + return document.copyWith( + deleted: true, + updatedAt: DateTime.now().toUtc(), + ); + }); + + Future _mutateDocuments( + LocalHistoryDocument Function(LocalHistoryDocument) mutate, + ) => _serialized( + (root) => _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final documents = [ + for (final document in index.documents) mutate(document), + ]; + await _publishIndex(root, index.copyWith(documents: documents)); + }), + ); + + @override + Future clearDocument(String documentId) => _serialized( + (root) => _withFileLock(root, () async { + var index = await _loadIndexUnlocked(root, repair: true); + final removed = { + for (final revision in index.revisions) + if (revision.documentId == documentId) revision.id, + }; + index = index.copyWith( + documents: [ + for (final document in index.documents) + if (document.id != documentId) document, + ], + revisions: [ + for (final revision in index.revisions) + if (revision.documentId != documentId) revision, + ], + tombstones: {...index.tombstones, ...removed}, + ); + await _publishIndex(root, index); + await _deleteDirectoryBestEffort( + Directory(p.join(root.path, 'revisions', documentId)), + ); + }), + ); + + @override + Future clearAll() => _serialized( + (root) => _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final cleared = _Index( + documents: const [], + revisions: const [], + tombstones: { + ...index.tombstones, + for (final revision in index.revisions) revision.id, + }, + ); + await _publishIndex(root, cleared); + await _deleteDirectoryBestEffort( + Directory(p.join(root.path, 'revisions')), + ); + }), + ); + + Future _serialized(Future Function(Directory root) operation) { + final completer = Completer(); + _queue = _queue.then((_) async { + try { + final root = await _rootDirectory(); + await root.create(recursive: true); + completer.complete( + await _serializedForRoot(root, () => operation(root)), + ); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + return completer.future; + } + + static Future _serializedForRoot( + Directory root, + Future Function() operation, + ) { + final key = p.normalize(root.absolute.path); + final prior = _rootQueues[key] ?? Future.value(); + final completer = Completer(); + late final Future tail; + tail = prior + .then((_) async { + try { + completer.complete(await operation()); + } on Object catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }) + .whenComplete(() { + if (identical(_rootQueues[key], tail)) _rootQueues.remove(key); + }); + _rootQueues[key] = tail; + return completer.future; + } + + Future _withFileLock(Directory root, Future Function() body) async { + final file = File(p.join(root.path, '.store.lock')); + final lock = await file.open(mode: FileMode.append); + try { + await lock.lock(FileLock.exclusive); + return await body(); + } finally { + try { + await lock.unlock(); + } on FileSystemException { + // Closing releases the lock even after an interrupted lock request. + } + await lock.close(); + } + } + + Future _loadUnlocked(Directory root) async { + final index = await _loadIndexUnlocked(root, repair: true); + return LocalHistorySnapshot( + documents: List.unmodifiable(index.documents), + revisions: List.unmodifiable(index.revisions), + warning: index.warning, + ); + } + + Future<_Index> _loadIndexUnlocked( + Directory root, { + required bool repair, + }) async { + final file = File(p.join(root.path, 'index.json')); + try { + if (!await file.exists()) { + return repair ? await _rebuildIndex(root) : const _Index(); + } + final json = jsonDecode(await file.readAsString()); + if (json is! Map) throw const FormatException('Invalid history index'); + final map = json.cast(); + if (map['version'] != formatVersion) { + throw UnsupportedLocalHistoryFormat(map['version']); + } + return _Index.fromJson(map); + } on UnsupportedLocalHistoryFormat { + rethrow; + } on Object { + if (!repair) rethrow; + final rebuilt = await _rebuildIndex(root); + await _publishIndex(root, rebuilt); + return rebuilt.copyWith( + warning: 'The Local History index was damaged and rebuilt.', + ); + } + } + + Future<_Index> _rebuildIndex(Directory root) async { + final existing = await _readIndexTombstones(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)) { + if (entity is! File || p.extension(entity.path) != '.json') continue; + final relative = p.relative(entity.path, from: directory.path); + final parts = p.split(relative); + if (parts.length != 2) continue; + final documentId = parts[0]; + final revisionId = p.basenameWithoutExtension(parts[1]); + if (!_validId(documentId) || + !_validId(revisionId) || + existing.contains(revisionId)) { + 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; + } + 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, + ), + ); + } on Object { + // A malformed record cannot invalidate independently intact records. + } + } + return _Index( + documents: documents.values.toList(growable: false), + revisions: revisions, + tombstones: existing, + ); + } + + Future> _readIndexTombstones(Directory root) async { + final result = {}; + final journal = File(p.join(root.path, 'tombstones.json')); + try { + final values = jsonDecode(await journal.readAsString()); + if (values is List) { + result.addAll(values.whereType().where(_validId)); + } + } on Object { + // A damaged journal does not make intact revision records unreadable. + } + final file = File(p.join(root.path, 'index.json')); + try { + final map = (jsonDecode(await file.readAsString()) as Map) + .cast(); + result.addAll( + (map['tombstones'] as List?)?.whereType().where(_validId) ?? + const [], + ); + } on Object { + // The independent journal remains usable when the index is damaged. + } + return result; + } + + Future _readRevisionFile( + 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 { + return null; + } + } + + Future<_Index> _applyRetention( + Directory root, + _Index index, + LocalHistoryPolicy policy, + String? protectedRevisionId, + DateTime now, + ) async { + final cutoff = now.subtract(policy.retentionAge); + final ordered = [...index.revisions] + ..sort((left, right) => left.capturedAt.compareTo(right.capturedAt)); + var total = ordered.fold( + 0, + (value, revision) => value + revision.storageBytes, + ); + final removed = {}; + for (final revision in ordered) { + if (revision.id == protectedRevisionId) continue; + if (revision.capturedAt.isBefore(cutoff) || total > policy.maximumBytes) { + removed.add(revision.id); + total -= revision.storageBytes; + } + } + final revisions = [ + for (final revision in index.revisions) + if (!removed.contains(revision.id)) revision, + ]; + final retainedDocumentIds = { + for (final revision in revisions) revision.documentId, + }; + final documents = [ + for (final document in index.documents) + if (retainedDocumentIds.contains(document.id)) document, + ]; + if (removed.isEmpty && documents.length == index.documents.length) { + return index; + } + final retained = index.copyWith( + documents: documents, + revisions: revisions, + tombstones: {...index.tombstones, ...removed}, + ); + // Publish tombstones before cleanup, preventing repair from resurrecting + // an intentionally evicted record after an interrupted deletion. + await _publishIndex(root, retained); + for (final revision in index.revisions) { + if (!removed.contains(revision.id)) continue; + await _deleteFileBestEffort( + _revisionFile(root, revision.documentId, revision.id), + ); + } + return retained; + } + + Future _publishIndex(Directory root, _Index index) async { + await _publishReplacingFile( + File(p.join(root.path, 'tombstones.json')), + const JsonEncoder.withIndent( + ' ', + ).convert(index.tombstones.toList()..sort()), + ); + await _publishReplacingFile( + File(p.join(root.path, 'index.json')), + const JsonEncoder.withIndent(' ').convert(index.toJson()), + ); + } + + Future _publishNewFile(File target, String content) async { + if (await target.exists()) { + throw LocalHistoryStorageException('Revision identity collision.'); + } + final staging = File('${target.path}.staging-${_safeNewId()}'); + try { + await staging.writeAsString(content, flush: true); + if (await target.exists()) { + throw LocalHistoryStorageException('Revision identity collision.'); + } + await staging.rename(target.path); + } finally { + await _deleteFileBestEffort(staging); + } + } + + Future _publishReplacingFile(File target, String content) async { + await target.parent.create(recursive: true); + final staging = File('${target.path}.staging-${_safeNewId()}'); + try { + await staging.writeAsString(content, flush: true); + await staging.rename(target.path); + } finally { + await _deleteFileBestEffort(staging); + } + } + + String _revisionJson( + LocalHistoryDocument document, + LocalHistoryRevisionSummary summary, + LocalHistoryCaptureRequest request, + ) => const JsonEncoder.withIndent(' ').convert({ + 'version': formatVersion, + 'document': document.toJson(), + 'revision': summary.toJson(), + 'format': request.format.toJson(), + 'source': request.source, + }); + + File _revisionFile(Directory root, String documentId, String revisionId) { + if (!_validId(documentId) || !_validId(revisionId)) { + throw const FormatException('Invalid Local History path identity'); + } + final revisionsRoot = p.normalize(p.join(root.path, 'revisions')); + final path = p.normalize( + p.join(revisionsRoot, documentId, '$revisionId.json'), + ); + if (!p.isWithin(revisionsRoot, path)) { + throw const FormatException('Local History path escaped its root'); + } + return File(path); + } + + String _safeNewId() { + final value = _createId().replaceAll(RegExp('[^A-Za-z0-9_-]'), ''); + if (!_validId(value)) { + throw const LocalHistoryStorageException( + 'The Local History identity generator returned an invalid value.', + ); + } + return value; + } +} + +class _Index { + const _Index({ + this.documents = const [], + this.revisions = const [], + this.tombstones = const {}, + this.warning, + }); + + final List documents; + final List revisions; + final Set tombstones; + final String? warning; + + factory _Index.fromJson(Map json) => _Index( + documents: [ + for (final value in (json['documents'] as List? ?? const [])) + LocalHistoryDocument.fromJson((value as Map).cast()), + ], + revisions: [ + for (final value in (json['revisions'] as List? ?? const [])) + LocalHistoryRevisionSummary.fromJson( + (value as Map).cast(), + ), + ], + tombstones: + (json['tombstones'] as List?) + ?.whereType() + .where(_validId) + .toSet() ?? + const {}, + ); + + _Index copyWith({ + List? documents, + List? revisions, + Set? tombstones, + String? warning, + }) => _Index( + documents: documents ?? this.documents, + revisions: revisions ?? this.revisions, + tombstones: tombstones ?? this.tombstones, + warning: warning ?? this.warning, + ); + + _Index withDocument(LocalHistoryDocument document) => copyWith( + documents: [ + for (final existing in documents) + if (existing.id != document.id) existing, + document, + ], + ); + + _Index withCapture( + LocalHistoryDocument document, + LocalHistoryRevisionSummary revision, + ) => copyWith( + documents: [ + for (final existing in documents) + if (existing.id != document.id) existing, + document, + ], + revisions: [...revisions, revision], + ); + + Map toJson() => { + 'version': FileLocalHistoryStore.formatVersion, + 'documents': [for (final document in documents) document.toJson()], + 'revisions': [for (final revision in revisions) revision.toJson()], + 'tombstones': tombstones.toList()..sort(), + }; +} + +LocalHistoryDocument? _resolveDocument( + List documents, + LocalHistoryCaptureRequest request, +) { + final requestedId = request.documentId; + if (requestedId != null) { + final byId = documents.where((document) => document.id == requestedId); + if (byId.isNotEmpty) return byId.first; + } + final path = request.path; + if (path == null) return null; + return documents + .where( + (document) => + document.currentPath != null && + p.equals(document.currentPath!, path), + ) + .firstOrNull; +} + +LocalHistoryDocument _updatedDocument( + LocalHistoryDocument document, + LocalHistoryCaptureRequest request, +) { + final path = request.path; + return document.copyWith( + displayName: request.displayName, + currentPath: path, + historicalPaths: path == null + ? document.historicalPaths + : _uniquePaths([...document.historicalPaths, path]), + updatedAt: request.capturedAt.toUtc(), + deleted: false, + untitled: request.untitled && path == null, + ); +} + +String? _remap(String current, String source, String destination) { + if (p.equals(current, source)) return p.normalize(destination); + if (!p.isWithin(source, current)) return null; + return p.normalize(p.join(destination, p.relative(current, from: source))); +} + +List _uniquePaths(Iterable values) { + final result = []; + for (final value in values) { + if (result.every((existing) => !p.equals(existing, value))) { + result.add(value); + } + } + return List.unmodifiable(result); +} + +bool _validId(String value) => RegExp(r'^[A-Za-z0-9_-]{8,80}$').hasMatch(value); + +Future _deleteFileBestEffort(File file) async { + try { + if (await file.exists()) await file.delete(); + } on FileSystemException { + // A tombstone prevents a later repair from restoring this entry. + } +} + +Future _deleteDirectoryBestEffort(Directory directory) async { + try { + if (await directory.exists()) await directory.delete(recursive: true); + } on FileSystemException { + // A tombstone prevents a later repair from restoring these entries. + } +} + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} + +class MemoryLocalHistoryStore implements LocalHistoryStore { + final _documents = {}; + final _revisions = {}; + var _sequence = 0; + + String _id(String prefix) => + '$prefix${(++_sequence).toString().padLeft(12, '0')}'; + + @override + Future load() async => LocalHistorySnapshot( + documents: List.unmodifiable(_documents.values), + revisions: List.unmodifiable( + _revisions.values.map((revision) => revision.summary), + ), + ); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + policy.validate(); + var document = _resolveDocument(_documents.values.toList(), request); + document ??= LocalHistoryDocument( + id: _id('document_'), + displayName: request.displayName, + currentPath: request.path, + historicalPaths: request.path == null ? const [] : [request.path!], + updatedAt: request.capturedAt.toUtc(), + untitled: request.untitled, + ); + document = _updatedDocument(document, request); + _documents[document.id] = document; + final checksum = sourceChecksum(request.source); + final adjacent = _revisions.values + .where((revision) => revision.summary.documentId == document!.id) + .map((revision) => revision.summary) + .fold( + null, + (latest, revision) => + latest == null || revision.capturedAt.isAfter(latest.capturedAt) + ? revision + : latest, + ); + if (!request.force && adjacent?.checksum == checksum) { + return LocalHistoryCaptureResult( + document: document, + revision: adjacent, + deduplicated: true, + ); + } + final bytes = utf8.encode(request.source).length + 256; + if (bytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + final summary = LocalHistoryRevisionSummary( + id: _id('revision_'), + documentId: document.id, + capturedAt: request.capturedAt.toUtc(), + reason: request.reason, + checksum: checksum, + storageBytes: bytes, + sourceLength: request.source.length, + historicalPath: request.path, + ); + _revisions[summary.id] = LocalHistoryRevision( + summary: summary, + source: request.source, + format: request.format, + ); + _applyMemoryRetention(policy, request.capturedAt.toUtc(), summary.id); + return LocalHistoryCaptureResult(document: document, revision: summary); + } + + void _applyMemoryRetention( + LocalHistoryPolicy policy, + DateTime now, + String protectedId, + ) { + final ordered = _revisions.values.toList() + ..sort( + (left, right) => + left.summary.capturedAt.compareTo(right.summary.capturedAt), + ); + var total = ordered.fold( + 0, + (value, revision) => value + revision.summary.storageBytes, + ); + final cutoff = now.subtract(policy.retentionAge); + for (final revision in ordered) { + final summary = revision.summary; + if (summary.id == protectedId) continue; + if (summary.capturedAt.isBefore(cutoff) || total > policy.maximumBytes) { + _revisions.remove(summary.id); + total -= summary.storageBytes; + } + } + } + + @override + Future readRevision(String revisionId) async => + _revisions[revisionId]; + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) async { + policy.validate(); + _applyMemoryRetention(policy, now.toUtc(), ''); + final retainedDocumentIds = { + for (final revision in _revisions.values) revision.summary.documentId, + }; + _documents.removeWhere((id, _) => !retainedDocumentIds.contains(id)); + } + + @override + Future remapPath(String sourcePath, String destinationPath) async { + for (final entry in _documents.entries.toList()) { + final current = entry.value.currentPath; + final mapped = current == null + ? null + : _remap(current, sourcePath, destinationPath); + if (mapped != null) { + _documents[entry.key] = entry.value.copyWith( + currentPath: mapped, + displayName: p.basename(mapped), + historicalPaths: _uniquePaths([ + ...entry.value.historicalPaths, + current!, + mapped, + ]), + deleted: false, + ); + } + } + } + + @override + Future markDeleted(String path, {required bool recursive}) async { + for (final entry in _documents.entries.toList()) { + final current = entry.value.currentPath; + if (current != null && + (p.equals(current, path) || + (recursive && p.isWithin(path, current)))) { + _documents[entry.key] = entry.value.copyWith(deleted: true); + } + } + } + + @override + Future clearDocument(String documentId) async { + _documents.remove(documentId); + _revisions.removeWhere( + (_, revision) => revision.summary.documentId == documentId, + ); + } + + @override + Future clearAll() async { + _documents.clear(); + _revisions.clear(); + } +} diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 0ad1759a..91980912 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -32,6 +32,9 @@ class HeaderBarLabels { required this.back, required this.backShortcut, required this.save, + required this.clipboardHistory, + required this.localHistory, + required this.findLocalHistory, required this.export, required this.exportShortcut, required this.exportGtkAccelerator, @@ -73,6 +76,9 @@ class HeaderBarLabels { final String back; final String backShortcut; final String save; + final String clipboardHistory; + final String localHistory; + final String findLocalHistory; final String export; final String exportShortcut; final String exportGtkAccelerator; @@ -114,6 +120,9 @@ class HeaderBarLabels { 'back': back, 'backShortcut': backShortcut, 'save': save, + 'clipboardHistory': clipboardHistory, + 'localHistory': localHistory, + 'findLocalHistory': findLocalHistory, 'export': export, 'exportShortcut': exportShortcut, 'exportGtkAccelerator': exportGtkAccelerator, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 0e22370d..9642cd64 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -16,6 +16,9 @@ enum HeaderBarAction { search, refresh, save, + clipboardHistory, + localHistory, + findLocalHistory, export, fullScreen, menu, @@ -391,6 +394,9 @@ class LinuxHeaderBarService extends ChangeNotifier { 'search' => HeaderBarAction.search, 'refresh' => HeaderBarAction.refresh, 'save' => HeaderBarAction.save, + 'clipboardHistory' => HeaderBarAction.clipboardHistory, + 'localHistory' => HeaderBarAction.localHistory, + 'findLocalHistory' => HeaderBarAction.findLocalHistory, 'export' => HeaderBarAction.export, 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, diff --git a/lib/src/platform/rich_clipboard_service.dart b/lib/src/platform/rich_clipboard_service.dart index e3bdfb0a..4f308cac 100644 --- a/lib/src/platform/rich_clipboard_service.dart +++ b/lib/src/platform/rich_clipboard_service.dart @@ -1,23 +1,52 @@ import 'dart:async'; - import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; + +import '../clipboard/clipboard_models.dart'; const richClipboardChannelName = 'com.busymark.app/rich_clipboard'; -const busyMarkClipboardMimeType = 'application/x-busymark-fragment+json'; +const busyMarkClipboardTokenMimeType = 'application/x-busymark-token'; const maxRichClipboardBytes = 16 * 1024 * 1024; class RichClipboardData { - const RichClipboardData({this.text, this.html, this.fragment}); + const RichClipboardData({ + this.text, + this.html, + this.sourceText, + this.richFragment, + this.sessionOwned = false, + this.token, + this.generation, + this.origin, + this.mediaBytes = const {}, + this.mediaComplete = true, + }); final String? text; final String? html; - final String? fragment; + final String? sourceText; + final String? richFragment; + final bool sessionOwned; + final String? token; + final int? generation; + final BusyMarkClipboardOrigin? origin; + final Map mediaBytes; + final bool mediaComplete; - Map toMap() => { + Map toPlatformMap(String ownershipToken) => { if (text != null) 'text': text!, if (html != null) 'html': html!, - if (fragment != null) 'fragment': fragment!, + 'token': ownershipToken, }; + + /// Used to reject an image obtained through the separate native image + /// boundary if the clipboard owner changed between the two reads. + bool sameExternalIdentity(RichClipboardData other) => + generation != null && + generation == other.generation && + token == other.token && + text == other.text && + html == other.html; } /// One write publishes all representations of the same immutable selection. @@ -25,9 +54,13 @@ class RichClipboardData { class RichClipboardService { RichClipboardService({ MethodChannel channel = const MethodChannel(richClipboardChannelName), - }) : _channel = channel; + String Function()? createToken, + }) : _channel = channel, + _createToken = createToken ?? const Uuid().v4; final MethodChannel _channel; + final String Function() _createToken; + final Map _knownPayloads = {}; Future? _pendingWrite; Future write(RichClipboardData data) { @@ -43,13 +76,23 @@ class RichClipboardService { } Future _write(RichClipboardData data) async { + final token = _createToken(); try { - return await _channel.invokeMethod('write', data.toMap()) ?? false; + final success = + await _channel.invokeMethod( + 'write', + data.toPlatformMap(token), + ) ?? + false; + if (success) _remember(token, data); + return success; } on MissingPluginException { // Unsupported hosts can still exchange plain text. Never recover rich // data from a text-equality cache: a different copy may contain that text. try { await Clipboard.setData(ClipboardData(text: data.text ?? '')); + // ClipboardData cannot publish the ownership token. Do not remember a + // rich association that can never be proven on the next read. return true; } on PlatformException { return false; @@ -69,10 +112,30 @@ class RichClipboardService { .timeout(const Duration(seconds: 5)); String? field(String name) => value?[name] is String ? value![name] as String : null; + final generation = value?['generation'] is int + ? value!['generation'] as int + : null; + final token = field('token'); + final known = token == null ? null : _knownPayloads[token]; + if (known == null) { + return RichClipboardData( + text: field('text'), + html: field('html'), + token: token, + generation: generation, + ); + } return RichClipboardData( - text: field('text'), - html: field('html'), - fragment: field('fragment'), + text: field('text') ?? known.text, + html: field('html') ?? known.html, + sourceText: known.sourceText, + richFragment: known.richFragment, + sessionOwned: true, + token: token, + generation: generation, + origin: known.origin, + mediaBytes: known.mediaBytes, + mediaComplete: known.mediaComplete, ); } on MissingPluginException { return RichClipboardData(text: await readPlainText()); @@ -83,6 +146,25 @@ class RichClipboardService { } } + void _remember(String token, RichClipboardData data) { + _knownPayloads[token] = RichClipboardData( + text: data.text, + html: data.html, + sourceText: data.sourceText, + richFragment: data.richFragment, + sessionOwned: true, + token: token, + origin: data.origin, + mediaBytes: _copyMediaBytes(data.mediaBytes), + mediaComplete: data.mediaComplete, + ); + // Tokens only identify current-session immutable payloads. Bound stale + // ownership records independently from Clipboard History retention. + while (_knownPayloads.length > 128) { + _knownPayloads.remove(_knownPayloads.keys.first); + } + } + Future readPlainText() async { await _pendingWrite; try { @@ -99,4 +181,10 @@ class RichClipboardService { } } +Map _copyMediaBytes(Map values) => + Map.unmodifiable({ + for (final entry in values.entries) + entry.key: Uint8List.fromList(entry.value), + }); + final busyMarkRichClipboardService = RichClipboardService(); diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 04fe49e9..71dfaf00 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -121,6 +121,62 @@ class _SettingsScreenState extends ConsumerState { ), ], ), + SettingsPage.history => BusyMarkGroupedList( + title: l10n.settingsHistory, + filled: true, + children: [ + BusyMarkSwitchRow( + title: l10n.settingsClipboardHistoryTitle, + subtitle: l10n.settingsClipboardHistoryDescription, + value: settings.clipboardHistoryEnabled, + onChanged: controller.setClipboardHistoryEnabled, + leading: const Icon(BusyMarkGlyphs.copy), + ), + BusyMarkSwitchRow( + title: l10n.settingsLocalHistoryTitle, + subtitle: l10n.settingsLocalHistoryDescription, + value: settings.localHistoryRecordingEnabled, + onChanged: controller.setLocalHistoryRecordingEnabled, + leading: const Icon(BusyMarkGlyphs.documentHistory), + ), + _HistoryNumberRow( + title: l10n.settingsHistoryCheckpoint, + value: settings.localHistoryCheckpointSeconds, + choices: const [30, 60, 120, 300, 600], + format: l10n.settingsSecondsValue, + enabled: settings.localHistoryRecordingEnabled, + onChanged: controller.setLocalHistoryCheckpointSeconds, + ), + _HistoryNumberRow( + title: l10n.settingsHistoryRetention, + value: settings.localHistoryRetentionDays, + choices: const [7, 30, 90, 365], + format: l10n.settingsDaysValue, + enabled: settings.localHistoryRecordingEnabled, + onChanged: controller.setLocalHistoryRetentionDays, + ), + _HistoryNumberRow( + title: l10n.settingsHistoryStorage, + value: settings.localHistoryMaximumStorageMiB, + choices: const [128, 256, 512, 1024, 2048, 4096], + format: l10n.settingsMebibytesValue, + enabled: settings.localHistoryRecordingEnabled, + onChanged: controller.setLocalHistoryMaximumStorageMiB, + ), + BusyMarkActionRow( + title: l10n.settingsHistoryExcludedPaths, + subtitle: settings.localHistoryExcludedPaths.isEmpty + ? l10n.settingsHistoryExcludedPathsHint + : settings.localHistoryExcludedPaths.join('\n'), + leading: const Icon(BusyMarkGlyphs.folder), + onTap: () => _editHistoryExcludedPaths( + context, + settings.localHistoryExcludedPaths, + controller.setLocalHistoryExcludedPaths, + ), + ), + ], + ), SettingsPage.ai => const _AiSettingsPage(), SettingsPage.window => BusyMarkGroupedList( title: l10n.settingsWindowSectionTitle, @@ -318,6 +374,9 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: + case HeaderBarAction.clipboardHistory: + case HeaderBarAction.localHistory: + case HeaderBarAction.findLocalHistory: case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: @@ -342,6 +401,10 @@ class _SettingsScreenState extends ConsumerState { case BusyMarkMainMenuAction.export: case BusyMarkMainMenuAction.generateMarkdownToc: break; + case BusyMarkMainMenuAction.clipboardHistory: + case BusyMarkMainMenuAction.localHistory: + case BusyMarkMainMenuAction.findLocalHistory: + break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -363,10 +426,93 @@ class _SettingsScreenState extends ConsumerState { } } +class _HistoryNumberRow extends StatelessWidget { + const _HistoryNumberRow({ + required this.title, + required this.value, + required this.choices, + required this.format, + required this.enabled, + required this.onChanged, + }); + + final String title; + final int value; + final List choices; + final String Function(int value) format; + final bool enabled; + final Future Function(int value) onChanged; + + @override + Widget build(BuildContext context) { + final values = {...choices, value}.toList()..sort(); + return BusyMarkActionRow( + title: title, + enabled: enabled, + leading: const Icon(BusyMarkGlyphs.history), + trailing: DropdownButton( + value: value, + onChanged: enabled + ? (next) { + if (next != null) unawaited(onChanged(next)); + } + : null, + items: [ + for (final choice in values) + DropdownMenuItem(value: choice, child: Text(format(choice))), + ], + ), + ); + } +} + +Future _editHistoryExcludedPaths( + BuildContext context, + List current, + Future Function(Iterable) save, +) async { + final controller = TextEditingController(text: current.join('\n')); + final result = await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.settingsHistoryExcludedPaths), + content: SizedBox( + width: BusyMarkSizes.settingsWidth, + child: TextField( + controller: controller, + autofocus: true, + minLines: 6, + maxLines: 12, + decoration: InputDecoration( + hintText: dialogContext.l10n.settingsHistoryExcludedPathsHint, + border: const OutlineInputBorder(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: Text(dialogContext.l10n.save), + ), + ], + ), + ); + controller.dispose(); + if (result != null) { + await save(result.split(RegExp(r'\r?\n'))); + } +} + enum SettingsPage { appearance, editor, validation, + history, ai, window, privacy, @@ -377,6 +523,7 @@ SettingsPage settingsPageFromRouteValue(String? value) { return switch (value) { 'editor' => SettingsPage.editor, 'validation' => SettingsPage.validation, + 'history' => SettingsPage.history, 'ai' => SettingsPage.ai, 'window' => SettingsPage.window, 'privacy' => SettingsPage.privacy, @@ -393,6 +540,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { SettingsPage.appearance => l10n.appearance, SettingsPage.editor => l10n.editor, SettingsPage.validation => l10n.validation, + SettingsPage.history => l10n.settingsHistory, SettingsPage.ai => l10n.ai, SettingsPage.window => l10n.settingsWindowSectionTitle, SettingsPage.privacy => l10n.privacy, @@ -405,6 +553,7 @@ IconData _settingsPageIcon(SettingsPage page) { SettingsPage.appearance => BusyMarkGlyphs.appearance, SettingsPage.editor => BusyMarkGlyphs.editorView, SettingsPage.validation => BusyMarkGlyphs.diagnostics, + SettingsPage.history => BusyMarkGlyphs.documentHistory, SettingsPage.ai => BusyMarkGlyphs.ai, SettingsPage.window => BusyMarkGlyphs.desktop, SettingsPage.privacy => BusyMarkGlyphs.privacy, diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index e7a0dee9..b95b2307 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -255,6 +255,9 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: + case HeaderBarAction.clipboardHistory: + case HeaderBarAction.localHistory: + case HeaderBarAction.findLocalHistory: case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: @@ -288,6 +291,10 @@ class _WelcomeScreenState extends ConsumerState { case BusyMarkMainMenuAction.export: case BusyMarkMainMenuAction.generateMarkdownToc: break; + case BusyMarkMainMenuAction.clipboardHistory: + case BusyMarkMainMenuAction.localHistory: + case BusyMarkMainMenuAction.findLocalHistory: + break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index df59d13e..3fbd4de9 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -20,6 +20,8 @@ import 'package:yaru/yaru.dart'; import '../../ai/ai_edit_ui.dart'; import '../../ai/ai_models.dart'; import '../../assets/asset_ingestion_service.dart'; +import '../../clipboard/clipboard_history_controller.dart'; +import '../../clipboard/clipboard_history_panel.dart'; import '../../app/app_settings.dart'; import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; @@ -71,6 +73,9 @@ import '../../markdown/markdown_section_editor.dart'; import '../../markdown/markdown_toc_generator.dart'; import '../../markdown/preview_model.dart'; import '../../math/math_widget.dart'; +import '../../local_history/local_history_comparison_view.dart'; +import '../../local_history/local_history_controller.dart'; +import '../../local_history/local_history_panel.dart'; import '../../platform/linux_header_bar_service.dart'; import '../../search/search_replace_service.dart'; import '../../visualization/visualization_card.dart'; @@ -539,6 +544,7 @@ class WorkspaceScreen extends ConsumerWidget { final documentViewMode = state.activeBuffer?.editorState.mode ?? settings.documentViewMode; final gitState = ref.watch(gitControllerProvider); + final localHistoryState = ref.watch(localHistoryControllerProvider); final searchResults = _workspaceSearchResults(context, searchState.matches); final colors = BusyMarkSurfaceColors.of(context); @@ -561,10 +567,16 @@ class WorkspaceScreen extends ConsumerWidget { final workspaceContent = Expanded( child: Column( children: [ - if (_shouldShowEditorTabs(state, gitState)) - _EditorTabStrip(state: state, gitState: gitState), + if (_shouldShowEditorTabs(state, gitState, localHistoryState)) + _EditorTabStrip( + state: state, + gitState: gitState, + localHistoryState: localHistoryState, + ), Expanded( - child: gitState.selectedDiffForDisplay == null + child: localHistoryState.selectedRevision != null + ? const LocalHistoryComparisonView() + : gitState.selectedDiffForDisplay == null ? _EditorPreviewSplit( state: state, outline: documentOutline, @@ -644,6 +656,32 @@ class WorkspaceScreen extends ConsumerWidget { _closeSearch(ref); } }); + ref.listen(clipboardHistoryOpenRequestProvider, (previous, next) { + if (next == previous) return; + _selectSidebarShortcut(ref, _SidebarTab.clipboard); + unawaited( + ref + .read(clipboardHistoryControllerProvider.notifier) + .refreshCurrentClipboard(), + ); + }); + ref.listen(localHistoryOpenRequestProvider, (previous, next) { + if (next == previous) return; + _selectSidebarShortcut(ref, _SidebarTab.localHistory); + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) { + unawaited( + ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(buffer), + ); + } + }); + ref.listen(localHistoryFindRequestProvider, (previous, next) { + if (next == previous) return; + _selectSidebarShortcut(ref, _SidebarTab.localHistory); + ref.read(localHistoryControllerProvider.notifier).beginDocumentSearch(); + }); ref.listen(workspaceControllerProvider, (previous, next) { final nextWorkspace = next.workspace; if (nextWorkspace == null) { @@ -866,6 +904,14 @@ class WorkspaceScreen extends ConsumerWidget { state.message!.code, ), ), + if (localHistoryState.warning != null) + BusyMarkStatusBox( + message: localizeLocalHistoryWarning( + context, + localHistoryState.warning!, + ), + kind: BusyMarkStatusKind.warning, + ), Expanded( child: Row( textDirection: TextDirection.ltr, @@ -977,6 +1023,12 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(_validateActiveAndShowProblems(context, ref)); case HeaderBarAction.save: execute(BusyMarkCommandIds.save); + case HeaderBarAction.clipboardHistory: + execute(BusyMarkCommandIds.clipboardHistory); + case HeaderBarAction.localHistory: + execute(BusyMarkCommandIds.localHistory); + case HeaderBarAction.findLocalHistory: + execute(BusyMarkCommandIds.findLocalHistory); case HeaderBarAction.export: execute(BusyMarkCommandIds.export); case HeaderBarAction.fullScreen: @@ -1028,6 +1080,10 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(exportWorkspace(context, ref)); case BusyMarkMainMenuAction.generateMarkdownToc: _generateOrUpdateMarkdownToc(context, ref); + case BusyMarkMainMenuAction.clipboardHistory: + case BusyMarkMainMenuAction.localHistory: + case BusyMarkMainMenuAction.findLocalHistory: + return; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -1351,7 +1407,12 @@ Future _confirmDiscardGitFiles( ], ), ); - return confirmed ?? false; + if (confirmed != true || !context.mounted) return false; + return ref + .read(workspaceControllerProvider.notifier) + .protectPathsBeforeExternalReplacement( + files.map((file) => file.absolutePath), + ); } Future _confirmSwitchGitBranch( @@ -2057,6 +2118,17 @@ class _SidebarState extends ConsumerState<_Sidebar> { onConfirmPushSetUpstream: () => _confirmGitPushSetUpstream(context, ref), ), + _SidebarTab.clipboard => ClipboardHistoryPanel( + onEscape: () => ref + .read(clipboardInsertionRegistryProvider) + .target + ?.requestEditorFocus(), + ), + _SidebarTab.localHistory => LocalHistoryPanel( + focusSearchRequest: ref.watch( + localHistoryFindRequestProvider, + ), + ), null => const SizedBox.shrink(), }, ), @@ -2077,6 +2149,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { setState(() { _tab = index; }); + if (tab != _SidebarTab.localHistory) { + ref.read(localHistoryControllerProvider.notifier).clearComparison(); + } if (tab == _SidebarTab.git) { final controller = ref.read(gitControllerProvider.notifier); unawaited(() async { @@ -2464,7 +2539,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } -enum _SidebarTab { files, toc, outline, git } +enum _SidebarTab { files, toc, outline, git, clipboard, localHistory } int _preferredSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); @@ -2487,18 +2562,30 @@ bool _hasWorkspaceSidebar(Workspace workspace) { List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { return switch (kind) { - WorkspaceKind.untitledMarkdown => const [_SidebarTab.outline], - WorkspaceKind.singleMarkdown => const [_SidebarTab.outline], + WorkspaceKind.untitledMarkdown => const [ + _SidebarTab.outline, + _SidebarTab.clipboard, + _SidebarTab.localHistory, + ], + WorkspaceKind.singleMarkdown => const [ + _SidebarTab.outline, + _SidebarTab.clipboard, + _SidebarTab.localHistory, + ], WorkspaceKind.markdownFolder => const [ _SidebarTab.files, _SidebarTab.outline, _SidebarTab.git, + _SidebarTab.clipboard, + _SidebarTab.localHistory, ], WorkspaceKind.writersideModule => const [ _SidebarTab.files, _SidebarTab.toc, _SidebarTab.outline, _SidebarTab.git, + _SidebarTab.clipboard, + _SidebarTab.localHistory, ], }; } @@ -2509,6 +2596,8 @@ String _sidebarTabLabel(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => context.l10n.toc, _SidebarTab.outline => context.l10n.outline, _SidebarTab.git => context.l10n.git, + _SidebarTab.clipboard => context.l10n.clipboardHistory, + _SidebarTab.localHistory => context.l10n.localHistory, }; } @@ -2518,6 +2607,8 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { _SidebarTab.toc => BusyMarkGlyphs.orderedList, _SidebarTab.outline => BusyMarkGlyphs.indentFor(direction), _SidebarTab.git => BusyMarkGlyphs.branch, + _SidebarTab.clipboard => BusyMarkGlyphs.copy, + _SidebarTab.localHistory => BusyMarkGlyphs.documentHistory, }; } @@ -2530,6 +2621,8 @@ String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => BusyMarkCommandIds.sidebarToc, _SidebarTab.outline => BusyMarkCommandIds.sidebarOutline, _SidebarTab.git => BusyMarkCommandIds.sidebarGit, + _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, + _SidebarTab.localHistory => BusyMarkCommandIds.localHistory, }; return commands[id]?.shortcut?.label; } @@ -3925,6 +4018,13 @@ class _FilesTabState extends ConsumerState<_FilesTab> { if (file != null && canUseGitFileActions) { await widget.onShowFileHistory(file); } + case _FileTreeAction.localHistory: + await ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForPath(path); + ref + .read(_sidebarShortcutRequestProvider.notifier) + .select(_SidebarTab.localHistory); } } @@ -4077,6 +4177,7 @@ enum _FileTreeAction { copyPath, openInFiles, fileHistory, + localHistory, } Future<_FileTreeAction?> _showFileTreeMenu( @@ -4159,6 +4260,12 @@ Future<_FileTreeAction?> _showFileTreeMenu( icon: BusyMarkGlyphs.documentHistory, enabled: enableGitActions, ), + if (showHistory) + BusyMarkPopupMenuItem( + value: _FileTreeAction.localHistory, + label: context.l10n.localHistoryEllipsis, + icon: BusyMarkGlyphs.history, + ), ], ); } @@ -8221,21 +8328,32 @@ class _SidebarEmptyState extends StatelessWidget { } } -bool _shouldShowEditorTabs(WorkspaceState state, GitState gitState) { +bool _shouldShowEditorTabs( + WorkspaceState state, + GitState gitState, + LocalHistoryState localHistoryState, +) { + if (localHistoryState.selectedRevision != null) return true; final workspace = state.workspace!; return (state.documentBuffers.length > 1 || workspace.kind == WorkspaceKind.markdownFolder || workspace.kind == WorkspaceKind.writersideModule) && (state.documentBuffers.isNotEmpty || gitState.openDiffFilePaths.isNotEmpty || - gitState.selectedDiffForDisplay != null); + gitState.selectedDiffForDisplay != null || + localHistoryState.selectedRevision != null); } class _EditorTabStrip extends ConsumerWidget { - const _EditorTabStrip({required this.state, required this.gitState}); + const _EditorTabStrip({ + required this.state, + required this.gitState, + required this.localHistoryState, + }); final WorkspaceState state; final GitState gitState; + final LocalHistoryState localHistoryState; @override Widget build(BuildContext context, WidgetRef ref) { @@ -8248,6 +8366,8 @@ class _EditorTabStrip extends ConsumerWidget { gitState: gitState, documentBuffers: state.documentBuffers, activeBufferId: state.activeBufferId, + localHistoryRevisionId: localHistoryState.selectedRevisionId, + localHistoryDocumentName: localHistoryState.selectedDocument?.displayName, ); if (entries.isEmpty) { return const SizedBox.shrink(); @@ -8282,6 +8402,13 @@ class _EditorTabStrip extends ConsumerWidget { onSelected: () => _selectTab(context, ref, workspace, entry), onClose: () => _closeTab(context, ref, workspace, entry), + onSecondaryTapUp: (details) => _showTabMenu( + context, + ref, + workspace, + entry, + details.globalPosition, + ), ); }, separatorBuilder: (context, index) => @@ -8289,7 +8416,8 @@ class _EditorTabStrip extends ConsumerWidget { itemCount: entries.length, ), ), - if (gitState.selectedDiffForDisplay == null) + if (gitState.selectedDiffForDisplay == null && + localHistoryState.selectedRevision == null) if (state.activeBuffer case final buffer?) ...[ BusyMarkDocumentFormatIndicator(format: buffer.format), const SizedBox(width: BusyMarkSpacing.xs), @@ -8310,6 +8438,8 @@ class _EditorTabStrip extends ConsumerWidget { entry.untitledName ?? _relativeDocumentPath(workspace, entry.path), WorkspaceTabKind.gitDiff => entry.path.isEmpty ? context.l10n.gitDiff : _diffTabTitle(entry.path), + WorkspaceTabKind.localHistory => + '${context.l10n.localHistory}: ${entry.path}', }; } @@ -8317,6 +8447,9 @@ class _EditorTabStrip extends ConsumerWidget { if (entry.kind == WorkspaceTabKind.gitDiff) { return null; } + if (entry.kind == WorkspaceTabKind.localHistory) { + return BusyMarkGlyphs.documentHistory; + } final file = entry.path.isEmpty ? null : _documentFileForPath(workspace, entry.path); @@ -8336,6 +8469,7 @@ class _EditorTabStrip extends ConsumerWidget { final gitController = ref.read(gitControllerProvider.notifier); switch (entry.kind) { case WorkspaceTabKind.file: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); if (entry.bufferId == state.activeBufferId) { gitController.deactivateDiffFile(); return; @@ -8345,10 +8479,13 @@ class _EditorTabStrip extends ConsumerWidget { .activateDocumentBuffer(entry.bufferId!); gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); if (entry.path.isEmpty) { return; } await gitController.activateDiffFile(entry.path); + case WorkspaceTabKind.localHistory: + return; } } @@ -8380,10 +8517,58 @@ class _EditorTabStrip extends ConsumerWidget { } else { gitController.closeDiffFile(entry.path); } + case WorkspaceTabKind.localHistory: + ref.read(localHistoryControllerProvider.notifier).clearComparison(); + } + } + + Future _showTabMenu( + BuildContext context, + WidgetRef ref, + Workspace workspace, + WorkspaceTabEntry entry, + Offset position, + ) async { + final action = await showBusyMarkContextMenu<_WorkspaceTabAction>( + context, + position, + items: [ + if (entry.kind == WorkspaceTabKind.file) + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.localHistory, + label: context.l10n.localHistoryEllipsis, + icon: BusyMarkGlyphs.documentHistory, + ), + if (entry.kind == WorkspaceTabKind.file) + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _WorkspaceTabAction.close, + label: MaterialLocalizations.of(context).closeButtonTooltip, + icon: BusyMarkGlyphs.clear, + ), + ], + ); + if (action == null || !context.mounted) return; + switch (action) { + case _WorkspaceTabAction.localHistory: + final buffer = state.documentBuffers + .where((candidate) => candidate.id == entry.bufferId) + .firstOrNull; + if (buffer == null) return; + await ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(buffer); + ref + .read(_sidebarShortcutRequestProvider.notifier) + .select(_SidebarTab.localHistory); + case _WorkspaceTabAction.close: + await _closeTab(context, ref, workspace, entry); } } } +enum _WorkspaceTabAction { localHistory, close } + class _WorkspaceTabButton extends StatelessWidget { const _WorkspaceTabButton({ required this.title, @@ -8393,6 +8578,7 @@ class _WorkspaceTabButton extends StatelessWidget { required this.dirty, required this.onSelected, required this.onClose, + required this.onSecondaryTapUp, }); final String title; @@ -8402,6 +8588,7 @@ class _WorkspaceTabButton extends StatelessWidget { final bool dirty; final VoidCallback onSelected; final VoidCallback onClose; + final GestureTapUpCallback onSecondaryTapUp; @override Widget build(BuildContext context) { @@ -8422,6 +8609,7 @@ class _WorkspaceTabButton extends StatelessWidget { ), hoverColor: colors.controlHover, onTap: onSelected, + onSecondaryTapUp: onSecondaryTapUp, child: Container( height: BusyMarkSizes.paneHeaderHeight - BusyMarkSpacing.xs, constraints: const BoxConstraints(minWidth: 112, maxWidth: 240), @@ -9909,6 +10097,12 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { child: BusyMarkWysiwygEditor( document: wysiwygDocument, documentId: activeBuffer?.id, + clipboardInsertionRegistry: ref.read( + clipboardInsertionRegistryProvider, + ), + onClipboardCaptured: ref + .read(clipboardHistoryControllerProvider.notifier) + .retain, initialSessionState: activeBuffer?.editorState.wysiwygState ?? const WysiwygEditorSessionState(), @@ -10024,6 +10218,12 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { language: _sourceSyntaxLanguage(widget.state.workspace), filePath: activeEditorPath, documentId: activeBuffer?.id, + clipboardInsertionRegistry: ref.read( + clipboardInsertionRegistryProvider, + ), + onClipboardCaptured: ref + .read(clipboardHistoryControllerProvider.notifier) + .retain, diagnostics: widget.state.workspace?.allDiagnostics ?? const [], diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index b8d6534b..1feae15e 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -7,12 +7,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import '../app/app_settings.dart'; +import '../comparison/source_comparison.dart'; import '../core/debug_log.dart'; import '../core/diagnostic.dart'; import '../core/source_span.dart'; +import '../core/path_utils.dart' show isTextDocumentationPath; import '../markdown/busymark_document.dart'; import '../markdown/document_outline.dart'; import '../markdown/preview_model.dart'; +import '../local_history/local_history_controller.dart'; +import '../local_history/local_history_models.dart'; import '../writerside/writerside_project_creator.dart'; import '../writerside/writerside_project.dart'; import '../writerside/writerside_instance_service.dart'; @@ -32,6 +36,24 @@ final workspaceServiceProvider = Provider( (ref) => const WorkspaceService(), ); +enum LocalHistoryCurrentSourceKind { editor, disk, missing } + +class LocalHistoryCurrentSourceSnapshot { + const LocalHistoryCurrentSourceSnapshot({ + required this.kind, + required this.id, + required this.version, + required this.source, + required this.canRestore, + }); + + final LocalHistoryCurrentSourceKind kind; + final String id; + final int version; + final String source; + final bool canRestore; +} + final documentSessionStoreProvider = Provider( (ref) => _runningUnderFlutterTest ? MemoryDocumentSessionStore() @@ -155,6 +177,7 @@ class WorkspaceController extends Notifier { late DocumentSessionStore _sessionStore; late DocumentRecoveryStore _recoveryStore; late WorkspaceFileMonitor _fileMonitor; + late LocalHistoryController _localHistory; StreamSubscription? _fileMonitorSubscription; final _autoSaveDebounces = {}; final _bufferWriteQueues = >{}; @@ -215,6 +238,7 @@ class WorkspaceController extends Notifier { _sessionStore = ref.read(documentSessionStoreProvider); _recoveryStore = ref.read(documentRecoveryStoreProvider); _fileMonitor = ref.read(workspaceFileMonitorProvider); + _localHistory = ref.read(localHistoryControllerProvider.notifier); _fileMonitorSubscription = _fileMonitor.events.listen( (event) => unawaited(_handleFileMonitorEvent(event)), ); @@ -377,6 +401,9 @@ class WorkspaceController extends Notifier { ) : null, ); + for (final buffer in buffers) { + unawaited(_localHistory.observeOpened(buffer)); + } _recordActivePreviewRevision(); _editRevision = active.revision; await _startMonitoring(reparsed); @@ -555,6 +582,7 @@ class WorkspaceController extends Notifier { Future markCleanShutdown() async { await _settleWritesForShutdown(); + await _localHistory.flushAll(state.documentBuffers); await flushPersistence(); if (state.documentBuffers.any( (buffer) => buffer.isDirty || buffer.isUntitled, @@ -567,6 +595,7 @@ class WorkspaceController extends Notifier { Future discardRecoveryForShutdown() async { await _settleWritesForShutdown(); + await _localHistory.flushAll(state.documentBuffers); await flushPersistence(); await _recoveryStore.clear(); } @@ -651,6 +680,11 @@ class WorkspaceController extends Notifier { } if (event.kind == WorkspaceFileEventKind.deleted && !await _service.pathExists(path)) { + await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(current), + LocalHistoryCaptureReason.beforeDelete, + ); + await _localHistory.markDeleted(path, recursive: false); _updateBufferFromMonitor( current.copyWith(diskState: DocumentDiskState.deleted), ); @@ -661,7 +695,17 @@ class WorkspaceController extends Notifier { if (_sameFileSnapshot(current.diskSnapshot, disk.snapshot)) { return; } + await _localHistory.capturePath( + path: path, + text: disk.text, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + ); if (current.isDirty) { + _localHistory.observeEdit( + current.copyWith(text: current.lastSavedText), + current, + ); _updateBufferFromMonitor( current.copyWith( diskState: DocumentDiskState.conflict, @@ -698,6 +742,7 @@ class WorkspaceController extends Notifier { } } } on FileSystemException { + await _localHistory.markDeleted(path, recursive: false); _updateBufferFromMonitor( current.copyWith(diskState: DocumentDiskState.deleted), ); @@ -720,6 +765,15 @@ class WorkspaceController extends Notifier { current.diskSnapshot, disk.snapshot, ); + await _localHistory.remapPath(oldPath, destinationPath); + if (contentChanged) { + await _localHistory.capturePath( + path: destinationPath, + text: disk.text, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + ); + } final remapped = current.copyWith( filePath: destinationPath, text: current.isDirty || !contentChanged ? current.text : disk.text, @@ -812,7 +866,20 @@ class WorkspaceController extends Notifier { if (buffer == null || path == null || !await _service.pathExists(path)) { return false; } + if (!await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeReload, + )) { + return false; + } final disk = await _service.loadTextWithSnapshot(path); + await _localHistory.capturePath( + path: path, + text: disk.text, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + force: false, + ); final reloaded = buffer.copyWith( text: disk.text, lastSavedText: disk.text, @@ -930,6 +997,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, isLoading: false, ); + unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); await _startMonitoring(workspace); _schedulePersistence(); @@ -937,6 +1005,7 @@ class WorkspaceController extends Notifier { } Future openPath(String path) async { + await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -970,6 +1039,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffer == null ? const [] : [buffer], activeBufferId: buffer?.id, ); + if (buffer != null) unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); await _startMonitoring(loadedWorkspace); _schedulePersistence(); @@ -1003,6 +1073,7 @@ class WorkspaceController extends Notifier { Future createWritersideProject( WritersideProjectCreateRequest request, ) async { + await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -1036,6 +1107,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffer == null ? const [] : [buffer], activeBufferId: buffer?.id, ); + if (buffer != null) unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); await _startMonitoring(loadedWorkspace); _schedulePersistence(); @@ -1128,6 +1200,9 @@ class WorkspaceController extends Notifier { documentBuffers: buffers, activeBufferId: buffer?.id, ); + if (buffer != null && existing == null) { + unawaited(_localHistory.observeOpened(buffer)); + } _recordActivePreviewRevision(); await _startMonitoring(tabbedWorkspace); _schedulePersistence(); @@ -1252,6 +1327,7 @@ class WorkspaceController extends Notifier { if (state.hasUnsavedChanges) { return false; } + await _localHistory.flushAll(state.documentBuffers); _clearOpenFileTabs(workspace); return true; } @@ -1274,6 +1350,7 @@ class WorkspaceController extends Notifier { return _runWorkspaceFileOperation((workspace) async { final target = await _service.renameEntity(workspace, path, newName); _remapOpenWorkspacePaths(workspace, path, target); + await _localHistory.remapPath(path, target); return _remapMovedPath(activeFilePath, path, target); }); } @@ -1290,22 +1367,73 @@ class WorkspaceController extends Notifier { targetDirectoryPath, ); _remapOpenWorkspacePaths(workspace, sourcePath, target); + await _localHistory.remapPath(sourcePath, target); return _remapMovedPath(activeFilePath, sourcePath, target); }); } Future deleteWorkspaceEntity(String path) async { + if (!await _protectWorkspaceEntityBeforeDelete(path)) return false; _intentionallyRemovedPaths.add(p.normalize(path)); try { - return await _runWorkspaceFileOperation((workspace) async { + final deleted = await _runWorkspaceFileOperation((workspace) async { await _service.deleteEntity(workspace, path); return null; }); + if (deleted) { + await _localHistory.markDeleted(path, recursive: true); + } + return deleted; } finally { _intentionallyRemovedPaths.remove(p.normalize(path)); } } + 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 || + !(p.equals(filePath, path) || p.isWithin(path, filePath))) { + continue; + } + if (!await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeDelete, + )) { + return false; + } + protectedPaths.add(p.normalize(filePath)); + } + for (final file in workspace.files) { + final filePath = file.absolutePath; + if (protectedPaths.contains(p.normalize(filePath)) || + !(p.equals(filePath, path) || p.isWithin(path, filePath))) { + continue; + } + try { + final load = await _service.loadTextWithSnapshot(filePath); + if (!await _localHistory.capturePath( + path: filePath, + text: load.text, + format: load.format, + reason: LocalHistoryCaptureReason.beforeDelete, + )) { + return false; + } + } on Object { + return false; + } + } + return true; + } + Future moveWritersideTocEntry({ required String treePath, required List sourcePath, @@ -1477,6 +1605,7 @@ class WorkspaceController extends Notifier { if (closingBuffer?.isDirty == true) { return false; } + if (closingBuffer != null) await _localHistory.flushBuffer(closingBuffer); final closedIndex = workspace.openFilePaths.indexOf(path); final nextOpenFilePaths = [ for (final openPath in workspace.openFilePaths) @@ -1539,6 +1668,14 @@ class WorkspaceController extends Notifier { if (buffer == null || (buffer.isDirty && !discard)) { return false; } + if (buffer.isDirty && + !await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } + await _localHistory.flushBuffer(buffer); if (buffer.filePath case final path?) { if (buffer.isDirty) { state = state.copyWith( @@ -1676,6 +1813,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, clearMessage: true, ); + unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( [ @@ -1786,6 +1924,7 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); + _localHistory.observeEdit(buffer, next); _schedulePersistence(); return true; } @@ -1920,6 +2059,7 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); + _localHistory.observeEdit(buffer, next); _requestDerivedRefresh( rebuildPreview: _activeModeShowsPreview, refreshOutline: !_activeModeShowsPreview, @@ -2122,6 +2262,7 @@ class WorkspaceController extends Notifier { if (identical(nextBuffer, activeBuffer)) { return; } + _localHistory.observeEdit(activeBuffer, nextBuffer); _editRevision = nextBuffer.revision; state = state.copyWith( workspace: workspace?.copyWith( @@ -2455,6 +2596,16 @@ class WorkspaceController extends Notifier { snapshot: snapshot, ); } + await _localHistory.captureSaved( + LocalHistoryBufferSnapshot( + bufferId: target.bufferId, + displayName: p.basename(active), + text: target.text, + format: savedFormat, + revision: target.editRevision, + path: active, + ), + ); return true; } on Object catch (error) { if (_workspaceContainsBuffer(target.workspaceId, target.bufferId)) { @@ -2629,6 +2780,16 @@ class WorkspaceController extends Notifier { ? state.workspace?.copyWith(activeFileSnapshot: snapshot) : state.workspace, ); + await _localHistory.captureSaved( + LocalHistoryBufferSnapshot( + bufferId: target.id, + displayName: target.displayName, + text: target.text, + format: savedFormat, + revision: target.revision, + path: path, + ), + ); return _BufferWriteResult.saved; } on Object { return _BufferWriteResult.failed; @@ -2669,6 +2830,24 @@ class WorkspaceController extends Notifier { required LineEndingNormalization? mixedLineEndingNormalization, }) async { try { + final destinationExisted = + overwriteExisting && await _service.pathExists(path); + if (destinationExisted) { + try { + final destination = await _service.loadTextWithSnapshot(path); + if (!await _localHistory.capturePathBeforeLoss( + path: path, + text: destination.text, + format: destination.format, + reason: LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } + } on Object { + // The save result remains truthful. Local History exposes its own + // persistent status warning when capture fails. + } + } late final WorkspaceFileSnapshot savedSnapshot; if (overwriteExisting) { savedSnapshot = await _service.saveTextReplacingPath( @@ -2783,6 +2962,25 @@ class WorkspaceController extends Notifier { kind: savedWorkspace.kind.name, ); _schedulePersistence(); + await _localHistory.captureSavedAs( + LocalHistoryBufferSnapshot( + bufferId: target.bufferId, + displayName: target.path == null + ? state.documentBuffers + .where((buffer) => buffer.id == target.bufferId) + .firstOrNull + ?.displayName ?? + p.basename(path) + : p.basename(target.path!), + text: target.text, + format: savedFormat, + revision: target.editRevision, + path: target.path, + untitled: target.path == null, + ), + path, + destinationExisted: destinationExisted, + ); return true; } on Object catch (error) { if (_workspaceContainsBuffer(target.workspaceId, target.bufferId)) { @@ -2832,6 +3030,303 @@ class WorkspaceController extends Notifier { ); } + Future restoreLocalHistoryRevision({ + required LocalHistoryDocument document, + required LocalHistoryRevision revision, + SourceComparison? comparison, + SourceComparisonChange? change, + }) async { + final path = document.currentPath ?? revision.summary.historicalPath; + var buffer = path == null ? null : state.bufferForPath(path); + if (buffer == null && path != null && await _service.pathExists(path)) { + if (!await _openActiveFile(path)) return false; + buffer = state.activeBuffer; + } else if (buffer != null && state.activeBufferId != buffer.id) { + if (!await activateDocumentBuffer(buffer.id)) return false; + buffer = state.activeBuffer; + } + if (buffer == null) return false; + final targetId = buffer.id; + final targetPath = buffer.filePath; + final targetRevision = buffer.revision; + final targetText = buffer.text; + final nextText = change == null + ? revision.source + : _restoredRegionText( + buffer: buffer, + revision: revision, + comparison: comparison, + change: change, + ); + if (nextText == null || nextText == targetText) return nextText != null; + if (!await _localHistory.captureProtective( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeRestore, + )) { + return false; + } + final current = state.activeBuffer; + if (current == null || + current.id != targetId || + current.filePath != targetPath || + current.revision != targetRevision || + current.text != targetText) { + return false; + } + final nextSelection = change == null + ? TextSelection.collapsed(offset: nextText.length) + : TextSelection( + baseOffset: change.currentRange.start, + extentOffset: change.currentRange.start + change.oldText.length, + ); + final restored = current + .edited( + nextText, + previousSelection: current.editorState.selection, + nextSelection: nextSelection, + ) + .copyWith(format: current.format); + _localHistory.observeEdit(current, restored); + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, restored), + clearMessage: true, + ); + _schedulePersistence(); + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); + _scheduleAutoSave(restored.id); + return true; + } + + Future localHistoryCurrentSource( + LocalHistoryDocument document, + LocalHistoryRevision revision, + ) async { + final paths = { + document.currentPath, + revision.summary.historicalPath, + }.whereType(); + for (final path in paths) { + final buffer = state.bufferForPath(path); + if (buffer != null) { + return LocalHistoryCurrentSourceSnapshot( + kind: LocalHistoryCurrentSourceKind.editor, + id: buffer.id, + version: buffer.revision, + source: buffer.text, + canRestore: true, + ); + } + } + final path = document.currentPath ?? revision.summary.historicalPath; + if (path != null && await _service.pathExists(path)) { + final loaded = await _service.loadTextWithSnapshot(path); + return LocalHistoryCurrentSourceSnapshot( + kind: LocalHistoryCurrentSourceKind.disk, + id: p.normalize(path), + version: loaded.snapshot.modifiedAt.microsecondsSinceEpoch, + source: loaded.text, + canRestore: document.currentPath != null, + ); + } + return LocalHistoryCurrentSourceSnapshot( + kind: LocalHistoryCurrentSourceKind.missing, + id: 'missing:${document.id}', + version: 0, + source: '', + canRestore: false, + ); + } + + Future restoreMissingLocalHistoryRevision({ + required LocalHistoryDocument document, + required LocalHistoryRevision revision, + required String destinationPath, + required bool overwriteExisting, + }) async { + final path = p.normalize(p.absolute(destinationPath)); + final policy = _localHistory.policy; + if (!policy.recordingEnabled || policy.excludes(path)) { + await _localHistory.captureProtective( + LocalHistoryBufferSnapshot( + bufferId: 'restore-missing:$path', + displayName: p.basename(path), + text: '', + format: revision.format, + revision: 0, + path: path, + ), + LocalHistoryCaptureReason.beforeRestore, + ); + return false; + } + final existed = await _service.pathExists(path); + if (existed != overwriteExisting) return false; + WorkspaceFileLoad? previous; + if (existed) { + try { + previous = await _service.loadTextWithSnapshot(path); + } on Object { + return false; + } + if (!await _localHistory.capturePathBeforeLoss( + path: path, + text: previous.text, + format: previous.format, + reason: LocalHistoryCaptureReason.beforeRestore, + )) { + return false; + } + if (await _service.fileChangedSince(path, previous.snapshot)) { + return false; + } + } + WorkspaceFileSnapshot? published; + try { + if (existed) { + published = await _service.saveFormattedTextReplacingPath( + path, + '', + format: previous!.format, + ); + } else { + published = await _service.saveNewFormattedText( + path, + '', + format: revision.format, + ); + } + final workspace = state.workspace; + final insideCurrentWorkspace = + workspace != null && + workspace.rootPath.isNotEmpty && + (p.equals(workspace.rootPath, path) || + p.isWithin(workspace.rootPath, path)); + if (insideCurrentWorkspace) { + await refreshWorkspaceFromDiskPreservingOpenTabs(); + if (!await _openActiveFile(path)) { + await _rollbackMissingHistoryDestination(path, previous, published); + return false; + } + } else { + await openPath(path); + } + final destinationDocument = document.copyWith( + currentPath: path, + displayName: p.basename(path), + deleted: false, + ); + final restored = await restoreLocalHistoryRevision( + document: destinationDocument, + revision: revision, + ); + if (!restored) { + await _rollbackMissingHistoryDestination(path, previous, published); + } + return restored; + } on Object { + if (published != null) { + await _rollbackMissingHistoryDestination(path, previous, published); + } + return false; + } + } + + Future localHistoryRestorePathExists(String path) { + return _service.pathExists(p.normalize(p.absolute(path))); + } + + Future _rollbackMissingHistoryDestination( + String path, + WorkspaceFileLoad? previous, + WorkspaceFileSnapshot published, + ) async { + try { + if (await _service.fileChangedSince(path, published)) return; + if (previous == null) { + final file = File(path); + if (await file.exists()) await file.delete(); + } else { + await _service.saveFormattedTextReplacingPath( + path, + previous.text, + format: previous.format, + ); + } + } on Object { + // The persistent warning from the failed protective/restore operation is + // more useful than masking the original result with cleanup failure. + } + } + + Future protectPathsBeforeExternalReplacement( + Iterable paths, + ) async { + for (final path in paths.toSet()) { + if (!_isEligibleLocalHistoryPath(path)) continue; + final buffer = state.bufferForPath(path); + if (buffer != null) { + if (!await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } + continue; + } + try { + final loaded = await _service.loadTextWithSnapshot(path); + if (!await _localHistory.capturePathBeforeLoss( + path: path, + text: loaded.text, + format: loaded.format, + reason: LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } + } on FileSystemException { + // A path already absent from the working tree has no current source + // version to protect. + } on FormatException { + // Binary or invalid text content is outside Local History's scope. + } + } + return true; + } + + String? _restoredRegionText({ + required DocumentBuffer buffer, + required LocalHistoryRevision revision, + required SourceComparison? comparison, + required SourceComparisonChange change, + }) { + if (comparison == null || !change.exact) return null; + final oldInput = comparison.oldInput; + final currentInput = comparison.currentInput; + if (oldInput.id != revision.summary.id || + oldInput.version != + revision.summary.capturedAt.microsecondsSinceEpoch || + currentInput.id != buffer.id || + currentInput.version != buffer.revision || + oldInput.source != revision.source || + currentInput.source != buffer.text || + change.currentRange.end > buffer.text.length || + buffer.text.substring( + change.currentRange.start, + change.currentRange.end, + ) != + change.currentText) { + return null; + } + return buffer.text.replaceRange( + change.currentRange.start, + change.currentRange.end, + change.oldText, + ); + } + Future _discardBufferChangesNow( ActiveDocumentSaveTarget operationTarget, ) async { @@ -2848,6 +3343,13 @@ class WorkspaceController extends Notifier { if (!current.isDirty) { return true; } + if (current.filePath != null && + !await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(current), + LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } final workspace = state.workspace; if (workspace == null || workspace.id != refreshedTarget.workspaceId) { return false; @@ -3518,6 +4020,14 @@ bool _sameFileSnapshot( return first != null && second != null && !first.differsFrom(second); } +bool _isEligibleLocalHistoryPath(String path) { + final normalized = path.toLowerCase(); + return isTextDocumentationPath(path) || + p.basename(path) == '.gitignore' || + normalized.endsWith('.css') || + normalized.endsWith('.js'); +} + List _openFileTabPaths(Workspace workspace, String path) { if (workspace.openFilePaths.contains(path)) { return workspace.openFilePaths; diff --git a/lib/src/workspace/workspace_tabs.dart b/lib/src/workspace/workspace_tabs.dart index 62e3fa9f..d15897a0 100644 --- a/lib/src/workspace/workspace_tabs.dart +++ b/lib/src/workspace/workspace_tabs.dart @@ -2,7 +2,7 @@ import '../git/application/git_controller.dart'; import 'document_buffer.dart'; import 'workspace_model.dart'; -enum WorkspaceTabKind { file, gitDiff } +enum WorkspaceTabKind { file, gitDiff, localHistory } class WorkspaceTabEntry { const WorkspaceTabEntry._({ @@ -32,6 +32,16 @@ class WorkspaceTabEntry { const WorkspaceTabEntry.gitDiff({required String path, required bool active}) : this._(kind: WorkspaceTabKind.gitDiff, path: path, active: active); + const WorkspaceTabEntry.localHistory({ + required String revisionId, + required String documentName, + }) : this._( + kind: WorkspaceTabKind.localHistory, + path: documentName, + bufferId: revisionId, + active: true, + ); + final WorkspaceTabKind kind; final String path; final bool active; @@ -47,8 +57,11 @@ List workspaceTabEntries({ required GitState gitState, List? documentBuffers, String? activeBufferId, + String? localHistoryRevisionId, + String? localHistoryDocumentName, }) { final diffActive = gitState.selectedDiffForDisplay != null; + final localHistoryActive = localHistoryRevisionId != null; return [ if (documentBuffers != null) for (final buffer in documentBuffers) @@ -57,21 +70,33 @@ List workspaceTabEntries({ bufferId: buffer.id, untitledName: buffer.untitledName, dirty: buffer.isDirty, - active: !diffActive && buffer.id == activeBufferId, + active: + !diffActive && !localHistoryActive && buffer.id == activeBufferId, ) else for (final path in workspace.openFilePaths) WorkspaceTabEntry.file( path: path, - active: !diffActive && path == workspace.activeFilePath, + active: + !diffActive && + !localHistoryActive && + path == workspace.activeFilePath, ), for (final path in gitState.openDiffFilePaths) WorkspaceTabEntry.gitDiff( path: path, - active: diffActive && path == gitState.selectedCommitFilePath, + active: + !localHistoryActive && + diffActive && + path == gitState.selectedCommitFilePath, ), - if (gitState.openDiffFilePaths.isEmpty && diffActive) + if (gitState.openDiffFilePaths.isEmpty && diffActive && !localHistoryActive) const WorkspaceTabEntry.gitDiff(path: '', active: true), + if (localHistoryRevisionId != null) + WorkspaceTabEntry.localHistory( + revisionId: localHistoryRevisionId, + documentName: localHistoryDocumentName ?? '', + ), ]; } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 20b6d289..e95de7ee 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1333,6 +1333,13 @@ static const gchar* main_menu_icon_name(const gchar* action) { if (g_strcmp0(action, "settings") == 0) { return "preferences-system-symbolic"; } + if (g_strcmp0(action, "clipboardHistory") == 0) { + return "edit-paste-symbolic"; + } + if (g_strcmp0(action, "localHistory") == 0 || + g_strcmp0(action, "findLocalHistory") == 0) { + return "document-open-recent-symbolic"; + } if (g_strcmp0(action, "keyboardShortcuts") == 0) { return "input-keyboard-symbolic"; } @@ -1391,6 +1398,20 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { localized_label_or(labels, "fullScreen", ""), "header.full-screen", main_menu_icon_name("fullScreen"), fl_lookup_string_arg(labels, "fullScreenGtkAccelerator")); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "clipboardHistory", ""), + "header.clipboard-history", main_menu_icon_name("clipboardHistory"), + nullptr); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "localHistory", ""), + "header.local-history", main_menu_icon_name("localHistory"), nullptr); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "findLocalHistory", ""), + "header.find-local-history", main_menu_icon_name("findLocalHistory"), + nullptr); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "settings", ""), "header.settings", @@ -1560,6 +1581,9 @@ static void setup_header_actions(MyApplication* self) { add_header_gaction(self, "export", "export"); set_header_action_enabled(self, "export", FALSE); add_header_gaction(self, "settings", "settings"); + add_header_gaction(self, "clipboard-history", "clipboardHistory"); + add_header_gaction(self, "local-history", "localHistory"); + add_header_gaction(self, "find-local-history", "findLocalHistory"); add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts"); add_header_gaction(self, "syntax-reference", "syntaxReference"); add_header_gaction(self, "report-issue", "reportIssue"); @@ -1926,6 +1950,12 @@ static void apply_header_bar_configuration( self->can_export_html = configuration.can_export_html; set_header_action_enabled( self, "export", self->can_export_pdf || self->can_export_html); + set_header_action_enabled(self, "clipboard-history", + configuration.document_controls_visible); + set_header_action_enabled(self, "local-history", + configuration.document_controls_visible); + set_header_action_enabled(self, "find-local-history", + configuration.document_controls_visible); set_sidebar_width(self, configuration.sidebar_width); set_text_direction(self, configuration.text_direction); set_sidebar_visible(self, configuration.sidebar_visible); diff --git a/linux/runner/rich_clipboard_host.cc b/linux/runner/rich_clipboard_host.cc index ab8b26d0..09701f66 100644 --- a/linux/runner/rich_clipboard_host.cc +++ b/linux/runner/rich_clipboard_host.cc @@ -1,18 +1,19 @@ #include "rich_clipboard_host.h" #include +#include #include #include namespace { constexpr char kChannel[] = "com.busymark.app/rich_clipboard"; -constexpr char kFragment[] = "application/x-busymark-fragment+json"; +constexpr char kToken[] = "application/x-busymark-token"; constexpr gsize kMaximumBytes = 16 * 1024 * 1024; struct Payload { std::string text; std::string html; - std::string fragment; + std::string token; }; void provide(GtkClipboard*, GtkSelectionData* selection, guint info, @@ -23,7 +24,7 @@ void provide(GtkClipboard*, GtkSelectionData* selection, guint info, payload->text.size()); return; } - const auto& bytes = info == 1 ? payload->html : payload->fragment; + const auto& bytes = info == 1 ? payload->html : payload->token; gtk_selection_data_set(selection, gtk_selection_data_get_target(selection), 8, reinterpret_cast(bytes.data()), bytes.size()); @@ -73,8 +74,9 @@ void write_clipboard(const SharedHost& host, FlMethodCall* call) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP || !argument(args, "text", &payload->text, true) || !argument(args, "html", &payload->html, false) || - !argument(args, "fragment", &payload->fragment, false) || - payload->text.size() + payload->html.size() + payload->fragment.size() > + !argument(args, "token", &payload->token, true) || + payload->token.empty() || + payload->text.size() + payload->html.size() + payload->token.size() > kMaximumBytes) { fl_method_call_respond_error(call, "clipboard.invalid-data", "Clipboard content is invalid or too large.", @@ -86,9 +88,7 @@ void write_clipboard(const SharedHost& host, FlMethodCall* call) { if (!payload->html.empty()) { gtk_target_list_add(list, gdk_atom_intern_static_string("text/html"), 0, 1); } - if (!payload->fragment.empty()) { - gtk_target_list_add(list, gdk_atom_intern_static_string(kFragment), 0, 2); - } + gtk_target_list_add(list, gdk_atom_intern_static_string(kToken), 0, 2); gint count = 0; GtkTargetEntry* targets = gtk_target_table_new_from_list(list, &count); const gboolean success = gtk_clipboard_set_with_data( @@ -136,7 +136,7 @@ void received(GtkClipboard*, GtkSelectionData* selection, gpointer data) { gsize size = length; while (size > 0 && bytes[size - 1] == 0) size--; if (g_utf8_validate(reinterpret_cast(bytes), size, nullptr)) { - const char* key = request->step == 1 ? "fragment" : "html"; + const char* key = request->step == 1 ? "token" : "html"; fl_value_set_string_take( request->result, key, fl_value_new_string_sized(reinterpret_cast(bytes), size)); @@ -163,6 +163,10 @@ void next_read(Read* request) { if (!request->current()) { fl_value_unref(request->result); request->result = fl_value_new_map(); + } else { + fl_value_set_string_take( + request->result, "generation", + fl_value_new_int(static_cast(request->generation))); } fl_method_call_respond_success(request->call, request->result, nullptr); delete request; @@ -171,7 +175,7 @@ void next_read(Read* request) { switch (request->step++) { case 0: gtk_clipboard_request_contents(request->host->clipboard, - gdk_atom_intern_static_string(kFragment), + gdk_atom_intern_static_string(kToken), received, request); break; case 1: diff --git a/test-results/history-runtime/clipboard-cross-process.json b/test-results/history-runtime/clipboard-cross-process.json new file mode 100644 index 00000000..d511461d --- /dev/null +++ b/test-results/history-runtime/clipboard-cross-process.json @@ -0,0 +1 @@ +{"passed":true,"text":true,"html":true,"rich":false} \ No newline at end of file diff --git a/test-results/history-runtime/clipboard-plain-replacement.json b/test-results/history-runtime/clipboard-plain-replacement.json new file mode 100644 index 00000000..c53ac026 --- /dev/null +++ b/test-results/history-runtime/clipboard-plain-replacement.json @@ -0,0 +1 @@ +{"passed":true,"text":true,"html":false,"rich":false} \ No newline at end of file diff --git a/test-results/history-runtime/clipboard-roundtrip.json b/test-results/history-runtime/clipboard-roundtrip.json new file mode 100644 index 00000000..4bb7aaf9 --- /dev/null +++ b/test-results/history-runtime/clipboard-roundtrip.json @@ -0,0 +1 @@ +{"passed":true,"text":true,"html":true,"rich":true} \ No newline at end of file diff --git a/test-results/history-runtime/markdown-clipboard-history-1280x800.png b/test-results/history-runtime/markdown-clipboard-history-1280x800.png new file mode 100644 index 0000000000000000000000000000000000000000..bacfbdba4c1670190fa84d03858dfac1d13f9651 GIT binary patch literal 132470 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

H}FaH{vd*e^#I)vNaqNQ~nWgeQ)k1(K3O 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%0D5-s-~UW`LZBae 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 z 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%0DprWSj+HExZ zQ}#$olYeei+M6j~CHg`eVZ=C!LrG;;5_MUn7%!K-hYpn=d4#6G3;Z(It{l%8QRsDf zY(PzlNd?eb&<%?mw#}|Wr?$((L%dM6FD5*S`8Ob}Ji}*+Qq25XOX>T>e+!Y#3n<$T z48Op?93WLh(t?xc^v^w;3-nZs6s>WyW~*mCiJ*1@a#u+Wi(&i{Wc@?$o7b;j$J_8m z_1o`K-vUQ{e0H&sN0P<#U&Fk@{(PrDzuqznh&^5n|28R@IrW3redz!?_L`0$E_fc_ zlMH*3dKz&1*zrpj0mwE=n-{-fm^`G{uI0slD?Wm`b-}2UUrs&*+-Fpci&2Hd{Bxfj zUg)1zv}xCF3fvayiIuhog?za%4*`Sha^z(`=EV6EV1110?M^sU1X!YmWR8hz5lbvBOMw2?`8VxA>ndU7Ofg2jpzjjm#pA413lav&gW15gFES(<_gUKJIm z>b7>!!cPg(F$zbvGiC`>&~7QkZ2Ub`f+Gwy$`M1bf`MY4)jkn?TfUQ8Us3 zaTRNV$q+A^d@aGqsG;Ef{K&&2qpDL`?xIe&{VhEn?Kd#ny7lX2%#h%<$Y%`GNd&%N z;x&c&L1?kFd;;3?v&OyB&&=}|Ur_o}wI@*98%y(Q3;n@qK4q3bz4h%VY@dRKid7p3 z2vXIgr^gF~>19IgPI0_M@Eb}y{`NmkrLj^exr9P9o6eJVv46@26vlM^ch=cZOu=2( zsZ&Q-JR|to;}2gzs>3o=$BYU(3UZ$tY@bOw`ylmmPMo{6U#WLi|GMHmDt@0 z|3~_1L7yldksJ&^+zQxQ;>5yR8D8d@@)sV4Ga-1Q_?1pK7+&)#xRL;R{SHiGXyGf& zToHX~tE6XWD>Tg$w+?%=G}71)VgtUSJimRV$VYHyo9mu%;k@i{$0pXOcm2_$V&8tK-A?j_;Pq8Jk`n2D zpkM;0b-1w5q$)X71%H02aMxeZ=mnfT*GyU!ipbM5ib{62eU>TYH|cGrN)I(I$wcCc zk~{>9q7J|wI__+Z(hV@6B|G6&HH@xazrF?UrOWKv;EA9{Xk{&4tF~+@sxZMAK*Inrb7u^Lh(tp|izuis)#vDn zg3Fvw@$W}V%U_d;Qt(vD4<%X0Mm2-gU~)`M%!|A{b1mg;i^wmqCeyiqB1O-L;PUfR zd>$csAAy}BOIYwNj@z`YVOxDl{T`5Zs1^r8#V(}quy50XN(Is37NmthQoZOl`q`=e z6NKd7J;L=D)H<7KeS&HvDIU#?J})o+A+GgQKM&ajB?g1EKx@@S`Xq@+IxM~mG0pbttG|Lo4?Bb~3ORHQMCXqu27m;Ov#}S{gxQL5)$<049D`TfrAJ3N>=W<*`|& zJ*br+#=y&bM&`dx`zF;2nGxnY?MNSYz7Agm?qE*u+5aY-FwENbRONwv7*9TF%Xfm( zI@*PDB>&Re&%8Nr-aN0k^$5M(d!4PYefRS6@F^RMFvNSZ&IXAl+=N0ryD%46z{5;- z!RBsZw9#4$V**SSnDhL;1E%;RE8_3q;6@vH{psb>T_6ngymHSioG5Z+PzLVMYVY4@ z#Ad&?8+O6dKnWBge^vLCHC65HKfgG4>2j{dx47=yQp6}FZMyaCyGy!o@EIP9#otc? zfy?lZf>Kqy29hjdEql$H^<=L54NCNU5|DKCHwo;6so_B&?R{2}yqE`-Rj3MDjj(EA zoG7xkYckzP9_nJf$RDQvH}bC#5;g4X@JGM;*(sZScq3GW&-K z3L%hzl0#zvrFcjv)r)*rNh3lhv)gRPy}Nfi;am$1Nf{yxx0M+W0^Tko*RLOn&If#u z3^ZQO!3FD<@*CZ{MbRg5lupfx$eH(A=s(hCGQzHo2}hiY@6Fa+GQWGo%lr4H-#^1+v4Q&PgK{eEuX@Qa!^fm@Jonib{s4@|BgmPGBFiR za@poC$Cc61^jn@i*cUfZYwOdtI@BjLXnbzjSBkvqC^1owZR{moAvU2EP+z3L2+3*U z^Y?tbuEpa2jBj<{N;jdRVt2EcN6bQ?E|d@S1jYfESHDvsaVHeC!5|EK(@dLGE$gSd z(NNpbWz(8SQw_^0oE7Z-iw_@`MfbAhDKchF4Yl?)WCgg~Og}ohX3qK#y=e@uQ^y0A zAtv)A*XG!|y*ws-13Di@`KJKF5*c;AJs3j^RGmUQnOIi_Y=gcsnDvpKtp}xOY@=<( zc+82cCSDxC-;9uzKFh7pgDkYRnguMNzG}ng*n*X7*DgYq0tM&>^eR*LmOs9VB_*hj zwDfe{Hy%COOZI$zuJ2h~I!Uk=;JCDInSJTx5YI zC5s;(|BFH)CnF|zsY&#}^Z#1jm1i6vC)*W7@AeRyx!v&fFJ8Z1^Q;*3XvsG@6F{k>8RYO`yMglP&>fA{5F&+aoMYOz_vfHeRRHm_wnOz@@Iel z_t-^>>Z|`Kzy_@Q^5R*_4$wMTdBP>lf6BX@UXe z@zx%FRz2GQlg{&H#h2Q%S#0jB!MO%dd`_i#+Z&yBmhTkn49AbKHF>yruPmi4@P6Wh z-tGNCh@j0JA^)Ey7ncXvMu@yvb^=)bhzch;GzE-CL5cu-+z4xb_6tyPxn~r7qL9&q zh;-b!>LMvK849rdgk0WIdw9B&uGb&7x%R;=+@o90TU7j&>OVL!xi!^-9%v@6sxhD1 zg%fP8pI=hldwVR*i~c#sGUfM~Q3YG3j5}BS!rOy&U@4I#D=5jf!lM|&72h9#i zgNPn^6_zM#&zl=EHmgIh@u66lf&(bp#_gGHMI)Dc;cI2^rY)9&!cxtI90V7{bV;O z8_YI*SEU=#ZsEd)m?b$^$~L4HrJey;7x~wylN}{B8Cnrgrk*hw0{=Olrhu~7%zaJV zOqX95`NkvOHmhpS6GpnVa8U}K6_LV8rJzO^>q|U(frdIEK^LktA*`ismxV+DsEyx zfa?N?%V7}#Nq80l@5UgT^j@cTngO?gVD2HJJrI6%)hf|6ap_q==nqndW#b*qV3wXVh^KjwjXgEImz=0@TEF7ZwNH=pHeB##y*{B z?Ad0>9-=_CuZ6p?TbjT?GvufYYb3`W;|59R4}nJm)linP@nJykhzN^1dXC zJi^sN8YyZ>wYy_0X>~dPEMiDp%y3PlMN&O#P@9w|KpobZ{l!h6ENeYSaHK#q0_-E+^T>Lc7Q3!mYo85 zijo9N8bT7Ns0pa@sqVjIuS%W$+Yz%pOUPjpXnO$3V&?ezugKAXdnx>L<$HL2o~TsS z_=U_2&-(o_@eZ+9jsjW;Ms}nN4zrj<3yhq#e%qn@K;bLCM>w}Mn?lbs5sBaFm!Ftz zKqGZ-acVmXg-D1;8e5=?mNo-7U}dN*Oi-jB#`r#kplKYpK6>(RMvE=`9}w!6>!D{JbtJ~28%aq}{nekd z4?wc+LVqR!J$lzu0iz`m;$dof+6jTk7-odTHB83O_{`c~T(R|3L^}~FodZRa;Kh+r zLZGJT8(*qUA=e3Wda*!bmqIHSQmr>;t^KK92oMg;nTZ%+*YF>HO@Xp=fo!h`gk%EfPn%cc0=Wan)3DzZMau)|KX)KC2w{C$) zKz;PmKAV1e8o7mUgzy%TVYMrq7EeG33*=4mJPWr%{%ub*Wt1#pj6 zJIy|KkRiyH1sTfYrb@j@m=P%xL6I|Z&wo%OU~X&;?uwL{d?yEo`zg-2GJnZu)@1MA zh>B9nuu`B!T&3Lk{oK;wAE2E@$DJQwpVP5Zr!Dq54%;cZr_xPuz5Ir(>Wc8S51DlV z*OqhGs_65Nsz})*{PQxUQeDQUyl8_4KtdBq%V;in$UQPnpB2?MH2S2jP0=(jf@VZF zAha(b(Se%T>jJ|bJd6yJgfO}y@{TQV=OWLRVsO`UU{hH!5x>C^>HTc-qquWXJ>W!s zYu#uqP0iihQe@l8WoDnrMt7L_{5*Ko`zeu1rcOoU^4}~(S|oembeMU&c!QI#ML8+M z1@B8KI;1EsFD>E&h%qRDro-E#+pSYNf(8N?=8-h_A3k{N_Zn~<>aV?XchZ4pSUvvu zoO!{8a3caG9ib&nTEFK~cll({;4-a9E5B7H7MX!k@u(b6Er!6P7QX2+U&0x-iGOUZ z-MZ3j_2muQ2@2!#^LCT5R-(?hRjHKEEhz}Un6tV3<;V8z6Jz)C$o_ZL!jk|B9TLCr zW49L^1E=5-pB?=Ul9imZ zUnCCETW7+c5D186d(k+-oXh@R0r>)tS5iQnuJ`3%|lvc!-ZR0peCulb~0^g zKmYrpy_S;@=C$~?E`!%Uu@aSUQx=Qx&uP)4KD?5?{ohuwfW95JI3*^@^uuKzB_svyx zDM9dhWYiW=VUsKC()@1!WPy~oudkk--nM>YexL*vtg_;)!Q{VZ35wPWEQDuedj(|$ z^)Aa*JT6`=>HTEkqD7-CCcT0~E=E+*`uFoG=8?)di?K68h^o1~v2)q$7J@WG_bSTn z_92NpKUd$198IjKesw{A4v4vqt$1X9EiXa==w=rlOuKedRK0h0Brv` zd+yHt>7H`{Eu^U}D(d0At|mqIc82pHD%&tThK$0ZxBW~L(Oi#qt(W{^PcLm&3#d6? zMAYZ22fFgN+$7>@Kh)JXAZAwd%6xoj%`(&V?}CS&**ukx)Y7?Lx$VTw{?67>TDk1E zAnq z`27V8OJCHPu)+g|^(+hxyekM9>%d2)!-d@XAVfsTXWg7<**5W@-Ra?TpRC(Z{Ci99 zpE|H4Ibo-UB_T0(LKH{)HPr!tw-(G0v_ckY7cA*mBqURgi*_|sY z0m>92#^{s^;t2+6AcU)z8PxRcF?}0d`L)T8D=KXD%+UM+cnIsUJK>7K^5thT_D_Ua zVB?e3ntAZ0EHya_0IxVS-0`t-{Aw0Qh^~SV8Me&YEe-^lUVI`Cqvy#XAO2?}BJTI( zd^K9FyV&~}s91h&cmGOC(41A)fb>i}w`8nZzBV_k%bw!)@cOL>J?y&ocJ~Tz=e=`P z0Hh{Nnl#WfH=@KY>R)6y3#iGo=Q)B9Q!J|KtkWE@=-vxnZl=M_T;SkFG_7c3@wU58 z{Z>XSFa~P4q7jrA5Sd_{d1pBr!@?u2MAi}&jkZu^8D51YkL-bBsO;>_Gtxo+V6(0;EOzzTTw2{<@Zv(~fMzLyZEg zbEi(#U3j0?12Wz3jCz%j0lW*<5CnH2%?b5(t3fd(8We5x%`&M?)#$l>n#PRK8?;XB zsm!Mi*H#@~dCOXRRbFDvuw#}ShtkiN3q?djX+rQ)?Rg>~pe;?Fq>4A~7tT4q24MtqVfCTQ}dYeDtSn z4JvoVIqv#UR8d*GVqN>Oub=<**N{}b%p2QUxi^VA>zcfEYQxu4uh(y#HDbWY4Sr+v zCj8uXVZ;=-V@HQf&brxsdvqPG?fP-U_BSlFFU)O!*hIH*T-EO> z3qunkJ8w99${}i}0+}FT`5(m&Qv~(dn>e+5GXZCh1O;s&^r-9Vw&#e!2zX`<%>McF zr>3T+3kj83_>N9*v6k?JSn4ofKvVk2t5AzubGOK1?q0?U1WbTm<868z%*r0bH9VFa znzmXIbj((|G^obwSy`>LwY5iQfo-_fo?$e}Qjl=?srj6xvllLGBaW{8_~=6y4Ih1; z)|wQHRspvRPc>60kARSC>WSir%zW%AQn=9?Tes&}tmat(wL_v3|GeALgWbfwouBrT5lLOWefzIZxv||yDBGAJ zF^55BudW^#xw)m~7SJ=%CJOOR!L-#oo-d8!c_du}QoDMVmX>*2LjK%e^-yQVnzws< zH-S@}zhp@rbeC+UoE$Xe)_cl6H4focvSYJ)HBs-?Ry=t0Xbbh8=*u|(*OQa=j8D#( z4ple~S03sn*OJ!#57Y-A8O?i%aXHvEU;l*O>mM9kBo(rX2?#W7T04Rcj|Z9E0`|C+ zVqeQ@06PQCxwGtG+|E!sl+M5u|9}85BA5m3n=zOSmTJd}?#-By^o(AHMxh!Sv1>E} zBIESBF={*-d-=?x`G$pnA`1YGvgrt8%YvRIvq4!r?C3vgM zUvD35T>lGjXfrN3XeZ~qE*suNMP)PT>~YMd@kUKSN5M3HPYfRbY#I^wqZR4=TjIi3 z1qG-mqH<1{1|6hfxQ1S52WVf%e*KzIsw9#v&;PLKfR>muDW^8n=jY)ryaFmcd3*?j zxB)0rcSc8FmxAD}Gs1Eg_wTl5z67>j#%C4a;L#f01`k%l5=HChI`8-63B63#Y-hA( zw1Gh*kQ6=0QV9s*Nw4zqHqpMGNDlb?y$gX&cs&Nw1Wg4uK_J_X`JqQ^m&mPZu*c|` zQhG4kpF5D`VcVJvb)Ht#;rF*4rV4Co27>e+jfmJqc&PWND-HoiQ9zC^=&L{r+me(- zsgxK`vo~h^_}ZYbV>s!EW)wd9HCY;ZuC!@`0$Z{%2yOlmfU;y#hwq)@Q72+8l@P1H0q9KtD8#i9iLz_HV( z>tnFAoq9php?mi_1o>p zR0|Htz0}w#)r{Q2hoC!tT#9 zGU|N#q`Ui?C@(Ls551>II#++#(8g1NXBHy=omnF`Z1`(5W7})A*Uvze((xOnW}RwkB8b zfB!SB)3t{4t3LKWzZ}!s-1chKYyH$?m3v$Hoc=VfPkPYIM!1aR$#TF zra6CP+>NRU&#_t8`Ao+OsGIt6v?!GtDH^+tZT0#WBU;iiD@NkNM;UO$Hh_k{d+*-k z&?G*H-{43hR^!NR8>@b*znR!v^fB21q&~~x5b7dk*gT<9X6esG`YVx-XnkEjRhAd%lFc56 zq!83BNtFs0I)o#fa#5u*(Qa&GN}bMHT1$}4)Ztx;GPp3KH~1^~JW)ZCJvMCHA*}R@ zXuDJZJ4vdK-K23^2@6vmGv<1$*RsHs>`_b6*kjp< zM-?Oo3aL#D4OkFBcHdqg(`;5IQzm=09H1A}V*EEdw8K?zsI2^GZFjk|=_7X}y=--H z`dD@EIe`HAkGogGS>(ZuQuR==<(XCfY(YcQ!RK*dSQoY+GJB)H#B$p8yn+G*%R^t_ z%98%)rM?aN$eFX6@9E{02B#;M6Ohk|RBWdDwZBB^)lBGx=P$c)`CxNAHc1@a5g38q zQBzCH_0*{ztX8lE-X^o8|FRxQW~Q9^K4<(4s)G6R=O2mwS#|hK$KKz5f|JW<61OMC zRhk=3RDOuf(wDXcv4~*+n+s7}A$o6Syj{OhjIrw3xHrBdHM&kYv;#GWdcS^=df%xu zWT3|C7aNWxRs)I52flgAKx=UEqzMzQQn!idoPpI@`9F!9E=28Gut7|PDZy*?K8~H2 zIXRh?q?@QTFyJ28r-TU?$;5=xUEr)8bal-pTt@RLrw~r!8X6HnTgwd z!tm5t=q!lgc07tnK+AP}^t&9NVV^4(I;041C8<-;)Eza_=!dHEkdDK-DIciCMWluo z-+T*;&6HPsnlYc$K$fBlUek1d#f!zbPoP8CUd|)yx%Gb)KEJ%3x=sLMj%qR~F8$%% zJ9hwjL?dJIIXgDSxiUU^&Ck-(7f{8F{chU7fB&2;j|{>w zU#?lbdMqp`ms8YRptA-OC>Z+i9Ez#ae5lt6Q>HY37<##?_%RyvlrZ_N8VX6$Kz<#pz=L^LVj*##UN%{y`UZI{o1l+OHs1RL#_k-uh9cGWJxEGl z;7hLo%n?*@XlkGsdCDk}9*c`La>N`K8(h+3#3I(STt%dTljAnI(mhQ=D`0o5?gLGiIAwTlcCcFE*e-uC0(O2Jlty9S!dePtP03f4uka z-%f3`&)0V}r6F%W8pXXB7<~KuyfsDtqnPaX2n|$7oV69u9!+7+swv7@ZiNi{rcBW) zNRP=>Z)jKQ8(lGW_-gp6Fu|1vR+Z^PRnx#8&FYa(kF3azZ}nWdbM=RT=rCTAHGO_m z@_=b-YoD2sDGsL8r%f6+b^%8l)mbGY^h!7W&UPBHQGBwlUAt;&lu-30wq9{X$w;Tz zl){#XerARa89Ip{O`~k4$%c??W>HkxzCaWH-~81|_|r&g;Z{C8ij8O#qc`-;>JxIt z%hJZCw`a9@;sY`VNmNIUO@2V}Mdh8Cnc0%Vk$CWO^wMERE!r*tteg;Rh%Lc2c7+36 zJjlpM!aKkdlJx4;tFj+>G#X91k8J?&)m1+=jWn)LVfJ`t?3xeF2~8rPFwaGm(sBP? zb1SPkpEh)oD(I3_TUO_34>zNFnw4cXtJ9j^y?gVQ(!jZf_9(1NLv=LLtm%mpCq`eN ztM1LZ#5PbS(e6n%Zq%TuyNPyYvvA~8**6g=nsZRwSkjV}$I@VfPdte!2f;kGrlQ(N z>S1smF+X_w_HBCi9P&CGd_!tl$3;KsI=&zk$iowSij+8dV z_cGVNf9Wup@9^-U3Js!Wzka9II2gQr|33c9`UC^r;)8yE9mpz=izKAuF3M`dMC3^? zUcTInfU@p81KlkXgCDj^-I-2s)&FIBe%{o1t-H}9flwxbor+A7=HJ-H=p$XGmz+Oz zMAX{im-gx%T9xr-=Dg(c;I{c+%3 z#-Gy~+PeJ4&K<3KpE9b~wCP^`%6A_=Jh^1;qwmT!w*dsdqf;_? z+30U%%x2A-S5r0HOqCzDCMA9cP_%?$2|75eQE@hMcCMV`GrR49N!w65oeE#3O5UUr zoaz6Bz-412M2h5tkabOP^4P@{cq@rUP^$d*Z(0U7)~O*99IxK8yD*88G1Z3u_Q z)h5>DOwp~EvT6gJQ9S-8;(p5y{@bN=>HmiqrqAqturZTy`056_^wQ~rvEo@pCJxPv5a4W9X zXi`V1dxu2N;Gyzn<{oj-3t47MR>J4HfA?nYbD{%6AEFaONn0NjIWZnPxNBFh9?81G zBaVAOlZ{Pjw*_;?mHBvdRj~c0VL_*ord-ObviIS`@))DDm652#kd&VfQI0b|+Y$Uw zDyo84k4O%fW>rB$a8QwUDH^AU6I7jg^+r?R8EN=r#m=0u+(w*DIIHMC#zUb{H*Wb=g6fQFrPF``)1S_o(fa0t2aRF;Zr!;f>4BzhYs2ZlOH_O>DeMR| z+sCSp+PHB;bd zbCbIJ=)ZmUZt16ZIiPXwQkEHw;hQ{VW(#ttgO9#!Frx~|!^ZIYoCx@QpqPFLNMNnR ztz^bTX9ObVOP5x8V~S;#2xFGkxCY+{GQXXCV`a1$x>c$+PzrNAYIb;ext=_^I3Vyz zlt2ncr(Vs-P^D!`=Er9*ZICUaMbu2x^B2D?yyWUyQy5YzfOhnD0z7lt8ya2d{Pq6_ z_^jRBZLA+Qh>V}g3`TTxsK!NY@YlTj{4E4~OJd?WBuPDemRVWVXLPDPNh?q~kN{{T5pJp6W> z-ufwuJl;!GI<*i!C|%D+-s@oV?p*YeFE;6THAu0H7h-vQ9{uT~G&u5W_vZTFr%x6o{(gA(4r zNaG?UffOD*1GXif=N&mVeVzE{LE=z_BY`;nxOrM~RdQUf>W0Gnz5T6LO{TR)CUI)u z!RYv!HEPJr@O88brZJx7+-El`mYf_UA}}M~R1O%@NeI z>4nAj54tiEXLNw*QG)}}^n!A|dMJlyMA-gOl&@V0^76aW63ZGZ&CQQ~8Kf@a1Kp8Jg3 ztFKjkg7mZUx@oK4e)uqkzm!}?+&^;i5zwcGfDYV2`SIlV7w{q`Cjsf2=wm&uZwhBjjin{Q$*A7wfsk z#`76w&v*k&!0bxQ+1`6^pM}i)=ewJ>MIKc8F)B6vhr{~yOogAh<0J^{kX0wL;$e<0 zuevCGSE(9VZ?d$}cs{}Ko77HuR08NqECK6SAmOc7Hlp-J^^U1b`FRjxyuJnuca&kr z8P8gfoM_Fg+N=Wf0qG0$|NQFJR@{AO%uc386RRz+59QtbLG_pCTq!erKL-d1iBYlP z`=PWjGP`MR;q{w(YzX{p8!nX>gOU=y%n=NoiU{5j@M^fL60Na@`G6 zO-xQt3xE`{O0@$~V4Mf7!ntJIUu9*(F9wLAn$@$psO)B3SknqS8oyS$D-uG;_lb1w z%!~I89zmakj^y#&GNygBtn#1J_wxU~H*dsSdtW$P-ET)oJy0jjfdjW5J=(E;{rUs% zJVG$nao41-k7CX+ht^@pkd}-qCC0-QfHWmc{g`?V#Sn=3sgyfo#+@ui9Q5J*J?CTZ z&s+zaFm+2)g~rF{$m_y&IWSh~H!LC_ypcE9xpPXi!#`+{mC0dxGWx}>6g?|~h@eZJ zUHGJY`1&Jj8yieJjt}keVJUeO%ee^w`+pfs&scNz?Afb=Wg{lp-|orXH+gwSe+-k8 zV|7pBd-_Pa$RM6bAsQ4Tk4}}zHW@k&$Vg*E3jh8mvmF|YTDtU9a;FyOPa)-4^JBAT z)nmSpyh4KC3_bkAM)m~kp3r%XVLTCV-=3r zp!ySmrA9KRu@h8|3D$-nI8ax@Co6N@DFTj5i6Sk$>9+l8f)MfM6JP~VZC>_SW+sFqM4S~ND?kGkJ<{* zbuqLMk`=l@Qenp&ZjB|MPi70bxkspO`a|OGpd=GnkHUp_J9gyANHsU%WQmCwqtur6 za?w4%qiJi_NwW)v6}CDx)cpxilq3esSXLIKw3P-BhmHqtO<6+M5q5)`&GirY>cWVn zLIFFb3^q?#ug#@O21EJq%Kieh4I+UiiZ2M>ETPEMhOQ3T-Dm&ZBDNlP?Ao=K@RWmA zcPWE#q{m_cUxLme1c|7&;{U-QVA9|bC>yzD zXM6R76Dxn`R*C|TlkAbTF9-Ln?XaT2D6Shg1a*{^w@m6fh6#UOXs2{2!j9ls!ke8* zTQf3gk{-`H8fHJLMb$z=A=Nx|+ctPm{ZC%+!I|b0{bC$T&dh0j@{;x|j|!QR+Xj$X zP4&Y%{{foB&DRU^tEa3iq#a7EJfw|D03t=8UeT8Dj&2Z9!gTsQ z9*P%_aEJgnm~8B#C*qsbaUe)bokaC>^vabske5%o2O{{fKn_HUGK!HzbYd%kk-g50 znGx!75hhA%J9Ic=|4cQLC*6P#>vWaqTm^?rPOe2iQC&eX3hZxAuPe_ls{1dDZ72ui zMVAF^fEyR%ku-T&J@Gs z(vX4lD3m}+Xyjc9Uq|twLObgm{^1@%egS(yu<@yDMFZ+T=O=7+5&c;{0!*RB`OaGh zo-$&9u8tFSYe235QClAjnf<(Y34q`uDM2dkew~? z`oy$g?FL!sHFuGyfye6sON|DZMF$0z6E-KKC4?qWCVBSioB; zup+$$HHg*szk&_TKb=je{9QpuC(e07rA{sS9?QLBH%r(w6NW)I9XQYuT>Js&UPhtM zoTHcD?<1qU|6M0h}bd(=VjrB z@iwks-S!PbUk~nJwYqJn;fyN=LSdS{oeP3BeE7VeQ^#x9S5p9`Dn=6LU0Y1&X(Q`Z z)Jk*JXJ~6}-P0>pfU9C)5=71bV%&=0+ z)r#P|m+YQ+0h~fa$|$C{QQ`9#y6)0k*uNz&vK4Ga8e2dntyaH(>A-`J;mU6H_9Ohu z3Zkv6>AlHbFX2mW-Mu>oH6mk}Q(4Oly?@EaA#j@F-8cb%y;q`%k&ZHaDO+%5F9e?=q2@DcpkVFs7 zxP@r!?(TjhA|j$_h&-iQ1J`6JnSj{CKO74QsY6dKB1uGT5*O(A)mi((EAWWN>ov;I zS_9YIhDTl~?|u4R;Ppg6aP9Hq3n?FHCnBgap8{?K|8`#SQL8gF{U&;_FEzh%CS1?U zoB+v4nkRgCw=j48~fJ?MO^H;|869%Vdg(0 z53av-J&iONeqB}d(L&wlWy%UU#swCO7HvbmdgP^tEuT8CVq#|%FEceDfslDExW;N~ zYF8dOENxZT6h(L9VSl`(iPm0sHvUY419F8(dQW5B4FjTQixDU0wyap&N5yTcSq8O# zTF+&G{E5Yxk7zifRd#hf9Ud-p3F`T3V8Shi&H+7(#3GdAYmP{O!$sM6e`ILz+)MQCM z|9SP!9sLQ8KVw4Wk78ns!^6d)v&GPegEu@9p6@#Oto6ZlrmatgYA@LEJ7ubEAA>Ak zgNlfym&N5M|7z-SZXy^+H?U_SA1HMRFM_V?3@#0ytGR9Dc%F103 zoEM)zH*_1j8Px9R>C>lE-jgkxpE8>BuFI$~-BjEpS<2!R!K)rek4tKGp1?nr6MEF} z)cXZL7b})LzohlbE>`oBwwBh6CA-Z)&)|i}^ANkZr&t`AbYfa8W^7maJ=f%4kXqb_ zjK^QY_2dcV2psKL)?zszkBzB*DoC%9W?$Utu+4xuK^`Rs8B;4s-@@y;aw5vDmX#K232Hj zZQ8Uc`mm7<9ij4qy9s{z{o}*IZ+5~e0zHlC+w^VxHM9$eeXa!QRJ$GVjB-)>N!|GI zXCJ3GKm?W}S+Q8wba^j|UPw zsioMUQDT{x0itruK&oIUwpkH8Z8`rNtg#1gw_=!lLyRZ-)z5^URqeDBVTj`4O#cqCIHj793}U zdV|%;2=o$&XwQ*T?L>GYnk)~VA|V*Po1ZUR%bwef@6Z3AV`|!$K@E+$TM_7y<8K@ZHpHUbg)m*A+&gM$7M zv{JRr;tyc_QSM1GvFq&W_NA1Qz}2EXM*H8jwzr%d!_eANp$J41Q#x_`A%;~X1NRq$ z&d`tJACKBTCjWZp#KTupmlPc={I+wLkG2iFf8w!U;RSqlR2(hynwda%vjAI(W_cGU zl)aY>vt+1I_7z{5IcN_!<>ptZ(KYjHE5vrmqhTp8>!Lzvk|?c_iXn56pnz(R42w$q zMXJ-XO_zSBfL4rk)84MDJjgk>6|=8YUT?TEO(DvhM(*5sKlkoiRuEkqdazRCXa{#GoXD%6_*ne@6ga++W2J5s;2)Ns&tQq({5=P zV|tjvXCAm<8&!5u9+dm2O~scR6lG!i*uSF zA>KlqHtTS-rbE<#d<;a$Mrso#a<8$G28uoz^q`1#L|C+cK)`{YQy1YGktI`2Xe%f* zjV60UvO$VSWDsnEMLK{1ogTYt@9cW8?Zt>G*@$_b0q_q6q~kzBobVaYBsNrnk{b28jbF{t4(Lr`!ra zW+@|l!fIbRR;y!$?TM}O@yEffL`mqPtV=Brt!b}6qPt+Q=sjHN?V_R|?mg1^HI1)P z_D}Y0Dm^iiF|m`>grwgsci%LgZzQ_cA~tr>EL0i*9Vqn*F3I1w->@{FTu3 zJFRJ-k53O4@+DLnLI6(j)1%g!1xwctEOUn=d6;!+IS*vbz>cV~XA%%@aCdDJ?KvsV)_h#XE#fAf0T<}UBH3Sw zI!a8NH244sb5k5zm9UJ2>J)ACryXVxguLw|Dbqf{H$qeU>0HaY!yqPS!PiKPn8NK5 zj~*PE$S@Ky3e16aKXIa4qV7Eo&uK(1K~va#dOVsymaec9ZJWE`x;iSa3g7QA*_KZn z2ZAe8`;)|H^7Q1~A-zkHC}xPVc2nb@#oH3)?@tpSq;M%EN`%wd&Jsirf6L=QP>X(S zxL!5-Pd>Q4TW{rsog6aN&Ykb`^<1JeB+TT0-uFT3=L9i^r)K6clLn zk&}CaEo3HV7JiT;f)3t5yuxh|s`?k5UxTrUExXsa)iYE4+jia1tf5oVYh1W!k%y9= zxXXtwm?3Y|-k5BO>OEz#M)T$URo3;%tbt&F04`6r6`;p=6EEnyfn-Be8OG7(oOD{$ zi`X#DO-eKk^Uq)|35XA7lzH`N=6p<)LXjr5M>~zz|^0o z1!lk_PbZ`QynoN0Z8!s#nwUIRJcw>gj6$Qy)Z`#t#W+u{rV!=LWFZUL2jJgZpmHrL za|X-(ijE%5tu>1JIw+)xtN6y`xmW-*3(q3Klk`Y%6SXA`R(DorM+wFk?oTV71q*_F zV-}AP3bo|lRW~x0WD5?pbLWGEvoGI2ZKiv#f5fF1f>-?`PMtW@R=M>AiP&~Hydx$= z*UC_kP#qmu+RzjQCY;W)s!$Z$+W-6tF*4y$CWX3<$i4tks()b)uq((kNjfMho|C~= z|4D7=Ru&q!9V?-g*NRwzP_P#_Cx7aBnnDiMIn*NX4g0QN3~XLd|Ni5nkKwwF63t3c zF0!MoL{N-qVFo@RR*_xZmw}L-N+N~cjttmO*G&dl-oDo6Z_(|<38oiaKcH>Cs&csnXcYGhV)wXI4rb5Ys@P#_Ddn zYVQpfaZx7r!Xqel1Og}tM3$l)<9~3<-Hwe`RLr0=CH%52MGJ%lNt-9IYc`@T3MRz+xKo{4co6Xt41oJOG ztpr7)sO8ArW_x|e2t#49)+>oz4O1=!MkOVopu)Z)oBbaHtwK&Mir?}kBQ{Zf=5h*M z;d>BTUmR&nRF(lLbWKc5+>*;0;K*u)mP*WJfnLgjD}I2IK#+wuZ%&^)iJum6eEhbk zY6;b2RF$|C!ni<&N=u1o9RUFT;wr$f{9DgSrlD3s<-yESaRGD}FCO5wQpBx`Z2%3I zcopnAK6w$5@x+w1fbb`lx<7+65hK6@%?eXS)oJtRmo5GKN`QM2k1u%*q>l1UghXJ% z5-J{H$;Rx_J4?t75b6sv7rkL7NX40`b>Rr1Wo1i5Wo0n(#<_DdKW>LihxYE4 zSL0`$dXuXf^r#&3EUEiATa38M&~EspogjM!gIv>LR8lM{8{EVw1`0hW> zh>xWkCdj9ATxStq#OBi(z^fHr4}gU(kMK(!@VqD)yQz3W-^p7fd<$S8gC4E+TZ1fe&biRSJ4AjlIBh2N6b7E#+AEk~ep z)vx-mtNe{a|21VhCsYH5eaiR|+ih(R=q4H5gGxaZLPz?Ab7cU2F(I5QD3DRx+oSy~ zpK()1HcJ}*-AE5z4#%_CQfNP^qa-3x5FRM__zn`9VBnTM-OPzg*jpz_9StgH+<^@z zlz5_G<^Rh1$F~PJq9wr6KP-%Hhi7go+{kGMav3_K4|0^>KnfeX@8gTDU3-3u z75D#3;oQG>-hU4thx*?4d#5kDfz~@ONYev&P^!mZ2PntrNg+QE1JfbMeS_FSO5%;b zgd-FOV$Wu=4&S*@%_T_au@FK_#25nnDx9?v$>$eIGUUL{3+tLB>`Ve!rlgQeqgiAN zx?uARNqzeRGbUH@dKs4=aH+3RttUPq7gBm_lIhbtjCwKoP)-M+dKAq?oC13R54U8A z+oc^_wp_Sk_V4F!{&%FNyJf2{z{tnJqM-O*qYRWezx~+Z!@7MQc91aGVm5gbR1!tA zKQd@exQ(D;GvZDutvEP$2oUOtOV%`h53onDKXoB=i=Js(nBGm!9EsRZVe3#CQ7*NR zu*}CfH`pNo5`W6VlSlw93DAvuR6jNLGmnU1H7=Y!p9G4S@$Df++W(JsBMBM4@|<`F zv6<0!oAhqp4gdW|%B~Ou1U^^xU0B5Bt|PVa5~O+J!k1+t=tO9>InqkiEw|F^F! zA30Foidb(xS^f6SA&+40}wKbPV;vY?O|1s+H z{l}-%sgqDS@Jm;#@Z&%K_uo&4=s*zW|NB?rT%iT{-@h(+{{PL&Lq+j$xElPi}aZ1PNcg=R?1z+s1y;>Bn^?>0?v>q zfADdQ8Yfmt@4uxb1FzeE9oFm(%11oPeT*rnWi!R^wqm$NYI|YGfJBs%2%LbPP)3~Q zD3ik~AM`%Q$9;z~yjkO*6=4X5$WBIl*HH?B)u8a~NCi8ZGw(a)5hsdZE&56|t#~@5 z+oy{S^cLoz+O~QnQ5iTk;L{}%;YmZTy&rEf-or@Vh0Gne|(7C+iC!hm|^7R|I! zZ8BVI6I)=0VZxF+Ysh+A!z)lt{L}$7phd>M#U}vxn5>cNeI029 zQ_zlaiVE}PM*w=_Qw1QM;{EH>QBpsd`Byu`UL73N+xuJY1bP8xj76M$Sd<0}v5JiG zI(B5?&!I_qL#Jk$MPg6dmN`2%#Wb7Tw3uuX_SleMve<<9EH^n3^jv6cU;Irus3p&F zXNl0+3!J5{&JKHRWmw_z6_y2$qpIqigaMr0baaTLYHCxXqqrh`#(nroXq@&DUz}(j zY3XH9YH=luBkN<4{)SeUnk}CD|F%!<2*Cq(5*&E$ki7t9bB8?IF&X<$^r@}zP<-(y zbu4qz)OX*z-O}_#q>1KAc&mk(h!Yjq{pMkdw@cgos|Ao5{u+Tv<(J-l(Hsf$))Y5O z)O9OF_bY~oNyJ30EtIpHTt!TF;mV015hcZn9M>^FdLiazGrhYe-r?|@pbPjanl~U` zW+jPGaPR4yud7zC4&n9vT{1=#L!OwXIP?ILLA#y_4)%petU>J7)MLe+J$NuLXj{ss zbDV33={Y{^@^EDJJ=fuXL-2YVZuv)uh-`Yz|B_coBn)`#&({-LYspF?%6SBt65?@O zlh^)R8L%`g-f7pa*We)B6v1gA&7O8A5!srA^(B!Y>=$S7m!_vTs=xG&RoVUS)z0I0 zy}KBn`_V71o8yj%ZjMI{_VyoLtd}rL!9cs{LP}(!W#(T+CSBu79rCsXZtIU&;tGS-jjz}@f9_uNEjAq<# zxQa}4lB_IAwih`wtYpa-q~V^&kMHKuh3Py`x~?GeWMyS}&RxN)L?A!Kgu+ks%6uG3 z0O^V+v9aEK+~qjM#h^tzhC&VlX1ZN1pms_tVGc*M5;F5}H;E|$bHJWkz?ihXM@$Ea zx7ohEl^7T(T;CEJ1=us6>g>U^OYeCLOj>5ubyQ#?txPl<4>vA~2ZSMiNaOTgS51VAn*h{vlJC7fqq{902 z-ChK=K_L;18VvX)SmJ0yKZ~@YCr?yz;!3hMtj%k)EU$_3zPR7RR2C3ng=;zaxDsV6z2{ny8gUV!=GE7nfFPVIhn*{q%Pt znAwFYTs$K74ocR?9K=KDi9pT0NCOdLj;O`Y`)iT}Nv?$OaEkG5{EZWVft}^{m7;wT z3D+dL%!Ir5>D5$U)}M+GpHpjyf>J&_ypE~DPBIp|E9mX#Q6^%>mICjb|0jAp==UrF zB=Cf%%&RiGlbh>CO!wW>OM}1+ak+?)X&hC{?d`sNOjtjmH=Df{XqNEC!xZF44=3;MWq2v_mE&NBZev?sar>GB9x-o(=4KnUCeY4Ls5zok0Q7c z6DQefn8>+rtF@u|UH*{eAZ`y!B_0J71lG6A9OLh=5>9yWIEZ{I_H2^NSklu(Sxx#x zmGNISYSckm;RU7hU!FX9Qe=f?=Ofm0D5HdlPM*#z>{$RE3hV@+B}@i6!p_%?DG|U( zJ_hHX6=$dd6~N1=rA zzGvs?3o9uRTim+onYTvZ{6<9}A+|7}R$j!6L;Gqc0R!>ZCiK3qzX_TCPGl4JIfq0V zEV=7Y#O-^II%E=UST%a_+1<5{c7ce6nLKfq5cIv;VIgebVum#J;Yfvs_#-N@;*jIG z@C6zd0#-uIq8ff0Tv|E3efJSxawmzI=3>T#kWlgV&=K9ROVV#o4R=b=V=jd_1930I zML50tX)VLey!||k#x7eUrMLTRvA%cGmocmd&So7=s+j$&{W#<^DRUNhavjRUw-u-5 zUJP;)6pNFS?_`^ht(9!22>zE;??)+;{k^-enqR9eKJqj8TFAdHqBBaRPxMo)~ zh&M0gY{wj=BbOzTo3V~q6?qmdt>$;d1Uc5>^vt^JY-rgjzU~7_T1e>oi4CD0i;}H} z6c*+dn3TJG^>1`EG~1E8(aOrdQq8O|R4sNzS{@~Z$PNYU-s9FI!`J;DfHXtbEqFLY z*r=>*_{GMdZOu1C8_p+Ihnu3pNlGH|h> z>Z2(;V(~P4&+~RQZ#g4vV1X!v*0cPs3@__v@BQKDJTN5f-V=8Znso#jV{8M+L4?kJ zjbA%=m{k$nwTAE(--UXbNh^E|qwZgASD%Z*QeoWeIdeWG!m;-tnNouKlZ2&^z_~IEBfc=c6Dy3nUm5Z-&c$o5C;tBc6TK+e_};G zXeWbwi7CcBMSfs#1)N+!fDMH$4aHt~r~&Z%O!?BGQH?j4n!)-H=4Oh)5A4gbkym*f z7gehJ8~w@Q(=aOezR2DzpU6EhlV#)TKF0JkF;y3PTB*~PG&C~6y*^b9)XX=Gk<>ib9E2*W5b!ijf1 zKozGSW2#zupgb4Zv>2=-W+xKn$zoXt;+)j&2}+MFjwTVRE}{djU*~!7HC0&l7vIcU zI#H*w{lJn|*2S+3&yR)J*H^6vO2bI1(i*+o*#=*tU!pFZ_|`mIf~a+owBEs@+{~ai5W#6n6@m zQ<%o{?wyvY>5FaEkb5K8PK^v5rfN4eOtnljsjl{EI^W_aF21{W1$HI^htMe8=agIV zTEAWMq2_G)_~K!Naxk93DAOQB%C6-mSFB(HWVc?uE_{1Nb(K%9z=buc4lkFdIS23w zwNH(4KYaL}zPTqHS(tl!*)bPYj!`F-(ncS5ciw1-%*(qbFZ6W$4O<;Uj*MCCxDkfY zy?eV4dy=VWKk_%-U}VYvH0Riu^+dq z;qZGJ?+QsF&>DJsSlJVPMj5OxD7=- zG`TMw$C}SWzYk%Tg1SX1bMLXsa3r3gYz6>@UcBF=%`fJn605asfxjS=B8L?6`N%jR zv_mLevyXIDE*?R&Cb(^i>kN*p7s>WwbiJ5riFYRO%w%W7-$Yd)AL|vChYok~*=bUx zfn4i&F;GrM#%IF2rcI82KlmI<7gQGB+pKx#!Gq%>IFamlDk1NYaqkV9YIU_gatKg_ zihLyu3kJWcpVeG+UTJ2(#F^FtyiZICpsZcZXc#v+I=^XrP&%X`Q>XgFrQUMM9EVFq zWMKGSb23YtIFfoncqB0=^Qu(S`%2nBFS}=Taoy(43lZ~h%i&f%cp8MGOvNnkVpE_` za__IH7b#3pRtY5xsDwx-ME0WLGq1wfT$y7Cc@=7>GKf#!>j+g!d9-)lMW=+`eihr* z2&_$~ks%{dEA(NP{{59?(m4F%Z)wUIdt1uMPuFjZebH2z@4&MsI zpJ+5V1|N`!LK}T(R}-*Cg=)RTeV)YSoqMXoflnz5Ts= z3P!8%MKzX&ZY9D)hrgQyJdZ(9X-Q_nuRZn_Br!-(unK32Zo=9Hq&EQz85vFI_Ht4xaz`;G>#Y}(IJ4L5%(NdVw>OF%OJ+om%l1T z>@j;Gmm-+L@Yc(h%afE6e%!aTvb3K&q6cHg*?9+l}cxx_2KkfA@hd zu_-*ieLFRXHiMs4fCP!j9J=)bt(G{LG5$1kp7s6&n*b`4Z+dEJN6*QNqkQKrR*4k( zi*^ye!!#Ryof!B2`8w(x?-9Ch16<HgFuX5q*6p6Uz;*|WKe`4Fr=hU$<@! zx_yWcAWor(;H6Lc8MM5_@N5Fd`}0_i1J^7rJ_9}`^7%wz_vyREohMID(UtrkmpNHE zX_C|~j4mP@n0@XUdGKi8gL(88E#HZ*h&psO_`&+(%LaLybo+M!fDr`!_jqSWo+uK?`O&O<55b! z6H^wVyGS0apOBrlY15_+D2bp1jPi&{6Eh9i=OL3rzdae*XdrdGA5;C264v;fcw{AD|0qDI z5cV0G4}mh+@oOq8cB}xlc3mI=(@p&c&io-N1gaNx+g+Rncpb-cvBLA50HiQZv}8tI zc%JIy$zvInepuxetv2k+BmFGdQA?+7+45zh?B7eSOKi)4g{LfBCPz8{5G8r}Y360m zd)s=aE6s*pl8yP7hKY?o!KDg;N7wg2myUi3){4-tvV zJfdR(0n0ZZ2!=PLk2=Ht^#G{1)Q*Q=^!uO(Vxps{yIDx@D0`54Fi)(E{Vtt~lCsod z@8kS@50DRKdQh|qf*AtaKydGDx7nc_O)#qDd}<-&b6_lc=&z}~J9Bwkq*Hxe!(GXA z)pLu3XCM?g1$vWQ>ytC%kSm!uf&~at+e{Md?9ex$9!?ehd-h0~ME~$D>zCqx!iWdh zwNsz-SFL{y257(?le(j(ee>Q_qh@C&F4PV~^MlE?=kF{du3IN-Q1Zcl{@l~o)K~Jj zd^?djg8hv&jHWTZzpeV=`Ux}x@gSk82zg%o)lxVx<0)?_0y+1B>BK-Tv{hq^EPV_T zc~f%IPl=f?pFith)crv@fVjq;^vtiLQk& zJ6lm@hM1%!2HdfDDLchfM6cnC)`IC0tE2F{McX!QnET#pH_|ByQxacu4@In#@S-tH zcA5Ri?-UylBR-tm`VpUyGcoX>ChYCyOP83Q)KS@R0T1BR@%J%G`~M%ia{SOIdehP_ ziu`h(I#WN1^Gw`XfYn%tRPkk_kU2+b1pFMtvtNMNZbI))RA~moR+BLy!dR(rR)O;( zl@p>(b}emS1}cYeD&C}SL55LCgIg=>&5SCt6xmaki+45o{P(swuw-$Ck%6v*!>{ok zerL{f>)$^p+*b3<`l9mfL}~i+4MU48DLlF>F8LVoiQK7N*%iBlLkz)+#Cy#0GyK*X z6Ibmphw{23Jx3oQO@{~0v0VB8Ij{y94ci^N{6;1(IbMCmjUAg%g8$0hMs4EwVaVqk z2hd^wZJgYqKLaEbI#Pz*&WI|~qnibaKE@dk;{q(OpMoEb1qfESYDt5V2nn|s=8L#$ zu8xif>veH)DK$eB!r3w3(z2pM*2N-AO0!M`jD4UGMsyiO>I`-UuaTPu{r6|JQ+cnQ zx{B38`KGL14Hiiyt;%plrcpkeBFm^P?$Nfv9Z_zG%nnfG@M(KmP!8!b=Zz%mi=RWM zpns+*xeJWjdN*=#L@JWk+($q~Um`#_f@af_Q6RTD3++MLT3{x{bIiS3a=yXO@&`7!}X{?;Bh5L!Zn+^(iV}JDeH6 zt}{pf?c(BJzGWP;!j2{~yD?O>9WZX3CqVCb=3w^?TikO-@*lhjjDg?f?D}t&a|AtL zOQHymzY6~MO$I&rArTT*u4dkZ*6~!MxXLdSc|xKIx%6p+ps&Y!zQ2zF!)gk=cEcQk~w<^KyPCEWdx= zq|p57w&vuV+x^%w@kF}?hh&d;^P5pu`lX@syTtR4Bjar2ogy58Mjw`MPwM9-j^^4^ zlTEj15Z?W;?r{D0}}~RJ?~&JW)@AF9bv6*2`ya964@WSKJCn z^MhUmA2NMkRkaY!Rovyvxz&qHREIALybWX2zK7!bog>68xT|HoVDE8o4)~?p5b&(Z z_vjGBxlXW>!V@7gL^;8=gr5{FA~l6fmoDjux#2L}wr)bL0UXzTv`KgNo>T?`vEw8C zSqY~mG?U6|7eRi5LH-@v?63G&3m`aQI`#1+DDdN8mMl*>k=6JXxkJYaK%i{cN>JZB z7>|8}6cXa4_(HKdo0W{?_md~64<`_wEqrpI4&6sxNOKK2dh}>*8B7?XcNTJj2VHLx zRUL4<;40;@XNVJAxXjpK0jzL0q}TrG!Nt1mHv-U~GBu~wmYx83##gL-btGvPShi-syjrxF+@F?> zF;fGYfrs1676!bMdViAEjT*~m-b2iKs9syAY@~2Fi1CQ)M&YUuwSlbhd5G9C$z&(e zaAaFPg)dWx+DJ6~w-|cc(^~`RVojn?qN@qfi}?0P2UXw+fB%l@^nlNprpF!5* z6)#n~?fkiO$4T)=vLXQ;Tm}E9Ehl`8_wT!N##)%DFX2m5X*{7cW(R0w%<(>XIcTb{nrsI}(UT+5X8n>}?R^IZvnE^@f{@lDZ+5TCX zPti|h=jZVT8$x%4rmp=EIdT82S!Fwy#zrg5Kbf%hNfp~tlJm<{&#Ho~0;gYgvqv}j zYBtuq-m~!3r|(u%w@W+!0Y_B1AC|45^U(Rvr8ku&LNmYHMKGk>Q4 zj^7#MUG0(;)bE|+7NvT#81GZdol+}r-T5TDVncv<2<%jnC(5K_dLd*0+$gs1Q6z_r zeF>~s7#<1~$etmpWS(0Iy&MEfSb`tk_*183Q4$nJ3C@nQN*GoZeU*O#yS?41HUDUu~&c&MUVo#nO*TXxcUFkRy- z%V=2Gv6vS=PnEEEO?>AA6LqzDovj1|2W7a_uH-~(yzje1H#8oOg`3T*XNA4N}09x zzhhd>+8OZ5_92J%u4|b{PyWdwNK&qcz`439`t0B zN7KEBM>K&-oo<88s`M@il>leI=D)<~VJg^y+-=Nx5V2d<9TJ}y=xyz`N_Ul{m1}9$ z)8x|@<1)X=eO=NQ)4&YMbDGkWM!oTk+3v% zm#Rs0&qR~xQ?oM}9SzUU*Y!H#0sa92 zK7Y0(U`yM}4HEGJxw-w$2fBNBgwuG__93yqLuZ#m!|9!3n-E3Ihydyc90VC%QX>9{ zZUFgK{tjVl6gmlqtiTsXuR(f<_+PpRKxy&W7~R?=5lK5pNHDa!Q&dvPt&v_?^EfVh z>GBmB#b@>nUt}|Q#38*OI~FaMJ$3q!Z=6j`(Xy-BrKTfC#`c1UoE?4s_#UaS&y~F% zCiMA}=9jI|e?{-Y3xTiRr0gg;S9tqi>kg|oTkTDXYu8E9DC=u&)UrQg{=RSd$e_{L zQ>J`mXc<#O?LOGDJ^8Nv;ULE?c=}9Ty`Mt>&jGy%x(v2^Z(NC}=khrug{!xAO7fFq zD6YBH_42c?EIn=9rEfpCO}fkWX6(H*HsNk!yn6>Z{m@+{n*+SIC`ozE80L@_=@Wf2 zoa9OhikIIX>quIP?b@=a-wuB)qZh75KF-ORZ|$*ybLC)1xfXIVz7TZT&yQt!^bZG5 z%uXbtQs5EP-FhgPO0OA4%?f?U-WQTQuvfo+F*;MTZr{Fy?Y&LMUT0ONDO2x2Y+{L! zi`tQr@?HNi!qwri8&NND1AljrUPFD3;l`~*42 z`eV4thSLT~uST0`mr0w16s_+y*;dWbIUpx3Jgpzc zFAh~>1GiH9qB2EVzV+F%;r$;*zIi8W(>X{rCsO{#p~H*L+P^IBSpK5xrrC=1Rp!IT zIu^=4HCDIsvX)hP<8`o8gz5F}?_@`1oS)luZ$`#?v+zkS@(r$&7W6I%Et=y{I_$*_ z*KI|L9V;I6(>EU+wmhx;iEHWe9a~tB$3`!EaKm<_WQ^&Jyi0P@`X8lFH>gjVb#z3U z{&GvjoU4_uU+IS@1=a4(OzbOH5`DB=b$>_orI$B!kSu6(?cw=(=BZT$^JASpG&T;e ze)jA^YEt8T6*F0-xV29<@4af75Q`(gAm*Is=zUaWon>TCG3ofVUHYizs|l%@wsKVNBK)@%2E$9Wf@-q?3k z>WNLv(jL*X=dbc|l^)cj>e9Y?q=s90Mf>#cub=0ZBp(Zt8P;-9+8(9m=c1psjt*no z^{qQZ3R(s4DTyOFun!v^hO`y}u9}SB>|m<3>V(C{78g+L1O z`2F21VSd2#dk>;V45<}t9iCWGiIG;_m4`p<1Zs@&em!?&zo!G8@hY+j7 z@!3hY9S>Vgz2vE{5k6wAu3KQVgJPM{=cUu#w@#ewR5~GiOi)JL-oY(R+pJvtU|N#q zC`Gr}GN=0$_oBX6ys%v(`{`#)+p}49{Vrd4zBj+vsm(MKhu)6rKes;kJwUR_?@HLb z=waC@aqZ$d*01_&ex}l@KdIl(g`FRFtzczJZCc>ZiCR-Sg?T$Els^6XR()G($7PKr zGdPJxS6q2rci^2)|N5;_w?xxa^TT1I-Zag7+#J@5V!#X9tSJIKFVR=hq;XR zGDx};eY9&=8Ljk}HuRU6|L)VJRzY5EG_D%+-$8X-g1%{m=WqS^&Dgnjz3Qv$ zPMNivrhMs;+&Vqr(iUga#FL&W*ApzWhpc}4?%A%_E3Z^c-dW@^UH-Z5qsJ#NU09#G ztE3}Z?+cwM{BE>An7qFDLtT#e}sTfYF;e&NgV6ibpXS5>R;N?qCWmUF-?-LDy! zqno}AoV|YgkhXaaUl%+{ZJ4@dh~bBxpC>!_+I{=IqiK?S`k~G*jlbPlYI!O)cXPG5 zwr^EQ*4zb)-i1#ed|_Fzva6E6Zl0>ag}o~l4_Gx$S7Q1tZF-7!(OA3Lza}>K%2MD7 zp$Ilafq`SJjr8N7CAH`yXg@Kx&GQ|BQpN&!2+`wyD959;8OlU!aI8t8a~X$qMK8O~ zdRSua8iN@62_aZvgw>s!-VPRCX%Ya^<29dWgcpp%mehNa)m=Jz8q`%}fMzEG zoXre>PYZhiVufegP>`~Se#liTh`Ku0 zbCBR|K~{w$uJi>pzZiHe#zC{)WItXbA&Q4}?}Iw87M-#1=a>2;9=mmGAMNNkm`66Y zc=dTVpiSx8#rR~TxnfXr_LY_SM^$1cKb_pA`>!v)M(fv%xHIaALfMsulmp)`Z(r-I z;U(>_`TB`_+E&fd)UD1h18pblu*&_cn{ar(+q>IgGS4Ct4E7l|)YW_Rl2u+?dUa&^ zlMgoSw^!$s*sZ8ttMh7H$4`^o7POu8rvK-rC2|Q*?tf7C`}y(vyx8zOi(v_Nw?c0j zr#h*sFZ=%fcknA2zh3jxU+%U#)!d`+%vzS%q7J@mOQHgR=rkWGEBi3k#Q^;rD6xnOiC< zRV*}rUi@g^x$_5NxE9X4j)H)8btc=8ahh|A^3suu&q~BlBH-RttEPh%p)c!<`2XYf z>)~zuXC#-8x_^J^vo5GF0{R1 z_~T8VXY2yAb7dT_t-DkB^iarw4(1y+A7l&oFl^Y6bsayaj4{i2@+#+C$r<4 z)-3$^{D;29(4D1^!v1o1%ea}@Ceg$_c>450%O0&So21;!$_|?!Jbyvy)zQn6?8`bo zw6SsU`efqXX5Ze{lCcx+Hl8azJpa@N(;VFmuAN7piF^1?Qn_rZ*-(GuWcvuM(4I?1 z8XRmlQnu!>zLs0ZerxYe-g(QT&&}yNCV#egBwhYC;mB$AU8mPtEj`rP-Q>&9XI`e# zsk1Ut9GCiPF1)wnLvfE^PK9l@nafyuk6DzG?V%qa*QaNQZc%Ul+OMTD;RpFwuu+cD z)knqxR(rdEQOhfY)rR&l{$-&T1fCe;9T)G=5E6kcMMFaaT6>}DNhTTt;GkqJj56{- zBD)u|)?nfSL-*_%?OhVVd(8~w1q(Eo{K36lJv1@X2`gV_dhA`hbwMl2j}(YpNzrT3Z<{E(2*id_yz0fF1!|!z|RPS(PULsb(NCj)oZN|z8bB$T4uF^l}VjVa`}e7 z8jh!{U7bDV4V39!xIVp6=66zWJD-Zuew!D4Ta_Xmu_Usaq$tN=xbh_BWwwYx+xN74 zdU8=ruQrqI>xWcNN%QbI(8F-)!)u1-d3Axo-pdtIKB=X>^34ugVdWQ|`!;3QSLMW! zj}5c8ZK%ko3ca!7f|c0MllzoJwtEeqreUiSUO?!y7Es5xV=90dNRy`8rcf$kLVYmh z$NK>v(kTDDmpodeJa+6#jsf2iqX@mBNXWg*>n~m2$sVKb;yShwunXYgkMzX zXVJa}CFSRMMfj7-G~PWH0fV;5z=0)(I}D9ubEE6s+C4sWx)*2aA@c+4s%6d6945Z- z=o`AgYj)qXEuO}MHS!Hd8T)KpXVASmN8`m`*R@>9WkA=VSsYtTK~z0OX{oA>ITjeLVfbje=KNI!S&n0RJg`Kd zQ7O-uQ;-q6C2l`+2BpW(dS6B6zO1K+Z;lxqe zp^?Y>rfxa8aDhpSE`F!yKL54AyT{E%N77E+cGEO^7_7TZZGvT9<(Dp3`r0Q)PEj7y zvx8TbW4`g33e5{vOYeA2Z2Q7yl>VOg-oL$-8b5sYZB{4LMEWu)W#dM_@_NpwiUo}= z(2Nn|x19OcuIozlL4Oft60cK=@K&T&N4TxZKM}AM-_Lwzs-wP0$Lx@=-&%9zY<3H# zvf(~U&&ykQZ{=D%_K0GbA4PTHi752R4tU8tn?EEP;YCYeAdw}m1bYpv+7T5YnAS|+FM0El`FU*hlXFrF?G;{aLUk%Uic76nF425&B&w%A+U&F}c` z1HK!24*d}0-By}Ce%3s%<+oq>-yPns%S_U7J>%4FX5_jZ`j}NVW6)sLEpKi5Y$)uV zayW0!hOnTlM=#u-%Nci&#Js$IKR){Twz_-izfVYSIy2$CLsIJrmfP~ zPuX$E&E3iJ&Q@p3C5dgQzr40Lt%_7(yPOd+$os2Q@J*|!-?qe#O*=cSB;iV!uECs+ z?Yj?99%C3gs!z_KnZu`=mCE-FeRZ((^Zc`h5zjuHMyHEI zfNgZ(;BC)BooxD}0UC$H_5%|=7Fp;yKHFMZS*c1}MH3eS;|vb0o@iibHig)9^jg8F z{s>c+K`3}G^%&xDRaH4M`KZ@Wm<~~{t*LlG4vY<1J9kf8Id1PS=QwsKgg`@mnuftkc=2bk=M$^HKU4uJjd}E_@XSt}=_vUte zardETn9HWndC{L^L-HeEtUh_t|AvG6i5*wgPwWkQka?heY5DW?dq4Fbgq^1XlN#vh zIqK;pxhYX0g(FrhJp40uU(?p%qx_?!_I#dFHEUmmbV1|eC_^8ub$MH=BKtqIsk!*{ zivBZMxwHG-Ck|Ko-6iPs?U=!HRI7K6UN)|X9~`_c@6EP?pmXonG(P?(>2LHPJEi_!K?PqT&ooD z)T^>fKlxi9TDIL_SXMr&bA6b9O3N!JXHB*#RhCT5IazitNczb>>upM<8aI-UtzPx( zOYo5m1I;s5ha7S{dT02TVSdt03$|*v|2e)SduQb01M0)o4=>Pp{cYlkg}>`7io*0F z*0VzAj0wCGF~et9-Q{K$%=#+0?-07Qx=8RM&o3N+PEb9NF#pdm{Vd>U1! zlom6cqP#ppN7A3{H@~Sb9u1+G>GW$pU(|uGLg!s%1U-YN-+Lv zX*Svpa`BGPtcEDZxA8r5hI2Ow3<4QeXg0`%F^e|2V1{{Pj|;~F4s)0p0~ubdyn}Bxjt$Jiaivh z_5M2Cap0g2H67v>8u)*iTs58YzHw)RszcR?M8)@&c6rwiP25*3z4~R9t&Yjm!yzAL zCD@!2+-1ou>p!hfttDID|5SM1EHScU;G})yzB-4^iIR?3tmm?FMVf;_OsiIJO-KKh zS#InTr5gndH_O+pW1Fzb$~dKySIdKps<&o+ZD_qV#^l!P4dYqA%5M@Hv$OAiu(j0Z z#5|baa5!SV(Sz8^aP8zDcP{pNmN4b(W9yaa@2kg+Q=IwJLvu-X)sr9kQPJH`ieb-GqKlMDkc1QvGLK~7`&&9ZCH$vnDf$>W=f-BFSbmH>5vhSK zqaVy!y{M(8JXdhYa1NcW=nT;)fxQQk5>}Av zS;bWcR}vM3qe8ki$x7z-P2(4;>9J_ z!$$1yqS{?XMytLyzeHbUeCrl5BVDSRUM|V}Q9WPP)#%2PLccYQL49m4#oY{hR`C1A z5tRbX;fuC4WX#I!^wBT+)8Ojc$Nd{De^fc>u?&9d>(%{v-LLPFXSJ&;U4Iw!k)0TN zsXpQ7+~2WH&MpOR3+5%pJ6Ab1-(6K3(o1(+Rr>rsWEf>ItdlS*3VS7RXYHs=mE{od zNIuAIP#5&{1(&8q0SK~}ssAkYe%d`_%=?87pH!AFu>x2=wvG1sbw{MIdFyyrLlTpDlY}#H|X&RAs zt84Rzn{nvLiLj)hL5aWJN}r#U=_)hb-nI6dRnhpgTiwLp+c@`ciK}O<_VeO|9l7rt zI%|pR%Ac7zCXOsRzay;pA62K>wn&D~Fc0c-!!dp)zPqgK9DI8#J_ZigpVnbupi-LN zv~?e5=}l`ob*@<%dpqj5ki{M<^XmQ}KYZMSHB(xDbD7tEJum;Z^4!Vku5@4nT#TBn z8vnc}Z#*@~B=q6?ogIM+O>1}hh)>e|ahm^e`)dS-%L0@xup0I9NBMCtc3H%C1XBt6 z|DT_}gZSfLac=VYVdhP8i6#NB;`qY)g_aXdq9>)@`p*~MZ#Q^ELTc;w-H&&V;+h?q z9U6@8=l{{O-tMDi{VWzk4wVY7QRA+o_TQDYOvF^C#l)Fywx!j=c5&oLc4F98$XPen=cf;gN6s5S&lI4 zL`x5E^MADfn+^V|dHn3U?MSeOTaO+or)k-j;Zzs3clIeIc){ej_rLviqIM%GaVVgB zz5EgJ6-%b0j-}3eNVHf(=hA?|f71QEs;x^-#9bDDymiLO`t}2FkHhVpgQV`f_fd=}fIXZ8n>&7>jI6 zNc1dYR%u@c!bI25s>EL+=wNp__<{oU#}5pK zk4Rjj)93l6!<`a4<@B|>pYuph=4ek#XYWf!o9<5P)4r?y#8DNt4<64L7iJRQpuN`m z#01N1WgoR!ZAUKr{^y;3zV)2&Z41+8#yjJ5i- zc0Ish9L6cmYJ#y2_j?s)O62dC6 zfpdk#h)E*o3}0E>+D;TYU#4IWQ+oOMahaBZK@Sm&hDGTnpAdMlm!|Vng2tX-Y>p_a z>>>Mm=%7buuV3#C&L}eC!5v2?SCyByNM za*}E2c$rD-^W*558&xRmwuMdHUzn(Z5{h(0%p|v|NgiOO5e5)uZ7k~W$X zt|THIfyYS3>^YN_xF|Iw<7F@&p*-VgG>BPeARy_wK0m^8{KF3N4R6~yaE zOC}?{J*TH2=DQHoAf~mFQ1)QUW)b9y>*yxOwL~H^AlSVU9Ug^VNkN5nYray-e)ceo zihVE4yCQzJ>8DGRE2il_fKIPX?g0#b(|m_QWAe5S9a(Gxl6T<1heB$eO3gSO@`uC> z3duAvECq^?FVd20af`k3A+s%}-MXg89bi!)v6n~)+8)Hf6Iz7b2Wu=yK*uG;P@1YL zvLSk`4mY9k5s@+6QXey;CuZ2F8c;;cX6Ha!aZrqVhUSQV{KP|#Dad?dG^dw)yOL(r3=>G=fZ7HsddVxaOQ%RK2$dK)UcP zP>c-m_Wny&L-HE;wIFr@LlHx^7g4s0VgYzpa!*ccx@e$G6zMtuDLCy#f62}&5|ITd zjhZ~TK`d3gn(*Gn=Osp@|q3gPV9&`;X*F zi|H;*nWppA+Ogxx@v*V?@x617ai$>M8MCZKV6TaR!D}hK7WU#X}*6$>5*h0dFJ0MJWU8au67tI}QZHWtN)o z^@U-0e0(=DP`S2R^seB33`Co~#K@?ngk#y2HxL1DSjlFfnLGOV6cIun=Usu}Ldw%x zFj#Ap<*;aZXNzGA#5e2$LY%Q$m!X9WZL&`J-V>bWAuSC-Y0ICUk9eoJ;9ZW9nSE;z zLa}yHmLGv8j;<&wED=+3(3me}jt#O*Asr_MYJk?Vmobx>{ zf~B6&IHzSzVKzuj*WLZLLCX`npppkkAtR6>BDw5ZDc0CgPAjn z-!`vytUnplH>5PKQn_B?{CBk?Qn;d!wVHw+#P-NYJOaFwT6AuoF2j(XQZ3@nRPD#ViYR^Ollcs#}L3 zQwX(vd0#v`A|exC_Jdz`sKi<^D1g;Z(&t&G%5Xw=%bqpJbBoOf^qkwb6N5Uu!R%)l zRNZP8pB>|eq$DT_85yK02)2sFwi+ydF_jsoiMY`OW|&p))!EpKJP&bq+2B_(_Pp-x zQKbj4Hft;_f6CXAF zj>xJE+<|6Gu(0CTZ`pF6L#dCCPaOh7pTPIY9Or;iu0YfM)zs9s5bZ=+!D%i{R`W2A zs0r>$wzjt76k?JWmv(y8nutl2(Uq>n#%J1e?t6&oL1D&zCbxi=g{wiu z+>e)`?^L@koL&c?U$*tMTxo(hl{>QM z(Hi!BlBUJLBG&nBmLy-p13ij}aph@{vfSK7jGE(|F&Vv97;Quqgf*hag`^FlK;e;w znErd&u5NMq&|LT8h|_)a{Dwr$ALiAr;DppPSOR88wk*_9x4=a0J#lSh@tjZ5eow7B z+g3VVb|S~=oBV%V>>_w0{ zZB$Pcd(*J{b@2)l5@kKymQC6Iad%{7s^Eg_tKU=DNc`4*LJtd+V^Q^KI|{ zwnhazK(R4EK(Vk80~?!=5)c>+98j?n127O{1OtnZ1~C8&M6pI4lTuI+72{S!QG@}J z-)rIQv(Gtmet$pD^<2m6+B35ay!poZto5#yxB7iAzrGlX_v~(VqRNx@Q@oVqxyeGa zhNTy5bmy>R2vx3kpC#sYajiSaIXT~#P4CSU`#^ikahQq;@-o#_J7!&A3xoH$Sz)Yr zUfTRQ&vM#*E}41spsro(sP~>VFZ0`QH$MFq1nQ1Z>=J$&{@wKizi~8oF_n(1;8wO4 z3ysBc>79>v%?DaDlwD;+9cZ)C<@XCTj}%y&?zbB^ZcLZlxS))1Jx7%hQ{E)8p>gw@ zq@i33Nc^EXy1KO#ty{O2<*8Y zSxh_2cW>V|$PJ!TRA6(rt;4{Glxcgp(e%}_GK@m$dTMYO_GGs8T5f*>Zd-co8|&!Q z&?|c%+D?uteQg)LQ5hKrEcMi+c}C*e%AP%V@@xkcJk4S`gHz*S9_|Ys$v(1mSm@$q zeM->SwNj8Xat_EwMn|+?qX5xxQEmfxll4=Q*{!%ta4(r?vIT?+18G)2%&qe4yUk2y zvKNJ>21i(|B|<>YA{JbJVviIc!x8yK4v2I||G z0(X*qkI+pzW>)lBDz&IE=H}+l;d8TwHaJT!2M#DL!iyJ9JQ)Q=XY<_?ofn+I;og}U zgSJz8CcwPUD9K0bslCK*Hbz`7B;c_*1D+2O#yVzxQe158B4tKTjyFm7<}GOU@p$%9 z_Yq(I>@WFHuRMkjOSA!J{p!;9@7|5-u{Ss>J^Q?< zsPV4AZ>4mKsFrJEr^5(qJYz<4n0K3ZZv)?WtJ_~*a^GkRKs-QKSM~wHW8J{x+;m>s zFVUvCO#$lJ7JU;&kkCr6H%HerVVGdkL>;#7s5wR8=VF{{Qu?h+i*00#NB9rx2xCdm zWo?#kQ1G#iSRU9k%j~~AKa7pE-FZ~9Qz0-)Z zsml{1dOp^Q6Y`}lk$v>r67)%(Z;;E?24!l2iA4RVedci}3b3kc2=hfB*{Dq)1@oCJ z4^nTktfaFeSWC_jF1sv8cRb?ECq6uERSQ`r#T0|eAjBdCTd~#YUmIec+bSI^U(|TS zyrE~2o|A%Hz*1cN3-xFbPFPkGw!#Ij^>a3^WuShAZc^Mzh!eWd3^5R;4McG*{vn17 zIk5)~I;-|}6tif^w}_8}KgkYg+;wt(sT3-2SjdRSdDoYyGO;r;-dfu1oq6bUA@kWO zR8}I&N^5-h?)>7tw;A|l=MWkyFKaf(Hs?PiudckZ@+uqnxJi=i?w{0wH2~ma1(NCd~&8hAQv-b-KUL^qYd|Y}nU#;b^o6Oky(X za{@Snry7rlG(0PD>f9m;bm5WP!W4cxY#kc04Ycdr_;D-m66FW|ZFO>XE&1yBALn{=F(S5?zhc+ z-zksU7KR@fbRUmI` z;O1#R<9_9IsrixEa`HcZ=5X!6tw&Yv9^qf|BQnfjg~~kc6!$9Y@=s8v<`-)Ik<+k8 z4NUXDKO!jjd6m_M%2SbpXJ$kNsA-P>o^uBO-or`y@Z znZIU7JW{HJm1~Y1IYO(E|Dv>JWBJ3q-8mO{`|toIO83u6WSnSf zYe!yxFZ&~T-mb8PJaGXnZyNJJmpv0?zNTvm^Pp(TJKLa9quBiqWEBAZRfPiNc6Dr# zYs<4gzx8Y<6WtVlrZchkK`P72Enav<#^h{J zqk*$X@h7`|BLZ{gCGR2*Oj(M>L6VFIOm?cm!S(W=J7)ZRi;LTggumVILhJ-FwmC{)fMW) zzkeDXEK|3!PJk?QeEaO;py8Dt`g6k~?(Y1#4PN6$&!BBv10_;fh9e>22bAW}jqL0) z+NbldVeQ4zNvb%9wn9NmxF9QbBWMbjSMqYigl|%MsDDj=}0uXw6?4%t$KZ_7#fdQ~dXSTi2>=>^i zj-EMzHNUk{*75t3Zhfw~F?;4n-7`fBP;SoVY^q%CUjE*+lU15MPU$n z@?>P*3V)T{nrF26{1ULPG>SI;aQWNN zU2>KwHdyK1pt3e+4Fyy1|(vDnJd(M zp{ztb_VwD$2rNON8fR;BBWR4AH z8L_pJfi`F_gFxqiKzO?TKG0_P%06=5o*C?#;Eka(Rb$#`-X1w>)W&941wG@%9kpuJ zkm*ahE&vh|OhBo0-8x2-;+8bL{m*Zzww!N)BN;L%4a8yTipX2?BWaHm8z8oxL%@G3 zDcOWF0Ho|B1d(oYzz`)@eKR=@iO+<)mue|YN zGQx4L`(Y$YT-LwlCEQw<*-Wv4!8&o7+2232mcjl%|A;&?`~QNU$%sC2!rip(4Cha? zah&MK%4X5&q+9k%bJEhPoV(vlw&UQn|w{u9|pM|3SY0q zx0)Cb{(VjMEG98|q+n=JaB3tLVfRK#8+tl6@x}-QBi{T1j;DmY@F6c-K`M~<89FgH zYZ>-*wRgKXb@8+(J}FHI!*3~j(_E~deeQ15F>cwE;9f==Uy_#3dhmF`IP}W0;>qO+ft`Jb_ZpY^hVvh|dED8q9PUuPetA0+(rv0@rlbJO$_aq| z@zIOQg?dNla^I%xXA3uIG>E!n(fH+LG0rfJ{Fay$B};}mX4!3(YHw}3>&mfKffd2M zUY_SPl{_@3Q|Iih&2H?!+qV8dgBx9n5@uGrnk_VQ%F9aoLYdR><@?j@1^kz|tmTHl z*;y21Kq^XsU(RwwGdPIkFDZF6Nndsd?pk&%qaS1c7|lJmmxROraZRssdgdN?%UBP= z@Pd7C5Sz_-Dicw9Z{WO3a{mP&Q;Hfdc+6p=aUJ^i|Ejv$?_XMg8g?=+YoNt7r@riE z4~~c^vnrd^OE#Lxdhpx*cIroU@AJAlccBC2FXIHCm1^Bs)K5$DZu z)2L5t7{CF;rr(5YEPI^mu%GQsV>CxWr7?6Vr_}18u3n!(jPy)r$`yb_tmXJgQ>`f< ze&B(j@BwDwldmv{vNqm!p>m)_`beHwUIsxASO&zpUiMI$6yN&uPhn07hByhh8<$&( za){{g#t@Z~zQ-;VP5)bk9>o9EgYgmSh%7^I3UfaR%j3gerT6j=-(=|VYU`r`%Q?>3 zO!c6`_|W7YR{{5kyV}BCRmq*N+#-wR$)KWmgRT*P5IS8A=#SCz1u@$;nR-c&`b&n2{DeELL0h~D?s_*Tr}L{RfD{BWp)8BsntIW1rS`?(ikL% zq3}7F6XXI^iJA7KY16jw5|Jh=t1furW7OVoV|GhArov zhYz>cHY*#F72Zk1)XvTi$Za+!-~l4a1tbzu#b8=Hu6x%(gVec)yurp!P9Z1VMjso? zCS{xem-F*G;RO;Hok3Ez&ek@V)&ZcW3nZdkSGGHITt!=~S&$k5*d;F{!Dn$vn|*#* zkUGLY7TN)9`0&*qEr0`-&0Tz2SV#ZxNq^qDwUGu~ZWy^VVWsETMqGE{!iB`)kO+Nd zM*6D-t_#glrSsG9D0wxJ*g#8Iv3oXmhE9MJ(JgYrA?{UqmgGR@nkW=Z|1=?xfh}2t0n`|oRTNisG?0bs zunz&3?qtl!P7Q>4UX3g5cGW3}*lc<6~yyA0bhsj3rR+$XOzcV7tim~D{FE2Z3 z?DR)H-3_ZIBtDrPBkAN%(V_=W)(HrqA-sfxlO5LN1jbcd$;;~q81H4JH;#s%eHQ`Bs{5Wl>frg6 zg~gQMmdd+uaXbXg+qZ8NWzMQXmG;BeTFuWzz~#j8rxP)G^xd()6D5*HQ|0m(Fsgr6 zF08tc!Kf%WS~ue1dCmt>zwNN9ur&uw5apgVsJa~H-lt<@^}so0`ywx0WOQ^Iq8#rM z)K4t{VyH^&gSY~Zov2(PPscy_KHGDh=Wwaf7&)OJ63qp+m(F`8D0&U|#ln67Ul~vm zY7|X4-WPl!<5dU^SwJNm4mmN_?@P~wnf8>GF+#)(ZzeU5-yfI6Q=^1HP0jiK4A z>YHuwcR*ZOT_|%cm6+n#k(jHvtd2n1bNwjPR{m81ORlvTfkQi|PWdfg7YS7}R4Se=V@)7PtzcjCC`#T5J>U+wp_m z{yV7Ha-Lr}x9b8l1ZR)%)6}#sg9lr+v5b0Of68JB36zyfd_GB1wZinb=!_C(vD9Exw);1!qIym6A@@Zp!PM0i^G>9d|)*h-K-a`9zPMNY5TiyzG%s_!vcl3Vg@5Fx=7#oN80Yh%_A(?6 z2hK)SZI~m?;*83yb3<4deN>Y>KbUh5bzKu~R<`|`xQPoYWUD6I1Fd|CzSJ=(oc)vy z(7@altFkNC6sK(AQ8niM>bW(p^32b{R!9U0gAl4^cz8G@7rK%~cz0IESHBriEm3ng zYGps9WPGrYj8SS{AIEZS)_cBHDvjY_T{JV12^|=^8vO)~hZkr5?Zhib*L)lTgi=e9 zxAw~v$GZjW&1nFia2>tHbPEe_o-J8@Nn0iY73SaFz$)a2Xp))7;{hAAiP$zIO}ZKS zYQA7VLTtz`M>z<=K(nggG0h9wYmgWWRTO41f(fpRm1X95x~q=Dbt}rVQ-64~P(oDQ zqa}ZxEx4j>!C`t`YT-g#G)C;R9r*Y)kKHdVN$<5ETm7RdApBd)ZDT~225{`SV1cTv zx;i?{T00wpX=fsYLE7S4vIW-5Fa&kebS6#vl+_IjN!S>}(hZGamKq^bMhtibydoX1 zEUFCZam9^(mPMJWk?)Eb2)c6IGT65MsFMBFx|Q$sx9r=(7ALIv(&9Y3cgFV_IK9@N zJ$v>h+Ne{D3XBueQ?l1*9~r(*gLii1UAyNp5%A95aCVFIG{}sJ@=W~eA|IgfaG#~~bLRBGtnXd+1EdPQSW2Zo^t5TB z`8ut|tJg5xM`Ny)89M$_g^pr2qdBpFQ$v-wbvDMwFAR;aojEpmdSr*WF}|t{LNvKr zJ1je0rnDu$qxoHtiYj+6-RQnNkaJ{eA8B3yaqPB>)JuYriE* z!-4dlPMoBJF_=ge5=f73z&~oo$DBl2cl#Of%YAIpBgDxE1XY!*qYcTzGAC8UTiKGv?3fTKTV1COn zdF*6H8$+LVgQ~gX@Zt7hS`LR?c?xtQx+`II*vGJga@Ivlt3DS~bTY8O57~S%r&wLF z`s>>+-=7ALu8-_tRJRsUA3`svA5{Lb?%#;jXJ!kO!0NQrt!}fTz+2T#IDD5~Q?+{R znZRb7MwDkf;ST2pr$2Un$Z_@Yp8JUl>dQ&J>p6La+s_^FvOG|>DAptzfiF?Gv!m_l zC;7aOb8@2fs#&oE?qXV8%2TX@?j()F2DI9bV(3~rzy$b^#=ka;e`M)1wC!(umVF3J zZ;Id&A4jvJr#*pu1-kV^-G$(!Vf>)7Ggf84s0c$J5YSM)K*O}sPQ7*OFpCf~+d!q; z$5s0{emMDJl7p;PJe$8~LbtU|1NQB6Vvn-PwAPu4WTGU0N>jDEu(T&HJq-gDF>`CE z`+?}zZQPFabw~XT2vhWR^6-bjdz(b>Y}a5=oWtziB5qP z^JQNm!(q3b>579emZy8% zXBV@|QRyq&7`*YC+#_a?q2-bhzROzfqrtIxmf(SdP#StlK>F$tIS08^qmBp zUL~(i4UNt*D@!wbHb0U!)ayqPFGm71Wb}tn))r0+Y$S zgRZ&juwk=Srkp=^{lXhN-~%!~y?p03j;cBllWQMkp7Wvfl0jzf(=U3G_^mbdH}gY0 zjEgww9aXN-X~!bZMXoR&ZW=J6&yv@ZdG-?@mvt5?Cv|GeqsM0`+K638PDzXXmOnIO za!SSE$aZoT43Grf!-o%hX}z{_s+c?Flo{u*NH!%$@xD=6d+ew0dksl{lyQA^qOxNR z=zMJo6I#H%`UfZXc>35C$KZg?)1S2o$|A{8p6us@#F;F}PYOWDN=@h#dC(#p^#aAa-Ws&hP>z3m(mxJ9m(4^oQ^a zu3c>K0n3)l`u@7aiG{Wy;Ub>O=|wM`ObnN7ERg1aMz*WA_Jj%Hg4q9ChY@6dn;Dd^~^r6)|Ilzw+HXk!kR2Y$D${Gnb%gm5mPcDbff> zMm=TEeCNHl{ChVWU`ZvDr6Y)HKo=b8eNoyszb8NoN}|7aBFlbX*Q~#ufkv2^m>}uZ zjLkHg#1e73lABCEn^2u3Ot4)*EB$Yo%83=Hw3y&Hye09IwxO@bs|i7Kdg-%i3dlYf zc!>iQ9ASR0s0Tq&(ZN})P7x&S{;`FhZj}KQ2E2Cza*DM&RhzHFu(gS*`pRQfO5KyJ z@EtX(mdI)f+`jCq`1#=Y*M)3)ESZ_#m<(vuaJ*IWi$$&`-(Ib%wBA$d1P)PRuMH-x4Rp z;UPs&D^i!0J$cY3c?eZrYe&mz$A{YCMMfYcDoar;io_C=&)@>ZalrXqG@Jkrp+$f#10P8 z+ZSi7+nHEY-y*|rORdi6uMn~0l96T(k-67EgR(Pi7%w?&(MG<+mENYW)0mXuYrh6= zVmFn{kPQ1$0;v8b(=IL;ZBZQ^W10KI!a_geT5@2dZ%O18t~2cu3Xnnm_q-$J()oN&Q`J`(q%T-ddCemZ+@Ujs1v%ZckPlZIE`#ve zeehsIuqQNI0(=@<7yU)}?{)?X(G@ofuYU$WjUp)EBzGm?{qKQ9Iw0liGicDlJxN)B z@5uMXF9A&U{#j2WXzYIp_`%gRhb0;U0m)W9>Drbq9pmcinquI~j$0PjDD9HV5te}; zg?Ze4f+CH!Ec4LvCI)63R;^hh3lU@yILTW`Rc0=%NCcvA0*)@HOZ4`w&H2C?eHFe$ zwvEYoPMZrUxc%fa5C$RJu$kz_tqT9RBsQuOTBcpt93qqdy8I6b%ulsc@291xutbU{ z-!H-%T4mf011ir9CP??!xpQBlw!RLiBwGo>JfP!~tG?xeP>o!=G~v?)urTyv95BKh zhh5Uw*1imt&LW`gzm(-pm&f4{rFr$P_Cq#_IVWa#6wq%|uFBx`-v{%@b{{#?>w_7R zStx9zR9@r`!`M5zx%m}es+=0Nle>=GzBURb=)}KY*tmb|tKA!t>Jq6v2e5e)5CJp6-dkQJpllJJYl_raXv<6}>$DX4I#Ia&k=iu==( zxyOVN;T;9xJ)RR0p(t2E_@DSNQ9s7Lw1z_3lB0guk$_zIGAzf@XO!OnOkg;lAAPE8q*F=Rn$! zQ8_v4LilV3V^H$hOz8rciA3UXu0jH+MYATy$D-6|0Y!2S&y7}EsDE({>Ftxzwp#hOo=XPe(^*!_WoyTNt@Rz(RAk3_o8)FMKFTi$4mc z_!v>LZ`K;3lhJa-?C{LVE$;_YCWJj<;vq{%FR}F?DjHdf%`mk_Dk>tbzy!t1M2MWjpBQ zDN&nj*)raCP@Q=Zec9w)wNQLKU(KHjY0r;u;Y4`3mfe1lGz4C)wdKQp7ySy^8 z?oPi2;(1%+}>TU!_gJ z0g&IrO+H*m#4eCDl<7gI^%&Ga4dv9HaP5wB#!1fDKMxIRO7ghQoeu5WOJj30uebco z%Lb=)*Idnk8$b+WP~4#|h}>^5T6aUUvD?zK6{XBde9 zF((Ba2P3a67B6RP_sZ^cq@;`!h6W_97uXQ1qw%`}VmG z`vE=ioDk*BlY6wZujC|L%<-*J(H5ANs7TSSkiZDIO5P0pCcO(Ruay^$RNbRe&v< zg`J|qwfE6DP3XoNI&k$}k6JyDL5+dUODV88h3Jk2a>hagolI!$4qBG zt^kzvV%!2N2sZlfEM;x<5KF%Uv?BQ22&V=yrR(p8@ey=X?VIgc`x+-D!W8+Gc#{yD z45}Q=6U_c8ZyZ2uC3sGbs>M)CW&rR9uOFh8^*S9Lt%KYO*QX@HZr!y@{Wjkkz3DF` z{gGEOdH(8U7G~)ayR|4->3aVRg4QeiRTrx4-2~hF6QX$ zY*uWQS>s^kHjASTdn|I?O!sgvG6X{+@_8J@Au68x3EyaJODDmPidnk5W*lPAtn6r#LQOL=|_3J1+3z>c|Bl^oWtBgz-yB`A0ykP|G1m^FD*dR{Bw)zh+kh0N){Zf zF3q~3^6lFIOiU5(*y_>Xeug5)RX7CWanBjSrLTlFx*ep%mc@}5l+ z)lZHn9t)t^efUanm5C+_8O>M%${EGmdwFZ~u3oAwY4U0Ou2EMu2C6mya%{s=CWXMT ztXLYAy1ojAr!KSx$mpz<#dy#^^Ui7l7hx?-gBVm6jeuHXl!4@lv?K_&wR8r6Y8#jx zZaBXKLKJqOKvRqa0x~dFDlXG`E@I@94TP8h{AvCH)@+t~a_&s;A8>tI@{BQj_UFf{>K!WFIdnEfsb_wD|=!=0E;! zKKV_=WipXKMJg_XNXm|JL5G+)Oa__H^Y|Yfu0gN+w{F=I_h7k#){}ZZioNIw(Ux{= zW_%e`43_}OJ{$cg(W-H|lR0}c0^yDYApsPq!)Ln2l%1HvOWp)Y3{8n_N)(YPlhsJy z#Yy-x$XAxpO*C(S-f2mxBCKfCb`xF;5r5GBV;P#BbN0eH5#fOXUxyJf@BJ=#JEsjp zTK8`6e-u~$Xo?aa6k-(qC{8KapA8y;Ho=j0&t#j?ysbyfoBLj8ZNp`1pvX&Mwxu-H zOW0a!a|hv7^JdLhjyKKA%j-GszR9*K_Fc(<%)hq48=u)geKuKd@S&#B zw$D}$#*R(u#HQ3#&y>~wBhMN#r_=j?uyd{5lTHMn9>&yHUS8f{Qe$2X5&47F8_%1k z4Lt8no2ifkW&|wW5KQ`OQDyb175R=cW>r>S&ew|xYS6%>mEPx^JJ(w@|81+~ z0H4R3BO|T)Y^vAHAlp9VhVhsgeYczV&pv-@jNXRXn%kO>*{0Dtpn=tlT6^s4+C4A3 zss8%rFG&w(Jxg}DzgO#Y|23~a{CV);mxK5JJgA0MOZ{kh24ex^SF<*4wxTEa71t*g zw8DMJ$UM=-?5`Y#cOKQ}0dx__dj0L|vz+oeBAmXEDq{Ww!fx&}-j}k&b@etZ@7|&z zjL$>3u!2fdyF%uZKOXzFp8@Ml^Zv5^mJhnZmYZ>uIv7G<>f_ZXPplmeQngVz^(sN$i62M0kUDK_vnYvQyjUxP=}xa8t*=V=YE%Ze#vF+s0Fo{aCb?)V}j z?NFR=-1W^JL@1&V4KAMNtt=nx`Q09|wp1Eqv0s&$lXRkvM;?2|H#p$8$IZwN8H-zp zHB%d~M%7jfulj0+R#x89m4B6V=iDOqQ7^MNKwVvou4rm-wQPL!J_zt)-m>#I+6VvLB#X8X6i@fCrBr)i!ZbbGa3>@ZE!VB~Fsd z5uy~>IK%#lZyjb*K8ufOekHnm?qn?-S|a+#D4+K1Cd9~F;zcAxf`E$A=N5%dfY$|+ zYE-{|ec>w+ibsE%TAiv@XmSKyx;)%m8<4`=cCov!(~9GiFvh?LD%m?gmV%42*{iP0 zSPZphQLp0rA@M$in_c1&^!2Bmd)#FgA_3l;JL~K(bOgY<-8jJK_lO{HD!d)~rG`IB z<2@(d&1N-wRKH2Z>r)B!7qu=zMA?EwGunI5tYuG%v&f-=*-OiJIhN7e)_vT#U&JOw zGK}{3q5z9;c5`_>{3@O5F2NQPz&_%>u3d{Ns<^Hu20Arp5gS4j9nt z_u#Nk;sM?8G&&zs*~efeA%Aqz*^&!nJQoop)RZzNZtA5Fa_~l$GAo$u=EIl(vD2ed?oy z7qd{`vZ04&qUG|(r}yhSsN5Qir8i2UtEDx*doslL zuP&fT|B#Rn|4Ww_7v~wF!u*B)+$Sk%dDxZ*l<F;gDQArCr4KHf`F7YqT6ufh-#q z9_m0=XM6usrQwwN2D%4P;UJBy`HMOD>1^-hyGqC00Mfk79Wq#TQlewA61 zcd>3}Qq!YY68Qu3=srJK(&9CxT-`*raf+Ns`VbW6V-DD7u3rvsDJEpCX4S*LpV4HC zfh1I%LLVE};z<~o`)BC!=YR%S_crYA(|^E#Yoy3~Et`Q07!Fm9QV|esX<^j~;EZ(mS!p=H1SGeMgmR3w=6jR z{j{5I_wHl2zIpX9JA1sT>Fx z(#iDEwRMm}vtlEvXwx-9J0Ok~2Q)_>Z&=5Xw>p$TKML*1&-PsQ5Fzwzio>}zWxXLh zc(%78&WfrlhYW3EeF2j;55|z+t$X)|rLrr7b}XPt?@9X2M5BXn-c-F|t9Mvz;F7me zKqhtn0}=FM_Bx@a<_v9%LutL%)v5F6&6|z(zMOZ=asGVk_jgd<2;{t2+FsCq8FmoC zm`7x7%N8Lce4Z@TZ9UwAOoTnVcXtlRuy}=E zmft@XmSyVEVGVKTL=_x!VTIf^zQU&$*HuXfegT>;HmT{4UbEBV^Q&7e=iVK^+SQNatEzM3my>@NPFwYWki^;P{MtW67e(t~vCOaX^rk8wtq15&}!Y5^L+Qmep$p%&QbN+$x zzg4I7b1Q^pn=pCu7|=+f{T%1a3IAkBHtT4`6SRw z)UwX{-}HM8LTC+5Oa%qMx3l4$2l2mE*w#S4XR~rv{9TStPMaLB`UD(W2+PBo=^G3|{=L`me>jY83c7Ic;5M4+o~1?< zYfdE+Dmx&yzRXkT{p|r@vaBxUCBDf#*B6SuU=1Z~3!WE=V*yo{@@#tbbHF}B#!fwl zOYO0y7xwysvEVu4J3`mb(0v1-zgG7O3t$TAWcX(Kf+Zoc z6W4n5hv=_ow59}l!4$|xLZ-6F!vjyeW!S>iSjzkHvTY-rP}d-p+7D&H%c@m+p*bxF zp+0S7mZYF!WY;ep{xZirz$#uQ1DOsvF4CSnY0JXj%fR%l*H6<^yGpkOwzr8^OVlG= z2%!FCzG%_I9uP;+o&Tg3$vlTKR7OsLSifN1 zn!n9nZ~x=J8vJ)h*o1X`Y6)2I?;po|{PbW^(*FKo`VZ@P)IYr78UFzNT{npx=Rf~2 z5RdPF{;?-efT;`{gaFGwKHbJk(%I1P;?u8yCyuBIVc18VpD)!BJ!C;qk>86KFIJd4 z*L=weEjg!fK!{Gn|4_KL+8*_r918rH-`ghP?E(wSOtdz+!o zduTv;#w9f)Bi^AC(bUbtdu|{5>R+y74S?&lv3^_g*3h1oBipmEYy9(CP=0DDMj}Ja zwCGavGc6W1_-VTTk8Ab+bD?-_{^Q4lDgVds+kbo({^Nf?&cC1QpHKDwgIDKNv@X1q z!Ap=&Bu$$>okgb0V$b+TL-(wOD8J6Bd3-o+M+fNMneKT2k)-&EHmjNMs*3PHT)|%A z+MvW5}r+-ljmOWqNQh~%#^#rDAhfJjKwJvNwXbHQY zw{YsEzO?y$D8aD9d3U$4!*nF__RLJ)ri$mEq}yEpzlH_C2m-J^3_P*kQ^HDDef!WB z0DVE&hwOL<$qRva!MG*0<<4EO0W;nABM*8sB~Fk+n_Yf7Ka77{p0lsM$6*Kfq4MTE zTeeT8!0eNnCUK)4U%CTaqWvT3&hh-KVg?fuMuJU#v;Z0#)UV&~gW9g$aNB(lGbIWT zu!0wGMA7C$Kr-CU; zt#9qb^Z~IYgEV-qjO$^gQE|?z7BB8g$9}vb1&nKV;P@x&m%H>dbEgj@nA3Iblj6XB zeXU(8iUIJOCuP${U@Pv7ONEqeCZ&d!@@kSs2= z=gJOS^DXvvso%8cy8ItNOr96jm_aUSx!oB*$ z$ccD^J@f!P+|1{zN3ZkwVIAK%jkk)rXwkmK`l(9Wsn3htq-m z77tQvF?br@CcfLxOgQ@w&9tp*_098#HU0!I1}a2Aor;&9$225}-6EzVOcpGExh_$>KQT{wMo@B_X(y#B#BqveYWLj-N%i|?l8!V!dDxO5YO^w;E<8&HMQ82RB z-wIu0j+JyN^BkVA{(a@h(WC2#duY+3Plk5xh%}fErt{tEoK0-nYhc{FiuIA%t>B6q zI%rN9<2ngiQ7}OGpFZ?PXm0`URMXjpEgls(nNVyNf~!j8255%jGNbErL@-P;A#~>l zhAip9f~K%V=V{*HdFz|xxn&}k^%(t&9csRxe$gv5)ej=3bqea%8AL!I?pJznjAJV4 zuA6u;Wz`auMD>_k{-q9*VGL$SIj==(B=PPJ=R$`<3}I|WRCt+33+a-1@5}L#PZ`!o z!|WiDsI&spS>g)fr;R+S$O zB>;r)Wh_b#Uf2g15#-A8Mq_FQCRd@p80tw?(CCJSMB z#oMluXG)=$JqSTTN5+mDCks8r97i$f19wYGLW9(NM0G&2qj@{v?Y5&9Ymi$2FDDv9 z3`c#JzMU@WZF=slzC3>Y5VzN>dE9f)(=4J25Jx zt?wOgB*YhxNL__&$<=QA%bBm|x-O`yEqYC8`DV?UZGbouS4*3mQ$ue);03@^_nd-8 ztV&Bs@2z43YHMK|5ew|b=gt^+h2pL~r1!7n2SZIU6w1Yh%F&&*A;2=9f`BVkffNCI zj!?^9rKz*8)Mlhr8RXf1=+Xv25Tr!3R37F6Qj2ltPF0@$!_bE!>BLncB6|z~FCvb) zAX-;g-Bd7iDEW8dT2(-o+`v+Fk}v4En)@$qKB)eEhWpJ+58JG6)EIGiQqn-awLS>; zqgMTT#H{$~`8rrlSzR6s`+=UYl~08E(vQA7c3dOMa|5tAj+;hgX6dreupV{UW`t$S zha;{cWZJ|`)Lc@~$bTUcq}#G4`&K-Som%a|W<`k-Q9K88fydfoK4Nm-sbrn1RxTD9 z_h)9r>lk%pYF({=9liV-E|iTOJEa{xO;6iC47P8g10vJim+PBg3jxoh%Zo z&#;eK#DC-hUc>bOwIYrXK~x!-cVB#FvaC2yhV_x|!LKzv|D^?RcMr>}NAKYW*8hTG zT|5&QDi!sLTq7oUwhUSsh(Y^!#Ic+vp`zEta~C%^G5%xkr+G z`QT$1KqScq0h#u?DWmL%Ssr@hs#)>rXX}K-VH|=v$t0mzPIJwnXW5Rj<3oOGC*J(g z=(8!HwG@zafEVH@6Ztu+Y{5578t=-Xd~xE+*%OnV7h@C(`l;5n&^ zCIPEnTx*EYOuqB+Fge@~oylt$^N}|Q7l07m0^VWBr#2t0MQ zraM*LHl;6x3kyauErfnl)co_lzgQGfP~b=|kmI`2_0wdz|tu^R#Teh27G8{qP-t##34 zJvBT64InGLXdM7;y%f`}MU)J`Ehcxzw}z{VCIiYM$rb*W>#PC@i`{!#^q*)}n?Te_ zYy=goM+Rx}Gpxk91w4SFclW0h9-kM?ue2@NXLoJ!@{)rYqti7mBPmOp08hP3EneZP zlO_$2PhxzaNkoUQa@N)w;whkG7cu8cag+FZxQQ1uyBk^*IU-6;doW9ERbXn~s?)Y- zEsP4#wG83g$1|Vw$=YI9Fo>O`6cNenM^7bjcVf}&V}$Ea(!>C11q~5#2sgJ`cx6RJ z2ruws4rq=9%Yut1e$6~f8^rZ(W8m6~IYa;HvV1n!JGKky_AsAv?wwjsWoK~MvP$Bx z`eF=45I-)eU#-iu_7WUKbWZbDt-1y5_yyWgApl+Bx6jJMW}bL6XQAek_$HvYMdTxp zhoPEYc*Km2)|=!-;@Ki{eEoUnBeBi|dbObC@53fqQKvA?o5r`Fo_RiSYsMrHD#;%d zeFf*-@2iz24;P;qT6Krd?@R+GbT#5?;&Am0?6OWJ53TZ7?#_;9=j|5oVn)!o4E;bQxlBifn7I#>&yiwHxkE_VYB2r!PnX7hX8g{l>+ zMEAn-1125I=&2#NOZb@Pwt_~Zk)j@$*D1JY`ukY`3-oq+EFHb>Y5Baf?DO8 zXt5O=h&F-N3()D0bPC;%@Po9Bq)uy3zDRPHxcLZeRdm1g1Q+!LWhL6dv}b{gePdt( zY!c=LFRHp-LX%Fg_iP3-!E1RrQQ}2rtSFpF=S9rguwlda;NCpaCIPzfM|yTOG_*Ze z=WIXdD>Qm_{Og_y8)eMzLoX(w+^`j*Ko|+7%=8rdsY&+V;)FD_S`KSJd+!c1opWqY z=Dq199c^mF2vefUR@JLpcY>X{ZsEs|O(>TI3pox&2B3Vml;WkQ7RtSC(4@%?iO?3K z0zk5B+hgm#9b7c13>-mNwAsYlv6{!2quaYm0wu&m7Y`>zg)itOb(|U)k|Fn7mXjgdzbQ_nn}er>!}H*>4x#Dkp{@mE6q} zx^0^9)w8V2KKC+HsaX;SGi^yoH$}X%FR3o-C`m@mp4}OHB&slrk5MaWt=kYc4wNzB z-ah+R#F}1Qv@x3sBS{5JDH+2{%PQ0eFr!Y&1Ie%>iYxKvFp#{(Iw>1tX~4kJ>m#P4 z$IvS?-#hH|&S01#iC7dSPg1T22RDMzZNEf!b#Wd9h@EGriD8>XYnwDB79pAW=o(aBI5g<_?oMWvj)ELjkO_;FtQU3lgDNkrE zXN%_Ik8v)ylo8rPi|?2|J22V4+HPj`c+EZL zv*J{iTymb(^WQ3{?rEj2evfpXHwuAFSr7>gL-+NZM2Vu=YJ2)b&yl14oSwNPx?lE8 z`e?&t%DUUe)TUP%w)$%W!r;@sd2RYnCFtQb4r)m3GM+MHj1olvhj ztixm%_~D=$S%u4;TMVzjCv1YNdoZ7@jo?qkocIljRpX)`DNcN-xBXFF^JSQD@Xby* zr!C3`_dhz(q_maSsmanAkM5alyY57IRM2s^LkuJDNAYs=Sxo{CGU%X^Qt{mX?9%NI z{ZHo{9L5-6yy;YIYSXGrvIRZVx3o$;Lm#QZRE;)w6nGWejx?Ws-toI?>oC@-dnF_c zC^);Xs))kL)Ts7jU*fLNZi5{XT?6`jH>a~BSKnY++7{DWD=-x8GCOIMXL zvhS&gn-Vyn`E4}SLtv&pvE8PC_6YBld4-SqA47chO}P~6vvYInUS~cGw?Fc2cVM7S z;?CRd^|c@8|5NFaaehoz--##m>KXpJfmHpTkmm+eV*E|qa5PQUoa?&pQ7(|z4}N#vk~ zQK{TNz1iMd+jqIfREN&H7=TgT2Ykvr`upjd(o0ap>qOq@YkrWI<3@N1THp3an6FSh z1l;4ip6Yt>C);sa8Kd1pcb(lM2F*o;pm+>2`lIXnHahW5?d?jweU475?{XsUkD(EH zynn5rbQE_t=B&kj?@7@2610A$*%lRXZwViwYt!|!M1^lLqzhrG{# zmL?x#F=&}7FVZ>ft#E%5>>_h-!G49Le7y@k91~3lmB>|z>EY(cngq_QhUp?a+_ctZ z?+J)bR{Jy``o6`sG0L;I7RsdX~qh zT~tx>dPNwzE=ZN9--JBfuZv!~)x(2__hD2+Qj}!2IG^&)w zhrSIz%FVrsq^QT?q03ClF9QCb<6~ktZB_H&$EsL-%`fa})N#Mjc(%7rjNW+~5Y5DI zj9JqCi0GHeWYI9p(UENj$BD}R2pJvRE9ln=mM)ods-IE$fr3_+Dgz#s&Nv?ZrYdLW ztzV}~i;a>IrLBAUAbC00iMYR$P?QT-4@3Kd|Q)brsV4nNickhlxP!%@b zwr6gh!=<%7yQRkDbc{YDSF>@`rYP`JK4pB*e>r6AsjbO_$El6&YfW?13>mh>mw*t= z%Zfc{nmV@Qs&$`o%rn1DKe9lVOK_E!fsI!xBGPdE}KFWEq#D9(a!Q^c)&Jl&z?gBQnOeR!9hfMEJ9X zp$i+4lpS>7UEr%m0?bKV0fv`@)4Ja_eNu|3<`l!Z_(N{pYKWEQ?UOSReX4}~lLDLLp%@|#|GC2f^#9BH1AveGf7+ z_zFqpwFe`c7w(C#!xzwN^xk55D$he}5#Zp}4pX)wg$c(xE}amLTc=Z2^Yjf)Ihc>h zTdu21>rKe&#IW0!xIh91;zR<`n@y+@{dDBaHId4SxO;ugaS^~M$$l@sYteecAWf=Q z%osZg{s3N|XTAxexnmAPu*hvW&Lm_X97?6wN53rzMPtU(fTttWzIc`nlJ-K-q)Ck$32OM&euwjtRi76Mx&(PD< z-?_)qVO8IQAfQAZ`-;(S=+WI{>E^60&WBI``7%?ISC+hLh)+dstAIw3Zc#6fB(MCq z&d{>u-*gR8B(fo3WyRN?bM779#zP|OeP|r&Dy*!nuVD9;4Fd$cO7|tQF70;%IA{Ko zCZ_4!xo=E*dGg?iy^|d0PY_pOm)JwBppC?dB?@d5PB9r5LtO)j1(E<_Vwqq|LQ#RB z`A#;?@4zApF!_QHAN0D}O(Of&ncb(7dnfu$5O~p<2SnStWG{r40Y#O-OM&$yOOQUw zt8@XhABePo@FEEXj|^T)T=y)mE(b;M3_kA|n7llE2cv^XqbM2ED-28glZwD5M8n9) zBNB1b{32XFBy#SANO5LnfTfwk19Im6zcolJDKcT6x%nKY^n3zuk!d=c;H`xVi#;lH zrJDfb6{DgEucl|Xl=;$&h4OftoOR%OFssgHgN*nK&@Fy)?=`maI#X94;uK7@ZWg+o zHKh)3#!{HkY~yWFj`2be3%XUlJyg??b7~$BW#0tujNo^N4@wqh*wK9*S$GAh*B2lNFyHCX@rfqcWq^u+jNZB8TxKTE$O zs<@yfK$G$#2^jPd0T*V3^1!1+xygt1zjP>hXpyXNqW(%${NFo5!g|}#YRh&y0&?X8 zFgzpe_eQO%``w*&g)DGwNOTcS6%kSb9klYX^UuB~tqYZAQumfyx9T|Y2lzi%$%2yw z7~jkLGHLZlUSgWJ_{@c>J7Q~qc^67JZ(YoUDPQv!*t}b{6q%GD(ehgj5+e1eEhGu{ zMI?6;;2Wws2z8Qa;Y_U_F}EwGOq+I7Ml12<;eex%Y~KAL8ubiJh@h`})~D+-AoLn^ z{G?vuGf(z--3Okq>Uwo{sK%Rh86J~o2K?BiS(0;55UMH`nK=vpg8BO6^ihb($IRn zB{;b)%;npwVQe)CDL;khtMwg*DiDlVrE79F=+8rxhva8Y$h26s5GvJ?305dPDV5Bj zPEGFmg;RJ%qkON1rKP12;_1iji8}5cPzZBEX3pBb3ipew z4{JGy-3>Q~8&pA|<9>U3u6dkIqA^YZj$?05EuHV~NYp@CcL^pkXiT#`tI=F2HkhPD zR2}QVQ6Y>WQD^bW=;X5y0OIbKhg#Irpv(_}Xb7PgmuXQ)At|@Rh9#%H!lz^SAlgC~ zHqEsAvdV~woLkV+1aFVBupB%6P}MOGM8U_Uus!t1^bWBaW8ePErKqW$z8^b2^z$q? zJG0IqW&5qRk$w2Kj>M4`?0m)K?Fe8Hed5s?7pZSVbWl1TFqdE=9vOBkpM8Ths{!** z=N-!o8LNuPRJaLfQw&o_FI8`IeHk*J5mY2+bcB6tl{W>jY>JYU-ziQZkc?MJ)rXja zoU!tVMg;QcArzZr(nb|G(HO9GkP~Wq$uSXFE?=CHkoo2|5oHqz1^rz9*;xt+pu||2 zU7ejn9}SIjNbLyptUlc%bTq$5_war6E_4E8`Q`)G_dA#|$*t5A)i;>67fpE9$|qlc z{OdxrUwBQk;*D7DzmCF!zrFDFYn5r8gMJ;XfkHYUYul^6WLy=09ChpBY@PnsYLrM( zO^zvM#)6*s51YW zKff*#F@0rnr_Nwg7M2@D%@jTAxKib(#IEMI@Q)dH|9hVN{|dqWb!`xE*2o*>(*5hl z|D{*QHSF9ut4xc?zRIt*H6QiIyNoE$_^GKVRVQ@VW?@%LGbCr<;ln$mieYmSkK?CR zw1hOx+6M7Rk|cp*`lOJiwhZMc33`=2p-**maj|gEgK@n4Gje4Ngw=xN-i(0PsTG~o zJjiku^Pv7dfS5{e9< z4wLSl?9tAAv1!jR3`m0R0{ato6Q@-2vSvneNii9moTXVH%yVL5>x!8!mHntZ-h6!A zy~0XD*k-Oa+K=iTVN*lTEj~W-Jus9-&pa=RlZ(sU8fSc}b~;K6pwUxRAv(H*VWxQ? z6ry<3&@D#9)AC!GTn1R&*7sJBc9OihR0ZZQ{*=r9Qbf8z@S>*2p0I%dAz$L@X?7)iM7lJp)J-99da|$r!gsN0M7ze6dK`Jz)N;;Y!Z~Qh+ zG<^hiOB*(GpXDJ++m@t@oH ztD`UIAYV+3=KPGr66&1}YpL}$4g%nK_&QwqgO`731K+)+x~kaoxSPwAe`x`*6ON0^ zPW-K3!hku!Wut%U&*GG>8Eue0tBh|Et>Z>2k;9|R%(z8I(OXF4&!Zg|pk=ss?_Mgk znfqV72$j#rrI7ZUMg!{T>Kd(#j=TnIxfn8&sXnUavw75j%+u^^TZs!35C~|u0gc5t z(tYAmUxot=OLH;ZV@4SNY0(>%SHuiR0)#L864G?zgd?=xlB+d6i_Gwq6nx&$)zZggP@2ckt6~lpyZ&1f)bS|StW@G zC{Z$k8-JWhHTyCR65#AcrpkAd=^u$@P) zPq6uOXq}>RifjKa0n!fX{h~DkhjXMY<2xKYL1+3;j3M|lkFm)&hR|rpw^pmkLpMA^DTEYiE0$Cuje}MCmBZAA0gHb>{38fQ8%VgpM z01F8XZ6aX+3PUD&lTr|fW_~h*{lW&&3iquMH({_nc0~@mdKqCfjX1JbGTgXX$ffLA z@LP;*a3y2*q=GN(K>?Wu=9{qQmtXh`f?nMd(*Xb(>C9pS^E{N4lK_g&a5?Be0YVAR z4ePws6%qI4#9PWt^2XUxCnory%aa`Pbz4o)_ zI1!m(@*qj`fLF3ed0W_?8bN>}kX5ZLLrI(7{staoY{=z^egZHY>262*fpqKyf&dt7 zJ4E`?YuUsoVuD1iiTF5^LI9mHiIgsQSCe`MZ$QSi;1*)4>zV8JIMHg*g(6Ium?bPW zyXW8Te6Dz<;6s>b09oDuO8&d_&m=t0{9mA4pu&+`;0Nx6$`ocxhl%3?4#+?1`eu{l z_r?m?E$v4~=r`RjBxBUBxGBg@bCyB7VSwWZxZU+<-Jxg3(^t%S=1}A*rwR>Ssg1M z>l6lOlz2F#sKkq0TfM71d%Zq^kjYNUhPt6{#$XsN!tq(BR$e8nI$PM^U0cnE;z%sp z|JDt=qfFe_qf|?1ByQdiUdHkN+Y9sNO;VEfBD|smHHlQ;WCpc0V1Uzt4mxlR=*G4> zkh5`cLc|>!o}CFICb%GYHG}!NZ3U`{e1YjX9;;PD<o9*1Dih?g5I1JK-1}9AmOKF93S*wGkJeowA*~J!MAdtTk$IlbhdO}>p9e@4FiTSXL-Z_&)fzc6{Lq;N zNkA-Hb_k*r@@xrP3Gt~LJavgU5&SC5H^0Jz0g!7x-u>C*3LMp77zO?L9(eKr{PAWA zaH8G;Dj_S(ezE_cAzXq0!|Y=_ck06g?$vpi$->>+L)B*~NCN+s}k85v3( z*1l^VP?3?s6<+zUT1dXvAknPUKW2Hs|<0ze1_$mm}Z+t75} z(RQhG>g`8dhOdqAYb+xgt65*noPnDQf%|Zq-az~2Yd!G=k(s0ugxZN4cx~by;a`9f zx(OaV7;CDD93#JSGnsma8jpM&^wPF%+=p05SG+=3TEfQ(Tjnf-eI$*+q?L&lq zBubKx-EDy&%Wc^F7~NeWRz1hT;hmu`r!GuKaVzb=GCRfb5zW1tvF! z_4Ve=i3Qr~bx?zMzUUsqfVKp z{^pWxafx|vi^9vPWf9X0T@R|n_h}d{p?74*$vdB(uxzHmJ8z;fKS-NEw^wYS$FAP) zK;D^ztbAfW3z!Chg}7c)H-yD3|BdH8+!$Amz_9^ob~i+U_3>(L?<|v6EZXW-)1zKz zRL@OM!*s|CL$Y`5Kb40g_=THW#N`)zsT;NgjI9SJGnx`s0dL@zA^bJepq^OE@X7cJ zcZRPZ%;iW&L7%Nq%@EZCQj895(r#NC^cx)v2rV>T6dPcq2c$A^FwWIl%_#tQ&K z43xiwKX6bmir$8Vb?|*JN~IF-+shG~{@i2L4zHHafRf!drz*bK%SdMDz(|S`Po@uM zeT3C=QzdAeUm?{{88Xdp2;DKj#!|H<0QtiaC>m?=u#oL*aKC672hbMYzH=wYfzSQb ztxLbZM>~fRbTvIhc{F&ufwm&FCrQgUz%J#3QbH(eJRUp@II9unS?x<6_Lb~Gz}2&S z^9#$6j-HI$CaOsApvplprXb2W!af1t>@ki(I{F`3ziK6+PpO3^gm*Fg4)j3qBNo(% z$${KPWym0ixid05Znyiycn8v%U}1+)JiFRYxu75-Z}2&zM2Vmc>@VGBKK{zo(&8db zS%PxXNo13pJ_e|fP;P)1z#AB4j93JZ+YNv)QBRRrixer+c5zx@c{J%Iuk~ACLP&WY z!?&xeXK4!%@jlR!&MnynF*gO{sD_sJD=?>{GZ+7c^G}RY0==x@?0!(dA@p%awLr#H z@1>`g0R@khng9IxsaFQ~`x|kk`pB9*=1$-Ec<74D{`vUX}gnfj)l+;C~9iO0t$LN}yRH4QZ)sqcwGon|rQ5!Wf zXfkm?-F{JrOr~;OyMcwtQ@>2sIf|WXOefg%_uEU);2A&xbqtQ$NtkDO1qVZcxfzKl zxhZ%J5c8|>{1|TKfFy=*Q#Zg~uSED&TwL7Sf_pNkl$>#u570Ofx%v_8iPwUSNXn{l z7z}}X4T@LRcG~1mC zIIfcmw=w4HAGD{E6TwJV&9Zz25wzTUxufcKv)99yUm$1_!AgN0Mm=TJ2pRwZARaoM zouN=7Bmn>iLVHRnKDe}(UcmAt2nBK}_pVd$nU9tr%_Y3>*ZLy|&Z-ArdbR@wZxU#k zq}whgYO?B>O$iO>l;IG$yWdKt_4L5_rjdr&VjL5AV!WU(Mx)AdN_k&d7}T$6q@d6r zcxPBB6O=(rjP^yitOfXow+7v>O8ycL>UCtlu3g44=zW8$^D6vwTnONO3yb2)N_XI= zkdfZ`*{(lq4d#4c>9V1Mk)T-@l`>LmO_M>(3RRAQ9snN+43+7_hbNes(dSEEA4-%!fyn!I^QY%4XMiBCL~=duXm6*mL=H*Z5D?#^ zaK4r*=QJ6F&*s|)a8T7G*I6J<9432U$G2dB~K1T70L*1>qE)7 zwWyp5RgAL>6=Veqks8xyoSZ7~%GH)>YDNBu6)T3riRFVMTna=yAF(%`vVWk0!xg@3 zmVF|z+Za}WIMX%m;;^W|Z-i^Xo?;I?9Rk9+G51yc@4qSVUQwtk@cC~l#;02{sM?ek z6`hoAwDoXtVdpytcb9?!c33H3xsl;ar{&&IfX?Q$lzKv)ouPhpUW8Mj|JL3Kc*yN? z?L@R9Ek+2rv8ByBNvx1nveyKX<4_2t&`xcJp9M>ZAamg zC!J8lu;W2E`x%TcZ@3L?lv*SF=7Y-Du1#%iZIica;XR1%51t^41cmT|91;fig#}9# z{JV4Y?6?!x!Z1<>2A18qES|$R_Z>W_XR>V*kT{2T8@KFOiQJP1ycUy#x(5bCQOzhr zz2CC=9OUVitR33!rd7H`KBihwHv5+xWP ztr!;DldV%%l{43B0Ev1Kc%}jm1KZdAFc25T#M^Pyz;3Pd5ZM6cdiMLL>!5W3a5la$ zf%9cA9i7447qCx7=T*@}5v`jDG#C+d!4r6-;Y){&V9@ZP5IY_brW=(WTv)1mERzI2 z8r}P#N_LG&leA}>n@Ecb7YA^iY*caI&HN?w&t38D9CSi3f@bougH6(^k8 zR?dJ=xZcn8xYTenP$0shEiv3I(EP*x(_3P*4sHJs3^j;H9*Fg#f}&%iqcG6jLMqU< zHvTq6QYd`u>Jo0_ybrP7Tcos3p#{7l)U&W6?#8S|ZG3hxAzI$qp||PQsAoO-8W|mI z<7lMAq3IC_pLM0TJ+$}00UfdTK|N^YVcN8O;sP)h(d_&nPW^mrCNoayc*ZFqN)T#A z2lpO1VpQKuW=CTTLLWGlDDh}%bJN}D<@*DB$H(tK#4HU9+D6GT+EsMalqIm`ARJ@~ z-4Rrwt=V|WIje7CLKbQEbh4B}lL{b85;k$nAQ7gC>m5Ny1>t?S!XMC8!NhX0=;eN{ zd7?pZ0g8Afgf{VYrmj@sNKenDn>SzAWYhr|qjt~#%SMsC^pIEijJ5BW>lxb>$a@S1^rF=4~nM?M)azJ-@?U@$fJNX8A|%SR*1d9 zie9}e4nDXrdt6yYDFrJI%|izQ0t4?Q$A7|g)J!1ZW@qMSVX1iFfWh{nKrV53p0`m& zk`_J(O|Or<9+#Pspu(UQb7%3_b~^|(&yaBs*d+Z>%hi8W=8)~-x*mTzYCH*R0$!)R z$i-m#wH3f4>baHguYP)7OVlnE(6Bzi%IXfHi(I6HufUnQdXl5HDXLU^O*n%f0XUpsn!J1#Q?kEe5PkiYMtXH5s}KOLw_ARHXU(#1Iw}X9l%1$Ov2CL zHBr4IxmAWe6i)JYIDRMuX6qH8uX>K3py^c&+0CwH@Lipew0ZgX5K1BLc8jPWw|*@V zEgy=D@r~3j^@0T8jLOF5h6a>)@gOyn0&z|UP=HjLI?NOLSoB&kAHWxf?yY*QEbv{x z?+?WhwfFF0bdL;nwE=C>2ip^Y`7cJsXQ(MZL**Opd=cOHfqf31b0W|&Lj!&wrH}z! z!P@CoO)OJG8y2eu!oU!iDUv~31@^FkRE)dj`{T!ts~(nX#(v)nsvM_u1<$cZqpbxJ zHpz~GljmWJ0L1Xnw83qKc0dn_aTfAgE9!~xvXm2@5K)mgLQw@BHs^_ppwd*Wtu8~J zw+odxCk7{cvRu_*kG`4+Ndop zm;;;>MN|QDM{!9>juTfZ?S#>Gtm#|2jcgHX9d|?u)ZlI|g(kNcOQ15c=;ncnw;RC_ zP|H(Pm@2^(ouG`M^g71J7YH&z9Trnk$Zq7?i=#=^r1ggya}bp=mE9zU@sJbqy=rxBD3!`KKshGgc)j{Sot2U}NFBqlmyRe>OCJjS2`OkN}p z(TVG!w26g9G*AN~_eH#zLi$J7p|H5H02N^#m<(X(>mqp-J-%Jx+BMSqM_l=+mPw7w zg-qEA*MbBPpX^S3cV%(0GdllffKLDoDu2GS1(F!i<$!y?kBLeY9RtX|RCDb+-tUV) z?bp7oF;>YFK|vwiR44qK%KctDMN~|gq+((}u~_EET8S59<6I;f3;7$gCntIHSz)H^tM75z_Munf>=<>WMn z`FkaDbE}cKG(esthB>Wfn2)*Ob{c2TQX`cuY;EOhYvm>9M@`MtTX%n)o7W=5UKtu} zhTJX$L6`&76jUBVAhtpJlU=ikP7;(uCu9b|Tn*>4@hgLo{_3Q6g$3$}nQY~EZBX@! zgHnux2+i6sxK6d-<$I4EOPr)TZ6*W(KKL2==z`Rmj)lKz>#lRdLl|`;3*@gHb07$= zN>*W_G{_rydKdNWRDz?=Q31mB_I;pDMdTj=A| z>?|Yn*lnDZ=`WfT(@_#5bkjiUj9KNpY8emDe;euVzXGub*@Q@2d``(9yE<|Y>=ZE2 zh#J6MoeG5Y>S|Anoe+4K3mOo9F#kQm1_}nyzW(z-S&>wMus?|kfdja9DB$~o=W3V+ zCA8SL4_A8y@J9N>p}pk#d<=${k7B2R2IqaeUr<1R%zOu;p@Vzi)FiFO;rA^Q&l>oN zB*gTn1~`3|L75<*2RR`Ftt=w$7xG%$C{M7lc>rk&ifhA*L8NpD?w61jMnMJ!N|esB zAt>lizzFne%B4Gl?FF&9WW)cz# zMVG7pXYlCgC{bwOz)3!3wSr-Dhx|rQDZc*`;ep-ojC$4u2HVLEkQ%(hhX(PjE?$kl zz#NI23j(AwgMA6&>LSdElUT*E7j~kzQKqC}r<2?TX(wSE0p{bzjpJZG$YGRy6^9lt z0yYV}poo<8y!wLVu5|QI+o_%_!%Ycc(NjVnR3!;6Vvdwxm-q_1P8l#mzi{P8SZKuv z;EC=VxgQCSVvYiC4gf!uR3kL^34se=u3Dd-{`c-hP(P%mMTMw!1VS^b`3{TS$1Q$8 z!R7a<$?4oD0=D;oALxPtkIcDqZH*_pH;3u3zaC??yk>;~X`gr^(Mx2B?nL%ofb_n)!?qxGHDIvAcNhnJ$SZN1!9$w2x};tfYJM)dRv$ET+QW#$PGp1 zWu$jA$?bsLL84j&7ibaVXx+SGrss(a5I8`h;0dDr0_Un1OOC(+FL~V`pdIx7dMx$6 zlTsx&8p*E1R#wioew11?51BuFM8x8wWh!1EN)VzdhBOdwC>(I%NOeG@Ijz*c1}Fv| zKFR+HT~@tId5-lO>hqED&e}caNXSb+-|yffVJ6wH4>7=UAV80_s6q?xRps^z zE#nv?i2(k3t(m0;TqelgM!Z_~$H`miW_VM`6XH$=s= z&V}&X$8eNq`;V(HJUj+~$?^nJ--Fhw9tvw&&zY{)G-FTD@eyvUa_rvcYe`Wg+)8o5 z2~1Wq8pB@EI^`M`$nz0v^cDoqpMQmeLnztb5_=!d;S$Wu9yhge=z$}VODP~Q>MFemeP$LsyU$IepcX@jb1abm`MGnhp?RvshnM?67C;0RE0otIZ<7`GEQfB~ij;pp>P zQS12??>U<;MiFGd5A@grAp|J-OM$Rw63D?5ekYWFcpl#n92zQPX!xbzK=>zVr>9zr4nZBGour z%z9IzF8J@SX{Wt;z##tXYw_v-{m%co9sggt8=iSat^b1yKtAFB@n`dz2obRI0$+v# zN2N|OJTTpW`ci;jBJ%LzuYE7%f3Ju&H3-LAK51^EK%;OFL`u=NG@~}6T1WK&Zo9zS zQ)G!=?h6y!etS>pJoNckOq+q_ppn{ow+*3@0K637+w`HJruJ%)z$g={ay?pD$FeXCoPSK)DiGKK}90uXNI;i z{w9~Hjp@ka=Ow+I$brE0Ey1yc!m1ZZ)(Vmk5-`K;mtNrR-Z3yJNBdP=Ozbv%X^7zf zwSU;FS6^`%yznJJqDKK%UkEfPJCmzgoh z;y5W+(QW}mLPjz4^!INGpz^ne87FiWr_?eyasKw>kgUG3h8Z5p;2;6dfQKWO>>XGx=%B?U^rD;)n|bjV;i!kSkXtvWQ7Ibf7Z^ei(oWI!pt(K|XSg(I9*NE8Sr7Geb6 z)`?pLD+U(W$B|xyBP~P{b*ha;+@S%DH!|c5jY={fX8^;kwvdksHQ=@hbN&|*u3 zeivj>aTGzw%Quj_3devt7+s)1s=x|4#BctwP{#qsJdvl6E&!&xV^eI(!_#TOqy-$8 zAd8Z@^phwRcs#Eb2$uNh*R1k+6cs} z*UU}`%mQ>@Igs7qiB{n?13=gSXhPap_ZmJ-;WyFQsE7`;cR~4sbM`nVrw=kH5A;Rl zfJou;i!g-6YAmH`mkyv(4$w`ICr0!o{H7p2BcNlS0d^Gd+6@3GeB&%^&_)LM1jEvk z#e!Y1fT}{qcIsNhA0X49s<^1T@e9fc#nVOr6Le+kl z*K{)lpeL-MLS!~H+=hn$J`5sHqxessj0hC5rMd3a-_C!!U9`UwWiI-swV*1YGdFYM z#_uI6MTre<0V55cyR6UOFhW>DUuCa_9BKr!d?yN7g3Q345-H_ zuD-*f6#YsU5HBaPW++k3a539}s*C}aPk>(c^U07;_{w1?slYYB$SD95gq`7&cHC#* zeykrD3#>$L{)Fxlrgk0&J_cVZI`#F5%Sp%w0ad39`F~7I#C@kJb9Z$mHZ9$Fnz|_W zZ708OLkEA;%h#{*DP7*uQ^2NPi*v~W7&F{Zo}-oF4U~}SGb#rpE8&o8$w!Fo!H#nu zZ?O7{cjd=2%k@vUFCs49?Z`;cR1W;e4w2pNBTCSjAZjv!`Pnqs1OGoTR49PK;9-Ke zLY0T~Z3=Bt#H1CBsP9IsVLE<%6MK`8{bW0U;N9CtSO8SQMm7L)aMfZmkmcC4F=@*5 z8Bm?nVi>(bMIdXGe7{>Nd0zYQ;^^7=p{Yr$4zeF;o~@HMpvHwt#~h}n)?MIbXRnnT zM*(n3C6xv7u(+zq1DHs`GNdAKmgjKMM;nt=22L;|nUDd|`;_XJbJ(zD-Z%d}Vrvpx znE+Y3b?WexAGEAA@9wGGt4@_AU_MMW(q>k)l=2Stg!ItF$>Nr5Q{%gD=2uXYZ=Dn&GGyi+x zzYjzem?CHc@*ubp+L~~n$;Y&cZ#V~Dppd{A5#k|$ACR30_@e?_-qbc26dfbzmz&BJ6=y z`yO_iGWbzrF%q92*eS7*PAVhg7IczGR6#S2H26V^AQs~2NQk5>hd)6#xDr6OJAMA| zNFdg^1P@jM_TlIvqcdRZL!b-_dpNm}|4zyTKSxQ7`-MHkT3n2$6OKIrv~ka4L>L9E zHzW(hb__P|#Mh~1ZyugLtQ9c_s0clPL?f6f!T!49Ts}`Z>N0#LP3#PiKjbqW>Qj)O zHk!O3!d3+HdIKJ%otvGS(!}G3FdD{OhhXPrP%rOWktzY`=e^w9jRZn95&Zq>SLj+u z{TCkk@Xuq%1|a|IyW7g~crT*_B<;=sPT`ez8&x37t>iz@WJ7$ujCcN=n0N;p3+N;} z4oAcSJ^2=Zvk{1H2ptNrn}qO99JC#*-Y7Jda2#=97$fuGk@N%C~~9E{4bO z>VX--6*S39kXUTrvEvS?8U(?AjCczA^Hl)L;9ng_N{UFLqI&T65ww0oxjY)_&A{DU zXf{Z!BSw3m&RzeY_!f`~g<^tw2tqFhKv%T82}cMyU&{M-XdCyTE%x*AaCi4tRQZJd z09;)$=Grroib1~u{xL~L<%{Lmy|akB5^6kBii5RDo63t--FhAAO(gEjiIXQyF*S}^ z@qHe^8SKl=xQAR)W2&ej@z~0dc9H}QCZgIP8D=dF$09h&fnW{lUnz2ki@=cxNJM%s zX!C#-PzURw64fGIfSRO|E;bVRH`vm};(5C<9eMivIWJe!;YOek0Fh4W1WyTH z(_cUhq!leGDlYCF7|=}?g!>JSL!dId4L1CKo#!_tjuY&lJHdnE-}KE&b#3^M+vGY? zjf>-lBuo0AU(?%h&iv2+NB;4Dzw_U>9AJ~eR$~Dx^>zy<2`mqm&Bcq9+MS&Va-UN z&Wh@RFdy^64 z@`!EM;lGnMI1UNx3XGU<>$|)D{rMk(RJMb!+HCC3yUcu5a#s(^nzZ1a;Ggu@viDIw zb7QKwam-3RzMVt=>#veG4SUz)40p9JM--5{s`M48wLhSi zLkI+$i5>Bb5n7h3IuMBl;70-v+t8b!5wuROF+jqL)*hFViLey70BFLHciFdh?=IP5GA^q$2Yr<^LRIT5g7hqd2U+S>2 zJzM7|Wz%OaDWvD%T4=F;jx{)13gmiF4fnfZ(V15wz3!@ zV%^i@?G^g)4^hDXVqy~ZwVDDZR%%q8p`nVbO5D|DpyTa%m8J0J?+qx^PszU{hJUz! zUV(v25e3%oe*cx2Wv%8RWGfD}M^{+;9tna6NQW7!0<1G8A8y3cvU#w{^6e@e{KdNu zFQxsPinpi%NbbkAlCaI8Lgj5C8e2)R8>y1^xyXTvqN6SKo%E`;m1)+e_otj>gT5}t zD(TA?JM*ma-8RtLl(!^kqnv8QzvujC#nUQI^BIrczRu*=KB=q6qEt?&fDZKhAYkdTrOB8P;9P*!{61 z+d2Q)j+#Uds*cv=d^F|V+;;gHwv^GX3$~}*_-9lrX~_*Lol^Yt7e%g7dxcwMImM)% z8O4|#9s5TMD%W9)>Y83d-Up9`#x(9szw6S!l}UYsZke`0B=7S+pHsu))#gU;=U2=q zPIP1xdOENS=9ShpA7B=97R||O&5F75=r*(Z3=bQN1oS+_EY2(tOpU zrdQz=YT16@RMIA6ZarH&Yms02wR7}5)7g~}^07L`1%uP~ULLW2G}QK4kh`%l{e$z- zF59bv)RlK{hVxO(G{!SAb9>C&w1+hsk34Z46}0y{yPT`Pzb@s|xu~iQdA5|WIH?3`qtBmnEGnN~ewuW<%{spQi%frQ_4jaP zIsvsrVHUoJ-7IG|nomy|2x9gtr^@&%*TDs?d2TP`=9rKYdO;2~678 zW)*Mw=d&Hu5t<^$IHd2Jo6eSy0g@VAc``JOu!4L~T z4kg~bamqSPC7LZa!&L-G@I^Ybxe!EaDn ziatVm!w4R{5^!xjMj0hc^Sa3hj`WMN)$ z^f257X7$^wlDEUad#oeVA1J~;&?a#Oqz%a^;d4%9+d$72;y<&Kn&mH$wm@4|Na)N? zy$)ABs5|~bw+NXmk^2;ID}a9yfDE6M&EQ#L1jjkh%**`GhQ(SFO*x1tIskkqP~Fsg z*1Ad(_wWpoS|Mc|1Zu;T(gc>2;*S?$?wog9W`=eB0^J!fYuJEWfJYwG%p?x5+ysAid^ z|JzGLJN75DinVPuO5bDUSQvCrA^O&l8{?{_9rc+Hjq5@VGUrhIeaG~eD>rLTNy~A| zbgP=rCRf9a5SIt)BNY}DXJJl6t;AIB)qmW9R(zhjLpfP%yX3Y$=7{bIr;vrq9dsIqLZFNFew{cRL!vTKEu{UyG zFLA!_d_1?ijV^SHQFG>#v2AH)OtX|+%#CksM#lHE+|C)J`ZMTIxo5e3AMTi=-A ztt$tPDc1Hab(71Z*OLMk)-GoWX6;Vca+vCNL)G<&$Y>fhS3ute_+*|55x(|?amHQSG=xc$#h{umE`UZ>d^he5%dqca5# zqJf2Hu5R`=n2Tm7;yB!(oVd95i_`2z+}EO3r>-K;{@uBhZflDNz3z9gGcT)7Ue}Gk zS&Elsky+CXSX$ou?oRLPm73MlFVl|fIx*D9I^a%Um@MmLGIi^Z4aG7P4+bRuRGDMw z8$2tdRkLuuJ;%(sVJ&m_vibcQH;t>F-%7uRR@Oz+GBU8nbiEmQYb!XYq;Ayd!#z8K zcwQ-SK>7R-o#-zcM`SW49DG~Z_x;4Q+m*T5n^C)#U(VWWykN#y@@|Ibz}dV+BmTQ; z36wi?X{8M>9pk6$Sk6?-rTKbqy+g`&U69(2%jwSeI7{o7(jM#e>UV_?^&7m_ZEYuJ zJ{_NwiywKb={|gz&#ZB>bCpS!MyM?+A}aFy{l>$?N)8@dde=Pf(2qMseBm7sq8eDq zusU+KK|jx5aLMG@&tuEgohogoPOAE`H?Ju`6LJOjH`F9GFZh67|Un{LFex6xmcvAj4KehD+zMh@2f<3{&3c# zAYLQ{{{lemcvQrRXaUhbpn$?p2Yqj7ZHT!!02HZGFUF(WW+5C@!~luc0#=Fgs~Gw|j95$e|DFXhms~^&hz-pmoH&n6m5Z@BFCfBMi#ViWIy=bvG!Q4C)-B@DFOOS@MJROx@LcwKXY}0fkWkuwMIvk z?b~<0HD1nf@<@p)6SQ3V>q+#c#jJ_JtgbP;jk4SRX;__>YH&P#@$E%~m-BODpH~LM z*I&L0z4#+w*+3#XDk>3IW}RCfbhc~BOM01m4=qh#?9!t|i+8$(fx$VZw2!}hyHK?? zqDG4@5_Wg(THNZKPgz03>zp&TOq)8!|Jf54l|wVV`*;_v_=CIW zu2vn{fCQCsqsNwWy5A4wtpBhXZ_h4B%`>7d&eHEXX=|`B-Lb7}PuTwICkopr{_xzP z*Y?%Owslb9WVY~jDkZAM(Tt|<<3l(0>N!?Vt2fQRUTb-X6QK|{l==v!Q#$b;AN<}P z&1=^>uUS>XyWisM{bu{sfk!Al?kofqB>i_V)mi6x;o{oc#5H1>uDtK#$MF>ikYik5XYC)q9%y>Cn> z3q!iaf);CzPJ^{ggnVpum@)z%swpZ6xIl`!%GKoDk=}8_62Q zIX9omJL0RbP3AD4{9AzG_0pMG`i&l8KL-2HZ1{piaWFwW@_o>mehHyMr^nf&!X|se z?pHEXAn(U*5l)EzSF7wuJFe|O}rD2D|^nN|Ze-14)G`(DW5c@tTsIb_m?G3$e zSbCFVn#get%1}P7Lz>GQR$VqP8$OB}$*87ZUS+OUsLDJMqp!b` zs>N}1kLEWjT zJoG-a*C{2)C0)A0>~FvIvBxa!=kYxHf*j|w!Tl8}f6avnZu$G3HErmXHC~1K7bwTO z!cga;@1=)^6Piu!=UQhF$dHju)k>c2$kZ5V^#v?Q*mMPAb-87rmIHpiqo-F4{eA^f zv+b0WQ4r@q`WOh31c>@KAw(jCNH9Rq9ZJHyJLrW73Q9plQ_tPqxtN#XqeP{8MsnE5(=kwRnVcMr6!tq%Dr z^;z`_G;rKRHw-4z6POnLH8Qw@a97ZYiqaG**R5?q#6=am|I7_Hko<`q5c;Fg&n$nu zxn9{m*ewqh)qBOLz5E+3z9-5^X8bw3>wjqM&aj}?qk?m$_w%Oy^5fhef zvG725x733yaxcf@GI8=$uezY8LZFBn8(pP3zZ)WM7$0zSR`qTQ)aklZp zycSOpdwP0)=Fc1m1*XbJUFaRD=4k#U^Gok|k4#)y$_1;%6YAsYoF8jmW;4xA7t}I5 zcX2(&e$uO?Sxi7pa69GhfvOIE&e)D!XlLGPGLr7sW2Q^V^w1EfayfElVa;*EUG$$o z=J|!fUe0;_`-kK^KHNXE;<+G#o1iH8`dYj;FK9Rmb~6)CpFaE3f4{#`@^rbhdQn?l z+vM8&=2OV;%&zC2RkVGg;4fSA#(EP%X{2ss;00=PP8F(jO=fkoSrAXP7F2ZH^D0e$ zjvmD?aGlUosJnK0_?Azg@4ajFai=U4CiXrMc%0ENT$`(~ez>qbN}Y~bSb^s8V41L< z*f2xp1GjPq^?`DR+@}>29v^d4L^QXl>o4tS+n8oD)XKQmm~r6!_>KcCPg8ukz`<#$ zYIyr;lVIzw5_49U=Y1Bx8~lSkePWUD{IqGHr3~S9m2F_AxQqd!-M79?3ZbO^V!CP_ zdbqBme=U%8{EF)HQq{Ak+0MW6Jh|$O)Uzhjjz50IDkMF2!d+KNO6FvL)sgTMKRDJK zB&{2&)Up~Ac?~Sa-inXia?*dw>g(HXE2nM9623KS_IpfJhiY)ZQt4Gg{zE0thlHme zPW5DUH_;!wdNrvyF~Yvw*ShSdt<@WPYPVJ49n_~Lznd-OrY$EuTE9sBu}iFgw!td< z`cA`-9}}~AqFpX@2%n`hpsVGud0O=gUU~OwdydV)$6q2YyHK0(d^6B85(&7|DcXNu zaIaX~!I7li=A*-kA9TO4N%_$_7NECp?l7@x`1aN25{;~k~(!)eH z^{`EO9c>kK0x_#+KQ+tEzc21=-MAhH`C4;Zm%%0kHHGB6HHI%%are=-(t&LvJOV>O zsh9##cWb{aSn@TB?I4dL^cKFeTOS_W@P&tcR_d>vJS-<1cd6f>W{-6B=gtfxtN2Ev zXs+=4=L&lY6tnQg)nZJ%i~(&H#vCR;FYjp9H6FQAct!r(kiAN4Wy^F`@niYgipoOw zZ+(|b4vSl_4O?X-k6tqI?2%Fv@ZB}8)@$;@WdEbH*0~)zS|T*Yy{X@G_ATGN7Q3CF zY9hjHrV6ZJUxR19^2dY>>dys)`${9F(sQh0*Q-3-IXZc=ztvx>e2o>$pzWt%73F+x z_>N|3;BiMxm2<0o9Xu9C2F#YLi~yDDbGmx1mFM1l37t&ljzcV-ba?ReX)|KnQqpZt3Kfh+Ar6!&f>{ah5W-P4vnzm`ou1&9v38ins zpuKeT$&jD*5&Mp69%bpuX@A!nF!SC({_=_3y8YjeO()#eGOCo^P4~%qa5cuXT}}15 z^&1nq0|%W)90b2D#OA*nmsQ@sXP;wGdA6ynT832mHQAcPcq;QPJ84B@1X=cBicL%$8nNBq%b+!^L~*XGC3)KTF-0t^a^ zPM-K4Bd&#N?s2r_{z2zLhu5dZa&m9wD$h>dmNL7M&~H0=okB(<=O0Hk%_VcgY9CF8 z2dpe^DE&+yR`K=^^ceoK_+rWW6a}JkmV>2Ya`#}#>zm<}8$IvD(^Jsq$b}T>Rqe4{ z+Vv7Qfu#@wI=?1p(iI-lnEMl%bM0;j!t&DfB2`=&5G3#%dQsh&A~$V|1IQhmf_$g zE%4B+Gyx}&5YY0TpJ?jog~sh`;XgKFW`tk{ijcNP226O-%CdR!3}WdT?6(53zK=s; z8_UIylXKvQ^%~L_a#d=U6CzoT=CAyoLhbaOKYt@ zs_#d+_d1G(dbT5|h`{54AB9JlIyy~@xJa{5|?%uedYoA;$>bC+y79x8*2rJ4Ns2}?SG^Ir2gWvjI5GrdJRo&P+u z>W?$AT2MGhTOTE?I9QYGQP#$g<*)C1z-qeTV4eMl?6}k2`Zt)Br_Pk`OIYFBNp0stel%E|^C}}O<4nfI4p~2orMV~01EG4M zV*Od^Tz+k~-cdv5zoybxr<~2MmEmyz+Jp866W#sS+vqL-f(Du9-0i<91%G|7Za0CMY#>l2ZE!l2 zp>)*qs0`-iz_5|i?cJGWIOIcN??DKS{r&jJsYZ$H^~=j$>!CCpx)Jaev%(8===GOz z!5%?2)Ot9E&=g`meylJ`>$uU}`^Xo>ohYOWEt<|p*uhRrx$i%g+QtUXJ z+UNi%gO*DR(ggVD$WWN5gcfe2(cp*9L{c{HT4XUQ4jHKTHtjWd4&rcT$KaEoqYgtP-q9YCbUNz(4svGGR|6J;455|U-<)fmS_q%(9di)EoW`H-5>lWb+vnj zMJ0!&X*QS5ICtLshHVyDMvMWu5A6No-Y?FZ=W8;7dUL{mwM&O)p)YSVrA|GCuiA@` zkvF2?C=K7eyVPgZf+1e!2rqJzh^-XbPRT#oe$-Q7UTmNu%-g>x#|Qr@#+oWw{fODd zZrAwBEr^rNVYN-8&c1D~v}|%ZJ7z+sv0a(w+{3?5+Fv+7LtP>1m2A|&P3LFynrUt6 zGPP~;Um{@)fi=c%G9A0uKKtBX&4A^Tqq}Lv*JACw?;D=+pNc(WF%dE^;~c`@$Fu6- z-9@!vXKt9O71#EbuYY$rhgDHuMSw7qZ0^cKCqZEr2G*tb=3k*)o@g>(xwTb@_gq^_ zC!0IZQn5f{hWV*rGjVa|!5g}w{ciaoEd~l*t;r&&gcG%gyrkk@2L>dqn;exb4&m4R zCVBiCZyGy7NX45yjvWUh1uPwI}_^>nh_N<8K2ub3U4o*cnhrzc(Q zRz{ulD3 zbX)Y4C|#s|c1FOB^Jn&*l&wPe3{H)^rQ6t^KD0Qvfo8UKgm;xrqgrTvzg%O2(%A{= zfWXfA-BMno4e`UZ7ZBFOCziMBRfrSBwK@LjddY-7p9F>gk2C(IorzamD^$Lm~`! z&@HWvY|(NaQ|SDp$=&r)`F_T7PW#Nlz7{*@F|*oN&PvLcEFXnBthy(6-ER|^DT^JN z{~^-ikT$nir5UobTCL8!q-^I7ClA^XwbT~AZFXZxl1b991$ZZ9m$j{aUY8iIFJ)`H z?`=bGb1ZGwVV`NI-KL%5Oc7tpA}P`gTaL-D%|@mGXN$)z z9&8m_);4c`XB5cQd7;g?=A>uNg6dWQUWn1^O9QUjCOy#n)^$=^tOEy7YEW8L5uLAI zQE*w7(A30Yt2zI5mHUp?vx(hX=>~pg&&v)9@Y6c~*Ktmm79X+D1|OXF*LRUv7L9gTr3=h8Yo)KwwIx+@w?%+njCQFydexyv%$twq zE)<>x^ZVDzLa{I#OLo@cK+quGxN;Mfz)QjTb`D4l#6Vy%?RmkZi@+`e%MCExCfe=h zJ6i0Q$IKvSi-LtHVKhG8u*tMhS&vaXwe?;s5X9p+mo&P^D~Gc{Neeiqd5My@?`3sz z-3N2Db(M&y5giwvSta%TndNMmo%5zleN!J*K|6x!h^ z2y2Ok7%c61!<2@DZP)t3Mqt0xzKcOf0L~t^zQw_&Wv2oB-8XO5kA|F&uSv~94MN{+ z)rU1#^l@alCvX^dW?^J5GdXCjVJ>Jn9$jrE-Kyz}4CsPI$0MaGV1(7#nbwd$^8S3| z_+c|p;?zoXTxvn?BYz3HyR|0o)NbzPzrgw{hd|~vgph;Jk9(A4S8^$!?)7u#8#%2tvs9I7@n2qy%TnPhi9(wf*tR`8!fVUb%R(_ zOJ7?Q^)+5h8z`V*7|1NP;|aRIBCe8b)WY3+(nE6n=qfmuA%~L(FD^nxn^Z~7y3rMW80w%>j@GP>42b56hZi+F4DU2n~5DoJI1<<``D zDn{uu)K!ur194`!tid;eRAsd~k{mh%KBEoe-k-8E%-_qD>#}l8`@TwF{+wC&M&0qS z+2r}(2l#h$XHKJbPLZp-E1%dLzWILrCy!{fQMv4J2KQKyZ~6AF#h)RI0~fnKCMsG! z8v8VyEuX8tD!@O`vWxaS2;J@MLB=;8wIx)K=ruH?MVX6vSw9-lsjU}JH)vLi304ZA z91{vCxS!K95WwLebKmAiYSv`YPTqkZTDTNhHtM@6`mrnvcQ1}x{_@ymYkJ#{O(T$P zy&>|3K)*7v_ugh7u$7)2?T7e(X9|8N^+E#Iya?)i4`QexnqH z9Q(W`g=^6#q-nWIeOyi>wKpQ~?(KPjH++4rbnRPP%35(wA5e3v$)Rn(Z++L?GPQki zY&*|Nj=<`4U5iQVk-B7)x_e!&LHCpNLt!KsY|&{Y?gb$KApH#N2M&%u-?s4K0|)w{ zVcL1Q*>E3dT4El+@T?2=Ar0H^jigzuFplAfyE>XVzZ73)&7sx0(vB8#OX~HJ!=K8> zM>6@uy_9DL`fA(mmoYMh1vDc1b!(|tG>cCRtn|vp&skNb8N3>*i|Xr>WRrCcY3pBN zSgjVOM1rVqV6&CPy+T*vbdDIfC2$Fu`yvIsCvtj0X#v8enO=tV1<7=k<=Ely0EWmr zqk(5Rmi^y4lzQn%nojRw(p$SaV^$DmT%ueYe0}6E6SalX341Swkm>N`_GBGa`t0%S zmBw-!c2lt`;l-8uSe3I5)|0aOb&1j*?eitGYFs7M+wQ+Y01yyfU#y1Wya!zQPobRfk=O2AV73J_UtZRCPMJ<)nCZ+96 z$lP#pPVAN~&PU8|BCGr~tG4^`qx>Uzc}oBAKkkk-C`w*bW_PGriZAkiw`k6KEj42` zuYJQViggqGuZEg|&9-xs4XK4sR6fqV=70R;j@&iZVv2viCJ+2YvGw17p4z+d_P_sn zd`y$-_K!1~(sSfzH~ky6sqmA>k1CJk{rd|PS1wVY?LfZdPHfKm{SS)&KYeq59GIGs z;3I)FaM7#J*kM`4LvXGclus)=C8HD>{;_26t90y?dO0w}BvfN{SZO4aHf8R5>;yBA z-=(ye`2_pf*p!un_=evT0|2}lg&ZfWcQg(e*g+hSJ`4|XhxK`fk<44MvxvikMRdlhT@4GWfW0bF z_hje9${T2;EXA9C-Ewe6r5*=wS3G<5x*kva0}=A7fTnq2zHlr0(PGDhy+4kegt{g% zFLJa#*+zeJFk$BUue?oEeQ+~W?`oiU>>#=E_7CBFNUUpd*azc8Y(cHpWbugIc3~1n zyOy1j?mS*?=~g2pg^NPVQanXo zZax}+#OS~~)fbfhARv2j6Za;K4M}=3_8BbR2#DcY9u(|ek9>4zwFN5EMvDV${kAe2 zClk&gLm*}3P-9$BF8`ScCk&yrFDox^vbes$b6s6M6oPvDaq=Q3#AzWzO=Q=wT4RDD zK?jym={d1eXH)QjF$tqbGBa<(;~pK(&HJ-K);otTYqhccOc1ko-tXk0-Q+60{X#cf z--Jmdkb(UMv=mI@)6i2!p~xrsQz~Wx+O98m$pQoLJ^>0`HSb`sH$-fD5O?wR`zX`` zlDyS0h~%&wejZ^bAYQv^zf>1CP;%Up)@=X(wTaXpcsP z9~d!xZ=LCxSvoSg`>|YkV6-i%f=OGexFNMI^a_nF6$w?66C!AfG$Ly!Es=w3Gippu~@T zQ)b>~5iqJMZ0%RH0CpJxJQ9oPZR(d)Kb+wa7xkq7f7*M`sH)Gd-4`{Q#G0Z( zv8A9WDjEd@6iA|?h@euXn^@?GG!-c(#`q)#O+X|dh>Czx7D_JyYN7}VNEZRYh|&Zb zD4p}W$@9GD)A_K+*k_Er$NMrIi3QeL|GUg`&1+sWd{t-07XT{-zptMYvZ7>l^1We) zw!@|ej=VW#?rhoYlwl?{WP`(8H;4>p9T?EQ|(?`NQJt^kDr zrO^iO2(TV{6?{b5N;JzF9T;pFdXwG?a|YQ~%buE#H?RI(d^^YY4$o`QGR2|XF9LW* zz$-MumHaGsX4@0tXGSm)T4uBZ5P&nh^cZ?gd7j_!-81T|L~)w6496vk-nvY5Ox$Xn z(x=U-Wvc}wr0rU1WDL|d+JBc?#ybJ)N#R&RIlo5iF_S7cktr9Tv$_gP!kNIQr3KE%;ekT z8~sQ6)<9BqE0`DT{q)A6%I@UQHuJ#Bpnh?k#nT%Y8=smx(D*I{QI|8Yk_-(En<@-8 z>+(ad10x1R$Iu-JSFzqd8CU0OF9)LubGh;U3>BB{&M>VOTj$ofx=O{nA!2b*9QIlJ z8^1U5wW_zcko8jZI3mRo#!K#`$*Tr%>lNw3veS-9+WsB~P*Zfn_{nOElT6c+NloXD z*0L!2YW(JSwVXp}S6uC;l)nTR}0vP1>7VVLEWL6H= z`*oGBr|tL}{ah=s_6B0-c}+QODg%REZIz4sxP^#KC5P?3HN!~Zr#MMn>el)mACxJ2 z%Oi$Pq9W1M5_zJS&d89{&RGkI&ni!u)~j5Oe))iD0&fR8_vMKmY_}Wl%QbOgC6xy% z44pmPl0y5o8$a}~#W@HwYUDda^rM0fi+w=D9p731;Q}=MvE#V*h!vz4?2bpZWiUwH zC%29ui0Vgcakqs>rIFH^%^r`R$zHm6@wPBC<)R_kEi!baOvjP4vn&7dG$H+Ap?F42w@tb3$^_5jR`qo$(V12rZmkcxj^cxC zSg%Y{+1P=rS-!^Q7FlXQTmsGm86A4-H@q@$;K|bRG^g4yZjI{8zdkM(CWI_%+O%n= zblw3J4uq843(XzFP@>InrUS?i7n95uKu$Zu0ZF^nH~K|H(MR{bDsAUG0=#z*N6@Lf zoL)Hrj-C=nc7~>}w=i?Y5!uZ+V8RlTBCxK_z+JqeRrR8+)#^B#EIz| z|5Uem^~<#)+wFSZpege1HB;1q9;>yml*!<(!n|Pg90@_Jr}f>8Rlo0?(_JM_s<@R2 zcYG*Ry$*lwEhmnP!qpcyN}io~ldn1SCC%RZ;YJ2iwQ9N)b4Gsk9tKopBBbJml6&)& z{5k#KG$&`d#%v1FUyzJT5-a!F?M+Mi8rx8&{fvHb9bp_m1a>n?j?1SLAkc;yj>_xm zydfp+SM)STL0PZan?KuOaXPTUnx7^Xe3?HhkfpN003P|-C16vi;zAKK;a*Oo7`I9+ z*`u6{=)rw7X=Y-nTst$HpN2X}gsI;0qz5Y=XmjSke08s`+hW^AOoL>kwy~9=D@LIi z_7J^vKFbl6tYa5a?f(>h?qEZ|6_PlCKwE@uFjo-zpJ&5Cp710VWL9iX+WAxTulyBS44)&EfRVuR}83R3$fh z@LaU7v87LSO5KvQ`=4`ig-C=rtAHX)`QwqeA!r1lL~-h%I1gf6mUL9YqKs0sFBdTkh@;|mV^mrTrdJ!!Y zIGV%UT)FWAyh^n?pB=q-yT{{H#F@Ut6Kz7=O`K9L+RRjqh_=E6XFX$sx$(v9xB0g3 z_EU)vQOjrd+Nr)mSKWH?PuhRWd1NsbG9Oc1tSgDbnB$D0<;X`|54}~vCCX5ln z8vss=SEqBtBYGz$==Se^V)cw*&qX1(=eye1Qd<^vN{#!4*s0r<8)d_z8g*V4V-iHO z|1K_DLE+DaeEMWu-xJP40ivXCH>a6Qoihvo7R7`WoQYkM>xl|%NECFJ8V>YpsCiL{ zq$pL{{5fgm-ElJ=e}h?7l5?XADT&1vld>NlU$vW~ZU=89wL2kU%w(0bAKb5uo*kQv zhL&(M2iWEDpY?NJUsFVh;dND6VG-$DFsTOCiWl_YQ#@1@MH&HwF@zvg?#%wrMR2lC z+S0JywYq{ZX1U}2%XRMMxVDQDMxA7y1gniEkIl2#o8KSlcHTBag>=)F78p zAWBf8A}0J2DiO&N@KZ0Hu#!%TTmuWrex!nmujy%=3p16a? zNvQWPb;$AOiFesuW<+xhtk!nm=QSkoo zOGZUqE~>tN>0G1uR^6__Qa`SPEW@359D5CD;n=!oH`n|*((s>qEC^QbaCl08{! zyQLRuf^KMQhzr|W+c0#B^QLM#)$^qSD>&*wZ?Rc77M_I;{bC*eZFvs;xhO_B2NiV1 z1RI(gb;i_QW-W!3VW5zbl)Q>6iaj>2s_I!yvK!M=q)#0lvZ+j6PplGrKem-jT6`$;1>9YBUw)%JOOsJGWMQ}QBm#LC#*)GNfO z-t(uLpLUJw&(jt0>L7{8!HuGQE=hr79zPL3NGILwGG8X{m-pg z@yM)~n=x*&cXjO#e!t89%gGk?`<+;MJ0hgdTo5!)_$Z)yr^k~jMLBa5DoL(?eYk$= zsdb$dm&(oZ<{I~Q-G3d?ywl-F3m;!B%CnnS2#^bU;qt7%mA_TI4UIv=RKKs`WsH;=VqUVDtKh zK?2DYf_5R27_{UQ3-1dk>j1tYxm<766JqHS5n~fbNuRQ?GI{)S+bvB-%$0yxtbmob z*gWmoge9su(T}g`D%Z%uwp;MyP5f4z>qCdwXw{l5cW{TD1Ud&u5?rs1dN=-cHDO#f zMMsm?DG|P#(Qs2C%USZyDG?4}o60D5-mvH^@94+ZPW9ayZA*p)VH^ zR3EzIf`Ls%PzwF0XHzCesgMlv53rk6q=I)e`kw_381!S*g{be5rAEpmQ3BEu$sJGO z3XHXjP~u``<}-l91BAgVnCu|8`5~`ylWckvE^v=vzP#wvnGkqICob;N#1gU^ul(^)>17Kv$YtxF%wwXZG7y%<{P z#1ow%<)wsfd_*ckd0(;LjE)IX5$mNU4&>hl!A&N${3Lgq2o7nB$sc%eRP!#<+Mz&U zs^qvnNv^K0n3HA%!}Q2|m-`O%pvsi6@c=o%cHhBv>lkv%UJ}=Baf?=&2&6zf07!3o zVQysQB_iEOoB0&FNxwLWq5ya)AkAEkSJ9!(MG^vSCJGG zD~O|<32>HNF#mLgy{i?64Cta+B#%U;uFtOIKc%=+Ovy4|CL)_4x_M?pj~3ZU(f~2g z-M4wegJuysJj5B`=xL6?NQdVRd@5|uagDx~e_uR6^J1-;PKNL4zITlLghFUV?hbp#8-!mUUtvnfEQ48+9D?|KD`GSISKvvAVNy5FArJU?hx8Cz-N zyQi{-lum4A*ulbkc5;IJjlRSU$obsTfuAzxDENAhA0Dx+y znHMab-dL5GrR^c{cdOf*5AV;)^w^3qhbl!L|BBBXO{?pz%niDaBZLEG7}-c!L!eM( zTD{+R2y7P9mnF!B0(kd7kyun`r(2O$C%%8>!~HwNxsmoY#(|0Y0WMe-hK3u)?I{W3 zU4VoMum6e|Xp-w~w;m1+)Kgn1pkTp9ksnp^;MZy{CVotSf*9fuTG~XxMfKU`KXKWC z@(1fAdQP{#k8=YWn3mQh7$V0>o0qzui8H7Va|~Mg;9Um?Vjew|C-Yt`&x)IAV`Ag< zP+E{mM6g3hw2O>bd?ZbSPCTU>B$+3CbWsH{P|1z;DN$>W@1okkG%vevQ@nHhD5;?TU4N^5$O8v4qoM6UVHMne4{2HQTJxpZPKBe(AOov7c4>S)4%yaIoMpj}o zjcj3J`vplz&|)&Cu9WRk@imjmv?okORPw9HT zGivKQd)H1~KWgg-9$N2sk6ZVb#|KAmezOLngY#;6*e)%|75zn)jCo>>#vkn{FnpmnEzZ(YOl(T9Tni-ji^Zt2(m zVb_U2S^Xe2UHt!_udDBjBDVO?*Z%ka!`Tym^WvlbiZcRtRz3d@7l40hocG?LQUCcN zscSkPNR9cgKmG52^Z$9n|LyzvfBKSsYu`HhpA)697NPznmLq=(`|-nT|NO1^@BdGS z&_G98O3LO~rM;21_Rq0#al6uM&9i0=jGN)F)!W;r8s4Iyp}`s{%<+>Z z1r_|I^s`F(tjR|y^%(KZ$8Mg#cyYna{CvK-uyEs(rl#B5mu2L-h1vZvaev_>my20h zX1s#@(xoOmDAhZ&ti|FXzMAm4)Hs)~U&u;nP@6?k{v>+s8-9ANMGghzh*G$0a?P_h zt|1{Iu?Y!Aq-v+9%^72}#?J1NtD74m6&!B<^;brY#kb#{0~XXTMt>Qh`v*jEUozr;+NeD&GIl zC*P=zzUHtk!Pv;i=zyY;qKm@KTeoOHHxX5~)TQhbiSkZGnG!FjLow)>Pn|X`3Zy8j zatKz!FTebvzhT2|)5J+HPL|pl@|}hj7SD|P>*_2x1YU}x=coh&MK6l0jBRUcJEbsI zyyd;oSnK`%0RbPe$tzw-H>aWy&M{e8!{OP2h9MiRF6S|-2wnTc2M^3@9MiOFIZ2gO zRm$yi;;va-@yGWpMvWS!`^M&U(fq&y_hS3ep^LaolaEFsUn;&*q;PLmM_MEHo^w$P z*VWjwYVnPe!veIGYUH? zIoUjGqQ$2`pIs1*Qr&0780qVu2Z`i;N8T%C`iP-x&K6g`x%mzUkm5K;+d8>A6%}?L zGu@~n##L<<`lhJS6_q^(^_h3FhK;D}PM; z;_q(+FU$ihXu*O77jL$y8Bj3}Wt=Nj)o8HGbaz#o3u$S_nVFg5s`Ki1ce^E5cMra8 zaDc}P%gMmUCx?MLf|*7OBSQS0&WT0dh>1%lO-*NS-FuIQBswPdV^PoGx=Xo+OF+=98$!DPAX4gH$j?X;Hm@KdLvk%e!e=CQ@d zCjV{WhVL^i`CRpNbe%}%s zCn*b4b(=b4#-&%UTxYAeeD(eJ-!HMWO0KA^6yyRkSOf>uT}x(HR#uv2x<>g9yj)M^ zLYvzuP+D%SpgwrC+`InR1N}ew&p!JaVnFcmu}_$-Qvp7w$RO(#*`E0?6#onMrAwd}D6Dobr_xLKC_rA_k#Ien@7o?8`#tXPlQ zVZ6Y+larP<7t(QX7B0FT1AHoAV9eK+?)sNC+JxPm_2#hKg@;sjynhy2pcWwTnTg}p z=ma-k;XK+iu44R@DJI_DS7}M>W|J;~=*vL+*fhUnIK6|KUnp9#g0Dp^(FWz%?CSn2 zm;jh)dyU^bwy3$X9J4h@Z^BCrLR9bZvNy+cBkOFLg{C(Q`P0pg+-{qf}* zHP1cyNCGcXQZk}aSj|mzhfA^{{|7-&ZlMy9$PE*|T+sS(*jt>PB~+frh_WK1va;y5 zcjtOXxaLt6zrd#Ii3e`Kf;7uKS=kNw(Op3oXdSTPW4!9_u4GP-I7egOd1piZ=)bU& zW*9@fcNxs7L{Z#i$d#*FxEJ{UNgjX$j(KVYOs=5K^s$*$K<>aR9Wmpy@b>L3#BAag zvqD)~7rc@-qnjBiTsfQ>K{1(=SKZ7=j`2CLG_oH3G*gi((zr((e0_7pN2LX!1%Bwu z{q=ZL$@b3`6!a)6aPhOzP0}!#i!f6rDB#eMjt|X!lWz=mPw`ZL8SE z;H7F}3Cs38ScI0tR@@u+XMg{SR>H`Ir^2u3-Q&U)eW0Z841nARJ5wB|0!QWcM8Cl} zaCMxiyf8eX7`b7?zZk@Do-$9zNzJ5T$`Aq9x8I{C)++P;EF3Qr)5mof$8S5w)?CCI z16Eg4_MA3%?sKmp@p`bVF7)+zO`5*!D|DYl@F+`t#^^zo2Z2x$0ZJuccHk-4?s+KU zNe;g|MFcT4N!+95=A4c~=GuVO4`L|TD)k(s{x0pMiZ+8QAq1Nm+4OD4ll9Jwcw->hJ-Hr_6I0{l}(v{uZ2-n$=r=7 z1hSQLlDzf5 zPCrL)1MBzdhnZgrJyUx|ws=v)lX=$ua>DE(3itNZREt8jzxediuQHnJ0ca6t2YKZZsa-_xSanrY`g?B^yi5i!c{W z?d@^QJTG|UVhf?I4@S#0X=yov>O+U5aPI2Rv&5N#M0Y7HY!=qUjR3-s@y#jB8Kcwm4MPEt+Pg1dcml{5N}Yd!t#*E>7LnfG(p5{1+^Z$0 zi=N6{>BpAE(I_Yu&d9(ZQoJGSC^2V5XxX{Sb8ibMX} zYl!y1EeOmJC8RL-n%=EV@T3ym7uU^Jb-ln?JlUA?17)vTTU))@_wIXZrq~sSsV<*B z(d_c}?+J4(MqQTc@6sXWv=;$bN3k3|_lA^@iFUe=Y7RWQ&^%`J=&l&eL2V+|G)Bce zjQ{;w9tQ1+i^_!^gNavm+{PXVkAZ1iprW#kKy{MB7C|7A+4RM8ceU>Hyg^-Lk40?M z^!RZkBAjhZ-KvJx@&$vBvFi(oXprGNIdRGIWNn=Y*;Fy(ev``K>t|;= zXMD{g^wHYc%~G`fn%;Y-wA6q0n&U@P{UH8{4EquSHDCM9(exeA&JSge9N<-je!1f( z^0*YAeoqvG3?QwFz537aQMYrLxR32YQYPZFCU4$ z;4M#;mX1MzVN*aws)I>Ugr+sB(88n&mN~{&9&1C&O#V@d(859%igC<=r~HtEXkz`j zF5){l0;0oUk(6`Wz*`*>O~HfZ6+CV=EE$P?ZHIKDq^?cawR5K}I!C|!{Ilwl6K?r8 zZ{D=S06a+ArIVb#E4hXywHOTPSJf77{)oqlyM%SE_ZuEWXiF(t)N zFUEk+MM8oz-NIL(W=kT`60;@CcLiI{2f_$l?uPxD^Z>=#jbx!psogMhEA#yPCe2yB zO)GLMqmKk5Jr^=aVA}7zHD^t^FLZV_Q%yrQ&Bs65Qo0v><@U_SC))4dP9d{vgOV8& z|2mi~t=fU8JPS)Ja0f7{BR~GAIQ{+B0*wQQN$}NaSt6+068Sb>DP)tdQFKyPK69$+ z@Pz-`W0%h$S_r`0ZwDouGiXxHUxjcklY*lrO zr#O$qdiH!a;gi1k<_D3@aq>B7g_R15iiW(#?qpL==f=&OO+a-zv)UC1%@$zk_Cdl} zsQD*51k|Gs`W4te8|#EIf3ogLrZQu93f-q$Nd|mbWw2Or)zKEh|WC4#ZS!rUM`gI#jJ~w&u{w6E+nQ;d(Lg z#&In9LBi$IX|gy`RgFGHt~MwcJWV_IRj6cc)YGeCgqXlvn5tI~0SM5nimPMU0Ssf| zbInnC=uY#)BAkk(}ImPKu=Y@ZrPU zPWQqmXv&-iX7ZoCs;awp^~~$2(FQXj zQu;J44cmYb)x5fFlhciGhzu-Eu*lShTNhNP@{=L>BZs0phY6!erI!XfJhMemhqM<1 zI6TL{|9&YMg$JRA17}p?$@Wa@I~4QWhYd1MU$X3Ukv&5X$$J?3=ne{LFzYuWUQb8Fre&&N%ZH2e7YfY``KmhLZm1x)vVn0C(o#!;!x^=2YcnJ~fO@?dna zUtS7h&tZDT7CiSDapxI_b&ic{eCLy6H@lw$HmqB}J|fKtUzrN2`$SBUWroYShrazz zfH1gLTy?3^578MJ+Xz}>!cL`<69Ywh!JSk@orP>}6VZqX{H>Yw58=@%x;EV&cfzB! zY#pZPxANB29j=h!xZB5=kJ{?>jC4Wc$-lPEog1;c-t9|y`HiflB=7a>*QwsUt{8?4 zt+7gfrEc9hs06=(N8NqI3#35a$^6p z^T!nQiK%f|GMk1EoF_Bjn6!u>Q~jxniiJU7M9Y3_4UMRjY<34U8L)OL`75t3APo{a z6G&=kId{S8%Fgj-qOdPrK@n*=a{cb-NmRsJV`vNymtK_q;c@xBIXKVKlB^m>V}^yp z)+Bjvebc9-qvL^e8ThrpZb1gT;H_KM+P6)21!oP3y1slYDbjfMVH{|)l1JT<*tIO?!2;XXL60qCYvE00qfAb zdHU*xr@^$=e%88e?hEoiDAZ@kyf!$`T0{7k$Z4?WE@u(TctB`Xl#!C2Hto{+^M7A( zfxRc0r0}LM>S(UWb96m*hLPk(!+=n~(Y({qr2Gw=AU~N1!Jl zJmPcpP~UP}MdkOVSnYo_R=5yo=6T*eKB>ElWWd=oQ?LH{=(oH)zevsT`2C01K<)uw z$_T~kcmB~za3UQ2;S51-+W+c!&7#g(8!DVpdP%7vW$CJB9z`3;5B8LtXcbr*t4y4_Td0C zI+enSzG$6_{o2pDZTff0r@LwCZWRE6#_)ifz$Zuv47on}gK1ZWo)r^cfgcnW7dt1_ zFU5;iJBR_y_5gID8a`RrHbZms_E6u3@F%k;ua6w8Dfdy^%1^^2j70(2hZ`VhbL;P< zpPZxaWy^?N=do2>1}Asfu2#`D$bAW@w67%jsA^QV$Rn%)R-9b+Tsuty{{|w?0!KA~eacBqgG!S$yn@dBGNchZfek z0*I@^4LZ$qwW(95%3`SlwWR0In-|{Id;89vz?7xk zV9!R7Zb?>~e09p%&L2<7k2oSksjL$=kT6nb9wTR6E z4oL5}<=U8k{dX(MzAd7g8!d5*uamO#e3a%Z~UI$NR(WJIop3OD@#o`WcfOQ`>nqd6%!-C?zh?}kQUA! z9P`QQkAaNvfSXyd8Sl>wk&^mgy!P6!4Gn=~@lPT(B=~QKfNBUNLzE};V<_te&de|F zr7;Gr-71hJ0Wsgo4=qxW!6AuI1>h+zHg@Ci;B&ujdQMwGH=I+_;7~96;)^vA-miY< zAvmeDp?E&{&;uonQdH4Xi7`N^2|hOx&i4UF%&a@z?aoILARg)ItyF*OpXT{9p}898 zo3j7qccOv^&P8tS?Bcg^^V|2&sQPaGlLW%z?beajV2G_?PDaLY#HmJ;I0IFfA&ww_f&bSAr(~i!$Mk$ zj`{1|stYJEQm_%ouRyghlj4s8grL=ndh|w8($!9uonedgDu`|^J!^Iei|+aS;Sy@U ztcM7ie70`l90O(yUj$83`^0eWlw0+CL4jH!+*bmAfdfQiVaChh3H!cYq+f=Q zCtFm5L;*pdd-%K(Tc33LZWE1X>Uolvh%!SJ@umdKRhOQWY!*PYgUYX5X~Otjao!yY zi;w`>hQ9wv>S{D2DEjodYwLqO3$rcqeSEs;>-u*dY){QfSWSR3F8-=|y3a+^eWZtg zQ88i_00QU1WH#oH{#A&H#as<`q}oOK#9P=9v2nQSsk3J%;MXo<1DyTiMXLo0K_B@L z>AiaSaw~}p9buhs97G?Z1Fh{M;sY-^H)_nd+ng<>GTh}4%_RNq^upRxp%e8AQB0$e zt(~euInONtqPK)Y?M3^?JZC0Xtu?v>ld?sFid7?ZbP9K1&Ad|`FSFxbJrh(%2kreKlQZCo5yVz^&Z&#O1AgT&Oh-Pd7eN_1Ob4NiR7qx-smQunEwQx%jHhlQKs|`<8wZgwuya-P-O>Du!GXr_-|eEdmnSM> z4zkYT*>G)H-DaWyu)wEw= z*VJ9wK?wx?gdUZyrohdZnSul_-#7tZ2*~XLNd1V5|MCK5e-^EJv*sYbUDn-EYeEZT zz;XW@^}wiee7d&w_Vl#SI+7y-G_I^E$$g3D@Kuo^fgK3IQ}^Mq+VY{*cp!)wwxS-< z-!QDeEyodwlaR$yhkDOG9~s$=6jTeK?>!PF?M1l{7=NvWKk2!>Y_QTxHQ1Ni{4oB@H93t1hI+QAE#G(;B1gfU?=*(u_Zaw<+K z;Gj8B?Il~?k!2~Rb2!Db!>qERBBS$?O>&I@&O7d3S<~I!orMmJNYci>`>sv^p^uo* zzL5MzNlD4RAvXsJ6XWWso_wR&A#%R?*fhMfEsb)L{>C#KF|m>5-)R1&1Ig&lU7kTjG2qz8LWA*p$0h zgleGGBPHeX?RW1iYutt7h~eh3es<`0ke6y7mb!1B+D3mzDg6@{5#K2wtN7y#>NdGv z$Z2iP_N7*e-zs!ecA(rv3_%gsA3)MWzJ}x=5fEk&l)(O316IUDGFNOmVvcRI8Gj`)m1uv2}t>#DbEDflYPP zB(G!xR6S*Z!J5to9T922q(Di;BYA%088KA zCrd0p5zjC6(WqtjlB|5Xjf75-(%|33JiC^MxFfu#6Cet-O-(!15uk8RbOS<=^vC}j z{Q}@ddG1V6;V3UMQ7B_;m$0vlxe&|ZCyAe$-%RL)K;R(+kC0J>XTr!3v5{dH%x2h^CE=nZD9o?vov_K*fiCy~@MCXhN zbv?&k6R~^b!U=A!uA-bZ?h8-df5IqcN|X=%Hgm>|g>jYgv$Ib${<~T*X|9Ha$c|h} z6whW7`qek(Z2x}3I;sv8sKcC67}I0K(Q6fDKEZe?LLSIG3!O^7^o~hQxBYsvM(@tU zc{qf+#yy)4$K#q2V^}97ZVu-c?M`l=lcN{&I_2BA>)Tx%R>e3}-+zVu;Y_G0|J{Rp zXf?qh=!CT$S6%=^e?U?oh)5 zEf)4WaXlLHh8IR;)5e$bPgL6mlvPH1tuU=3>cDn@U7jF_(jfn|{rj(<1ujo$jL(fm zX8FSPERsh79SR(iBC?25VGMOsEh5)2pvhTdu*(44=pxr%3cr}P*;OyB? zAwI6Gc|-cjTl5%d-G-qEl1hjvZqEE#hW}BH&#rv?{i*F&wz)Um{t#D{Q;^0D`yD0_ zN3d8MR-fS4P$=KP!8f6Zpc6cj<*Vdx*dVpvpGYdCXg8K273$bRvX*vYO3zhNun+XH z6C}1H%s@UKo`texvvGe$h6=yA13u9GKx%Vkb+tgBVdpug=3zqF{zw3sWGfa&5GCJ# z|FZ$X3uI46Lx-EUaG^H2E*lrocaV-TBiO<@8F+Yfn76>FzQ*$`J z`n`-h&gqNP)h+d6k_&3w#PyJRcx>U>Q5GN6g^cc7D@dTRvHJV>XYjeFPM>}|<(^72 z+f3;K%Qm0#73uSC|Gx^LiV@*|vu2H;_V8i)Rurpd*DH{yl$Gg|nz3DrRc%L7e}jpbRtafh#WheuvIcy&yk zGT+tKW-pkv(8Va5arx|9lSV%Gl7C4}hn-k1wJkqdZpp%hBDsC$*>4&*No96qDbK5U zU;kG*j{o(lR(}tVa=GeNP;Gs|Q~ag&I=!`$Z?^yN{{W$@GgANn literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png b/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..4c3762ebe376946a6ba97278779d833bfad6c385 GIT binary patch literal 106890 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


-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 literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png b/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..a14f7b04a677d6c9ce586d2145997f728e5c8c69 GIT binary patch literal 129567 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 literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/markdown-fragment-restored-1920x1080.png b/test-results/history-runtime/markdown-fragment-restored-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..06e5926ae5127c89003f7a96d58d0ce686622f8c GIT binary patch literal 180851 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 literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/markdown-local-history-1280x800.png b/test-results/history-runtime/markdown-local-history-1280x800.png new file mode 100644 index 0000000000000000000000000000000000000000..3731346f0996b96b13d7e4cf238a2b385b0b16f0 GIT binary patch literal 117688 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

    ?h_~WA3H`=kKnLJa(e^fL}@%Wk!;Jc^rv?- z1=!vSQYayUOb<>n1l-%<{NO=;;LF7;fD)i%@wl+RVYgzet=KoC z{7oB~O4->{NQXkS6Rf-l;G>>*AO8oa#}t!7^Q;#z;aK@9__IWQ7UJOWLKQN9m&R%X z4NV;OToQCkoVyLv4n7ow-OGU@>2i?K5ayO3L0}r+4u4a-u&&gEgv+}Z zR>J&+BvS@SHl-MpNaZaD$HX@4!N>>>n8dpAXh;qUX&X! zW7<4Rx&6w!uO#V{`FBz3JHN9Gvoor9n5(+BR9E4d+}T6IfzyLkEPU2O$O$xP6UAQT&CP)@@(srZ6iEI9{}YSv@3#ug{Ww58fQfud)243+ z@Lw|vivrW$Evuq*nN)UYL*00SJDExWFd3qyX#wA&Vpnw9^#M)_k@TfstDijzxvA4s zdVexG=gjzMSgUi0P*C&e7Q5f;HJC@l5`0$j(x~ra`(^(sps};Ivw5BKx8)iAFk?x+ zlMAl<_bpH>Dy=>|dWEd(dLMI>;o}08lWzwe6?@v7?qB)&Ql{m?Aw`2Es>9g3vd0$~ zg&jiySq>S&$>SVn;L!KxBumX{C+}WdS{!ONf$6V0B*TD$NCgOhD{bc!fQ~xA1rH;I zV1Ttl#tMFyxPdN#zGAHX;%0w0bahd-vw~-hes$>_QI?m#;(gSibctTmdIiiHkw)?X zP#%asx1qHn;_;qur7mC)ctRBR^FxopBeVg>Q2GFGP3Tb4)XcW8`|>5Lw09~j0?`u~ z?eU$NW;zL{T?!1cRrNyLs6s8_Y1D(=77i|tVP@(I#q2A%+oJHCe9gvPh4Y%#NAkWO9E?b{pY}W8xpdXGM*l5z}~e^ zn>Mpk4r80rii7nIRA(@_eT3q4rMABQpGddyoP*rYbgt{Tsa_FrG?$JEC}*-W+?-Qi z-plK-rAUvuho8gwT`%wH#j$ITKTU-2(46O9O*y2*bm6LOQjV0Is));Cg8z=g9^!H; zJDXeL%MnXGJS*`JaTrEp0iJ&ML zIPAhM4d-U;yogJg%~?ljDrBC!`MA-Z2FYZ5{i+5xQXGMKBdMEY;NTx?^!Kn!zcw}4 z$hUfR@F1*pi7OK%vA-di^hK~JOx%bG3G9Aqg@j$tHT1MU;LtVE-0@HgUK{|IY}??! zc&cC-E;?RN1`^N#QYN?^9Y*xbcgXP5vjrT`&fz-|<58HbUIvDPXk_>Uq&3vX1I|xW zY^7XRI61XsnADUjWEftO$j!Fjx3Ob;M>iTyQ3UY z?;0^KuOr3o)=w*a-0hy&cGCssgzfvO*deB@vsrYj-Lds;CSgC$SW+}Zt#w9+KBVcD z#D05ivbsOmXJ*bf{pdvUea6@`At>;y0$Ym5B_}A5qILVZc)H~ z>7T0isNGx?Bu*Ft!E2O?xOBH(m>~w&b>pR&)Zjp#qFyr<9wT3c6-Iy>z{tRHG3Pob zDd{OqW#=#$oTTNqTRNuUOLbYS45n1rY2G$B(__kq@=^>w1VlefTtWK#2Sa;ZK0S|J zD>X)h*a*af8PPkOC6y==4R<_AApU#!p+`WGh{XeWK``a`j{QtuNmCGICZ?ST{nmyz zEnn#+6iBF+LLB}db8j8h^}2nHf?e1mA)uh3U{NBipdtu@5=yHeNK2Q1TNFhF1ZhN& z6akSg2~k1mMq0Y0yWjcjv-kPkcij7q@&0+w*k^|Y-|zSNtY@t`=bBRkd)%{@nPytu zeK*#x!6ZsQz3^V)1M9{Rbg^Z)R|guC*|>E|2|ENcR4$-_!#TT=mI9j;vS1GLkXrs+ z0E}p6cm^}kwE>N5YV4A!swH)DaiKOkb2bFUG40%Sas5Y;nVE`COo0pY%Heqau00_p z(l5rX!PDH_xVYdG3hj>M7da&zY&2lexkbCeNQ_ z!6tg*mrG*mF^3R_>#=Ss%I71acCUS%rF%E(Qrn|!uRuOOt@iTERN4aEv{W+trUDqg z$=mPWxm0h{y@j7myFK3{?e}5}HAzY;*(Pe(RcHS2`^Jt+-$QbBPgjkS(NiJ};IM%5? zk5t2STvW7tGtXE!o}m4TpKgRZf_j$4Z6G^M0S1;BmbQcS)J6wH0xwiIg^Qr$B|QxO zYjE=mXb14j$N5N1oL^X;{h%xjFPVD^i^v|^yX9iC?|RKlGve`2%(&ow4Fd0@m8Q(Y zgh<6f6yMMV$J`s@4QBGAdA|4JhbE0rWWvVtC35-9!~OOxE-Zv8u&3571y0UOPVYEY zSZG->mU{z4Br&D{QbU*$xWdGhAIw6RfCnXxTl3D7q(?u>q8lgr*$y~5Z0tGGvo3ex zbd8gp>%;XdoN4t=!5Q0TLV42t1wGyDzMbd|zkv-CR21l3T-%f_0gl6_U|hPu@^YN`%G8?MY78|LJ{!^@v5qgQbwo%zT_L zmM7E3mc1?2noZw*k$S-kJI9Rs9cwT++*DD~Pd7mT9&F`Mr4jjV9ljs{<7Uj}q}u}k zC5KMOWP2P+9<&&P(mI-e>)~{fn3kpkVF^`vH)#IC{QQS7>mUh<`Ot+tgMA1thWMl< zK4D=v*2N=tF~p@jA(A+p6LRlOaPT~=g;Y5%ho9OBznb$v=8|iOwLa+I@tGOpYb$&X zgelv$ZQI;$=N&4k@5SW-oV|Yg_MVjnM3SlOY_xC7leWkVOj|G@4U=1%Zt?OTz<4{NPYQfc>dl?K%@gp2KRit4{kCV z`s)N`E*ALVUK0jM4=koOeAw!QTRH&QTpM{`I>pUtaeiZZ@y^*#czE=KRKHMV@UGFT zzprX@*-9#7uj-be(IW#n#Wl4J2G!>>C`fpXi9q@z{A`Zz*`h@%6vPhiKA0EgjX zq*CY!nuKow!W=PNK$|5Eh92T(%st#lZZg{49@w3U-;M~N_iw)|B=gJ-DlYG14+_&M z1@}TIO7L}R>guS+HSU?m`WmhyFrx6+FMkX^e__e~6>PT7Pj8C?P=b0X0iP;-c^Z29 zngmTz_~O<6DLBqsa-M9#6K(^wmPGlLmcB$-33YG@Zf)Y!LBKMMOL)2PGC_8PVz}?c zG?&0FpODK&i%13%7e0CMXt#r zzgarzk%`y>cM@JcF_(rtbCt4Pb4x`9^bPPi`2o8H5|9Feb~r%;-;Y?nrbS{->FM|E z+ZXAe38^QEvqt(vtzZqTStSK)l3AbL)6%{WZFXO~$ z>3!*=SeH}ej^{%cX-kk6H4r%tl*9eN=j>vV{7$)(Nl>c^G!uF_I{hcql0TDQtPaVZ^& z`LV4>PR%D`<*N2>!4M7E5Z$MhG3i_D)$Kx0-as=fT|LlL;`+RL>(TP#`9~@d3%Etz ze15s4UH^!5-bC<9_mT_=S0*qOqr=oS(9ZHkBAF(+^Da2{MEv+0`x@{n19b@}zUXqz zIO30nvv}?3Ub4|aB{GZ0R2j4#`h57lBhDsD{=p$p(GaNCI8EAw$XElyc(?0gD23pw zk7u#oJw57?p^Z((RZdq>TVt^AN>{OrCnpJPSqG$x3hYNfm6l6=qzcwFAjW!RLxI?o z6nh^7GxJXF=vhc6VV-jvIjJ;sbk{XBuEIeZpurI!az-xOll? z@9gBlXH~5cQGjk3SYWOx0V>7MuZW=Ly6eBRJFPO{*@D=T2-e_b2N0`aVcWg(zCPH0 zAgbyXB5454iBCU&n7t_SV2)HEHpDytF5sb~f;NLBeOgj8K$^V)$ta;PY%F8nd)-U% z+jkTFb|>SnpRf6R$f!Gau{|TaDuZgW!1mQfmnfHewLae3`^+3hkCZ!FDU5P%&QtH3 zDtbC*8+JlyCu$WoOxd2<)PSSQs4}gkuIzY>-xQITv~n78s_$6` z3=a%vTSefm>bcl+c*!nUo@rT3ZzRMgHQ6|V_rpDZz;|N}J|Q8pK%*h_X<9WlfX5ig zu+{+$(PR{#6Lx9m<9ZfY))};ol zU5gyAk97+<%qdS6;3#UcP+NCBk=h7J64{j=0J&w50GK2Oe zB7wnNGEbVG+Nem_;=fntgaAnn6AbaTmahA| z#&8&m=e*+RKD=a=6aSPM)6XUfP?g;i3wcTObaZDp93+!|`$6kuPXnJJsIDW*3|37~%IlEGX{?C8nr~d!>Beh?D{CFS# zwDK_0`{2)?#<&47iAKWfIw(DVYSYlrq)eYyd4kN0cXIf*|GaAaT_t!E#TGFJGU4Oe zq%&-Q&o{z)ELU>I3bs-)&Xx;~0;v1~)OYaofIjjvT686o8o>s!|=6T z88%jD;Z2OPQdnI4A^xd6Lbr;{0an2y?t$il-%o9*zJj4+!v+05UB-Mg&z=hIYG5dc z3OBXjN6=^@RmJJ${1PL94gY>BE4KLbD@)#aa99ZkH`&n`6&DnQT^okQ;mtfp;h}~E zU3AxOBcex-Zpu`ZmP2 zC|rQFRr`XqnHeqToX@XB^>mZ3{7ssw+*pR6eVwW;elhX<0Mh@u zz`9bp^`G-WVYFx@F?Yp_qx?~It^=0p`IWUG8EpcA|9Wn<1}*U9)b}~nU-5ggrjT$R6B_C zfjK6(k?@-{KHlEnK&IV?>lSiy?3#IIR~9+Un0lNQSdWZcws^5PxacH4PB(OADUpV~ zHc*K+X0)2H*RsyvBx6CleZgo9jtE7Vy3wU&rV zNlzUA^w83G2n}JrTwVWl?n>o79>8xX599f$&gf_)Cjx?ug!lgUle?|LOYsZb9dOKY zTx>(ZwsrV&Bn|+?-V0yD&0=mi3`t1v8rHJ6w+FBK9ryZb66HBOjC2@7-GvWiI*7cL zlDVt8L*n13)X86IGX>%!VEoDl8}tXqmFq%I3NI?FRLiN(iNrft1>dUp|}`swua3m;FPQ&hQnRgHCj z?q;@m={HwHg6*K2K^!RBGrXY*> zlv(B*h}AC4E;XfU0b&QjiwtftTsmlndZCgrnfZmx7Y|I?Gvm-P*g(WTWN2bwP=Y`b z7c>OOe?cvV)EI)vF5=q`J}r8gP{{mdhY@21iXIY3;`t0GO~V;~kTCJ`>SxhR>#m}! zf{A9Oh_6&634Mp20)r6X=mP>4&u}Qhs%kad&jO1^aP>1N+CZ$L=_W!!>h&gm$CV2n zQm%zU=WAsp^y-oi9tZ*NLFmzEr9~M1R$)9rdw@K(hPNib>QRKr8`6}9qC^aP5i3^p zQC$!$S0*CVFix=WXX-b81~&)*^~2<{0|HxYpiZ1`$Xof2O^Z@!eGJO1f?!aHG2ilS zI6z0#cY=_jr$EoU8BWOX)e?`1%St$~BTs zLZccE0t;tf1-*(~{+#32EO!c^+jw4Ke`~v?g)9cvr#@HgwOzQ1UzA|){*YbYdZ@l) zjre+#qwoJ|R$jERn%f=hc+&Xn&-v86LBpZxZi>%rQ-uXzwzx^jTz#;Cut39;o14zK zL~U_d&NKaGWAQD;ng4p`w!62F#rS!hU7hEg@Jg~=u=vxU+-LLeL)WDAV(mj1GD`cO zCKT+|H)p@#AWWwvr=)0Ou?JSX<}k^%gV?b;ntJ3;eU(=m_r?y!PUoah@A$4*vpbU7 z*BO`1Ht3{L;hySL*Dk8*a|#PP*I>e`;Z>N!5j{0}m3| zFp@w5@KWpN?$(FjNYV#1a!?)Ni2-)A6;}xM3LX?(Ih7N^ce8;8^&2$N(X6EJu z>p~j>n%f0qy_|00(R`fgV<%7cLfSPmdt}XdN4J;ah(akq6$$ zTH^%239KA<2ef;xkcy(~c_@pvvszq!x6%({DA=3X)d} zc%OL116RcnD1$)qIizT0?}`FB&%io9mODZY`_68_)Ep+|M5Swn`4UP6CQTn%|#y@n);UCGl^?3iC=xdT9tiLPD#$iGm^93nBg3I zm7cwk{kmg$Y`^JhecVOd->gpXv5yv0`rDnK)u5WcwLAE!jn{6^%Pk!SM~}<&bF~CX zh&XyVzbHr%t)vO0ud;o=9|JB#2v43TZ8?6RsY4)x!`(qJdG-4VO!{Ke9%l)afK&c) zWlj)ZJFR~s!@rM`%E#}N`LJvzyDlbq%PxRSf+_QnOh+WhndWS8Oa=uqXvD_jrY_62 zq_eGnKc690N~R!zQ?1EHD!ct%`c_}}`ZtT6{^eEo)|fAJQwV+iW)?bDHp<(xoBEsR%xt>wYckkG&my5N9aJQVSU_*RR)G>p%gr z2tb=TLQ02I_tDbE1S|7{in3dajYV(Fq_5#(j)fP?HD9puL4F%qJ|a3Z=rB`LQ>~mJ zNr15|TxdVoS0VTs_ZZ?jSTwFJLzzvYu4l%@cL;yOJzN3p2_V6(fh~ZpeP`%~@X*F? zpg`OE98s(ZIWu&)x2lav80P9iL!L?x|F)M@;{mmPBYT!Phwe$@c(dJL>@K8S=01Mp z`sVE`CnMBz+&-jyu6Il@b%dOEtV>>VVBO@D8g_bpGGz?s<3QQXK!J?yu($p<6FsILR|`35RmaG4HmY#3g?rLBEob!8b$Nd4xOcYOx}zm}GJg?{%SL(x=woZ<*BRqAcbhU(iatiawz6-jx@RT2ctTVt(>;RQ)PlY} zD0-$;xAr;Pr{Ve-#R~C%dv6z4_IxMqzsnI1ee!nd2+f=_Pp8q^4J}hYKRZ7C<$R-m z;l8Yx!+{Lb-&?NT?hDF^OLNIFsoS6PU!BpLKFoKbs3_n0EM3+yF{AT1#4`rsYWpverfA@>hK4U1>W3ol=OU%as% z2?WDuK$>ZD1)RAT=@A^P9)G0p1a^UL6XWZ2;|1rR(`XoX;JiSZL`=bRj7Buf7-?xG zb#z$QZrFxIMEyx7cRF6AOhg7hX;1)m50z*9Nt{%u~Tw_;;-|D+3ROlU*^wlS77D)nDE` zYM!pxE+azkF&mzfIMj2YX#9>eJ@F&ha3TXKmbT$AhWQb&k`&S(&>Z zo)_9yQYlTD@9MMh8nySJk-WE3KC z-5fVXhDo~!=FKOdm9eJ)x}pEXnJPNSKiGYw=;booNuD*p%rbOH{vr-Uk%;XuFA@(^ z^%p&K{Az7!>GoyEZ$A-xx2zc;eF^h4pt#t{3?!c_F+9B<6OoZYvvunWRwmWgS4{9E z0ju~}zA|csQv!j*=1rS^V{6nuD5xg(#wkQ(#PU7W(yHBiP33RqG z6A&~2CZGsNv^t4M(h4A{LA>q@wWS)VNlE(Cxk0hL%N$4V%_Yaglnx*FERF!A=jFg1 z+!Fq{wt0HnjcB8JBn4#fSp?f+f1g_Zhg{JBgHXD@&k~txk#C2Y_CAV%J3oo5ac+qDtY{Unh?3@7$ z58%<;ZI-FVrrVao&JjCOkfkxSwDck00S}&Y!}9cyB7X-283imJLIq&$$}txBV|Nc; z0!Q~gU%XD)Q2tRrQ@?DY)3?p(y50+JC9L?zaHrKtDjGo20)IP z?T~Wu#L)II%dw=Q+WN5arH8yQ0Gpd07Jg&1%J#WvHcEGT@>hm+clTgZ!@0ZDI}UmY zXz-;bt+BLSzaeUiG2;h&YFjNDWu=_x1;=e%r_V$b)vj33k3LnFxH5s}&aCHrSP*Rn zAjiK8>^obP6*noH-|!~o&z#Ub< zyxx~HFXLqf7XQ3bF{&-z!p`eM<4;rHD68LnfX28;Pb%#sKVbrVVBJ?c|Ty-{w|*Wdpcv=eP@ZSnQG;TRWEKE-`hj>=nI5u_lhkFog# z%{+Gm5kf%?GaE+s8-G`302o`s^$X-N)l(#b?Qa z97Y@gTRQp@@Sl(cf}2;+4Fai>Xi(4h=YgHD-nI0BX}l-Hn)%P}dH~s52@Nk27f~!) z33Hks&H4Y7a#W;;#eEo)a7d0d@8s6&yCdKCi>?IC{==#F%}qws?n>a+d1`j#O1H1l zi#eK|6wRS``hELfEkKIfCrN{$vZqJ?+i@LQ^Uz6aU-1@Zz7UPlbrPN0sB=S-+zkXz^4wfb zI)qsdV#8{{Gk=B+`VJUwLo*Io?ghrk7!_5Bn;^At^YgnAzJPb}+s~gd<+r@V-c{1C zd+zQYm6UWn`_0Vxm}5sF)@xy3HUew}IT>6GqSfo00-@=*SQ7^kHr5zuAqK(v1^Q*E zZ6IU(*3=Y;%{pE?r6!EO50Ohpwl_do{%J~$ZJU9V`~HoOdG=5K)@NKr^`<|5DO@S` zCuUvjRYF6DUGAS{Wj$DBhaCo(>P)l9l}23|UZ^ujJ{(4zl#3RD_N#ZbwGp;g^`7|* zRjU)$VcQ)SsdRO9kcPJsEofq&2QnNAe$`Q4RV{r=XbM zStz@ZH^+7$@9M0pga3guPg6>MUhd}lV|O+QoR{~04qUzy(7@RMP}!ymdxY1EuNi_| zF2w_0qH|q0lN+k~@M}TUbG=1P<|E9zWS#_GUHRz$%YWDSwCONcw^XXj)}gPGLc5x8 zb3&1DAQVnG!KNrHzb?30q>l?U}|{q3#ux?Q?<<%{uOTv@_&&Ew>L`h#A@1j|1OL-1(mBp!T9WqL34!Wm5zdiJuO}Z@sC6BtKvxS{eG{5UVV?L%X>xhan({Zb7B8su44d^sw^!| z@$94HN{D9W_7*o)^mG>VlqC0s!&?Ti=(dwLtq1T2LG~L2mShzfWUd(NYiQA?PyQ4F1&(aFZHz)RkiFpS zLP!~>o}Jd#6GX6sSfzMu%WjNOkWx`m3I5`SeVGnKYQ8!o4r`&qDA@wvkHD*h-(i6V z>ucB}L0Pzjx(eAj35Ov(QDm^|hhl zhN|*eti{$()6&yl0cSOjNV3#s@<<_xGo>ydi0FwBg+zR3(Z;PgXKZ|u=&%q8=~r8<1ivB`6ixcT7|gV5@ABwG(}0ddHYQ%^a}#pM-yWBSzh zWx&K_pdbW@!q})W1{J^bH!-O>LthoS*U%S#*>BB5mwsF0?2d{2%G#Z7Szc(jKaS?W z9?yMHI0{0~5Y6ScE2p*>>7CoJz2Q3Az{?`Bf?G%Z8XQB5x<3ql-tHj4!p9YQJnu2? zW^cP{f-i=i<|}mY-hrDw=}ry*?EJNn3oG-1_Z0M*bGMsJ4YB;UAz*`qAkOeYw%rNM zT(!~b-;no5Scu^+EVMxV+@LZHsi$(5h2D!oRJM&qkE}ZqbpJQ`K*8!WKysdloW&>K z3^_q)gBJbUF9o>_^kPcs({gm$I=VmQ?R(98vXjXG_Ya}A!Uehq{E^$e`Pm*U&>9-X zooP=3r?mg%Qs$%cC;htm`l8F^jqcoegtcc3)==-ttEs&Np~?DM&s=@$qe>pYY$6T| zh<|;boD&-Q5WxE-nd|p_XdeP^(^JUCIZiFCA$rz=c4UJ;n^7NrCfby>{^s9Z?_7Ha zoRCoBxdOI9C;@mc%KEN4_IU9OMMJd;@d@A@Aen1O9ySJZ@o6bn2=(;WI`HE2Kh4S6 z0huojb0wIYV2V(ccpW%;p}Qe5gdnF*^YVVjZ-iJI0v<4yYOqbh`DmSOh>U~-AT6Q? z6%y7|`7KE9WK4m~4ruy0Gczt=y_0h|^BC==-<>=I^mNPe$l)MnQDK9G=>5z-p>;*kg$5%y;jkvWybZ2-Z zTpEJR2%y$uDD%4TJ_3XSz-lyfd-ED_KqNhqtcn6J3BKb;L5wm~(+oTCh{h-H*hQ2M z&^*LV%AG>fd-&}$qoH?yAILF2MGcLQ%O*&U+p=|Qs)r_YlLT4|3lGn<1zhz9g`jCl z9UvC|U?drb3KUJ@N;D9bRZ^mcPG)*T0oJE7Y$sU}iTd6=uCTDMOsFOcn~h+O#gO|2 zHPjnn`?QP8v~+Y>1%+%CqC`&Y2`bUY9_;Tz^}-tkk)>A7Fg^loWc2|6(8ZjeIujHS zFpgUqYK+R6Gal+S_2N5MifZ>B+x0jvZ26S2-q0Z)0yzvVmkLI>;9M0|N)wt;gpk0sEt?swk*9d64ODAjxl7Bf2;1=jSJ{tn3OP z;_7`)#KK;=Qle5ZjEHI|tSG>fHid;Qc>DU^(a}-ra}1DnQHlR(?xYUaEYdDvwguD8 zGw_ShG$tk$RpCz5iX5=u0_g+loB<~VA%D|$o5T#HWMu9VA%ngFs9#id%sbME`^sKM zheWi%sXFW1t~7O4ZTUxa0PMEXkQ6+;lE;Im=0T)iI_Z~e+$!L>G%f)^?8&=#x8u8z zut|&(8dDAS6OzlyR~?62D)M@Bd1d7*0@9<>0^=GI5RzO_^{R^>PY9WclB*JYX;0BK}Bz)@pz2jh=-`e)l!snzkMX%;nNo;Qy z01#YMLA5vT-i<7W1g++M*FP$t{|w8F6gZwnp`*r7x}f(Vy@6ahz$GausmpME2n!Q4 zq4;zOj%3Qp%GfKaYqGWuL7!G*Tv1jQzfZgCZRB@_is#&?Pgia@PO*E>9!PIl&ZMyi zOX3^F>ba#m?w1{~SID6(gaZp;2|R!G#@KHy1o5?KgS7!P!sGDCf!CdNu+)1{2H3Dw zT>OQx^Y&L4y{$*k*N#_;?>KLH`ZOL;EIHv(K{dL_`nryP1hY&|GSm8ZBKUnxodOCQ zBbe;}a1yo_y4DN${Gp|lbI6-tTto+4)4%c>7r)gX8X^dQn%U6ei~4__-~YTBk2ezW z+|+1$Ui}P|#3(BMr*E%8%K)W&xZF{q#lxBp7W2s(ig{@(<$1g^bnLLe*pT1Ys2nt8 zg8LG6N+R0qR9*5$xa_5!eG1Qvw#L7J<=}h|uIr|Nh9Kp98vbs#)*CtiSPBU^>tU4# zUzZ6D4cNX1k-jHshNdYMiP6+nF zH{v0XX8nrDSv);r@A8|P=zOFUN2|NwR>!(t=kcmA7LZfdG{YwixeL(2gofD2%O z6AsB(#q5BYlLEE91?$9Y?^}1qC_Gm?*|52o&*cZ@ze6`3n;qvs&meX?J~byJ!ylgj zejBXwgh_*rCl!x7{LG3VtJP;9v8s4Ip>{gWkT9iE=5uNl-h1&7-vEliPiS`Hg^?yk zJWG;s<``|eccD-LDE!lsDLD-E@L?*r|+$o*Q3l#BC1H8QC3xD@hc%# zjBpC+!^;Gdv5b5`$tnB;GpPR8rt<6O{Jw5I1?M*E9u$_UOW}Y)=%%%qwu{7G) zIVp;ecv6utR-u+>mpcOFfXBsq>m#E&VH^J+YQUDYgpY*s9?yvxSV$BL*rSml4wuNZ zh0UxtW)@<#iuh;@DEeMZ5GIrDZ|G~R?oF%n@$nIK6WZLYL;B}4;u8~h1xQCFKTm}a z2EP)su0>-e2~$Q{Ao;gLNEB>^TCuNghsza7lrm5uLni4L6K}kuzdV%@WO%}bO@KFr43Er=U%4xpNmOsqXit}HWfJSKK3JPfz^MHCO3yVU65Q zM{&HTfR>}b?o`2BTUB$&g7%5nUkdCid;j~(m-zOyFRVN70igx7fhjBY@NH8Sy2ScA z5juHNSTXcBJCtKN0fKN_Bryu^%B*Z9j&j=>g?`oCws0lO7U(^dvSQQwC`MV@LD? zY9Rt?&hnR)mcAb z&D)pNeh39L4P=!DA0M#RJiWWosc4eppReC4^T3SS;>SL-D90>{RXt}r0dH!DsVWHw zNIH6Y?tUso$BYW2*`#ib+l*+6$^Rjn(yt-kDUokN){wipvS<~O981vmJKwipSF}-! zb5lXijQxY^j5W*oeo6p) zWX`%4e#sL;{N(oP>fJDL#K{?v6v91$dBEQ6!i3mG=~P{ z{y732JEE`bc?io7B;O`s{~ltU=v1s$!5j2DCSYO$_3qtY08|L4fafNQYg@;TJ2<1q z)w8_ukt5_cG+7jYv+IQdG4Yc$gad))R^bwB>QycsV;VFMoOG0T`!2q%MXPlX5%D#UPinIvw#2{p33ynm$ck8 zSbhOKg~68wrXIwn7USz2L~0i?(cgGH@s#bxkHlF^)>W}IouB~c^wIFG z;WAbDL;AeBGL_y&`Lhchj0(kNN3aE^@SHG8s)Ds|dh?Ns-a-_svLj_R@H z8t>kA2Um!-hU$;2fI9sFqbo=|>}Ge+pV=PSGjq^#^x*liRz{qTt}186w(S6!ZiwTZ3W~bw{>(bVcw33S?K#7h^ZP*uqd5p zuv`4Zgr_o6z@=#HyWmTsmBnK27tUVSW(y1tiZ>aGKf2!i12P#hMp(4pN2*` zgeEqN!|5<|WzgU85LeQ8=ZjO=`F;T@0O~ZVhZpp&u?e{!1YeU1r9$_QOD<~-Tn1Qx79D#+tAoKmcrRBww ze)6iSS3vHr&J~HCbz1ox6$ghQC=G0EY#{zU#=)V5EtDmgBV3ACvd(C2GIxM)g8FYmNiANZ65PTf^gVVXN0YM;d)K~1D{($rbi;G_q*F1J{A-8qszQ%m~NzO&QMNdDDqQ0`Rvp!0B^1nX66uo$?q!Q_IA`u?- z<9`?Y{yr??pjSdy`Hy)&h<@~W^D;*Q$<_?+VnANX9)-IY7^o_HN28$NpkOe*f{P|+ z_B-xZbespkIf;v}Mc1^-yY*1oMQh<5t(Hz0$zwi#?1gRc3Ev@$HI*=60UmbC$fyhs zkNCHjZDx5Z3mj;`&`s#41&cbp#pd}=ytgQ(g7n2qflrco-+8-@F={SPlJ}))xT|Q^vzwRj&o?^a*NDHR6JL39yn1`+D3E@kwLZ%5Q@6&NOsBR1r(}eIuxfVot=Lv1!G(PHg8ew*=qL$LCGf znm>GCFG~vyq=PDkXjv2$6;X|MqNd_OmK_`%#75J(>}F!%1EJJf*!~njZ=rI06t_MC zi9e;Ku^u|~7tA;bQX*vYscpj2(()f%9lx6Z(m1$KUU)$=sqb|hAI)*iT!`umU(1U8 zgj6}@V{~q=Mr<24n;-?ds;eC1V%vWZ)V6He(0T?c;Gm!aCHdo=Q1rve3-TjJC_-+yJqK zLB5G&Zv@!l515uS8__o`?9Q zHmPL`mREpooi7l>$+0g`_UqkK`-g6ykycSa*gsmy#nO8%DS5QYY$ymxx3{^%#*{TXJyncGO*T1 z{jwE&q_8O5)j6{+NwziLz(VwmbBn8{?L*eO;RyS3z2UsIuUB}3L_8g}*&<9GMlbHI z*O_~{_147G9g~wBsyw3SXjKC%bzh45WZqSqpFiMc>2@=-@i*@WrV;1Gv9FW#$1a!u zr7C(|W?oz5F70=I6ZV~H{O->gu#Jq&JAtoPrMa7E%WH4f>ZerJcc1=Q+V+2Fi^2My>l_w4z~}&FJUp_8~nX^ zn}vmhuqx7l3R%k@5)@QIrr6YB{QWMp!9o*1SQw-zsQ2yDD3JzB&(Jc4$eAFbOM@A*-jcZV5*}ouu@X6mK+Uu$nF)4?hN-l&0>h zz=k2_e2VuRKat`ULGDj-D*<~`$zf~O$1P4nOFNE5K=T2wl{S$wM#s6^L_#e%`Z?U^ zlS=Lbe$a%qk`z|mD@S0ag9kp1ridJGsHw30(}$#VNFg;n!a_y@t*7^nppiin6cJ(^%5V?g%li~UF2+inc zSwhHga&p3eQzn-0j%vcGlPBF>TsFY(z@pp|UhS0kG@6GUmUG4n=?Tu`;k4uoZMI$- z7Mv&XHpxdnu>QQ#v?p1Cy{AlfD{l%D?Ird1K`W^xF{i!47-Z59YgSY1pYpO3YQ|d3m<#+ zKoy62Om=7HqhScB;1Mt85fC zH8qm5vK#a3>(kVWnwp!nppMjdYkWuIv^2BNZ^2{FerJlCPFa&@p(b{Rsqv&_aL0|} z;-DRw;dva_S(N>zua6yQ+;Qaw{mg2fI*n@JEknJqMr)(diyMRvoFBa{qOv(ZyUth1 zyz{lg%hkM%oBbIJVtn}Wmi8a+8!ItrKAp;|i>_u)hT)s|HOg+yAH6M6d(=F#a~^d= zS^{w77M#RYG}G8*0@qm|-aG}{*6%-lJj0?jCS%-=qZZjIzzZLvxAl7SCXU-;wEWcA z0`4VD=JyN@y8&eVhZ$&ocNTCOG3AAEQ~@9`%oSz1Sm^g8j%NC`@3$YJ2x_mz9$kz6 znz46J89T9M3?ctIURa>glxy@efO1$K1@gX)s(O9OT-! zNwa%P=9hKM=Zvp!I41h`$nU@8P?TR;kMtV!+o^Fiba^FCS{?58^qdM|l&Tc(h+xQ+ zs?G{mw__m9=a_vL?77j5SCA`R9Gx}?Y)Q6Kz!s<4J2f%!5GFt%i^TP~{7uz1YnXSQ zf)<#SjqNE&8?wZn!P{br8p6ZNn~3zDvc3W+;PE*ry2(x6Q+7u@HNz$dY-%;E$VB8lhu5tuFC5^@>j>xNwFdtKc=G%#cr0g4))#VM+J z!CK{za*2@T(HTH3AqSLpPa#BmlkZQ6A1q#r@+0hdtX&FF$b7%&2U z0vj59Q%z=6UvE<%rwo4MJ|M=o+q$g3*VexY7-A}duw-eg@}I>JlgzY+vHcwdC7I-Q zUml^)|9p_|x}!kR{E6`s+hdKkHk@aqxUjrT>3p~Jfa++YZI|2@o>pnOw)nJ^z&#;_ zre^bwOHtbn->bjH`dRq(?fE@(`U^#YuH8VdY0=eec_PMbq7I{JykvpNLFJn_$1sHZ z0Ej^QOPOw0LZNWO`lCz}9E^5iE813Ge=DFX9K2!>gFGe|U=D!~)YVlowrFS%HT8yc zI~F?V#cJPWAyc9PL!u3d%=!J-#yMDQk^fJXAM3{)C(UZWd2I;FgMq>phS^ih%xi!; z;AU%bsdj12Ff|uBt*hI&y`gGfL&K8ubM#@jfFSUuFpdv&NFdfU*JB7$-qiMXs5!@; z589P(;FH~x6`~stVm}^2C1=U+^%xwXBj#~B+#DvT;WP|C`Zf7DHAbs`;+~xPMuv2(#wXF z+)AQSy{-x;4P_5@_1A6xZp$WUvJp#Olh!BB+$=1u@_HUuO+EBe$kUFS^6Z$^nor7- z`iD%N+W#u3evEoM7D8R%Y(VXqwYuA0qrGjw;BS_#ogUvcMutL6Xh%;N&6^l=a#*m)}Et&ahV@ z#_WmecT`5qq$=zp29I4>X+!s0fvqbS7@J`-GVvDe=KM@rG*oW( z5WlzU4J~s`3DK}u#2*?VoGIMAdJrr^OjQB{1Fc#Ue48Yg01`ne#N;@42)#LGFK2X{ zdejuhPMjb%#X?h+;t$Y=z?D~@pg<37 zc`K$KjgzMG)w|AjEl}mMq6WW}$W{NQz`ri6X?gHt7H%-Gq9A0D=+f9)R#sMUH6dWK z25VDch=ZvP5npVaoSuLKF)9U|F;WrynA;V&hk4(=_;jjtFHL+>;;gR`tRey4mfdFy z&<#eL{QDEByk%cU+U-Y$o%aOwc3Z@4yrQVMkN6wnEj^9`!6EQM zsMx!6tgi{rfK#FUDOb?m$+BqKAeSwefm&H!er@{4*zl~*g}~-?e&xCc3C#oyw}<-`aEVSq0g$YI_hNda_l|GCi*?M6_#P*@+9RIO1@$eV$X>yhf$=usuikPZ$G zK~YurRw^y}5(0*A58QE6V2#Xr@6~BAudqMW!9RQPmB?Pr6Q|D#rS(fOs`j1K?Kkfl zbKMd;JC>`^<72*1@^wSsUPCwOLlW$Mo=ze!CN=4=&ei+Wr>NYxI8)Z(?&=osg$pQV zarN_NZ(Gfw+6 z1mA*68;D;W@#LF*{?6u2a4^*j1y2N|7r>vB5egzxL?=FMa<7da4!wZpz5%i%GKkvR zvDp>hPQKc=@NcTNZy){alGbucyu$h2MA_ZI{jJctf3L4snhgFF-_$H3BO^mP1PHxh z$^7fHZF2cw9){}LhK8>G{<-GM6qh62-DDUZ#{XV`hri1M-C^>h{z1)#+DQ99Olz1t6R(!H6+~kXk zPVq#LCab}f50b6z6Q?VDFGuWtDg81gp}MTUUAQ(Amx8%w@Ik#U(|Fr3A{xRbb&MoM zo73vDoxKkglgI&_PO?OfHLZ84r_jFWZqnC9x0TZzr1=mVltv%e&YYpf40L=Kmw)U$~v zPJCVoY%XYN`7Fa}-7u8E!kL|!IS@B^7b2U)MAN5zZwx&!@p(CRvwz_fcU>yk>mnrV zThh*EIupC6)`xy2w5xygdFE@MFY}Z^b$b=ZhgL^A$3&eD-?BTp);PE*H-THCt;WGa zKZ{p3`rEne7;m@tgY=wSH|`tB_Pw>=GZe)1ZEE?~U2T2o`=xRVvOB&MGzKP3F}p7< z>Us(K|1C#<+@1|v8Gr>Cf{^j2%t2d2ZSBPRA-=A?F(Tjs^Lcik!P1TsekHsNZV-F$ zyodt!bM3~Br@_I&^?keqDPPG>A9^97xhyQHDHIS8Fg!x}BR)p^B-XMaVVWO^4zZZw zOV2w5a0U*h?qIp#k5CG`M}fV0hpgH10!Qs9CoAh0>#8S16FwsbP3Q3I+_Oy|ScZ`b za~_B?k!g}^0!yoPQ#RfuJy^4acl-r2v!wDwmr(^iT0Y|g#P$-KdkumSlK0dt&mWOk z$qAyGb;|!KJFC^B^A(tyfA+BQqrPqD^&?ixUZ_qE4KFsQFJ)-&iL&b5=E!_K`lVFk z<=0u8S-7>Fr@IfgzM1?vxmsFi457_EQv-Q-Gjs4Y!+ zK)}jdX54$gj<0gZC7{UOZ|0WPh*7Ant*yQDxoo=3pEJB{+UhX~I_wY;@|YwrIypJT zw`u6<=>fRjxpQZwzB)sWQ@o^{a`X|74g6HKKxpR}N4x%9HKkq;O0!;_MKRBqlpZQd)5eVA>&nQ61k zf{cq^CQRxwZS%)Blrt4RV)8w`vdZ}FUC0U(?}KoY?XtNuFIGD8tPK>-m^m5<1?f6m zYRi;fqx(clKepM|&5|!qI;GbB^WoH;^s@HOiK&XC9ejOurJ_#uTz5IFRhd`KL=#2x zzWrunIaYBTrkH$*cW2Ddu-_t1fNKq`pvwT4@FhOa`31j_RkSP4KGyz+ki zuY-bKpl*U?fZP>?-Z`|`Xc7aBAay&glClXk0H+qZhsbJ*mv<+v{UJgPFA+grl-ziF0Kd&2uy%*fxeyJv~wNG zZQwCSaMF0-i1L_QcWRZ!L3R%j(-13E!1fmRQWejuh9`Lf@}JJeAkI6sws~)Y zs}HT&_u;F=@A299J#u^>GNxL~HYzZ`<}zuxQ$Xz&#S{OGN#oa#`b>_Dxv~N38&6x3 zb$7r|0{4jv5Kz7N%S~~as`Gf9oA;U|16%~e3E_0S?}?*FLlYA{G5+K%!YzpSW|7!A zP?E-}xxKeD%3eQ7BUbH@cJa*_lwv&oQBk1_-CpS%{$zE_^g>)iqqaePq8XDiMnH^l zTtUeBjHLCc2eVBnjOdn5NVmr-VoRrf-m7FJ3Y zl{4-if`sKn`D#Pc&?!DXKd2{mQ&UIPP%qd$v_kb2=48S`Jk%giOS^k`c<5vUlp$RV z5LFe9d)Bo$XYPntYSBghQ%H%f2T_Qp$9h(X*;IpS`b% zH~6UJiCWeD>Ah(=L0RW7UbH$!RtChZKZ)7am#sGxl9;E9o2@Rt5W#g!tKEj;$=&8& z10L&?>VEHB;aCq+FTI~lj| zkid#2x-RL2kF2b$E{q0*uwxtiJo*TBa9~lqn~qNZ(=yff0N7u{5f=!=KbFTBIjMY2 zOqMW+8uTHkS}5dO-NnC<-TAvOEPyYjg2t&ZkJq^+Lb{mi z`TG+wUuw&@0blhORGBELHnO`h$dEcEvPRcM-dE#7Tch-0Gur<}-J6Ex+_r7JSD8X) zlFTGz3>i{FhD3x?q9SuCLz4=b5+!qnkVu+KNJ=zNnWK>qNlHngNmA=~;(qRXtq<$N z+SdB8Z14MQ_w(H3y8i$3JcfPWkNp^)_+{(0jb5w$m*-5meY$>%?2OeZolPDD)avxI z@M<+=+(gBWwO?Mo`ffX{c6-Z@b8*?UcraI6>rLQA!&6;XSIa4es%a;6jqYUM#C-2j z`&yllPS@T(00}~+t*5WQhc!!-6&bIFtmbrAmc2R&U$>ckM~K2voO+L_-P=)-ytWE@ z^z5Wk?`W4EQ(R=4Tfw=Yn_39cKah|J<2yBx~k9kS14wl<;uBJAjeWNmSD z6!t4(o50u&d9u+?eac=$HgTPr$c)Swt={L(o;}4V9h{MRRpvr9mAD@t&xj>07(*}> z>9KV9!13cx0ohpQU18>C@s0J}XdXcIEIy92ad4Q~?+CuqVM?Bdn?vM z@EX?_{<0?e`Yzrb6ZZ!vR^_;5|5y7jpgbF7}6j{e>Z5}{ja#KI-Up8g)Hh1~`iYwaEQ%W~|te!l`E z792fxYKHD6)s&AZ<7;ni{(Segck+y(OG8yXT=iF;JnAy3HMPxaw=Il)*8cDswleu` zeV;+9M>~MX6-B4`g?@h7cM?f=YTB_!h29gKo0;Hs0_r3G`N&EfRCpGUDR1Iy@WFCASj>}N>A)hcRcXvfFtX5F%T0JE0 z?Cku)SI3eKKb|9fMzoz^$oew(a(a)Fu2yicC}4&JHp$YX2Rk(%vO`W;Ijn0|hH zO}lAt0xy5v+;-H839oe|AC*>pE%OU$>C*2g3Wyg!n)(GB&R=4zo1*gOhfK(~A%Vj~ z6KpPx&{jl$vDufEfnG%M>y3x7yWp^1UFLjfz>cE2;55`_ynUsPxt(Y1nEz!`ey z@2nGg6QORvd!jD_yho2Rbj;s;f}<~hPZ&q`<6j&(c`_jC*$iFXHo`7&o=aUPHX<$u z8I=50F}Svlc6*56P;^&fXCo#PtL2yJ>Ut{0_@*HYaK*v~=fKxEEkZDDM3NTvE#n^S`qYi%4x$A5EqTL3Buz67>gqHq4L~ut zmiZ|2B>7Nt5sbEGg{AcMHu=!qJ)g$b7gRoZOuV!pvFv1>k zIKuYvpq_jVwPVdi=R@vgc}IYcllw_ir~Q-x0A9irjm!4w>I8KNc}rY+W+5>%1WV4C zarRB%ME{xbKdq}})?2l`Q@ZKFmP1tmH8N%go_Nj=@eUju{NbC!n(>iC=a#EP`DcCJ zZZ|~hd~dgzd)kfaKUj6T!AhTJ4PFf={mnY57kw;=v(Ddk_>BB~LsPBkbGzwi*mRXs zTm#(fUw*HTf|n=ZzUV{1rVmPQtUDoS*kcfT`|)qDz-M;IG34Aq^CS2N>3{}w&j zj;R^56(cWdziIQ_zMJJIMtNM>9JRdcQVnvhMXEIt_2?f>1^APZFWAob{=ap8@m)>Q zn|x(O-G->gj8G=<|IfMv{B~Fz-v8JG{^nLtVeh_nzOb$HBBk+_Hhop+I#j9rBenZ- zd-$y1O1-4=L|QGGcI^hj{X@MFp}MKr4ifWxn9lhxc$v|+!LWFuicHWPosx)?&T9X2 zbrX8Lrc-MBv&kJ-rM3udc;_^`WFGudp1*H(ck0i|ZUytGQ?K7Obo%!X#yWL*=cD-B zVDYc;@5=y{7RySJo$BhoIb(mbYf<|Kqe_j1)Jrt-;b!sHUvGXkJzz12nlE_vA|HZSwaI^ci_mQ(0N3 z{QYIzy|KQJRtNw(L=C?sr0|P@#;)yqeO@uKgzx07U-f%^ik^1-uQaiJEO(?UvOpeG zSk_glbJBJ6-6BlORzPAB{XcycivHe>wJk73PQbZyVUY^cg8iXqE%6?h>!>>g9 zR#fHdy1Kf}KQ@=zEOjXQ{<>Sg|7w$`o~-!#wU3&bH|vELFu7V*6au>h`ZmXVOiNr= zZ?D4MOcVWLI!|=TVt)!_sVK-iKn!`+POm z=`eB{RaI4up8rwnwjgxjrK{01WrN@|C?;14H8bPPq;~Crqs(BgFz&5bhnYV&(`XAb%x+At6Gk#D=KDl;wO{ z)^~w!i^`pmN})kUvOXPr7Xq28RqB+c%OsF(;@!x#&-x%wJH=*FnhoRcw}s|(vdz

    1xb8as78S9v-yOdUeg4<<^!FXxGuOu6el?n_^{;Mt5+a}2{{e|H z<6XuGDgutaN3vw7?d%KNVx$kn$N&I|?>T7;{E5s+2 zFX2039xW5V@(Op^bUqn@W|+*JJ6PwZax5 z1Y8m~T&t+;VNuNJ59h-Fi9o;G7jZhB3|j{MTqt9*`*x+8*+#REgI#y-8r5_n`_R$`VIO4HZBhw3|bYa0%cSV34) zo)vQPa#!OeQMR|SiSagd1w%s5z|fN49Vc#k)r}>%Mx4wP<80L93qC!$vy$rN!-o&o zZ}pUYV}_gPrcIHTTjcR$A!{=;4cW8i^?0=YyxBP+hb|P|cuDOFi)R~>vr5}gBaPmr zEH?bs{-*zthRd8garW$isTcYnMd7Pi4mg!IJ@y|hzy^fJ_t6xQ*z<*=5tl5`)$XA~ zU=+DQNnz6#nwXrMyCLS|H2u+h8qO%48U0aoOpVVRrHpzPn|a|M8_SE&Zm0|I`b@TO z;i7&)ezIA95pQCHeNY0CtS@ytSb~@f0i<#0TfK?JA@Mv~6C|10*(I58 z^GdS<{EHSTd*RdW?{$2%t;w5T>JBI5rHB46?8;0%Io{@@?8y;ktMPvDzUR%g6NU^Z zd;!^>WoGWVefxIyCWuV$Z_EjzTR<3txT2*P5Rz>cg-ImStsD+YL6*%of35RA`QE*a z=;a|5Gt3WQ#f2v857d&S(;2qEps>(CNK-?@oG38-&eeHY?ty8Zy-?myePF6iy zLOp^i>-wiy*>lmi&KKS2sPV52Fmm)!tPxD2rKIh=VpqBd)fjH-A5X9GnZj=J)2C;d zn)+Pc%6EY17|8quQ{YVa?)UImk)PyBog=jR&><*d|90j*diDgZWaL&JX&gQK&F#Np zS8v*P0#NVp(W6P2o{B=!xLB+RV_Aj*TpA`gVP5ZaXsaPvu@t(>*xK%@?y;s5C*Eos zRrIE8#l8#nmJugP5X^czvky1QseRrPy@`;|-9j}{tKd_^46?Cjd{k*xjx_=MVq6@3 zV|8(+Lyb+yf*|2;jf-RC#g&rJ()3L;YwqY|KPdgYy{fd={`_dg`u)%MXpD7lvrzNm<-lKW zJpcEDx3`%dGqOrNB_A7lp!Xw6eSa9p+J%QI4jY0D7B(SPrSGPDNy)_wV%?bPsX4Y zh1n_`!RRjLTAn9qQGqmV+LRbQ8>J!16<0j5&e3j_e9iHndK)^Qxcqwjsq~Bs|NU7q zyAD#cVR%b+$C!nQhEKy*aJ6Wq$)NtBp|WTdQHDGg%4QcdS-qAd_zf86T;q7eu4HfG zsBNh&(l1%RFB#F*wz`ekD5)Bm-a}2ojtn_~UMS#$Q{RAi^=Tg~O4SOR^lp`EwXyf- zpK8m^t@cK`O7DnGSGeQ2Qq|x`q4(9*ZRN(5_Ac2S8KYwGqYDDTy`xJHmUw0axr~_F zyXStJD=k_L>6GbdQKAzvYoe`d|MIeI{_;}Pw#w2P_U#O^Olj=Cs<6z! z+Ud~OWL`-?!|jn#qh~H(D@iXftqjXE~+gml~>lRR5cUzNCL@>WkAp6c!_A-dNKMGV69Bp+xMuzkVY*HT65o zWlu9350ZB0>E85J$fFZ&+&YdQ-J|uuA!FzKJY2F%=k}^MZj~2mjVDD7$Wr^cHKiu? z{?aMAiJdJTS)d*YfKHsVU2`u_<0C4`uy6evJL_)qKl=IJ80Aqqfgyp@`*|%ZTg=(YY{xf4TaewAzNHhh{6; zH4cz)mPfCD(A2^`7__cRf2Ef~XwJhoCzg8*+-Q|H{e8n7t!o*mU=uTaRnc=@JxZ`0b~$i%& z9lwwJcUEfGSF_-ZmI+ifOzKKea#>}lv~raOPG!ro*dhZ$cdm`!!aw5D@xAWWU0g7B zvsHPsr=gnJ=2vAC#;M8duyoqnsIXv(@ss`Ys#PFjAOd9q7WGWbL0&vtG%k|5j)(w1w;7 zd+ZA~J+)S!Ti)T0tasNb_gWXU+_t{6q06MS(`{z9QF8uYOr{Xr9uaHX;qVR73vl@) zEU7w*4BgU6x_ZyxM;{9&-E7z8rT@JVjb5$IQllMJ-{#%j7Ic5O|Cq2qlcKmygPM6; zSNB;zg0X^?DVx7NT~+cqWN`4segU!*dk4HK{28MM_A}SqDa3AK-K~D3PmEquI=cBI zqu9JTS0}W1)8pfrbIZ1DzvrCz^p#Z5(v*4=k2WV%Y_4a{+P=ESZJm_oAx1H;I)Z_1 zU)UnuWwrdklgXRxGxj^TkNNkA*HcF0qIs`RBskEk{ItZxy=ZP zrPimnC!BpG>J(2*Sr*G86`JWt4<84@@oBLBXNefOaY51(z~ zyZ?50+zpcohXIT}=@0MUxK*wwe(Y;Ap=tGap}U*mN#H1KZH%T}XXcv!nH`rC+$ zci|#(dvLJWs5jb?9Nzn0hBE_}@`7dk@t`8bsf7GgSvFKwNsd%@4+ts<-Xl)HCx|mQm{w zh3`fsoJ%;JAJ%raRj&oFoo^m`x^z!nrdN)6p+$y8Ottc?VfXTKy18v1-q~c`Z4GBv zr%`JYHwrnWwZ2V3&TPf-3F%*!zy7ym`8so?_3QB#78YWI1Au@SlEnlJi!S^`C|RAS zJY`A-1w;ZOZ*9s_x?-d%3?aXaa~w^S7bGRR6e(V>eLlx-vEQfoYAw- zi9>6x_s`uh@$X>eB(H3XbJTtJ%Zq;~Exf(nxu*kP+S_Ne%v4q_Xm~5T{&o-J(4X}=6_gu$CXH>ygC2GQopb_(hLyr23!RPryyNQz2mIGxBwIY4 zld}HQH>phP4HW207!Ib-#!84L0}Rr zE1m68!8I4V3G4^`Xu){h^^QAuF*J}E_D8%cD+_y>_wqyU=)I%eTwS9|<(Vf0>k10X zIu{-`(^;a>ty>4+6l?1*8w&@%ZyFZ6muYSq6@6Ce%lq-_&;ViqGBKJ=^e2!feE*w)Uwv z4)tfw>SJ!+tGmlR7JM5YGxXrTt|=;|Ap;-8pVFPadN9l!g1R|n!)mLKWJ$>Oo4;NA zHEn3GG{z>59XmC5x_!%0{U?kyt^VNi;9YI%&oKzltXeRMwdPw3l@I6BF01>=Bg#7| z^+iqPyy-TX@<^#^jh16i4-8-EWq9y)=04L4radiouYNGGV}*S7N#5QDeiHF#tc0O+fgh3E~EpJ+c6EU17a zC)PM^w7gU%;1K8P(5GJu=vSIIZOWcx3YuoVY=cGfjWzv)$8i&S*x?}0_$2GF+b}{V z%-0|}#hi&nR&cnC-y;M~M`{a~pDW8-(PV9)RUbx?g9gxX=yzT8ZbEuj#g580IuoM4 zNlw~F#n+f!%T2eq<7lhe-_&~#lD^7TRXATCt@UbAnc~cC{WfA{r*HRV&Ng|ovF+xW zZ+tI?hwlA+Yf+0yw>~u;Fs@b0{XKVTX|7qS<&>f4cjm77ox$x(joY9|?TQZwF!`ggLu<3NGSzXq-MLg5X zd6r*YyXsR;)3k288n3s1o%FNMIF*axaeLj*jyhf#-BmJJ=RxVSE0+J>^7ghtSr)p7 zZr-r{d{kSp?8~;C%*;z!kMzP8SsvYJRW@fez{jy3wpe(u@_s8r?Hh;m9Z#svjxalf zPZOBjJ3zo}wU1BxCcu;n)AeCXQqY^LhA0B{d4o}iQB@2M1kB6B7dv<$^d-G891#xZ z&{PE20=y`IMD%`3xO$?M;g$UvV#v{vAUy1dV;3TtiT0nDr5xzS3p|Ev0xrI|EVpZS z=*uh#ht(o%7xUW9@K3s`6mycDfC4#ZQ~d=!5vIXQP}24t7FKoPdVCj_lNK#=*gQ8N z>ZZ2#HShO2_Ct*`F2@?qs5d)P0Fh%}ZuV-DoO=0ECC&Iaf8AB1519@8URyQRE9}1V zs&9nc!wJ?}pX4hh>pw*MpW``@W=LLVVU&orHfcTOZr=K;>*SShBSCJx5ix z``82JcYe0r!O*8oRmqd525sB61;svR>M$(E_ESe87h@v|xQzVJ8XV`vDEgDQov%w~ zt+llD;Bm9fRIqZ0xW$kzdP^LIp9KZP#wt*<3+5bD476P=engS6z2V0ioC`L`^q|DR z&IV^fn7L;BR(E&nxtel4dW1f>z%4Go)CBiHETO%hZpK5$k2{FJ!GfSn_vO5GASFPo zz|c^|2@_6(yrQvAX9t=uQ!cUFsnc(Jn~l#oGW|ri z=EYT8)wimjwV71(-SPbQ)7=t^4?2q`RDPyuWf( zJ?3wA*5A?1CsFmQ*p=uH^3vHitsmfRkQao#D@?obw{~6pe!#V;z@m%M5}rS$gy0JF zl?|dYj*c*i-GlldpD{2h4q==GI{dY^VntkCNk~$03Mz@$x1GC!nrmT&0QAr1gI1A7 zvUtr)I4=ggo^WX;`uwf2gm zkCTP)5MkoqS8veaQ>To|u62#Mbt~FE`5CzUZn%!H=+U3QR}_y7Y<;86ua4hRuNps^ zt5G?;S5fEuoN>`>HHI@q#xDzFum1M?0)LD`nhO!&i9MH&)x#_J+%_;zwhVXsHn}I0 z(5FFN++`f3%@OTA9FhW@LuN1-)-&8%9yPoWZm`+M?si1hkKqZ!_G=H_bXUIM`a?a+6L%`<{NZ=7C-OL8bhliVH zoa}DF6MjJ>F=|vv^n<~jcKrQ7Z4#RH>7W{gVPF^D=*=yW+q++|a9H2(&tJrUuA7Ez zsj{l7bX(8={h?{oh9#g~SFr6lP73w@pI;WqrzET!`Typ>RND5G{QXE-bB4;T;3$ig zrb0USE2@DTf%Vl~-6pT6!)dk^u{jW?_zCCZ~-MJL55&rT?RDKS6c zbc=~F6-0JS`mpnIFFKP&KL?H;-3c%V0Z|Gl%k^F-k*^8wz8ao!WhC*XykLxU3OiR1Lv8SZI&hqX>mnB%GH_GT)1F}|lQS2nNNN9h*_n@F zOuKU1_85iUv5hwxx|hlo(~MnQs6Io7%Hm6st|Qy;hzy?q#tt2&i)Udk^({Mi*A@fz z>agKyD&HP4#!iwOhhSl%YjGhL!L4wu@M1Q*ukc3*mm_Xee!8{ zT%3AXlt~cfi%gp~u0}&s&_lTv@Hr$q5E1snA5852#3pKd$HT?l6lO_~@i%SW+?;4~ zSX(h|*a$D33S!({48uA->GbCuMJezt#ViKYSp`M*r06(CPAt^(^73#MIh7lU>U!(9 z2LmM%?snLX5po_33+-@UBet6$OPqe-j(^z`U-KgPX!Aa#>te_L3w^FCAC5y)}n+xhLksE=8)} zdioYAtoQY87f)P#&~XRvoyd_yM)wY$^*4&8`$7sDo~i$d%%cN3hYuCtEYr)XG8P`+ zU+m(IBTdKsBO$Ws%A0d#ljbIJc>*ygjY$~%H(LFgN47)6bJ*Kkx!$BJfmATx(9GJ3 z9iu*+4;SWx{8o#M?L%zZPOH?^Jt|qB6W)_03hm|P_a!7GC>OD!a6d!e%eiQ}9oqi+ zx$`|XmF22N1%)F~Psjge(j=U5rXu?jGD9)yqkFV|;h@e*l8b9L1(;ooU3awUGRi)& zSGMq`n_CNv>8`+1Gv_>J`k=p0>xs|Q3p;#F%QUZcmzQTKZ0)e-oJ&$P2UH@@)+vm= z_he@3*O!_#CUFC!va7EFxRB;IBB4~WJQk?RU*n5~9Gh4S3U5+?N%Wl${&}Cp<;p55 z9VmfQ@j=GYCFE>Zk7OBHS%mKcughuCT7g-KR8Q%Fw`~D(*2&4X%h1#NnmdkQ0yJ!R zX%%)V!fl3L2JLqT4$+9~jYA`iLZim-GgkIgl;C&QeE&3Qez&jr!-xez(|Gm^7A{nM z-t^B?d;WF?yZjg`bt6DeH8YEU=tzA*9oIv{N#9Zh^ImDZh^FG-;&VoS2nG{EI~Zkz zXQV6lu8D+9TajxSk&it_Xk@+cqmyerf41Ug2Zs%|?%Np|B4-#Je0y_K6GCZlMJ#^{ zIk_ou(_+2M+KkI>u=eS0((dmoG+}}l8(|7znsZ_8Qru7(H`xL4UaHpF=^>kAI&|;e8XCgHZR(S0 z2ro9(Rjw1;cZdn;cH`}`z2h~PC~uDkpzY9M{s1LOqY^rpw%mUd65JdUw~UKe0QMg= zNIPe_UZ-b^%wXR5Z!(B-(uS256}4dh8@kTsG{zZQhK4P+Dc1abr!Rigo|hwaeYG%w z1NF63Z)E(%yvn{`?|-xa;+A!M#G8A@zD%53+TeCJD|h`cF-I+#ymIBGaIK&$w3c5v z2#HBa(v0E}g7+Od#J89zNOTjooPKLVBHYNURMQ2FG)@J~3tPn^BXM|ylP~^`3Z8!c z2#pe`UY`Xm9C+&VY4I;e_~Y@WW&|qu_=dK>{*;ZGJyfJ#G{rb=Cup7FgzczTlsqWA zqF*}YV6^B0Rrb=E5NM*)Ug<1;+8L`-V)*dwKMK2=xXsib_QEmM1IZV%l*()c>^nv0Nt zSE8fc2myp23|`waMR4N!X`xbL0T`g1IXW1Fk{>ut3oqhYavbUe>NkY;ZX54hy3~{j z`aqSo#0jv{Zsb2#WNTC#%h#@zArGT=40$NsC>|4v@BrlFBtzK!F-={P)oA>YD?ZDMQ`GKZ{nLRz{ zH$Cbi5S_ii2tWWXB!APeUrhZ^oj0!?;I;n5Uih2Rob_ahg=iu9@~B+DQfqCztMvQs zrw7g$C_!j(0CbNVCXEE(YfVi=WT?I}GT)G@7P;{uC|{AA$gl)mIG&?~e}2nh!&V&q zc%A}GZz>s&d((-bi>1xX-YU18jDa0cd*8u>TcT`|>|nnC`i8f?Bw|#W-YZ+djawx) zo&veQL;cQ?=!<{&a>B@o>&+xcHigKO84a@cuBF3M0gEa~a(9t)a$KwK#B;STB6`2M zrQ+fhzhV|LvZY834fI@dAEmT-U49}-=#^vxVby|vQP?r*v5uWGlTwpd0kkf8=~79GiWRbH&><)VT$tCh_QuH zqen;D6wQsR|Mp0fn^t6jl$y3!XLu~bCQ!Q`#Y z+R0{JJ9kF9)tvD~$&PT7Tf#sC)O>fY^Ju~kJS{DX@GgfsJ97NE;@NnY#yekY0Fh+k zWpQ-hbLh~#u(Y||&&PlK0N^APH@w^PKN~FC{xUT4ihqopE0N$kUr$ez`?e*SE&Ck_ zOsx9TZyN;sUn}P*B?)6<^A{{gf1$Lb2Y&)%4kt; zm>DO??Qoxz$Iutnv~C2SzC(tDnARA6;=oTwD^4f6I?YE|hZS&lMK?khwTySfkh6%v zU#qI9PKIQbh)vxLXhfWlgz_Z^Q}ImQQiio3C&(P%RHLga_ix-vCYzkRdU&9GSoQ)A zf|zqD&I;)|TsY!4`~;}r`0T>%S|B49elp!lQJ>wzs$`=`;7*hcwxeeQk@fdZU3G4{p-xgvJXCL`X= zY!EGA+U?=Am;|l8l(e#RpasNl{Lm5u=lf+oG`uruYwZ=eFH_GUV&PN?$C182cDUdB z$Rp|FrNf%oJ;tShpBj9s0e2)j5FbhU^zYv!@KmtuZzp(GNo-;hFh{7R3waZ}&UYmx z#w*@dvS26g35cSSi#-{G7^ zU-Mz(N$qTn4XKLa6EmUPlCgJe{H|n=E&1jX(vF#YWUEP;nL_#7OcFJ?CO2LGyrxO) z_Fuot@T*rx!Dh*^;w4#(`Q+{muWKg(`$McuSbDBl_Pc_8{&vZ+FVgF+tq;;Py=S~( z=KT2`_%a>3bsIeAu0|zf-Jidqg6?d2x$OJlaq^Y0-deEttY9@1iV5>W8d6z zV1zEX!g z1sH^`dzSg~<1-fty`JuYo)hH&(Zt4cJU|YCw6bj>Xz|`;>S9pk=> zN)_elGc+@{+m?$t_kC-%_$+KP#-aG3r1gL9FdXv{+riA8^+=?$3B%v z0QKbM(1mYfVxQ}zl@9`Vp@g865H-(-!S5u+*$ zbE^73diYS`xSsNe5i@ZO9Bg?O=rlpMZ&@Jq0L$ zRh14ak9!tW`m@-v@Jz|~oY$;GSPt?lcHYu93#TS~)04jpW8%aB^0{)c-ctDf5g5cM zkO;R%4BD_Y<>HN3BY)qN`72bN+9ag#=~D@`&)BrSTtP1D2|z?E3OZ18oD^H(p@-K~ zkg5t;VJE)x+DpGQ8|ynsf=z6!NOhBw+huy%VJ?JhQO4t&W+~|s`0sg1-L_GuLrpa^ z$Bm=pqNB!)uNNq1tITVSjO-wXwS4ogXdQe#%_(($h~ZgkI!uKUnDGnNaDn{;>ZcBl zF|Ep;X;PO9EDaE2@xZT$`PJq=3Nbb6UJouktZIKgOP~BXh&Dd()Q$R~Ce>zi?Rt?z zr)<-3{MMA-B$Z<808<6H2(%?eRfW4z+G}@5W*l~7jywAsavh07m8CiJwpguSe*k>v zZepSo2OhMQ)!=KNYcA~Y$++<92I}mEKRb2qyax}7tgyCWGk^3TtG3@iZH^@S82AFH z%=54%m|IVFBi-21&Mp?#ieo{qUB@2e-$b5)_o!(I;yL?$w z1@kJ|Ax1JQO&Q6gW)bdwVFR|7rswdO>GO5H&fT_LI6)^OeBqoSIR(zunucE9kBa7< z!GI!IW76gi+7f}E2cOMY)yVK!A3i#9Fe1bFc-Da0hYueDDK|f&eXp?(SmkpYVI##c zP2jeSbgo`c1!1_vni1bq6Ks@3Z3%>AVznAxz0>L^-mVnb%i$KY!rflez=|Pyo(gUl z#jm0xavfc#N&%^mYAE0nP=S-hr_u4M@N)(9%%x88owED-*Un=iC_A^*ym6=?pJsn* zw40_YfL(&ZZk|zPWu;gI$z!jWxu)D1#|q?!pHiIdi5p1V4|f z0Iy<;%A`74v_0f#-q|MDcF<~3=fR^UP*tefkMRCRRWjv*9!aH_=m~-EF}4>q{e?N( zehPEoR06J;$%(5>pDj9mP=sY`*Y2Z8e-9TV*Kux(wmlLcV1z>yCNt!*7Nj^6wgqKw zBgwVHuCS*l;P{b{0{JxF9N*o{{RmcqA}VgaRi=MLwAHJ+YwqUb#;j6 z!X}$S9@Rh#0ilAqqtrL2oi=!Ac5|Z?JqQ5#bTC1BP0_cC_Lj^1JwdI_8Zk%YmF9@caW3>NJ3 z@aUAi*_QcZ)DD7Zh?-HA!y=wxtj^mR|+qQBi>K%>66HJpyPtvRaG+qJdFGBLHS^Gy0xZW2FMsb6OF1^_v zxEVG;o3?|xJhu&z5W!*2)US{M6Cf52`e-=6=j(_BcVj)=Fjhg^ z>M8)l!#^G01~Y$(ksb(kvg>S|Nv;s7yNip9 z6;@=W$hN>{R3I)mzWhZyx*MG5Fv4EL!coc_KMtI35d$XIo^-~%Vvg}##f~C;aY=*i zJLwaln4J&JZt8pg%^Q=f7ds3?jV@I0UgwWvuuQ4r1nkXWu}Jhg8Q|ed{$=hhR)z(W zq-efx&9@_*=Og{9Mg0>=K|dpPTBW)W4pO(es@4IGZ+981S-+h^EPaxO#%;BT?@Ql0 zv4KVT@dD_mQ2UgVkTB%R<=7sPmRg;$j-{`stQmEB&Y&SjwDI0{2mfKIOLy8qS880ZxwdjW8bWT%wE$uM*^ zu=Z73bsA502H%?E=os@gAe;lzmgm?*bNkY=TC_{7PG|<^$C28Ktw@%&&a6uBf z(_tx#n1vJsTiKX=9Uy{-k%UKsmuJH31R-JTel`6F?XHs$&V9u2k+>n!OEAP9_XbrAXK;3c2_1Vp8#`M8A8p&Vw~HD znf<>ukl?!?0}2X2{aF&9N!t{}ek=*YKdq^ukV1r2+{7A)8W^}~<4n~>m};>7&vkz>aemd&u3m$Nu#d2b04wh*gJMvEJ-%}ROy-h6q% zP95^sU1~hn!fo6u&ennzTj8pOu~t;e6c*r6gRz&YRVs*Y)>uWciKl`FS@?sqG0oz^ z46xOxsuXY*bdh}&wLwrvvIxevU*ZICY*1IUEr|e)9rs^Kxe2-g+%dnv@nVVa*v|}{ zpJWNZB`Q&1K2az5s5?sIIqCcU`$vu!`{>nj&O@pK5ee#B1Yc}dOKkrYIp~ThHu~)F z&FVm0AjSzotf!~91HOn@#ii>Dw?kE9a9L*3x%NnK_hPv{-a()U3%-Ij5utj6g*(BKc))S?2+~zt+SzFVvJ_8dqahu}O9~~oG^g#Znd8U-7eRO*}f^_|4 z9u4wgA0N}gTK)p|Ts5Uk0StZL_$$8A4S&PVa&qrE7vsSwy)4B_RbI5O`N~(|0f?!F=F=> zp|kI?2jbL2m*a)hi8_ucF+qhhN8RTv>%V$Y-kzUWhG@vE<>X)nnUnA-z2f+NHKeLc z>b0v^m(k-azq|JWV`VR}mLJ*X4nbL@E077w`=(USyq%~jwUF<~r1(dnsGyW+liPG* z|1&$rwD5k>bk84hBrv?>R?GX)7d*~A{FI=c?OL{+f-n`yXrSw$Gvhex!gq^{Z&B*q zLDhlvvLJxX7e$zw6uzcksrZ_&MX8?n;)Oiz0FQ&CAmCt#8(!j0-U@QK-6}amZT})VlOasA5G2P?9JVUngo$y?hPvsCX9;J z;*?fx*wBs(hwWE?=z8t#)G37Lu5ZcwyY1iFZQeW;ycW;GaM!^74FR@AQ{Q`2yR3>-Mqa?@DcMy)Y)#z_8v@u((Ux;v`h`ncR=|FnMW+Zj{qbg^y=t~T_^;8y#Q zW{G)O;)hsfLILAfQ4Q;x|K&?~^=7BS{-*x%XFe=Uq+)| zobKP6tI3upjFRjaTQ60+uKYh~l!XKuH_*R*shZ?Z4|qyP_~wWf1lSV}0?wuVIod@5Q8_66g{Xn%jsFlr)xc+T4Hy0g$NfJ*Jhg@z3~ z`9kl1;#y>6BBkmxP=`}z&J<8>jd%ERR!NJR>h~{;F%Zz$bV+ly;Gng2bdt!L?>>Iq zi=l<6ASA-Jj|!)U%de`gl0I}OfktNW3y8eWFo(hpiC(goz^-?=Z`Hxq1w>ykzHlxI zuX>`r;485r#S^(OIa91#y6I5f3+V-g)iM(4*@+Hq(TM~__L6LY_hDV}pdN1tIw?M9 zqM}r}LyB?32Mq$|P<7&)QCl(y+!N}WU8g&#M`%ne4qS`<8C>RQF_mP+Yx*jN3@}LA z=jqwyX;$TH(V~%9q$T`bpwB{uN3UMI5OY=-Uv-i!6Iyb|Z;ISJp<6*QF$G?Z=IbuP ziSFAp6}tV_%CItns*B8&XTWf_k3MXM` zkMd_1nusnL%uE6F7G`t?PUs+{%h#`$rI>?Er8)i)VsTG#~D{e`?5)GHeZ|}*uU+Fk!>0d!e?+8=~-!~yq^ zARnk`(SHkec*Zf8K=~JR}|HD&RuZ3Z>!s-Hjy z1VM(xMJW37x7ejUfdoReBmM&Fcre&<<3~BuXbmX+gnJ1v0T-<85$()k=XI4b33OE} zc-UBXOa@zqsJ9$i3K>sNRX|x%3plalaomP~PUA21e<3DKMzSUqRaY}no!tdu6yKL) zBNjSK_y^N--+EvcYB8%1)im%3r_S6_sKWDp%s`{EHO?F$94^dT( z$}I|-MN39}n#>v*_U#?QanzSzxmQ8sxdq95AsEu8{rmehj|y54B!G5Nt52HLOE|!5 zH-!FQ={Qv-iT{GZn5u6iO2t>T9sir&nKmu+Dm`g_`MiNED4nrqZ$}P7Bbyl%{!mxZ zq95C4J{akiWs9xt@EVh+o0ows!k&dVXm}q!dD4w!+&Y4tmg1jz7QaFI-p>;grx?u$ zzdSzVR)kKPvrn5WI8SnGUuET13~Eq7%%Hckc-`^^sEO8I*+gm`uqY2m&1lmy4Gq6) z`j?v<^Q93Us$g1yKS@~Pt@(x)m7U*V4_H8!PkYj%2B#J9?Kus<0c1olA-IM#-C-;RJLuAwG^5Pu*J2;lmi3m&!+6k?{l*B6cdLl3w*>w}%RaA7$XpX7d zJ|?!MwpFZsnY$>(sG>RI+LkS{?JTclD34GRsL z{O43;UBgZV3p&>vo;FS7BF{n`Re%xu$a!SVh{$rAqojqCy-+4Z8XcfN7_;{JNkC_= znu=BK7!hnWoaLz0Ei@e#4TS6kX1R%`QS7Spbq+kJ;4yG)@jMrhNa{|uQX`98zU zC`U=OD;ny5fLXV{Jc)NkPVBygF?nDsI5cJTkd*dl;4Se=MuA1_U4h`%Zb%0h-(U6=-h$AS1RY&~paNM@R#5e8q3JKh(vd z35(udl?ZumRA>!u)B?@&yyxkD&Rx7Wu?ww#)w=HuAgmmlcKLS1JTxO?I*yYFHG-&& z*okx@QZ35PMTm*lt_{qM1j5RHbSd(B#n9h5+_)b#J2|#gP1flk2x*fp8OlKw|5hkj zEBi}y=gbkRYtBjQRGM)q;y$i7NhL4u*p+3kdqL+-|oBb?K8G~0~xB# z0Ud|yb}p>bOJdGWLB7l1%M~@&)3nKz@XoEKM}0p}ec6ZlCFbehATn**r3t!YrALk% z*Oivu;9z$J1?etJjQ+}qJFHt^d8Q=Da@a4&8su}PgdW@1JkzYOZ3JB;eDPgks8~=X&t7jnI7HHDmRCjWANkM6 z&~D=W$~xvifW7Y3)?c{|rTXERqL9M8*B<@n_v|b2eUSaHrvKvQRAvSKiAO^&9~qnw zHXZO*@H3^Jho|c>zsW|Puf^d1e9XWuD}-3zmNngaIeq`s#!<#|B$LEk zE+7rV8*$-U&w~UBKwwAypt%g|p3;pa2Y$^=1Mm_!I?uixVjmN?g(Ln`m)~PPjPZq? zN*h=M5JaKn3JrBETKD(&6%x=(%G@5B`kj;rzC5kiwr<9Q7)51zM62JCwQ<%AEMmQ- zft`m(Yt*?-VM}Fus_7SOI=q@Iq&4)T=HJPa+=~IF5@WFfMwZw@tYt(3!O=`mRi4@v zmX(t!P27ya?%&srtIoBg)E5PA_}%9%`8z-;WvQtM>{<*}2)zQv-{^Qo#I}u?E=GXZ z69xP)<9R0suU?_$KvB_I)T2@B*;aso+(DRGA%;=#xG>-oS`x;1njq0c%E{mR4}8sD zkHd!#`PHio;kQDpeDA5*! zz<7e!t^n;73n|0z{+(?ZmcCZ{3Mi`}S|}>{ZsGRB$BgMr8V#?=rS}yRINq`Wi?@9H z&{8Zzwg3DUKT2@)-E>Rusi@UFJblW^@?5j_rt#PnilHnKZ;|s)3yAEHl}ZRea;X$& zZ)|F&o%4;2-9bLE z*K8CpgG3k%3SNyI@RirJX#Wb-LL%>cMXSIGk|vx7k21Cp=h(v|q{0-;_sW(EbHP8t z7s1zvH`2fYksq$HqXyFiq2(TvDd19^^Q9}y+qqFFu0jYDWERCAzlds*lqg)%EC zX41C0W#BU+fq<;zt1IM1a3Z0(u>yDI8?+Hk0mqs%4K7Rqs$id*lti#?V!I@wwwsRM zFK-Nkyd2J?8Yo0hLE1}}vCc#+mk}rk&A%8!;3q+kvtQsIDh~nZM9G(=yGX9Eeof5J z&=iZm2z5EJPbhWg?9Yp}8$vbbnv-^L_$#f-lOwz&7p6E(`X?Pe*^g00!s%nyHT{YNz}Ia=Nry|I&*@WATFiR z0RahBX=#gRH#$`t2J)%%bj|OHY=c)Uhjz*P(v7hum7M=YW2rp@uLN zg4$L1`iV}QSo4mNJ^fp_zjX#G#aV2Q=i9-*L(ruOLP9cPL@J|#wml!(a76m`=`-1K z_T*tkS0oe`cWGL$FNhzjswVYI3Bk}#_n_2aiNZbsS)j0ho!aja+%gIw)}D5wKVpVK zr0K1QR!~)FZP{|uxTpBH^zOYhw;vF5NeE=o>IjORW^ng=g)wW}%gV}LS&`aSNa2{7 z*iBhUkC9I!=lK1jP?0iDI9UU+mFQLBKQQc(g1chiVLE>L6>OiPt)8cCB%J{Iwvmcj zXq#azXe0fM7kmcjv)H7G7y}s#<7RhxZ}^3XNo29Wl9x3D`bt8Z&_d_{Va0Sy-Zj0LuA{2|+8U=;mvC5D6 zyAhza5mTP;Sd}Ox5ikljX3zQa=MNWaCC_Muko@j>mxjTL&8acP%z|VKN^A>ViF>lS z7#E>^7Vh1AU{@O|m}I1kY=hcE7KJKJ#0p|OD2&;7SUdvn^+b83}2i?zQHEf*0a*L1YPHPa=v>mXkYEMVgbbX>C(@on}_&U_FvzsCcryC7~M{ zrFMXr6Y9bi34sKz6EZZQQB4=y43P79PIIijS1T3ALa<1 zl4=eTkwNw^jdfZYq%U;pFymaPh0fJz?fU6OF8-7A*1aWrmgo~3!c!Js2*VtIu~*{K zEU{iP5-r;d&(600LzGPez6qv|tO{23j3BNYa|7u&8n;8V2u&m$fRe2yt2QqKMziSe zVB8(>eyfb&Xi2C?LiLP&`cnm6$)D@to#oj6wlsbngNg`>#|t`6NQ2e6dlb8 z$ySIwBr$GWxv|A}ptMA*j2`zcAOJBjDTYYD44-p(f`AYyA2}ig@19@J{Kq?hR3e%n zHTZh)cvgBnVzF$%cD*_EuCICJDPZmG^%XPwIyRNic#;-+vc&hGScc26pkxRC;7jja z9{2s=A~(I0$B)Zo+&{1GD2R>(!aCB(EX6+x))X#3hvY@Ea|e%P5mp=>Ctz^c29*sR zhHCKB*uV{q1~Y^7h5Ha{;FhEb@4{RkayUDP#ilR9@FA`?Y2-Z_w#&oJnxBR1(n{m` z43p6;;{|WwcLeDjJ7hwudrh0(?MT%Rd&mNK*3FAUPhNm6YR zTVR@(^`l2Oqp}sZ&~Juk@QueA-D)6siG~<&&a^rgAy}c%&^hw1XwO^TJ~XK=Fl7{- z_vGnEU*zN%YEPnrr@j{}Eg_D21Ev{&P&n-M%kXsjAZs0=qYxt%Vmc#G{@`cgFg<*+ z?XNwE)vOpb+;f#n-^M)|gck&L3My;gh=_Ljii=@((Z6)pU$ZSUpwVKXVCr8cJJolBi=@Wn;a_*nYxo!xzW_qFcRM|sdb9hrXnoGpTy7M^&i zx?y;XZRyGA+tzQ=EiEp4d1vd|L_gMP^E%mU`t+tpwjHrEay$Lw*QVv>Jto_C)-2C? zl=H~(sg2Kf4V#ZW&XudrW|)`?4fsS*Kg29#uzyuHs4Ws{&B*XfDrdZN=$5JYRY+Im z1~lr8RtUA0*5jZ9+;D%ADbEEkmuWj_-7rQE=gmu7J?s>&EKt?^oN7KDZ|VzEd-mM9 zO$58t)k*aylo=~TUC=Ibt3cBA7nhs}JU6?Xwm`uqC@c8ZmL?WX>Cg^|6dNL`@uxxg zH`0a3xeTJYl9Dj}v2M4C@#s;bB$Mr$2;2j)nz+Z%#wyJG;}X-3h@(5Vm3ph|*RT7c zT~N5X^i`)TcNAAsy5Rq;Uv5~YSNs$q%h}SLQ@LrzR__C1kSj-8y&4NbD&(;@?MmgK zTq%T9)}>#Pi+Z{ANz8W7Al>2{mABV!5HdH|+MV9Z^MI5o)~>(%k$$!`fcS@cQK?aD z$>PQP(Gm(Zo{%P_rMV#$-ZS+=iTZDBA@DnxvWR#5OWj^sIk1dT2elDxCek}~@44*+ zPt?lF%HXKVfC1C9j`%U~#Yr+KJmEbm>H64q=0N@gMuVr)0^d0VfCPC;vACq*mb0B* zRBrosBr-v!k{G{MR!#$EraCw9*u>`H{)|*nC|i90b>`G5U*-X514;5)vu3sAqf_M^ z6s-YcCV|1hyJ$m@(5mK{A%Twr1nrlPsXGV|%M%X)*n)g9jR+eTVu4;6AeUTV_lH1#Hot)6go(JS*baM-jX z*5cSH?ngU~_}r}Xq%%(_Zf2f7h4D?BqJzdJ zJ$v@V5+y`w%995VFe+L9ZR3wzH(qew$NP^SEnKqWLiop}@h5_HD`i}hnwL`8Po6om z1uodninCO=*Vj7LIKEhOt#6y;=Vwc6#?PKJNB?$eR#w*Up)YonwI!GtX07}3-G4$( z1aFM?x@x#~=3&vAv6)JUQbikqd({Q3SK_G}j#dBG+gz;vKU#b5xSsd_|NkXfku6Rr z*&?%|$f)cUDwLGnkX=ZWk&u*;m03zd;%HbYq9mb|C@LJHWQUacUGJRp{{Ak%KYzcU zKhEd#J_o&C&vAd;@3(aiG3#z_^g=_wN=>iu<>CCwN#W)Z>uYc&M|7SwZP)h06`wRF zG-jbyhe3ndKzS$}n{Og$M2H*S7wMu<_)T#>e%ZtmTOcItdHPa9`@#&pAQl_YIBhz6 zwztq(at@LJPu4&ahTuBcXH?HQJnYINCPF}d-wl`>vnf+*A^8I0Ha=6A#6ALZQGB6B+e$%tXR!ERc@E^#!FF z`SQ+jGN{Ty#1<)O1!)c}d}d|~p-s5M^y9n+N_sCVHDA-VfvO;l6~UzlOU1@zWo0Em zuOfT!&|g#ZfR`0wgQ}$d-v)(Zpg7e z%W^7l8kR3{x>xQLp66Y-dF7QHi@|p<>K_XwrDn_2@GaM_ZP;6qdv@_Nv*xkf!bnM# zqww_gsCJLNKWF9fKD^jGW=-oWM|_XzK@;GG#&W`uLCq9WxR!ie`x|nMIBu@J z{>{EINJJtg(RN}R;^C;R*Wl9YGdi^tf_Zv-rDf?p-UL-C{ngtVgaK8cF(!2s=UCvDty*P%)Z484mbf^5k_#ljM&i~Pn!ESzZRv86 zEzZ-UIs;-iVzKgxg@ew+y47vmu0BV;$$;xA^`8`kc0BrVYM3s6&SnGoA6po$#osp7^t>_@94H@Ct%nlrLHOzPwah zxDw%jXac4L=zW(89WQ;;{Qi;~!P7l)*9F&O4}kFI1Am`{ug#T}HaafP0(3%9dU|H+1Qy!?J<&Av<9V8J)X*ZpK5~Jboq{{! z`}T1}WaJdYom6A_iBuJy$(u-P`cVsM`X;?xLt9~rPVBs6j<)!6kf<(maS;X)bHY}O zNXt1e&0>JtVyq<-2?F7!z=|Vi$T^_5vadu%-fx=db9UF2m=|l~Rg+z0yP*ewbL7X8 z=A(UTYIsC$2gf~IWafvN@j^x?Vic4icn`#`QCQoY-iBQcEqz109EFyen z=gysKI@_|Eb)9ff0V*mG9+qAH7|2#vae_qz^A}@IM%5(!x-oBI3lo;!TNCG z9m?AgLh_d=8DxosoOH;RM^Box%Q56lOpLC0SB7W!-=!Jv2&9Qb^ag+*qX+@=Pa?iJ zzAty$4(T!v%;}kT|^KiiK&`C#kAisYZe)`xANT!WldueK-tbtj5 zNq;8;ED#=3;3syA+23^vH|yTCMT^ZszJn-%_-|!hAZ?`=aM-}gpDwNB6M`I(!n+fZ zTjW-QfyMGDA7GW4z}a}jQmG^I-T^KkhQmC2_WGPh5;VnQLe=-Bj=>t)}&5xDYdB`G$A{my&%c-S4It z4iwD1e80qN^ga+Q=VkTJo*Pgfb-m>qS5oT@u3SzUta(ZbK-^jb2gbXtTl@9hlNMoP z>rY9CZGBGi{dJF;N`bkgrf`$~s7*Ku&!!Az4R=oI2h8E?-n`HRX%KHOw%g}aY8g3L zY@0K&mEJ(Ia@&Y0kn>P9_%eFIAOp?i^Wl-|_;sAj)cpv>aU!R-6ZU75n{de=W&kqABI| z4vj#Ax|!DtG>`gIn~#PTF_}hM{-gS_$`hg*;}Mn?8XsvxutQ`J1249#1Sk>25mcrV z4FakK$1hsC-+9z**O#~I+AEPJ|lxtXhnG^>}dBP*Ee#UVIz@+hRm~i1{L2=P|!Yv+<2`c3?B1)Mek{JIWe4QA9==olze@KHc(u z56QvS2tt_2m~si7ewgb!TTl^^8lc8w&Ok^_+EU$6|cWQPAG|W zFWO0@%#?YK#qT->jSfV+PDv9{PL8o3eZdcWoz_PbrW1QRlCUH4lL-?hY&OC$Dn*y` zijX)xr$qg+6JdZar@5?t_c&9{yK~2&Zl3%1t4WdO&goD5BV)+i3vf4dmCly^65NKo z_}S|+{jNw3z!cvc5pr>gwXBmDQ79*f4AAVIIXGa^q($CX;|EF9uKT_L&n?`Hrzg7Na z{rLVyhySP0>)5g6#{cL4ar5Z^^2+okbZ;ru6l7K4S2{mMxOv|D)SS%B>nKJEV{f=| z_ycRw|vGwcMRkXbEmCLtlEPD6fwfkECZ)RSGG~&uO(`$=k3qe1W8Y+`o z7|Z=Q)8g|#mA!i>2N7l}-SBFZlzBR8iZ=xbgBfI=qlv@qAuzu2$)?EvHVyBPM6G9S z;p4OYni`!wsBpjW_RX6Udkn2J_y9ZjyrX_AqkM-eC&@t0RynAORC0#5q9=}R$ zo2ulTe&3qT4-pAVk$~TDdp8zz(`GKknvaM$G!^8g?U@5~1}L;+$2W?Vv=Gi#(Y$~` zgi%4gH8;o>6Zjf2j<8ekK~BysID)KyPIbxLyQx8vAza~dLbk7>bwH!KdXe|T_uyx~ zbonoGrrVaiGbQ=Y70$;WG@wt<#oy?25N1T{1f$44m+ZL95+od^d7w6?Xnkwh!n)2Asq_M*A z7{0qI@;=x#zBn(}vIcNWD(5dg|2krHmF*e`YOb`WS@)Za{1&egYpMq;iYP4NX6<&| zT?tLnH|5T5iWZ)e2?a}NJS-F^A~)Ki$=ejps1d!?I(6t!bbg$|bw98;-KQdn{)6Gd zUw_!Bm1mDrCS2b=mf=irKoKPVQBF|MGhd&dl599{S2kad^5)GP2NFJELSL7XZ2g^Q zHqb8mR-fL_#|!J!qosma_dv?#A$pnkRXCd0nQ4 zD?1hhqEf{dZx(frkEBqbm+JGoMw5OxpKiY%L$(+n88?-cY5}15Bj@7O zoQvGLtlED=%k`{}8-CQ*LH!HrbwPOv@mW^S^qc((7nfUzYZ~M7K{o`|%^iCIW=StvEtOOU8TA6Q`D%i#SaGu;6C(;YRYnNiw7#p2PJ z+LaVS7no@WNaBRt)Yb22HTtW+a-XXom5RIA-y}Y3i=dkqlHZu4NIw@7BhQdT8rnNJ zT!&I78srCb@<*(=?Eywg-t{fb;gC}#K~ZsVrKX5N2^I5S60t;b9(6)^C;lrZ*vsu> ze@n1;8zPBV}%}!Ku9f|M| zg69S`m2n|R`v6TredVL-ZTZ2?j4p}SjP~evt(ZqGh{#30k7ljCMm~DN)8Bq_h&q3- zGj^NCXy-kOmi3iku7_i0H|;nj!G%Pi^KkI22rZ)YLF8gTX)PL5m|)1N*pc1+yhKkc z4_$nGyp>YlAw$}6294xnLK1}SAASfA0S%;uOVpV&=a|vt=JL|X=?xyQx5qc^kdHIf zq&|2qTe|`0m!l2y-ExCY|F{2<_WB1sZ%!N<=B9AFC@$2U?-TgK&@9Tu8NEh1D1x z5@i9%yGg9YWSOQBOC)DzHsg7t!2-qO{ZW#N;CfUyK1#e@{5>Q*w&~n_tj~0vXTVJW z?BZtLINX!2Rd_NEvFVYy;9-Os+VNCHI5(+QufUJ^94%u+?!<%*VHM4^u#n){hpUpa zZ`G<*;KgLmNHT0~g|Ugrr})HQssb(Y1;t;>y0A|C5h$fWY#B$A_-l-6a&A_Zlp8S` z?60c*ps3urnZfHsbor- zN8>`Hywu|yM2H`6GH>m&V~L>lu5UhV*JvFzNkMbodRpj&joff)Gd_HX_KK|WFOS3lfbT={2JxZvY#}6ypF?N5MgAOTih81>(H#y0&%?@A<6pgIt58|K=s8 zZv}ioWU?`0_R6z`oWOp#%&r#}YBI9HDn)8rD%7{{51lpmC z`uvSCN2U`Bn;MG$IME}~>WbL#5%o50)yj)@OomK~v7iPBUxOU47y%6S!&@MB zJQnVXz?@~e-c;UsXlbMfq8$i{a02I{n-VP%hnXZPU@g0H_t04B)mu@ua{{aK5x_CM zKYjjb$ieYpgG~=@tIjG>wqq|F;5xj>SSqXjL4_{8+w$e-QF-P(M=4W; zmEQ|*)xLey{-ntJL|%!%t5r|SBFb{aXB?+;ML~Tem&O zfNv9b4yVyBvDjyBk$AIO3Iengj;}{99`o#ysCd+U;aS?z2;ZczNsL|WXVzcLfzJGq zSW`JHc0B`g`2HUv0HW28Z&#g56@FvG|oRfTMd32q6y=J}t+z#C!cgI?(%>J3n6PWH`2|i^Yvc`l8E0 zH&JnN?6yB_AZgcAqn@UugpOaG*{Z%W-NYs^2QJz<0%;@@jFUTs9xw3Ov0b|wi};H- zHEjLbI-U{+27CtAU>3mFYD`!D16(DiWFe?a`9a6tR1hGpPK-7+fOvzQ`|TxDlsDS= zWfhI(MsO``{KnlESIyf1*ekk>Tem`v20I&Iz!S3uI!-i5B3%#>7<_W<*hPQK+i8;*UFh~4w@Y+7Pr?igu2=!L@wd5-YbR;2 zG)s7*K^hHvRdhR$-u6g~!5uqpT3OvpZ-jT0k~`WHu9=AV9>+Pi#W@e(qKx4btJ0<5 z8*i5J7&+SNauSGklOtn9*S&gI5_mOCkh9Hete@?^G2`xC^QfVX6adNCog6>4Tds=z zYa`Pfmp?D&+3UaK<40p8N@(y#Bq`wkDB_wNEfn1f$D&i}80%un{EhK_5>C0<5Q`4f z7@^ZBE6epX70C5{Y_bc>Y^-x!r^V!ymyQ$d;9MYc*~>u>vz@Q{LShixF1>p4vR{5l z_4vt?g*^xeXeItxJic^ua5Hm2h0EVh`<|Qq&Fz}5C=1OVUWnF;OiSr;!3SD z_3Mu=pr3R0=9t*ne3#iyAydQkjWVW9Hu6Gy*g;RP7227)OnT3jq;Bx1#g2@O%qM1)1D<`x#$QC%RuFh$HW+AH%p@}N(&CAB3Mlkr*vO)tKYa%~K-X!vzfrai8d=EA(=44Ip4Y8j%{I)Z_g>8z-u(y{nN-JSlywVJsFH-=M$PYp2En2 zfbLf|vA9J4Xv0W=`>=<>uSS3=I@;c53p9tsqf>F|>FKpjj5X=x)Z#ZI?s#QC{OGw- z*YPD-w<4}(4-3lrpIF{f-56-wWZ?g33ojN`51{RJLPrsbdIiemfIuu4og)+b|1<>qm$-!b{H zJMR>*iA!LLI8d@RcsxZSO>D$0y#?cKV0cgi{Qx=6*V!uS3&7Ny{#qs)hs{Yq_1$&H zJ_?Cm#OuDS(QPfUWI(xkGinIMv5%iY-`{?};)pC8A2-f?Zw4fg9mC**M~}9GRI=9g zagf&e$%h%@7`$cDofpUp!pVr1EmC_t^o{iTbLM<{U^>pZ&mp>up}MBp5iojt~h5H zTXZA4%~=^;n7k^xJkloS#qt0yE7#}7-MfwGadfWLXuKxq!#{0)8;(_mDvh5LmP8ki zw}~BQulsf8wKbnQax(X2Rf>04#nPu2H?kf*c5NdO4{0nwf_;k&rom-D&{zW8TAXg5 zsFjwM=J#~d22;PwjZ0fgZ^Gf@`r(4m=oy$>>I zpi8_~V0%?zhsf^%u%6T;O9YdR*R5N3gvgeMYTe&XpcW_|*aZa>vcd&LW8(1WLGkO^ ziQdo4r!88#G;-{;P;bdak*bY&hmZ(|ASuV zUNQQAGLoo_(Pq*f)&tzi`=JgVw&84DJMkTWiOsWo6dO`fc!yOV#49KC|DFp~U zGgnAljvQQba6t6i@;Nqlb~4fjffK>wIwHNGBjf^&5HTytPKm#b&KoMxxkxoXO11~Z z2YAm^&QfG8ZdcB-23_*DZ^FklYu&mYN=5+XqNw-+C54E!Sw3FBL4%DzP5{4lSkCzc zBrR)57ALQ+{v@_D;IykBZ!}~o*op~@dn9?rBxB|@y)Vi2gs!4&9YP(R19M5PYu$Y3 z_vP!_%uY-cM{f;nmq@msUR~P)wTsN0yjoZ=mjc{xf%yf9ko`6o zA;l_*ZI|GeQ#*p`r($}%^P_&wFml=gf^PKt7u^8a{l9g3ew$#G} zOco&_20NjHnEb1wavanlw?*!8HTKGzxn|8E;b%NOJyTtGH*?g)2rtbl+QQHLcgjZIZbBW%I9%jC!t|?|b5`wT z!2J^=a-Xy~`0pYbng9pG#m$=jrd#A6C2Rf{i_ol@%N*za^@@T|=)C^V0_LcF4=Ta0 z;kW<(#s4p4%l~J#=KsxKaMSt8L7ek^XA$&61l!EJ!mG-l+lbPs7>+qaw2;`U;~GIz z{`<=@W^9w;08I!5=mi+%bF5S871hZ@RPoAbwV#>0?V+v=zS zbzdKjAU&wN4%*rg*WLa|UQpch5p(B}VICT`?w{(A6a@rj-L~%U$4LH>6P#n>85t2F zL^;*>8{I6_&Ta*@=Tx;fY8W z=*^$?WekVBmd3r*@DY&gdG+DF&2N@>#k)-#&)^*UnMfg7Zp0zb#5s6k>sk&Ikfx&r z(!JxP*%3;peJhne-!yYx2H+o)FT*LtN-_!HsY0gQM~cW)23AcwItaN2MNXkWL??{0 z*_UVTT=s|$Z(_ljM8-{18cc^lL7X~XaR;)!(zZwboBy-`l~^-s^LBq^-a)<&Cj^RH z3n9`UU{yr>W!2YW_M;DBjIOPaV7A?d4&f1RCUsyVeFm4@B6v|9rCRWK&>7~Ex64tw zI7an1%NM5tmQtUj_KR-i+KgvLzoSx$CHe2##h0D=%93l#__vTHt+az z!@?2>vLDlV&m;rae{nZ>pH4uD0I(6W*SN5gg~milM09_GTYzF}DefD6Zzm6}JRjKo zQOBr{HD%xNvD8uTwMq!7Boda@b&0d6^XkCIb-U^oai^0uaI0FD(#;XHxj z69V-o=A`%L-qF@XxbZCo96p$gQ>cCc*AOU$-y7MXX%bg2HiMxD zHUlpPP35C)#Q=*p>1CIS8w?D1LvZoPY;%ASkEbQ&JTQ#MA-M|XpzI}2gZr`|& zyyKy+5v3oM?Q|IPr9UMM(!ph*&A*TH=%c)<+L_Z2fN8+wnReW*J!Q3UzQ|hn#)#?4 zyDFTzKq`Qzh*UMdByKIuuEp9BBQ}oOquQjA&ZvpegQOq0p`;KmERx+3dZ%r1@N(3& z-5Jdw0?bw3wG9=#LraxRG#?f-v9IBbFps7g@I$I1(KVD!$DSvVw+3+~B66|TuqS+< z<4I*M9)q(&FD;3_7m*-$79*afB}Y;LQg}Li6xUznQ42!IqlU-0xYMLNAykv%)nW~J7V#3_|Qfs}0dS1p_yym+}*+A>>wyvIb zz&HumLm23M`y-%~xkn!z9cSGUJr1OErjkyxlgsE^MjWaWL9V zIy__IENJqV6khY?Qi?W$x0V5t&Q_FB=qSa^ukeI&0ZWooksx2*Sp1*}_IVHAl$2ns z)ieA$h$thzvmkQk8XG;~#q|#e2-%;jO)v{Xl|ms3NIFN+`sXYfMtlX=t)4=PB5`{6 z?oD#38M>244j1Y8I|D}sc2;!i*fHC3XhhkbwuCMb)uPa|*>Zf{djqB_I%Gi~#rm?> znLThw1vkBL8x`?2VmJL78YJ2jX_jFQAH~J7;ZOVIeV6ZGv{oj+SBeY`TF$X z!-uezGnOhn0AJwk6!Qy39Bh0urxxp;H0f_Rn(K)-nR!UwBnpw}L%H^tRkaSJiz(?V2HbvPe;24cqvX)Y$!fH8LZMTcyK8Gt4FHB2 zPX3V;EjG)5ft^;bTgbPPs8IB)fTW_1qMy(6|MgQkpN_jlyGvG&^Qzdwx-UsbD{21M zAdcRDUcYvA{VxJx3mpv-7snO9#PP*E55{QT^SGgl7~p?vH@1Dw)F;VLvfvOAzw2P? z*NR?6q^=_WOo{6Z9c;d{gYfsXv35 zPvQrCIh*Ylqp|sc-m26l?TV%MS*1UF6d(^T6Qgu&?d!jgz7@e_K~9PBf-Yq_7}>qX z(IyZzs&r*d{3xF=}keZF^d+(m{q(xoCOyU}bA z11;;vnqTQ}kbOIExQaFl(qL@arD;5v&w1ucCYco+W*T$(~$+7M!mQEAHi6&X0e+8w?MedegFUbABU6Rjp=Tex(udF zU@n=RkS{bKLizUXk6H%g-Vzz!zrA!PS*=KdN-Dt-4bk?f9mwtS-v72{3#*Q`=C4?A zC41>OoAx?d+VgjG)l4+1Uq@+7j92&QeXAYY{d+S^Cr6-rd=4HG)jPV&-{;QFc=&pX zs}hY1FJJ5$j{&uv-_6!*MY{S9riJ>wc6J{0>r?kPjy}s?r~I1a^Jm0*vz&trLe6k-qmCuR zIkMUiw@5yTF9=5xogwO?TC%@;E@3c~yWt)Cjr5lD7hU34WJ zaCpMmuGpYVZJ@Uzw|lREljqF04m{?v@zBO^CZ&bxI?1}W){R!`d)Plab!O_<(k0sx zC%?3mUge7pH(_r8QX8yC}{s7AY;1!o@pv=2R!qB^z7r8_U*16+<0t%*?|lpvE*0K zw=Q7=woL2Llspe9Lim7WlhHLYJ^@QCj*?%~0=2pk%5f%%+RkM)zC!}iYpgs@>i2j& z+bW^{HpVb?RrTO>L>!6vhTMoj%Zd~fx15r3otqD#`&hPe@W|3?z+xFXc2yIxyRoXzqm?Gk?g?w^A9>ek&WTROj+HBQ ze@uGMR3F<}Sz(&G=h~*MeUg+lW7FSv9G_0;dA(a@^JO}h&OIxv{qI)~8a;Exiv9py z;)@btLY+EwaJpKBsI(!wp)H>&*l zb~EaRF(DpNM+O6d@mh&>nU=Sf;^LJnzlq3%bpzSk-B2N7GI*)w7_HoEetnNJZ1(3I zjEnHSx9Y`ukH-f~I>cXWe)`4d4tskRmCmg-c%_Z|^3Kg_4330Z*1UR9aISymh)Hwm zuq?Hpez4J*Wo1j6Ep&IEWHGsO%c9%Xo#r@qyna6N$;Ch$mEC5Z?-yQP7V{|ef=fx> z!IGkzmDLw*XKf$9T4lG*j9LvlH5jFOj_|IVTUJlf`n6$gy=y)Hyu^OJu@!adbnnX~ zQ@?AL-$j(T_}pF8>lXTmCSU*&QZ|hEn~_U|oV2IY^9y-_u}M(>&3fi?_#QoKbEUV> z(?joH@0)WYe~N!v&4Il+%^Q6Dwc?A9nibJW=@N< zMmw9Xj&7qU%1Le6*wXFCnh!eezpPj6eeu|1XziN3b>qw3=6f`BFW=Gnb-lrJGtOSr zA2jWM4|4aizw5)qsVZmzrdd2fzAARO-UsF}uc4jaOiRL?(2(j(cu-_X=;_n*pcTRE zrvz|<{@#B+e!P>Pzh;9juliejZlajKqRY_1{)+QsJWqQ+$+_aUe~iLuTKhMp+m`vz4W067?d{%Yl01*4=;R&$4^=Xp9Jx+0l#u%-;P~i->jEf8 zYY*((*Gx6@DBgPBcb=;$v#Ju0Ir0KU{DkWf4>Ddo*(k*XKH;!#1GqtK2s3Mset-Dh zvpe3iPHwpnb<%2WlA`C;c6n`ke;d`bLCt~3JtM0MOZvGPde}Vrkv;d_;J{ryk{9>8 z7~kXDv9Zbb_I~&8<6JO*@60-_czhg;C;oTU-1eaa6vHnZ2^CqO9KE=lv&;7Q`wK@R zZ@_OQWKQ$ju_aw=)f%#!jdlJ#_l522VHfs2H$+7}Teb9c6Vpjky2NQt9<>kD& zKL!QP+jk{oRNKh-)S_(PAy?bH+m`5Cy19;4!l%-JUR6#Xbmq4*x6h8AY!R!J_rmpU z-#`8=9=@`$jOLyQRVg(l^jGHz!fDy6Rk(BX*|U~iDv0e5(q8TATG!L|Jz#+oF1gCxXUp?D65~l+v`;$E{{9Rcfl(HS6xp-9Cqw zmCf0*O;K;B=G@vJHoQJqo@VegeSQBz>;I}kw-JFX>8P(P0SjWkWG2WSJQ;JK7YNj- zwPicTQYXseY`{v01C6Wy<-BfQvWlYVfzQ*8b8d{;bR_7`!X_{4cwSt0Yeq$x%iYS! zy1jPBd|F|DT;KP=zfZ=oSu}kp=9jE6aM`IpR)MHX4nWaG?Acpt>!1$+Cu9~1lve^8 zIXHypxo`YBOl?&2tA^p4+VfT38GM;ORqx6i0LGYX#&SmGmLle37h2?XH`yTu z!9u#r4n(L*dJR1!&x&*0*}09)rp*CEhq@QE|2{tAL569)$(LptPwseQSfbywz{sVg zOVb`-T-Nv78TGBh&K7rwJfYNFXLnvnV(h4I@i$MetXE`gJd{eJ6@9Te*Ff2ARc%GP zLB|VD7XO>5Y7+>EAOkkwhF*9ztYV(tv8{RNExU?v1Qg5FXv1iyH&+cy+%Y}+AcNDt zR5c>Q_d?|C)VN0_%0&!~3`nHD%hJnOXJ)C0hT_~JQ%HQx$P^H*Nee}&4WeV~wkh*I z6u+79EzcRM(}acrm+6_%0_Gk5WiipKW}%s;eW+rMk;K4%lRJPBT>wZ>ns(W+bi zX#w4P9MI|-Qh)4B57k4H?)ORkRC=k+8~aUK>oZPtQ45MO-nHfH+^dy^YmV$pE8BA6 zPyZ0K3oLK*jvb*oiYIE!YmCc*W?$H)=5QtDVL{nLTRM2eYxMxMPpv2!q=bIPGNBU5 z{?^T#O%n2fS0z~|eD?Ux%_Vh@oOR|b$ghrRY2X4Kro%LpTcrKnEf_wN5%ly3Tk`jzhlg zD425ca7?VN+ad(uqKaZmkpz&3wp8KCjKT)-pC>=*>fHl56ygdP3ltk4`W8q}l2fwn z2J<$^9}cBe#L54=Ez)d2BR2pJ{z`EM zL!v5VZwO}F(ez2ea$*?;V_==$JPvGr8*gY5iOuGfL&DAvDjgiX^$u~2P1$&J(yTt2 zpt2#mT%(S%a+Sxr@9oucV&6BZ8eC^#o5SYen@%Wg+SW17?&S6Gn zdh&~$7Au2O!nzrZ%61yzH+pQgXP0lCckEEzS$|udke;2Mooq7TOXbVHm8pHb{@QWk z`>!E>_g^-0icWY`u&&JL%ROy8)rte@w`h`=k*B|~2KikNg*>n07E~fF{e(6rI4;ie zWo60d>C-p3d}8#I1M0mRAzU_QmtGW6RIlMI*0h#`57E=`87o%d*zv-58UO^X%}^PV*~uCKMF zDgXG;i})Zq+0fO8Lvx2{NY5m4f1z@B0D#C9jKty$*93>2CcjoZf}wFj?|O+O=j z)JAk@dLklXobyn6vO(*A>H^F3MO=*{85>a}ZnqF3iXx^wB1-Gh%gFdF>^d(sKyFtB zU~(JI9EqnP%K^DZN{>gn0n=;}vJ*a_eT0mq@q5w7qoSe4Q1@++HR5@#VfhS8ay$Wx zIVJtxCY*ks*P^~MRbt%r#Ly2HX7q}!h}1qES~}V$!8|j@)?&>N4P-Sb%FSgf3v+dL zc6JeM)CAA>{FB?Do?cOs(-XLU1XvFJy?_a@OXeCipt?uXgMBUQDH{B_;9Hso1tR`p z%T?d@^K}#ANazCh{7pqchdy%LRtA!ONW)y`IXnY)=I&Ql;|NrkRaA56n7^K8ZS4cj zPrA1faHs4hd-z%OwK!cDfAdhgq>EY^Ld7~p(r|}(RJXIQ-|O(9L;VuY9)ACwdxN}h z)a(KewX(bT5nu(0{X+D0^GGz8hM<#w1u!B`x3CB>KGBV|0Uky1t;4xoD4uR%pjSsb zhi-9>+Dgp+vUd{t+5h0dZJxE79)QH#aq~~rAw-9?q~bWN}j); zR{n9@+Lb+j0EZyg5le5&E1%>5KEx; zsip4M)6NELA1NYW@1)r_%*wL7mp8|Lm#j!M^Br`EZj3D-oiQP7tSIHNu>?->Rvq#2%&4rF|b`i>YT0UaRnB|n^FJK)us=xA@#j&s~R z?#%DMY*0G}pnz1{vv13z&kiZMQ)nJJz1I6)_O0yxd(4+U%%4@~;ME#h_m3#6)SB2n zvfvd&QrEKTLrwM4$j|3lq|PV6XB3=Mc{CFN3z483sKO6Bky>YFjst zf$D@~x`9GyBTuQU9i$I6f!y`^(tfD>po_D5^HS!78qlt;d`HV@bu~J!h%ErDjqto* zO!DlhCtn%1B!^>AyIpy@$Pnx<&>)jxD=@Af`HY=@OFh*$BZOc%e6)bHv0Jty`y76%ubi>2i+q zuzB^*KSj*Va?Lny4^K?Quq#0c*d;wpJFe}ypz~pP3RQ({8(8@8T4`}K0abPvp0Mv} z&IJc_@d$T(WC3AL<*P3h*n|i2G{gkgM%KBHB0zn96n+%VTB*Fd5pucva#9$a{*4<+cYwcd4T|EI+OeXcEpaf9h6gMm*8vHG#2Z(rEM~?v1u=&9Arg`Bzm~z z@1GikOsRzy<&WW`)`4`%mgSoqbT}p}SlErE@I>%Ogc!|7?z``8dmr$@R5cgolOJoS z>GSia8=&*R!1s(C+^q)&H7Rr>;nVJ(IeWITY`BE?s~Vyu-1%ZvW;92 zg1&YNXj)NRhHRFv%WMt?elhmSl4%G?go8v8#WJZzq>vGY2oE(6TcyGilKLfzLgCcm z?pnqTqqFB-=F0_ttmb*`OFXUky72YWeJDJU7ZqWKx`DjAV6INSCHHHZrtaILX12C| ztk>y&YMA?vzJyE>UP6yXzq&Aq*0@4_cH{`?&pT zC~Gz%MS{oWnfmzRR>P20-0RH<^oS-;dwTU%b+JcvJ*wZxHEY)RB>u?D&ljJA*pL{) zAo*x51C|6GKD$66nhmm0YPG+A9P>Cin~gDM;a5;nFg~Dwv_RgYipEqWf5o77y{JA6 zb8z0uKDUe)Ea(EieSSf@TFClZLe{J4SV<;(yFtqo2;MU^S|bzSQE}3%nm%-T5zMxz(dIm29QdH}Wh?A>c_3@aa;U;u_Vs&|+g;$`kxh~$KyA%!@wpgH5{raeVW1GV{on;8%t;?mDZj8 zzJj_EU3BK$FYpUlaB;>ue$w;Y1oTtQ`D6G$wxH9aj}vhM{wwW$tLcwmca@LdXR}ss zK7rV5u2VjtM1J7RJ?Ircb^eHzOg88tGh#URf@+3Kph-CS_xkwq^ABH0B)zAmQ- z9x66az#2%PsKw)j!a0+PhIBgV`7-#tp6WrDpiv$65zF1hzx8)S+wFtxtv$xzS6_XM4DW2-A#?dw5EMPNAJ)v?ZC!-o87+XA+TQohOo@ z=d4Z;T8XBUXS*H}K$L8Foh+RzN=r+H{8p<|qZrT3t31Xkky*)e19g`y2tOQ;^XdzsUO|gJoE$;F0=bEbi~L}qppSIb!W4B z?x6Fh=>ZiT7%ejh)5*a}}&nhEY>niVLn6}C(^-6vD zoLH)hMEVN!Ko~8oDv&|HJMp}o9+Tp&D%%5Ja!Gal`k_!0Dn!x*P{IkZ zENiRjWZ+777&pbC9L2g{i78nAHmeJtpQ+<|M#jV*GrH#{dU&vjv9^L*7iyt+Kq@ZZ z1L8F!G)H%O2k&Ho>cxNvFd+fNe;kM-xKFK)HDpYr%q#JmP{lF3h()KrM|5Q5v^%!> z>$dhTp4|F!_T=gO+|iif1xI>yrxmooN#sR>Aqh-Bd1!^Q{lPx((J;urg4w;UJY~JR zyNL8TQ+m3T5lAeDxXfeX>X9f6JlHpWIY_Vqj!_Q1zT8Zy_Nd$0Y_q`fs4C_QR!Oj6 zU3PlY+;Pqag@)F}h2F++Rd>ikL7JkJ?nD`=UA~M^h;TjmG*lmu!a6c)RrjuO?>O+^ z4di@Nflc{cvQq*J?scYQ&iMsdlLGZsc@*Surhz=Th=OOdPfgP?qdYug*3rD7nybP! zSJ``9ely|z?=B{0HiOEa`7?HQFL6^pzcPcbC;sN;$NnLCZn&-O@do_~1syO<8+NA! zYM#4`R4fX69^T`zYal)ke%C+%w5tKIBDqE#3e!Rf|I)lmUB&cNh?fj^5APa>W1ojmSMkOsK5e}}`o84a? ztB=KS>6aBw!KY4{&Ka?r<^a{>#!FM4Jbrv4E^hy&ziyYlFDc1etS~E>cMn=dvf(+D zE|`Rg8wkbi>vBAX68R>{`4dc*Zl{{jg_-nHtF3Aqg9)ZDi?3t_I?y+bGscqITm;cU zqam-YYP3n13baZ^l-D)%^9nm0qrwYg7i;BKZ^PHj(K9YA^u1Ayf7YAmVmtl@m0Aev z+0R|iOEG2q)is}#aHY(d9muvmGBQTapMUCx$>C=6(R_Vn5Q-!gl$B{(waT8o7 zDkRBk@Lem4C#fFPso1mgc-BzT&*-(^^7>RYA1?`If;x@#4kkzgcHjJ7t3jx(h-L|U z*Xo%$f;S$c+Cz`~K@;&CFJ!#`>++FdHgBd5vtb-voEOs|+BPu-A?=x_WA=S7*<0_7 zqKMt}#w;_D?Gp5aM1$9sxPyYLYlD~lx}DZ~f&F57bTD^<)#8o&_Fw@WOVA{i?;|ID zTc$j5MtuT@5UD0mu8Q*)E|SXiiYuO(jv1JvT1bUHhEV%g+LfyJB8mTzuc zHo5iDw-ux5%KhVzJ05C0ByU0=R^6_uZl_gq?cti^pF14UuQW}rS~D@RhNBdyX5U{4 zi%5%7GavJkU>iUmq4U0SPb- zCm?!L=hs!}TnuwX9F>D|6(LsdUI@AWvj*l_;T>2Nv|J~mM;`w9(F z&pUaJ<;t%tDg}#^DpqvdJ)iu9jj1o^usUUUvP$&kx~ew-Aogvo8|hd{Qc5bNe&1Ff zepoyDUa<&R{5q@+vrDl*q_wX70f*(3QqTLj08S8~=MOii| zV|z3}9vi~)7F$)!+6Wb;t(o=NPKv?DU@f1j5*8xE?@p$S`QCA|9Io?iHB#ibubo0}Z`it>;dj?Qv%e1E;% z>g*}y?hj%&_`1X`aA16d9vR`cnS{e8&@?rWCn~QU7$JKhGE$N>|Xc= zEe@_28Nf@}?_vM61&bCXBM9X|vpYZI2Yit1aAuAgvhLf29!3gA5Mh?NwxYu)^cmB} zv?hR@$RNPs_+&$e)Ab!APM?b}iP83L%DUQ)eT*=XFEu>5co=9jf=kcTm%>X7(U|No z?g&Z{-uO0u{l!*{=d^2d1Y+O|Vl_HR|7I*2NS3oq3S=UF$sik9RLhI~iZl!P_xMs)BTNbr)L{Tz(KLl6d@M zi>@`ga_Q2>)WR#HyUqEt9aIrqHuXBe}wMWP=-g{Z5 zv(SUru4u(2q_mGN_f90_i_pTxG+Yv@YG2R~Y)JBVh?#`%xbTak;l3#?hzq4fjk>qn{p@g;p6;jdaZ=xfAr}f7 zD27Mxa>nb|C2!V^T9-3fqdTAQXrAuG+P&mGXfmwIlQPkc9%<0JZ)c37!%`$@5?@F# z6Q|vUnlFQoSDBcs)ZA$$5nCc?VWy%gtjde)k{V=$%xH7^E5{@AFMS&Dl^70*Zg1MY zy)XPV>~~DXuZTI^Y`Au3N+XY|PxY9xH}j)6 z-nH{;e(Z@XS>(l%i;0FD-eSP?fAUXF{PoXDg$(YZ)Bp=XlXvL(GB|bh zZVNoxGIboXRaJK?tJq8SmCvmSA0Hx7wa*Hkeiru%DCON{9&4AL;VbRgKZ9FAG13;rGNwp&6=4Z%YtiQD2X+(#$i!LpS8U5>@vIS)N zjeGX_m7qIcId;Djtiz0&-FxS6+YjeE;&f?WL9B%NWI&9y_Ulpc;LB^bh7nm=^D-aF zWc)g#ZOGb>>P}A^gK{CY-6%oFc!)%-s?xBbSX%;oy2ozOFPnTclwr0scV^yk!;)hj zf?GRi60+X*OHHVcx88qEG-4bk-~%wQACZRls&z8?AnuRp-LOj2p0RJfiZ=5w*{JwK|IAmA-|mUzzKK z^*G(Tz9RyDkSCs6F%Z+_W=A}=PglsqLX#vyYASiiZO27jnr}Z`7Nk8L@=SMU{Dxdf z4NED@5v=Z0BVU=iOhReJ493)N!63%Rcww;KGCw~Cip3qBP3 z)eG8l&5&a$7JXhNEYaPqSC(xB5ZS+rna%ojyJ$vu3qposp1exG+Vv%T=6u z*lI+zUCyc_)^7J7ysiQ=J?j0XWa=>I+sFMFBdm^3)9{lVIFb3&a2y27<*W_OZun7H zcQDDW6AzzyFs+{?(<{z_Rze{f*7@-2pJZLD&rg@8oqrIZpL>K!3+zLz+wYF7S|QD( zZOzZ=A1mCMxK)q4e{S&Ttc?#{YL7GePcOF1E3jSV;F0$rWJA>2g-($Pe;y#M2+$5$ z7b6Kp!U^zZNykKqlj3jqBi9^K&e6%KACXZaZ(FQyJ!hl;H%ZXf@H^e|ZuY1^_s^_j z7yTEp*doM(laC!4w-jJN)C2oocbGP^pdlLh@Mm{A*}lEd$vZbD7Oc(_ZkM}dIQd%- zHJ{k`;TF@T1)poT+G)s!Dp^a<`|L$yEgAIaX^c??)|A`U)KRSY`et{Q>*v|7d5tdY z^=L3nFM9ZB6;;(b>N%5#nw#fTnK6eAH(6FoHDNBJ@4-3Ad4@bXot03VJp@| z^!|Qef6TCntHN!^=O6EV+}5!4i=VK#ANo{u_a%w*49QPmI&!92ehG70K@gXq#bSP= zjk~gQuG1MFL%LU$hcWh%6O}y0%|hz{QYv0nwVBuSi2S0rczkv30H_wdP#spqF=xtJ zw2(_H@^-lAsIjlWH7&8z?cf*8h(*f=89H0fm{Fh066N*|uLuuBT-vF3+mvB%lw%quLlI+4_cNz@eE(VQge1nvV(XD3?G(dNV@IavTO#W^Pu>nN%j>#p*}~9L zO>HhA#VXD>sSv~lnvdzJ<|8sqH$j1X+{kMavlv9JXQ7E(#Z4)5-k0 zd8-(WQNN!w3tQ3q+3RBtmo*wny49IkmztBCl>|Ph8yfI^)}!p&pW!Gt^2tgn^p?m z0X8%F@o#n=Dx3gC$6xq^4thCmYB-a&)F&~3AhCf;YwYEj#pZ%KFbH>#7_^{B$Oz20 zhOx=n&$_4%#H&EU%&#tNCL6uzN81^!PzC}Tfh0|g3zF@`iLK(`V&K70nZkU_i`)Pr zOe~au_bP9HxDVxU*-3}_l3)rjEWwDAHrD2pKkfRt8j)PTWR;xqK_(_kdoTgUHXN-h zy%e#TzANk0_BV6dPN+7xomVq zsKl)1$%3m9I>0Y%j#FZt4kpqTds6|k#lD4_PZniJLgRyv!H-C|Y=(FhtV+tz{bHw9 z*;y&P8@T9XL-uf{d@whTzA~f8ON$QoP7Ufu0S-NV=;+a-DgBFIzn1NeT+!6e_nW*_ zDC%?{YdXRla1AG&D)ZpNduqB9Mv8P`ZrYwMgGg~BX%qihJ-9gi=hCWUkLo)& zQ?z3`e`p=?_D-;#o}Nl_KIiel28jmk8l&7S;s827{rH75ad8sQGp>bUTs8>OC(?L?)L}3N-EfYJSU9@kXK6H*!VkJf z!cXCy!=j=_GMfmw`EC3?zL=!*BBzs`?#w3>nj8y7i{mGLA=woGN~7JgXTZJVyh0wqeCj(`UHB21}I`WYXN znt8}2CChc?&|||qu-0`CaWc@-ss$UJ%GlEvYz~nO5gcNM9QVZ)z;q7IFL5vmD-k;V zFzNlG20DB=;_o5CTT(jENAUt?x}Le@M(--ok+O?K7HrRXlNdo!tdIj;UMN`CAVw4_ zG*8-I<7;3-^w$!E%xr(7*R$R&RnC);ACktv!Bx;Q*@_3v?4{9|1J4sbUs8INJRm16 zBV#0(G+$RNUobI;537p-pS``>(=(JjA>1}4KbK8#f?TlqPUbkT*}HTu$veZ#Ac9^Z z3X=hzkq&Yiule#?l~=5lWxAuouO$Un8~mZS!i)+irw50wH^jQ5#7<`k*N1@ME~ykR zXV{bgR`Q*hXj*>$!Hp&$%mkH^z?(}wmb^iT6 z?SK6~vFfiq5FR)x!k`yhG%U+kV_yo-J7z?#+4I(4Kz+mBEttYjhVY3EZ37KFSFJ;b zEms3MP2j%!I_Jgg>OC&-Ao0fo79EK*`jD5-wP6cN_}||bPXmDvmIt36rN3}o#hBuS z2;9(2w8G=Q*LbpN*0-=<&5Unf@e;gY(f+2!hCXq2EZ^Qh%XBjcqmU~&^4l1!=qv+- z>x?cAml(@8F<+rPCg3W$2t~K0vjJGWC2K?nfLAtyZ&88$( z1pAcF{tU*zDZzmpII;BQ%i8t^(>rLLa-LH8TPmeh7Lv14;i{%vufxCUTL1X%q6@p) z^Kp9{h5Q6*EcwTn%9D!!e$Ve-v!9CMe3y5pK05MR zM{TEn{H7*lwk4_&hX)1KvMu*M$o6!VtL`|E9VWs1v%1w%-Aa6~%(6AM;fVr*Q#3S| z7ukZgP2Re&=-xfQb}!vhw%tJO)QN}=;!otFrb#-ZjI{2iHHGh17cje^Ip9v2hTnZ0o-q4s_+larHlCt@W%MM>( z{9eW^I@$mJu10muW5?Fa&dzGzKOB>!vngI@lexLMPFuoCHnx)OHz^zb`v9{V-~0Rb zBq^Pg6%$KOhHsaz`k-(U0BKO2|(GnoJP^Zxp0 zBI{C)@;_c2{!f1T|IhDcwqSpf?w7A$_onC>oZBR_AzC9oJw0M@aIkholIH2wEP>hS zk*J++KlTW-GtBYt_6WEz(YH5ASKro)f2V|m#Mz;boR1IQ^H)wt9(2;IYG_z_^ytx- z_4S$L#ddf;`QJN8)D+?hV`FFcKVEcBAHS*j(SbE4rCzH)7oEosr{2A_^;A!+na>OS zZz!9P8hzaT-JU%){JsH3^WDF%<*EoaYMhJJ|M{ay#<4@;L&}lIudNE6Kc~Kb&!L-= zQ)v}rIPbX6(2#qg@6DxKw~i)N?^V&$_j32W!~WmbcsTI=`}h0qY;$NRI`7!p+FIOX zgl82=Ra+}-vr_W-^QEUweapW+;(+&|A$-+kwcW>@)7Tt)@bzk~i(6LjIwy|z-Ze0A ztlPCj)5k~A($exuY^>pzo3XLn%^An%OocD9Ff%tK9~P3eZ(WIPVa-?b@+JL}B}=5Y zZSxtpxn=8CHcrmKoSd93QQh6$7eD1Y?n}_QZu4Y3e9=Mg9L?>JSZg| zhOAh()4V!-wX}@P#Tz%cEUm0$+vs(V1{h9Ub*}h!c zrj>krkup}*<@NQfc%Hz}P==nK9_jPLChf%+j#zb!_0~K%Khhz+Ws9Uf zG}uux7qNe^$7^mVpW@fmWqx<38)L3rOSRt;TAgPn4+x8hba%bDRN%X)n3J13)?{e* z;`M9h=g*(J&rWV$!KV;%^Cpi*lCHREF%PzhhK7dU{rh~&n0Y80H*QSU6J5K*WvPXQ z1<#ff^mp&wi;jzn+Is43*Oyne*8#%_l})g(qs=-aOb(fkV4mQmX#rF)E&XB{A#+o?2=}s%=q0n@q#;d@Y5qr?QhK%!ZBpx=jV@Tt~hw`AU4=u9i5f!pFcO^Ybtn5($_@s z2S-J*J~|W^>H6*M3skGRSSj^0XC(bEUBad@88m))tn8+QLb%F*6}ohbeGV~9Mp|V zSy|Fp`xmXPMjEEPYt{&D-ppW_VR8|b;%%Ig*^g}BneOEeA3qin6Js(qHs+DDU4<>} z)ZZW=>+o?^MG#9xPgPigo~VU`L&R>+sXZI6C7^8b?Dml5k}zHxqnRYrhaF~q`gCae zv3$Yp+qqAlK5g^vApQC)ZCCb)-^K18m$q~ z_(0w2?cE)%&)vjQ4KpLBhCXrMQcu$DCO>t)(|h?UJ)!zrTbD1+PefgLIn-Xb^{ks) zw$E=5uj$X{TZ=EaX3JzA%U?mejM@F`SWj{LjbG$1Hl-P|ad8F3$FF~VTaJlk&9)CV z6?Rx3)G9#*g|)}>&jbQrg!YM=oGQ$sDG+uoX3 z+O^fIR~wc2>_$)Z^7hWQtYW-*=gtGISji;535TZS!?e@GpJ^GJYaNXx@s;lKsjNL zmzTo9IK_zKVui};>Ynb3VA2ur{#Q`&;Q^76E2f4&hxPZ{m^?q5QaL?)r1y|kPE<($ zzUn`qPE1Hrk_e6hh2p#Tdpmkl2ptobIo6W8BPAt8di!>%{2fPhb>lNL%~I`bKO|qo zw67bLz^~&tR0gF#Rs0OcH3fJOXUC~U+%E?(usjRFlb{;M|KXOB~nabEjeK?4h z={!Aryt1aIqP3L=D_xGa8o@1QMXK^L7T)p~FP8ZJ{%KhF*2>b7CQw;G_(+qjobHh$7AH>VeKASW#bH~) z!NC#nJ18h7?O(F`0JO{7je4|FZL$s8UPwVZ-_T`JQt`*VPnvh!@e-@f1#3Hz^Y;H~kT9%cc$ zuqbjWrK%h^H(x*h`?t*5ex3RGiFyq!Em{ip> zmDbp+SFx_N0%M!mLuHK*mT3sHqjTuYw5_Ur`O?ogT`huF?y`yN_?P_zZQu=3J!<** z_|Wr9)wKFsHhT?U4l9$#?@<0J$!N;8c|Jqr)^e%cu7T6Z;&xVM+9gJb-hl2~GL~rn zgXTrpd3i4baalB_>JzAN^7YLnF`6QJ)ulHnsq}$OxxfLHz!@MK7YAB0xpz2@bd>ae zNa65V_;t#p(1lJ>QBh<6{ue0GSOh0Tt0Q$u5@o)3(3ZFSO`s@}SFl`pw@ z>lTAV9P7HBOHqOFkyvA^#f9I>;@i)(ZyGBR|1vbhvg+BwNZ7gI_R!4ISw{42sh=~} z(Rc4MFJHbKP_eh~%^i({2bY=7l$>dZzo@g>R2S{*Wo@l;-3?aob>hT{@{b>RfdBz0CYc zzhZ^tSaINV|3tl#I#6Bcr@OS}Y;9~B>n}cz-bh)mAaQqKk-bqp_zy}a8XXcse|Dr} zox{hR7l1zjznz_(QNV60`6{C6>sd@JqNNfrWt@M8WzCv34<9~Ub%(Nom9+*~VXSj; zftw(Dl_rg$w0gKje-TF*J_3OY10Qtez?3t-gj@`Yzv=ltjN@H#AtFKN_*9H#s zl-?EpRO-Deh?Rc@Fcv`Cmv7&!0Gn-EGS=BUI0$fs5ga%=>NJlJAe(6N`}^*JfiSG8 z0IIn4MRfi6!3cnWN4I@__~uRTY!NBShcrXhP06F_M;^bm`}QI*^~ITqil3^bc~LV zQ}Ilr{f$EQZBOZb&rR2~W?PNT|8lkOEM3PKsBA+wjKTp@0)lRiV+4l48Oiyv#B)1= zLQt@O9#m#|J|~ zL)nI7P!M$Xo9O=f^{X}KGzVeRWJhZzu`@6*DF6I*2jIEd;^=pdj*+YP?yUoMJn|)w z{g!qR@E*f)hLDh2iq-boey2 z!uroiTHI(=tH#F0ZtnKnHr!FdWN2te{*9dNhdudcK92%tyTs)S|7gjKLW8dWXt&Jf zX~{TV@jyMw{M4!77%@W{I)@C@(T3-4cJ0sInA9VS3q9Ubsfiz&3ac95zrU23DTVbX zw3CvB9Rb3FCK)fEQs7bXtpMdU~q}k3k20@#+-=`uodQuU=FHvpRoy8O@k}-5^De zXPZ3_uxaB{Ypwq8-vv;$p^k)Kj~XxnI8o zWMtSj9=u!rT_CJ-FWd9GDX8dLI<4iO(G}7L)XT^{nFCJ z)~;JeFm`t_fQ-P79jl=^VG}FU%-nG--HSe*ke<$hLWjp;($7S>tKv5ES^^;py?g2M z<-NiDJ`FP&Shv=v)=Vf7**NnNR+>q=LXBywx<(NVzqo98ovAB)?(9GOOzF&13cI!t8qmqxT3#+YEAtRjE#u zu>5gULPRytZWexh<0mByot_*Hv0cJ~9#O0E%%rrXL#r%h?V6c~hlh1i^lX}+tj;hg z)NQlS64i15rj|vG1!7Z?O|LA~|n;Ylbf%lJDq0>{TR1jQQ_7uQ5<#Xq@jgOCKS=FpS z8HE(4DStmY`XbKyrd5oDBb6D6SL3j*mI<$1WcxROmvcA{xqdi`1(yXnc(eMzF0SakyAQQ%}yZg3EU z)~~1F`%5+c^qd;Jn3uN=e}fhcDM3(9NQf4~rz#pVg(4{_iM^q0sb^-!|NZ-SchLBS zg*nIWsz9_cOSEW;AASuFFE67jxakUz;cBcw78pFvn_im1P=pKwNgl}7yhF)aY1m-Y z-<#)&GKUi_8pC)j&;CVzr%xE+!!sQ**Hr{BY6!n;HEgAM?)14Qzv}<^x};YT`V8(T zBrCgW*SWzxnwpoZcVMjpii$jwsxVE^@BEecDQ{t1v}X6(@A+03zXfe zJcmzRLqiewcAY2cAjPStk|D>oiE)#W&~#M>mo5&TU9oi?7dePv@P4`}DmFHoDC>9a zTC;4~vORl~T+pwxancB?hNi~REah`e%rK)ICE3j9m-VfYC@g+$j96p(vF+XO(P^HT zmGM!s&X1TfuiLo_dV|rklPmzCONtxvN}jxX_fFciPkVmy{UP2t2sB~Du~_&$=P2hR z@Mr0O?;!xTX>{zE)z{h>qf)OOE7r>U!C)w_tXxW2f9(}`>IOa98;1Jpz3)O54y;T9 zvxVT;>HY>%wv5Xc4Z=D)JNYS2gRL@PR4-t1;J;V2Jv$XRk)*pB?ERv@oC|ipjK`$S z;HUgU^3aRbH7G5p{n!l3Dk@8LuJA~tz+tfJsSE{1DO!2#G=xOvH+Q!C4($N+B+tbq zX%d1$wO5==NJ1k0rAMU*BxbYJ0n?0?k`n3p&u{N5{d=GJXiMSy@?r5TSYHYy)1td|A8VRx17Rk0n1fNNYlGC~)dlPARRg z`@`!4i1G;LQ}PK1?&fM!IyY!E^XpePEUlU8PT$6CD|NK9UI3pM_Ko@y(8>cLS$jQIv|2g#QuJZap4QUyBYaqdCv0Y@ksuHh%EgNplcDn)JTY3bd-rZ)3gY7&9%_MJ zF#9h2+DN)az0x9H*3-(CWy_Znzm4#zhJ=05Sfu5wd|jyU@L{OriJ;6U#7tck(F*V^ zcI;HtzO#)5aMgW&Mh3ldyt=soo)$eTYfJ05o*sXKFV<{7BR+k~1iv>)vW4v*k2hHp zg1qFIho{QOt{Baq%@SKfP1Pl1D_*`#rhXe6A6I^$7J<6q42vc|W2vy{Auy7%-&1P< z^(6J`blc!%<(nE7$P22gtN;6)n(oum8n`Fy7uunYviI`Mn<~(oZamH52WtA~G$wiN zmmK(qb&>xgGskVf;O6by#Dj3{dz~`q)X!q{his#wGPw+vXJV?dN!t==Gk^R!{LB0g zZ-gAIe%crRs+n+v|NrxN|GnJ=1!wn2|LZyNq!<5h9%y3jXzPEo0RLA8bb9TIuH&fe zr2b=5!7vNBv)vH~x$tSlKh7o$ZuYDAnfH%a!o$M>;AJCUo!Al(6l7p*uc8t&bmNL4a{Rc!wO1=SIB2mkAs6kFjI~;r|5X5O%Q{%=e{1yh;lX=5vGps^ zGe*C~`5K&)*Ez z#iA)kpPeN1z~A4WR1*77c`QZehD2RV2b$B#t|sy>^bUW`q}1!~|7_Ec+Aa z1jNPB$rD^;A+td&vjPgM;v=3Rr3+6~4PsvFV2TETx)}4xT}6fS>m^g#0>ezx zLK`;prPr9`*swwBt#c?OT6%9yl+czf?7&hK%DQ#ya_m}G5Q7pb5M%?H)9-|}@9oU3 zbqk&x9o+-jXbghD5;}(ZfPkfI6}<{nTF(FYz;Ncw8CZk*bn3Id#G2dY@UaVc!#WaQ zmcG9I4x#XZ(fyY^rT`iqQpNm+hK7g_RZv*SxuXwn61@iQ|D*PAO)V|z#>TvOI>L7% zzKo=yOj-=L7vgmI6xQtf=Lm=hv4;}e3Vuzyckf=5&gv|SiuEy?VY|zK6&v2auRy~h zCNWf43()XVUV+2<*Y4b5!mGD9mZEp# zhbQ`*p^Zq6hOmEdaBy`g*c2MRp$#)oPs@zgSQYozor-LYa~ae{H72N{NE~FJe8a1K zSDp>Xg_a_Frkw|NZw(r4-H<~D_vTfMlOvsFA6kx=cyJ;S1OT1+#Q2}gPU7VWA{l;u zuHX9h<;!yPID*|F^UB(OpoiyI4R#y}6Lk&9c9@!6Jn%4%-Rn1RP^cRIR!uD6NI_~X zPtuB&KJ|7j*+JVKJH)pv!Yye`)fXdAv`J&~Oxx3>mlpuB>*JKvLFF2sojMdBUptL2 zuxIa!R}fez6aZQ*U7BB!{&jqR#3sfkCK8g80^tt2#8rI$ybh9Q1;AZ25D3;+`OqO& z2;z=%XMB80)s!b3z&R7ZAPE%&st$sn&Q!xVqSd`R)Bfr45DSPoyq{34s^a%vlAQ_+3c7K@3tn#jz2bMP z;EnftYvef1#jM6&wVhlA4WiUjC5ViS4t= zraKLlLho{EJ7e>Q538V@D%Y8z#DMA%!b(U2xYtB2V;9aEO!Qvxeq~eB`#LhlYoRB6 z_@FmGR>Oa#?yJtGRaN(6V`6^3WiJP)Q@yl|1)8XX{H|R@T-Y?d13((Ke>jA9X!rZT zr9m@br+TG1PmGV(=F-S3De;3a1eBF2RY&nhA!k8vS5phtc(B--ZWPuyu^MpnR_)kE z%qytbRi*Z$d9V-GbA<^YN~I^Nbx#|+x$1r=>LeIw7m$%7rx{43L8fU0ymHI&FRzGC zt$pACK=fEOkEW=osHRoX=nmgTIO??uifo7VC*OJ)L(a39o&0W8;vsik^Q&j{JZD%{ zRmOMl3G*{&OgGE?gEhR9@%M<|`~uQ0NDN$yQM)2q)XbG|lP>9|RKgDrR5|YME-xq~ z6qau926=+Ke@NB>DC87#8=K^4CmKIUas5AjD66YK)@V8fIRx>LQOM>vhIVV8<>ppF zzCrs{|_#Dz#s-b6SPP9sMo zXY9N~L;MngE}*P-1H_9Tep2;Y$PV2MWDW>Pc52P))!lVcWt9L`0hd+bKEpfS{`ML? zw_M}jAF0<+!EJ?~F(RJu;HBcRW5+gjDvTO6b4O`^bS z(uarpvTG$$w~yj$0#*%m1p-bk;N4<0FEH=`A-N?rK+l|V$u-nR<6|PD@=V({c;sT8iJwt zTKFFzGo|^_Bd_A9-?B&BygtRscI>BVg&-wn@T43@Bm6gkH^Be$9dmwTzW7{N+mx2vB7&eYlK;A5gBV{?tWy~*VvIa1F4RPW<(HwlMT{&393P+wL38KsR4z1gW7OsnUohad1(9L z#F+;YSIub4b6A6*PEz!T1*269=Vhg&q9o!%J6z$jBQHvIJ;udZx-6@xXcv^Ii*Wc5 zuQO@o{|~x^5X!Utj7gwgJ2IhbN!>Be%fRT$uF5XnzlTU+OW8Vk5I6{#$6Hi&lqYU9~Dkj*U2&51fq zti6f~TF6Ay+ldG6Y$Na&$>GC@gYhC{(9qOW1<65{Q5m0p<;oR5CT>ZxUc^O3;4_K@ zz91ihF3gFQ#VIFj%JZMJ4%B+JnwOVr?zrjon>S}ZGMcXvmW9e53LFf?9S_t)IutS( zNWw2eo>_k9P5>8k#C2v+bP4*0cJTn^io}b7A4&8Ki<}7gLYZ@@&T2K_=CwnL7d4ao z;l^Rl!}5p4%8KYP=x{}IGgqvXnNP2``}mhowVqj3tZ$H!I2ZdnL>5Tc9MVJN+8s1V z0D!Brf|?OC=FXip_?Up7en{bwTq5G<2skgdK?>)}LrMEXzsGmxZr;3ESw9QfIx+O% zaiN9n6^)^O{Ag_DeJV-!s6E~204VMY+qV-D{Vf3JeERfhSARb};3kQhLKz|$g_Ksb1!f*;N=8No zx_b~bZKzN9P*yZ!NKd`!`pUYkIMz^LVF*>>+aJbhx(PMhAzq7pq7HyJked$uKj1gS zgOIQ=&!JrS1^d9G1LG=cFiC-Ww(8DCfy8OU?x*y&xz9gHozZirnL(2##0kj(gD*yi z$&kD&mRJ8}J0OiZHsgQnz?4>3Y_m}~c1tLw3$u2pR}7#2TTxZ?ih-q>LNjCGQ6eH9 z5FGpfm2~FIwL>J50rV~ekdF*!HBb>W%EN;{KYsiO$;J=rJgPm|IaCJc-fEWk_#~W# zjI4ze6jf{=0yra_-y*-*>AQ&J3=ITZmCjOcA{LPM2&f zgcs~?9V1Rb_9_9T9&ckG#1e2L(t@G*k)RCfwi+4^Xh|@M_X1~wU*l{pgFV#n!D$$4vM!i(^Bw;c!WykOjb&SC)(=oe*1`?4c)5o&>nrqITlk1rc#*>sAhlV|f9< zAlPtVpCmklz z_al%K56m2e>9rE_e~^Z-GPvNvblGA8ymuaXr}dOB^zH(;0WM$&;bIMMtH{6rck187Sf)epAQ zGKeYUuz(8@diDJL$O@#T!Q*=H(#V;DT5x3^e3c~WV9ia9bY_md+XDwzC(S^DQjY#f zh7eHT@9y@@(_j`k;6(5(@vH%Kvg}$UBm6T6G67_uB?+HZdS-{9zu*T*6#@oU+wLEA z5wkq*-4{brBdObdC3y#_Mn*>RpG_xDY^r`5NfDufb>oRt-)86JRDupA7^KNqP?sX{ zrla!G_tvUq*=g!9nsiHBzcxqjzc~@r;o-7XL zqsF`fQAAtzk+5;w)7h_{u*E|lsR+u;7rgsO6Dt=4QlAKt#XEG@5Jt-R^XJh!%pqM; z7ySGvcz80u(VFkbkG-e&NHQLx8~Ui|Pq&!;C=6O!VNGQ!`ZrDPKrzWWa z91-IBmi~sN`S%|^O=K>Jn2goUlEPR80A>XsvY1?6D)sH#6@;M!bVT8w`bKrRSM)>! z3kyl0z0)IOeC>dDcUPAmW=hCR75P!<5dnYwsM!t5v16Y!OSZ!q7QwzZ`*OZWu&uS# z{QJ8kBAVh!ptD3G#b5y&01(>nR!#b42J=uAnpHVFHYTQ~h}Vy)$NW#)N*q6BYs={E z?G1%0R!i(lR4C3mK=92hFD-~(NSHe|9h}{#-!@$hSPtn<9o8pa^Y4Qk(fE%}AUdEk zV^A4O-2_^Tv?+j2YtEfJN0M6T)K~7^JO0R-u_ZuKtIkgF-510$f&00RZe4&<&iT>0 zK)hb&&wP`Lo&b@BAoC&7l2&`Z=Slxkv}m=(h2N;(%UxYvQ5evd%5iYE++s#)uY{II z{12kAA>ARu%YiA$l3b#1Aml_k(#eyLs<+Bv9B=KebIh1u;@oon=zXhn_PhU+qg7P& zY!RPH8C;pV6d`JHKsLCNR5W)oJCI=f5pzgnmW0HTk@l9Cm+wNfd-GvIB6XYC<*Mi{@D$xQI~6I|pKwQ2)DzhfP!MP+B?)UaQ7R z9*3NlZPS>TA3i!1D)z@@DU7VBCTwd$u^|?fx}O@T%^bjRmmkicfxF4bZR6ulIo+TMgrh&}yDK@xjT*a^&{0XjVpH(uO>^Dm%gy?F5g5~LI+ zcZebkO3uJwNs+%WE__4sc&hWg$B#GSU6JtaUOX;}EJinoWDBq%8ngof{;eDoQZ@E)@P%;)ns}u^6_5pwa>Uj~f@)&X> z*6Q20Tp#b{V;5lfqQA3D2@IJ+!OR8~g`XsOK*_mxy#?MN4i)-HH_kfH01+`L6zFEE zs77Su4MRdRb8|x5wvkN0Docc`SGBjd*G_(3oE?x7$X>(Ey&QxV%R-C&T|s0&b8`yP zCgRYzku)j?kgk)h|0LRW`OSa(2KlF626@LMVOXWV@%RHhQ4(S#jSEB@EkzZx!8hlB zYgnH;MdtF5&q^w7ZfjeO-@$RUj6$9nLKxhGSetOj1?Yv0pP+JJ#()eNF&b}ES5=MK znP-@CEMF2C{RpB>f=`lgD=0xWl1d2D5fO@P7X&NAaO^RuQqbNf$?``?nVd-)tRZM1 zrmOXnAEh%gUMojl@{)1-$?<8S$GRyVP@1O}b8Hw&

    Ey}#7v64; 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�@$<(wvjh#jQZG^KF4H&*8D}2PB5d}51587-`<|g_R zV3%!z7YFbnJDCM%Dp?c=NBwd4bqFjA`_vr8Xu;XbiTDY)orD5!oc7!)Jf+ho z0GeR*r9Ai=>4(W`1BC&JF^r1sD|Y1y=dN6sn!*Y&@l2}~@Dk-zHF+F0q@#l5Pzm|OLlMX3FxytPp8VVS4f)5<(DI;}EV1=2hp$#q;lZ^(ncz=c`y?3Mb1`{PRZXOwU%I?Ly5* z4^AL7)&d3X_O^b}B#Gtqgd>&}0gz|r=29?bS7nKL>PN$AV8-qjH0XY-{%IJ3b=QVBlw?3#)z}nHN867YqAWW1_ z*2qgnFEi#ev%NTa3M2=G+*!{Oc(zK|<^;7^WU1kpmohs!m_icMwEXsf?7(=a5*yKL zgT>urGzr@_se(_q#das$H5*vNI3>w3hHjajw+9feRK$I?CKz7)*ZOShL}0+jyxT-{A|$K)M_ok!Aa^sP&MQMy<$R90&XSci49o%m7<_ui=vXQPIdv@ zdnkVPZHgaW_ynH5WB|-%Gth`N#4kX{P8rrBgjhfx7ZByjASVGt*~h5OyIXq@^PCqZ zTI@s6V)&6&#eAqVs`t+=`B5*V zFyv;q)iTN2$aGNu*M@LLkNNM0Fsp$W4;jGZFqqdej@c*?18c#7Qh`e;NmJ0YJ_A7N zDp=UrQ*}r%`VsjB0TMy4zi*Ytl9(1!&Q!Gj*(6EHi(x4{kw1FPg~$CmW!^NIB)O)| zw2!vdVg6YR!j5DBP;4KeRQOE%>aJcqwGtRPmhcY%R^eD6WLM(IO9tf&oe5tDh|6X1STnK&+Fgbk48hq?(y?${0K=Dd~@_7NKt(e2ElfF&O6704j@dmih010nU7=PcN&{ zZ?q*iyJ{Hu@q4sKNHE?mX#HxF6}R?NB_v!eq$=IGF&ee)CjEFc3%7Jh7>J3@bSy3A zgSe4`2V3*`vo?y0++-GT#rOe@ARi}qHuah{@_(9|UJGO>ICcf%gl1u-ETqv}AQB;^ z08SS<94bmkFA1uK-9|=o_Rh0ZT3Dq+&-Fbe3o+$mi5d& z-)5B+C=gQlf;50I(*$IkScGhK64pivhfC)WDP>8)&+IsGu&|(j#Pw4%xCK%(kiA8Y z6!ec}hQyfzACh+TnsP1d z4<}ilCP6V4D}lp+*nP3M^nTzv_uzTuYd>K|*r_$FTX5z0uPxl9e|Dof)C$fp-a~_< z5tOecbi)?rI+pg=Pkw4`O~L9wc^87g4ykx!NoX(4;!>!Kbqw~FU&T4FNi`EDhCKod zYB<(ND+(>pP0k^JE|wOCU6av=uer~|gQfcLsAaiY4;J;F20@IK_{1)R;gXUIq%%Wu zE@Y2C5Kfc;&^5JQb<{=~z$-b;@N#gFtb5W{ZpkPf^?YhOoDQS~2609Juv9uWcOp$g z))XOg(4`3Vaj!rcCS=8u_7MU29AHHZ^ukC+?2@efU>Tup?8_*Abfn$xBn)bl5E8JU z4X(<(0@;c5;-gHY# zmZ3}vq zNFITRgfLPG@$=`&&F(9+l#-G0uCN=LTzu+0uamANfaKyWyAaxJQup_BOBPn%u=N!| z`;Q*QHsO2Vea`~sFtO?EofNH}DLYUp3oqa!vxH0n1Wx`}pLco|#Rmu>MUa3bVe{=1 z7H;cX%QEVPIS@|j_bg51K}g`gmd(ekGU+V=gph!2)pN);>w!D*p*c(nFhap0M@&kq z6ONazUO!BxSk238d^YUl9%f_|?&iH_6qnxED z(PeY&m>X$XOCp=)4P)%}Kh0doGgw{tVtmpSrLiLT#*qfiD~1VV{qQDWu8@R;%l`RO zSRmOsIg02@PrRTJ`;M|=baL|W8fq^LRhNo1t0@d`L2a^HoX~!fl{keQkno{J$2&e+k>)iO?OFf zWE{LEd*gNernU!7TPbP zJb6?sfs2hz0$q?q`H&W?llQsQAEDv@1AY1T$lW|mx26Gi)$^_c!~t@PQS4GH7+zpV z7{9u=vkzz^Mpnr%hv~_>WS#Myue?vSIlsMs(a}*)?xW=5HmbW1xqLhZ2L~A#8RG$; zCjm|0Q%7e377Td}D1td*$H~mhtYRkZ+uC&lOkYi#^l}A9P;VU8)|X}LiPicc=qA4X zh!wgWZSv`@txs)IB3&1Og4B?KVFLJ@m@bYQCq($F7w_eLIA zlkE8djB&EQ5^=QmwH4+d7VZRNDq&x>e|}c zQ1jMLHe`MvmK(3YW(7Hb$H`iS*@TXT^4J)hCqnc=JA)J`geC{OF`#`x2SpVBjXqJ> zr~Sk<4Eel&ueBA`g>)9AMr@$QVpZ|9R7c`s_96MjDX2_RHPo0KbGM}J>R02&8 zO#qmEJ^$7>gnDQnFRub2qe(tQXU_OjZW0>xftNLb21Q=p@gO859%spqhqmqh5Lz5G zn0a{J87v=CO5g==-YcH^(RHlf1GB@>eNY1}g*=$*F>By+8RTGw2XGK@Au!zt+(jW;HI>+3Qo#g1mgfh@BpTDJ>Beh0;{**%+23`la zhL{`QaISFZ_N)E^{q&ONR~fFztsAj}1aEYO=lhe=$hdO~^{wSPkaL;D~V9lg_ zPGlm8@Hb#$nTSBXXK(ApuWG7FRjd@s5dGGzDF6^jv*BM@Hd^ndPymra|EjHR3mWU- zoy`;_FX>cHN>qfG)rGW~wb>w8?mqmn$Tov4=lG|2*B;vXYOW>B(9yB6KtDe}G;1u{ zCQ88PB(w69s6zugfz(j(#gj#1x$NB175xTTXAcAAOSK#1)10O%iucLLW5Kw z8EknNbD8lDK}OJk>ts9qd{_+dkiPeCPFO)i>#C z2${bz*=G1=F+x@BfvB?vGM&+>sr$(Kst=W>`8*ueLm`DXA|NER4|M^$hNL(iDbX&r zlSjKE5Z{O=JAoHa+tkEgS2Qn*gU^8ad@}aJK z6LzRKz2EQdT~d>!`-v?Utk$q7LFM@!H1H7mFn3ILT4~QGlazWJm`3l|VF# z-~b#0R;#;*$JV_cF>&}YiabtiAUO8g%TA301tqLloH#r|PkIit(4bsx05Zi73|*^^ z-V1mv@+rvAV~{5r0-+6fO#TAy#1qAtpfRpd7P4@^erBu}Zy#iDb zu>XRrBn_9mzkQp{2D(As&6U51?Z;wL? z^|j@<1yU%lq~CVr{tJ`Ir}w2)J)jKIf<;gwY*$+mk?|7mK`&2!*C_77)&i1ZW#{L& zAO@2@4mJ~ai*-TBH-2;OM669^$+z2BI_ilm%cTe4BQKZRu zlJQ46oD}hJ2##wdvRvt7*DEB(?A+XGa-YIqXo(RcI%9~2Nc$QI;nrcy+fxLp*$ifC zBc@~qhMGdwg}kXANDTeb1t1jUjuaeFV9uQ#x{*)s6}nYd!>BvHt!&FmXGcfqr|M-)a1K;%^Fe3M)eNjkc>oclcEJIaRuBRrC%rT_O!ZZkS61Lz$@SGD8}tKDQJt0TsUo-77WH zyhS5Kk5+SwuIQbvDNJ&~i)NgSy~msz`np-K-4W&j2BUUtV1o^fSNmD<58=`=5QwnS@+A0nTEhzpsyQRw47b08#3 zxbaz|Jk&e4$jFB7U78OMOYVrHyu&*^UQI81K+TrFxKM7ZsDHO$?Ka{IZe`Sw|Cb0( z$1q@uv{)cfx*7}&-2|Wz2s!skoQF2*!BFxOW^bVnJFI9zTGjL0As^zzD+34=P*miY ze)S(_jQU+9XMP;N`_Ak-1kE!wO$Gh&8(>ZpTJPEC0665~fE1Y<$RV5$RI{ep1K{!y zQZ<2x$+knbs~P?q;WlWo!2lHsB9>7Y4-S)?`Sa(0Gezq6 zr~W_2+J9mB{Vx*F{trA=#t*HBF48TK-y|%zDQbyjb{WO3ZMuIo2rW&ZOc8c+r}?QE zOB)-)>C|X@!~XxV_ts%qu3xw4YX=H~(gK307>J0VA_CHi7&Iaws348fDJs%sBA|$Y zg0!ThNGVE}bSa(EdB*Kpzi(gP*?V8-&vVXoZU0yc4Ot6DR}ltDvk&un;My3wHR+#2m;=p% z2N-Ktc9=CIX}Y4PG-5vs5Em^}hIF<%4E){yYI_s5dM76;Br`gTEDg7JmCh+dwt3W*<}|BvP=5xn!ZY zed}>%dOE4(i-Kp+);Rp_X6GZE$A-WQQLNl@Yt-{YONro(MFVKB<0=?tR>D z#I4~_){8oJ?*+Cxg657rzjyCmH}lR<>FMu*iGYt#;Cy2%MTCbe-P;luLFvV@M%adG z@F$HMhnz4ECuf9uqfPvoM?4W^Nnl=(XE<`(hx}m=;A3P$3X{wk-@wm#3zf6^?^mb)f{cLGuL1Lflc1uP0a zhDoqDgDCha<@vJTkk>-T5+Rl6k2KXzJ?ZW1#&sU93Y(sq-TnN#~!h*KMzs~28L_U zbQOel;6sBlG~;goZ6vKAq>-ed`upc^l*~pTWvNc%xo#!{afCyHDg#Worz%6d;KaZw z<^!fnF#Epm$dQ_UYcs%jR|U)n6wTlf;hK_u7>Xp4;sduPTq79{;3wzEcK~W2 zh_-^$`LJeFG4-8jIG#s=eUf)zcsLHJYUrRjy7u7M@oUGUvyJTdani`SWwb3xp9g8v z!xJ9l;ve?dq8V$9maB{yfi#arjTdY^&vX3v&zTscttdjVrVfI2y0bpHoqQjZyfxs{ z5{9~Y)|kwTCILu85&i{cDW_{V(gf0IK$lY`nH%QfW66rd3=|m`{mVAN_!CeUd9e8d zA3m(siamjn0YegNT0tDTonI59A%Lwf4}yXHlb%UKKcM$UXkRwwIA?-8^B%~D3^3Yk z{_MC6%a?C0Z4hioH9>ALYC9o&o7fD?fgxDR&?9f?`7UqI&uET{He>AVali>I$8Wwt zex( zAg_VEvtL+vF0SceHQ(%ytE(xg-h?*Kvkm2MJtn|MjGh5k+zSW*;{yl-6)2L(i0ZX< z_E^;fi$PS3#Fr$x;)*t-D7`uMf&D}J(S#6ua;#vhOg0$`1;jIfhI>C@iGX=j1BTq` z2N<#;FhZ;2NEABIb9nLDJH6WxHY(7~jLGqav&UCMC6X>GpRjlS&5vbgGP$VOXEwy#F zZKA0YcA7~*wPveUy6`7_Ss7cIfH5%O`8&44%Ls-nGDC1JF2(1}MMFkHKkUd1=5>`p z`r2tfI5HAXx&(w_oAfXQusWfsp+F-;Ft1zxH(ma(pRj}DNpBx32i>vnwW$Q80uGGE zFs}tiXw$k~eRpX@1C!1_B10&<+cE$l!FTpOT88k(l_TiKS{imIwSgXLUBcZYD0N() zLu?SHPBBiZ22_GH$!b3+Pt&{} zYTa%!7^N-;TZA^?xHh5E$s9DvSw1tdPSwT9ak}hs^FGCD8PJzOjm?4#R|H ze=G%m6HzP#7UkuoMKYCw1^=V-@Nd0pG+cmpMIj|pv~e25&=r;%YiYQMy&_>yBb>(~ zRx^);`n-i{d*%q~4&sjdQ2E`_5rOU74`xR-F3(oYIf(Jrye@V>J)X~b(OD@xXp-1i zo{CHAVgeqTJ(^E7_dC>_AV&{x%HO2RO%u~zPg$b==dgx|M~oN-1md22#iTW!gf+UN zAQE1Hhtgx9J=}dr*~mN-p|;>u@5|_Up#&1$uG5H&$-s-F2*e3?a0MElgrWRe8>tTI zn>S|JeIB24J!h8T3B#;9S zC*;tTJ6J!zhKJ2m9P9s?F(#C&e`kziwWo2{t-4ESV|8-1Nne{Fi;qBPB83?mB~^qA zfM((8Doj$oM#$`kn1b-lZqJ;hxdD-ZoFJZq?X^?0&1M(2k|XOH;tlcG?9ZBc z&B_3R0E8AKyNl4DG3ty%X!o@d1B@cgxDK`$h;NhSSUc+RTF$)c)Ov=Mp5AwM?9=t4 zmoI3g&y-1=_Re0n-#fd$A@k)+y#($Yy+;l7ee3nsxMfbSN^n>)Lj5@~uxEN#!YzUM zwkzd>gRTY)fVkF;m&GDtovu9(|1bET!3yXt`%NNl0{}SzQySo-uY&Cg5e!k*f|3L7 z?G=CsYs`*;1HKYbsDN~vKb_(Zh`F`Je!~R1xy-2Z*JEPNVUI1!Q45IT0PbWt|3c(5 zpo5OSK8-hv|9Dp|OQCgQH$P<>G;k7Tk02G6eE%*55*>Ecu9!fs0pSaE)~WFEMVB;y zC_KPr73kePByWd4sMc8<=d9h#vO`$-lU(V~Z!G2|-fFFrZ$$uXsb@O~5^+sHldl1q zOC)J>1?eKGBBGlJWEfeUaGp=Zwf2*OJgw8KuJxiCs3YBFOICwg$y^6TbNkmJf4WkyHu zBPLpCt2$YRsVgUyea4x-Q&^aVxN)H$TyyNah6XccQPx8O(#c5#r_$)ukY*HryvXc_ zZI){f(Jt2iV+ORV6Tl-6rW3tV46a;p!@l6e+mL+VQqgf&ulVw(b8`y@MDQeP4wp;n z6eF)FRPde1ha|4|K6z~eWac2cF9<)!^gD)d}h6#x` zG1g0WSri7T(a_HtA(#q-r~x1(t>z z^fb8XylNEgf`ZZsOeYWM5;nO2u`5pT$;$oJb5vin=lAdXh;J`ZPHmiaJCIa%s=MXt zd{tX>Nr_zlWn>&~adBorQ_EvrvaQO*XVEnb9a~9F%}lALN7igS1Hw%;HALz(|JYls zF_?lg#xefVO5-w*YA%1eP4!;P*!>v0Us^if{>*+KK!yc@_1=MjRrX)Y7rITCTmek| z@_+vK+CdVcLDC}~{V*`_HdqOIIO*-iyEhOP&4rU1`qdza78Df`sa~`epmP%xO=P)) zLqoeT99oQRve)FP;FG0h1ci-$U#qI<@U<+gtfY`h_*78o>>k;^uwZ@wJ1$(|?AaZ3 zUT;+O2vKK-bG4I=%YR!PT+o<^co#-b$gUk;V_42nyV94?K;(q7FWE04AuI?qd?{h0 zNU+|ljYdkF4=(r$3M`9>s2Dn8`0Y9(U4;(e6UYtXq=&%H99JQDpzE=)uIQRVZV6uo zgyD>_9O!=IiRX_%ZUmB2kMrl37S!=AH#$o!#mB)}9eH{lZ1eD|7cE=2awoD_ARmf2 ztD_}`HGqH#9n;n0W^_(2w+^_Tpb(#VVA)J3Yz}~;LIw3vSec#S5reZf#L7`i-NjdD zkUK;A2*jcgyPGp58|OT6?QmIMwB0fC+*CRd+{v?H$7msZlwZ|T$T_fGs&z9rW~1P~ znAg(6hK{8~OKz(a6cAQIWL@jc;vY7m{Wfua0u3e!12G1Vm;rH(`k#m)L{Uohb7fUk zK1v1mH}A6s>Tr_4x>q`4V6pt`e}x;|gZy&HJ8khPyjgsFyyzT{3@-Nj_f06U=KJT4 zA3t7EQ9&6m+8e${N6fWCEhw=M=Uye!14y)==3Vy9TIW=##}As6B_bh-BgtGKw~wJ5 zg|5=WZF;H4 zXe-5-|4P;=BfsVH&bk_N0C^omc5Xj^&PHs!vHVZLt`^RQGc#kv_L2&NJH#&!Nk(8u zHVhTLQ3DY3P84>;XkoZDo2q-)TF3{GZAmurh}{p4m&9EG(dn?c5g2@V(DFeIA@PbI zks4RdbkuA+M28Lm%pl6Mgmc`r8In)XU_;S&AJH36B|GnMK@u@xL_=(JY|IT`5Xv8W z)5@?c;p^mv%o9^Uo7X&9RD8qX{Te?5!H@~+hg>cZ-NxsURYSr(PzXvMQoKq~v!DZ| z4@@62(pXPE=xpMOBp{Q8c%?Yv3$I$7P||3rkD}MJt-&0MxMPP6+QRdP^F|_8oZOH>Dd1G%J6BgchwC?KjyAXHidw)6OFjP&v@%{F1rx|Bwf+3D-P(QAa*4Y?z61V8+=+QI54|e=L z^`=lPjv|bqFp*SJ+6r*P18j)E^$%@Z8qtRRtIuZ%=?tR$h1IS|)AK(LK#24bAu0&t zVNmf71oRk%L#Js8$c zciDh%hNEj02wMfneAjjVa0xqnZ6n&u7U(YoKYMlsi$J_p@%nft1~qr1lJ~$Gi?Rzk zs_yUWyY2eg1r>k+E`@)6LgCpfh#t0}hy2!R}b}hi{qSg{pY$na=v{)%js0l8BRe!i6TM>g+>uG2h zzhORWgO}7Dd|C&Ziik(*fZ3IsFj_9Bpzzp%&nyQg@6Hn^H2a-3OB|;)fz1$23GO|6 zx{$*$9kbX`Q(Nm}b(t7Qg0^O%&4bCj?f9?;2(EziOgY}OWBmGIVu_3+jl=})9H+fm zG<^H^Z3IAA9L%pz+%bu7Iq;PIRhA%`?_bcqjsWy3*9TgmC?%j&kxOT+RA>D%W?*n( zAO(9Il5X2TE(}HjUJ(~FpbAlYh~s|^dp3vjQp9C}3EZ3l)WGgTNky#Q1@mtQ*rOeyqQbJCZQt`e&oJ6NwbtBa-Kd?40L zP=oo52VsF?k&=gRWiirvk|erlh)Gd!D^*;`o5mukw1ZB0am&o?Z_CE&xNn-<*>&(}^<` zo-8L1&w5~o1;D)_Kd1D@tzG9?NQfQ|$o!j?yP}Sb-FJ6Cg@A#Zus`9u2G$_pUrQ_@ zuEJ`hwnP;Ur@w@QgD|L?Uc|K+1mK(VS3vvkt&itGU+55qY@-eX=rw80Vn)O!NF7nQoq5Y18YC5nLUs57VDCelP{o`c;IsS7Kk@VK6Cak#TY{Z$U7uj zVPc5Ll_N{_7Wo6IMkee?82~(QQs?LAaSE_Q3vVB8p3yol4x7N8isiO2pp``BIdU7Y}T z8f@PVh_V8iZO(RN1Z*zp;4pV|$ODPP-|P`FYNH6ZuDA+a>IEUujrHU;FIYi?1h5eWI`Nvr+i%>J1t;(~W{-&IJlKJ2=<;@XZ6E`* z`)=TYf5D#N$LhwZ-5WY2TW4)!*%)@zT-%M?`1B0J*sI_T?Rq=tTzwPcKg79 z4^%sz*vP$=z=;7h{(BJ-oCJDNxo&9aiSZ%I?I%wdP-2|fdxm`atL6KcVTnMmh3Sal z7U&Zsg2`7!9i{v~-HWfIWA}q8-H@&Qx*4ekdhVDE3+Yk+*?mjmm;IF=xl$TgC?wGfBX)#AO&9N6KwzJHsN~7tDToFh%di z#+rH!nIrf3E7Tiy{4TPF4Nb^`5Bf@zBOmAN#%r*?0J%vVPaQ-WewT$Ai6){XjME@j zeMv2;h->`sLuUIoG98Y&l(G>O#b>aaECAx;T%e*5i7?2|>Vg9kj6@bh@lAYu@pL~8 z0AE6n*b|8o88cu)M64yN=;FVnjlbtBNmV{1z0+$Nm<0aOc+?g}`tS*lVq9R)dFK2Wnn921aZNIlp z(b>s~q@CpAkOmQ7&kN?bN?10yg@!Mu-D?-dAqil%t2J~E?p*JV*6LU}>tf4+B6 z+-5}zQBqQp2Pm48!N)C2svoms0R8+>=5XQ>qhY@{$eHMyW04`dh6mX(%v-FY9CKX~ zfnq9~vwEUI!@-gTto_RblFo2%mTA(PGRsBd(*lUiAW|h@QD;v)CV=xsazz0PfNoE8# z78;{F`1xs()k`9c(KRu-Jm~CQs7GYX`Lwgh)er+xJmVDqX1WC@luK@729 zr6q*zW%1PAB#PFc^oQ)ZBF!@wUIhgO1lg7L@h>q|e@a4PC4d-RpvNSK#6}}_*U?X* z8zX3g5m^nl+J}G$|JKCVJBs>D8r^=Ov;!b=xdJ3@@z%pXFlqf> zHI@a38U@($c8rQfV@OO0j%V;<;Sph8J`PJNtTNyqr%k6PCZrHfV4-;rJCAG}N?=Hc zH;y49u#QL#vtDpp*N31f&>v*YoIJ#iM~t_zG*T7rY0pX=~^j4D**_6J2{L+ z{}RnPQo8_rC))rO1;zg8L-aUdW8_DoHRRV(Ul3=u+~@$}X72gku&TP67NH)cz^dDa z4*qwX>b>~Q33+|m66Rt=E&^#0`D{4*qT<{AJqZJk2hlO(&H8e5*{yaV5je+Ikn`P$ zFFD%KibDVdJ8?{V`uP&SAcjX}&ws1EHzqSPFx&@aNf$YTViU@-4j4V45^TpMkougWi$H$p$dLj> z>x*b35xHq#8H9eCotRR0|vS<~-y zjqjK5EX6gCqcLP4aJoPuLzD{n))ZoGeFLy?lwk;L(hw9fsBSD~J#lv9p-Z+PBXp|@-+X&*o6 z4)`kf_1xZ}L*x|Vh%5ZKIykqKCwcIN8};_8)`cN{+Mr~{>ydVvQHnP_F6wbrb)F%X zH|G9-574>mQkPBck%;8keSzL5H`>>8NtA2X+bU`|PfzdLw%7hftG|Cw{_}19zoGd5 z=}+FD9Y?%H#gU(%ZvkRG0dOH%#iPiI;Xg!+TM^elnZUTKp4$-Q!t#M9u8(IEq%Y{ zb^FoJ@yhx$sv*`!_m7A6ESSzy3A3$cJpWXpbldUrrC~dt=r6aagR_jrj~chtm#38! zi(Q-CHZwQt@-Y|})0-Q+6n(9CxntLytDRR)-8Tx*jgN-syA zzcb=js@UXfocYqc-$$=di|P4@NyYM)QabaeH=ou15g+L`c*`c^HLxtYhI-Z96C0+s zv-Sc?2gGirCx6+&weJ=|6b|o4Qw%)!QxM~`0D*v@nJ_AO%clCQv!}-c#QE_li);y<{*c`yJt94k+FFu@arO?_(HcVK! z6C97mn|KGU-@9*BXlj>KJRGvUL8FoL@$p#6b1#R>ea_$(E;e=Y`g>m7x`kEIf^H=( zZO{P}kQbjZaBu{`^nj$NXoBB+_)zJFhjRZ}1qJyNtM{S+fCK}sEoy(C3mgyFB_x(k zZm11?Y6gcPs~aAtu5B+DAo1AsU049e*zZ08ZfWh}l)jG<*KVw!SNKsy{Xu!JmsCaa zA-49qMI7l2e$K}UVhIHW(o#h9lsI|x z=+W)REQpUr9uW8|1_o+|^T=DjARD27@xugd0LZVwO^{eEfQv%^4F(aIog?K70N9P- zn%zcc<=gmtFFO7NCfGYu1f-uAxAV=}IozZjbg2RK8wE zk~VyyD|jv&_ZUIy(i`+I^5!J}M#`i!=tA&K>@_ey@0AZXrNxUk>0HVe$)y1fm533V zD4V5E+oo{Ul{wCil-q7@E0Ay9#d~%D#Ff7Ept~X2^6AQ~+ph+uPqdN-E_0## zydE7opv!cpe2xB4R0`8!{q>Ke&PdodwVEa_4LZb}%wTAZ&7v25^7X^+*Qe)q2j`lK z3Wc@ZzDOmrVT>aFG(wBKx5=g!Pw7mCjuV+9_YO;LmHfWxg_i=O(3Qdt`uACU^lzEi|%nDt%iG# zWmWS_i{*JjlN>f)vN7Jfy*%v4Ex6I{9lX`O5JbMd{4}q-^)+Mn`RN`Vm;2{!IXb4V ze7t|>Woud1WFmG-@`D9}&c^}2IJTLzy-mLq@TgxRWrZ*>iHscW?E+XU@9 zdiZF5OuNRKvUOzBJy0&Iprq^$_~OyM?P{(o4du{uv`3k(dH#q3m+*oM{oIs$z}wvW z#Z|Mtp$tC5<*%;H>l zc4D$(^79ba54~8?>#K!Z=jfmKvA!I5eZqoW>I?l4Y z$ne(Kk-5miSGMB6s~+LA&Bpux*&X%Ilq&Z3G0$cBx>b9g z?~>x(FTcP$)BMqy1ujrBFltcQ(Ym=96 z1WF9`_YUaSco(WWjPHmv)eOKte%*A;HKMsS<+@qR!*%@D@>>Rto-95N_4FJk7}PaY z2;Ey?OeXB4BLnz2g#akVx=#;peF(ry0uTy61n0kp2vU(+3G&X(Li89Db;C4V9&FA~ z4@)C@xeq}|wzn>p6{R_W{0X{LBCiMm1va1o_t7gyNlwMhxgrl%X~>fcFy6iemKSf* zQ8|nO5IFl{p|2$fgikjB>S!)_pl9?7^M1WmyQU*ufHhuBGb3zHW|S^6_`-+qr6S^h zjSwmnlJmUc;&q=thtqKY$iE8*A;?Jh1EbM}!c0Y7-4pL(G(Ql4^U!Qbg!WYg2WY&W zr0~M5J4xz^E*$sUVsJD7kbqC>LP|7z$mq%;7q1w*6)%B@1Pd5Y%lL2K03#R-5v`)Y zw7?t4CxN2~s|qjza4kFcsVG!%5R%nH>z>e}5T-AJ_!w2bErkQanqz}M1qf4wqI=`U z22{tS=!~#~AI#r6udiwia1pfCpGXuR#z;2~4AN#rEpaz3&DO18>FES}HS1Bn7Aee- z=no{t_6lW%N_^$ibkwxk-azZ9I`a9R+18gj+8mdJ!`UT>8MnwAfjigNUFj%sw-~$W zmi_T0<)v1*O+{7R9oG}CXNQk7)9+Ie_c@p`vf^l(P;r2>OTp*O>u3)hqm)gC(F^-K zv8}bqi}>Mj$b3dbIr`EDQ-{>O6W`>;*hcz?yz5SC>d)T$CFia1peYVGu}jF}Rx{bwqTX|_EvJ8L zIZZFV>rRMq34Evh(cxIeJolX7u*c-BdU-Y~%0ferY}oQIk=<6Il+C=GllvYw(I}ia z;x5U%?f`4jW52}n6B>0jUPK1r=4Jn^C@eo>nuF74qh=YPH^J+5oCyL^wtVO43BHH*8azWm-5%AfF6Jgn80V?RTiPmbnvFBRvL(E~G-?EmvZRZ=!;rR=XYiq~8499vNzs2szX4Ii&vYln5 zL78h_xvt{2nY8A1tEhnB>Ynxtv$ugIDFZ(@d#UYA{1L-7RsVjKW?Sl^n_sj2k66ZU ze%klYqR{ogl-jMk4)$MGZqN(z-gxPS$i1g&BRllo=yjDkg*^Ffm~6UgNj;mo{@ri? zo=3Gc?I|ww2WeQ{dDlMi+<5u!TFpAiz2*nB9H*?XG))9j3OT3OzHf-zY|^y6o>Jnn z%--~UKi62jv1e=YX7Xdlcx|%{{h&^09q(AS4&S}a+x>$DdfZ(Skdb)|2o~A+e6yPu zS*vZl$}NjKZhk>yeP1i1=B5L*wqtZzb+O95GuH~KDAA3LhdzwB?X+g?n4Xny7rvEL zL9;n6M)G^#t%gfEE6Z$p{VzQ%j&QOrRP`2!QKkR&{6iO`+If)^E*o1}>m@W4=Gx+YE4-+$tpA1T5IZDzqsiBm}mY@KPKa^23Z*I|?5H7h}fE4Zuey z0(@aB5yPw~16UuIN}Ew^f66BAZc_g z)DNtsfOeyce7XUxQP80SxQdCYU>FG5Mw;l)BfNQmti8g1gCM4834V`05)xnu!plQ{ zdee5EMXTRvNCyT5xdBYca-LUhh>MBwMZ-iNJXfF<1(=ZV6_Dgm3R@a5GbtoAt-kjbFHW!c8f{ypmHcT5> z0L7<>14BfjP7r}NX6s+(8y;{RXzMt^FWfyhw#5a0{W03eW*0hZn&voE-L3Du{ z2Njg61iV^*KPh_QFdG;H2va`-|-Zm8^58jD2{*Y+iwxq4pjA*>@HEhKmqg}nhTTlZuI)HCa@^0uRb*RgsZo7SzKo0T_oZQ`+%Bn*yDVzWOd0K2Ub+{g zG5mcdWYVbf`i#PY<<4ml8Zlbxca7dH${AMUbVYouG%A8B>MjNwc(r&hi$2c`FlLN9 zh2Z+7XOlF&8qEN=Ukl$J)NwG!2ec$@h{14_l6V4%(vHhYktlAt}F| zH8MA!HsU`1H)1dRu^YUUK}FfZOy}jl>cmP)*nqHP`30IU2Cfe3vKrFN3_=W^&U8N6 zryM&5dVH7%u8Wt8)AWb*}G@LE{dd;aMuh=(WOONMU|DA2Mf7IMZ`g{J3)pFU+ z>lhtq#7nDXM=i=%a9{SPP?QZDnZEt1v9SKmEmxp-dFjRx;kNnBAq>CmI2d(eoX_CT zdQYt9(0SYSZIu6g%~!_XFRW>A{T7K+c(wrnx?*bF-Y|qoQbm^azWCl$=>xfYT?&^thlby&g{sdV@raDt;L?z( z(6C$aqFYWjTh*}4PX10cr}|WSh)Lbf95~TjeD*(FfTQ}M_R>*8VmW8l73g_>OuFe> z_S$gyI#}A0)+`%8CauWJIZtD#5&UJk=xuZQ<41wdmRL&UJ9F&kHVN+W`;rp%(PH*p z<(4QOm(V%+v6Z*E_pE!@WF;{*KRu(ba0v1=)Yh-ZXJ+=XUAG>{Ye->;4k*jkR#jBJ zuTouPXFu=1;G(7|VOZ``B4_XA8Ps+0t)qduklF2?cV!#Hy*6juuFzifrvGcdM@+qW z;LSg_%KgpkINYubRd44vpm%&+J)NDobN2U0zP*h>t*|o3@l8ykGG;B0LUX$^Q$^*x zv;6mX_;)`0eA@HlNBTy-N4I1DJZaaOGv2PA!k*%4xH|JvSvsrzsuPThzCx?!b^D)K z^Sp~#VXd(FQS}Ac-yWN4Mkn%Wl?=u@J0Q9pvY|R~a_qHowevfFkD#~5pUle#lwV8H zto%xAy!))|CB?&44<2+(HStDhBWhRhS9rAfdgTitz@LrXPua`24Gr*DuKa+%-hT&1 zOL+Xmuoj_};PZUi-P-LCCPUZ#)QpLq9v4PZE_2QQIN#637u4I=x1syTAvS)!)r5`* zz94bx%%`gWP7fXz^yjo8io90;hcOXX694ebU@C}mu?1|o?cz(IB41X`n*J~fHvfU84kLgBNeDf)1HK1cN)H+$Hv_b2!^3IN*2$022WS9%;><36pH5&S`Q@NY z1J!@DZ#l$;^cmq(wi3R;faB5Oz8s;B^X5(2M;tfkspCv3QJ`R|hYtNdalA3Hr-jDI zDm0I`G(Nbt*gW~ZK88KS*2tfI>7>PQ`|+>_dkM3Xmj3GdPF-rqQKVCRBO1PJTU}RM zgtW|k^{*bQ39C`z$&^vY{6F_rh6nB9|E-jCau?^C#!nd^D*yrV^ILG0u^sbxd3(pP zPsd(Wx1SSlJ)n&urr@OC)_xf+p9essflyeI5Ts!X z{@x^fex__&{fBO~aJ{)fC(>rvx8Wx#7icVEqFE{n@Bd3#zHeDs_cs1K`T`}h>3xi*cT!oVdcdPEha=b(zGYWuj z(AsKma#^^hE!Sd)zVaUq?|%J-F|O{F2azO(<#M+#ovB<>@br9iW{E4FgPrZzJs;<_ z^oEQSm#86kqn#cRpCfnhcs&{&X!x2x*wIIri_p;-Nx+HQQOO z+eR$M*~9nIQwqh>M(D84TglQhbG4Vx`)|v7@wA>M!1ee>j}^{45G{k`6)uO1%0}27 zfu&2?$9fu$h_E{!?fB+4%RC-_-q9_#jlGnyGoW^0G)fVD;4sAzs}L#{kfr`|hwskWzoU2}# zI&YaOy$H6v&7ZCE0TXjqw8IR!asZ_RB|?QH;7&T?>tg48u_kTJ=uz=6U|6!d@H zFk>*930GhA|8grNDwW@l_I=fc?{JpObH^*qV9s66vZF;KSsG0nwz(y?N8Y5 zFGQWAFVG!93!BbMDw< zTmg0GO_>1t>lkCP~l0k6m1l3 z*p$gTQ97`{h9)UkJtVK8ndw7htVE|z0ateM^p6imdCV_q&$Q$hNpyTFk@u~iit{rv ztF>6{eD}7znI*kru5iHf7^~Z0j0saHGh#lR;I*8bTT-fF4(<@H(_-V$e7 zP0Jj{q^z?}8?wh8^65-iF0lmDi%<#A;TqpHo;#M_@$!(dcD=aTbw|>#T0U}7EdGvz zy`=K+14o7VVd(?km|51#FBoa6-A0OWwEP95qmEJIrHVsevwthAD%}mqVQ($!7F9Nz zm6U7_K7KyfE5>ZRukpxE{?q+hX)=PwdDrH+qf!F|jl7JN3?k~1y?6`^FAS=;oeXfh z|12tjJ25(r+9k_jMZZi&ea2(qxlTzvx-#p2uWV|;E2G(L&5YkT+8E!twiWLY&;9mp z-B7IN5$S3jn^hF6{f+sNDpU)i=KRz(7R7!yK8}59HO)U^lIw2Er?VykRjaCi4ExwR0J!-#9;TP}-MLpI0O%-gnMzEpb>}i#pG6ZrrePPe`)NB>z%^Sww@$#7&m| zimFmJSpeGNO0ujszV1DA_^714l9I`bY|7U}%Y+#2#pq|#@ojl|6V=miR9Br(KRSP^ zUtqtCLVluOQ$-fK2>spuSGrT?Cx*if1zyqs&C^vYWu7{iaxW?Q&DCBOmW^FItrZk} zHL}lohI_t`A*S0)TS3@Z3nc1FY6BRdmGC=Kj=e+=ItE$-yoacSFrNS_aza{~hnLrB zx5GHjpstf&r#jd95wd@1so%eQcNO8Pe(y#f7#uEMn1u6#-Gtfqtr(WPpr$4ZvKAZ> z4`EyynZXsv(8FmuCs1VVZQGHX;TN>f+;&+QlWl0&#(P607`+byYk^=0S_TR~wk>SW z=eaF0TX+bCnsVG_W|Ws?P6u6&H~l@`-QpOS0B1fSDTyw%1^)1m4cLlqOnXnyI%EeJ z<2h$00!XCf+qZl$8VPNUgf8?kG4%J~Qk5nqf9SkcBNoYd?|YSC_v^T2=V}CvJ77ym z=;&~O0jF%3@yiL(45J+4pv7N6iv^4w88bGG#lR7ibr|eALFCh1IINcJe~*mN$ji%< zURnT$n2e(0uu?iAEg97E_4OrqBNMADOBl53bU~CNQ%I;~5n-*y*IrG!>iqqVxvP#9 zTOB>NcYYhM80W516nlCjjoH1&llHiX&Iml{e9&F@)fN$`UnrJ}ij~UAV45w&#&F+( zTdbD5SN>8ceXa5CdO|{?c=0JVdNW4KWf83lXhkT;U1GKlttpJ%pCNzt?XGvHWOdfn zJ&x_Dt)k-1OuZK!vhPTWAaoY*qU?EHnsD$?;*nE#>o!-U+G@Ue=i)Hhy_nhjuG%)X z*k8Osqx2LusgkyYFn|*`(HZ_ z?2Rc&$_e5OBETWQHf8JBjRCu*9p$>JzRKetP|!fRENp$cVM%yMFvcWS*UZ%apk!tq zszqcxFCz~ww(@$Z-xl`p*>=dw( ze!p(>1Ivo4$~!qh6S)ycKP&30@5g5IZc^Ap_2@>Q;pd{VwUMfkBWycQYBDb&ps zCbMY`MMTV!<-y?g8m*iypXg0HG8~R8E~l&Q;NMeL=4STR`_Rp(4JxLwxsR?%W@mBw zUN&L~uKcO1v<0E(m3F({Ro4>nX7}dWr=mFyYyQl!AF{eB<)gpuLYX*6$nOjqBN_9b zE6_wp82Bl8G&05QP)z@h+F_43S4EmrXv|-TxT{ro>rI#bd1$bsY+-%c?8leUvF;9~ zjHfiDrGpdaoWt1-6OQv7jo_u@?CFwy&n&02&imu7ms+1U${ZAQmwNYVQ8QR9ZBTrc zrB=6TN%lSKV~>HsKD(z+#no!-BtnwOJ5RdWQ@2R(o6UbkxQW!Qb;#^obhz|qU!bRC>OLRpxr zhg@GVMphHhG|dP<3BDlZ<*lA(4QnW=e0W1-D8MV#Ng&l|u9- z2m}2s$OsrBSTV9CYXU3_dS<}6d)p6nfC!E zbwExWL%+o^N~S6R>DUXJL*Ot=H;7`) zfk>&q6E0}XfRPZ3xrrOlm|Ky%H1|PbgNpswx10C^CXLA(Fs$(fm|+k`1@VDL$`>)dSE5$fsZ9I3m$ko@JBRz2+S5e z1fh;om|zEK_89JXfnH+%VuDTGC1vX<@0Zo5f>W+Am@PZ7s+E-lXd)8OEZePLwpn88 z7ue<@#3pg>oPWZRqyrvl+hrz}U(qJ;Q?{<$)4sshWBI2_VTbwk-*GCL6_IMJXQ@K& zSA2<>5p$eoH=lg8Ota7u^{{4(g7oRHyIj1uj6MZOZH?Sr~lKe*kXz3s?j+9 z*3_l&`ks$<@M1dbz(-3v@+*gtjc<^teslIg2Iq7hZKgjXQBgN_O8p9{7>fONn)|ke z|CwiUF7D;gj57(d@5_BRp5vOg7^Ee1!i?EbKCHQ~o5{JSSAFhAhtGyq=fxKF(a(3% z)9VX&v8Unb20eaEpItt{rsY~MXqMYQ<+(Y(yR$#y*ax*(|ECG>z zD||eT{igM!rpY!&7Uzl2vSWif;eJ$cIXi}#Y-OFYCkE^$UdSrVn+%UgTh_&CuuivF z>1?kZf6p{Ep77pY_Xks>fuYfS`h0PrdE>(Fxz@I$IkWwt!DHWiXTHq3%28Ds{0OGM zmQ*U$Ha{oxG4)54|MKv_h)l-#uU8|ZIhDh{Hk!(+l-rxxDhK*|CvtUU1}b;TI}JP9 zQu}?AtqJ(HIl$BUgIaO-Ek5s%Jxd4AIrANLNip{eUU-);u#VSmyszQIX&_yVZLc~% zTUVv>I`o-e{ABX8H&B3|T6li>dgtu*;VkvBc>A$p4T0CcPY>x`dMfs=?(~n$9G&eJ zMNySk8b6GE>Cq`zkbU8~({bAO@UPP1ry2HRQ@1~lg*KV{D_#HLE7<1Bp>*(|!_=E+ zPbYoJ_n5lTVkBTRw7jk6iwoO1?^~kFJTA_5*)#Lcdfug;&q#NSDK8HY_?^w_dTAnF z9qrAGlhx(Nofa146VHe0zgp}bOFTc&7Ri?OW{&q;QdU}L>{6PPkK^*5vW3ybnKo&k zF24_svQh>0BR~AJL>FIu4GitNLofg8*J$R>=})Yk?|!vCvTN@Arum@$U@yzt(kFgm zymM+t1|7A|W+Z49#b-N0{iU7fuTFe2cDck>Dym7R)_lwHh~dv8>>P)WjMvBd)QT-`d-%dh zxNN{$C9HnFzr=p{?aa-wcb3D}%i0fT9w!DAd*Jfq%{jJdt!vrxG4fnMA0LCZ{{gPf zzAHOBE832D3(MB>y-;&Ahq>Y3L~eRF1~j%8^) zWSW@RnOMG*()D@iu8)(#ZSH+4$S)_EMQC(A6j53t<#pM_Au0l?-_m1~HOrZI`NYIr zKg8Zxuc-%@kxFpP?4W#~YI`v?wDIDJy9V^8;f?cBx$EW%D?}x`!k1?Z!hSA(Ki2Ij z`Kmrc(`D?-?|$p^@xjx>S$0OnwmivZ#hW-1tMivXae&KbWmsN)BReIMfF4WK_ zeDZ31e(`k-xj8dM$}uw|HyYFG#y3&&{6Dnp(UB@Hbu6H**P^Eicjnyv-1?MJlVffi zlZR`pSl*P%uipEXRr||}?>;^pdisDDgK9j3$~j@qPi?|G98$$h9U1q${gan-(dMaE zr(4dd*HzjooewnE6{Kg5_}IRqcQu%v`1S0^8`Vbu3E%PlT$}KE-^0(hC^m8&PNS!i zjZ&_ueO-AXuAISz;e3FD@WD^RuNqSF;}T82CNc3_6igkgqwZN+F8qF;_CDULwEG+SR^39jlrR4J^6=KPF~ff-hbxC4^1b>!z2B$#H??j9|A827UR8Fx z_NjwKs$JB&?YVCERujcVfZd#{^Zx!op={v9EC2qITW?IIeZV%C0$n4`_9CS{ z_kn;r<%Am=jYTk+*$NG<08E?8AX{(^s*k-8A<=B{WwYh)X9h)3Rb+<7-7TXgNt%<} zh^nD0+&;Sa6eAy4!LW%P{XecWWK(?5Qv0DGtc`Y~;)o@*m@KhPW#D&_s(8ak2)bi1 zZuWx-tEVTS*meKO^U zfM8SdjlY)%8aYPLDz3BX%Z`5AA1?)&p;TM`jeYp)Srb+AO_@xTgdbs6Rj0|YlpQQJ zJ7I&>YIrD35PES384g|!?$3N-|NGe({#eGO!nSo(=TxqPW2msT(ZW<(F7HiE%f&js zy>_k;tGkY2U{N$Ab043%l-y#|#5+DdUiEFZLXxHs+Q_Lh4YsZJ24M2ND=~%gfWmx? z8n6UNF7KVll0TTijaJ0=qsM+c+xp;C?W=U|_%%nyRkuy0D4P1^ z{d$v>di0i!MaD~ZY7W_H_Csvc2c9WXU^(iAJ5T-W_410{iw=#xO(&jAD6wcAuvkr5O zj9^4H#hlQ=*QH17_lH&_y?xSPZXxKDuK~J-rkGm};X1TE!7nwt(&utq69U2u(^1$c zmt7YpA#8WP;KsY>3}Bef6&~c#{CHg!G%PU|gi4Y-*o%+-i z&5@pQ5Q1GefRwt(dt*RC7RXBKtTE!9TU0pw&=P;>m}6H-yVS7~g~l+;B4&Pz_Pmnv za=v8eTk}76{j9{gWI16vtZokSCV50fg7ud_evQ|FDZw#3HKIBJ(?*|I(XwWZ8tfo= zQ-JFHpz(`oT1)EB;-$Byr%zHA( w23NzEBo{+?XTFCX<-;*ahnm3$x7#4|$qh_* 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 literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/writerside-fragment-restored-1920x1080.png b/test-results/history-runtime/writerside-fragment-restored-1920x1080.png new file mode 100644 index 0000000000000000000000000000000000000000..c81f68fbb9fa9c6ce088f9011f8cfbcc37f15f9a GIT binary patch literal 180021 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 z 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 Z8bKXq6u(^mHdevMr15{4#2Y*K{~x)Oo=^Y) literal 0 HcmV?d00001 diff --git a/test-results/history-runtime/writerside-report.json b/test-results/history-runtime/writerside-report.json new file mode 100644 index 00000000..f055d27c --- /dev/null +++ b/test-results/history-runtime/writerside-report.json @@ -0,0 +1,17 @@ +{ + "passed": true, + "checks": { + "native clipboard publication": true, + "explicit save captured": true, + "exact comparison region available": true, + "fragment restore": true, + "clipboardEntries": true, + "revisionReasons": true + }, + "screenshots": [ + "writerside-clipboard-history-1280x800.png", + "writerside-local-history-1280x800.png", + "writerside-comparison-1920x1080.png", + "writerside-fragment-restored-1920x1080.png" + ] +} diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 21e6e387..8d1beb2b 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -46,6 +46,38 @@ void main() { expect(settings.toJson()['autoSave'], isTrue); }); + test('history settings have bounded privacy-preserving defaults', () { + final settings = AppSettings.defaults(); + + expect(settings.clipboardHistoryEnabled, isTrue); + expect(settings.localHistoryRecordingEnabled, isTrue); + expect(settings.localHistoryCheckpointSeconds, 60); + expect(settings.localHistoryRetentionDays, 30); + expect(settings.localHistoryMaximumStorageMiB, 512); + expect(settings.localHistoryExcludedPaths, isEmpty); + expect(settings.toJson()['clipboardHistoryEnabled'], isTrue); + }); + + test( + 'history settings validate persisted ranges and absolute exclusions', + () { + final settings = AppSettings.fromJson({ + 'localHistoryCheckpointSeconds': 0, + 'localHistoryRetentionDays': 99999, + 'localHistoryMaximumStorageMiB': 0, + 'localHistoryExcludedPaths': [ + 'relative/private', + '/workspace/private/../private', + ], + }); + + expect(settings.localHistoryCheckpointSeconds, 10); + expect(settings.localHistoryRetentionDays, 3650); + expect(settings.localHistoryMaximumStorageMiB, 16); + expect(settings.localHistoryExcludedPaths, ['/workspace/private']); + }, + ); + test('AI defaults disabled and provider settings never persist secrets', () { final defaults = AppSettings.defaults(); expect(defaults.aiProviderPreference, AiProviderPreference.disabled); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 956e96cb..a8da26e3 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -6138,7 +6138,7 @@ After break. ); expect(find.text(l10n.noOutline), findsNothing); - expect(find.byTooltip(l10n.sidebarViewMenu), findsNothing); + expect(find.byTooltip(l10n.sidebarViewMenu), findsOneWidget); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.digit4); await tester.sendKeyUpEvent(LogicalKeyboardKey.digit4); diff --git a/test/src/clipboard_history_test.dart b/test/src/clipboard_history_test.dart new file mode 100644 index 00000000..cd64c5c4 --- /dev/null +++ b/test/src/clipboard_history_test.dart @@ -0,0 +1,364 @@ +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/assets/asset_input_service.dart'; +import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; +import 'package:busymark/src/clipboard/clipboard_insertion.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + BusyMarkClipboardCapture text(String value) => BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: value, + sourceText: value, + ); + + ProviderContainer container({ + ClipboardHistoryPolicy policy = const ClipboardHistoryPolicy(), + RichClipboardData current = const RichClipboardData(), + }) { + final result = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + clipboardHistoryPolicyProvider.overrideWithValue(policy), + richClipboardServiceProvider.overrideWithValue( + _FakeClipboardService(current), + ), + clipboardAssetInputServiceProvider.overrideWithValue(_FakeAssetInput()), + ], + ); + addTearDown(result.dispose); + return result; + } + + test( + 'retains newest first, deduplicates, removes, clears, and evicts by count', + () async { + final scope = container( + policy: const ClipboardHistoryPolicy( + maximumEntries: 2, + maximumBytes: 1024, + ), + ); + await Future.delayed(Duration.zero); + final controller = scope.read( + clipboardHistoryControllerProvider.notifier, + ); + + expect(controller.retain(text('one')), ClipboardRetentionResult.retained); + expect(controller.retain(text('two')), ClipboardRetentionResult.retained); + expect( + controller.retain(text('one')), + ClipboardRetentionResult.deduplicated, + ); + expect( + scope + .read(clipboardHistoryControllerProvider) + .entries + .map((e) => e.text), + ['one', 'two'], + ); + controller.retain(text('three')); + var entries = scope.read(clipboardHistoryControllerProvider).entries; + expect(entries.map((e) => e.text), ['three', 'one']); + controller.remove(entries.first.id); + expect( + scope.read(clipboardHistoryControllerProvider).entries.single.text, + 'one', + ); + controller.clear(); + expect(scope.read(clipboardHistoryControllerProvider).entries, isEmpty); + }, + ); + + test( + 'accounts byte budget and rejects an oversized item without truncation', + () async { + final scope = container( + policy: const ClipboardHistoryPolicy( + maximumEntries: 10, + maximumBytes: 20, + ), + ); + await Future.delayed(Duration.zero); + final controller = scope.read( + clipboardHistoryControllerProvider.notifier, + ); + controller.retain(text('a')); + + expect( + controller.retain(text('this payload is much too large')), + ClipboardRetentionResult.oversized, + ); + expect( + scope.read(clipboardHistoryControllerProvider).entries.single.text, + 'a', + ); + }, + ); + + test( + 'collection off leaves existing entries and blocks new retention', + () async { + final scope = container(); + await Future.delayed(Duration.zero); + final controller = scope.read( + clipboardHistoryControllerProvider.notifier, + ); + controller.retain(text('retained')); + await scope + .read(appSettingsControllerProvider.notifier) + .setClipboardHistoryEnabled(false); + + expect( + controller.retain(text('blocked')), + ClipboardRetentionResult.disabled, + ); + expect( + scope.read(clipboardHistoryControllerProvider).entries.single.text, + 'retained', + ); + }, + ); + + test( + 'current external clipboard remains transient until explicitly retained', + () async { + final scope = container( + current: const RichClipboardData(text: 'outside', generation: 9), + ); + await Future.delayed(Duration.zero); + final controller = scope.read( + clipboardHistoryControllerProvider.notifier, + ); + + await controller.refreshCurrentClipboard(); + var state = scope.read(clipboardHistoryControllerProvider); + expect(state.currentClipboard!.text, 'outside'); + expect(state.currentClipboard!.external, isTrue); + expect(state.entries, isEmpty); + controller.retainCurrentAfterPaste(state.currentClipboard!); + state = scope.read(clipboardHistoryControllerProvider); + expect(state.entries.single.text, 'outside'); + }, + ); + + test('equal visible text with different structure stays distinct', () async { + final scope = container(); + await Future.delayed(Duration.zero); + final controller = scope.read(clipboardHistoryControllerProvider.notifier); + controller.retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'link', + sourceText: '[link](first.md)', + richFragment: '{"href":"first.md"}', + ), + ); + controller.retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'link', + sourceText: '[link](second.md)', + richFragment: '{"href":"second.md"}', + ), + ); + expect( + scope.read(clipboardHistoryControllerProvider).entries, + hasLength(2), + ); + }); + + test('equal relative markup from different origins stays distinct', () async { + final scope = container(); + await Future.delayed(Duration.zero); + final controller = scope.read(clipboardHistoryControllerProvider.notifier); + for (final path in ['/first/topic.md', '/second/topic.md']) { + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'diagram', + sourceText: '![diagram](images/diagram.png)', + richFragment: '{"image":"images/diagram.png"}', + origin: BusyMarkClipboardOrigin( + documentId: path, + documentName: 'topic.md', + documentPath: path, + ), + ), + ); + } + + expect( + scope.read(clipboardHistoryControllerProvider).entries, + hasLength(2), + ); + }); + + test('retains whitespace-only text and owns image bytes immutably', () async { + final scope = container(); + await Future.delayed(Duration.zero); + final controller = scope.read(clipboardHistoryControllerProvider.notifier); + controller.retain(text(' \n\t')); + final source = Uint8List.fromList([1, 2, 3]); + final embedded = Uint8List.fromList([4, 5, 6]); + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: source, + imageMimeType: 'image/png', + imageDisplayName: 'diagram.png', + mediaBytes: {'images/embedded.png': embedded}, + ), + ); + source[0] = 99; + embedded[0] = 99; + + final entries = scope.read(clipboardHistoryControllerProvider).entries; + expect(entries.last.text, ' \n\t'); + expect(entries.first.imageBytes, [1, 2, 3]); + expect(entries.first.mediaBytes['images/embedded.png'], [4, 5, 6]); + }); + + test('a new provider session starts with no retained history', () async { + final first = container(); + await Future.delayed(Duration.zero); + first.read(clipboardHistoryControllerProvider.notifier).retain(text('old')); + final second = container(); + await Future.delayed(Duration.zero); + expect(second.read(clipboardHistoryControllerProvider).entries, isEmpty); + }); + + test( + 'insertion registry follows latest target and never retains disposed targets', + () async { + final registry = BusyMarkClipboardInsertionRegistry(); + final first = _InsertionTarget('first'); + final second = _InsertionTarget('second'); + final payload = BusyMarkClipboardPayload( + id: 'payload-id', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.text, + text: 'value', + ); + registry.register(first); + registry.register(second); + registry.unregister(first); + + expect(await registry.paste(payload), ClipboardPasteResult.inserted); + expect(first.pastes, 0); + expect(second.pastes, 1); + expect(second.focusRequests, 1); + registry.unregister(second); + expect(await registry.paste(payload), ClipboardPasteResult.unavailable); + }, + ); + + test( + 'unknown token cannot recover rich data through equal plain text', + () async { + const channel = MethodChannel('busymark.test/rich-token'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var clipboard = {}; + var token = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'write') { + clipboard = Map.from(call.arguments as Map); + clipboard['generation'] = ++token; + return true; + } + return clipboard; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = RichClipboardService( + channel: channel, + createToken: () => 'known-token', + ); + await service.write( + const RichClipboardData( + text: 'same', + sourceText: '**same**', + richFragment: '{"strong":true}', + ), + ); + expect((await service.read()).richFragment, isNotNull); + clipboard = { + 'text': 'same', + 'token': 'unknown-token', + 'generation': ++token, + }; + + final external = await service.read(); + expect(external.text, 'same'); + expect(external.richFragment, isNull); + expect(external.sessionOwned, isFalse); + }, + ); +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async => value = json; +} + +class _FakeClipboardService extends RichClipboardService { + _FakeClipboardService(this.value) + : super(channel: const MethodChannel('busymark.test/fake-history')); + + RichClipboardData value; + + @override + Future read() async => value; +} + +class _FakeAssetInput extends AssetInputService { + _FakeAssetInput() + : super(channel: const MethodChannel('busymark.test/fake-asset-input')); + + @override + Future readClipboardImagePng() async => null; + + @override + Future> readClipboardImageFiles() async => const []; +} + +class _InsertionTarget implements BusyMarkClipboardInsertionTarget { + _InsertionTarget(this.documentId); + + int pastes = 0; + int focusRequests = 0; + + @override + final String documentId; + + @override + String get documentName => documentId; + + @override + String? get documentPath => '/$documentId.md'; + + @override + bool get editable => true; + + @override + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + pastes++; + return ClipboardPasteResult.inserted; + } + + @override + void requestEditorFocus() => focusRequests++; +} diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 3842c10a..07962f72 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -446,6 +446,15 @@ void main() { containsPair('syntaxReferenceGtkAccelerator', 'F1'), ); expect(_labels.toMap(), isNot(contains('markdownAndHtml'))); + expect( + _labels.toMap(), + containsPair('clipboardHistory', 'Clipboard History'), + ); + expect(_labels.toMap(), containsPair('localHistory', 'Local History')); + expect( + _labels.toMap(), + containsPair('findLocalHistory', 'Find Local History'), + ); }); } @@ -501,6 +510,9 @@ const _labels = HeaderBarLabels( back: 'Back', backShortcut: 'Alt+Left', save: 'Save', + clipboardHistory: 'Clipboard History', + localHistory: 'Local History', + findLocalHistory: 'Find Local History', export: 'Export', exportShortcut: 'Ctrl+Shift+E', exportGtkAccelerator: 'e', diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index 79a1069c..c1a61e7c 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -106,6 +106,9 @@ void main() { await sendNativeAction('sidebarToc'); await sendNativeAction('sidebarOutline'); await sendNativeAction('sidebarGit'); + await sendNativeAction('clipboardHistory'); + await sendNativeAction('localHistory'); + await sendNativeAction('findLocalHistory'); expect(events.map((event) => event.action), [ HeaderBarAction.save, @@ -116,8 +119,23 @@ void main() { HeaderBarAction.sidebarToc, HeaderBarAction.sidebarOutline, HeaderBarAction.sidebarGit, + HeaderBarAction.clipboardHistory, + HeaderBarAction.localHistory, + HeaderBarAction.findLocalHistory, + ]); + expect(events.map((event) => event.sequence), [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, ]); - expect(events.map((event) => event.sequence), [1, 2, 3, 4, 5, 6, 7, 8]); expect(events.first, isNot(events.last)); }); diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart new file mode 100644 index 00000000..c3040553 --- /dev/null +++ b/test/src/local_history_controller_test.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:busymark/src/app/app_settings.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/local_history/local_history_store.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'continuous typing captures latest text at fixed checkpoint interval', + () async { + var now = DateTime.utc(2026, 1, 1); + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue(() => now), + localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }), + ], + ); + addTearDown(container.dispose); + await Future.delayed(Duration.zero); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + final opened = DocumentBuffer.untitled( + id: 'buffer-one', + name: 'Draft.md', + text: 'A', + ); + await controller.observeOpened(opened); + final firstEdit = opened.edited('B'); + controller.observeEdit(opened, firstEdit); + await Future.delayed(Duration.zero); + final secondEdit = firstEdit.edited('C'); + controller.observeEdit(firstEdit, secondEdit); + await Future.delayed(Duration.zero); + + expect(timers, hasLength(1)); + expect(timers.single.duration, const Duration(seconds: 60)); + now = now.add(const Duration(seconds: 60)); + timers.single.fire(); + await controller.flushAll([secondEdit]); + final snapshot = await store.load(); + final revisions = snapshot.revisionsFor(snapshot.documents.single.id); + + expect(revisions, hasLength(2)); + expect(revisions.map((entry) => entry.reason), [ + LocalHistoryCaptureReason.automaticCheckpoint, + LocalHistoryCaptureReason.baseline, + ]); + expect((await store.readRevision(revisions.first.id))!.source, 'C'); + }, + ); + + test('inactive buffers receive independent checkpoint timers', () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }), + ], + ); + addTearDown(container.dispose); + await Future.delayed(Duration.zero); + final controller = container.read(localHistoryControllerProvider.notifier); + final first = DocumentBuffer.untitled( + id: 'buffer-first', + name: 'First.md', + text: 'one', + ); + final second = DocumentBuffer.untitled( + id: 'buffer-second', + name: 'Second.md', + text: 'two', + ); + await controller.observeOpened(first); + await controller.observeOpened(second); + final firstEdit = first.edited('one changed'); + final secondEdit = second.edited('two changed'); + controller.observeEdit(first, firstEdit); + controller.observeEdit(second, secondEdit); + await Future.delayed(Duration.zero); + + expect(timers, hasLength(2)); + for (final timer in timers) { + timer.fire(); + } + await controller.flushAll([firstEdit, secondEdit]); + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(2)); + expect(snapshot.revisions, hasLength(4)); + }); + + test( + 'recording off prevents baseline and checkpoints without deleting history', + () async { + final settingsStore = _MemorySettingsStore(); + final store = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(settingsStore), + localHistoryStoreProvider.overrideWithValue(store), + ], + ); + addTearDown(container.dispose); + await Future.delayed(Duration.zero); + final settings = container.read(appSettingsControllerProvider.notifier); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + final buffer = DocumentBuffer.untitled( + id: 'buffer-one', + name: 'Draft.md', + text: 'text', + ); + await controller.observeOpened(buffer); + expect((await store.load()).revisions, hasLength(1)); + await settings.setLocalHistoryRecordingEnabled(false); + final edited = buffer.edited('changed'); + controller.observeEdit(buffer, edited); + await controller.flushAll([edited]); + expect((await store.load()).revisions, hasLength(1)); + }, + ); + + test( + 'store-wide find locates closed and deleted documents by path or content', + () async { + final store = MemoryLocalHistoryStore(); + await store.capture( + LocalHistoryCaptureRequest( + path: '/retired/guides/removed.md', + displayName: 'removed.md', + source: 'A vanished authentication phrase', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1), + reason: LocalHistoryCaptureReason.beforeDelete, + ), + const LocalHistoryPolicy(), + ); + await store.markDeleted('/retired/guides/removed.md', recursive: false); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 2), + ), + ], + ); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + expect( + container.read(localHistoryControllerProvider).snapshot.revisions, + hasLength(1), + ); + expect( + (await store.readRevision( + container + .read(localHistoryControllerProvider) + .snapshot + .revisions + .single + .id, + ))!.source, + contains('authentication'), + ); + + controller.beginDocumentSearch(); + await controller.search('authentication'); + var state = container.read(localHistoryControllerProvider); + expect(state.findingDocuments, isTrue); + expect(state.documentSearchMatches, {state.snapshot.documents.single.id}); + + await controller.search('/retired/guides'); + state = container.read(localHistoryControllerProvider); + expect(state.documentSearchMatches, {state.snapshot.documents.single.id}); + expect(state.snapshot.documents.single.deleted, isTrue); + }, + ); +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async => value = json; +} + +class _FakeTimer implements Timer { + _FakeTimer(this.duration, this.callback); + + final Duration duration; + final void Function() callback; + var _active = true; + + void fire() { + if (!_active) return; + _active = false; + callback(); + } + + @override + void cancel() => _active = false; + + @override + bool get isActive => _active; + + @override + int get tick => _active ? 0 : 1; +} diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart new file mode 100644 index 00000000..a48a826d --- /dev/null +++ b/test/src/local_history_store_test.dart @@ -0,0 +1,446 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/src/local_history/local_history_models.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + late int ids; + late FileLocalHistoryStore store; + + setUp(() async { + root = await Directory.systemTemp.createTemp( + 'busymark-local-history-test-', + ); + ids = 0; + store = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () => 'identity_${(++ids).toString().padLeft(12, '0')}', + ); + }); + + tearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + + LocalHistoryCaptureRequest request( + String source, + DateTime time, { + String path = '/workspace/guide.md', + LocalHistoryCaptureReason reason = LocalHistoryCaptureReason.saved, + bool force = false, + }) => LocalHistoryCaptureRequest( + path: path, + displayName: p.basename(path), + source: source, + format: TextFormatMetadata.utf8Lf, + capturedAt: time, + reason: reason, + force: force, + ); + + const policy = LocalHistoryPolicy( + retentionAge: Duration(days: 30), + maximumBytes: 16 * 1024 * 1024, + ); + + test( + 'persists full revisions and stable document identity across restart', + () async { + final time = DateTime.utc(2026, 1, 1); + final first = await store.capture(request('A', time), policy); + final second = await store.capture( + request('B', time.add(const Duration(minutes: 1))), + policy, + ); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final snapshot = await reopened.load(); + + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, first.document.id); + expect(second.document.id, first.document.id); + expect(snapshot.revisions, hasLength(2)); + expect((await reopened.readRevision(second.revision!.id))!.source, 'B'); + }, + ); + + test( + 'deduplicates only adjacent ordinary captures and preserves A-B-A', + () async { + final time = DateTime.utc(2026, 1, 1); + final a = await store.capture(request('A', time), policy); + final duplicate = await store.capture( + request('A', time.add(const Duration(seconds: 1))), + policy, + ); + await store.capture( + request('B', time.add(const Duration(seconds: 2))), + policy, + ); + await store.capture( + request('A', time.add(const Duration(seconds: 3))), + policy, + ); + + expect(duplicate.deduplicated, isTrue); + expect(duplicate.revision!.id, a.revision!.id); + expect((await store.load()).revisions, hasLength(3)); + }, + ); + + test('forced protective capture preserves an identical revision', () async { + final time = DateTime.utc(2026, 1, 1); + await store.capture(request('same', time), policy); + await store.capture( + request( + 'same', + time.add(const Duration(seconds: 1)), + reason: LocalHistoryCaptureReason.beforeRestore, + force: true, + ), + policy, + ); + expect((await store.load()).revisions, hasLength(2)); + }); + + test( + 'repairs a damaged index from intact independently checksummed records', + () async { + final result = await store.capture( + request('recover me', DateTime.utc(2026, 1, 1)), + policy, + ); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + + final snapshot = await store.load(); + + expect(snapshot.warning, isNotNull); + expect(snapshot.revisions.single.id, result.revision!.id); + expect( + (await store.readRevision(result.revision!.id))!.source, + 'recover me', + ); + }, + ); + + test( + 'repair rejects a record associated with a different document', + () async { + final result = await store.capture( + request('unaltered source', DateTime.utc(2026, 1, 1)), + policy, + ); + final revisionFile = File( + p.join( + root.path, + 'revisions', + result.document.id, + '${result.revision!.id}.json', + ), + ); + final record = (jsonDecode(await revisionFile.readAsString()) as Map) + .cast(); + final revision = (record['revision'] as Map).cast(); + revision['documentId'] = 'identity_999999999999'; + record['revision'] = revision; + await revisionFile.writeAsString(jsonEncode(record)); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + + final snapshot = await store.load(); + + expect(snapshot.documents, isEmpty); + expect(snapshot.revisions, isEmpty); + }, + ); + + test( + 'does not resurrect explicitly cleared revisions during repair', + () async { + final result = await store.capture( + request('remove me', DateTime.utc(2026, 1, 1)), + policy, + ); + final revisionFile = File( + p.join( + root.path, + 'revisions', + result.document.id, + '${result.revision!.id}.json', + ), + ); + final retainedBytes = await revisionFile.readAsBytes(); + await store.clearDocument(result.document.id); + await revisionFile.parent.create(recursive: true); + await revisionFile.writeAsBytes(retainedBytes); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + + final snapshot = await store.load(); + expect(snapshot.revisions, isEmpty); + expect(snapshot.documents, isEmpty); + }, + ); + + test('rejects an unknown future index format without rewriting it', () async { + final file = File(p.join(root.path, 'index.json')); + await file.writeAsString(jsonEncode({'version': 999})); + + await expectLater( + store.load(), + throwsA(isA()), + ); + expect(jsonDecode(await file.readAsString()), {'version': 999}); + }); + + test( + 'age retention uses the injected capture time deterministically', + () async { + final old = DateTime.utc(2020, 1, 1); + await store.capture(request('old', old), policy); + await store.capture( + request('new', old.add(const Duration(days: 31))), + policy, + ); + + final revisions = (await store.load()).revisions; + expect(revisions, hasLength(1)); + expect((await store.readRevision(revisions.single.id))!.source, 'new'); + }, + ); + + test('age retention is enforced when an idle store is reopened', () async { + await store.capture(request('expired', DateTime.utc(2020, 1, 1)), policy); + + await store.prune(policy, DateTime.utc(2020, 2, 1)); + + final snapshot = await store.load(); + expect(snapshot.revisions, isEmpty); + expect(snapshot.documents, isEmpty); + }); + + test( + 'rename, directory move, delete, and per-document clearing retain lineage', + () async { + final result = await store.capture( + request('text', DateTime.utc(2026, 1, 1), path: '/old/docs/a.md'), + policy, + ); + await store.remapPath('/old', '/new'); + var document = (await store.load()).documents.single; + expect(document.currentPath, '/new/docs/a.md'); + expect(document.historicalPaths, contains('/old/docs/a.md')); + await store.markDeleted('/new/docs', recursive: true); + document = (await store.load()).documents.single; + expect(document.deleted, isTrue); + await store.clearDocument(result.document.id); + expect((await store.load()).documents, isEmpty); + }, + ); + + test('serializes concurrent captures without losing index entries', () async { + final time = DateTime.utc(2026, 1, 1); + await Future.wait([ + for (var index = 0; index < 12; index++) + store.capture( + request('version $index', time.add(Duration(seconds: index))), + policy, + ), + ]); + expect((await store.load()).revisions, hasLength(12)); + }); + + test('oversized capture cannot evict the previous revision', () async { + final smallPolicy = LocalHistoryPolicy( + maximumBytes: 16 * 1024 * 1024, + retentionAge: const Duration(days: 30), + ); + await store.capture(request('safe', DateTime.utc(2026, 1, 1)), smallPolicy); + final huge = List.filled(17 * 1024 * 1024, 65); + + await expectLater( + store.capture( + request(utf8.decode(huge), DateTime.utc(2026, 1, 2)), + smallPolicy, + ), + throwsA(isA()), + ); + final revision = (await store.load()).revisions.single; + expect((await store.readRevision(revision.id))!.source, 'safe'); + }); + + test( + 'different paths with the same basename keep separate identities', + () async { + final time = DateTime.utc(2026, 1, 1); + final first = await store.capture( + request('first', time, path: '/one/readme.md'), + policy, + ); + final second = await store.capture( + request('second', time, path: '/two/readme.md'), + policy, + ); + + expect(first.document.id, isNot(second.document.id)); + expect((await store.load()).documents, hasLength(2)); + }, + ); + + test('an intentional empty revision after content is retained', () async { + final time = DateTime.utc(2026, 1, 1); + await store.capture(request('content', time), policy); + final emptied = await store.capture( + request('', time.add(const Duration(seconds: 1))), + policy, + ); + + expect((await store.load()).revisions, hasLength(2)); + expect((await store.readRevision(emptied.revision!.id))!.source, isEmpty); + }); + + test( + 'missing and corrupt revision records do not damage intact entries', + () async { + final time = DateTime.utc(2026, 1, 1); + final first = await store.capture(request('first', time), policy); + final second = await store.capture( + request('second', time.add(const Duration(seconds: 1))), + policy, + ); + final firstFile = File( + p.join( + root.path, + 'revisions', + first.document.id, + '${first.revision!.id}.json', + ), + ); + await firstFile.delete(); + final secondFile = File( + p.join( + root.path, + 'revisions', + second.document.id, + '${second.revision!.id}.json', + ), + ); + await secondFile.writeAsString('{corrupt'); + + expect(await store.readRevision(first.revision!.id), isNull); + expect(await store.readRevision(second.revision!.id), isNull); + expect((await store.load()).documents, hasLength(1)); + }, + ); + + test( + 'incomplete staging artifacts are never discovered as revisions', + () async { + await store.capture( + request('complete', DateTime.utc(2026, 1, 1)), + policy, + ); + final artifact = File( + p.join( + root.path, + 'revisions', + 'escaped_identity', + 'partial.json.staging', + ), + ); + await artifact.parent.create(recursive: true); + await artifact.writeAsString('{"source":"partial"}'); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + + final repaired = await store.load(); + expect(repaired.revisions, hasLength(1)); + expect( + (await store.readRevision(repaired.revisions.single.id))!.source, + 'complete', + ); + }, + ); + + test('global clear tombstones prevent repair resurrection', () async { + final result = await store.capture( + request('remove globally', DateTime.utc(2026, 1, 1)), + policy, + ); + final revisionFile = File( + p.join( + root.path, + 'revisions', + result.document.id, + '${result.revision!.id}.json', + ), + ); + final retainedBytes = await revisionFile.readAsBytes(); + await store.clearAll(); + await revisionFile.parent.create(recursive: true); + await revisionFile.writeAsBytes(retainedBytes); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + + final repaired = await store.load(); + expect(repaired.documents, isEmpty); + expect(repaired.revisions, isEmpty); + }); + + test( + 'multiple store instances coordinate concurrent index publication', + () async { + var sharedIds = 1000; + String createSharedId() => + 'shared_${(++sharedIds).toString().padLeft(12, '0')}'; + final firstStore = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: createSharedId, + ); + final secondStore = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: createSharedId, + ); + final time = DateTime.utc(2026, 1, 1); + + await Future.wait([ + firstStore.capture(request('one', time, path: '/one.md'), policy), + secondStore.capture(request('two', time, path: '/two.md'), policy), + ]); + + final snapshot = await firstStore.load(); + expect(snapshot.documents, hasLength(2)); + expect(snapshot.revisions, hasLength(2)); + }, + ); + + test( + 'storage permission failures surface without touching document data', + () async { + final unavailable = FileLocalHistoryStore( + rootDirectory: () async => + throw const FileSystemException('permission denied'), + ); + + await expectLater( + unavailable.capture( + request('never stored', DateTime.utc(2026, 1, 1)), + policy, + ), + throwsA(isA()), + ); + expect((await store.load()).revisions, isEmpty); + }, + ); + + test('path exclusions use injected Windows path semantics', () { + final windows = p.Context(style: p.Style.windows, current: r'C:\workspace'); + final windowsPolicy = LocalHistoryPolicy( + excludedPaths: const [r'C:\workspace\private'], + pathContext: windows, + ); + + expect(windowsPolicy.excludes(r'c:\WORKSPACE\PRIVATE\secret.md'), isTrue); + expect(windowsPolicy.excludes(r'C:\workspace\public\guide.md'), isFalse); + }); +} diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart new file mode 100644 index 00000000..fd4c229f --- /dev/null +++ b/test/src/local_history_workspace_test.dart @@ -0,0 +1,466 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/comparison/source_comparison.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_comparison_view.dart'; +import 'package:busymark/src/local_history/local_history_models.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp( + 'busymark-local-history-workspace-', + ); + }); + + tearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + + test( + 'comparison uses unsaved editor source and whole restore is one undo step', + () async { + final path = p.join(root.path, 'guide.md'); + await File(path).writeAsString('Disk version\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'Historical version\n'); + final harness = await _harness(store); + await harness.controller.openPath(path); + harness.controller.updateActiveText('Unsaved editor version\n'); + await Future.delayed(Duration.zero); + final revision = (await store.readRevision(captured.revision!.id))!; + final document = (await store.load()).documents.single; + + final current = await harness.controller.localHistoryCurrentSource( + document, + revision, + ); + expect(current.kind, LocalHistoryCurrentSourceKind.editor); + expect(current.source, 'Unsaved editor version\n'); + final before = harness.state.activeBuffer!; + final undoLength = before.editorState.undoState.undo.length; + final format = before.format; + + expect( + await harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + ), + isTrue, + ); + var restored = harness.state.activeBuffer!; + expect(restored.text, 'Historical version\n'); + expect(restored.editorState.undoState.undo, hasLength(undoLength + 1)); + expect(restored.format.hasUtf8Bom, format.hasUtf8Bom); + expect(restored.format.lineEnding, format.lineEnding); + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, 'Unsaved editor version\n'); + expect(harness.controller.redoActiveBuffer(), isTrue); + expect(harness.state.activeText, 'Historical version\n'); + }, + ); + + test('region restore is exact, undoable, and rejects stale ranges', () async { + final path = p.join(root.path, 'regions.md'); + await File(path).writeAsString('alpha\nnew phrase\nomega\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'alpha\nold phrase\nomega\n'); + final harness = await _harness(store); + await harness.controller.openPath(path); + final revision = (await store.readRevision(captured.revision!.id))!; + final document = (await store.load()).documents.single; + var buffer = harness.state.activeBuffer!; + final comparison = _comparison(revision, buffer); + expect(comparison.changes, hasLength(1)); + final change = comparison.changes.single; + + expect( + await harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + comparison: comparison, + change: change, + ), + isTrue, + ); + expect(harness.state.activeText, 'alpha\nold phrase\nomega\n'); + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, 'alpha\nnew phrase\nomega\n'); + + harness.controller.updateActiveText('alpha\nnewer phrase\nomega\n'); + buffer = harness.state.activeBuffer!; + final undoLength = buffer.editorState.undoState.undo.length; + expect( + await harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + comparison: comparison, + change: change, + ), + isFalse, + ); + expect(harness.state.activeText, 'alpha\nnewer phrase\nomega\n'); + expect( + harness.state.activeBuffer!.editorState.undoState.undo, + hasLength(undoLength), + ); + }); + + test('failed protective capture leaves current document unchanged', () async { + final path = p.join(root.path, 'protected.md'); + await File(path).writeAsString('Current disk\n'); + final memory = MemoryLocalHistoryStore(); + final captured = await _capture(memory, path, 'Old history\n'); + final store = _FailingProtectiveStore(memory); + final harness = await _harness(store); + await harness.controller.openPath(path); + harness.controller.updateActiveText('Precious unsaved work\n'); + await Future.delayed(Duration.zero); + final revision = (await memory.readRevision(captured.revision!.id))!; + final document = (await memory.load()).documents.single; + final before = harness.state.activeBuffer!; + + expect( + await harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + ), + isFalse, + ); + expect(harness.state.activeText, before.text); + expect(harness.state.activeBuffer!.revision, before.revision); + expect( + harness.state.activeBuffer!.editorState.undoState.undo, + before.editorState.undoState.undo, + ); + expect( + harness.container.read(localHistoryControllerProvider).warning?.kind, + LocalHistoryWarningKind.capture, + ); + }); + + test( + 'deleted document restores through normal create flow without overwriting', + () async { + final anchor = p.join(root.path, 'anchor.md'); + final missing = p.join(root.path, 'deleted.md'); + await File(anchor).writeAsString('# Anchor\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + missing, + '# Recovered\n\nSubstantial deleted content.\n', + reason: LocalHistoryCaptureReason.beforeDelete, + ); + await store.markDeleted(missing, recursive: false); + final harness = await _harness(store); + await harness.controller.openPath(root.path); + final revision = (await store.readRevision(captured.revision!.id))!; + final document = (await store.load()).documents.singleWhere( + (candidate) => candidate.id == captured.document.id, + ); + expect( + (await harness.controller.localHistoryCurrentSource( + document, + revision, + )).kind, + LocalHistoryCurrentSourceKind.missing, + ); + + expect( + await harness.controller.restoreMissingLocalHistoryRevision( + document: document, + revision: revision, + destinationPath: missing, + overwriteExisting: false, + ), + isTrue, + ); + expect(harness.state.activeBuffer!.filePath, missing); + expect(harness.state.activeText, revision.source); + expect(harness.state.activeBuffer!.isDirty, isTrue); + expect(await File(missing).readAsString(), ''); + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, ''); + + final occupied = p.join(root.path, 'occupied.md'); + await File(occupied).writeAsString('Do not replace\n'); + expect( + await harness.controller.restoreMissingLocalHistoryRevision( + document: document, + revision: revision, + destinationPath: occupied, + overwriteExisting: false, + ), + isFalse, + ); + expect(await File(occupied).readAsString(), 'Do not replace\n'); + }, + ); + + test( + 'delayed explicit save records exactly the source that was written', + () async { + final path = p.join(root.path, 'delayed-save.md'); + await File(path).writeAsString('# Initial\n'); + final store = MemoryLocalHistoryStore(); + final service = _DelayedSaveWorkspaceService(); + final harness = await _harness(store, service: service); + await harness.controller.openPath(path); + harness.controller.updateActiveText('# First requested revision\n'); + final save = harness.controller.saveActive(); + await service.started.future; + harness.controller.updateActiveText('# Newer unsaved revision\n'); + service.release(); + + expect(await save, isTrue); + final snapshot = await store.load(); + final saved = []; + for (final summary in snapshot.revisions.where( + (candidate) => candidate.reason == LocalHistoryCaptureReason.saved, + )) { + saved.add((await store.readRevision(summary.id))!); + } + expect(saved.map((entry) => entry.source), [ + '# First requested revision\n', + ]); + expect(harness.state.activeText, '# Newer unsaved revision\n'); + expect(harness.state.activeBuffer!.isDirty, isTrue); + }, + ); + + test( + 'history failure cannot turn a successful save into a failure', + () async { + final path = p.join(root.path, 'save-warning.md'); + await File(path).writeAsString('# Initial\n'); + final memory = MemoryLocalHistoryStore(); + final store = _FailingSavedStore(memory); + final harness = await _harness(store); + await harness.controller.openPath(path); + harness.controller.updateActiveText('# Successfully saved\n'); + + expect(await harness.controller.saveActive(), isTrue); + expect(await File(path).readAsString(), '# Successfully saved\n'); + expect(harness.state.activeBuffer!.isDirty, isFalse); + expect( + harness.container.read(localHistoryControllerProvider).warning?.kind, + LocalHistoryWarningKind.capture, + ); + }, + ); + + testWidgets( + 'comparison renders styled intraline spans without framework errors', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final path = p.join(root.path, 'render.md'); + final harness = (await tester.runAsync(() async { + await File( + path, + ).writeAsString('# Current title\n\nCurrent paragraph.\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + path, + '# Historical title\n\nHistorical paragraph.\n', + ); + final harness = await _harness(store); + await harness.controller.openPath(path); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.selectDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return harness; + }))!; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: harness.container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: LocalHistoryComparisonView()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.byType(SelectableText), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); +} + +SourceComparison _comparison( + LocalHistoryRevision revision, + DocumentBuffer buffer, +) => compareSource( + SourceComparisonInput( + id: revision.summary.id, + version: revision.summary.capturedAt.microsecondsSinceEpoch, + label: 'Selected revision', + source: revision.source, + ), + SourceComparisonInput( + id: buffer.id, + version: buffer.revision, + label: 'Current editor content', + source: buffer.text, + ), +); + +Future _capture( + LocalHistoryStore store, + String path, + String source, { + LocalHistoryCaptureReason reason = LocalHistoryCaptureReason.saved, +}) => store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: p.basename(path), + source: source, + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1), + reason: reason, + ), + const LocalHistoryPolicy(), +); + +Future<_Harness> _harness( + LocalHistoryStore store, { + WorkspaceService service = const WorkspaceService(), +}) async { + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 1, 0, 1), + ), + workspaceServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + final settings = container.read(appSettingsControllerProvider.notifier); + final controller = container.read(workspaceControllerProvider.notifier); + await Future.delayed(Duration.zero); + await settings.setAutoSave(false); + return _Harness(container, controller); +} + +class _Harness { + const _Harness(this.container, this.controller); + + final ProviderContainer container; + final WorkspaceController controller; + + WorkspaceState get state => container.read(workspaceControllerProvider); +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async => value = json; +} + +class _FailingProtectiveStore implements LocalHistoryStore { + const _FailingProtectiveStore(this.delegate); + + final LocalHistoryStore delegate; + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + throw const LocalHistoryStorageException('Injected capture failure'); + } + return delegate.capture(request, policy); + } + + @override + Future clearAll() => delegate.clearAll(); + + @override + Future clearDocument(String documentId) => + delegate.clearDocument(documentId); + + @override + Future load() => delegate.load(); + + @override + Future markDeleted(String path, {required bool recursive}) => + delegate.markDeleted(path, recursive: recursive); + + @override + Future readRevision(String revisionId) => + delegate.readRevision(revisionId); + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) => + delegate.prune(policy, now); + + @override + Future remapPath(String sourcePath, String destinationPath) => + delegate.remapPath(sourcePath, destinationPath); +} + +class _FailingSavedStore extends _FailingProtectiveStore { + const _FailingSavedStore(super.delegate); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + if (request.reason == LocalHistoryCaptureReason.saved) { + throw const LocalHistoryStorageException('Injected saved failure'); + } + return delegate.capture(request, policy); + } +} + +class _DelayedSaveWorkspaceService extends WorkspaceService { + final started = Completer(); + final _release = Completer(); + + void release() => _release.complete(); + + @override + Future saveText(String path, String text) async { + started.complete(); + await _release.future; + return super.saveText(path, text); + } +} diff --git a/test/src/source_comparison_test.dart b/test/src/source_comparison_test.dart new file mode 100644 index 00000000..7d782bd6 --- /dev/null +++ b/test/src/source_comparison_test.dart @@ -0,0 +1,123 @@ +import 'package:busymark/src/comparison/source_comparison.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + SourceComparison compare( + String oldSource, + String currentSource, { + int? bound, + }) { + return compareSource( + SourceComparisonInput( + id: 'old-input', + version: 1, + label: 'old', + source: oldSource, + ), + SourceComparisonInput( + id: 'current-input', + version: 2, + label: 'current', + source: currentSource, + ), + maximumLcsCells: bound ?? 2000000, + ); + } + + test('keeps separated edits as exact separate changes', () { + final unchanged = List.generate(1500, (index) => 'line $index\n'); + final oldLines = [...unchanged]; + final currentLines = [...unchanged]; + oldLines[120] = 'old first\n'; + currentLines[120] = 'new first\n'; + oldLines[1320] = 'old second\n'; + currentLines[1320] = 'new second\n'; + + final result = compare(oldLines.join(), currentLines.join(), bound: 2000); + + expect(result.simplified, isFalse); + expect(result.changes, hasLength(2)); + expect(result.changes.every((change) => change.exact), isTrue); + expect(result.changes[0].oldText, 'old first\n'); + expect(result.changes[1].currentText, 'new second\n'); + }); + + test('handles repeated lines without losing exact offsets', () { + const oldSource = 'same\nsame\nold\nsame\nsame\n'; + const currentSource = 'same\nsame\nnew\nsame\nsame\n'; + final result = compare(oldSource, currentSource); + + expect(result.changes, hasLength(1)); + final change = result.changes.single; + expect(change.exact, isTrue); + expect( + oldSource.substring(change.oldRange.start, change.oldRange.end), + 'old\n', + ); + expect( + currentSource.substring( + change.currentRange.start, + change.currentRange.end, + ), + 'new\n', + ); + }); + + test('refines Unicode intraline ranges on UTF-16 boundaries', () { + const oldSource = 'A 😀 brown fox\n'; + const currentSource = 'A 😀 blue fox\n'; + final change = compare(oldSource, currentSource).changes.single; + + expect( + change.oldText.substring( + change.oldIntralineRange.start, + change.oldIntralineRange.end, + ), + 'rown', + ); + expect( + change.currentText.substring( + change.currentIntralineRange.start, + change.currentIntralineRange.end, + ), + 'lue', + ); + }); + + test('distinguishes whitespace, final newline, empty, and XML changes', () { + expect(compare('a\tb\n', 'a b\n').changes, hasLength(1)); + expect( + compare('

    old

    \n', '

    new

    \n').changes.single.exact, + isTrue, + ); + expect(compare('text', 'text\n').changes.single.currentText, 'text\n'); + expect(compare('', 'created\n').changes.single.oldRange.start, 0); + expect(compare('removed\n', '').changes.single.currentRange.end, 0); + }); + + test('labels an unanchored expensive comparison as simplified', () { + final oldSource = List.generate(100, (index) => 'old $index\n').join(); + final currentSource = List.generate(100, (index) => 'new $index\n').join(); + final result = compare(oldSource, currentSource, bound: 100); + + expect(result.simplified, isTrue); + expect(result.changes.single.exact, isFalse); + }); + + test('version identity detects stale comparison inputs', () { + final result = compare('old\n', 'new\n'); + expect(result.stillMatches(result.oldInput, result.currentInput), isTrue); + expect( + result.stillMatches( + result.oldInput, + SourceComparisonInput( + id: result.currentInput.id, + version: 3, + label: 'current', + source: 'newer\n', + ), + ), + isFalse, + ); + }); +} diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index b6fb0173..51a0c4e6 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' show BoxHeightStyle; import 'package:busymark/l10n/generated/app_localizations.dart'; @@ -6,6 +7,8 @@ import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/ai/ai_models.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/clipboard/clipboard_insertion.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/source_span.dart'; import 'package:busymark/src/editor/document_text_geometry.dart'; @@ -19,6 +22,7 @@ import 'package:busymark/src/editor/source/source_search.dart'; import 'package:busymark/src/editor/source_folding.dart'; import 'package:busymark/src/editor/source_language.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -139,6 +143,9 @@ void main() { expect(nativeEntries!.map((entry) => entry['label']), [ ...expectedSelectionActions, 'Refine with AI', + '', + 'Clipboard History', + 'Local History…', ]); expect( nativeEntries!.map((entry) => entry['label']), @@ -1447,6 +1454,112 @@ void main() { await tester.pump(); expect(changedText, 'authoritative!'); }); + + testWidgets('source keyboard copy retains exact whitespace once', ( + tester, + ) async { + const source = 'before\n \n\t\nafter\n'; + final clipboard = _SourceTestClipboard(); + final captures = []; + final controller = await _pumpClipboardSourceEditor( + tester, + source: source, + clipboard: clipboard, + onCaptured: captures.add, + ); + controller.selection = TextSelection( + baseOffset: source.indexOf(' \n'), + extentOffset: source.indexOf('after'), + ); + + await _pressControlKey(tester, LogicalKeyboardKey.keyC); + await tester.pump(); + + expect(clipboard.writes, hasLength(1)); + expect(clipboard.writes.single.text, ' \n\t\n'); + expect(clipboard.writes.single.sourceText, ' \n\t\n'); + expect(captures, hasLength(1)); + expect(captures.single.sourceText, ' \n\t\n'); + }); + + testWidgets('source failed cut keeps text and does not record history', ( + tester, + ) async { + const source = 'alpha beta gamma'; + final clipboard = _SourceTestClipboard(writeResult: false); + final captures = []; + String? changed; + final controller = await _pumpClipboardSourceEditor( + tester, + source: source, + clipboard: clipboard, + onCaptured: captures.add, + onChanged: (value, _) => changed = value, + ); + controller.selection = const TextSelection(baseOffset: 6, extentOffset: 10); + + await _pressControlKey(tester, LogicalKeyboardKey.keyX); + await tester.pump(); + + expect(controller.text, source); + expect(changed, isNull); + expect(captures, isEmpty); + }); + + testWidgets('source delayed cut revalidates selection before deletion', ( + tester, + ) async { + const source = 'alpha beta gamma'; + final clipboard = _SourceTestClipboard(delayWrite: true); + final captures = []; + String? changed; + final controller = await _pumpClipboardSourceEditor( + tester, + source: source, + clipboard: clipboard, + onCaptured: captures.add, + onChanged: (value, _) => changed = value, + ); + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 5); + + await _pressControlKey(tester, LogicalKeyboardKey.keyX); + await clipboard.writeStarted.future; + controller.selection = const TextSelection( + baseOffset: 11, + extentOffset: 16, + ); + clipboard.releaseWrite(); + await tester.pump(); + + expect(controller.text, source); + expect(changed, isNull); + expect(captures.single.sourceText, 'alpha'); + }); + + testWidgets('history insertion follows the latest Source selection', ( + tester, + ) async { + final registry = BusyMarkClipboardInsertionRegistry(); + String? changed; + final controller = await _pumpClipboardSourceEditor( + tester, + source: 'abcd', + clipboard: _SourceTestClipboard(), + registry: registry, + onChanged: (value, _) => changed = value, + ); + controller.selection = const TextSelection.collapsed(offset: 3); + final payload = BusyMarkClipboardPayload( + id: 'source-history-payload', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.richText, + text: 'plain', + sourceText: '**rich**', + ); + + expect(await registry.paste(payload), ClipboardPasteResult.inserted); + expect(changed, 'abc**rich**d'); + }); } const _autocompleteSource = '

    fea'; @@ -1520,6 +1633,86 @@ Future _pressControlSpace(WidgetTester tester) async { await tester.pump(); } +Future _pumpClipboardSourceEditor( + WidgetTester tester, { + required String source, + required _SourceTestClipboard clipboard, + BusyMarkClipboardInsertionRegistry? registry, + ValueChanged? onCaptured, + BusyMarkSourceChanged? onChanged, +}) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/source.md', + documentId: 'source-document', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: onChanged ?? (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + clipboardService: clipboard, + clipboardInsertionRegistry: registry, + onClipboardCaptured: onCaptured, + ), + ), + ), + ), + ); + final field = find.byType(TextField); + await tester.tap(field); + await tester.showKeyboard(field); + return tester.widget(field).controller!; +} + +Future _pressControlKey( + WidgetTester tester, + LogicalKeyboardKey key, +) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); +} + +class _SourceTestClipboard extends RichClipboardService { + _SourceTestClipboard({this.writeResult = true, this.delayWrite = false}) + : super(channel: const MethodChannel('busymark.test/source-clipboard')); + + final bool writeResult; + final bool delayWrite; + final writes = []; + final writeStarted = Completer(); + final _writeRelease = Completer(); + + void releaseWrite() { + if (!_writeRelease.isCompleted) _writeRelease.complete(); + } + + @override + Future write(RichClipboardData data) async { + writes.add(data); + if (!writeStarted.isCompleted) writeStarted.complete(); + if (delayWrite) await _writeRelease.future; + return writeResult; + } +} + String? _nativeShortcut(List> entries, String label) { return entries.singleWhere((entry) => entry['label'] == label)['shortcut'] as String?; diff --git a/test/src/wysiwyg_ai_test.dart b/test/src/wysiwyg_ai_test.dart index 758fdc5a..567f34c0 100644 --- a/test/src/wysiwyg_ai_test.dart +++ b/test/src/wysiwyg_ai_test.dart @@ -288,6 +288,9 @@ void main() { expect(nativeEntries!.map((entry) => entry['label']), [ ...expectedSelectionActions, 'Refine with AI', + '', + 'Clipboard History', + 'Local History…', ]); expect( nativeEntries!.map((entry) => entry['label']), diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index e2941f19..9d5b99c8 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -142,6 +142,17 @@ void main() { const WysiwygClipboardHtml().encode(decoded), contains('src="file:///source/images/diagram.png"'), ); + expect( + _insert( + decoded.rebase( + '/destination/topic.md', + mediaDestinations: const { + 'diagram.png': 'images/diagram-retained.png', + }, + ), + ), + contains('![Alt](images/diagram-retained.png)'), + ); }); test('Writerside structure survives the fragment and insertion', () { @@ -391,7 +402,8 @@ void main() { tester, ) async { await copyAll(tester); - expect(systemData.keys, containsAll(['text', 'html', 'fragment'])); + expect(systemData.keys, containsAll(['text', 'html', 'token'])); + expect(systemData, isNot(contains('fragment'))); expect(systemData['text'], contains('[ ] First task')); var result = ''; await mount(tester, 'destination', 'Target\n', (value) => result = value); @@ -467,7 +479,8 @@ void main() { } await tester.tap(find.text('Copy')); await tester.pumpAndSettle(); - expect(systemData.keys, containsAll(['text', 'html', 'fragment'])); + expect(systemData.keys, containsAll(['text', 'html', 'token'])); + expect(systemData, isNot(contains('fragment'))); expect(systemData['text'], contains('[ ] First task')); }); @@ -479,7 +492,8 @@ void main() { await key(tester, LogicalKeyboardKey.keyA); await key(tester, LogicalKeyboardKey.keyC, shift: true); - expect(systemData.keys, ['text']); + expect(systemData.keys, containsAll(['text', 'token'])); + expect(systemData, isNot(contains('fragment'))); expect(systemData['text'], startsWith('Issues')); expect( systemData['text'], @@ -512,7 +526,8 @@ void main() { await tester.tap(find.text('Copy Plain Text')); await tester.pumpAndSettle(); - expect(systemData.keys, ['text']); + expect(systemData.keys, containsAll(['text', 'token'])); + expect(systemData, isNot(contains('fragment'))); expect(systemData['text'], startsWith('Issues')); expect(systemData['text'], isNot(contains('**When**'))); }); @@ -560,7 +575,8 @@ void main() { await key(tester, LogicalKeyboardKey.keyX); expect(origin, 'A word\n'); - expect(systemData.keys, containsAll(['text', 'html', 'fragment'])); + expect(systemData.keys, containsAll(['text', 'html', 'token'])); + expect(systemData, isNot(contains('fragment'))); expect(systemData['text'], 'bold'); expect(systemData['html'], contains('bold')); diff --git a/tools/clipboard_smoke.dart b/tools/clipboard_smoke.dart index 1c7c5d0d..ba112c22 100644 --- a/tools/clipboard_smoke.dart +++ b/tools/clipboard_smoke.dart @@ -1,5 +1,6 @@ // Build with flutter build linux --debug -t tools/clipboard_smoke.dart. -// Run the resulting binary with: write|read|plain|html OUTPUT_DIRECTORY. +// Run the resulting binary with: +// roundtrip|write|read|plain|html OUTPUT_DIRECTORY. // Use a private display: this probe intentionally owns that display's clipboard. import 'dart:async'; import 'dart:convert'; @@ -20,7 +21,7 @@ const source = void main(List arguments) { WidgetsFlutterBinding.ensureInitialized(); if (arguments.length != 2) { - stderr.writeln('Usage: write|read|plain|html OUTPUT_DIRECTORY'); + stderr.writeln('Usage: roundtrip|write|read|plain|html OUTPUT_DIRECTORY'); exit(2); } runApp( @@ -33,6 +34,11 @@ void main(List arguments) { Future run(String operation, Directory output) async { try { + // The GTK runner presents its window from the native first-frame callback, + // just after Flutter's first post-frame callbacks have run. Clipboard + // ownership on Wayland (and some X11 managers) requires that presentation + // to complete. + await Future.delayed(const Duration(seconds: 5)); if (Platform.environment['BUSYMARK_CLIPBOARD_WAIT_FOR_FOCUS'] == '1') { await File('${output.path}/$operation.window').writeAsString('ready'); while (!File('${output.path}/start-$operation').existsSync()) { @@ -64,25 +70,33 @@ Future run(String operation, Directory output) async { if (!condition) throw StateError(reason); } - if (operation == 'write' || operation == 'html') { + if (operation == 'write' || + operation == 'html' || + operation == 'roundtrip') { check( await service.write( RichClipboardData( text: text, html: const WysiwygClipboardHtml().encode(fragment), - fragment: operation == 'write' ? fragment.encode() : null, + sourceText: operation == 'html' ? null : fragment.markdown, + richFragment: operation == 'html' ? null : fragment.encode(), ), ), 'Clipboard write failed', ); - await File('${output.path}/$operation.ready').writeAsString('ready'); - Timer.periodic(const Duration(milliseconds: 100), (timer) { - if (File('${output.path}/stop').existsSync()) { - timer.cancel(); - unawaited(SystemNavigator.pop()); - } - }); - return; + if (operation != 'roundtrip') { + await File('${output.path}/$operation.ready').writeAsString('ready'); + Timer.periodic(const Duration(milliseconds: 100), (timer) { + if (File('${output.path}/stop').existsSync()) { + timer.cancel(); + unawaited(SystemNavigator.pop()); + } + }); + return; + } + // Give the X11 selection owner change a main-loop turn before requesting + // its targets, matching a real copy followed by a separate paste event. + await Future.delayed(const Duration(milliseconds: 100)); } if (operation == 'plain') { await Clipboard.setData(const ClipboardData(text: text)); @@ -90,11 +104,12 @@ Future run(String operation, Directory output) async { final data = await service.read(); check( data.text == text, - 'Plain text did not survive transport: ${data.toMap().keys}', + 'Plain text did not survive transport: ' + '${jsonEncode({'text': data.text, 'html': data.html, 'token': data.token, 'generation': data.generation})}', ); if (operation == 'plain') { check( - data.fragment == null && data.html == null, + data.richFragment == null && data.html == null, 'Plain copy retained stale rich representations', ); } else { @@ -102,8 +117,25 @@ Future run(String operation, Directory output) async { data.html?.contains('When') == true, 'HTML formatting missing', ); - final decoded = WysiwygClipboardFragment.decode(data.fragment ?? ''); - check(decoded != null, 'Structured data missing'); + final decoded = data.richFragment == null + ? const WysiwygClipboardHtml().decode( + data.html ?? '', + mode: document.mode, + ) + : WysiwygClipboardFragment.decode(data.richFragment!); + if (operation == 'read') { + check(data.token != null, 'Opaque ownership token missing'); + check( + data.richFragment == null && !data.sessionOwned, + 'Another process resolved session-owned structured data', + ); + } else if (operation == 'roundtrip') { + check( + data.richFragment != null && data.sessionOwned, + 'Same-process ownership token did not resolve structured data', + ); + } + check(decoded != null, 'No safe structured or HTML representation'); final target = parser .parse(filePath: '/clipboard/destination.md', source: 'Target\n') .busyDocument; @@ -126,7 +158,12 @@ Future run(String operation, Directory output) async { controller.dispose(); } await File('${output.path}/$operation.json').writeAsString( - jsonEncode({'passed': true, 'formats': data.toMap().keys.toList()}), + jsonEncode({ + 'passed': true, + 'text': data.text != null, + 'html': data.html != null, + 'rich': data.richFragment != null, + }), ); // Let native render hosts finish their first frame before closing the probe. await Future.delayed(const Duration(seconds: 1)); diff --git a/tools/history_visual_smoke.dart b/tools/history_visual_smoke.dart new file mode 100644 index 00000000..246416b8 --- /dev/null +++ b/tools/history_visual_smoke.dart @@ -0,0 +1,484 @@ +// Builds a disposable native UI verification target for Clipboard History and +// Local History. The target drives the production controllers and widgets; it +// does not replace them with screenshot-only fixtures. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/startup_path.dart'; +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/comparison/source_comparison.dart'; +import 'package:busymark/src/git/application/git_controller.dart'; +import 'package:busymark/src/git/data/git_cli_gateway.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/local_history/local_history_store.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:window_manager/window_manager.dart'; + +Future main(List arguments) async { + WidgetsFlutterBinding.ensureInitialized(); + if (arguments.length != 5) { + stderr.writeln( + 'Usage: markdown|writerside WORKSPACE PRIMARY_FILE DELETE_FILE_OR_DASH ' + 'OUTPUT_DIRECTORY', + ); + exit(2); + } + final mode = arguments[0]; + if (mode != 'markdown' && mode != 'writerside') { + stderr.writeln('The mode must be markdown or writerside.'); + exit(2); + } + final workspacePath = p.normalize(p.absolute(arguments[1])); + final primaryPath = p.normalize(p.absolute(arguments[2])); + final deletePath = arguments[3] == '-' + ? null + : p.normalize(p.absolute(arguments[3])); + final output = Directory(p.normalize(p.absolute(arguments[4]))); + await output.create(recursive: true); + + await windowManager.ensureInitialized(); + await LinuxHeaderBarService.instance.initialize(); + final settings = AppSettings.defaults().copyWith( + themeModePreference: mode == 'writerside' + ? BusyMarkThemeModePreference.dark + : BusyMarkThemeModePreference.light, + localeTag: mode == 'writerside' ? 'ar' : 'en', + documentViewMode: DocumentViewModePreference.source, + previewVisible: false, + editorFontSize: mode == 'writerside' ? 20 : 15, + autoSave: false, + reopenPreviousWorkspaceOnStartup: false, + confirmCloseWithUnsavedChanges: false, + ); + final boundaryKey = GlobalKey(); + runApp( + ProviderScope( + overrides: [ + startupPathProvider.overrideWithValue(workspacePath), + initialSystemAccentColorProvider.overrideWithValue( + busyMarkDefaultAccentColor, + ), + gitRepositoryGatewayProvider.overrideWithValue(const GitCliGateway()), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(settings.toJson()), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + localHistoryStoreProvider.overrideWithValue( + FileLocalHistoryStore( + rootDirectory: () async => Directory(p.join(output.path, 'store')), + ), + ), + localHistoryTimerFactoryProvider.overrideWithValue( + (_, callback) => Timer(const Duration(milliseconds: 450), callback), + ), + ], + child: _HistoryVisualHarness( + mode: mode, + primaryPath: primaryPath, + deletePath: deletePath, + output: output, + boundaryKey: boundaryKey, + ), + ), + ); + await windowManager.waitUntilReadyToShow( + const WindowOptions(size: Size(1280, 800), center: true), + () async { + await windowManager.show(); + await windowManager.focus(); + }, + ); +} + +class _HistoryVisualHarness extends ConsumerStatefulWidget { + const _HistoryVisualHarness({ + required this.mode, + required this.primaryPath, + required this.deletePath, + required this.output, + required this.boundaryKey, + }); + + final String mode; + final String primaryPath; + final String? deletePath; + final Directory output; + final GlobalKey boundaryKey; + + @override + ConsumerState<_HistoryVisualHarness> createState() => + _HistoryVisualHarnessState(); +} + +class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { + final _screenshots = []; + final _checks = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_run())); + } + + @override + Widget build(BuildContext context) => + RepaintBoundary(key: widget.boundaryKey, child: const BusyMarkApp()); + + Future _run() async { + try { + final workspace = ref.read(workspaceControllerProvider.notifier); + final localHistory = ref.read(localHistoryControllerProvider.notifier); + await _waitFor( + () => ref.read(workspaceControllerProvider).workspace != null, + 'workspace open', + ); + if (ref.read(workspaceControllerProvider).activeBuffer?.filePath != + widget.primaryPath) { + _check( + await workspace.openActiveFile(widget.primaryPath), + 'primary document open', + ); + } + await _waitFor( + () => ref.read(workspaceControllerProvider).activeBuffer != null, + 'active document', + ); + await Future.delayed(const Duration(milliseconds: 500)); + + await _seedClipboard(); + ref.read(clipboardHistoryOpenRequestProvider.notifier).request(); + await _capture('${widget.mode}-clipboard-history-1280x800.png'); + + var buffer = ref.read(workspaceControllerProvider).activeBuffer!; + await localHistory.observeOpened(buffer); + final baseline = buffer.text; + final saved = _savedVersion(baseline); + workspace.updateActiveText(saved, sourceFilePath: buffer.filePath); + _check( + await workspace.saveActive(overwriteExternalChanges: true), + 'explicit save captured', + ); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + workspace.updateActiveText( + _checkpointVersion(buffer.text), + sourceFilePath: buffer.filePath, + ); + await Future.delayed(const Duration(milliseconds: 850)); + await localHistory.refresh(); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + workspace.updateActiveText( + _currentVersion(buffer.text), + sourceFilePath: buffer.filePath, + ); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + await localHistory.selectDocumentForBuffer(buffer); + await localHistory.refresh(); + ref.read(localHistoryOpenRequestProvider.notifier).request(); + await _capture('${widget.mode}-local-history-1280x800.png'); + + final historyState = ref.read(localHistoryControllerProvider); + final historyDocument = historyState.selectedDocument!; + final revisionSummary = historyState.selectedRevisions.lastWhere( + (revision) => revision.reason == LocalHistoryCaptureReason.baseline, + orElse: () => historyState.selectedRevisions.last, + ); + await localHistory.selectRevision(revisionSummary.id); + await _waitFor( + () => ref.read(localHistoryControllerProvider).selectedRevision != null, + 'selected revision', + ); + await windowManager.setSize(const Size(1920, 1080)); + await _capture('${widget.mode}-comparison-1920x1080.png'); + + final selectedRevision = ref + .read(localHistoryControllerProvider) + .selectedRevision!; + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + final comparison = compareSource( + SourceComparisonInput( + id: selectedRevision.summary.id, + version: selectedRevision.summary.capturedAt.microsecondsSinceEpoch, + label: 'Selected revision', + source: selectedRevision.source, + ), + SourceComparisonInput( + id: buffer.id, + version: buffer.revision, + label: 'Current editor content', + source: buffer.text, + ), + ); + final exactChange = comparison.changes.where((change) => change.exact); + _check(exactChange.isNotEmpty, 'exact comparison region available'); + _check( + await workspace.restoreLocalHistoryRevision( + document: historyDocument, + revision: selectedRevision, + comparison: comparison, + change: exactChange.first, + ), + 'fragment restore', + ); + await localHistory.refresh(); + await _capture('${widget.mode}-fragment-restored-1920x1080.png'); + + if (widget.deletePath case final deletePath?) { + await _verifyDeletedRecovery(workspace, localHistory, deletePath); + } + + _checks['clipboardEntries'] = + ref.read(clipboardHistoryControllerProvider).entries.length >= 4; + _checks['revisionReasons'] = ref + .read(localHistoryControllerProvider) + .snapshot + .revisions + .map((revision) => revision.reason) + .toSet() + .containsAll({ + LocalHistoryCaptureReason.baseline, + LocalHistoryCaptureReason.saved, + LocalHistoryCaptureReason.automaticCheckpoint, + LocalHistoryCaptureReason.beforeRestore, + }); + await File( + p.join(widget.output.path, '${widget.mode}-report.json'), + ).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'passed': _checks.values.every((value) => value), + 'checks': _checks, + 'screenshots': _screenshots, + }), + ); + await workspace.discardRecoveryForShutdown(); + await Future.delayed(const Duration(milliseconds: 300)); + await SystemNavigator.pop(); + } catch (error, stackTrace) { + await File( + p.join(widget.output.path, '${widget.mode}-error.txt'), + ).writeAsString('$error\n$stackTrace'); + exit(1); + } + } + + Future _seedClipboard() async { + final workspaceState = ref.read(workspaceControllerProvider); + final buffer = workspaceState.activeBuffer!; + final origin = BusyMarkClipboardOrigin( + documentId: buffer.id, + documentName: buffer.displayName, + documentPath: buffer.filePath, + ); + final controller = ref.read(clipboardHistoryControllerProvider.notifier); + const sourceSelection = + '## Safe deployment\n\nUse **staged rollout** with a rollback plan.'; + final nativeWrite = await ref + .read(richClipboardServiceProvider) + .write( + RichClipboardData( + text: 'Safe deployment\n\nUse staged rollout with a rollback plan.', + sourceText: sourceSelection, + html: + '

    Safe deployment

    Use staged rollout with a rollback plan.

    ', + origin: origin, + ), + ); + _check(nativeWrite, 'native clipboard publication'); + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'Safe deployment\n\nUse staged rollout with a rollback plan.', + sourceText: sourceSelection, + html: + '

    Safe deployment

    Use staged rollout with a rollback plan.

    ', + origin: origin, + ), + ); + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: + 'curl --fail-with-body https://status.example.test/health\n' + 'printf "deployment ready\\n"', + sourceText: + '```bash\n' + 'curl --fail-with-body https://status.example.test/health\n' + 'printf "deployment ready\\n"\n' + '```', + origin: origin, + ), + ); + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: + '| Environment | Owner | Status |\n' + '| --- | --- | --- |\n' + '| Preview | Docs | Ready |', + sourceText: + '| Environment | Owner | Status |\n' + '| --- | --- | --- |\n' + '| Preview | Docs | Ready |', + origin: origin, + ), + ); + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: + '\n' + ' Publish the new secret.\n' + '', + sourceText: + '\n' + ' Publish the new secret.\n' + '', + origin: origin, + ), + ); + final imageFile = File( + p.join(p.dirname(widget.primaryPath), '..', 'images', 'logo.png'), + ); + if (await imageFile.exists()) { + controller.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: Uint8List.fromList(await imageFile.readAsBytes()), + imageMimeType: 'image/png', + imageDisplayName: 'deployment-overview.png', + imageWidth: 320, + imageHeight: 180, + origin: origin, + ), + ); + } + } + + Future _verifyDeletedRecovery( + WorkspaceController workspace, + LocalHistoryController localHistory, + String deletePath, + ) async { + _check(await workspace.openActiveFile(deletePath), 'recovery file open'); + final buffer = ref.read(workspaceControllerProvider).activeBuffer!; + await localHistory.observeOpened(buffer); + _check( + await workspace.deleteWorkspaceEntity(deletePath), + 'document delete', + ); + await localHistory.refresh(); + ref.read(localHistoryOpenRequestProvider.notifier).request(); + await Future.delayed(const Duration(milliseconds: 300)); + final deletedDocument = ref + .read(localHistoryControllerProvider) + .snapshot + .documents + .firstWhere( + (document) => + document.deleted && + document.historicalPaths.any( + (path) => p.equals(path, deletePath), + ), + ); + localHistory.selectDocument(deletedDocument.id); + final revisionSummary = ref + .read(localHistoryControllerProvider) + .selectedRevisions + .first; + await localHistory.selectRevision(revisionSummary.id); + await _capture('${widget.mode}-deleted-comparison-1920x1080.png'); + final revision = ref.read(localHistoryControllerProvider).selectedRevision!; + _check( + await workspace.restoreMissingLocalHistoryRevision( + document: deletedDocument, + revision: revision, + destinationPath: deletePath, + overwriteExisting: false, + ), + 'deleted document recovery', + ); + await localHistory.refresh(); + await _capture('${widget.mode}-deleted-recovered-1920x1080.png'); + _checks['deletedFileRecovered'] = await File(deletePath).exists(); + } + + String _savedVersion(String source) => source.replaceFirst( + 'The rollout starts in preview.', + 'The rollout starts in preview and requires two reviewers.', + ); + + String _checkpointVersion(String source) => source.replaceFirst( + '| Preview | Documentation | Ready |', + '| Preview | Documentation | Monitoring |', + ); + + String _currentVersion(String source) => source + .replaceFirst('# Deployment handbook', '# Production deployment handbook') + .replaceFirst( + 'Rollback restores the previous package.', + 'Rollback restores the previous signed package and validates health.', + ); + + Future _capture(String name) async { + await Future.delayed(const Duration(milliseconds: 650)); + await WidgetsBinding.instance.endOfFrame; + final boundary = widget.boundaryKey.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) { + throw StateError('Screenshot boundary is unavailable.'); + } + final image = await boundary.toImage(pixelRatio: 1); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (byteData == null) throw StateError('PNG encoding failed.'); + final path = p.join(widget.output.path, name); + await File(path).writeAsBytes(byteData.buffer.asUint8List(), flush: true); + _screenshots.add(path); + } + + Future _waitFor(bool Function() condition, String description) async { + for (var attempt = 0; attempt < 240; attempt++) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + throw TimeoutException('Timed out waiting for $description.'); + } + + void _check(bool condition, String description) { + _checks[description] = condition; + if (!condition) throw StateError('$description failed.'); + } +} + +class _MemorySettingsStore implements LocalSettingsStore { + _MemorySettingsStore(this.value); + + Map value; + + @override + Future> load() async => value; + + @override + Future save(Map json) async { + value = json; + } +} From ca0cbff91ccec0cb585af6f89a8f0e1f48c915e0 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 9 Sep 2026 13:38:10 -0700 Subject: [PATCH 03/27] Improve Local History robustness and add clipboard insertion enhancements - Introduced methods to prevent race conditions when accessing local history buffers. - Integrated `_externalOperationBuffer` for better buffer state verification. - Added `_bufferOperationTargetIsCurrent` to ensure correct operations on valid buffer states. - Enhanced `ClipboardInsertion` with capability checks for handling paste actions, including media and markdown assets. - Improved clipboard panel UX with auto-scrolling and better error handling. --- .../clipboard_history_controller.dart | 1 + .../clipboard/clipboard_history_panel.dart | 106 ++++--- lib/src/clipboard/clipboard_insertion.dart | 23 +- lib/src/editor/source/source_editor.dart | 131 ++++++++- .../wysiwyg/wysiwyg_clipboard_fragment.dart | 11 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 23 +- lib/src/git/application/git_controller.dart | 37 ++- lib/src/git/presentation/git_sidebar_tab.dart | 7 - .../local_history_comparison_view.dart | 28 +- .../local_history_controller.dart | 91 +++++- lib/src/platform/rich_clipboard_service.dart | 129 ++++++-- .../presentation/workspace_screen.dart | 25 ++ lib/src/workspace/workspace_controller.dart | 185 +++++++++--- test/src/clipboard_history_panel_test.dart | 223 ++++++++++++++ test/src/clipboard_history_test.dart | 84 ++++++ test/src/git/git_controller_test.dart | 99 ++++++- test/src/local_history_controller_test.dart | 277 ++++++++++++++++++ test/src/local_history_workspace_test.dart | 141 +++++++++ test/src/source_editor_widget_test.dart | 101 ++++++- test/src/wysiwyg_clipboard_test.dart | 81 ++++- 20 files changed, 1642 insertions(+), 161 deletions(-) create mode 100644 test/src/clipboard_history_panel_test.dart diff --git a/lib/src/clipboard/clipboard_history_controller.dart b/lib/src/clipboard/clipboard_history_controller.dart index 5eee4711..f454cb8d 100644 --- a/lib/src/clipboard/clipboard_history_controller.dart +++ b/lib/src/clipboard/clipboard_history_controller.dart @@ -157,6 +157,7 @@ class ClipboardHistoryController extends Notifier { } void clear() { + _clipboard.discardObsoleteOwnership(); state = state.copyWith(entries: const [], lastRetentionResult: null); } diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart index c1a9adda..55f63af6 100644 --- a/lib/src/clipboard/clipboard_history_panel.dart +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -7,8 +7,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/busymark_search_field.dart'; +import '../app/busymark_toast.dart'; import '../app/localization.dart'; import 'clipboard_history_controller.dart'; +import 'clipboard_insertion.dart'; import 'clipboard_models.dart'; class ClipboardHistoryPanel extends ConsumerStatefulWidget { @@ -23,7 +25,9 @@ class ClipboardHistoryPanel extends ConsumerStatefulWidget { class _ClipboardHistoryPanelState extends ConsumerState { final _searchController = TextEditingController(); + final _listScrollController = ScrollController(); final _listFocusNode = FocusNode(debugLabel: 'Clipboard History list'); + final _entryKeys = {}; String? _selectedId; @override @@ -43,6 +47,7 @@ class _ClipboardHistoryPanelState extends ConsumerState { @override void dispose() { _searchController.dispose(); + _listScrollController.dispose(); _listFocusNode.dispose(); super.dispose(); } @@ -65,12 +70,13 @@ class _ClipboardHistoryPanelState extends ConsumerState { !visible.any((entry) => entry.id == _selectedId)) { _selectedId = visible.first.id; } + final visibleIds = visible.map((entry) => entry.id).toSet(); + _entryKeys.removeWhere((id, _) => !visibleIds.contains(id)); final target = registry.target; - final canPaste = target != null && target.editable; return Focus( focusNode: _listFocusNode, - onKeyEvent: (_, event) => _handleKey(event, visible, canPaste), + onKeyEvent: (_, event) => _handleKey(event, visible, registry), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -149,29 +155,39 @@ class _ClipboardHistoryPanelState extends ConsumerState { ? context.l10n.clipboardNoItems : context.l10n.clipboardUnavailable, ) - : ListView.builder( - itemCount: visible.length, - itemBuilder: (context, index) { - final payload = visible[index]; - final isCurrent = identical(payload, current); - return _ClipboardEntryTile( - payload: payload, - current: isCurrent, - selected: payload.id == _selectedId, - canPaste: canPaste, - onSelect: () { - setState(() => _selectedId = payload.id); - _listFocusNode.requestFocus(); - }, - onPaste: () => _paste(payload, plainText: false), - onPastePlain: payload.hasMeaningfulTextRepresentation - ? () => _paste(payload, plainText: true) - : null, - onRemove: isCurrent - ? null - : () => controller.remove(payload.id), - ); - }, + : SingleChildScrollView( + key: const ValueKey('clipboard-history-list'), + controller: _listScrollController, + child: Column( + children: [ + for (final payload in visible) + _ClipboardEntryTile( + key: _entryKeys.putIfAbsent( + payload.id, + () => GlobalKey( + debugLabel: 'Clipboard entry ${payload.id}', + ), + ), + payload: payload, + current: identical(payload, current), + selected: payload.id == _selectedId, + canPaste: registry.canPaste(payload), + onSelect: () { + setState(() => _selectedId = payload.id); + _listFocusNode.requestFocus(); + }, + onPaste: () => _paste(payload, plainText: false), + onPastePlain: + payload.hasMeaningfulTextRepresentation && + registry.canPaste(payload, plainText: true) + ? () => _paste(payload, plainText: true) + : null, + onRemove: identical(payload, current) + ? null + : () => controller.remove(payload.id), + ), + ], + ), ), ), ], @@ -193,7 +209,7 @@ class _ClipboardHistoryPanelState extends ConsumerState { KeyEventResult _handleKey( KeyEvent event, List visible, - bool canPaste, + BusyMarkClipboardInsertionRegistry registry, ) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey == LogicalKeyboardKey.escape) { @@ -205,18 +221,20 @@ class _ClipboardHistoryPanelState extends ConsumerState { if (event.logicalKey == LogicalKeyboardKey.arrowDown || event.logicalKey == LogicalKeyboardKey.arrowUp) { final delta = event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1; + final nextIndex = (index < 0 ? 0 : index + delta).clamp( + 0, + visible.length - 1, + ); + final nextId = visible[nextIndex].id; setState(() { - _selectedId = - visible[(index < 0 ? 0 : index + delta).clamp( - 0, - visible.length - 1, - )] - .id; + _selectedId = nextId; }); + _ensureSelectionVisible(nextId); return KeyEventResult.handled; } final selected = index < 0 ? visible.first : visible[index]; - if (event.logicalKey == LogicalKeyboardKey.enter && canPaste) { + if (event.logicalKey == LogicalKeyboardKey.enter && + registry.canPaste(selected)) { unawaited(_paste(selected, plainText: false)); return KeyEventResult.handled; } @@ -231,6 +249,21 @@ class _ClipboardHistoryPanelState extends ConsumerState { return KeyEventResult.ignored; } + void _ensureSelectionVisible(String id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final entryContext = _entryKeys[id]?.currentContext; + if (!mounted || entryContext == null || !entryContext.mounted) return; + unawaited( + Scrollable.ensureVisible( + entryContext, + duration: const Duration(milliseconds: 120), + alignment: 0, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + ), + ); + }); + } + Future _paste( BusyMarkClipboardPayload payload, { required bool plainText, @@ -242,12 +275,19 @@ class _ClipboardHistoryPanelState extends ConsumerState { ref .read(clipboardHistoryControllerProvider.notifier) .retainCurrentAfterPaste(payload); + } else if (result != ClipboardPasteResult.inserted && mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.clipboardUnavailable, + priority: BusyMarkToastPriority.high, + ); } } } class _ClipboardEntryTile extends StatelessWidget { const _ClipboardEntryTile({ + super.key, required this.payload, required this.current, required this.selected, diff --git a/lib/src/clipboard/clipboard_insertion.dart b/lib/src/clipboard/clipboard_insertion.dart index ad19ee90..f538520e 100644 --- a/lib/src/clipboard/clipboard_insertion.dart +++ b/lib/src/clipboard/clipboard_insertion.dart @@ -18,6 +18,11 @@ abstract interface class BusyMarkClipboardInsertionTarget { void requestEditorFocus(); } +/// Optional destination-specific capability used to keep panel actions honest. +abstract interface class BusyMarkClipboardInsertionCapabilities { + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}); +} + /// Holds only the currently mounted document surface. Registrations remove /// themselves by identity, so a retiring editor cannot unregister its /// replacement or leave a disposed controller reachable from session state. @@ -28,6 +33,16 @@ class BusyMarkClipboardInsertionRegistry extends ChangeNotifier { BusyMarkClipboardInsertionTarget? get target => _target; + bool canPaste(BusyMarkClipboardPayload payload, {bool plainText = false}) { + final captured = _target; + if (captured == null || !captured.editable) return false; + if (captured is BusyMarkClipboardInsertionCapabilities) { + final capabilities = captured as BusyMarkClipboardInsertionCapabilities; + return capabilities.canPaste(payload, plainText: plainText); + } + return true; + } + void register(BusyMarkClipboardInsertionTarget target) { if (identical(_target, target)) return; _target = target; @@ -60,7 +75,13 @@ class BusyMarkClipboardInsertionRegistry extends ChangeNotifier { bool plainText = false, }) async { final captured = _target; - if (captured == null || !captured.editable) { + if (captured == null || + !captured.editable || + (captured is BusyMarkClipboardInsertionCapabilities && + !(captured as BusyMarkClipboardInsertionCapabilities).canPaste( + payload, + plainText: plainText, + ))) { return ClipboardPasteResult.unavailable; } final result = await captured.paste(payload, plainText: plainText); diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 04be8b88..9710de6c 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart' show setEquals; @@ -15,6 +16,7 @@ import '../../app/busymark_shortcuts.dart'; import '../../app/busymark_toast.dart'; import '../../app/command_registry.dart'; import '../../app/localization.dart'; +import '../../assets/asset_ingestion_service.dart'; import '../../clipboard/clipboard_insertion.dart'; import '../../clipboard/clipboard_models.dart'; import '../../core/diagnostic.dart'; @@ -22,6 +24,7 @@ import '../../platform/rich_clipboard_service.dart'; import '../../search/search_replace_service.dart'; import '../document_text_geometry.dart'; import '../editor_text_context_menu.dart'; +import '../wysiwyg/wysiwyg_clipboard_fragment.dart'; import '../source_folding.dart'; import 'source_commands.dart'; import 'source_controller.dart'; @@ -88,6 +91,12 @@ class BusyMarkSourceEditor extends StatefulWidget { this.clipboardService, this.clipboardInsertionRegistry, this.onClipboardCaptured, + this.workspaceRoot, + this.writersideRoot, + this.imagesDir = 'images', + this.assetWorkspaceKind, + this.assetIngestionService = const AssetIngestionService(), + this.onAssetSaveRequired, }); final String text; @@ -120,6 +129,12 @@ class BusyMarkSourceEditor extends StatefulWidget { final RichClipboardService? clipboardService; final BusyMarkClipboardInsertionRegistry? clipboardInsertionRegistry; final ValueChanged? onClipboardCaptured; + final String? workspaceRoot; + final String? writersideRoot; + final String imagesDir; + final AssetWorkspaceKind? assetWorkspaceKind; + final AssetIngestionService assetIngestionService; + final VoidCallback? onAssetSaveRequired; @override State createState() => BusyMarkSourceEditorState(); @@ -1695,11 +1710,113 @@ class BusyMarkSourceEditorState extends State { return ClipboardPasteResult.unsupported; } final target = _captureClipboardTarget(); + if (!plainText && payload.richFragment != null) { + final decoded = WysiwygClipboardFragment.decode(payload.richFragment!); + if (decoded != null) { + var fragment = decoded.rebase(widget.filePath ?? ''); + var assets = const []; + if (decoded.mediaPaths.isNotEmpty) { + if (!payload.mediaComplete) return ClipboardPasteResult.unsupported; + final prepared = await _prepareRetainedClipboardMedia( + decoded, + payload, + target, + ); + if (prepared == null) { + return _isClipboardTargetCurrent(target) + ? ClipboardPasteResult.unsupported + : ClipboardPasteResult.staleTarget; + } + fragment = prepared.fragment; + assets = prepared.assets; + } + final result = _insertClipboardText(target, fragment.markdown); + if (result != ClipboardPasteResult.inserted) { + await _deleteUncommittedClipboardAssets(assets); + } + return result; + } + } final text = plainText ? payload.text : payload.preferredSourceText; if (text == null) return ClipboardPasteResult.unsupported; return _insertClipboardText(target, text); } + Future<({WysiwygClipboardFragment fragment, List assets})?> + _prepareRetainedClipboardMedia( + WysiwygClipboardFragment fragment, + BusyMarkClipboardPayload payload, + _SourceClipboardOperationTarget target, + ) async { + final destinations = {}; + final assets = []; + try { + for (final entry in fragment.mediaPaths.entries) { + final bytes = payload.mediaBytes[entry.key]; + if (bytes == null || bytes.isEmpty) { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + final asset = await widget.assetIngestionService.ingestBytes( + bytes: bytes, + suggestedFileName: p.basename(entry.value), + request: _assetIngestionRequest, + origin: AssetIngestionOrigin.clipboardImageFile, + ); + assets.add(asset); + if (!_isClipboardTargetCurrent(target)) { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + destinations[entry.key] = asset.markdownPath; + } + return ( + fragment: fragment.rebase( + widget.filePath ?? '', + mediaDestinations: destinations, + ), + assets: List.unmodifiable(assets), + ); + } on AssetSaveRequiredException { + await _deleteUncommittedClipboardAssets(assets); + widget.onAssetSaveRequired?.call(); + return null; + } on AssetIngestionException { + await _deleteUncommittedClipboardAssets(assets); + return null; + } on FileSystemException { + await _deleteUncommittedClipboardAssets(assets); + return null; + } + } + + AssetIngestionRequest get _assetIngestionRequest => AssetIngestionRequest( + documentFilePath: widget.filePath ?? '', + workspaceKind: + widget.assetWorkspaceKind ?? + (widget.writersideRoot != null + ? AssetWorkspaceKind.writerside + : widget.workspaceRoot != null + ? AssetWorkspaceKind.markdownWorkspace + : AssetWorkspaceKind.standalone), + workspaceRoot: widget.workspaceRoot, + writersideRoot: widget.writersideRoot, + imagesDir: widget.imagesDir, + ); + + Future _deleteUncommittedClipboardAssets( + Iterable assets, + ) async { + for (final asset in assets) { + if (asset.reusedExisting) continue; + try { + await File(asset.absolutePath).delete(); + } on FileSystemException { + // Failed cancellation cleanup must not make the editor unusable. + } + } + } + ClipboardPasteResult _insertClipboardText( _SourceClipboardOperationTarget target, String text, @@ -2684,7 +2801,9 @@ class _SourceClipboardOperationTarget { } class _SourceClipboardInsertionTarget - implements BusyMarkClipboardInsertionTarget { + implements + BusyMarkClipboardInsertionTarget, + BusyMarkClipboardInsertionCapabilities { const _SourceClipboardInsertionTarget(this.state); final BusyMarkSourceEditorState state; @@ -2707,6 +2826,16 @@ class _SourceClipboardInsertionTarget @override bool get editable => state.mounted && !state._hasActiveComposition; + @override + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) { + if (!editable || payload.kind == BusyMarkClipboardContentKind.image) { + return false; + } + if (plainText) return payload.hasMeaningfulTextRepresentation; + if (payload.richFragment != null) return payload.mediaComplete; + return payload.preferredSourceText != null; + } + @override Future paste( BusyMarkClipboardPayload payload, { diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index ceb537d5..e8f41000 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -139,9 +139,11 @@ class WysiwygClipboardFragment { String targetPath, { Map mediaDestinations = const {}, }) { - if (sourcePath.isEmpty || - targetPath.isEmpty || - p.equals(sourcePath, targetPath)) { + final rebaseRelativeReferences = + sourcePath.isNotEmpty && + targetPath.isNotEmpty && + !p.equals(sourcePath, targetPath); + if (!rebaseRelativeReferences && mediaDestinations.isEmpty) { return this; } String? destination(String? value, {bool media = false}) { @@ -154,6 +156,7 @@ class WysiwygClipboardFragment { return Uri(path: resolved).toString(); } } + if (!rebaseRelativeReferences) return value; final uri = Uri.tryParse(value); if (uri == null || uri.hasScheme || uri.hasAuthority) return value; final resolved = Uri.file(p.absolute(sourcePath)).resolveUri(uri); @@ -199,7 +202,7 @@ class WysiwygClipboardFragment { ); return WysiwygClipboardFragment( mode: mode, - sourcePath: targetPath, + sourcePath: targetPath.isEmpty ? sourcePath : targetPath, mediaPaths: mediaPaths, blocks: [ for (final value in blocks) diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 3c79bee4..11e00ce0 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -4160,7 +4160,7 @@ class _BusyMarkWysiwygEditorState extends State { text: text, sourceText: source, html: data.html, - richFragment: data.richFragment, + richFragment: fragment?.encode() ?? data.richFragment, origin: _clipboardOrigin, external: true, ), @@ -6838,7 +6838,9 @@ class _ClipboardTarget { } class _WysiwygClipboardInsertionTarget - implements BusyMarkClipboardInsertionTarget { + implements + BusyMarkClipboardInsertionTarget, + BusyMarkClipboardInsertionCapabilities { const _WysiwygClipboardInsertionTarget(this.state); final _BusyMarkWysiwygEditorState state; @@ -6861,6 +6863,23 @@ class _WysiwygClipboardInsertionTarget @override bool get editable => state.mounted; + @override + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) { + if (!editable) return false; + if (plainText) return payload.hasMeaningfulTextRepresentation; + return switch (payload.kind) { + BusyMarkClipboardContentKind.image => + payload.imageBytes?.isNotEmpty == true, + BusyMarkClipboardContentKind.richText => + payload.mediaComplete && + (payload.richFragment != null || + payload.text != null || + payload.sourceText != null), + BusyMarkClipboardContentKind.text => + payload.text != null || payload.sourceText != null, + }; + } + @override Future paste( BusyMarkClipboardPayload payload, { diff --git a/lib/src/git/application/git_controller.dart b/lib/src/git/application/git_controller.dart index f18894b9..5fa5353a 100644 --- a/lib/src/git/application/git_controller.dart +++ b/lib/src/git/application/git_controller.dart @@ -813,17 +813,7 @@ class GitController extends Notifier { } Future restoreSelectedFileVersion() async { - if (ref.read(workspaceControllerProvider).hasUnsavedChanges) { - state = state.copyWith( - lastError: const GitFailure( - code: GitFailureCode.dirtyWorkspace, - userMessageKey: 'gitErrorDirtyWorkspace', - rawMessage: '', - commandName: 'restore', - ), - ); - return false; - } + if (_rejectDirtyWorkspaceRestore()) return false; if (selectedFileHasStagedChanges) { state = state.copyWith( lastError: const GitFailure( @@ -846,6 +836,18 @@ class GitController extends Notifier { currentPath == null) { return false; } + final absoluteCurrentPath = p.isAbsolute(currentPath) + ? p.normalize(currentPath) + : p.normalize(p.join(operation.repository.rootPath, currentPath)); + final protected = await ref + .read(workspaceControllerProvider.notifier) + .protectPathsBeforeExternalReplacement([absoluteCurrentPath]); + if (!protected) { + _rejectDirtyWorkspaceRestore(); + return false; + } + if (_rejectDirtyWorkspaceRestore()) return false; + if (!_isCurrentRepositoryOperation(operation)) return false; state = state.copyWith( isRunningOperation: true, lastError: null, @@ -883,6 +885,19 @@ class GitController extends Notifier { } } + bool _rejectDirtyWorkspaceRestore() { + if (!ref.read(workspaceControllerProvider).hasUnsavedChanges) return false; + state = state.copyWith( + lastError: const GitFailure( + code: GitFailureCode.dirtyWorkspace, + userMessageKey: 'gitErrorDirtyWorkspace', + rawMessage: '', + commandName: 'restore', + ), + ); + return true; + } + Future resetCurrentBranchToSelectedCommit(GitResetMode mode) async { final project = state.projectHistory; final hash = project.selectedCommitHash; diff --git a/lib/src/git/presentation/git_sidebar_tab.dart b/lib/src/git/presentation/git_sidebar_tab.dart index dc127242..387f63a2 100644 --- a/lib/src/git/presentation/git_sidebar_tab.dart +++ b/lib/src/git/presentation/git_sidebar_tab.dart @@ -242,13 +242,6 @@ class GitSidebarTab extends ConsumerWidget { if (confirmed != true || !context.mounted) { return; } - final path = ref.read(gitControllerProvider).fileHistory.currentPath; - if (path == null || - !await ref - .read(workspaceControllerProvider.notifier) - .protectPathsBeforeExternalReplacement([path])) { - return; - } if (await controller.restoreSelectedFileVersion()) { await onAfterWorkspaceFilesChanged(); } diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index 13793097..68e7f2e6 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -10,9 +10,7 @@ import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/localization.dart'; import '../comparison/source_comparison.dart'; -import '../workspace/document_buffer.dart'; import '../workspace/workspace_controller.dart'; -import '../workspace/workspace_model.dart'; import '../workspace/workspace_safety.dart'; import 'local_history_controller.dart'; import 'local_history_models.dart'; @@ -33,13 +31,17 @@ class _LocalHistoryComparisonViewState @override Widget build(BuildContext context) { final history = ref.watch(localHistoryControllerProvider); - final workspace = ref.watch(workspaceControllerProvider); + ref.watch(workspaceControllerProvider); final document = history.selectedDocument; final revision = history.selectedRevision; - if (document == null || revision == null) { + if (document == null || + revision == null || + revision.summary.documentId != document.id) { return const SizedBox.shrink(); } - final matchingBuffer = _bufferForDocument(workspace, document, revision); + final matchingBuffer = ref + .read(workspaceControllerProvider.notifier) + .localHistoryBufferForDocument(document, revision); final key = [ revision.summary.id, matchingBuffer?.id, @@ -100,22 +102,6 @@ class _LocalHistoryComparisonViewState ); } - DocumentBuffer? _bufferForDocument( - WorkspaceState state, - LocalHistoryDocument document, - LocalHistoryRevision revision, - ) { - final paths = { - document.currentPath, - revision.summary.historicalPath, - }.whereType(); - for (final path in paths) { - final buffer = state.bufferForPath(path); - if (buffer != null) return buffer; - } - return null; - } - Future<_ComparisonSnapshot?> _buildSnapshot( LocalHistoryDocument document, LocalHistoryRevision revision, diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index da24ec98..f97998a3 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -362,20 +362,28 @@ class LocalHistoryController extends Notifier { revision: source.revision, path: destinationPath, ); - if (source.untitled && !destinationExisted) { - return _enqueue( - source.bufferId, - () => _capture(destination, LocalHistoryCaptureReason.saved), - ); - } - final captured = await _enqueue( - source.bufferId, - () => _capture( + final captured = await _enqueue(source.bufferId, () async { + // A checkpoint created before Save As belongs to the source lineage. + // Settle it before changing this buffer's binding. Edits made after the + // workspace switched to the destination keep their destination snapshot + // and timer and will run after the binding changes below. + final pending = _pending[source.bufferId]; + if (pending != null && _sameOptionalPath(pending.path, source.path)) { + _pending.remove(source.bufferId); + _checkpointTimers.remove(source.bufferId)?.cancel(); + if (!await _capture( + pending, + LocalHistoryCaptureReason.automaticCheckpoint, + )) { + return false; + } + } + return _capture( destination, LocalHistoryCaptureReason.saved, - ignoreBinding: true, - ), - ); + ignoreBinding: !source.untitled || destinationExisted, + ); + }); if (captured) { final document = state.snapshot.documents .where( @@ -385,6 +393,11 @@ class LocalHistoryController extends Notifier { ) .firstOrNull; if (document != null) _documentIdsByBuffer[source.bufferId] = document.id; + } else if (!source.untitled || destinationExisted) { + // The filesystem transition already succeeded. Do not let a later + // checkpoint reuse the source document identity if history was disabled, + // excluded, or temporarily unavailable during this Save As. + _documentIdsByBuffer.remove(source.bufferId); } return captured; } @@ -454,6 +467,32 @@ class LocalHistoryController extends Notifier { Future remapPath(String sourcePath, String destinationPath) async { try { + // Observe-edit callbacks are queued per buffer. Drain them, then capture + // pending source-path snapshots before the store remaps their stable + // document identities. New edits already see the remapped workspace + // buffer path and remain pending for the destination. + while (_bufferQueues.isNotEmpty) { + await Future.wait(_bufferQueues.values.toList(growable: false)); + } + final affected = >[]; + for (final entry in _pending.entries) { + final path = entry.value.path; + if (path != null && + (p.equals(path, sourcePath) || p.isWithin(sourcePath, path))) { + affected.add(entry); + } + } + for (final entry in affected) { + _pending.remove(entry.key); + _checkpointTimers.remove(entry.key)?.cancel(); + await _enqueue( + entry.key, + () => _capture( + entry.value, + LocalHistoryCaptureReason.automaticCheckpoint, + ), + ); + } await _store.remapPath(sourcePath, destinationPath); await refresh(); } on Object catch (error) { @@ -500,6 +539,7 @@ class LocalHistoryController extends Notifier { )) { return; } + _loadGeneration++; state = state.copyWith( selectedDocumentId: documentId, selectedRevisionId: null, @@ -515,6 +555,7 @@ class LocalHistoryController extends Notifier { /// No current editor is implied: closed, renamed, untitled, and deleted /// documents remain first-class results. void beginDocumentSearch() { + _loadGeneration++; _searchGeneration++; state = state.copyWith( selectedDocumentId: null, @@ -556,7 +597,13 @@ class LocalHistoryController extends Notifier { ); try { final revision = await _store.readRevision(revisionId); - if (!ref.mounted || generation != _loadGeneration) return; + if (!ref.mounted || + generation != _loadGeneration || + state.selectedDocumentId != documentId || + state.selectedRevisionId != revisionId || + (revision != null && revision.summary.documentId != documentId)) { + return; + } state = state.copyWith( selectedRevision: revision, loading: false, @@ -565,7 +612,12 @@ class LocalHistoryController extends Notifier { : null, ); } on Object catch (error) { - if (!ref.mounted || generation != _loadGeneration) return; + if (!ref.mounted || + generation != _loadGeneration || + state.selectedDocumentId != documentId || + state.selectedRevisionId != revisionId) { + return; + } state = state.copyWith( loading: false, warning: LocalHistoryWarning( @@ -577,9 +629,15 @@ class LocalHistoryController extends Notifier { } void clearComparison() { + _loadGeneration++; state = state.copyWith(selectedRevisionId: null, selectedRevision: null); } + String? bufferIdForDocument(String documentId) => _documentIdsByBuffer.entries + .where((entry) => entry.value == documentId) + .map((entry) => entry.key) + .firstOrNull; + Future search(String query) async { final normalized = query; final generation = ++_searchGeneration; @@ -716,6 +774,11 @@ class LocalHistoryController extends Notifier { } } +bool _sameOptionalPath(String? first, String? second) { + if (first == null || second == null) return first == second; + return p.equals(first, second); +} + extension on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/src/platform/rich_clipboard_service.dart b/lib/src/platform/rich_clipboard_service.dart index 4f308cac..5e531efe 100644 --- a/lib/src/platform/rich_clipboard_service.dart +++ b/lib/src/platform/rich_clipboard_service.dart @@ -1,4 +1,8 @@ import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:uuid/uuid.dart'; @@ -7,6 +11,8 @@ import '../clipboard/clipboard_models.dart'; const richClipboardChannelName = 'com.busymark.app/rich_clipboard'; const busyMarkClipboardTokenMimeType = 'application/x-busymark-token'; const maxRichClipboardBytes = 16 * 1024 * 1024; +const maxRichClipboardOwnershipBytes = 64 * 1024 * 1024; +const maxRichClipboardOwnershipEntries = 128; class RichClipboardData { const RichClipboardData({ @@ -55,14 +61,34 @@ class RichClipboardService { RichClipboardService({ MethodChannel channel = const MethodChannel(richClipboardChannelName), String Function()? createToken, + int maximumOwnershipBytes = maxRichClipboardOwnershipBytes, }) : _channel = channel, - _createToken = createToken ?? const Uuid().v4; + _createToken = createToken ?? const Uuid().v4, + _maximumOwnershipBytes = maximumOwnershipBytes, + assert(maximumOwnershipBytes > 0); final MethodChannel _channel; final String Function() _createToken; - final Map _knownPayloads = {}; + final int _maximumOwnershipBytes; + final Map _knownPayloads = {}; Future? _pendingWrite; + @visibleForTesting + int get retainedOwnershipBytes => _uniqueOwnershipBytes(); + + @visibleForTesting + int get retainedOwnershipEntries => _knownPayloads.length; + + /// Releases obsolete process-owned representations while preserving the + /// newest token so an ordinary paste of the current clipboard remains rich. + void discardObsoleteOwnership() { + if (_knownPayloads.length <= 1) return; + final newest = _knownPayloads.entries.last; + _knownPayloads + ..clear() + ..[newest.key] = newest.value; + } + Future write(RichClipboardData data) { final result = _pendingWrite?.then((_) => _write(data)) ?? _write(data); final pending = result.then((_) {}); @@ -116,7 +142,7 @@ class RichClipboardService { ? value!['generation'] as int : null; final token = field('token'); - final known = token == null ? null : _knownPayloads[token]; + final known = token == null ? null : _knownPayloads[token]?.data; if (known == null) { return RichClipboardData( text: field('text'), @@ -147,24 +173,34 @@ class RichClipboardService { } void _remember(String token, RichClipboardData data) { - _knownPayloads[token] = RichClipboardData( - text: data.text, - html: data.html, - sourceText: data.sourceText, - richFragment: data.richFragment, - sessionOwned: true, - token: token, - origin: data.origin, - mediaBytes: _copyMediaBytes(data.mediaBytes), - mediaComplete: data.mediaComplete, - ); - // Tokens only identify current-session immutable payloads. Bound stale - // ownership records independently from Clipboard History retention. - while (_knownPayloads.length > 128) { + _OwnedRichClipboardData? owned; + for (final candidate in _knownPayloads.values) { + if (candidate.equivalentTo(data)) { + owned = candidate; + break; + } + } + owned ??= _OwnedRichClipboardData.copyOf(data); + _knownPayloads[token] = owned; + // Preserve the newest/current token. Obsolete ownership data is bounded by + // both token count and unique payload bytes. Equivalent repeated copies + // share one immutable media snapshot instead of copying it per token. + while (_knownPayloads.length > 1 && + (_knownPayloads.length > maxRichClipboardOwnershipEntries || + _uniqueOwnershipBytes() > _maximumOwnershipBytes)) { _knownPayloads.remove(_knownPayloads.keys.first); } } + int _uniqueOwnershipBytes() { + final unique = HashSet<_OwnedRichClipboardData>.identity(); + var bytes = 0; + for (final value in _knownPayloads.values) { + if (unique.add(value)) bytes += value.accountedBytes; + } + return bytes; + } + Future readPlainText() async { await _pendingWrite; try { @@ -181,6 +217,65 @@ class RichClipboardService { } } +class _OwnedRichClipboardData { + _OwnedRichClipboardData.copyOf(RichClipboardData source) + : data = RichClipboardData( + text: source.text, + html: source.html, + sourceText: source.sourceText, + richFragment: source.richFragment, + sessionOwned: true, + origin: source.origin, + mediaBytes: _copyMediaBytes(source.mediaBytes), + mediaComplete: source.mediaComplete, + ), + accountedBytes = _ownershipBytes(source); + + final RichClipboardData data; + final int accountedBytes; + + bool equivalentTo(RichClipboardData other) => + data.text == other.text && + data.html == other.html && + data.sourceText == other.sourceText && + data.richFragment == other.richFragment && + data.origin?.documentId == other.origin?.documentId && + data.origin?.documentName == other.origin?.documentName && + data.origin?.documentPath == other.origin?.documentPath && + data.mediaComplete == other.mediaComplete && + _sameMediaBytes(data.mediaBytes, other.mediaBytes); +} + +int _ownershipBytes(RichClipboardData data) { + var bytes = 0; + for (final value in [ + data.text, + data.html, + data.sourceText, + data.richFragment, + data.origin?.documentId, + data.origin?.documentName, + data.origin?.documentPath, + ]) { + if (value != null) bytes += utf8.encode(value).length; + } + for (final entry in data.mediaBytes.entries) { + bytes += utf8.encode(entry.key).length + entry.value.lengthInBytes; + } + return bytes; +} + +bool _sameMediaBytes( + Map first, + Map second, +) { + if (first.length != second.length) return false; + for (final entry in first.entries) { + if (!listEquals(entry.value, second[entry.key])) return false; + } + return true; +} + Map _copyMediaBytes(Map values) => Map.unmodifiable({ for (final entry in values.entries) diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 3fbd4de9..6eb04d30 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -10224,6 +10224,31 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { onClipboardCaptured: ref .read(clipboardHistoryControllerProvider.notifier) .retain, + workspaceRoot: _imageWorkspaceRoot( + widget.state.workspace, + ), + writersideRoot: + widget.state.workspace?.writersideModule?.rootPath, + imagesDir: + widget + .state + .workspace + ?.writersideModule + ?.effectiveImagesDir ?? + 'images', + assetWorkspaceKind: + switch (widget.state.workspace?.kind) { + WorkspaceKind.writersideModule => + AssetWorkspaceKind.writerside, + WorkspaceKind.markdownFolder => + AssetWorkspaceKind.markdownWorkspace, + WorkspaceKind.singleMarkdown => + AssetWorkspaceKind.standalone, + WorkspaceKind.untitledMarkdown || + null => AssetWorkspaceKind.standalone, + }, + onAssetSaveRequired: () => + unawaited(saveActiveToNewLocation(context, ref)), diagnostics: widget.state.workspace?.allDiagnostics ?? const [], diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 1feae15e..51b3ed9d 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -680,18 +680,26 @@ class WorkspaceController extends Notifier { } if (event.kind == WorkspaceFileEventKind.deleted && !await _service.pathExists(path)) { + var latest = _externalOperationBuffer(current, path); + if (latest == null) return; await _localHistory.captureBeforeLoss( - LocalHistoryBufferSnapshot.fromBuffer(current), + LocalHistoryBufferSnapshot.fromBuffer(latest), LocalHistoryCaptureReason.beforeDelete, ); + latest = _externalOperationBuffer(current, path); + if (latest == null) return; await _localHistory.markDeleted(path, recursive: false); + latest = _externalOperationBuffer(current, path); + if (latest == null) return; _updateBufferFromMonitor( - current.copyWith(diskState: DocumentDiskState.deleted), + latest.copyWith(diskState: DocumentDiskState.deleted), ); return; } try { final disk = await _service.loadTextWithSnapshot(path); + var latest = _externalOperationBuffer(current, path); + if (latest == null) return; if (_sameFileSnapshot(current.diskSnapshot, disk.snapshot)) { return; } @@ -701,13 +709,15 @@ class WorkspaceController extends Notifier { format: disk.format, reason: LocalHistoryCaptureReason.externalChange, ); - if (current.isDirty) { + latest = _externalOperationBuffer(current, path); + if (latest == null) return; + if (latest.isDirty) { _localHistory.observeEdit( - current.copyWith(text: current.lastSavedText), - current, + latest.copyWith(text: latest.lastSavedText), + latest, ); _updateBufferFromMonitor( - current.copyWith( + latest.copyWith( diskState: DocumentDiskState.conflict, diskVersionText: disk.text, diskVersionSnapshot: disk.snapshot, @@ -715,13 +725,13 @@ class WorkspaceController extends Notifier { ); return; } - final reloaded = current.copyWith( + final reloaded = latest.copyWith( text: disk.text, lastSavedText: disk.text, dirty: false, diskSnapshot: disk.snapshot, format: disk.format, - revision: current.revision + 1, + revision: latest.revision + 1, diskState: DocumentDiskState.present, diskVersionText: null, diskVersionSnapshot: null, @@ -742,15 +752,34 @@ class WorkspaceController extends Notifier { } } } on FileSystemException { + final latest = _externalOperationBuffer(current, path); + if (latest == null) return; await _localHistory.markDeleted(path, recursive: false); + final afterHistory = _externalOperationBuffer(current, path); + if (afterHistory == null) return; _updateBufferFromMonitor( - current.copyWith(diskState: DocumentDiskState.deleted), + afterHistory.copyWith(diskState: DocumentDiskState.deleted), ); } on FormatException { // Invalid UTF-8 remains on disk and must not replace an editable buffer. } } + DocumentBuffer? _externalOperationBuffer( + DocumentBuffer anchor, + String expectedPath, + ) { + final current = state.documentBuffers + .where((buffer) => buffer.id == anchor.id) + .firstOrNull; + if (current == null || + current.filePath != expectedPath || + !_sameFileSnapshot(current.diskSnapshot, anchor.diskSnapshot)) { + return null; + } + return current; + } + Future _applyExternalMove( DocumentBuffer current, String destinationPath, @@ -761,11 +790,13 @@ class WorkspaceController extends Notifier { } try { final disk = await _service.loadTextWithSnapshot(destinationPath); + if (_externalOperationBuffer(current, oldPath) == null) return; final contentChanged = !_sameFileSnapshot( current.diskSnapshot, disk.snapshot, ); await _localHistory.remapPath(oldPath, destinationPath); + if (_externalOperationBuffer(current, oldPath) == null) return; if (contentChanged) { await _localHistory.capturePath( path: destinationPath, @@ -774,27 +805,27 @@ class WorkspaceController extends Notifier { reason: LocalHistoryCaptureReason.externalChange, ); } - final remapped = current.copyWith( + final latest = _externalOperationBuffer(current, oldPath); + if (latest == null) return; + final remapped = latest.copyWith( filePath: destinationPath, - text: current.isDirty || !contentChanged ? current.text : disk.text, - lastSavedText: contentChanged && !current.isDirty + text: latest.isDirty || !contentChanged ? latest.text : disk.text, + lastSavedText: contentChanged && !latest.isDirty ? disk.text - : current.lastSavedText, - dirty: current.isDirty, - diskSnapshot: contentChanged && !current.isDirty + : latest.lastSavedText, + dirty: latest.isDirty, + diskSnapshot: contentChanged && !latest.isDirty ? disk.snapshot - : current.diskSnapshot, - format: contentChanged && !current.isDirty - ? disk.format - : current.format, - revision: contentChanged && !current.isDirty - ? current.revision + 1 - : current.revision, - diskState: current.isDirty && contentChanged + : latest.diskSnapshot, + format: contentChanged && !latest.isDirty ? disk.format : latest.format, + revision: contentChanged && !latest.isDirty + ? latest.revision + 1 + : latest.revision, + diskState: latest.isDirty && contentChanged ? DocumentDiskState.conflict : DocumentDiskState.present, - diskVersionText: current.isDirty && contentChanged ? disk.text : null, - diskVersionSnapshot: current.isDirty && contentChanged + diskVersionText: latest.isDirty && contentChanged ? disk.text : null, + diskVersionSnapshot: latest.isDirty && contentChanged ? disk.snapshot : null, ); @@ -805,7 +836,7 @@ class WorkspaceController extends Notifier { for (final openPath in workspace.openFilePaths) p.equals(openPath, oldPath) ? destinationPath : openPath, ]; - final active = state.activeBufferId == current.id; + final active = state.activeBufferId == latest.id; state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, remapped), workspace: workspace?.copyWith( @@ -826,7 +857,7 @@ class WorkspaceController extends Notifier { state.workspace!, remapped.text, ); - if (state.activeBufferId == current.id && + if (state.activeBufferId == latest.id && state.activeBuffer?.filePath == destinationPath) { state = state.copyWith( workspace: reparsed.copyWith( @@ -840,9 +871,12 @@ class WorkspaceController extends Notifier { } _schedulePersistence(); } on FileSystemException { - _updateBufferFromMonitor( - current.copyWith(diskState: DocumentDiskState.deleted), - ); + final latest = _externalOperationBuffer(current, oldPath); + if (latest != null) { + _updateBufferFromMonitor( + latest.copyWith(diskState: DocumentDiskState.deleted), + ); + } } on FormatException { // Keep the old buffer and path when the move target cannot be decoded. } @@ -866,13 +900,16 @@ class WorkspaceController extends Notifier { if (buffer == null || path == null || !await _service.pathExists(path)) { return false; } + if (!_bufferOperationTargetIsCurrent(buffer)) return false; if (!await _localHistory.captureBeforeLoss( LocalHistoryBufferSnapshot.fromBuffer(buffer), LocalHistoryCaptureReason.beforeReload, )) { return false; } + if (!_bufferOperationTargetIsCurrent(buffer)) return false; final disk = await _service.loadTextWithSnapshot(path); + if (!_bufferOperationTargetIsCurrent(buffer)) return false; await _localHistory.capturePath( path: path, text: disk.text, @@ -880,6 +917,7 @@ class WorkspaceController extends Notifier { reason: LocalHistoryCaptureReason.externalChange, force: false, ); + if (!_bufferOperationTargetIsCurrent(buffer)) return false; final reloaded = buffer.copyWith( text: disk.text, lastSavedText: disk.text, @@ -898,6 +936,15 @@ class WorkspaceController extends Notifier { state.workspace!.copyWith(activeFileSnapshot: disk.snapshot), disk.text, ); + final current = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (current == null || + current.filePath != path || + current.revision != reloaded.revision || + current.text != reloaded.text) { + return false; + } state = state.copyWith( workspace: workspace, preview: _safePreview(workspace, disk.text), @@ -907,6 +954,16 @@ class WorkspaceController extends Notifier { return true; } + bool _bufferOperationTargetIsCurrent(DocumentBuffer target) { + final current = state.documentBuffers + .where((buffer) => buffer.id == target.id) + .firstOrNull; + return current != null && + current.filePath == target.filePath && + current.revision == target.revision && + current.text == target.text; + } + void keepBufferVersion(String bufferId) { final buffer = state.documentBuffers .where((candidate) => candidate.id == bufferId) @@ -1400,6 +1457,8 @@ class WorkspaceController extends Notifier { 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; } @@ -1414,6 +1473,8 @@ class WorkspaceController extends Notifier { 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; } @@ -3036,8 +3097,9 @@ class WorkspaceController extends Notifier { SourceComparison? comparison, SourceComparisonChange? change, }) async { + if (revision.summary.documentId != document.id) return false; final path = document.currentPath ?? revision.summary.historicalPath; - var buffer = path == null ? null : state.bufferForPath(path); + var buffer = localHistoryBufferForDocument(document, revision); if (buffer == null && path != null && await _service.pathExists(path)) { if (!await _openActiveFile(path)) return false; buffer = state.activeBuffer; @@ -3104,21 +3166,24 @@ class WorkspaceController extends Notifier { LocalHistoryDocument document, LocalHistoryRevision revision, ) async { - final paths = { - document.currentPath, - revision.summary.historicalPath, - }.whereType(); - for (final path in paths) { - final buffer = state.bufferForPath(path); - if (buffer != null) { - return LocalHistoryCurrentSourceSnapshot( - kind: LocalHistoryCurrentSourceKind.editor, - id: buffer.id, - version: buffer.revision, - source: buffer.text, - canRestore: true, - ); - } + if (revision.summary.documentId != document.id) { + return LocalHistoryCurrentSourceSnapshot( + kind: LocalHistoryCurrentSourceKind.missing, + id: 'mismatch:${document.id}', + version: 0, + source: '', + canRestore: false, + ); + } + final buffer = localHistoryBufferForDocument(document, revision); + if (buffer != null) { + return LocalHistoryCurrentSourceSnapshot( + kind: LocalHistoryCurrentSourceKind.editor, + id: buffer.id, + version: buffer.revision, + source: buffer.text, + canRestore: true, + ); } final path = document.currentPath ?? revision.summary.historicalPath; if (path != null && await _service.pathExists(path)) { @@ -3146,6 +3211,7 @@ class WorkspaceController extends Notifier { required String destinationPath, required bool overwriteExisting, }) async { + if (revision.summary.documentId != document.id) return false; final path = p.normalize(p.absolute(destinationPath)); final policy = _localHistory.policy; if (!policy.recordingEnabled || policy.excludes(path)) { @@ -3238,6 +3304,29 @@ class WorkspaceController extends Notifier { return _service.pathExists(p.normalize(p.absolute(path))); } + DocumentBuffer? localHistoryBufferForDocument( + LocalHistoryDocument document, + LocalHistoryRevision revision, + ) { + if (revision.summary.documentId != document.id) return null; + 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; + } + Future _rollbackMissingHistoryDestination( String path, WorkspaceFileLoad? previous, @@ -3274,6 +3363,7 @@ class WorkspaceController extends Notifier { )) { return false; } + if (!_bufferOperationTargetIsCurrent(buffer)) return false; continue; } try { @@ -3286,6 +3376,9 @@ class WorkspaceController extends Notifier { )) { return false; } + if (await _service.fileChangedSince(path, loaded.snapshot)) { + return false; + } } on FileSystemException { // A path already absent from the working tree has no current source // version to protect. diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart new file mode 100644 index 00000000..ca977592 --- /dev/null +++ b/test/src/clipboard_history_panel_test.dart @@ -0,0 +1,223 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/l10n/generated/app_localizations_en.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_toast.dart'; +import 'package:busymark/src/assets/asset_input_service.dart'; +import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; +import 'package:busymark/src/clipboard/clipboard_history_panel.dart'; +import 'package:busymark/src/clipboard/clipboard_insertion.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('keyboard navigation keeps the selected history entry visible', ( + tester, + ) async { + final container = _container(); + final history = container.read(clipboardHistoryControllerProvider.notifier); + for (var index = 0; index < 24; index++) { + history.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'entry $index', + sourceText: 'entry $index', + ), + ); + } + container + .read(clipboardInsertionRegistryProvider) + .register(_PanelInsertionTarget()); + await _pumpPanel(tester, container); + + await tester.tap(find.text('entry 23')); + await tester.pump(); + for (var index = 0; index < 14; index++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + } + + final selected = find.text('entry 9'); + expect(selected, findsOneWidget); + final selectedRect = tester.getRect(selected); + final listRect = tester.getRect( + find.byKey(const ValueKey('clipboard-history-list')), + ); + expect(selectedRect.top, greaterThanOrEqualTo(listRect.top)); + expect(selectedRect.bottom, lessThanOrEqualTo(listRect.bottom)); + }); + + testWidgets('unsupported payload disables paste for the destination', ( + tester, + ) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.image, + imageBytes: Uint8List.fromList([1, 2, 3]), + imageMimeType: 'image/png', + imageDisplayName: 'image.png', + ), + ); + container + .read(clipboardInsertionRegistryProvider) + .register(_PanelInsertionTarget(supportImages: false)); + await _pumpPanel(tester, container); + + final pasteButton = find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ); + final button = tester.widget(pasteButton); + expect(button.onPressed, isNull); + }); + + testWidgets('failed insertion reports visible feedback', (tester) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'stale item', + sourceText: 'stale item', + ), + ); + final target = _PanelInsertionTarget( + result: ClipboardPasteResult.staleTarget, + ); + container.read(clipboardInsertionRegistryProvider).register(target); + await _pumpPanel(tester, container); + + final pasteButton = find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ); + final button = tester.widget(pasteButton); + expect(button.onPressed, isNotNull); + button.onPressed!.call(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(target.pasteCalls, 1); + expect( + find.text(AppLocalizationsEn().clipboardUnavailable), + findsOneWidget, + ); + }); +} + +ProviderContainer _container() { + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + richClipboardServiceProvider.overrideWithValue(_EmptyClipboard()), + clipboardAssetInputServiceProvider.overrideWithValue(_EmptyAssetInput()), + ], + ); + addTearDown(container.dispose); + container.read(clipboardHistoryControllerProvider.notifier); + return container; +} + +Future _pumpPanel( + WidgetTester tester, + ProviderContainer container, +) async { + tester.view.physicalSize = const Size(600, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => BusyMarkToastOverlay(child: child!), + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: ClipboardHistoryPanel()), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _PanelInsertionTarget + implements + BusyMarkClipboardInsertionTarget, + BusyMarkClipboardInsertionCapabilities { + _PanelInsertionTarget({ + this.supportImages = true, + this.result = ClipboardPasteResult.inserted, + }); + + final bool supportImages; + final ClipboardPasteResult result; + int pasteCalls = 0; + + @override + String get documentId => 'panel-target'; + + @override + String get documentName => 'Target.md'; + + @override + String? get documentPath => '/workspace/Target.md'; + + @override + bool get editable => true; + + @override + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) => + supportImages || payload.kind != BusyMarkClipboardContentKind.image; + + @override + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + pasteCalls++; + return result; + } + + @override + void requestEditorFocus() {} +} + +class _MemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async => value = json; +} + +class _EmptyClipboard extends RichClipboardService { + _EmptyClipboard() + : super(channel: const MethodChannel('busymark.test/panel-clipboard')); + + @override + Future read() async => const RichClipboardData(); +} + +class _EmptyAssetInput extends AssetInputService { + _EmptyAssetInput() + : super(channel: const MethodChannel('busymark.test/panel-assets')); + + @override + Future> readClipboardImageFiles() async => const []; + + @override + Future readClipboardImagePng() async => null; +} diff --git a/test/src/clipboard_history_test.dart b/test/src/clipboard_history_test.dart index cd64c5c4..17a23b98 100644 --- a/test/src/clipboard_history_test.dart +++ b/test/src/clipboard_history_test.dart @@ -299,6 +299,90 @@ void main() { expect(external.sessionOwned, isFalse); }, ); + + test('rich ownership is byte bounded and shares equivalent media', () async { + const channel = MethodChannel('busymark.test/rich-ownership-budget'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var clipboard = {}; + var sequence = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'write') { + clipboard = Map.from(call.arguments as Map); + clipboard['generation'] = sequence; + return true; + } + return clipboard; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final service = RichClipboardService( + channel: channel, + maximumOwnershipBytes: 180, + createToken: () => 'token-${++sequence}', + ); + final sharedMedia = Uint8List.fromList(List.filled(80, 7)); + const shared = RichClipboardData( + text: 'same', + sourceText: '![same](diagram.png)', + richFragment: '{"image":"diagram.png"}', + ); + for (var index = 0; index < 10; index++) { + expect( + await service.write( + RichClipboardData( + text: shared.text, + sourceText: shared.sourceText, + richFragment: shared.richFragment, + mediaBytes: {'diagram.png': sharedMedia}, + ), + ), + isTrue, + ); + } + final sharedBytes = service.retainedOwnershipBytes; + expect(service.retainedOwnershipEntries, 10); + expect(sharedBytes, lessThanOrEqualTo(180)); + final historyScope = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + richClipboardServiceProvider.overrideWithValue(service), + clipboardAssetInputServiceProvider.overrideWithValue(_FakeAssetInput()), + ], + ); + addTearDown(historyScope.dispose); + await Future.delayed(Duration.zero); + final history = historyScope.read( + clipboardHistoryControllerProvider.notifier, + ); + history.retain(text('clear ownership')); + history.clear(); + expect(service.retainedOwnershipEntries, 1); + + for (var index = 0; index < 10; index++) { + expect( + await service.write( + RichClipboardData( + text: 'image $index', + sourceText: '![image $index](diagram.png)', + richFragment: '{"image":$index}', + mediaBytes: { + 'diagram.png': Uint8List.fromList(List.filled(80, index)), + }, + ), + ), + isTrue, + ); + expect(service.retainedOwnershipBytes, lessThanOrEqualTo(180)); + } + expect(service.retainedOwnershipEntries, lessThan(10)); + final current = await service.read(); + expect(current.sessionOwned, isTrue); + expect(current.sourceText, contains('image 9')); + + service.discardObsoleteOwnership(); + expect(service.retainedOwnershipEntries, 1); + expect((await service.read()).sourceText, contains('image 9')); + }); } class _MemorySettingsStore implements LocalSettingsStore { diff --git a/test/src/git/git_controller_test.dart b/test/src/git/git_controller_test.dart index d814064b..c563535d 100644 --- a/test/src/git/git_controller_test.dart +++ b/test/src/git/git_controller_test.dart @@ -5,6 +5,9 @@ import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/git/application/git_controller.dart'; import 'package:busymark/src/git/application/git_gateway.dart'; import 'package:busymark/src/git/domain/git_models.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/local_history/local_history_store.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; @@ -528,6 +531,54 @@ void main() { ); }); + test( + 'restore protects a repository-relative file against the repository root', + () async { + final repository = await Directory.systemTemp.createTemp( + 'busymark-git-restore-root-', + ); + addTearDown(() async { + if (await repository.exists()) await repository.delete(recursive: true); + }); + final path = '${repository.path}/README.md'; + await File(path).writeAsString('# Working tree before restore\n'); + final store = MemoryLocalHistoryStore(); + final gateway = _RootedGitGateway(repository.path); + final container = _container(gateway, historyStore: store); + final workspaceController = container.read( + workspaceControllerProvider.notifier, + ); + await workspaceController.openPath(repository.path); + final controller = container.read(gitControllerProvider.notifier); + controller.attachWorkspace( + container.read(workspaceControllerProvider).workspace!, + ); + await controller.refresh(); + await controller.loadFileHistory(path); + await controller.selectFileHistoryCommit('1234567890abcdef'); + + expect(await controller.restoreSelectedFileVersion(), isTrue); + expect(gateway.restoreCalls, 1); + final snapshot = await store.load(); + final protected = []; + for (final summary in snapshot.revisions.where( + (revision) => + revision.reason == LocalHistoryCaptureReason.beforeDiscard, + )) { + protected.add((await store.readRevision(summary.id))!); + } + expect(protected.map((revision) => revision.source), [ + '# Working tree before restore\n', + ]); + expect( + snapshot.documents.singleWhere( + (document) => document.currentPath == path, + ), + isNotNull, + ); + }, + ); + test('project history reset uses the selected commit and mode', () async { final gateway = _FakeGitGateway(); final container = _container(gateway); @@ -1042,11 +1093,16 @@ class _PagedHistoryGitGateway extends _FakeGitGateway { }) async => _fileEntries.skip(skip).take(limit).toList(); } -ProviderContainer _container(GitRepositoryGateway gateway) { +ProviderContainer _container( + GitRepositoryGateway gateway, { + LocalHistoryStore? historyStore, +}) { final container = ProviderContainer( overrides: [ gitRepositoryGatewayProvider.overrideWithValue(gateway), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + if (historyStore != null) + localHistoryStoreProvider.overrideWithValue(historyStore), workspaceServiceProvider.overrideWithValue(const WorkspaceService()), ], ); @@ -1532,6 +1588,47 @@ class _TrustRequiredFakeGitGateway extends _FakeGitGateway { bool get requiresWorkspaceTrust => true; } +class _RootedGitGateway extends _FakeGitGateway { + _RootedGitGateway(this.rootPath); + + final String rootPath; + + GitRepositoryInfo get repository => GitRepositoryInfo( + rootPath: rootPath, + gitDirPath: '$rootPath/.git', + currentBranch: 'main', + ); + + @override + Future detectRepository(String workspacePath) async { + detectCalls++; + return repository; + } + + @override + Future status(GitRepositoryInfo repository) async => + GitStatusSnapshot( + repositoryInfo: repository, + files: [ + GitFileStatus( + repoRelativePath: 'README.md', + absolutePath: '$rootPath/README.md', + indexStatus: GitFileChangeStatus.unmodified, + workTreeStatus: GitFileChangeStatus.modified, + category: GitFileStatusCategory.modified, + staged: false, + unstaged: true, + untracked: false, + deleted: false, + renamed: false, + copied: false, + conflicted: false, + ignored: false, + ), + ], + ); +} + class _DeferredDetectGitGateway extends _FakeGitGateway { final _detectCompleters = >{}; diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index c3040553..26ce7c83 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -6,6 +6,7 @@ import 'package:busymark/src/local_history/local_history_models.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:busymark/src/workspace/workspace_file_snapshot.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -199,6 +200,282 @@ void main() { expect(state.snapshot.documents.single.deleted, isTrue); }, ); + + test('a delayed revision read cannot cross the selected document', () async { + final memory = MemoryLocalHistoryStore(); + await _capturePath(memory, '/workspace/a.md', 'revision A'); + await _capturePath(memory, '/workspace/b.md', 'revision B'); + final initial = await memory.load(); + final documentA = initial.documents.singleWhere( + (document) => document.currentPath == '/workspace/a.md', + ); + final documentB = initial.documents.singleWhere( + (document) => document.currentPath == '/workspace/b.md', + ); + final revisionA = initial.revisionsFor(documentA.id).single; + final store = _BlockingRevisionReadStore(memory, revisionA.id); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 2), + ), + ], + ); + addTearDown(container.dispose); + final controller = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + await controller.refresh(); + + expect( + container.read(localHistoryControllerProvider).snapshot.revisions, + hasLength(2), + ); + controller.selectDocument(documentA.id); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + documentA.id, + ); + final selection = controller.selectRevision(revisionA.id); + await store.started.future; + controller.selectDocument(documentB.id); + store.release.complete(); + await selection; + + final state = container.read(localHistoryControllerProvider); + expect(state.selectedDocumentId, documentB.id); + expect(state.selectedRevisionId, isNull); + expect(state.selectedRevision, isNull); + }); + + test( + 'Save As settles pending named and untitled checkpoints safely', + () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + final named = _fileBuffer('named', '/workspace/A.md', 'before'); + await controller.observeOpened(named); + final namedEdit = named.edited('pending A edit'); + controller.observeEdit(named, namedEdit); + await Future.delayed(Duration.zero); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(namedEdit), + '/workspace/B.md', + destinationExisted: false, + ), + isTrue, + ); + + final untitled = DocumentBuffer.untitled( + id: 'draft', + name: 'Draft.md', + text: 'draft before', + ); + await controller.observeOpened(untitled); + final draftEdit = untitled.edited('pending draft edit'); + controller.observeEdit(untitled, draftEdit); + await Future.delayed(Duration.zero); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(draftEdit), + '/workspace/First.md', + destinationExisted: false, + ), + isTrue, + ); + for (final timer in timers) { + timer.fire(); + } + await controller.flushAll(const []); + + final snapshot = await store.load(); + final a = snapshot.documents.singleWhere( + (document) => document.currentPath == '/workspace/A.md', + ); + final b = snapshot.documents.singleWhere( + (document) => document.currentPath == '/workspace/B.md', + ); + expect(a.id, isNot(b.id)); + expect( + await _revisionSources(store, snapshot.revisionsFor(a.id)), + contains('pending A edit'), + ); + expect( + (await store.readRevision( + snapshot.revisionsFor(b.id).single.id, + ))!.source, + 'pending A edit', + ); + final first = snapshot.documents.singleWhere( + (document) => document.currentPath == '/workspace/First.md', + ); + expect(first.untitled, isFalse); + expect( + await _revisionSources(store, snapshot.revisionsFor(first.id)), + contains('pending draft edit'), + ); + }, + ); + + test( + 'file and directory moves settle pending checkpoints before remap', + () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + final file = _fileBuffer('file', '/workspace/file.md', 'file before'); + await controller.observeOpened(file); + controller.observeEdit(file, file.edited('file pending')); + await Future.delayed(Duration.zero); + await controller.remapPath('/workspace/file.md', '/workspace/renamed.md'); + + final nested = _fileBuffer( + 'nested', + '/workspace/folder/nested.md', + 'nested before', + ); + await controller.observeOpened(nested); + controller.observeEdit(nested, nested.edited('nested pending')); + await Future.delayed(Duration.zero); + await controller.remapPath('/workspace/folder', '/workspace/moved'); + for (final timer in timers) { + timer.fire(); + } + await controller.flushAll(const []); + + final snapshot = await store.load(); + expect( + snapshot.documents.map((document) => document.currentPath), + containsAll(['/workspace/renamed.md', '/workspace/moved/nested.md']), + ); + expect( + snapshot.documents.map((document) => document.currentPath), + isNot(contains('/workspace/file.md')), + ); + expect( + snapshot.documents.map((document) => document.currentPath), + isNot(contains('/workspace/folder/nested.md')), + ); + }, + ); +} + +ProviderContainer _historyContainer( + LocalHistoryStore store, + List<_FakeTimer> timers, +) => ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }), + ], +); + +DocumentBuffer _fileBuffer(String id, String path, String text) => + DocumentBuffer.file( + id: id, + filePath: path, + text: text, + snapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + size: 0, + contentHash: '', + ), + format: TextFormatMetadata.utf8Lf, + ); + +Future _capturePath( + LocalHistoryStore store, + String path, + String source, +) => store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: path.split('/').last, + source: source, + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), +); + +Future> _revisionSources( + LocalHistoryStore store, + Iterable revisions, +) async { + final sources = []; + for (final revision in revisions) { + sources.add((await store.readRevision(revision.id))!.source); + } + return sources; +} + +class _BlockingRevisionReadStore implements LocalHistoryStore { + _BlockingRevisionReadStore(this.delegate, this.blockedRevisionId); + + final LocalHistoryStore delegate; + final String blockedRevisionId; + final started = Completer(); + final release = Completer(); + + @override + Future readRevision(String revisionId) async { + if (revisionId == blockedRevisionId) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return delegate.readRevision(revisionId); + } + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) => delegate.capture(request, policy); + + @override + Future clearAll() => delegate.clearAll(); + + @override + Future clearDocument(String documentId) => + delegate.clearDocument(documentId); + + @override + Future load() => delegate.load(); + + @override + Future markDeleted(String path, {required bool recursive}) => + delegate.markDeleted(path, recursive: recursive); + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) => + delegate.prune(policy, now); + + @override + Future remapPath(String sourcePath, String destinationPath) => + delegate.remapPath(sourcePath, destinationPath); } class _MemorySettingsStore implements LocalSettingsStore { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index fd4c229f..0fc78bee 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -154,6 +154,128 @@ void main() { ); }); + test( + 'restoration rejects a revision from another history document', + () async { + final aPath = p.join(root.path, 'a.md'); + final bPath = p.join(root.path, 'b.md'); + await File(aPath).writeAsString('A on disk\n'); + await File(bPath).writeAsString('B on disk\n'); + final store = MemoryLocalHistoryStore(); + final capturedA = await _capture(store, aPath, 'A history\n'); + final capturedB = await _capture(store, bPath, 'B history\n'); + final harness = await _harness(store); + await harness.controller.openPath(bPath); + final revisionA = (await store.readRevision(capturedA.revision!.id))!; + final snapshot = await store.load(); + final documentB = snapshot.documents.singleWhere( + (document) => document.id == capturedB.document.id, + ); + + expect( + await harness.controller.restoreLocalHistoryRevision( + document: documentB, + revision: revisionA, + ), + isFalse, + ); + expect(harness.state.activeText, 'B on disk\n'); + }, + ); + + test( + 'untitled history compares and restores through stable identity', + () async { + final store = MemoryLocalHistoryStore(); + final harness = await _harness(store); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Older untitled draft\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final snapshot = await store.load(); + final document = snapshot.documents.single; + final revision = (await store.readRevision( + snapshot.revisionsFor(document.id).single.id, + ))!; + harness.controller.updateActiveText('Newer untitled draft\n'); + + final current = await harness.controller.localHistoryCurrentSource( + document, + revision, + ); + expect(current.kind, LocalHistoryCurrentSourceKind.editor); + expect(current.source, 'Newer untitled draft\n'); + expect( + await harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + ), + isTrue, + ); + expect(harness.state.activeText, 'Older untitled draft\n'); + expect(harness.state.activeBuffer!.isUntitled, isTrue); + }, + ); + + test( + 'editing during a delayed reload cancels the stale replacement', + () async { + final path = p.join(root.path, 'reload.md'); + await File(path).writeAsString('Disk version\n'); + final store = _BlockingBeforeReloadStore(MemoryLocalHistoryStore()); + final harness = await _harness(store); + await harness.controller.openPath(path); + harness.controller.updateActiveText('Edit before reload\n'); + await Future.delayed(Duration.zero); + + final reload = harness.controller.reloadBufferFromDisk( + harness.state.activeBuffer!.id, + ); + await store.started.future; + harness.controller.updateActiveText('Edit made while reload waited\n'); + store.release.complete(); + + expect(await reload, isFalse); + expect(harness.state.activeText, 'Edit made while reload waited\n'); + expect(harness.state.activeBuffer!.isDirty, isTrue); + }, + ); + + test('delete skips binary and excluded history descendants', () async { + final image = File(p.join(root.path, 'image.png')); + final imageFolder = Directory(p.join(root.path, 'image-folder')); + final excludedFolder = Directory(p.join(root.path, 'excluded-folder')); + await image.writeAsBytes([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]); + await imageFolder.create(); + await File( + p.join(imageFolder.path, 'nested.png'), + ).writeAsBytes([0xff, 0xfe, 0xfd]); + await excludedFolder.create(); + await File( + p.join(excludedFolder.path, 'secret.md'), + ).writeAsString('Excluded source\n'); + final harness = await _harness(MemoryLocalHistoryStore()); + await harness.controller.openPath(root.path); + await harness.container + .read(appSettingsControllerProvider.notifier) + .setLocalHistoryExcludedPaths([excludedFolder.path]); + + expect(await harness.controller.deleteWorkspaceEntity(image.path), isTrue); + expect(await image.exists(), isFalse); + expect( + await harness.controller.deleteWorkspaceEntity(imageFolder.path), + isTrue, + ); + expect(await imageFolder.exists(), isFalse); + expect( + await harness.controller.deleteWorkspaceEntity(excludedFolder.path), + isTrue, + ); + expect(await excludedFolder.exists(), isFalse); + }); + test( 'deleted document restores through normal create flow without overwriting', () async { @@ -451,6 +573,25 @@ class _FailingSavedStore extends _FailingProtectiveStore { } } +class _BlockingBeforeReloadStore extends _FailingProtectiveStore { + _BlockingBeforeReloadStore(super.delegate); + + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeReload) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return delegate.capture(request, policy); + } +} + class _DelayedSaveWorkspaceService extends WorkspaceService { final started = Completer(); final _release = Completer(); diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 51a0c4e6..6549eb20 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:ui' show BoxHeightStyle; import 'package:busymark/l10n/generated/app_localizations.dart'; @@ -7,6 +9,7 @@ import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/ai/ai_models.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/assets/asset_ingestion_service.dart'; import 'package:busymark/src/clipboard/clipboard_insertion.dart'; import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/core/diagnostic.dart'; @@ -21,6 +24,11 @@ import 'package:busymark/src/editor/source/source_gutter.dart' import 'package:busymark/src/editor/source/source_search.dart'; import 'package:busymark/src/editor/source_folding.dart'; import 'package:busymark/src/editor/source_language.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/src/platform/rich_clipboard_service.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; @@ -1560,6 +1568,92 @@ void main() { expect(await registry.paste(payload), ClipboardPasteResult.inserted); expect(changed, 'abc**rich**d'); }); + + testWidgets('structured history rebases retained media into Source', ( + tester, + ) async { + final root = (await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-source-history-media-'), + ))!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final sourceDirectory = Directory('${root.path}/source'); + final destinationDirectory = Directory('${root.path}/destination'); + await tester.runAsync(() async { + await sourceDirectory.create(); + await destinationDirectory.create(); + }); + final sourcePath = '${sourceDirectory.path}/origin.md'; + final destinationPath = '${destinationDirectory.path}/target.md'; + final fragment = _structuredImageFragment(sourcePath); + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + String? changed; + await _pumpClipboardSourceEditor( + tester, + source: 'Target\n', + clipboard: _SourceTestClipboard(), + registry: registry, + filePath: destinationPath, + workspaceRoot: root.path, + assetWorkspaceKind: AssetWorkspaceKind.markdownWorkspace, + onChanged: (value, _) => changed = value, + ); + final payload = BusyMarkClipboardPayload( + id: 'source-rich-media', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.richText, + text: 'Alt', + sourceText: '![Alt](diagram.png)\n', + richFragment: fragment.encode(), + mediaBytes: { + 'diagram.png': Uint8List.fromList( + utf8.encode( + '', + ), + ), + }, + ); + + expect( + await tester.runAsync(() => registry.paste(payload)), + ClipboardPasteResult.inserted, + ); + expect(changed, contains('![Alt](../images/diagram.svg)')); + expect( + await tester.runAsync( + () => File('${root.path}/images/diagram.svg').exists(), + ), + isTrue, + ); + }); +} + +WysiwygClipboardFragment _structuredImageFragment(String sourcePath) { + const parser = MarkdownParser(); + final document = parser + .parse( + filePath: sourcePath, + source: '![Alt](diagram.png)\n', + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final block = document.blocks.single; + return WysiwygClipboardFragment( + sourcePath: sourcePath, + mode: document.mode, + mediaPaths: const {'diagram.png': '/original/assets/diagram.png'}, + blocks: [ + BusyWysiwygStyledBlock( + kind: block.kind, + text: block.plainText, + ranges: busyInlineStyleRanges(block.inlines), + attributes: block.attributes, + completeBlock: busyMarkWysiwygImmutableBlockSnapshot(block), + ), + ], + ); } const _autocompleteSource = '

    fea'; @@ -1640,6 +1734,9 @@ Future _pumpClipboardSourceEditor( BusyMarkClipboardInsertionRegistry? registry, ValueChanged? onCaptured, BusyMarkSourceChanged? onChanged, + String filePath = '/project/source.md', + String? workspaceRoot, + AssetWorkspaceKind? assetWorkspaceKind, }) async { await tester.pumpWidget( MaterialApp( @@ -1656,8 +1753,10 @@ Future _pumpClipboardSourceEditor( child: BusyMarkSourceEditor( text: source, language: SourceSyntaxLanguage.markdown, - filePath: '/project/source.md', + filePath: filePath, documentId: 'source-document', + workspaceRoot: workspaceRoot, + assetWorkspaceKind: assetWorkspaceKind, diagnostics: const [], editorFontSize: 14, wordWrap: true, diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 9d5b99c8..1d4d5b64 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/clipboard/clipboard_insertion.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_html.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; @@ -153,6 +155,17 @@ void main() { ), contains('![Alt](images/diagram-retained.png)'), ); + expect( + _insert( + decoded.rebase( + '/source/topic.md', + mediaDestinations: const { + 'diagram.png': 'images/diagram-recovered.png', + }, + ), + ), + contains('![Alt](images/diagram-recovered.png)'), + ); }); test('Writerside structure survives the fragment and insertion', () { @@ -362,8 +375,10 @@ void main() { WidgetTester tester, String id, String source, - ValueChanged changed, - ) async { + ValueChanged changed, { + BusyMarkClipboardInsertionRegistry? registry, + ValueChanged? onCaptured, + }) async { await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -372,6 +387,8 @@ void main() { body: BusyMarkWysiwygEditor( key: ValueKey(id), clipboardService: RichClipboardService(), + clipboardInsertionRegistry: registry, + onClipboardCaptured: onCaptured, document: _parser .parse( filePath: '/$id.md', @@ -625,6 +642,66 @@ void main() { expect(result, contains('**Bold**')); }); + testWidgets('retained external HTML preserves structure on history paste', ( + tester, + ) async { + systemData = { + 'html': + '

    External

    • Item
    ', + 'text': 'External\nBold and linked\nItem', + }; + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + BusyMarkClipboardCapture? capture; + var first = ''; + await mount( + tester, + 'first', + 'Target\n', + (value) => first = value, + registry: registry, + onCaptured: (value) => capture = value, + ); + await key(tester, LogicalKeyboardKey.keyA); + await key(tester, LogicalKeyboardKey.keyV); + expect(first, contains('**Bold**')); + expect(capture?.richFragment, isNotNull); + + systemData = {'text': 'Replacement clipboard'}; + var retained = ''; + await mount( + tester, + 'retained', + 'Target\n', + (value) => retained = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + final value = capture!; + expect( + await registry.paste( + BusyMarkClipboardPayload( + id: 'external-html', + acquiredAt: DateTime.utc(2026), + kind: value.kind, + text: value.text, + sourceText: value.sourceText, + html: value.html, + richFragment: value.richFragment, + origin: value.origin, + external: true, + ), + ), + ClipboardPasteResult.inserted, + ); + await tester.pump(); + expect(retained, contains('# External')); + expect(retained, contains('**Bold**')); + expect(retained, contains('[linked](https://example.com)')); + expect(retained, contains('- Item')); + }); + testWidgets('Ctrl+Shift+V is not an Editor paste command', (tester) async { await copyAll(tester); var result = 'Target\n'; From b0bc55a3d6d68cba6c3a7fb4aa11e9b5ed1534da Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 9 Sep 2026 15:09:55 -0700 Subject: [PATCH 04/27] Enhance Local History with path transition handling and clipboard improvements - Added path transition methods to support safer buffer path changes in Local History. - Enhanced clipboard operations to handle image insertion with markdown and XML syntax support. - Improved error handling and state validation in clipboard and Local History operations. --- .../clipboard/clipboard_history_panel.dart | 2 +- lib/src/editor/source/source_commands.dart | 24 +- lib/src/editor/source/source_editor.dart | 82 +++- .../local_history_comparison_view.dart | 117 ++++- .../local_history_controller.dart | 144 +++++- .../local_history/local_history_models.dart | 6 + .../local_history/local_history_store.dart | 65 ++- lib/src/workspace/workspace_controller.dart | 246 ++++++++-- test/src/clipboard_history_panel_test.dart | 52 +- test/src/local_history_controller_test.dart | 127 +++-- test/src/local_history_store_test.dart | 27 ++ test/src/local_history_workspace_test.dart | 444 ++++++++++++++++++ test/src/source_commands_test.dart | 8 + test/src/source_editor_widget_test.dart | 131 +++++- 14 files changed, 1321 insertions(+), 154 deletions(-) diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart index 55f63af6..37282077 100644 --- a/lib/src/clipboard/clipboard_history_panel.dart +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -392,7 +392,7 @@ class _ClipboardEntryTile extends StatelessWidget { if (onPastePlain != null) IconButton( tooltip: l10n.clipboardPastePlainText, - onPressed: canPaste ? onPastePlain : null, + onPressed: onPastePlain, icon: const Icon(BusyMarkGlyphs.text, size: 18), ), if (onRemove != null) diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index ba55b8e7..efb63f34 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -291,11 +291,15 @@ abstract final class SourceCommands { TextEditingValue value, { required bool block, required String altPlaceholder, + String sourceReference = 'url', }) { final selection = _normalizedSelection(value); final selected = selection.textInside(value.text).trim(); final alt = selected.isEmpty ? altPlaceholder : selected; - final replacement = '![${alt.replaceAll('\n', ' ')}](url)'; + final replacement = imageReference( + alt: alt, + sourceReference: sourceReference, + ); final inserted = block ? '\n$replacement\n' : replacement; final altStart = selection.start + (block ? 3 : 2); return _replaceSelection( @@ -308,6 +312,18 @@ abstract final class SourceCommands { ); } + static String imageReference({ + required String alt, + required String sourceReference, + }) => '![${alt.replaceAll('\n', ' ')}]($sourceReference)'; + + static String writersideImageReference({ + required String alt, + required String sourceReference, + }) => + ''; + static TextEditingValue insertTable( TextEditingValue value, { required String Function(int columnNumber) headerTextForColumn, @@ -351,6 +367,12 @@ abstract final class SourceCommands { } } +String _escapeXmlAttribute(String value) => value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + ({int start, int end, List lines}) _selectedLineRange( TextEditingValue value, ) { diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 9710de6c..cd30c2b7 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1619,6 +1619,7 @@ class BusyMarkSourceEditorState extends State { _SourceClipboardOperationTarget( documentId: widget.documentId ?? widget.filePath ?? '', filePath: widget.filePath, + language: widget.language, text: _controller.fullText, selection: _controller.fullSelection, ); @@ -1627,6 +1628,7 @@ class BusyMarkSourceEditorState extends State { mounted && target.documentId == (widget.documentId ?? widget.filePath ?? '') && target.filePath == widget.filePath && + target.language == widget.language && target.text == _controller.fullText && target.selection == _controller.fullSelection; @@ -1705,11 +1707,19 @@ class BusyMarkSourceEditorState extends State { BusyMarkClipboardPayload payload, { required bool plainText, }) async { - if (_hasActiveComposition || - payload.kind == BusyMarkClipboardContentKind.image) { + if (_hasActiveComposition) { return ClipboardPasteResult.unsupported; } final target = _captureClipboardTarget(); + if (payload.kind == BusyMarkClipboardContentKind.image) { + if (plainText) { + final text = payload.text; + return text == null + ? ClipboardPasteResult.unsupported + : _insertClipboardText(target, text); + } + return _pasteHistoryImage(payload, target); + } if (!plainText && payload.richFragment != null) { final decoded = WysiwygClipboardFragment.decode(payload.richFragment!); if (decoded != null) { @@ -1742,6 +1752,64 @@ class BusyMarkSourceEditorState extends State { return _insertClipboardText(target, text); } + Future _pasteHistoryImage( + BusyMarkClipboardPayload payload, + _SourceClipboardOperationTarget target, + ) async { + final bytes = payload.imageBytes; + if (bytes == null || bytes.isEmpty) { + return ClipboardPasteResult.unsupported; + } + if (target.language == SourceSyntaxLanguage.plain) { + return ClipboardPasteResult.unsupported; + } + final alt = context.l10n.image; + late final IngestedAsset asset; + try { + asset = await widget.assetIngestionService.ingestBytes( + bytes: bytes, + suggestedFileName: payload.imageDisplayName ?? 'clipboard-image.png', + request: _assetIngestionRequest, + origin: AssetIngestionOrigin.screenshotPaste, + ); + } on AssetSaveRequiredException { + if (!_isClipboardTargetCurrent(target)) { + return ClipboardPasteResult.staleTarget; + } + widget.onAssetSaveRequired?.call(); + return ClipboardPasteResult.unsupported; + } on AssetIngestionException { + return _isClipboardTargetCurrent(target) + ? ClipboardPasteResult.unsupported + : ClipboardPasteResult.staleTarget; + } on FileSystemException { + return _isClipboardTargetCurrent(target) + ? ClipboardPasteResult.unsupported + : ClipboardPasteResult.staleTarget; + } + if (!_isClipboardTargetCurrent(target)) { + await _deleteUncommittedClipboardAssets([asset]); + return ClipboardPasteResult.staleTarget; + } + final result = _insertClipboardText(target, switch (target.language) { + SourceSyntaxLanguage.markdown => SourceCommands.imageReference( + alt: alt, + sourceReference: asset.markdownPath, + ), + SourceSyntaxLanguage.xml => SourceCommands.writersideImageReference( + alt: alt, + sourceReference: asset.markdownPath, + ), + SourceSyntaxLanguage.plain => throw StateError( + 'Plain source cannot contain an image reference.', + ), + }); + if (result != ClipboardPasteResult.inserted) { + await _deleteUncommittedClipboardAssets([asset]); + } + return result; + } + Future<({WysiwygClipboardFragment fragment, List assets})?> _prepareRetainedClipboardMedia( WysiwygClipboardFragment fragment, @@ -2790,12 +2858,14 @@ class _SourceClipboardOperationTarget { const _SourceClipboardOperationTarget({ required this.documentId, required this.filePath, + required this.language, required this.text, required this.selection, }); final String documentId; final String? filePath; + final SourceSyntaxLanguage language; final String text; final TextSelection selection; } @@ -2828,10 +2898,12 @@ class _SourceClipboardInsertionTarget @override bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) { - if (!editable || payload.kind == BusyMarkClipboardContentKind.image) { - return false; - } + if (!editable) return false; if (plainText) return payload.hasMeaningfulTextRepresentation; + if (payload.kind == BusyMarkClipboardContentKind.image) { + return state.widget.language != SourceSyntaxLanguage.plain && + payload.imageBytes?.isNotEmpty == true; + } if (payload.richFragment != null) return payload.mediaComplete; return payload.preferredSourceText != null; } diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index 68e7f2e6..06092e62 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -10,11 +10,26 @@ import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/localization.dart'; import '../comparison/source_comparison.dart'; +import '../workspace/document_buffer.dart'; import '../workspace/workspace_controller.dart'; import '../workspace/workspace_safety.dart'; import 'local_history_controller.dart'; import 'local_history_models.dart'; +typedef LocalHistoryComparisonComputer = + Future Function( + SourceComparisonInput oldInput, + SourceComparisonInput currentInput, + ); + +final localHistoryComparisonComputerProvider = + Provider( + (ref) => + (oldInput, currentInput) => Future( + () => compareSource(oldInput, currentInput), + ), + ); + class LocalHistoryComparisonView extends ConsumerStatefulWidget { const LocalHistoryComparisonView({super.key}); @@ -26,7 +41,7 @@ class LocalHistoryComparisonView extends ConsumerStatefulWidget { class _LocalHistoryComparisonViewState extends ConsumerState { Future<_ComparisonSnapshot?>? _future; - String? _key; + _ComparisonRequest? _request; @override Widget build(BuildContext context) { @@ -42,16 +57,17 @@ class _LocalHistoryComparisonViewState final matchingBuffer = ref .read(workspaceControllerProvider.notifier) .localHistoryBufferForDocument(document, revision); - final key = [ - revision.summary.id, - matchingBuffer?.id, - matchingBuffer?.revision, - matchingBuffer?.text.hashCode, - document.currentPath, - ].join(':'); - if (_key != key) { - _key = key; - _future = _buildSnapshot(document, revision); + final request = _ComparisonRequest( + documentId: document.id, + revisionId: revision.summary.id, + revisionVersion: revision.summary.capturedAt.microsecondsSinceEpoch, + currentBufferId: matchingBuffer?.id, + currentSourceVersion: matchingBuffer?.revision, + currentPath: document.currentPath, + ); + if (_request != request) { + _request = request; + _future = _buildSnapshot(document, revision, request); } return FutureBuilder<_ComparisonSnapshot?>( future: _future, @@ -68,15 +84,22 @@ class _LocalHistoryComparisonViewState if (data == null) { return const Center(child: CircularProgressIndicator()); } + final actionsAreCurrent = + snapshot.connectionState == ConnectionState.done && + data.matches( + request: request, + revision: revision, + currentBuffer: matchingBuffer, + ); return _ComparisonBody( snapshot: data, onClose: () => ref .read(localHistoryControllerProvider.notifier) .clearComparison(), - onRestoreAll: data.canRestore + onRestoreAll: actionsAreCurrent && data.canRestore ? () => _restore(document, revision) : null, - onRestoreChange: data.canRestoreChange + onRestoreChange: actionsAreCurrent && data.canRestoreChange ? (change) => _restore( document, revision, @@ -85,7 +108,8 @@ class _LocalHistoryComparisonViewState ) : null, onRestoreOriginal: - data.missing && + actionsAreCurrent && + data.missing && (document.currentPath ?? revision.summary.historicalPath) != null ? () => _restoreMissing( @@ -94,7 +118,7 @@ class _LocalHistoryComparisonViewState document.currentPath ?? revision.summary.historicalPath!, ) : null, - onRestoreNewLocation: data.missing + onRestoreNewLocation: actionsAreCurrent && data.missing ? () => _chooseRestoreLocation(document, revision) : null, ); @@ -105,6 +129,7 @@ class _LocalHistoryComparisonViewState Future<_ComparisonSnapshot?> _buildSnapshot( LocalHistoryDocument document, LocalHistoryRevision revision, + _ComparisonRequest request, ) async { final editorLabel = context.l10n.localHistoryCurrentEditor; final diskLabel = context.l10n.localHistoryCurrentDisk; @@ -131,10 +156,12 @@ class _LocalHistoryComparisonViewState label: currentLabel, source: current.source, ); - final comparison = await Future( - () => compareSource(oldInput, currentInput), + final comparison = await ref.read(localHistoryComparisonComputerProvider)( + oldInput, + currentInput, ); return _ComparisonSnapshot( + request: request, comparison: comparison, missing: current.kind == LocalHistoryCurrentSourceKind.missing, canRestore: current.canRestore, @@ -229,18 +256,74 @@ class _LocalHistoryComparisonViewState } } +class _ComparisonRequest { + const _ComparisonRequest({ + required this.documentId, + required this.revisionId, + required this.revisionVersion, + required this.currentBufferId, + required this.currentSourceVersion, + required this.currentPath, + }); + + final String documentId; + final String revisionId; + final int revisionVersion; + final String? currentBufferId; + final int? currentSourceVersion; + final String? currentPath; + + @override + bool operator ==(Object other) => + other is _ComparisonRequest && + other.documentId == documentId && + other.revisionId == revisionId && + other.revisionVersion == revisionVersion && + other.currentBufferId == currentBufferId && + other.currentSourceVersion == currentSourceVersion && + other.currentPath == currentPath; + + @override + int get hashCode => Object.hash( + documentId, + revisionId, + revisionVersion, + currentBufferId, + currentSourceVersion, + currentPath, + ); +} + class _ComparisonSnapshot { const _ComparisonSnapshot({ + required this.request, required this.comparison, required this.missing, required this.canRestore, required this.canRestoreChange, }); + final _ComparisonRequest request; final SourceComparison comparison; final bool missing; final bool canRestore; final bool canRestoreChange; + + bool matches({ + required _ComparisonRequest request, + required LocalHistoryRevision revision, + required DocumentBuffer? currentBuffer, + }) { + if (this.request != request || + comparison.oldInput.id != revision.summary.id || + comparison.oldInput.version != + revision.summary.capturedAt.microsecondsSinceEpoch) { + return false; + } + if (currentBuffer == null) return request.currentBufferId == null; + return comparison.currentInput.id == currentBuffer.id && + comparison.currentInput.version == currentBuffer.revision; + } } class _ComparisonBody extends StatelessWidget { diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index f97998a3..3ed0dde9 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -187,6 +187,28 @@ class LocalHistoryBufferSnapshot { final int revision; final String? path; final bool untitled; + + LocalHistoryBufferSnapshot atPath(String destinationPath) => + LocalHistoryBufferSnapshot( + bufferId: bufferId, + displayName: p.basename(destinationPath), + text: text, + format: format, + revision: revision, + path: destinationPath, + ); +} + +class LocalHistoryBufferPathTransition { + const LocalHistoryBufferPathTransition._({ + required this.bufferId, + required this.sourcePath, + required this.destinationPath, + }); + + final String bufferId; + final String? sourcePath; + final String destinationPath; } class LocalHistoryController extends Notifier { @@ -197,6 +219,7 @@ class LocalHistoryController extends Notifier { final _pending = {}; final _checkpointTimers = {}; final _bufferQueues = >{}; + final _pathTransitions = {}; var _loadGeneration = 0; var _searchGeneration = 0; @@ -291,29 +314,51 @@ class LocalHistoryController extends Notifier { } if (!ref.mounted) return; _pending[current.id] = currentSnapshot; - _checkpointTimers.putIfAbsent( - current.id, - () => _timerFactory(policy.checkpointInterval, () { - _checkpointTimers.remove(current.id); - if (!ref.mounted) return; - final latest = _pending.remove(current.id); - if (latest != null) { - unawaited( - _enqueue( - current.id, - () => _capture( - latest, - LocalHistoryCaptureReason.automaticCheckpoint, - ), - ), - ); - } - }), - ); + _scheduleCheckpoint(current.id); }), ); } + /// Suspends automatic captures while a workspace buffer and its stable + /// history identity move between paths. Callers must finish the returned + /// transition after publishing (or abandoning) the buffer path change. + LocalHistoryBufferPathTransition beginBufferPathTransition({ + required String bufferId, + required String? sourcePath, + required String destinationPath, + }) { + final transition = LocalHistoryBufferPathTransition._( + bufferId: bufferId, + sourcePath: sourcePath, + destinationPath: p.normalize(destinationPath), + ); + _pathTransitions[bufferId] = transition; + _checkpointTimers.remove(bufferId)?.cancel(); + return transition; + } + + Future finishBufferPathTransition( + LocalHistoryBufferPathTransition transition, { + required bool committed, + }) async { + while (identical(_pathTransitions[transition.bufferId], transition)) { + final queued = _bufferQueues[transition.bufferId]; + if (queued == null) break; + await queued; + } + if (!identical(_pathTransitions[transition.bufferId], transition)) return; + _pathTransitions.remove(transition.bufferId); + final pending = _pending[transition.bufferId]; + if (committed && + pending != null && + _sameOptionalPath(pending.path, transition.sourcePath)) { + _pending[transition.bufferId] = pending.atPath( + transition.destinationPath, + ); + } + _scheduleCheckpoint(transition.bufferId); + } + Future captureSaved(LocalHistoryBufferSnapshot snapshot) async { return _enqueue( snapshot.bufferId, @@ -363,17 +408,19 @@ class LocalHistoryController extends Notifier { path: destinationPath, ); final captured = await _enqueue(source.bufferId, () async { - // A checkpoint created before Save As belongs to the source lineage. - // Settle it before changing this buffer's binding. Edits made after the - // workspace switched to the destination keep their destination snapshot - // and timer and will run after the binding changes below. + // A checkpoint included in the Save As target belongs to the source + // lineage. Newer edits remain suspended until the caller publishes the + // destination path and finishes the path transition. final pending = _pending[source.bufferId]; - if (pending != null && _sameOptionalPath(pending.path, source.path)) { + if (pending != null && + pending.revision <= source.revision && + _sameOptionalPath(pending.path, source.path)) { _pending.remove(source.bufferId); _checkpointTimers.remove(source.bufferId)?.cancel(); if (!await _capture( pending, LocalHistoryCaptureReason.automaticCheckpoint, + allowDuringPathTransition: true, )) { return false; } @@ -382,6 +429,7 @@ class LocalHistoryController extends Notifier { destination, LocalHistoryCaptureReason.saved, ignoreBinding: !source.untitled || destinationExisted, + allowPathChange: source.untitled && !destinationExisted, ); }); if (captured) { @@ -476,6 +524,7 @@ class LocalHistoryController extends Notifier { } final affected = >[]; for (final entry in _pending.entries) { + if (_pathTransitions.containsKey(entry.key)) continue; final path = entry.value.path; if (path != null && (p.equals(path, sourcePath) || p.isWithin(sourcePath, path))) { @@ -541,6 +590,7 @@ class LocalHistoryController extends Notifier { } _loadGeneration++; state = state.copyWith( + loading: false, selectedDocumentId: documentId, selectedRevisionId: null, selectedRevision: null, @@ -558,6 +608,7 @@ class LocalHistoryController extends Notifier { _loadGeneration++; _searchGeneration++; state = state.copyWith( + loading: false, selectedDocumentId: null, selectedRevisionId: null, selectedRevision: null, @@ -630,7 +681,11 @@ class LocalHistoryController extends Notifier { void clearComparison() { _loadGeneration++; - state = state.copyWith(selectedRevisionId: null, selectedRevision: null); + state = state.copyWith( + loading: false, + selectedRevisionId: null, + selectedRevision: null, + ); } String? bufferIdForDocument(String documentId) => _documentIdsByBuffer.entries @@ -700,7 +755,18 @@ class LocalHistoryController extends Notifier { LocalHistoryCaptureReason reason, { bool force = false, bool ignoreBinding = false, + bool allowPathChange = false, + bool allowDuringPathTransition = false, }) async { + if (reason == LocalHistoryCaptureReason.automaticCheckpoint && + !allowDuringPathTransition && + _pathTransitions.containsKey(snapshot.bufferId)) { + final pending = _pending[snapshot.bufferId]; + if (pending == null || pending.revision <= snapshot.revision) { + _pending[snapshot.bufferId] = snapshot; + } + return true; + } final currentPolicy = policy; if (!currentPolicy.recordingEnabled || currentPolicy.excludes(snapshot.path)) { @@ -725,6 +791,7 @@ class LocalHistoryController extends Notifier { reason: reason, untitled: snapshot.untitled, force: force, + allowPathChange: allowPathChange, ), currentPolicy, ); @@ -763,6 +830,33 @@ class LocalHistoryController extends Notifier { } _checkpointTimers.clear(); _pending.clear(); + _pathTransitions.clear(); + } + + void _scheduleCheckpoint(String bufferId) { + if (_pathTransitions.containsKey(bufferId) || + !_pending.containsKey(bufferId)) { + return; + } + _checkpointTimers.putIfAbsent( + bufferId, + () => _timerFactory(policy.checkpointInterval, () { + _checkpointTimers.remove(bufferId); + if (!ref.mounted || _pathTransitions.containsKey(bufferId)) return; + final latest = _pending.remove(bufferId); + if (latest != null) { + unawaited( + _enqueue( + bufferId, + () => _capture( + latest, + LocalHistoryCaptureReason.automaticCheckpoint, + ), + ), + ); + } + }), + ); } void _setWarning(LocalHistoryWarningKind kind, [String? detail]) { diff --git a/lib/src/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart index e2f05773..d69e206f 100644 --- a/lib/src/local_history/local_history_models.dart +++ b/lib/src/local_history/local_history_models.dart @@ -224,6 +224,7 @@ class LocalHistoryCaptureRequest { this.path, this.untitled = false, this.force = false, + this.allowPathChange = false, }); final String? documentId; @@ -235,6 +236,11 @@ class LocalHistoryCaptureRequest { final LocalHistoryCaptureReason reason; final bool untitled; final bool force; + + /// Path identity is normally immutable for an ID-bound capture. Save As + /// from a new untitled document is the one capture operation that promotes + /// that same identity to a filesystem path. + final bool allowPathChange; } @immutable diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index f9ca8c93..5efe8d88 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -69,7 +69,13 @@ class FileLocalHistoryStore implements LocalHistoryStore { updatedAt: request.capturedAt.toUtc(), untitled: request.untitled, ); - document = _updatedDocument(document, request); + final identity = _captureIdentity(document, request); + document = _updatedDocument( + document, + request, + path: identity.path, + preserveIdentityMetadata: identity.pathMismatchWasRejected, + ); final checksum = sourceChecksum(request.source); final adjacent = index.revisions .where((revision) => revision.documentId == document!.id) @@ -104,7 +110,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { checksum: checksum, storageBytes: 0, sourceLength: request.source.length, - historicalPath: request.path, + historicalPath: identity.path, ); final target = _revisionFile(root, document.id, revisionId); await target.parent.create(recursive: true); @@ -698,21 +704,56 @@ LocalHistoryDocument? _resolveDocument( LocalHistoryDocument _updatedDocument( LocalHistoryDocument document, - LocalHistoryCaptureRequest request, -) { - final path = request.path; + LocalHistoryCaptureRequest request, { + required String? path, + required bool preserveIdentityMetadata, +}) { return document.copyWith( - displayName: request.displayName, + displayName: preserveIdentityMetadata + ? document.displayName + : request.displayName, currentPath: path, historicalPaths: path == null ? document.historicalPaths : _uniquePaths([...document.historicalPaths, path]), updatedAt: request.capturedAt.toUtc(), deleted: false, - untitled: request.untitled && path == null, + untitled: preserveIdentityMetadata + ? document.untitled + : request.untitled && path == null, + ); +} + +_CaptureIdentity _captureIdentity( + LocalHistoryDocument document, + LocalHistoryCaptureRequest request, +) { + final boundById = + request.documentId != null && request.documentId == document.id; + final pathMismatch = !_sameOptionalPath(document.currentPath, request.path); + final rejectPathChange = + boundById && pathMismatch && !request.allowPathChange; + return _CaptureIdentity( + path: rejectPathChange ? document.currentPath : request.path, + pathMismatchWasRejected: rejectPathChange, ); } +class _CaptureIdentity { + const _CaptureIdentity({ + required this.path, + required this.pathMismatchWasRejected, + }); + + final String? path; + final bool pathMismatchWasRejected; +} + +bool _sameOptionalPath(String? first, String? second) { + if (first == null || second == null) return first == second; + return p.equals(first, second); +} + String? _remap(String current, String source, String destination) { if (p.equals(current, source)) return p.normalize(destination); if (!p.isWithin(source, current)) return null; @@ -782,7 +823,13 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { updatedAt: request.capturedAt.toUtc(), untitled: request.untitled, ); - document = _updatedDocument(document, request); + final identity = _captureIdentity(document, request); + document = _updatedDocument( + document, + request, + path: identity.path, + preserveIdentityMetadata: identity.pathMismatchWasRejected, + ); _documents[document.id] = document; final checksum = sourceChecksum(request.source); final adjacent = _revisions.values @@ -816,7 +863,7 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { checksum: checksum, storageBytes: bytes, sourceLength: request.source.length, - historicalPath: request.path, + historicalPath: identity.path, ); _revisions[summary.id] = LocalHistoryRevision( summary: summary, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 51b3ed9d..5fff22c4 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -788,6 +788,13 @@ class WorkspaceController extends Notifier { if (oldPath == null) { return; } + final historyTransition = _localHistory.beginBufferPathTransition( + bufferId: current.id, + sourcePath: oldPath, + destinationPath: destinationPath, + ); + var historyPathCommitted = false; + var historyTransitionFinished = false; try { final disk = await _service.loadTextWithSnapshot(destinationPath); if (_externalOperationBuffer(current, oldPath) == null) return; @@ -847,22 +854,45 @@ class WorkspaceController extends Notifier { openFilePaths: remappedTabs, ), ); + historyPathCommitted = true; _fileMonitor.updateOpenFilePaths( state.documentBuffers .map((buffer) => buffer.filePath) .whereType(), ); - if (active && state.workspace != null) { + await _localHistory.finishBufferPathTransition( + historyTransition, + committed: true, + ); + historyTransitionFinished = true; + final derivedWorkspace = state.workspace; + final derivedOperationRevision = _activeDocumentRevision; + if (active && + derivedWorkspace != null && + _canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: remapped.id, + path: destinationPath, + revision: remapped.revision, + source: remapped.text, + )) { final reparsed = await _service.reparseActive( - state.workspace!, + derivedWorkspace, remapped.text, ); - if (state.activeBufferId == latest.id && - state.activeBuffer?.filePath == destinationPath) { + if (_canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: remapped.id, + path: destinationPath, + revision: remapped.revision, + source: remapped.text, + )) { state = state.copyWith( workspace: reparsed.copyWith( activeFileSnapshot: remapped.diskSnapshot, - openFilePaths: remappedTabs, + openFilePaths: state.workspace!.openFilePaths, ), preview: _safePreview(reparsed, remapped.text), ); @@ -879,6 +909,13 @@ class WorkspaceController extends Notifier { } } on FormatException { // Keep the old buffer and path when the move target cannot be decoded. + } finally { + if (!historyTransitionFinished) { + await _localHistory.finishBufferPathTransition( + historyTransition, + committed: historyPathCommitted, + ); + } } } @@ -931,25 +968,35 @@ class WorkspaceController extends Notifier { recovered: false, ); _updateBufferFromMonitor(reloaded); - if (state.activeBufferId == bufferId && state.workspace != null) { + final derivedWorkspace = state.workspace; + final derivedOperationRevision = _activeDocumentRevision; + if (derivedWorkspace != null && + _canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: reloaded.id, + path: path, + revision: reloaded.revision, + source: reloaded.text, + )) { final workspace = await _service.reparseActive( - state.workspace!.copyWith(activeFileSnapshot: disk.snapshot), + derivedWorkspace.copyWith(activeFileSnapshot: disk.snapshot), disk.text, ); - final current = state.documentBuffers - .where((candidate) => candidate.id == bufferId) - .firstOrNull; - if (current == null || - current.filePath != path || - current.revision != reloaded.revision || - current.text != reloaded.text) { - return false; + if (_canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: reloaded.id, + path: path, + revision: reloaded.revision, + source: reloaded.text, + )) { + state = state.copyWith( + workspace: workspace, + preview: _safePreview(workspace, disk.text), + ); + _recordActivePreviewRevision(); } - state = state.copyWith( - workspace: workspace, - preview: _safePreview(workspace, disk.text), - ); - _recordActivePreviewRevision(); } return true; } @@ -1406,9 +1453,19 @@ class WorkspaceController extends Notifier { final activeFilePath = state.workspace?.activeFilePath; return _runWorkspaceFileOperation((workspace) async { final target = await _service.renameEntity(workspace, path, newName); - _remapOpenWorkspacePaths(workspace, path, target); - await _localHistory.remapPath(path, target); - return _remapMovedPath(activeFilePath, path, target); + final transitions = _beginLocalHistoryPathTransitions(path, target); + var committed = false; + try { + await _localHistory.remapPath(path, target); + _remapOpenWorkspacePaths(workspace, path, target); + committed = true; + return _remapMovedPath(activeFilePath, path, target); + } finally { + await _finishLocalHistoryPathTransitions( + transitions, + committed: committed, + ); + } }); } @@ -1423,9 +1480,19 @@ class WorkspaceController extends Notifier { sourcePath, targetDirectoryPath, ); - _remapOpenWorkspacePaths(workspace, sourcePath, target); - await _localHistory.remapPath(sourcePath, target); - return _remapMovedPath(activeFilePath, sourcePath, target); + final transitions = _beginLocalHistoryPathTransitions(sourcePath, target); + var committed = false; + try { + await _localHistory.remapPath(sourcePath, target); + _remapOpenWorkspacePaths(workspace, sourcePath, target); + committed = true; + return _remapMovedPath(activeFilePath, sourcePath, target); + } finally { + await _finishLocalHistoryPathTransitions( + transitions, + committed: committed, + ); + } }); } @@ -1575,8 +1642,19 @@ class WorkspaceController extends Notifier { topicPath, newFileName, ); - _remapOpenWorkspacePaths(workspace, topicPath, target); - return _remapMovedPath(activeFilePath, topicPath, target); + final transitions = _beginLocalHistoryPathTransitions(topicPath, target); + var committed = false; + try { + await _localHistory.remapPath(topicPath, target); + _remapOpenWorkspacePaths(workspace, topicPath, target); + committed = true; + return _remapMovedPath(activeFilePath, topicPath, target); + } finally { + await _finishLocalHistoryPathTransitions( + transitions, + committed: committed, + ); + } }); } @@ -2890,6 +2968,13 @@ class WorkspaceController extends Notifier { required bool overwriteExisting, required LineEndingNormalization? mixedLineEndingNormalization, }) async { + LocalHistoryBufferPathTransition? historyTransition = _localHistory + .beginBufferPathTransition( + bufferId: target.bufferId, + sourcePath: target.path, + destinationPath: path, + ); + var historyPathCommitted = false; try { final destinationExisted = overwriteExisting && await _service.pathExists(path); @@ -2997,6 +3082,31 @@ class WorkspaceController extends Notifier { documentBuffers: buffers, clearMessage: true, ); + historyPathCommitted = true; + await _localHistory.captureSavedAs( + LocalHistoryBufferSnapshot( + bufferId: target.bufferId, + displayName: target.path == null + ? state.documentBuffers + .where((buffer) => buffer.id == target.bufferId) + .firstOrNull + ?.displayName ?? + p.basename(path) + : p.basename(target.path!), + text: target.text, + format: savedFormat, + revision: target.editRevision, + path: target.path, + untitled: target.path == null, + ), + path, + destinationExisted: destinationExisted, + ); + await _localHistory.finishBufferPathTransition( + historyTransition, + committed: true, + ); + historyTransition = null; await _startMonitoring(savedWorkspace); if (hasNewerEdits) { if (state.activeBufferId == savedBuffer.id && @@ -3023,25 +3133,6 @@ class WorkspaceController extends Notifier { kind: savedWorkspace.kind.name, ); _schedulePersistence(); - await _localHistory.captureSavedAs( - LocalHistoryBufferSnapshot( - bufferId: target.bufferId, - displayName: target.path == null - ? state.documentBuffers - .where((buffer) => buffer.id == target.bufferId) - .firstOrNull - ?.displayName ?? - p.basename(path) - : p.basename(target.path!), - text: target.text, - format: savedFormat, - revision: target.editRevision, - path: target.path, - untitled: target.path == null, - ), - path, - destinationExisted: destinationExisted, - ); return true; } on Object catch (error) { if (_workspaceContainsBuffer(target.workspaceId, target.bufferId)) { @@ -3053,6 +3144,14 @@ class WorkspaceController extends Notifier { ); } return false; + } finally { + final unfinishedTransition = historyTransition; + if (unfinishedTransition != null) { + await _localHistory.finishBufferPathTransition( + unfinishedTransition, + committed: historyPathCommitted, + ); + } } } @@ -3721,6 +3820,38 @@ class WorkspaceController extends Notifier { ); } + List _beginLocalHistoryPathTransitions( + String sourcePath, + String targetPath, + ) { + final transitions = []; + for (final buffer in state.documentBuffers) { + final currentPath = buffer.filePath; + final destination = _remapMovedPath(currentPath, sourcePath, targetPath); + if (currentPath == null || destination == null) continue; + transitions.add( + _localHistory.beginBufferPathTransition( + bufferId: buffer.id, + sourcePath: currentPath, + destinationPath: destination, + ), + ); + } + return transitions; + } + + Future _finishLocalHistoryPathTransitions( + Iterable transitions, { + required bool committed, + }) async { + for (final transition in transitions) { + await _localHistory.finishBufferPathTransition( + transition, + committed: committed, + ); + } + } + Future validateActive() => _validateActive(rebuildPreview: true); Future _validateActive({required bool rebuildPreview}) async { @@ -3978,6 +4109,27 @@ class WorkspaceController extends Notifier { workspace.activeFilePath == activeFilePath; } + bool _canPublishActiveDerivedContent({ + required int operationRevision, + required String workspaceId, + required String bufferId, + required String? path, + required int revision, + required String source, + }) { + final buffer = state.activeBuffer; + return _isCurrentActiveDocument( + operationRevision, + workspaceId: workspaceId, + activeFilePath: path, + ) && + state.activeBufferId == bufferId && + buffer != null && + buffer.filePath == path && + buffer.revision == revision && + buffer.text == source; + } + bool _workspaceContainsBuffer(String workspaceId, String bufferId) { return state.workspace?.id == workspaceId && state.documentBuffers.any((buffer) => buffer.id == bufferId); diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index ca977592..8b29ccff 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -79,6 +79,43 @@ void main() { expect(button.onPressed, isNull); }); + testWidgets( + 'plain-text paste remains enabled when rich paste is unavailable', + (tester) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'Readable fallback', + sourceText: '**Readable fallback**', + ), + ); + final target = _PanelInsertionTarget( + normalPasteAvailable: false, + plainTextPasteAvailable: true, + ); + container.read(clipboardInsertionRegistryProvider).register(target); + await _pumpPanel(tester, container); + + final paste = find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ); + final pastePlain = find.byWidgetPredicate( + (widget) => + widget is IconButton && widget.tooltip == 'Paste as Plain Text', + ); + expect(tester.widget(paste).onPressed, isNull); + expect(tester.widget(pastePlain).onPressed, isNotNull); + + await tester.tap(pastePlain); + await tester.pump(); + expect(target.pasteCalls, 1); + expect(target.lastPlainText, isTrue); + }, + ); + testWidgets('failed insertion reports visible feedback', (tester) async { final container = _container(); container @@ -157,12 +194,17 @@ class _PanelInsertionTarget BusyMarkClipboardInsertionCapabilities { _PanelInsertionTarget({ this.supportImages = true, + this.normalPasteAvailable, + this.plainTextPasteAvailable, this.result = ClipboardPasteResult.inserted, }); final bool supportImages; + final bool? normalPasteAvailable; + final bool? plainTextPasteAvailable; final ClipboardPasteResult result; int pasteCalls = 0; + bool? lastPlainText; @override String get documentId => 'panel-target'; @@ -177,8 +219,13 @@ class _PanelInsertionTarget bool get editable => true; @override - bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) => - supportImages || payload.kind != BusyMarkClipboardContentKind.image; + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) { + final configured = plainText + ? plainTextPasteAvailable + : normalPasteAvailable; + return configured ?? + (supportImages || payload.kind != BusyMarkClipboardContentKind.image); + } @override Future paste( @@ -186,6 +233,7 @@ class _PanelInsertionTarget required bool plainText, }) async { pasteCalls++; + lastPlainText = plainText; return result; } diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 26ce7c83..51cfe69b 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1,12 +1,15 @@ import 'dart:async'; +import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.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/local_history/local_history_panel.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_file_snapshot.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -201,53 +204,85 @@ void main() { }, ); - test('a delayed revision read cannot cross the selected document', () async { - final memory = MemoryLocalHistoryStore(); - await _capturePath(memory, '/workspace/a.md', 'revision A'); - await _capturePath(memory, '/workspace/b.md', 'revision B'); - final initial = await memory.load(); - final documentA = initial.documents.singleWhere( - (document) => document.currentPath == '/workspace/a.md', - ); - final documentB = initial.documents.singleWhere( - (document) => document.currentPath == '/workspace/b.md', - ); - final revisionA = initial.revisionsFor(documentA.id).single; - final store = _BlockingRevisionReadStore(memory, revisionA.id); - final container = ProviderContainer( - overrides: [ - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - localHistoryStoreProvider.overrideWithValue(store), - localHistoryClockProvider.overrideWithValue( - () => DateTime.utc(2026, 1, 2), - ), - ], - ); - addTearDown(container.dispose); - final controller = container.read(localHistoryControllerProvider.notifier); - await Future.delayed(Duration.zero); - await controller.refresh(); + testWidgets( + 'a delayed revision read cannot cross the selected document or leave loading stuck', + (tester) async { + late ProviderContainer container; + final state = (await tester.runAsync(() async { + final memory = MemoryLocalHistoryStore(); + await _capturePath(memory, '/workspace/a.md', 'revision A'); + await _capturePath(memory, '/workspace/b.md', 'revision B'); + final initial = await memory.load(); + final documentA = initial.documents.singleWhere( + (document) => document.currentPath == '/workspace/a.md', + ); + final documentB = initial.documents.singleWhere( + (document) => document.currentPath == '/workspace/b.md', + ); + final revisionA = initial.revisionsFor(documentA.id).single; + final store = _BlockingRevisionReadStore(memory, revisionA.id); + container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(), + ), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 2), + ), + ], + ); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + expect( + container.read(localHistoryControllerProvider).snapshot.revisions, + hasLength(2), + ); + controller.selectDocument(documentA.id); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + documentA.id, + ); + final selection = controller.selectRevision(revisionA.id); + await store.started.future; + controller.selectDocument(documentB.id); + expect(container.read(localHistoryControllerProvider).loading, isFalse); + store.release.complete(); + await selection; + return container.read(localHistoryControllerProvider); + }))!; + addTearDown(container.dispose); - expect( - container.read(localHistoryControllerProvider).snapshot.revisions, - hasLength(2), - ); - controller.selectDocument(documentA.id); - expect( - container.read(localHistoryControllerProvider).selectedDocumentId, - documentA.id, - ); - final selection = controller.selectRevision(revisionA.id); - await store.started.future; - controller.selectDocument(documentB.id); - store.release.complete(); - await selection; - - final state = container.read(localHistoryControllerProvider); - expect(state.selectedDocumentId, documentB.id); - expect(state.selectedRevisionId, isNull); - expect(state.selectedRevision, isNull); - }); + expect(state.selectedDocument?.currentPath, '/workspace/b.md'); + expect(state.selectedRevisionId, isNull); + expect(state.selectedRevision, isNull); + expect(state.loading, isFalse); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: LocalHistoryPanel()), + ), + ), + ); + await tester.pump(); + final context = tester.element(find.byType(LocalHistoryPanel)); + final refreshLabel = MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel; + final refresh = find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == refreshLabel, + ); + expect(tester.widget(refresh).onPressed, isNotNull); + }, + ); test( 'Save As settles pending named and untitled checkpoints safely', diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index a48a826d..5546dc90 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -240,6 +240,33 @@ void main() { }, ); + test('ID-bound captures cannot implicitly move document paths', () async { + final first = await store.capture( + request('before move', DateTime.utc(2026, 1, 1), path: '/old/A.md'), + policy, + ); + await store.remapPath('/old/A.md', '/new/B.md'); + + final lateCheckpoint = await store.capture( + LocalHistoryCaptureRequest( + documentId: first.document.id, + path: '/old/A.md', + displayName: 'A.md', + source: 'edit queued before remap completed', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1, 0, 1), + reason: LocalHistoryCaptureReason.automaticCheckpoint, + ), + policy, + ); + + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.currentPath, '/new/B.md'); + expect(lateCheckpoint.document.currentPath, '/new/B.md'); + expect(lateCheckpoint.revision!.historicalPath, '/new/B.md'); + }); + test('serializes concurrent captures without losing index entries', () async { final time = DateTime.utc(2026, 1, 1); await Future.wait([ diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 0fc78bee..5e23361a 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -12,6 +12,7 @@ import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -243,6 +244,47 @@ void main() { }, ); + test( + 'reload reparse cannot publish after another buffer becomes active', + () async { + final aPath = p.join(root.path, 'A.md'); + final bPath = p.join(root.path, 'B.md'); + await File(aPath).writeAsString('# A before reload\n'); + await File(bPath).writeAsString('# B active\n'); + final service = _BlockingNextReparseWorkspaceService(); + final harness = await _harness( + MemoryLocalHistoryStore(), + service: service, + ); + await harness.controller.openPath(root.path); + expect(await harness.controller.openActiveFile(bPath), isTrue); + expect(await harness.controller.openActiveFile(aPath), isTrue); + final aId = harness.state.activeBuffer!.id; + await File(aPath).writeAsString('# A reloaded\n'); + + service.pauseNext(); + final reload = harness.controller.reloadBufferFromDisk(aId); + await service.started.future; + expect(await harness.controller.openActiveFile(bPath), isTrue); + final bWorkspace = harness.state.workspace; + final bPreview = harness.state.preview; + + service.release(); + expect(await reload, isTrue); + expect(harness.state.activeBuffer!.filePath, bPath); + expect(harness.state.workspace?.activeFilePath, bPath); + expect(harness.state.workspace?.markdown?.filePath, bPath); + expect(harness.state.workspace, same(bWorkspace)); + expect(harness.state.preview, same(bPreview)); + expect( + harness.state.documentBuffers + .singleWhere((buffer) => buffer.id == aId) + .text, + '# A reloaded\n', + ); + }, + ); + test('delete skips binary and excluded history descendants', () async { final image = File(p.join(root.path, 'image.png')); final imageFolder = Directory(p.join(root.path, 'image-folder')); @@ -387,6 +429,163 @@ void main() { }, ); + test( + 'external move keeps edits made during history remap on the destination', + () async { + final sourcePath = p.join(root.path, 'A.md'); + final destinationPath = p.join(root.path, 'B.md'); + await File(sourcePath).writeAsString('Original\n'); + final memory = MemoryLocalHistoryStore(); + final store = _BlockingRemapStore(memory); + final monitor = _ControlledFileMonitor(); + final timers = <_FakeTimer>[]; + final harness = await _harness( + store, + fileMonitor: monitor, + timerFactory: (delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }, + ); + await harness.controller.openPath(sourcePath); + harness.controller.updateActiveText('Edit before move\n'); + await Future.delayed(Duration.zero); + + await File(sourcePath).rename(destinationPath); + monitor.emitMove(sourcePath, destinationPath); + await store.started.future; + expect(harness.state.activeBuffer!.filePath, sourcePath); + harness.controller.updateActiveText('Edit while remap is paused\n'); + await Future.delayed(Duration.zero); + for (final timer in timers) { + timer.fire(); + } + + store.release.complete(); + await _waitFor( + () => + harness.state.activeBuffer?.filePath == destinationPath && + timers.any((timer) => timer.isActive), + ); + for (final timer in List<_FakeTimer>.of(timers)) { + timer.fire(); + } + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + + final snapshot = await memory.load(); + expect(snapshot.documents.map((document) => document.currentPath), [ + destinationPath, + ]); + final destination = snapshot.documents.single; + expect( + await _revisionSources(memory, snapshot.revisionsFor(destination.id)), + contains('Edit while remap is paused\n'), + ); + }, + ); + + test('external move reparse cannot publish after a newer edit', () async { + final sourcePath = p.join(root.path, 'A.md'); + final destinationPath = p.join(root.path, 'B.md'); + await File(sourcePath).writeAsString('# Before move\n'); + final monitor = _ControlledFileMonitor(); + final service = _BlockingNextReparseWorkspaceService(); + final harness = await _harness( + MemoryLocalHistoryStore(), + service: service, + fileMonitor: monitor, + ); + await harness.controller.openPath(sourcePath); + harness.controller.updateActiveEditorMode( + DocumentViewModePreference.source, + ); + + service.pauseNext(); + await File(sourcePath).rename(destinationPath); + monitor.emitMove(sourcePath, destinationPath); + await service.started.future; + expect(harness.state.activeBuffer!.filePath, destinationPath); + final revisionBeforeEdit = harness.state.activeBuffer!.revision; + harness.controller.updateActiveText('# Edited during reparse\n'); + await Future.delayed(Duration.zero); + final workspaceAfterEdit = harness.state.workspace; + final previewAfterEdit = harness.state.preview; + + service.release(); + await service.finished.future; + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(harness.state.activeBuffer!.filePath, destinationPath); + expect(harness.state.activeText, '# Edited during reparse\n'); + expect( + harness.state.activeBuffer!.revision, + greaterThan(revisionBeforeEdit), + ); + expect(harness.state.workspace, same(workspaceAfterEdit)); + expect(harness.state.preview, same(previewAfterEdit)); + }); + + test( + 'Save As keeps edits made during destination capture out of source history', + () async { + final sourcePath = p.join(root.path, 'A.md'); + final destinationPath = p.join(root.path, 'B.md'); + await File(sourcePath).writeAsString('Original\n'); + final memory = MemoryLocalHistoryStore(); + final store = _BlockingSaveAsStore(memory, destinationPath); + final timers = <_FakeTimer>[]; + final harness = await _harness( + store, + timerFactory: (delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }, + ); + await harness.controller.openPath(sourcePath); + harness.controller.updateActiveText('Save target\n'); + await Future.delayed(Duration.zero); + + final save = harness.controller.saveActiveAs(destinationPath); + await store.started.future; + expect(harness.state.activeBuffer!.filePath, destinationPath); + harness.controller.updateActiveText('Newer destination edit\n'); + await Future.delayed(Duration.zero); + for (final timer in timers) { + timer.fire(); + } + + store.release.complete(); + expect(await save, isTrue); + for (final timer in List<_FakeTimer>.of(timers)) { + timer.fire(); + } + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + + final snapshot = await memory.load(); + final source = snapshot.documents.singleWhere( + (document) => document.currentPath == sourcePath, + ); + final destination = snapshot.documents.singleWhere( + (document) => document.currentPath == destinationPath, + ); + expect(source.id, isNot(destination.id)); + expect( + await _revisionSources(memory, snapshot.revisionsFor(source.id)), + isNot(contains('Newer destination edit\n')), + ); + expect( + await _revisionSources(memory, snapshot.revisionsFor(destination.id)), + containsAll(['Save target\n', 'Newer destination edit\n']), + ); + }, + ); + testWidgets( 'comparison renders styled intraline spans without framework errors', (tester) async { @@ -437,6 +636,77 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'comparison restore actions wait for the current replacement result', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final path = p.join(root.path, 'pending-comparison.md'); + final computer = _BlockingReplacementComparisonComputer(); + final harness = (await tester.runAsync(() async { + await File(path).writeAsString('Current line\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'Historical line\n'); + final harness = await _harness( + store, + comparisonComputer: computer.call, + ); + await harness.controller.openPath(path); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.selectDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return harness; + }))!; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: harness.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 context = tester.element(find.byType(LocalHistoryComparisonView)); + final l10n = AppLocalizations.of(context); + final restoreAll = find.byWidgetPredicate( + (widget) => + widget is IconButton && + widget.tooltip == l10n.localHistoryRestoreRevision, + ); + final restoreChange = find.ancestor( + of: find.text('${l10n.localHistoryRestoreChange} 1'), + matching: find.byType(OutlinedButton), + ); + expect(tester.widget(restoreAll).onPressed, isNotNull); + expect(tester.widget(restoreChange).onPressed, isNotNull); + + harness.controller.updateActiveText('Replacement current line\n'); + await tester.pump(); + await tester.runAsync(() => computer.replacementStarted.future); + await tester.pump(); + + expect(tester.widget(restoreAll).onPressed, isNull); + expect(tester.widget(restoreChange).onPressed, isNull); + + computer.completeReplacement(); + await tester.pumpAndSettle(); + expect(tester.widget(restoreAll).onPressed, isNotNull); + expect(tester.widget(restoreChange).onPressed, isNotNull); + }, + ); } SourceComparison _comparison( @@ -457,6 +727,28 @@ SourceComparison _comparison( ), ); +Future> _revisionSources( + LocalHistoryStore store, + Iterable summaries, +) async { + final sources = []; + for (final summary in summaries) { + final revision = await store.readRevision(summary.id); + if (revision != null) sources.add(revision.source); + } + return sources; +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for workspace state'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + Future _capture( LocalHistoryStore store, String path, @@ -477,6 +769,9 @@ Future _capture( Future<_Harness> _harness( LocalHistoryStore store, { WorkspaceService service = const WorkspaceService(), + WorkspaceFileMonitor? fileMonitor, + LocalHistoryTimerFactory? timerFactory, + LocalHistoryComparisonComputer? comparisonComputer, }) async { final container = ProviderContainer( overrides: [ @@ -486,6 +781,14 @@ Future<_Harness> _harness( () => DateTime.utc(2026, 1, 1, 0, 1), ), workspaceServiceProvider.overrideWithValue(service), + if (fileMonitor != null) + workspaceFileMonitorProvider.overrideWithValue(fileMonitor), + if (timerFactory != null) + localHistoryTimerFactoryProvider.overrideWithValue(timerFactory), + if (comparisonComputer != null) + localHistoryComparisonComputerProvider.overrideWithValue( + comparisonComputer, + ), ], ); addTearDown(container.dispose); @@ -592,6 +895,147 @@ class _BlockingBeforeReloadStore extends _FailingProtectiveStore { } } +class _BlockingNextReparseWorkspaceService extends WorkspaceService { + var _pauseNext = false; + final started = Completer(); + final finished = Completer(); + final _release = Completer(); + + void pauseNext() => _pauseNext = true; + + void release() { + if (!_release.isCompleted) _release.complete(); + } + + @override + Future reparseActive(Workspace workspace, String source) async { + final reparsed = await super.reparseActive(workspace, source); + if (!_pauseNext) return reparsed; + _pauseNext = false; + if (!started.isCompleted) started.complete(); + await _release.future; + if (!finished.isCompleted) finished.complete(); + return reparsed; + } +} + +class _BlockingReplacementComparisonComputer { + var _calls = 0; + final replacementStarted = Completer(); + final _replacement = Completer(); + late SourceComparisonInput _replacementOld; + late SourceComparisonInput _replacementCurrent; + + Future call( + SourceComparisonInput oldInput, + SourceComparisonInput currentInput, + ) { + if (_calls++ == 0) { + return Future.value(compareSource(oldInput, currentInput)); + } + _replacementOld = oldInput; + _replacementCurrent = currentInput; + if (!replacementStarted.isCompleted) replacementStarted.complete(); + return _replacement.future; + } + + void completeReplacement() { + if (_replacement.isCompleted) return; + _replacement.complete(compareSource(_replacementOld, _replacementCurrent)); + } +} + +class _BlockingRemapStore extends _FailingProtectiveStore { + _BlockingRemapStore(super.delegate); + + final started = Completer(); + final release = Completer(); + + @override + Future remapPath(String sourcePath, String destinationPath) async { + if (!started.isCompleted) started.complete(); + await release.future; + await delegate.remapPath(sourcePath, destinationPath); + } +} + +class _BlockingSaveAsStore extends _FailingProtectiveStore { + _BlockingSaveAsStore(super.delegate, this.destinationPath); + + final String destinationPath; + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.saved && + request.path != null && + p.equals(request.path!, destinationPath)) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return delegate.capture(request, policy); + } +} + +class _ControlledFileMonitor extends WorkspaceFileMonitor { + final _events = StreamController.broadcast(); + + @override + Stream get events => _events.stream; + + void emitMove(String sourcePath, String destinationPath) { + _events.add( + WorkspaceFileMonitorEvent( + kind: WorkspaceFileEventKind.moved, + path: sourcePath, + destinationPath: destinationPath, + ), + ); + } + + @override + Future start({ + required String rootPath, + required Iterable openFilePaths, + }) async {} + + @override + void updateOpenFilePaths(Iterable paths) {} + + @override + Future stop() async {} + + @override + Future dispose() => _events.close(); +} + +class _FakeTimer implements Timer { + _FakeTimer(this.duration, this.callback); + + final Duration duration; + final void Function() callback; + var _active = true; + + void fire() { + if (!_active) return; + _active = false; + callback(); + } + + @override + void cancel() => _active = false; + + @override + bool get isActive => _active; + + @override + int get tick => _active ? 0 : 1; +} + class _DelayedSaveWorkspaceService extends WorkspaceService { final started = Completer(); final _release = Completer(); diff --git a/test/src/source_commands_test.dart b/test/src/source_commands_test.dart index b7c8eeca..ec13c556 100644 --- a/test/src/source_commands_test.dart +++ b/test/src/source_commands_test.dart @@ -197,6 +197,14 @@ void main() { ).text, '![Logo](url)', ); + expect( + SourceCommands.writersideImageReference( + alt: 'Diagram & "flow"', + sourceReference: 'images/a&b.png', + ), + '', + ); }); test('block insertion accepts localized default content', () { diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 6549eb20..8c7a6e92 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1628,6 +1628,129 @@ void main() { isTrue, ); }); + + testWidgets( + 'standalone image history ingests and inserts one undoable Source edit', + (tester) async { + final root = (await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-source-image-history-'), + ))!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final filePath = '${root.path}/target.md'; + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + const source = 'Before after'; + var transactionCount = 0; + String modelText = source; + TextEditingValue? undoValue; + final controller = await _pumpClipboardSourceEditor( + tester, + source: source, + clipboard: _SourceTestClipboard(), + registry: registry, + filePath: filePath, + workspaceRoot: root.path, + assetWorkspaceKind: AssetWorkspaceKind.markdownWorkspace, + onTransactionalChanged: + (value, _, previousSelection, selection, undoGroup) { + transactionCount++; + undoValue = TextEditingValue( + text: modelText, + selection: previousSelection, + ); + modelText = value; + }, + onUndo: () { + final value = undoValue; + if (value != null) { + modelText = value.text; + undoValue = null; + } + return value; + }, + ); + controller.selection = const TextSelection.collapsed(offset: 7); + final payload = BusyMarkClipboardPayload( + id: 'source-image-history', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.image, + imageBytes: Uint8List.fromList(const [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ]), + imageMimeType: 'image/png', + imageDisplayName: 'screenshot.png', + ); + + expect(registry.canPaste(payload), isTrue); + expect( + await tester.runAsync(() => registry.paste(payload)), + ClipboardPasteResult.inserted, + ); + expect(modelText, 'Before ![Image](images/screenshot.png)after'); + expect(transactionCount, 1); + final asset = File('${root.path}/images/screenshot.png'); + expect(await tester.runAsync(asset.exists), isTrue); + + await _pressControlKey(tester, LogicalKeyboardKey.keyZ); + await tester.pump(); + expect(controller.text, source); + expect(modelText, source); + expect(transactionCount, 1); + expect(await tester.runAsync(asset.exists), isTrue); + }, + ); + + testWidgets('image history asks to save an untitled Source document', ( + tester, + ) async { + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + var saveRequests = 0; + var changes = 0; + await _pumpClipboardSourceEditor( + tester, + source: 'Untitled', + clipboard: _SourceTestClipboard(), + registry: registry, + filePath: null, + onChanged: (_, _) => changes++, + onAssetSaveRequired: () => saveRequests++, + ); + final payload = BusyMarkClipboardPayload( + id: 'untitled-source-image-history', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.image, + imageBytes: Uint8List.fromList(const [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + ]), + imageMimeType: 'image/png', + imageDisplayName: 'screenshot.png', + ); + + expect(registry.canPaste(payload), isTrue); + expect( + await tester.runAsync(() => registry.paste(payload)), + ClipboardPasteResult.unsupported, + ); + expect(saveRequests, 1); + expect(changes, 0); + }); } WysiwygClipboardFragment _structuredImageFragment(String sourcePath) { @@ -1734,9 +1857,12 @@ Future _pumpClipboardSourceEditor( BusyMarkClipboardInsertionRegistry? registry, ValueChanged? onCaptured, BusyMarkSourceChanged? onChanged, - String filePath = '/project/source.md', + BusyMarkSourceTransactionalChanged? onTransactionalChanged, + TextEditingValue? Function()? onUndo, + String? filePath = '/project/source.md', String? workspaceRoot, AssetWorkspaceKind? assetWorkspaceKind, + VoidCallback? onAssetSaveRequired, }) async { await tester.pumpWidget( MaterialApp( @@ -1764,11 +1890,14 @@ Future _pumpClipboardSourceEditor( searchOptions: const SourceSearchOptions(), onSearchOptionsChanged: (_) {}, onChanged: onChanged ?? (_, _) {}, + onTransactionalChanged: onTransactionalChanged, + onUndo: onUndo, onOpenSearch: () {}, onCloseSearch: () {}, clipboardService: clipboard, clipboardInsertionRegistry: registry, onClipboardCaptured: onCaptured, + onAssetSaveRequired: onAssetSaveRequired, ), ), ), From 740b3d0d593e8a56345a46dc51ecc7c75a62afd6 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 13:45:25 -0700 Subject: [PATCH 05/27] Add support for promoting untitled documents within Local History - Introduced `_PendingUntitledPromotion` to track and manage untitled document promotions. - Added `_pendingUntitledPromotions` map to monitor pending promotions. - Implemented `promoteUntitledDocument` method in `LocalHistoryStore` and `LocalHistoryController`. - Updated path and buffer transition logic to handle untitled document promotions effectively. - Enhanced state cleanup and validation during document clearing and promotion processes. --- .../local_history_comparison_view.dart | 9 +- .../local_history_controller.dart | 82 +++++++- .../local_history/local_history_store.dart | 72 +++++++ lib/src/workspace/workspace_controller.dart | 2 + test/src/local_history_controller_test.dart | 197 ++++++++++++++++++ test/src/local_history_store_test.dart | 51 +++++ test/src/local_history_workspace_test.dart | 96 +++++++++ 7 files changed, 505 insertions(+), 4 deletions(-) diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index 06092e62..ce5f0260 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -63,6 +63,7 @@ class _LocalHistoryComparisonViewState revisionVersion: revision.summary.capturedAt.microsecondsSinceEpoch, currentBufferId: matchingBuffer?.id, currentSourceVersion: matchingBuffer?.revision, + currentSource: matchingBuffer?.text, currentPath: document.currentPath, ); if (_request != request) { @@ -263,6 +264,7 @@ class _ComparisonRequest { required this.revisionVersion, required this.currentBufferId, required this.currentSourceVersion, + required this.currentSource, required this.currentPath, }); @@ -271,6 +273,7 @@ class _ComparisonRequest { final int revisionVersion; final String? currentBufferId; final int? currentSourceVersion; + final String? currentSource; final String? currentPath; @override @@ -281,6 +284,7 @@ class _ComparisonRequest { other.revisionVersion == revisionVersion && other.currentBufferId == currentBufferId && other.currentSourceVersion == currentSourceVersion && + other.currentSource == currentSource && other.currentPath == currentPath; @override @@ -290,6 +294,7 @@ class _ComparisonRequest { revisionVersion, currentBufferId, currentSourceVersion, + currentSource, currentPath, ); } @@ -322,7 +327,9 @@ class _ComparisonSnapshot { } if (currentBuffer == null) return request.currentBufferId == null; return comparison.currentInput.id == currentBuffer.id && - comparison.currentInput.version == currentBuffer.revision; + comparison.currentInput.version == currentBuffer.revision && + comparison.currentInput.source == currentBuffer.text && + request.currentSource == currentBuffer.text; } } diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 3ed0dde9..2b28865f 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -211,6 +211,18 @@ class LocalHistoryBufferPathTransition { final String destinationPath; } +class _PendingUntitledPromotion { + const _PendingUntitledPromotion({ + required this.documentId, + required this.destinationPath, + required this.displayName, + }); + + final String documentId; + final String destinationPath; + final String displayName; +} + class LocalHistoryController extends Notifier { late LocalHistoryStore _store; late LocalHistoryClock _clock; @@ -220,6 +232,7 @@ class LocalHistoryController extends Notifier { final _checkpointTimers = {}; final _bufferQueues = >{}; final _pathTransitions = {}; + final _pendingUntitledPromotions = {}; var _loadGeneration = 0; var _searchGeneration = 0; @@ -407,6 +420,7 @@ class LocalHistoryController extends Notifier { revision: source.revision, path: destinationPath, ); + var promotionCompleted = false; final captured = await _enqueue(source.bufferId, () async { // A checkpoint included in the Save As target belongs to the source // lineage. Newer edits remain suspended until the caller publishes the @@ -417,19 +431,39 @@ class LocalHistoryController extends Notifier { _sameOptionalPath(pending.path, source.path)) { _pending.remove(source.bufferId); _checkpointTimers.remove(source.bufferId)?.cancel(); - if (!await _capture( + final pendingCaptured = await _capture( pending, LocalHistoryCaptureReason.automaticCheckpoint, allowDuringPathTransition: true, - )) { + ); + if (!pendingCaptured && + policy.recordingEnabled && + !policy.excludes(pending.path) && + !source.untitled) { return false; } } + + final sourceDocumentId = _documentIdsByBuffer[source.bufferId]; + if (source.untitled && !destinationExisted && sourceDocumentId != null) { + _pendingUntitledPromotions[source.bufferId] = _PendingUntitledPromotion( + documentId: sourceDocumentId, + destinationPath: p.normalize(destinationPath), + displayName: p.basename(destinationPath), + ); + promotionCompleted = await _completePendingUntitledPromotion( + source.bufferId, + ); + if (!promotionCompleted) return false; + } + + if (!policy.recordingEnabled || policy.excludes(destination.path)) { + return promotionCompleted; + } return _capture( destination, LocalHistoryCaptureReason.saved, ignoreBinding: !source.untitled || destinationExisted, - allowPathChange: source.untitled && !destinationExisted, ); }); if (captured) { @@ -740,12 +774,16 @@ class LocalHistoryController extends Notifier { Future clearDocument(String documentId) async { await _store.clearDocument(documentId); _documentIdsByBuffer.removeWhere((_, value) => value == documentId); + _pendingUntitledPromotions.removeWhere( + (_, promotion) => promotion.documentId == documentId, + ); await refresh(); } Future clearAll() async { _cancelCheckpoints(); _documentIdsByBuffer.clear(); + _pendingUntitledPromotions.clear(); await _store.clearAll(); await refresh(); } @@ -767,6 +805,15 @@ class LocalHistoryController extends Notifier { } return true; } + final promotion = _pendingUntitledPromotions[snapshot.bufferId]; + if (promotion != null) { + if (!_sameOptionalPath(snapshot.path, promotion.destinationPath)) { + return false; + } + if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { + return false; + } + } final currentPolicy = policy; if (!currentPolicy.recordingEnabled || currentPolicy.excludes(snapshot.path)) { @@ -811,6 +858,35 @@ class LocalHistoryController extends Notifier { } } + Future _completePendingUntitledPromotion(String bufferId) async { + final promotion = _pendingUntitledPromotions[bufferId]; + if (promotion == null) return true; + try { + final document = await _store.promoteUntitledDocument( + documentId: promotion.documentId, + destinationPath: promotion.destinationPath, + displayName: promotion.displayName, + updatedAt: _clock().toUtc(), + ); + if (document == null) { + _setWarning(LocalHistoryWarningKind.pathChange); + return false; + } + _documentIdsByBuffer[bufferId] = document.id; + _pendingUntitledPromotions.remove(bufferId); + if (ref.mounted) { + final loaded = await _store.load(); + if (ref.mounted) { + state = state.copyWith(snapshot: loaded, warning: null); + } + } + return true; + } on Object catch (error) { + _setWarning(LocalHistoryWarningKind.pathChange, error.toString()); + return false; + } + } + Future _enqueue(String bufferId, Future Function() operation) { final prior = _bufferQueues[bufferId] ?? Future.value(); final result = prior.then((_) => operation()); diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index 5efe8d88..e6eb2fb7 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -21,6 +21,18 @@ abstract interface class LocalHistoryStore { Future prune(LocalHistoryPolicy policy, DateTime now); + /// Promotes an existing untitled history document to its first file path + /// without requiring a content revision to be recorded. + /// + /// Returns the updated document, or `null` when the requested identity is + /// missing or is no longer eligible for this transition. + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }); + Future remapPath(String sourcePath, String destinationPath); Future markDeleted(String path, {required bool recursive}); @@ -176,6 +188,40 @@ class FileLocalHistoryStore implements LocalHistoryStore { }), ); + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) => _serialized((root) async { + return _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final document = index.documents + .where((candidate) => candidate.id == documentId) + .firstOrNull; + if (document == null) return null; + final destination = p.normalize(destinationPath); + if (document.currentPath != null) { + return p.equals(document.currentPath!, destination) ? document : null; + } + if (!document.untitled) return null; + final promoted = document.copyWith( + displayName: displayName, + currentPath: destination, + historicalPaths: _uniquePaths([ + ...document.historicalPaths, + destination, + ]), + updatedAt: updatedAt.toUtc(), + deleted: false, + untitled: false, + ); + await _publishIndex(root, index.withDocument(promoted)); + return promoted; + }); + }); + @override Future remapPath(String sourcePath, String destinationPath) => _mutateDocuments((document) { @@ -913,6 +959,32 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { _documents.removeWhere((id, _) => !retainedDocumentIds.contains(id)); } + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) async { + final document = _documents[documentId]; + if (document == null) return null; + final destination = p.normalize(destinationPath); + if (document.currentPath != null) { + return p.equals(document.currentPath!, destination) ? document : null; + } + if (!document.untitled) return null; + final promoted = document.copyWith( + displayName: displayName, + currentPath: destination, + historicalPaths: _uniquePaths([...document.historicalPaths, destination]), + updatedAt: updatedAt.toUtc(), + deleted: false, + untitled: false, + ); + _documents[documentId] = promoted; + return promoted; + } + @override Future remapPath(String sourcePath, String destinationPath) async { for (final entry in _documents.entries.toList()) { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 5fff22c4..264cb776 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -3669,6 +3669,7 @@ class WorkspaceController extends Notifier { continue; } final load = await _service.loadTextWithSnapshot(path); + final sourceChanged = buffer.text != load.text; buffers.add( buffer.copyWith( text: load.text, @@ -3676,6 +3677,7 @@ class WorkspaceController extends Notifier { dirty: false, diskSnapshot: load.snapshot, format: load.format, + revision: sourceChanged ? buffer.revision + 1 : buffer.revision, diskState: DocumentDiskState.present, ), ); diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 51cfe69b..9853014b 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -362,6 +362,167 @@ void main() { }, ); + test( + 'recording-disabled first save promotes untitled history identity', + () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final settings = container.read(appSettingsControllerProvider.notifier); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + final untitled = DocumentBuffer.untitled( + id: 'draft-first-save', + name: 'Draft.md', + text: 'Untitled history\n', + ); + await controller.observeOpened(untitled); + final originalDocument = (await store.load()).documents.single; + + await settings.setLocalHistoryRecordingEnabled(false); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + '/workspace/Guide.md', + destinationExisted: false, + ), + isTrue, + ); + var snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, '/workspace/Guide.md'); + expect(snapshot.documents.single.untitled, isFalse); + expect(snapshot.revisionsFor(originalDocument.id), hasLength(1)); + + await settings.setLocalHistoryRecordingEnabled(true); + final saved = untitled.copyWith( + filePath: '/workspace/Guide.md', + untitledName: null, + lastSavedText: untitled.text, + dirty: false, + ); + expect( + await controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer( + saved.edited('Named history\n'), + ), + ), + isTrue, + ); + snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect( + await _revisionSources( + store, + snapshot.revisionsFor(originalDocument.id), + ), + containsAll(['Untitled history\n', 'Named history\n']), + ); + + final reopenedContainer = _historyContainer(store, <_FakeTimer>[]); + addTearDown(reopenedContainer.dispose); + final reopenedController = reopenedContainer.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await reopenedController.refresh(); + await reopenedController.observeOpened( + _fileBuffer('reopened-guide', '/workspace/Guide.md', 'Named history\n'), + ); + expect( + reopenedController.bufferIdForDocument(originalDocument.id), + 'reopened-guide', + ); + }, + ); + + test( + 'failed first-save promotion is retried before a later capture', + () async { + final store = _FailingFirstPromotionStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + final untitled = DocumentBuffer.untitled( + id: 'draft-retry', + name: 'Draft.md', + text: 'Original untitled revision\n', + ); + await controller.observeOpened(untitled); + final originalDocument = (await store.load()).documents.single; + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + '/workspace/Recovered.md', + destinationExisted: false, + ), + isFalse, + ); + expect((await store.load()).documents.single.currentPath, isNull); + + final saved = untitled.copyWith( + filePath: '/workspace/Recovered.md', + untitledName: null, + lastSavedText: untitled.text, + dirty: false, + ); + expect( + await controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer( + saved.edited('Revision after recovery\n'), + ), + ), + isTrue, + ); + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, '/workspace/Recovered.md'); + expect(snapshot.documents.single.untitled, isFalse); + expect( + await _revisionSources( + store, + snapshot.revisionsFor(originalDocument.id), + ), + containsAll([ + 'Original untitled revision\n', + 'Revision after recovery\n', + ]), + ); + + final reopenedContainer = _historyContainer(store, <_FakeTimer>[]); + addTearDown(reopenedContainer.dispose); + final reopenedController = reopenedContainer.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await reopenedController.refresh(); + await reopenedController.observeOpened( + _fileBuffer( + 'reopened-recovered', + '/workspace/Recovered.md', + 'Revision after recovery\n', + ), + ); + expect( + reopenedController.bufferIdForDocument(originalDocument.id), + 'reopened-recovered', + ); + }, + ); + test( 'file and directory moves settle pending checkpoints before remap', () async { @@ -508,11 +669,47 @@ class _BlockingRevisionReadStore implements LocalHistoryStore { Future prune(LocalHistoryPolicy policy, DateTime now) => delegate.prune(policy, now); + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) => delegate.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + @override Future remapPath(String sourcePath, String destinationPath) => delegate.remapPath(sourcePath, destinationPath); } +class _FailingFirstPromotionStore extends MemoryLocalHistoryStore { + var _failNextPromotion = true; + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) { + if (_failNextPromotion) { + _failNextPromotion = false; + throw const LocalHistoryStorageException('Injected promotion failure'); + } + return super.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + } +} + class _MemorySettingsStore implements LocalSettingsStore { Map value = {}; diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index 5546dc90..9bffa5c6 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -68,6 +68,57 @@ void main() { }, ); + test( + 'persists untitled first-save promotion without a new revision', + () async { + final time = DateTime.utc(2026, 1, 1); + final untitled = await store.capture( + LocalHistoryCaptureRequest( + displayName: 'Draft.md', + source: 'Draft content', + format: TextFormatMetadata.utf8Lf, + capturedAt: time, + reason: LocalHistoryCaptureReason.baseline, + untitled: true, + ), + policy, + ); + + final promoted = await store.promoteUntitledDocument( + documentId: untitled.document.id, + destinationPath: '/workspace/Guide.md', + displayName: 'Guide.md', + updatedAt: time.add(const Duration(seconds: 1)), + ); + expect(promoted?.id, untitled.document.id); + + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + var snapshot = await reopened.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, untitled.document.id); + expect(snapshot.documents.single.currentPath, '/workspace/Guide.md'); + expect(snapshot.documents.single.untitled, isFalse); + expect(snapshot.revisions, hasLength(1)); + + final later = await reopened.capture( + LocalHistoryCaptureRequest( + documentId: untitled.document.id, + path: '/workspace/Guide.md', + displayName: 'Guide.md', + source: 'Named content', + format: TextFormatMetadata.utf8Lf, + capturedAt: time.add(const Duration(seconds: 2)), + reason: LocalHistoryCaptureReason.saved, + ), + policy, + ); + snapshot = await reopened.load(); + expect(later.document.id, untitled.document.id); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.revisionsFor(untitled.document.id), hasLength(2)); + }, + ); + test( 'deduplicates only adjacent ordinary captures and preserves A-B-A', () async { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 5e23361a..0ade1806 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -707,6 +707,87 @@ void main() { expect(tester.widget(restoreChange).onPressed, isNotNull); }, ); + + testWidgets( + 'workspace refresh invalidates a completed comparison by source content', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final path = p.join(root.path, 'refreshed-comparison.md'); + final computer = _BlockingReplacementComparisonComputer(); + final monitor = _ControlledFileMonitor(); + final harness = (await tester.runAsync(() async { + await File(path).writeAsString('Current before refresh\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + path, + 'Historical comparison text\n', + ); + final harness = await _harness( + store, + fileMonitor: monitor, + comparisonComputer: computer.call, + ); + await harness.controller.openPath(path); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.selectDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return harness; + }))!; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: harness.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 initialRevision = harness.state.activeBuffer!.revision; + final context = tester.element(find.byType(LocalHistoryComparisonView)); + final l10n = AppLocalizations.of(context); + final restoreAll = find.byWidgetPredicate( + (widget) => + widget is IconButton && + widget.tooltip == l10n.localHistoryRestoreRevision, + ); + expect(tester.widget(restoreAll).onPressed, isNotNull); + + await tester.runAsync(() async { + await File(path).writeAsString('Current after refresh\n'); + expect( + await harness.controller.refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + }); + expect(harness.state.activeBuffer!.revision, initialRevision + 1); + expect(harness.state.activeText, 'Current after refresh\n'); + await tester.pump(); + await tester.runAsync(() => computer.replacementStarted.future); + await tester.pump(); + + expect(tester.widget(restoreAll).onPressed, isNull); + + computer.completeReplacement(); + await tester.pumpAndSettle(); + expect(computer.replacementCurrent.source, 'Current after refresh\n'); + expect(find.textContaining('Current after refresh'), findsWidgets); + expect(tester.widget(restoreAll).onPressed, isNotNull); + }, + ); } SourceComparison _comparison( @@ -856,6 +937,19 @@ class _FailingProtectiveStore implements LocalHistoryStore { Future prune(LocalHistoryPolicy policy, DateTime now) => delegate.prune(policy, now); + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) => delegate.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + @override Future remapPath(String sourcePath, String destinationPath) => delegate.remapPath(sourcePath, destinationPath); @@ -926,6 +1020,8 @@ class _BlockingReplacementComparisonComputer { late SourceComparisonInput _replacementOld; late SourceComparisonInput _replacementCurrent; + SourceComparisonInput get replacementCurrent => _replacementCurrent; + Future call( SourceComparisonInput oldInput, SourceComparisonInput currentInput, From 5dcd7f9f139c9685d40f9ae0d143d58c9af001c4 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 14:11:43 -0700 Subject: [PATCH 06/27] Track and restore pending identity promotions in Local History sessions - Introduced `PendingLocalHistoryAssociation` to persist pending promotions across app sessions. - Enhanced `_localHistory` to restore and flush pending identity promotions during application recovery. - Updated session persistence logic to save and load pending history associations. - Improved Local History robustness by ensuring consistent handling of pending promotions during shutdown and path transitions. --- .../local_history_controller.dart | 124 +++++++++- lib/src/workspace/session_persistence.dart | 51 ++++ lib/src/workspace/workspace_controller.dart | 61 ++++- test/src/document_persistence_test.dart | 139 ++++++----- test/src/local_history_controller_test.dart | 123 ++++++++++ test/src/local_history_workspace_test.dart | 229 ++++++++++++++++++ 6 files changed, 647 insertions(+), 80 deletions(-) diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 2b28865f..b29a1834 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -211,16 +211,26 @@ class LocalHistoryBufferPathTransition { final String destinationPath; } -class _PendingUntitledPromotion { - const _PendingUntitledPromotion({ +class LocalHistoryPendingIdentityPromotion { + const LocalHistoryPendingIdentityPromotion({ + required this.bufferId, required this.documentId, required this.destinationPath, required this.displayName, }); + final String bufferId; final String documentId; final String destinationPath; final String displayName; + + LocalHistoryPendingIdentityPromotion atPath(String path) => + LocalHistoryPendingIdentityPromotion( + bufferId: bufferId, + documentId: documentId, + destinationPath: p.normalize(path), + displayName: p.basename(path), + ); } class LocalHistoryController extends Notifier { @@ -232,7 +242,8 @@ class LocalHistoryController extends Notifier { final _checkpointTimers = {}; final _bufferQueues = >{}; final _pathTransitions = {}; - final _pendingUntitledPromotions = {}; + final _pendingUntitledPromotions = + {}; var _loadGeneration = 0; var _searchGeneration = 0; @@ -369,6 +380,14 @@ class LocalHistoryController extends Notifier { transition.destinationPath, ); } + final promotion = _pendingUntitledPromotions[transition.bufferId]; + if (committed && + promotion != null && + _sameOptionalPath(promotion.destinationPath, transition.sourcePath)) { + _pendingUntitledPromotions[transition.bufferId] = promotion.atPath( + transition.destinationPath, + ); + } _scheduleCheckpoint(transition.bufferId); } @@ -446,11 +465,13 @@ class LocalHistoryController extends Notifier { final sourceDocumentId = _documentIdsByBuffer[source.bufferId]; if (source.untitled && !destinationExisted && sourceDocumentId != null) { - _pendingUntitledPromotions[source.bufferId] = _PendingUntitledPromotion( - documentId: sourceDocumentId, - destinationPath: p.normalize(destinationPath), - displayName: p.basename(destinationPath), - ); + _pendingUntitledPromotions[source.bufferId] = + LocalHistoryPendingIdentityPromotion( + bufferId: source.bufferId, + documentId: sourceDocumentId, + destinationPath: p.normalize(destinationPath), + displayName: p.basename(destinationPath), + ); promotionCompleted = await _completePendingUntitledPromotion( source.bufferId, ); @@ -524,7 +545,20 @@ class LocalHistoryController extends Notifier { ); } - Future flushBuffer(DocumentBuffer buffer) async { + List get pendingIdentityPromotions => + List.unmodifiable(_pendingUntitledPromotions.values); + + bool hasPendingIdentityPromotion(String bufferId) => + _pendingUntitledPromotions.containsKey(bufferId); + + void restorePendingIdentityPromotion( + LocalHistoryPendingIdentityPromotion promotion, + ) { + _documentIdsByBuffer[promotion.bufferId] = promotion.documentId; + _pendingUntitledPromotions[promotion.bufferId] = promotion; + } + + Future flushBuffer(DocumentBuffer buffer) async { _checkpointTimers.remove(buffer.id)?.cancel(); final pending = _pending.remove(buffer.id); if (pending != null) { @@ -536,15 +570,38 @@ class LocalHistoryController extends Notifier { ), ); } + if (_pendingUntitledPromotions.containsKey(buffer.id)) { + return _enqueue( + buffer.id, + () => _completePendingUntitledPromotion(buffer.id), + ); + } + return true; + } + + Future flushPendingIdentityPromotions() async { + var succeeded = true; + for (final bufferId in _pendingUntitledPromotions.keys.toList()) { + if (!await _enqueue( + bufferId, + () => _completePendingUntitledPromotion(bufferId), + )) { + succeeded = false; + } + } + return succeeded && _pendingUntitledPromotions.isEmpty; } - Future flushAll(Iterable buffers) async { + Future flushAll(Iterable buffers) async { + var succeeded = true; for (final buffer in buffers) { - await flushBuffer(buffer); + if (!await flushBuffer(buffer)) succeeded = false; } + if (!await flushPendingIdentityPromotions()) succeeded = false; while (_bufferQueues.isNotEmpty) { await Future.wait(_bufferQueues.values.toList(growable: false)); } + return succeeded && _pendingUntitledPromotions.isEmpty; } Future remapPath(String sourcePath, String destinationPath) async { @@ -576,6 +633,34 @@ class LocalHistoryController extends Notifier { ), ); } + final affectedPromotions = _pendingUntitledPromotions.entries + .where( + (entry) => + p.equals(entry.value.destinationPath, sourcePath) || + p.isWithin(sourcePath, entry.value.destinationPath), + ) + .toList(growable: false); + for (final entry in affectedPromotions) { + final completed = await _enqueue( + entry.key, + () => _completePendingUntitledPromotion(entry.key), + ); + if (!completed) { + final pendingPromotion = _pendingUntitledPromotions[entry.key]; + if (pendingPromotion != null) { + final remapped = _remapHistoryPath( + pendingPromotion.destinationPath, + sourcePath, + destinationPath, + ); + if (remapped != null) { + _pendingUntitledPromotions[entry.key] = pendingPromotion.atPath( + remapped, + ); + } + } + } + } await _store.remapPath(sourcePath, destinationPath); await refresh(); } on Object catch (error) { @@ -808,6 +893,7 @@ class LocalHistoryController extends Notifier { final promotion = _pendingUntitledPromotions[snapshot.bufferId]; if (promotion != null) { if (!_sameOptionalPath(snapshot.path, promotion.destinationPath)) { + _setWarning(LocalHistoryWarningKind.pathChange); return false; } if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { @@ -949,6 +1035,22 @@ bool _sameOptionalPath(String? first, String? second) { return p.equals(first, second); } +String? _remapHistoryPath(String path, String source, String destination) { + final normalizedPath = p.normalize(path); + final normalizedSource = p.normalize(source); + final normalizedDestination = p.normalize(destination); + if (p.equals(normalizedPath, normalizedSource)) { + return normalizedDestination; + } + if (!p.isWithin(normalizedSource, normalizedPath)) return null; + return p.normalize( + p.join( + normalizedDestination, + p.relative(normalizedPath, from: normalizedSource), + ), + ); +} + extension on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/src/workspace/session_persistence.dart b/lib/src/workspace/session_persistence.dart index d0025e83..251812ee 100644 --- a/lib/src/workspace/session_persistence.dart +++ b/lib/src/workspace/session_persistence.dart @@ -38,22 +38,57 @@ class DocumentSessionEntry { } } +class PendingLocalHistoryAssociation { + const PendingLocalHistoryAssociation({ + required this.bufferId, + required this.documentId, + required this.destinationPath, + required this.displayName, + }); + + final String bufferId; + final String documentId; + final String destinationPath; + final String displayName; + + Map toJson() => { + 'bufferId': bufferId, + 'documentId': documentId, + 'destinationPath': destinationPath, + 'displayName': displayName, + }; + + factory PendingLocalHistoryAssociation.fromJson(Map json) { + return PendingLocalHistoryAssociation( + bufferId: json['bufferId']?.toString() ?? '', + documentId: json['documentId']?.toString() ?? '', + destinationPath: json['destinationPath']?.toString() ?? '', + displayName: json['displayName']?.toString() ?? '', + ); + } +} + class WorkspaceSessionSnapshot { const WorkspaceSessionSnapshot({ required this.workspacePath, required this.tabs, required this.activeBufferId, + this.pendingLocalHistoryAssociations = const [], }); final String? workspacePath; final List tabs; final String? activeBufferId; + final List pendingLocalHistoryAssociations; Map toJson() => { 'version': 1, 'workspacePath': workspacePath, 'activeBufferId': activeBufferId, 'tabs': tabs.map((entry) => entry.toJson()).toList(), + 'pendingLocalHistoryAssociations': pendingLocalHistoryAssociations + .map((entry) => entry.toJson()) + .toList(), }; factory WorkspaceSessionSnapshot.fromJson(Map json) { @@ -71,6 +106,22 @@ class WorkspaceSessionSnapshot { .toList() ?? const [], activeBufferId: json['activeBufferId']?.toString(), + pendingLocalHistoryAssociations: + (json['pendingLocalHistoryAssociations'] as List?) + ?.whereType() + .map( + (entry) => PendingLocalHistoryAssociation.fromJson( + entry.cast(), + ), + ) + .where( + (entry) => + entry.bufferId.isNotEmpty && + entry.documentId.isNotEmpty && + entry.destinationPath.isNotEmpty, + ) + .toList() ?? + const [], ); } } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 264cb776..dfb76c67 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -268,6 +268,19 @@ class WorkspaceController extends Notifier { } final recovery = await _recoveryStart; final session = await _sessionStore.load(); + for (final association + in session?.pendingLocalHistoryAssociations ?? + const []) { + _localHistory.restorePendingIdentityPromotion( + LocalHistoryPendingIdentityPromotion( + bufferId: association.bufferId, + documentId: association.documentId, + destinationPath: association.destinationPath, + displayName: association.displayName, + ), + ); + } + await _localHistory.flushPendingIdentityPromotions(); // Entries are authoritative even if the last shutdown was marked clean. // This protects users when close confirmation is disabled while dirty // buffers still exist. @@ -457,7 +470,11 @@ class WorkspaceController extends Notifier { ); } final load = await _service.loadTextWithSnapshot(path); - return _fileBuffer(path, load).copyWith(editorState: session.editorState); + return _fileBuffer( + path, + load, + id: session.id, + ).copyWith(editorState: session.editorState); } Future _restoreRecoveryBuffer( @@ -540,9 +557,29 @@ class WorkspaceController extends Notifier { Future _persistSnapshot(WorkspaceState snapshot) async { await _recoveryStart; final workspace = snapshot.workspace; + final pendingHistoryAssociations = [ + for (final promotion in _localHistory.pendingIdentityPromotions) + PendingLocalHistoryAssociation( + bufferId: promotion.bufferId, + documentId: promotion.documentId, + destinationPath: promotion.destinationPath, + displayName: promotion.displayName, + ), + ]; if (workspace == null) { await _recoveryStore.writeEntries(const []); - await _sessionStore.clear(); + if (pendingHistoryAssociations.isEmpty) { + await _sessionStore.clear(); + } else { + await _sessionStore.save( + WorkspaceSessionSnapshot( + workspacePath: null, + tabs: const [], + activeBufferId: null, + pendingLocalHistoryAssociations: pendingHistoryAssociations, + ), + ); + } return; } final workspacePath = switch (workspace.kind) { @@ -576,17 +613,19 @@ class WorkspaceController extends Notifier { editorState: buffer.editorState, ), ], + pendingLocalHistoryAssociations: pendingHistoryAssociations, ), ); } Future markCleanShutdown() async { await _settleWritesForShutdown(); - await _localHistory.flushAll(state.documentBuffers); + final historySettled = await _localHistory.flushAll(state.documentBuffers); await flushPersistence(); - if (state.documentBuffers.any( - (buffer) => buffer.isDirty || buffer.isUntitled, - )) { + if (!historySettled || + state.documentBuffers.any( + (buffer) => buffer.isDirty || buffer.isUntitled, + )) { // Keep the run unclean while recovery data is still needed. return; } @@ -595,9 +634,14 @@ class WorkspaceController extends Notifier { Future discardRecoveryForShutdown() async { await _settleWritesForShutdown(); - await _localHistory.flushAll(state.documentBuffers); + final historySettled = await _localHistory.flushAll(state.documentBuffers); await flushPersistence(); await _recoveryStore.clear(); + if (!historySettled) { + // Preserve the unclean-run marker so the pending association stored in + // the session is retried on the next launch. + await _recoveryStore.writeEntries(const []); + } } Future _settleWritesForShutdown() async { @@ -4185,6 +4229,7 @@ class WorkspaceController extends Notifier { DocumentBuffer _fileBuffer( String path, WorkspaceFileLoad load, { + String? id, DocumentViewModePreference mode = DocumentViewModePreference.editor, }) { final format = @@ -4198,7 +4243,7 @@ DocumentBuffer _fileBuffer( ) : load.format; return DocumentBuffer.file( - id: 'file:$path', + id: id ?? 'file:$path', filePath: path, text: load.text, snapshot: load.snapshot, diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index ac9b9a43..83b38437 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -38,70 +38,87 @@ void main() { expect(restored.format.formattedText(restored.text), 'Saved\n'); }); - test('session store keeps only tab identity and editor state', () async { - final directory = await Directory.systemTemp.createTemp( - 'busymark-session-', - ); - addTearDown(() => directory.delete(recursive: true)); - final store = JsonDocumentSessionStore( - filePathOverride: p.join(directory.path, 'session.json'), - ); - final snapshot = WorkspaceSessionSnapshot( - workspacePath: '/workspace', - activeBufferId: 'second', - tabs: [ - DocumentSessionEntry( - id: 'first', - filePath: '/workspace/first.md', - untitledName: null, - editorState: const DocumentEditorState( - mode: DocumentViewModePreference.split, - selection: TextSelection(baseOffset: 2, extentOffset: 8), - scrollOffset: 42, - foldedRegionKeys: {'heading:2'}, + test( + 'session store keeps tab state and pending history identity without text', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-session-', + ); + addTearDown(() => directory.delete(recursive: true)); + final store = JsonDocumentSessionStore( + filePathOverride: p.join(directory.path, 'session.json'), + ); + final snapshot = WorkspaceSessionSnapshot( + workspacePath: '/workspace', + activeBufferId: 'second', + tabs: [ + DocumentSessionEntry( + id: 'first', + filePath: '/workspace/first.md', + untitledName: null, + editorState: const DocumentEditorState( + mode: DocumentViewModePreference.split, + selection: TextSelection(baseOffset: 2, extentOffset: 8), + scrollOffset: 42, + foldedRegionKeys: {'heading:2'}, + ), ), - ), - const DocumentSessionEntry( - id: 'second', - filePath: null, - untitledName: 'Untitled 2', - editorState: DocumentEditorState(), - ), - ], - ); + const DocumentSessionEntry( + id: 'second', + filePath: null, + untitledName: 'Untitled 2', + editorState: DocumentEditorState(), + ), + ], + pendingLocalHistoryAssociations: const [ + PendingLocalHistoryAssociation( + bufferId: 'first', + documentId: 'history-document', + destinationPath: '/workspace/first.md', + displayName: 'first.md', + ), + ], + ); - await store.save(snapshot); - final restored = await store.load(); + await store.save(snapshot); + final restored = await store.load(); - expect(restored?.workspacePath, '/workspace'); - expect(restored?.activeBufferId, 'second'); - expect(restored?.tabs.map((entry) => entry.id), ['first', 'second']); - expect( - restored?.tabs.first.editorState.mode, - DocumentViewModePreference.split, - ); - expect( - restored?.tabs.first.editorState.selection, - const TextSelection(baseOffset: 2, extentOffset: 8), - ); - expect(restored?.tabs.first.editorState.scrollOffset, 42); - expect(restored?.tabs.first.editorState.foldedRegionKeys, {'heading:2'}); - final persisted = - jsonDecode( - await File(p.join(directory.path, 'session.json')).readAsString(), - ) - as Map; - final firstTab = ((persisted['tabs'] as List).first as Map) - .cast(); - expect(firstTab, isNot(contains('lastKnownText'))); - expect(firstTab, isNot(contains('diskSnapshot'))); - expect(firstTab, isNot(contains('format'))); - expect(firstTab, isNot(contains('searchCurrentMatchIndex'))); - expect( - await File(p.join(directory.path, 'session.json')).readAsString(), - isNot(contains('# First')), - ); - }); + expect(restored?.workspacePath, '/workspace'); + expect(restored?.activeBufferId, 'second'); + expect(restored?.tabs.map((entry) => entry.id), ['first', 'second']); + expect( + restored?.pendingLocalHistoryAssociations.single.documentId, + 'history-document', + ); + expect( + restored?.tabs.first.editorState.mode, + DocumentViewModePreference.split, + ); + expect( + restored?.tabs.first.editorState.selection, + const TextSelection(baseOffset: 2, extentOffset: 8), + ); + expect(restored?.tabs.first.editorState.scrollOffset, 42); + expect(restored?.tabs.first.editorState.foldedRegionKeys, {'heading:2'}); + final persisted = + jsonDecode( + await File( + p.join(directory.path, 'session.json'), + ).readAsString(), + ) + as Map; + final firstTab = ((persisted['tabs'] as List).first as Map) + .cast(); + expect(firstTab, isNot(contains('lastKnownText'))); + expect(firstTab, isNot(contains('diskSnapshot'))); + expect(firstTab, isNot(contains('format'))); + expect(firstTab, isNot(contains('searchCurrentMatchIndex'))); + expect( + await File(p.join(directory.path, 'session.json')).readAsString(), + isNot(contains('# First')), + ); + }, + ); test('recovery store distinguishes clean and unclean runs', () async { final directory = await Directory.systemTemp.createTemp( diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 9853014b..10718f81 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -523,6 +523,129 @@ void main() { }, ); + test( + 'flush retries a failed first-save promotion without another edit', + () async { + final store = _FailingFirstPromotionStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + + final untitled = DocumentBuffer.untitled( + id: 'draft-flush-retry', + name: 'Draft.md', + text: 'Retained before first save\n', + ); + await controller.observeOpened(untitled); + final originalDocument = (await store.load()).documents.single; + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + '/workspace/Guide.md', + destinationExisted: false, + ), + isFalse, + ); + final saved = untitled.copyWith( + filePath: '/workspace/Guide.md', + untitledName: null, + lastSavedText: untitled.text, + dirty: false, + ); + + expect(await controller.flushAll([saved]), isTrue); + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, '/workspace/Guide.md'); + expect(snapshot.revisionsFor(originalDocument.id), hasLength(1)); + }, + ); + + test( + 'remap settles pending first-save promotions for files and directories', + () async { + final scenarios = [ + ( + initialPath: '/workspace/A.md', + sourcePath: '/workspace/A.md', + destinationPath: '/workspace/B.md', + finalPath: '/workspace/B.md', + ), + ( + initialPath: '/workspace/drafts/A.md', + sourcePath: '/workspace/drafts', + destinationPath: '/workspace/archive/drafts', + finalPath: '/workspace/archive/drafts/A.md', + ), + ]; + for (var index = 0; index < scenarios.length; index += 1) { + final scenario = scenarios[index]; + final store = _FailingFirstPromotionStore(); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + final untitled = DocumentBuffer.untitled( + id: 'draft-remap-$index', + name: 'Draft.md', + text: 'Original lineage $index\n', + ); + await controller.observeOpened(untitled); + final originalDocument = (await store.load()).documents.single; + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + scenario.initialPath, + destinationExisted: false, + ), + isFalse, + ); + + await controller.remapPath( + scenario.sourcePath, + scenario.destinationPath, + ); + final moved = untitled.copyWith( + filePath: scenario.finalPath, + untitledName: null, + lastSavedText: untitled.text, + dirty: false, + ); + expect( + await controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer( + moved.edited('Recorded after remap $index\n'), + ), + ), + isTrue, + ); + + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, scenario.finalPath); + expect( + await _revisionSources( + store, + snapshot.revisionsFor(originalDocument.id), + ), + containsAll([ + 'Original lineage $index\n', + 'Recorded after remap $index\n', + ]), + ); + } + }, + ); + test( 'file and directory moves settle pending checkpoints before remap', () async { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 0ade1806..463c52c7 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -10,6 +10,8 @@ import 'package:busymark/src/local_history/local_history_comparison_view.dart'; import 'package:busymark/src/local_history/local_history_models.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/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:busymark/src/workspace/workspace_file_monitor.dart'; @@ -586,6 +588,201 @@ void main() { }, ); + test( + 'shutdown retries first-save promotion without requiring another edit', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory); + final harness = await _harness(store); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Untitled lineage before save\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final destination = p.join(root.path, 'Guide.md'); + + expect(await harness.controller.saveActiveAs(destination), isTrue); + expect(harness.state.activeBuffer!.isDirty, isFalse); + expect((await memory.load()).documents.single.currentPath, isNull); + + await harness.controller.markCleanShutdown(); + final afterShutdown = await memory.load(); + expect(afterShutdown.documents, hasLength(1)); + expect(afterShutdown.documents.single.id, originalDocument.id); + expect(afterShutdown.documents.single.currentPath, destination); + expect( + await _revisionSources( + memory, + afterShutdown.revisionsFor(originalDocument.id), + ), + contains('Untitled lineage before save\n'), + ); + + final reopened = await _harness(store); + await reopened.controller.openPath(destination); + await reopened.container + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(reopened.state.activeBuffer!); + expect( + reopened.container + .read(localHistoryControllerProvider.notifier) + .bufferIdForDocument(originalDocument.id), + reopened.state.activeBuffer!.id, + ); + }, + ); + + test( + 'unresolved shutdown promotion survives in session and retries on startup', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory, failures: 20); + final sessions = MemoryDocumentSessionStore(); + final recovery = MemoryDocumentRecoveryStore(); + final harness = await _harness( + store, + sessionStore: sessions, + recoveryStore: recovery, + ); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Lineage needing recovery\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final destination = p.join(root.path, 'Recovered-guide.md'); + expect(await harness.controller.saveActiveAs(destination), isTrue); + + await harness.controller.markCleanShutdown(); + expect(recovery.value.cleanShutdown, isFalse); + expect( + sessions.value?.pendingLocalHistoryAssociations.single.documentId, + originalDocument.id, + ); + + final reopened = await _harness( + store, + sessionStore: sessions, + recoveryStore: recovery, + ); + expect( + await reopened.controller.restoreStartupSession( + reopenCleanSession: false, + ), + isTrue, + ); + expect((await memory.load()).documents.single.currentPath, isNull); + expect(reopened.state.activeBuffer!.id, sessions.value!.tabs.single.id); + + store.remainingPromotionFailures = 0; + expect( + await reopened.container + .read(localHistoryControllerProvider.notifier) + .flushAll(reopened.state.documentBuffers), + isTrue, + ); + final restoredHistory = await memory.load(); + expect(restoredHistory.documents, hasLength(1)); + expect(restoredHistory.documents.single.id, originalDocument.id); + expect(restoredHistory.documents.single.currentPath, destination); + expect( + reopened.container + .read(localHistoryControllerProvider.notifier) + .pendingIdentityPromotions, + isEmpty, + ); + }, + ); + + test('workspace rename settles a failed first-save promotion', () async { + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory); + final harness = await _harness(store); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Untitled before rename\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final firstPath = p.join(root.path, 'A.md'); + final finalPath = p.join(root.path, 'B.md'); + expect(await harness.controller.saveActiveAs(firstPath), isTrue); + expect((await memory.load()).documents.single.currentPath, isNull); + + expect( + await harness.controller.renameWorkspaceEntity(firstPath, 'B.md'), + isTrue, + ); + expect(harness.state.activeBuffer!.filePath, finalPath); + harness.controller.updateActiveText('Saved after rename\n'); + expect(await harness.controller.saveActive(), isTrue); + + final snapshot = await memory.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, finalPath); + expect( + await _revisionSources( + memory, + snapshot.revisionsFor(originalDocument.id), + ), + containsAll(['Untitled before rename\n', 'Saved after rename\n']), + ); + }); + + test( + 'workspace directory move settles a nested failed first-save promotion', + () async { + final drafts = Directory(p.join(root.path, 'drafts')); + final archive = Directory(p.join(root.path, 'archive')); + await drafts.create(); + await archive.create(); + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory); + final harness = await _harness(store); + await harness.controller.openPath(root.path); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Untitled before directory move\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final firstPath = p.join(drafts.path, 'A.md'); + final movedDirectory = p.join(archive.path, 'drafts'); + final finalPath = p.join(movedDirectory, 'A.md'); + expect(await harness.controller.saveActiveAs(firstPath), isTrue); + expect((await memory.load()).documents.single.currentPath, isNull); + + expect( + await harness.controller.moveWorkspaceEntity(drafts.path, archive.path), + isTrue, + ); + expect(harness.state.activeBuffer!.filePath, finalPath); + harness.controller.updateActiveText('Saved after directory move\n'); + expect(await harness.controller.saveActive(), isTrue); + + final snapshot = await memory.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, finalPath); + expect( + await _revisionSources( + memory, + snapshot.revisionsFor(originalDocument.id), + ), + containsAll([ + 'Untitled before directory move\n', + 'Saved after directory move\n', + ]), + ); + }, + ); + testWidgets( 'comparison renders styled intraline spans without framework errors', (tester) async { @@ -853,6 +1050,8 @@ Future<_Harness> _harness( WorkspaceFileMonitor? fileMonitor, LocalHistoryTimerFactory? timerFactory, LocalHistoryComparisonComputer? comparisonComputer, + DocumentSessionStore? sessionStore, + DocumentRecoveryStore? recoveryStore, }) async { final container = ProviderContainer( overrides: [ @@ -862,6 +1061,10 @@ Future<_Harness> _harness( () => DateTime.utc(2026, 1, 1, 0, 1), ), workspaceServiceProvider.overrideWithValue(service), + if (sessionStore != null) + documentSessionStoreProvider.overrideWithValue(sessionStore), + if (recoveryStore != null) + documentRecoveryStoreProvider.overrideWithValue(recoveryStore), if (fileMonitor != null) workspaceFileMonitorProvider.overrideWithValue(fileMonitor), if (timerFactory != null) @@ -970,6 +1173,32 @@ class _FailingSavedStore extends _FailingProtectiveStore { } } +class _FailingFirstPromotionStore extends _FailingProtectiveStore { + _FailingFirstPromotionStore(super.delegate, {int failures = 1}) + : remainingPromotionFailures = failures; + + int remainingPromotionFailures; + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) { + if (remainingPromotionFailures > 0) { + remainingPromotionFailures--; + throw const LocalHistoryStorageException('Injected promotion failure'); + } + return delegate.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + } +} + class _BlockingBeforeReloadStore extends _FailingProtectiveStore { _BlockingBeforeReloadStore(super.delegate); From afc84897ad8465f04599b150dcfcd505adbbe03a Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 14:33:15 -0700 Subject: [PATCH 07/27] Preserve Local History identity across Save As and reopen --- docs/history-architecture.md | 6 +- .../local_history_controller.dart | 130 +++++++++++++++++- .../local_history/local_history_store.dart | 18 +++ lib/src/workspace/workspace_controller.dart | 1 + test/src/local_history_store_test.dart | 51 +++++++ test/src/local_history_workspace_test.dart | 113 +++++++++++++++ 6 files changed, 313 insertions(+), 6 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index b83a761a..6a77bb1d 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -47,7 +47,11 @@ Known file and directory moves remap current and descendant paths while retaining historical paths. A first save of an untitled document continues its lineage. Named-file Save As retains the source lineage separately and starts or appends to the destination lineage; an overwritten destination is protected -before publication. +before publication. Path transitions explicitly distinguish moves from Save As +so an unresolved first-save association is never retargeted to a later copy. +Pending first-save associations are also matched by destination path when a tab +is reopened, before baseline capture, and promotion refuses a destination +already owned by another history document. ## Store and retention diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index b29a1834..bd8ee980 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -197,18 +197,32 @@ class LocalHistoryBufferSnapshot { revision: revision, path: destinationPath, ); + + LocalHistoryBufferSnapshot forBuffer(String id) => LocalHistoryBufferSnapshot( + bufferId: id, + displayName: displayName, + text: text, + format: format, + revision: revision, + path: path, + untitled: untitled, + ); } +enum LocalHistoryBufferPathTransitionKind { move, saveAs } + class LocalHistoryBufferPathTransition { const LocalHistoryBufferPathTransition._({ required this.bufferId, required this.sourcePath, required this.destinationPath, + required this.kind, }); final String bufferId; final String? sourcePath; final String destinationPath; + final LocalHistoryBufferPathTransitionKind kind; } class LocalHistoryPendingIdentityPromotion { @@ -231,6 +245,14 @@ class LocalHistoryPendingIdentityPromotion { destinationPath: p.normalize(path), displayName: p.basename(path), ); + + LocalHistoryPendingIdentityPromotion forBuffer(String id) => + LocalHistoryPendingIdentityPromotion( + bufferId: id, + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + ); } class LocalHistoryController extends Notifier { @@ -319,7 +341,29 @@ class LocalHistoryController extends Notifier { Future observeOpened(DocumentBuffer buffer) async { final snapshot = LocalHistoryBufferSnapshot.fromBuffer(buffer); if (snapshot.untitled && snapshot.text.isEmpty) return; + final adoptedPromotion = _adoptPendingPromotionForOpenedBuffer(snapshot); await _enqueue(snapshot.bufferId, () async { + final promotion = _pendingUntitledPromotions[snapshot.bufferId]; + if (promotion != null) { + if (!_sameOptionalPath(snapshot.path, promotion.destinationPath)) { + _setWarning(LocalHistoryWarningKind.pathChange); + return; + } + if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { + return; + } + final pending = _pending.remove(snapshot.bufferId); + _checkpointTimers.remove(snapshot.bufferId)?.cancel(); + if (pending != null) { + await _capture( + pending, + LocalHistoryCaptureReason.automaticCheckpoint, + ); + } + await _capture(snapshot, LocalHistoryCaptureReason.baseline); + return; + } + if (adoptedPromotion != null) return; if (_documentIdsByBuffer.containsKey(snapshot.bufferId)) return; await _capture(snapshot, LocalHistoryCaptureReason.baseline); }); @@ -350,11 +394,14 @@ class LocalHistoryController extends Notifier { required String bufferId, required String? sourcePath, required String destinationPath, + LocalHistoryBufferPathTransitionKind kind = + LocalHistoryBufferPathTransitionKind.move, }) { final transition = LocalHistoryBufferPathTransition._( bufferId: bufferId, sourcePath: sourcePath, destinationPath: p.normalize(destinationPath), + kind: kind, ); _pathTransitions[bufferId] = transition; _checkpointTimers.remove(bufferId)?.cancel(); @@ -382,6 +429,7 @@ class LocalHistoryController extends Notifier { } final promotion = _pendingUntitledPromotions[transition.bufferId]; if (committed && + transition.kind == LocalHistoryBufferPathTransitionKind.move && promotion != null && _sameOptionalPath(promotion.destinationPath, transition.sourcePath)) { _pendingUntitledPromotions[transition.bufferId] = promotion.atPath( @@ -440,7 +488,26 @@ class LocalHistoryController extends Notifier { path: destinationPath, ); var promotionCompleted = false; + var retainedSourcePromotion = false; final captured = await _enqueue(source.bufferId, () async { + // A named-file Save As is a fork. Resolve an earlier untitled-to-source + // promotion before recording the destination. If storage is still + // unavailable, detach that source association from the buffer so it can + // be retried independently without being retargeted to the copy. + if (!source.untitled && + _pendingUntitledPromotions.containsKey(source.bufferId)) { + final promotion = _pendingUntitledPromotions[source.bufferId]!; + if (_sameOptionalPath(source.path, promotion.destinationPath)) { + promotionCompleted = await _completePendingUntitledPromotion( + source.bufferId, + ); + } + if (_pendingUntitledPromotions.containsKey(source.bufferId)) { + _detachPendingUntitledPromotion(source.bufferId); + retainedSourcePromotion = true; + } + } + // A checkpoint included in the Save As target belongs to the source // lineage. Newer edits remain suspended until the caller publishes the // destination path and finishes the path transition. @@ -479,7 +546,7 @@ class LocalHistoryController extends Notifier { } if (!policy.recordingEnabled || policy.excludes(destination.path)) { - return promotionCompleted; + return source.untitled && !destinationExisted && promotionCompleted; } return _capture( destination, @@ -502,6 +569,9 @@ class LocalHistoryController extends Notifier { // excluded, or temporarily unavailable during this Save As. _documentIdsByBuffer.remove(source.bufferId); } + if (retainedSourcePromotion) { + _setWarning(LocalHistoryWarningKind.pathChange); + } return captured; } @@ -582,10 +652,14 @@ class LocalHistoryController extends Notifier { Future flushPendingIdentityPromotions() async { var succeeded = true; for (final bufferId in _pendingUntitledPromotions.keys.toList()) { - if (!await _enqueue( - bufferId, - () => _completePendingUntitledPromotion(bufferId), - )) { + if (!await _enqueue(bufferId, () async { + if (!await _completePendingUntitledPromotion(bufferId)) return false; + final pending = _pending.remove(bufferId); + _checkpointTimers.remove(bufferId)?.cancel(); + if (pending == null) return true; + await _capture(pending, LocalHistoryCaptureReason.automaticCheckpoint); + return true; + })) { succeeded = false; } } @@ -873,6 +947,52 @@ class LocalHistoryController extends Notifier { await refresh(); } + LocalHistoryPendingIdentityPromotion? _adoptPendingPromotionForOpenedBuffer( + LocalHistoryBufferSnapshot snapshot, + ) { + final path = snapshot.path; + if (path == null) return null; + final match = _pendingUntitledPromotions.entries + .where((entry) => p.equals(entry.value.destinationPath, path)) + .firstOrNull; + if (match == null) return null; + if (match.key == snapshot.bufferId) { + _documentIdsByBuffer[snapshot.bufferId] = match.value.documentId; + return match.value; + } + + // Re-key synchronously, before the first await in observeOpened(). This + // prevents a fast edit from scheduling a baseline under a competing file + // identity while the promotion retry is still in progress. + _pendingUntitledPromotions.remove(match.key); + final adopted = match.value.forBuffer(snapshot.bufferId); + _pendingUntitledPromotions[snapshot.bufferId] = adopted; + final pending = _pending.remove(match.key); + _checkpointTimers.remove(match.key)?.cancel(); + if (pending != null) { + _pending[snapshot.bufferId] = pending.forBuffer(snapshot.bufferId); + } + _documentIdsByBuffer.remove(match.key); + _documentIdsByBuffer[snapshot.bufferId] = adopted.documentId; + return adopted; + } + + void _detachPendingUntitledPromotion(String bufferId) { + final promotion = _pendingUntitledPromotions.remove(bufferId); + if (promotion == null) return; + final detachedBufferId = 'history-promotion:${promotion.documentId}'; + _pendingUntitledPromotions[detachedBufferId] = promotion.forBuffer( + detachedBufferId, + ); + final pending = _pending.remove(bufferId); + _checkpointTimers.remove(bufferId)?.cancel(); + if (pending != null) { + _pending[detachedBufferId] = pending.forBuffer(detachedBufferId); + } + _documentIdsByBuffer.remove(bufferId); + _documentIdsByBuffer[detachedBufferId] = promotion.documentId; + } + Future _capture( LocalHistoryBufferSnapshot snapshot, LocalHistoryCaptureReason reason, { diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index e6eb2fb7..7c1bc97d 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -206,6 +206,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { return p.equals(document.currentPath!, destination) ? document : null; } if (!document.untitled) return null; + final destinationOwner = index.documents + .where( + (candidate) => + candidate.id != document.id && + candidate.currentPath != null && + p.equals(candidate.currentPath!, destination), + ) + .firstOrNull; + if (destinationOwner != null) return null; final promoted = document.copyWith( displayName: displayName, currentPath: destination, @@ -973,6 +982,15 @@ class MemoryLocalHistoryStore implements LocalHistoryStore { return p.equals(document.currentPath!, destination) ? document : null; } if (!document.untitled) return null; + final destinationOwner = _documents.values + .where( + (candidate) => + candidate.id != document.id && + candidate.currentPath != null && + p.equals(candidate.currentPath!, destination), + ) + .firstOrNull; + if (destinationOwner != null) return null; final promoted = document.copyWith( displayName: displayName, currentPath: destination, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index dfb76c67..b0f5e833 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -3017,6 +3017,7 @@ class WorkspaceController extends Notifier { bufferId: target.bufferId, sourcePath: target.path, destinationPath: path, + kind: LocalHistoryBufferPathTransitionKind.saveAs, ); var historyPathCommitted = false; try { diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index 9bffa5c6..bfd3957e 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -119,6 +119,57 @@ void main() { }, ); + test( + 'untitled promotion refuses an already-owned destination path', + () async { + final time = DateTime.utc(2026, 1, 1); + final untitled = await store.capture( + LocalHistoryCaptureRequest( + displayName: 'Draft.md', + source: 'Original untitled history', + format: TextFormatMetadata.utf8Lf, + capturedAt: time, + reason: LocalHistoryCaptureReason.baseline, + untitled: true, + ), + policy, + ); + final destination = await store.capture( + request( + 'Existing destination history', + time.add(const Duration(seconds: 1)), + path: '/workspace/Guide.md', + ), + policy, + ); + + expect( + await store.promoteUntitledDocument( + documentId: untitled.document.id, + destinationPath: '/workspace/Guide.md', + displayName: 'Guide.md', + updatedAt: time.add(const Duration(seconds: 2)), + ), + isNull, + ); + + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(2)); + expect( + snapshot.documents + .singleWhere((document) => document.id == untitled.document.id) + .currentPath, + isNull, + ); + expect( + snapshot.documents + .singleWhere((document) => document.id == destination.document.id) + .currentPath, + '/workspace/Guide.md', + ); + }, + ); + test( 'deduplicates only adjacent ordinary captures and preserves A-B-A', () async { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 463c52c7..54a5c40b 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -634,6 +634,119 @@ void main() { }, ); + test( + 'second Save As preserves a failed first-save association as the source', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory); + final harness = await _harness(store); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Original untitled lineage\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final firstPath = p.join(root.path, 'A.md'); + final copyPath = p.join(root.path, 'B.md'); + + expect(await harness.controller.saveActiveAs(firstPath), isTrue); + expect((await memory.load()).documents.single.currentPath, isNull); + expect(await harness.controller.saveActiveAs(copyPath), isTrue); + expect( + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushAll(harness.state.documentBuffers), + isTrue, + ); + + final snapshot = await memory.load(); + expect(snapshot.documents, hasLength(2)); + final source = snapshot.documents.singleWhere( + (document) => document.currentPath == firstPath, + ); + final copy = snapshot.documents.singleWhere( + (document) => document.currentPath == copyPath, + ); + expect(source.id, originalDocument.id); + expect(copy.id, isNot(originalDocument.id)); + expect( + await _revisionSources(memory, snapshot.revisionsFor(source.id)), + contains('Original untitled lineage\n'), + ); + expect( + await _revisionSources(memory, snapshot.revisionsFor(copy.id)), + contains('Original untitled lineage\n'), + ); + + for (final expected in [(firstPath, source.id), (copyPath, copy.id)]) { + final reopened = await _harness(store); + await reopened.controller.openPath(expected.$1); + final history = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + await history.selectDocumentForBuffer(reopened.state.activeBuffer!); + expect( + reopened.container + .read(localHistoryControllerProvider) + .selectedDocument + ?.id, + expected.$2, + ); + } + }, + ); + + test( + 'reopened tab adopts its unresolved first-save history association', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _FailingFirstPromotionStore(memory, failures: 20); + final harness = await _harness(store); + await harness.controller.openPath(root.path); + await harness.controller.createMarkdownFile(); + harness.controller.updateActiveText('Lineage before closing the tab\n'); + await Future.delayed(Duration.zero); + await harness.container + .read(localHistoryControllerProvider.notifier) + .flushBuffer(harness.state.activeBuffer!); + final originalDocument = (await memory.load()).documents.single; + final destination = p.join(root.path, 'A.md'); + expect(await harness.controller.saveActiveAs(destination), isTrue); + final oldBufferId = harness.state.activeBuffer!.id; + + expect(await harness.controller.closeDocumentBuffer(oldBufferId), isTrue); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + expect(history.hasPendingIdentityPromotion(oldBufferId), isTrue); + store.remainingPromotionFailures = 0; + + expect(await harness.controller.openActiveFile(destination), isTrue); + final reopenedBuffer = harness.state.activeBuffer!; + expect(reopenedBuffer.id, isNot(oldBufferId)); + await history.selectDocumentForBuffer(reopenedBuffer); + expect(await history.flushAll(harness.state.documentBuffers), isTrue); + + final snapshot = await memory.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocument.id); + expect(snapshot.documents.single.currentPath, destination); + expect( + history.bufferIdForDocument(originalDocument.id), + reopenedBuffer.id, + ); + expect(history.pendingIdentityPromotions, isEmpty); + expect( + await _revisionSources( + memory, + snapshot.revisionsFor(originalDocument.id), + ), + contains('Lineage before closing the tab\n'), + ); + }, + ); + test( 'unresolved shutdown promotion survives in session and retries on startup', () async { From 5810c4d34d8642aa089c16175dd4e6b9c1bf4861 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 16:41:41 -0700 Subject: [PATCH 08/27] Refactor PDF export error handling and cleanup unused BusyDocument methods - Renamed and made `showMarkdownPdfExportError` visible for testing. - Enhanced error dialog to display additional failure details when applicable. - Removed `_restoreSourceBlankParagraphs` and related methods from `wysiwyg_document_controller.dart` to simplify document handling logic. --- .../wysiwyg/wysiwyg_document_controller.dart | 139 +---------------- lib/src/export/markdown_pdf_export_ui.dart | 19 ++- test/src/app_smoke_test.dart | 136 ++++++++++++++++ test/src/busymark_document_test.dart | 118 ++++++-------- .../src/markdown_pdf_export_service_test.dart | 145 ++++++++++++++++++ test/src/markdown_pdf_export_test.dart | 59 +++++++ test/src/preview_builder_test.dart | 49 ++++++ 7 files changed, 460 insertions(+), 205 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 666ea622..30b38731 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -1,7 +1,6 @@ import 'package:flutter/foundation.dart'; import '../../core/path_utils.dart'; -import '../../core/source_span.dart'; import '../../markdown/busymark_document.dart'; import '../../markdown/busymark_markdown_serializer.dart'; import '../../markdown/markdown_model.dart'; @@ -3268,19 +3267,20 @@ String _incrementOrderedMarker(String? marker, int offset) { } BusyDocument _ensureEditableDocument(BusyDocument document) { - final withBlankParagraphs = _restoreSourceBlankParagraphs(document); - final hasEditableBlock = withBlankParagraphs.blocks.any( + // Source whitespace remains in BusyDocument.source. The Markdown parser, + // rather than the rich editor, owns whether that whitespace creates blocks. + final hasEditableBlock = document.blocks.any( (block) => block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly && !block.isSourceProtected, ); if (hasEditableBlock) { - return withBlankParagraphs; + return document; } - return withBlankParagraphs.copyWith( + return document.copyWith( blocks: [ - ...withBlankParagraphs.blocks, + ...document.blocks, const BusyBlock( id: 'empty-paragraph', kind: BusyBlockKind.paragraph, @@ -3290,133 +3290,6 @@ BusyDocument _ensureEditableDocument(BusyDocument document) { ); } -BusyDocument _restoreSourceBlankParagraphs(BusyDocument document) { - final source = document.source; - if (source == null || - document.blocks.any( - (block) => - block.kind == BusyBlockKind.paragraph && block.plainText.isEmpty, - )) { - return document; - } - final frontMatterBlocks = document.blocks - .where((block) => block.kind == BusyBlockKind.frontMatter) - .toList(); - final sourceBlocks = document.blocks - .where( - (block) => - block.kind != BusyBlockKind.frontMatter && !block.isGenerated, - ) - .toList(); - final generatedBlocks = document.blocks - .where((block) => block.isGenerated) - .toList(); - if (sourceBlocks.isEmpty) { - if (document.rawFrontMatter != null || source.trim().isNotEmpty) { - return document; - } - final blankOffsets = [ - 0, - for (final match in '\n'.allMatches(source)) match.end, - ]; - return document.copyWith( - blocks: [ - ...frontMatterBlocks, - for (final (index, offset) in blankOffsets.indexed) - _sourceBlankParagraph(document, offset, index), - ...generatedBlocks, - ], - ); - } - if (sourceBlocks.any((block) => block.sourceSpan == null)) { - return document; - } - - final expanded = []; - var previousEnd = document.rawFrontMatter?.length ?? 0; - for (final (index, block) in sourceBlocks.indexed) { - final span = block.sourceSpan!; - if (span.startOffset < previousEnd || span.endOffset > source.length) { - return document; - } - final gap = source.substring(previousEnd, span.startOffset); - if (gap.trim().isNotEmpty) { - return document; - } - // Two newlines are the ordinary Markdown block boundary. Every newline - // after that represents another blank paragraph in the rich editor. - final baselineNewlines = index == 0 && document.rawFrontMatter == null - ? 0 - : 2; - final blankOffsets = _extraBlankLineOffsets( - gap, - startOffset: previousEnd, - baselineNewlines: baselineNewlines, - ); - for (final (blankIndex, offset) in blankOffsets.indexed) { - expanded.add(_sourceBlankParagraph(document, offset, blankIndex)); - } - expanded.add(block); - previousEnd = span.endOffset; - } - - final trailing = source.substring(previousEnd); - if (trailing.trim().isNotEmpty) { - return document; - } - final trailingBlankOffsets = _extraBlankLineOffsets( - trailing, - startOffset: previousEnd, - baselineNewlines: 1, - ); - for (final (blankIndex, offset) in trailingBlankOffsets.indexed) { - expanded.add(_sourceBlankParagraph(document, offset, blankIndex)); - } - if (expanded.length == sourceBlocks.length) { - return document; - } - return document.copyWith( - blocks: [...frontMatterBlocks, ...expanded, ...generatedBlocks], - ); -} - -List _extraBlankLineOffsets( - String gap, { - required int startOffset, - required int baselineNewlines, -}) { - final newlineOffsets = [ - for (final match in '\n'.allMatches(gap)) startOffset + match.start, - ]; - if (newlineOffsets.length <= baselineNewlines) { - return const []; - } - final lineStarts = [ - startOffset, - for (final offset in newlineOffsets) offset + 1, - ]; - return [ - for (var index = baselineNewlines; index < newlineOffsets.length; index++) - lineStarts[index], - ]; -} - -BusyBlock _sourceBlankParagraph(BusyDocument document, int offset, int index) { - return BusyBlock( - id: '\u0000source-blank:$offset:$index', - kind: BusyBlockKind.paragraph, - inlines: const [BusyInline(kind: BusyInlineKind.text, text: '')], - attributes: const {busyMarkPreserveEmptyParagraphAttribute: 'true'}, - rawSource: '', - sourceSpan: SourceSpan.fromOffsets( - filePath: document.filePath, - source: document.source ?? '', - startOffset: offset, - endOffset: offset, - ), - ); -} - bool _selectionCoveredByKind( List ranges, BusyInlineKind kind, diff --git a/lib/src/export/markdown_pdf_export_ui.dart b/lib/src/export/markdown_pdf_export_ui.dart index 665dd933..9237eda7 100644 --- a/lib/src/export/markdown_pdf_export_ui.dart +++ b/lib/src/export/markdown_pdf_export_ui.dart @@ -165,7 +165,7 @@ Future exportActiveMarkdownToPdf( final failure = outcome.failure; if (failure != null) { if (failure.code != MarkdownPdfFailureCode.cancelled) { - await _showPdfExportError(context, headerBar, failure); + await showMarkdownPdfExportError(context, headerBar, failure); } return; } @@ -228,7 +228,8 @@ Future _confirmPdfOverwrite( false; } -Future _showPdfExportError( +@visibleForTesting +Future showMarkdownPdfExportError( BuildContext context, LinuxHeaderBarService headerBar, MarkdownPdfExportException failure, @@ -256,7 +257,19 @@ Future _showPdfExportError( onPressed: () => Navigator.pop(context), ), ], - children: [Text(message)], + children: [ + Text(message), + if (failure.detail.trim().isNotEmpty && + failure.code != MarkdownPdfFailureCode.destinationExists) ...[ + const SizedBox(height: BusyMarkSpacing.md), + SelectionArea( + child: Text( + failure.detail, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ], ), ); } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index a8da26e3..9041db73 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -5090,6 +5090,142 @@ void main() { ); }); + testWidgets( + 'rendered views ignore redundant blanks while Source preserves them', + (tester) async { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const source = + '# Test Title 1\n' + '\n' + 'Lorem ipsum dolor\n' + '\n' + '\n' + '\n' + 'Lorem ipsume dolor 2\n' + '\n' + '\n' + 'Sincerely,\n' + '\n' + 'User name\n'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.editor) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/empty-paragraphs.md'), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + final editorContent = find.byKey( + const ValueKey('wysiwyg-document-content'), + ); + final editorScroll = find.byKey( + const ValueKey('wysiwyg-document-scroll'), + ); + final editorFieldFinder = find.descendant( + of: editorScroll, + matching: find.byType(TextField), + ); + for ( + var i = 0; + i < 30 && editorFieldFinder.evaluate().length != 5; + i += 1 + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(editorContent, findsOneWidget); + final editorFields = tester + .widgetList(editorFieldFinder) + .toList(); + final editorEmptyFields = editorFields + .where((field) => field.controller?.text.isEmpty ?? false) + .toList(); + expect(editorFields, hasLength(5)); + expect(editorEmptyFields, isEmpty); + final editorContentTops = [ + for (var index = 0; index < editorFields.length; index += 1) + tester.getTopLeft(editorFieldFinder.at(index)).dy, + ]; + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tester.pump(const Duration(milliseconds: 100)); + final previewScroll = find.byKey( + const ValueKey('preview-document-scroll'), + ); + final previewContentFinders = [ + for (final text in const [ + 'Test Title 1', + 'Lorem ipsum dolor', + 'Lorem ipsume dolor 2', + 'Sincerely,', + 'User name', + ]) + find.descendant( + of: previewScroll, + matching: find.byWidgetPredicate( + (widget) => + widget is Text && widget.textSpan?.toPlainText() == text, + ), + ), + ]; + for (final finder in previewContentFinders) { + expect(finder, findsOneWidget); + } + final previewContentTops = [ + for (final finder in previewContentFinders) + tester.getTopLeft(finder).dy, + ]; + for (var index = 1; index < previewContentTops.length; index += 1) { + expect( + previewContentTops[index] - previewContentTops.first, + closeTo(editorContentTops[index] - editorContentTops.first, 0.1), + ); + } + expect(find.text(l10n.untitledResult), findsNothing); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + final sourceEditor = find.byType(BusyMarkSourceEditor); + for (var i = 0; i < 30 && sourceEditor.evaluate().isEmpty; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } + final sourceField = find.descendant( + of: sourceEditor, + matching: find.byType(TextField), + ); + expect(sourceField, findsOneWidget); + expect(tester.widget(sourceField).controller?.text, source); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Editor and Preview share task-list presentation and wrapping', ( tester, ) async { diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 7e906a94..3e1d2716 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -2140,9 +2140,7 @@ void main() {} }, ); - testWidgets('WYSIWYG Shift+Down traverses explicit empty paragraphs', ( - tester, - ) async { + testWidgets('WYSIWYG ignores redundant source blank lines', (tester) async { final parsed = parser.parse( filePath: 'topic.md', source: 'First\n\n\nSecond\n', @@ -2169,19 +2167,9 @@ void main() {} TextField fieldAt(int index) => tester.widget(find.byType(TextField).at(index)); - expect(find.byType(TextField), findsNWidgets(3)); - expect(fieldAt(1).controller!.text, isEmpty); - fieldAt(0).controller!.selection = const TextSelection.collapsed(offset: 0); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.pump(); - expect(fieldAt(1).focusNode!.hasFocus, isTrue); - - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - expect(fieldAt(2).focusNode!.hasFocus, isTrue); - expect(fieldAt(2).controller!.selection.extentOffset, 0); + expect(find.byType(TextField), findsNWidgets(2)); + expect(fieldAt(0).controller!.text, 'First'); + expect(fieldAt(1).controller!.text, 'Second'); }); testWidgets('typing replaces a WYSIWYG selection across paragraphs', ( @@ -5044,42 +5032,44 @@ void main() {} expect(controller.markdown, 'First\n\nSecond\n'); }); - test('WYSIWYG Enter preserves an empty paragraph across reloads', () { - final parsed = parser.parse( - filePath: 'topic.md', - source: 'First\n\nSecond\n', - ); - final controller = BusyMarkWysiwygDocumentController( - document: parsed.busyDocument, - ); - final firstBlock = controller.document.blocks.first; + test( + 'WYSIWYG reload ignores an empty paragraph represented only by blanks', + () { + final parsed = parser.parse( + filePath: 'topic.md', + source: 'First\n\nSecond\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final firstBlock = controller.document.blocks.first; - final emptyBlockId = controller.applyEnterAt( - firstBlock.id, - firstBlock.plainText.length, - ); + final emptyBlockId = controller.applyEnterAt( + firstBlock.id, + firstBlock.plainText.length, + ); - expect(emptyBlockId, isNotNull); - expect(controller.markdown, 'First\n\n\nSecond\n'); - expect(controller.document.blocks.map((block) => block.plainText), [ - 'First', - '', - 'Second', - ]); + expect(emptyBlockId, isNotNull); + expect(controller.markdown, 'First\n\n\nSecond\n'); + expect(controller.document.blocks.map((block) => block.plainText), [ + 'First', + '', + 'Second', + ]); - final reopened = BusyMarkWysiwygDocumentController( - document: parser - .parse(filePath: 'topic.md', source: controller.markdown) - .busyDocument, - ); - expect(reopened.document.blocks.map((block) => block.plainText), [ - 'First', - '', - 'Second', - ]); - }); + final reopened = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: controller.markdown) + .busyDocument, + ); + expect(reopened.document.blocks.map((block) => block.plainText), [ + 'First', + 'Second', + ]); + }, + ); - test('WYSIWYG Enter preserves an empty paragraph at end of file', () { + test('WYSIWYG reload ignores trailing blank lines', () { final parsed = parser.parse(filePath: 'topic.md', source: 'First\n'); final controller = BusyMarkWysiwygDocumentController( document: parsed.busyDocument, @@ -5094,30 +5084,20 @@ void main() {} .parse(filePath: 'topic.md', source: controller.markdown) .busyDocument, ); - expect(reopened.document.blocks.map((block) => block.plainText), [ - 'First', - '', - ]); + expect(reopened.document.blocks.map((block) => block.plainText), ['First']); }); - test('WYSIWYG can edit and remove a restored empty paragraph', () { - BusyMarkWysiwygDocumentController open(String source) { - return BusyMarkWysiwygDocumentController( - document: parser - .parse(filePath: 'topic.md', source: source) - .busyDocument, - ); - } - - final edited = open('First\n\n\nSecond\n'); - final emptyBlock = edited.document.blocks[1]; - edited.updateBlockText(emptyBlock.id, 'Middle'); - expect(edited.markdown, 'First\n\nMiddle\n\nSecond\n'); + test('WYSIWYG does not create blocks for redundant source whitespace', () { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: 'First\n\n\nSecond\n') + .busyDocument, + ); - final removed = open('First\n\n\nSecond\n'); - final removedEmptyBlock = removed.document.blocks[1]; - removed.applyBackspaceAtStart(removedEmptyBlock.id); - expect(removed.markdown, 'First\n\nSecond\n'); + expect(controller.document.blocks.map((block) => block.plainText), [ + 'First', + 'Second', + ]); }); test('WYSIWYG Enter in unordered list creates next item then exits list', () { diff --git a/test/src/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index ff8c3f6a..25ab1ada 100644 --- a/test/src/markdown_pdf_export_service_test.dart +++ b/test/src/markdown_pdf_export_service_test.dart @@ -1,7 +1,9 @@ +import 'dart:convert'; import 'dart:io'; import 'package:busymark/src/export/markdown_pdf_export_service.dart'; import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/typst_compiler.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -48,6 +50,125 @@ void main() { expect(callouts, isNot(contains('quote(\n block: true'))); }); + test('PDF export ignores redundant blank lines between blocks', () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'busymark-empty-paragraph-export-test-', + ); + addTearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + final runner = _CapturingTypstRunner(); + final service = MarkdownPdfExportService( + compilerLocator: const TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': '/bin/true'}, + ), + commandRunner: runner, + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + + Future export(String source, String name) => service.export( + MarkdownPdfExportRequest( + source: source, + filePath: '/workspace/empty-lines.md', + workspaceRoot: '/workspace', + destinationPath: p.join(temporaryDirectory.path, name), + options: const PdfExportOptions(), + overwrite: false, + ), + ); + + await export( + '# Test Title 1\n\n' + 'Lorem ipsum dolor\n\n' + 'Lorem ipsume dolor 2\n\n' + 'Sincerely,\n\n' + 'User name\n', + 'ordinary.pdf', + ); + final ordinaryBlocks = runner.payload!['blocks'] as List; + await export( + '# Test Title 1\n\n' + 'Lorem ipsum dolor\n\n\n\n' + 'Lorem ipsume dolor 2\n\n\n' + 'Sincerely,\n\n' + 'User name\n', + 'redundant.pdf', + ); + final redundantBlocks = runner.payload!['blocks'] as List; + + expect(redundantBlocks, ordinaryBlocks); + expect( + redundantBlocks.map((value) => (value as Map)['kind']), + ['heading', 'paragraph', 'paragraph', 'paragraph', 'paragraph'], + ); + }); + + test( + 'bundled template renders redundant blank lines identically', + () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'busymark-empty-paragraph-typst-test-', + ); + addTearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + final ordinaryDestination = p.join( + temporaryDirectory.path, + 'ordinary.pdf', + ); + final redundantDestination = p.join( + temporaryDirectory.path, + 'redundant.pdf', + ); + final service = MarkdownPdfExportService( + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + + await service.export( + MarkdownPdfExportRequest( + source: + '# Test Title 1\n\n' + 'Lorem ipsum dolor\n\n' + 'Lorem ipsume dolor 2\n\n' + 'Sincerely,\n\n' + 'User name\n', + filePath: '/workspace/empty-lines.md', + workspaceRoot: '/workspace', + destinationPath: ordinaryDestination, + options: const PdfExportOptions(), + overwrite: false, + ), + ); + await service.export( + MarkdownPdfExportRequest( + source: + '# Test Title 1\n\n' + 'Lorem ipsum dolor\n\n\n\n' + 'Lorem ipsume dolor 2\n\n\n' + 'Sincerely,\n\n' + 'User name\n', + filePath: '/workspace/empty-lines.md', + workspaceRoot: '/workspace', + destinationPath: redundantDestination, + options: const PdfExportOptions(), + overwrite: false, + ), + ); + + final ordinaryBytes = await File(ordinaryDestination).readAsBytes(); + final redundantBytes = await File(redundantDestination).readAsBytes(); + expect(ordinaryBytes.take(5), [0x25, 0x50, 0x44, 0x46, 0x2d]); + expect(redundantBytes, ordinaryBytes); + }, + skip: canRunTypst + ? false + : 'Set BUSYMARK_TYPST_PATH to run the real Typst integration test.', + ); + test( 'bundled template exports representative Markdown to a valid PDF', () async { @@ -233,3 +354,27 @@ class _WritersideMarkdownParser extends MarkdownParser { ); } } + +class _CapturingTypstRunner implements TypstCommandRunner { + Map? payload; + + @override + Future compile({ + required String executable, + required Directory workingDirectory, + required Duration timeout, + required MarkdownPdfCancellationToken cancellationToken, + }) async { + payload = + jsonDecode( + await File( + p.join(workingDirectory.path, 'document.json'), + ).readAsString(), + ) + as Map; + await File( + p.join(workingDirectory.path, 'output.pdf'), + ).writeAsString('%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n', flush: true); + return const TypstProcessResult(exitCode: 0, stdout: '', stderr: ''); + } +} diff --git a/test/src/markdown_pdf_export_test.dart b/test/src/markdown_pdf_export_test.dart index d0cbc59a..c10d1452 100644 --- a/test/src/markdown_pdf_export_test.dart +++ b/test/src/markdown_pdf_export_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/core/atomic_file_writer.dart'; import 'package:busymark/src/export/markdown_export_assets.dart'; import 'package:busymark/src/export/markdown_export_mapper.dart'; @@ -9,12 +11,69 @@ import 'package:busymark/src/export/markdown_pdf_models.dart'; import 'package:busymark/src/export/typst_compiler.dart'; import 'package:busymark/src/export/writerside_pdf_export_ui.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + testWidgets('PDF export failure dialog exposes actionable detail', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: Builder( + builder: (value) { + context = value; + return const Scaffold(); + }, + ), + ), + ); + final headerBar = LinuxHeaderBarService( + channel: const MethodChannel('markdown-pdf-error-test'), + ); + final dialog = showMarkdownPdfExportError( + context, + headerBar, + const MarkdownPdfExportException( + MarkdownPdfFailureCode.compilerFailed, + detail: 'Typst could not load the bundled template.', + ), + ); + + await tester.pumpAndSettle(); + expect( + find.text('BusyMark could not export this document as PDF.'), + findsOneWidget, + ); + expect( + find.text('Typst could not load the bundled template.'), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.text('Typst could not load the bundled template.'), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + ); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + await dialog; + }); + test('PDF export eligibility is limited to active regular Markdown', () { const parser = MarkdownParser(); final parsed = parser.parse( diff --git a/test/src/preview_builder_test.dart b/test/src/preview_builder_test.dart index 07dedfc4..c78f31db 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -17,6 +17,55 @@ void main() { expect(preview.compatibility, isEmpty); }); + test('preview ignores redundant blank lines between blocks', () { + // CommonMark 0.31.2 section 4.9: blank lines between block-level elements + // are ignored (apart from their separate role in tight/loose lists). + final ordinary = parser.parse( + filePath: 'topic.md', + source: + '# Test Title 1\n' + '\n' + 'Lorem ipsum dolor\n' + '\n' + 'Lorem ipsume dolor 2\n' + '\n' + 'Sincerely,\n' + '\n' + 'User name\n', + ); + final redundant = parser.parse( + filePath: 'topic.md', + source: + '# Test Title 1\n' + '\n' + 'Lorem ipsum dolor\n' + '\n' + '\n' + '\n' + 'Lorem ipsume dolor 2\n' + '\n' + '\n' + 'Sincerely,\n' + '\n' + 'User name\n', + ); + + final ordinaryPreview = previewBuilder.build(ordinary); + final redundantPreview = previewBuilder.build(redundant); + + expect(redundantPreview.blocks.map((block) => block.text), [ + 'Test Title 1', + 'Lorem ipsum dolor', + 'Lorem ipsume dolor 2', + 'Sincerely,', + 'User name', + ]); + expect( + redundantPreview.blocks.map((block) => (block.kind, block.text)), + ordinaryPreview.blocks.map((block) => (block.kind, block.text)), + ); + }); + test('preview headings carry parser anchors for outline navigation', () { final parsed = parser.parse( filePath: 'topic.md', From 182b223f9301ea36a2bf8b34b44636debf15bfee Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 17:02:00 -0700 Subject: [PATCH 09/27] Ensure block focus after inserting hard break in WYSIWYG editor --- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 1 + test/src/busymark_document_test.dart | 89 ++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 11e00ce0..34bf4343 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -4597,6 +4597,7 @@ class _BusyMarkWysiwygEditorState extends State { _recordUndoSnapshot(); _documentController.insertHardBreak(blockId, offset); _emitMarkdown(); + _focusBlockAfterFrame(blockId, offset: offset + 1); } Future _applyCodeLanguageCommand() async { diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 3e1d2716..329142d7 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5407,6 +5407,95 @@ void main() {} expect(find.byType(TextField), findsOneWidget); }); + testWidgets('hard-break toolbar command retains focus and advances caret', ( + tester, + ) async { + final parsed = parser.parse(filePath: 'topic.md', source: 'Alpha Beta\n'); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final fieldFinder = find.byType(TextField).first; + final field = tester.widget(fieldFinder); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + final l10n = AppLocalizations.of(tester.element(fieldFinder)); + final hardBreakButton = find.byWidgetPredicate( + (widget) => + widget is BusyMarkHeaderIconButton && + widget.tooltip == l10n.hardLineBreak, + ); + + expect(hardBreakButton, findsOneWidget); + await tester.tap(hardBreakButton); + await tester.pump(); + + expect(markdown, 'Alpha \n Beta\n'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 6), + ); + }); + + testWidgets('Shift+Enter retains focus and advances past the hard break', ( + tester, + ) async { + final parsed = parser.parse(filePath: 'topic.md', source: 'Alpha Beta\n'); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final field = tester.widget(find.byType(TextField).first); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + + expect(markdown, 'Alpha \n Beta\n'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 6), + ); + }); + testWidgets('WYSIWYG editor lazily builds large documents', (tester) async { final source = List.generate( 500, From f04c44c7e5489842e81b2b2f4c4fc6170de84e10 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 18:14:52 -0700 Subject: [PATCH 10/27] Add support for blank line insertion in WYSIWYG editor - Introduced `insertBlankLine` method to handle consecutive hard breaks. - Added blank line command and toolbar button with relevant glyph. - Updated document controller and serializer for proper blank line handling and preservation. --- docs/pdf-export.md | 10 ++ lib/l10n/app_ar.arb | 4 + lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 4 + lib/l10n/app_et.arb | 4 + lib/l10n/app_fa.arb | 4 + lib/l10n/app_fr.arb | 4 + lib/l10n/app_hi.arb | 4 + lib/l10n/app_id.arb | 4 + lib/l10n/app_it.arb | 4 + lib/l10n/app_ja.arb | 4 + lib/l10n/app_ko.arb | 4 + lib/l10n/app_nb.arb | 4 + lib/l10n/app_nl.arb | 4 + lib/l10n/app_pl.arb | 4 + lib/l10n/app_pt.arb | 4 + lib/l10n/app_pt_BR.arb | 4 + lib/l10n/app_ru.arb | 4 + lib/l10n/app_tr.arb | 4 + lib/l10n/app_uk.arb | 4 + lib/l10n/app_vi.arb | 4 + lib/l10n/app_zh.arb | 4 + lib/l10n/app_zh_CN.arb | 4 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 3 + lib/l10n/generated/app_localizations_de.dart | 3 + lib/l10n/generated/app_localizations_en.dart | 3 + lib/l10n/generated/app_localizations_es.dart | 3 + lib/l10n/generated/app_localizations_et.dart | 3 + lib/l10n/generated/app_localizations_fa.dart | 3 + lib/l10n/generated/app_localizations_fr.dart | 3 + lib/l10n/generated/app_localizations_hi.dart | 3 + lib/l10n/generated/app_localizations_id.dart | 3 + lib/l10n/generated/app_localizations_it.dart | 3 + lib/l10n/generated/app_localizations_ja.dart | 3 + lib/l10n/generated/app_localizations_ko.dart | 3 + lib/l10n/generated/app_localizations_nb.dart | 3 + lib/l10n/generated/app_localizations_nl.dart | 3 + lib/l10n/generated/app_localizations_pl.dart | 3 + lib/l10n/generated/app_localizations_pt.dart | 6 + lib/l10n/generated/app_localizations_ru.dart | 3 + lib/l10n/generated/app_localizations_tr.dart | 3 + lib/l10n/generated/app_localizations_uk.dart | 3 + lib/l10n/generated/app_localizations_vi.dart | 3 + lib/l10n/generated/app_localizations_zh.dart | 6 + lib/src/app/busymark_dialogs.dart | 7 ++ lib/src/app/busymark_glyphs.dart | 1 + .../wysiwyg/wysiwyg_document_controller.dart | 12 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 20 ++++ lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 16 ++- .../busymark_markdown_serializer.dart | 17 +++ test/src/app_smoke_test.dart | 91 +++++++++++++++ test/src/busymark_design_test.dart | 6 + test/src/busymark_document_test.dart | 73 ++++++++++++ test/src/html_export_test.dart | 10 ++ .../src/markdown_pdf_export_service_test.dart | 104 ++++++++++++++++++ test/src/preview_builder_test.dart | 16 +++ test/src/writerside_pdf_export_test.dart | 32 ++++++ 59 files changed, 577 insertions(+), 3 deletions(-) diff --git a/docs/pdf-export.md b/docs/pdf-export.md index 39b77dca..d4a591f0 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -39,6 +39,16 @@ Choose the destination after confirming settings. BusyMark asks before replacing an existing file. Export can be cancelled, and a failed export keeps the previous PDF usable. +## Intentional blank lines + +Use **Insert blank line** in Editor view when a specific one-line gap is part of +the document. BusyMark stores the gap as two explicit `
    ` elements, so it is +visible in Editor and Reading views and preserved by Markdown PDF and HTML +exports. Source view shows the `

    ` representation. + +Additional empty source lines are still ordinary Markdown block separators; +repeating them does not create additional vertical space. + ## Offline content PDF export works locally with BusyMark’s included PDF, math, and diagram tools. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b162d19d..0aa677cc 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "إدراج سطر فارغ", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "نمط النص", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a7934ba0..2e6747e2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Leerzeile einfügen", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Textstil", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7efa62db..0af64e34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -576,6 +576,8 @@ "@link": {"description": "Link command and preview label."}, "hardLineBreak": "Hard line break", "@hardLineBreak": {"description": "Hard line break command label."}, + "insertBlankLine": "Insert blank line", + "@insertBlankLine": {"description": "Insert blank line command label."}, "textStyle": "Text style", "@textStyle": {"description": "Text style menu tooltip."}, "paragraph": "Paragraph", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index eece5225..f7ad799c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Insertar línea en blanco", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Estilo de texto", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index e5acefb5..955d0352 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1065,6 +1065,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Lisa tühi rida", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Tekstilaad", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index a756ee4e..c50cf1a8 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -978,6 +978,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "درج خط خالی", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "سبک متن", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 17efdd3d..7bbc6257 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Insérer une ligne vide", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Style de texte", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 0c4381ba..93ad239d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "खाली पंक्ति डालें", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "टेक्स्ट शैली", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8e175ea0..fa7c6e15 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Sisipkan baris kosong", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Gaya teks", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 563b7bf5..b24960df 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Inserisci riga vuota", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Stile del testo", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0d672e6c..4217d5f9 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "空行を挿入", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "テキストスタイル", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cc0fb6de..7f164d28 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "빈 줄 삽입", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "텍스트 스타일", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 0c2eb071..2365be6c 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Sett inn tom linje", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Tekststil", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 951680ef..1274b1b5 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Lege regel invoegen", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Tekststijl", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 62b996f8..97513971 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Wstaw pusty wiersz", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Styl tekstu", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e6df7f5e..b3a37f49 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Inserir linha em branco", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Estilo de texto", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 09d1cf33..dd07209a 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Inserir linha em branco", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Estilo de texto", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 537f3708..2ddf9c43 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Вставить пустую строку", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Стиль текста", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index eca8744c..44c58aef 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Boş satır ekle", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Metin stili", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c01a715b..7f4bd802 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -977,6 +977,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Вставити порожній рядок", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Стиль тексту", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 5157ecbd..a7fea7d8 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Chèn dòng trống", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Kiểu văn bản", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6ddf1cfb..744be947 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "插入空行", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "文本样式", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 392124ba..361f30c4 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1137,6 +1137,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "插入空行", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "文本样式", "@textStyle": { "description": "Text style menu tooltip." diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index d86eed4b..99705ab3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1825,6 +1825,12 @@ abstract class AppLocalizations { /// **'Hard line break'** String get hardLineBreak; + /// Insert blank line command label. + /// + /// In en, this message translates to: + /// **'Insert blank line'** + String get insertBlankLine; + /// Text style menu tooltip. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 3c1449b8..9dce9258 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -935,6 +935,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get hardLineBreak => 'فاصل سطر صريح'; + @override + String get insertBlankLine => 'إدراج سطر فارغ'; + @override String get textStyle => 'نمط النص'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 5382a5fe..74e9d6d9 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -955,6 +955,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get hardLineBreak => 'Harter Zeilenumbruch'; + @override + String get insertBlankLine => 'Leerzeile einfügen'; + @override String get textStyle => 'Textstil'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 8ee5fbbe..81186e0f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -936,6 +936,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hardLineBreak => 'Hard line break'; + @override + String get insertBlankLine => 'Insert blank line'; + @override String get textStyle => 'Text style'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index da46b78a..774f7efa 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -953,6 +953,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get hardLineBreak => 'Salto de línea forzado'; + @override + String get insertBlankLine => 'Insertar línea en blanco'; + @override String get textStyle => 'Estilo de texto'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 54bb25b5..86076bdb 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -940,6 +940,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get hardLineBreak => 'Sundreavahetus'; + @override + String get insertBlankLine => 'Lisa tühi rida'; + @override String get textStyle => 'Tekstilaad'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 290b7a18..550048ea 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -936,6 +936,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get hardLineBreak => 'شکست خط اجباری'; + @override + String get insertBlankLine => 'درج خط خالی'; + @override String get textStyle => 'سبک متن'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 7e7e6440..021823bd 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -952,6 +952,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get hardLineBreak => 'Saut de ligne forcé'; + @override + String get insertBlankLine => 'Insérer une ligne vide'; + @override String get textStyle => 'Style de texte'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 25fe787f..1b6ad5e1 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -935,6 +935,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get hardLineBreak => 'हार्ड लाइन ब्रेक'; + @override + String get insertBlankLine => 'खाली पंक्ति डालें'; + @override String get textStyle => 'टेक्स्ट शैली'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index f5bf3436..0da4f21e 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -941,6 +941,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get hardLineBreak => 'Pemisah baris keras'; + @override + String get insertBlankLine => 'Sisipkan baris kosong'; + @override String get textStyle => 'Gaya teks'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 28d2e8f2..f4b5b017 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -950,6 +950,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get hardLineBreak => 'Interruzione di riga forzata'; + @override + String get insertBlankLine => 'Inserisci riga vuota'; + @override String get textStyle => 'Stile del testo'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 063bbf8e..b78e4a9e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -903,6 +903,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get hardLineBreak => '強制改行'; + @override + String get insertBlankLine => '空行を挿入'; + @override String get textStyle => 'テキストスタイル'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 0c149462..15b33536 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -900,6 +900,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get hardLineBreak => '강제 줄 바꿈'; + @override + String get insertBlankLine => '빈 줄 삽입'; + @override String get textStyle => '텍스트 스타일'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index cf272f03..a1502b95 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -940,6 +940,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get hardLineBreak => 'Tvungent linjeskift'; + @override + String get insertBlankLine => 'Sett inn tom linje'; + @override String get textStyle => 'Tekststil'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index f967b024..2507e8e7 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -941,6 +941,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get hardLineBreak => 'Hard regeleinde'; + @override + String get insertBlankLine => 'Lege regel invoegen'; + @override String get textStyle => 'Tekststijl'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index b11fce80..cf479eab 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -956,6 +956,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get hardLineBreak => 'Twardy podział wiersza'; + @override + String get insertBlankLine => 'Wstaw pusty wiersz'; + @override String get textStyle => 'Styl tekstu'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 79789ebd..b70627fc 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -948,6 +948,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get hardLineBreak => 'Quebra de linha forçada'; + @override + String get insertBlankLine => 'Inserir linha em branco'; + @override String get textStyle => 'Estilo de texto'; @@ -5175,6 +5178,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get hardLineBreak => 'Quebra de linha forçada'; + @override + String get insertBlankLine => 'Inserir linha em branco'; + @override String get textStyle => 'Estilo de texto'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index cc85c506..e3f3189a 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -952,6 +952,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get hardLineBreak => 'Принудительный перенос строки'; + @override + String get insertBlankLine => 'Вставить пустую строку'; + @override String get textStyle => 'Стиль текста'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index ad22cf53..02e44124 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -942,6 +942,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get hardLineBreak => 'Zorunlu satır sonu'; + @override + String get insertBlankLine => 'Boş satır ekle'; + @override String get textStyle => 'Metin stili'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 9a9796a4..8cb1cd3a 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -958,6 +958,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get hardLineBreak => 'Примусовий розрив рядка'; + @override + String get insertBlankLine => 'Вставити порожній рядок'; + @override String get textStyle => 'Стиль тексту'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index b95a9df7..c1c92050 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -935,6 +935,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get hardLineBreak => 'Ngắt dòng cứng'; + @override + String get insertBlankLine => 'Chèn dòng trống'; + @override String get textStyle => 'Kiểu văn bản'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 74199185..c13cf4f7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -888,6 +888,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get hardLineBreak => '硬换行'; + @override + String get insertBlankLine => '插入空行'; + @override String get textStyle => '文本样式'; @@ -4867,6 +4870,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get hardLineBreak => '硬换行'; + @override + String get insertBlankLine => '插入空行'; + @override String get textStyle => '文本样式'; diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 1266c196..d2d1412d 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -1599,6 +1599,13 @@ class _SyntaxReferenceDialogState extends State<_SyntaxReferenceDialog> { scope: l10n.markdown, documentationUri: _htmlDocumentationUri, ), + _SyntaxReferenceEntry( + title: l10n.insertBlankLine, + example: 'First line

    \nSecond line', + identifiers: 'br + br', + scope: l10n.markdown, + documentationUri: _htmlDocumentationUri, + ), ], footer: _SyntaxReferenceNote( items: [ diff --git a/lib/src/app/busymark_glyphs.dart b/lib/src/app/busymark_glyphs.dart index 056ad1d1..3a5b4805 100644 --- a/lib/src/app/busymark_glyphs.dart +++ b/lib/src/app/busymark_glyphs.dart @@ -10,6 +10,7 @@ abstract final class BusyMarkGlyphs { static const IconData ai = YaruIcons.star_filled; static const IconData appearance = YaruIcons.desktop_appearance; static const IconData blockquote = YaruIcons.chat_text; + static const IconData blankLine = YaruIcons.insert_text; static const IconData bold = YaruIcons.bold; static const IconData branch = Icons.fork_right; static const IconData category = YaruIcons.tag; diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 30b38731..7169c013 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -879,15 +879,23 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void insertHardBreak(String blockId, int offset) { + _insertHardBreaks(blockId, offset, count: 1); + } + + void insertBlankLine(String blockId, int offset) { + _insertHardBreaks(blockId, offset, count: 2); + } + + void _insertHardBreaks(String blockId, int offset, {required int count}) { _replaceBlock(blockId, (block) { final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); - final nextText = text.replaceRange(safeOffset, safeOffset, '\n'); + final nextText = text.replaceRange(safeOffset, safeOffset, '\n' * count); final ranges = _styleRangesForReplacement( ranges: busyInlineStyleRanges(block.inlines), selectionStart: safeOffset, selectionEnd: safeOffset, - replacementLength: 1, + replacementLength: count, ); return block.copyWith( inlines: _inlinesFromStyleRangesWithHardBreaks(nextText, ranges), diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 34bf4343..a5dd0a5b 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -600,6 +600,7 @@ class _BusyMarkWysiwygEditorState extends State { onAdmonitionCommand: _applyAdmonitionCommand, admonitionCommandsEnabled: _canApplyAdmonitionCommand(), inlineCommandsEnabled: _hasInlineCommandTarget, + lineBreakCommandsEnabled: _activeCellId == null, admonitionsEnabled: _documentController.document.mode == MarkdownMode.writersideMarkdown, @@ -616,6 +617,7 @@ class _BusyMarkWysiwygEditorState extends State { onOutdentCommand: _applyOutdentCommand, onToggleTaskCommand: _applyToggleTaskCommand, onHardBreakCommand: _applyHardBreakCommand, + onBlankLineCommand: _applyBlankLineCommand, ), ), ], @@ -4600,6 +4602,24 @@ class _BusyMarkWysiwygEditorState extends State { _focusBlockAfterFrame(blockId, offset: offset + 1); } + void _applyBlankLineCommand() { + final target = _activeTextTarget(); + if (target == null || _activeCellId != null) { + return; + } + final blockId = target.targetId; + final controller = target.controller; + final block = target.block; + final selection = controller.selection; + final offset = selection.isValid + ? selection.extentOffset.clamp(0, controller.text.length).toInt() + : block.plainText.length; + _recordUndoSnapshot(); + _documentController.insertBlankLine(blockId, offset); + _emitMarkdown(); + _focusBlockAfterFrame(blockId, offset: offset + 2); + } + Future _applyCodeLanguageCommand() async { final blockIds = _commandTargetBlockIds(); if (blockIds.isEmpty) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 5d2c2b3d..a87aa038 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -24,9 +24,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required this.onOutdentCommand, required this.onToggleTaskCommand, required this.onHardBreakCommand, + required this.onBlankLineCommand, this.isBlockCommandEnabled, this.admonitionCommandsEnabled = true, this.inlineCommandsEnabled = true, + this.lineBreakCommandsEnabled = true, this.admonitionsEnabled = false, this.alignEnd = false, this.axis = Axis.horizontal, @@ -46,9 +48,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final VoidCallback onOutdentCommand; final VoidCallback onToggleTaskCommand; final VoidCallback onHardBreakCommand; + final VoidCallback onBlankLineCommand; final bool Function(BusyWysiwygBlockCommand command)? isBlockCommandEnabled; final bool admonitionCommandsEnabled; final bool inlineCommandsEnabled; + final bool lineBreakCommandsEnabled; final bool admonitionsEnabled; final bool alignEnd; final Axis axis; @@ -141,7 +145,17 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.hardLineBreak, icon: BusyMarkGlyphs.hardBreak, shortcut: BusyMarkEditorShortcutLabels.hardLineBreak, - onPressed: inlineCommandsEnabled ? onHardBreakCommand : null, + onPressed: inlineCommandsEnabled && lineBreakCommandsEnabled + ? onHardBreakCommand + : null, + ), + _button( + context, + tooltip: context.l10n.insertBlankLine, + icon: BusyMarkGlyphs.blankLine, + onPressed: inlineCommandsEnabled && lineBreakCommandsEnabled + ? onBlankLineCommand + : null, ), ], [ diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 83006d9d..048945c5 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -453,6 +453,23 @@ class BusyMarkMarkdownSerializer { var nextAtBlockStart = atBlockStart; for (var index = 0; index < inlines.length; index++) { final inline = inlines[index]; + if (inline.kind == BusyInlineKind.hardBreak) { + var runEnd = index + 1; + while (runEnd < inlines.length && + inlines[runEnd].kind == BusyInlineKind.hardBreak) { + runEnd += 1; + } + final count = runEnd - index; + if (count > 1) { + // A whitespace-only line ends a CommonMark paragraph, so repeated + // two-space hard breaks do not round-trip. Raw inline
    elements + // are defined by CommonMark and preserve an intentional blank line. + buffer.write(List.filled(count, '
    ').join()); + nextAtBlockStart = false; + index = runEnd - 1; + continue; + } + } final source = _inline( inline, tableCell: tableCell, diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 9041db73..213c693e 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1416,6 +1416,7 @@ void main() { findsOneWidget, ); expect(categoryText('html', l10n.hardLineBreak), findsOneWidget); + expect(categoryText('html', l10n.insertBlankLine), findsOneWidget); expect(topicNavigation('html', 1), findsOneWidget); expect(topicNavigation('html', 2), findsNothing); expect(find.textContaining('article, aside, div, section'), findsOneWidget); @@ -5226,6 +5227,96 @@ void main() { }, ); + testWidgets( + 'explicit HTML breaks preserve one blank line in every document view', + (tester) async { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const source = 'Before

    After\n'; + const renderedText = 'Before\n\nAfter'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.editor) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/explicit-blank-line.md'), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + final editorScroll = find.byKey( + const ValueKey('wysiwyg-document-scroll'), + ); + final editorField = find.descendant( + of: editorScroll, + matching: find.byWidgetPredicate( + (widget) => + widget is TextField && widget.controller?.text == renderedText, + ), + ); + for (var i = 0; i < 30 && editorField.evaluate().isEmpty; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(editorField, findsOneWidget); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tester.pump(const Duration(milliseconds: 100)); + final previewScroll = find.byKey( + const ValueKey('preview-document-scroll'), + ); + expect( + find.descendant( + of: previewScroll, + matching: find.byWidgetPredicate( + (widget) => + widget is Text && + widget.textSpan?.toPlainText() == renderedText, + ), + ), + findsOneWidget, + ); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + final sourceEditor = find.byType(BusyMarkSourceEditor); + for (var i = 0; i < 30 && sourceEditor.evaluate().isEmpty; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } + final sourceField = find.descendant( + of: sourceEditor, + matching: find.byType(TextField), + ); + expect(sourceField, findsOneWidget); + expect(tester.widget(sourceField).controller?.text, source); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Editor and Preview share task-list presentation and wrapping', ( tester, ) async { diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index 192850d0..d50b8c8f 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -1401,6 +1401,7 @@ void main() { onOutdentCommand: () {}, onToggleTaskCommand: () {}, onHardBreakCommand: () {}, + onBlankLineCommand: () {}, ), ); }, @@ -1460,6 +1461,7 @@ void main() { l10n.link, l10n.inlineMath, l10n.hardLineBreak, + l10n.insertBlankLine, l10n.admonition, l10n.blockquote, l10n.codeBlock, @@ -1485,6 +1487,10 @@ void main() { }; expect(buttonsByTooltip[l10n.codeBlock]?.icon, BusyMarkGlyphs.codeBlock); expect(buttonsByTooltip[l10n.htmlBlock]?.icon, BusyMarkGlyphs.htmlBlock); + expect( + buttonsByTooltip[l10n.insertBlankLine]?.icon, + BusyMarkGlyphs.blankLine, + ); expect( buttonsByTooltip[l10n.codeBlock]?.icon, isNot(buttonsByTooltip[l10n.htmlBlock]?.icon), diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 329142d7..97baf1d4 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5496,6 +5496,65 @@ void main() {} ); }); + testWidgets( + 'blank-line toolbar command round-trips and remains one focused edit', + (tester) async { + final parsed = parser.parse(filePath: 'topic.md', source: 'Alpha Beta\n'); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final fieldFinder = find.byType(TextField).first; + final field = tester.widget(fieldFinder); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + final l10n = AppLocalizations.of(tester.element(fieldFinder)); + final blankLineButton = find.byWidgetPredicate( + (widget) => + widget is BusyMarkHeaderIconButton && + widget.tooltip == l10n.insertBlankLine, + ); + + expect(blankLineButton, findsOneWidget); + await tester.ensureVisible(blankLineButton); + await tester.tap(blankLineButton); + await tester.pump(); + + expect(markdown, 'Alpha

    Beta\n'); + expect(field.controller!.text, 'Alpha\n\n Beta'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 7), + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(markdown, 'Alpha Beta\n'); + }, + ); + testWidgets('WYSIWYG editor lazily builds large documents', (tester) async { final source = List.generate( 500, @@ -5625,6 +5684,20 @@ void main() {} hardBreakController.insertHardBreak(hardBreakBlockId, 5); expect(hardBreakController.markdown, 'Alpha \n Beta\n'); + + hardBreakController.insertHardBreak(hardBreakBlockId, 6); + expect(hardBreakController.markdown, 'Alpha

    Beta\n'); + final blankLineRoundTrip = parser.parse( + filePath: 'topic.md', + source: hardBreakController.markdown, + ); + expect(blankLineRoundTrip.busyDocument.blocks, hasLength(1)); + expect( + blankLineRoundTrip.busyDocument.blocks.single.inlines.where( + (inline) => inline.kind == BusyInlineKind.hardBreak, + ), + hasLength(2), + ); }); test('WYSIWYG table and code language commands serialize Markdown', () { diff --git a/test/src/html_export_test.dart b/test/src/html_export_test.dart index 68181283..f9b10ebd 100644 --- a/test/src/html_export_test.dart +++ b/test/src/html_export_test.dart @@ -155,6 +155,16 @@ Footnote[^note] and again[^note]. }, ); + test('explicit blank lines export as consecutive HTML breaks', () async { + final result = await export('Before

    After\n'); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final paragraph = doc.querySelector('article p'); + + expect(paragraph, isNotNull); + expect(paragraph!.querySelectorAll('br'), hasLength(2)); + expect(paragraph.text, 'BeforeAfter'); + }); + test( 'content-addressed assets relocate, deduplicate and reject symlink escapes', () async { diff --git a/test/src/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index 25ab1ada..ad2776ec 100644 --- a/test/src/markdown_pdf_export_service_test.dart +++ b/test/src/markdown_pdf_export_service_test.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; void main() { final typstPath = Platform.environment['BUSYMARK_TYPST_PATH']; final canRunTypst = typstPath != null && File(typstPath).existsSync(); + final canMeasurePdf = canRunTypst && File('/usr/bin/pdftotext').existsSync(); test('display images do not reserve a fixed-height letterbox', () { final template = File('assets/export/markdown.typ').readAsStringSync(); @@ -105,6 +106,109 @@ void main() { ); }); + test('PDF payload preserves an explicit blank line as two breaks', () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'busymark-explicit-blank-line-payload-test-', + ); + addTearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + final runner = _CapturingTypstRunner(); + final service = MarkdownPdfExportService( + compilerLocator: const TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': '/bin/true'}, + ), + commandRunner: runner, + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + + await service.export( + MarkdownPdfExportRequest( + source: 'Before

    After\n', + filePath: '/workspace/blank-line.md', + workspaceRoot: '/workspace', + destinationPath: p.join(temporaryDirectory.path, 'blank-line.pdf'), + options: const PdfExportOptions(), + overwrite: false, + ), + ); + + final blocks = runner.payload!['blocks'] as List; + final paragraph = blocks.single as Map; + final inlines = paragraph['inlines'] as List; + expect(inlines.map((value) => (value as Map)['kind']), [ + 'text', + 'hardBreak', + 'hardBreak', + 'text', + ]); + }); + + test( + 'bundled template renders two explicit breaks as one blank PDF line', + () async { + final temporaryDirectory = await Directory.systemTemp.createTemp( + 'busymark-explicit-blank-line-typst-test-', + ); + addTearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + final service = MarkdownPdfExportService( + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + + Future textBaselineDelta(String source, String name) async { + final destination = p.join(temporaryDirectory.path, '$name.pdf'); + await service.export( + MarkdownPdfExportRequest( + source: source, + filePath: p.join(temporaryDirectory.path, '$name.md'), + workspaceRoot: temporaryDirectory.path, + destinationPath: destination, + options: const PdfExportOptions(), + overwrite: false, + ), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-bbox-layout', + destination, + '-', + ]); + expect(extracted.exitCode, 0, reason: extracted.stderr.toString()); + final positions = {}; + final wordPattern = RegExp( + r'(Before|After)', + ); + for (final match in wordPattern.allMatches( + extracted.stdout as String, + )) { + positions[match.group(2)!] = double.parse(match.group(1)!); + } + expect(positions.keys, containsAll(['Before', 'After'])); + return positions['After']! - positions['Before']!; + } + + final oneBreak = await textBaselineDelta( + 'Before
    After\n', + 'one-break', + ); + final twoBreaks = await textBaselineDelta( + 'Before

    After\n', + 'two-breaks', + ); + + expect(oneBreak, greaterThan(0)); + expect(twoBreaks, greaterThan(oneBreak * 1.8)); + }, + skip: canMeasurePdf + ? false + : 'Set BUSYMARK_TYPST_PATH and install pdftotext to measure PDF lines.', + ); + test( 'bundled template renders redundant blank lines identically', () async { diff --git a/test/src/preview_builder_test.dart b/test/src/preview_builder_test.dart index c78f31db..b4e9cbb5 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -66,6 +66,22 @@ void main() { ); }); + test('preview preserves an explicit blank line encoded with HTML breaks', () { + final parsed = parser.parse( + filePath: 'topic.md', + source: 'Before

    After\n', + ); + final preview = previewBuilder.build(parsed); + + expect(preview.blocks, hasLength(1)); + expect( + _flattenInlines( + preview.blocks.single.inlines, + ).map((inline) => inline.text).join(), + 'Before\n\nAfter', + ); + }); + test('preview headings carry parser anchors for outline navigation', () { final parsed = parser.parse( filePath: 'topic.md', diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 42b1b3cb..24b5ab73 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -161,6 +161,38 @@ void main() { ); }); + test( + 'Markdown topic blank lines remain explicit in Writerside PDF', + () async { + final fixture = await _WritersideFixture.create(); + addTearDown(fixture.dispose); + await File( + p.join(fixture.module.path, 'topics', 'intro.md'), + ).writeAsString('# Introduction\n\nBefore

    After\n'); + final exporter = _RecordingMarkdownExporter(); + + await WritersidePdfExportService(markdownExporter: exporter).export( + WritersidePdfExportRequest( + moduleRoot: fixture.module.path, + projectRoot: fixture.root.path, + instanceId: 'guide', + destinationPath: p.join(fixture.root.path, 'blank-line.pdf'), + overwrite: false, + ), + ); + + final paragraph = _allBlocks( + exporter.request!.document!.blocks, + ).singleWhere((block) => block.plainText == 'Before\n\nAfter'); + expect( + paragraph.inlines.where( + (inline) => inline.kind == BusyInlineKind.hardBreak, + ), + hasLength(2), + ); + }, + ); + test( 'native export composes the selected instance without a container runtime', () async { From 6dc7add19dad3d85f0a9a83d547947d696af5878 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 18:55:38 -0700 Subject: [PATCH 11/27] Improve markdown serializer with enhanced hard break handling - Added `readableHardBreakRuns` for better formatting of consecutive hard breaks. - Updated `_inlineMarkdown`, `_listItem`, and `_blockquote` methods to support the new behavior. - Normalized standalone `
    ` layout in raw HTML adaptation: - Introduced `_normalizeStandaloneBreakLayout` to handle soft line endings near `
    ` elements. - Adjusted blank line example and identifiers in syntax reference for consistency. --- docs/pdf-export.md | 3 +- lib/src/app/busymark_dialogs.dart | 4 +- .../busymark_markdown_serializer.dart | 49 ++++++++++++++++--- lib/src/markdown/raw_html_adapter.dart | 22 ++++++++- test/src/app_smoke_test.dart | 4 +- test/src/busymark_document_test.dart | 45 ++++++++++++++++- test/src/html_export_test.dart | 2 +- .../src/markdown_pdf_export_service_test.dart | 4 +- test/src/writerside_pdf_export_test.dart | 2 +- 9 files changed, 115 insertions(+), 20 deletions(-) diff --git a/docs/pdf-export.md b/docs/pdf-export.md index d4a591f0..c3d6f853 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -44,7 +44,8 @@ the previous PDF usable. Use **Insert blank line** in Editor view when a specific one-line gap is part of the document. BusyMark stores the gap as two explicit `
    ` elements, so it is visible in Editor and Reading views and preserved by Markdown PDF and HTML -exports. Source view shows the `

    ` representation. +exports. Source view places each generated `
    ` on a marker-only line instead +of appending the tags to document text. Additional empty source lines are still ordinary Markdown block separators; repeating them does not create additional vertical space. diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index d2d1412d..76ab095a 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -1601,8 +1601,8 @@ class _SyntaxReferenceDialogState extends State<_SyntaxReferenceDialog> { ), _SyntaxReferenceEntry( title: l10n.insertBlankLine, - example: 'First line

    \nSecond line', - identifiers: 'br + br', + example: 'First line\n
    \n
    \nSecond line', + identifiers: 'br', scope: l10n.markdown, documentationUri: _htmlDocumentationUri, ), diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 048945c5..9c978bb1 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -41,6 +41,7 @@ class BusyMarkMarkdownSerializer { BusyBlockKind.paragraph => _inlineMarkdown( block.inlines, atBlockStart: true, + readableHardBreakRuns: true, ), BusyBlockKind.math => _mathBlock(block), BusyBlockKind.codeBlock => _codeBlock(block), @@ -254,7 +255,11 @@ class BusyMarkMarkdownSerializer { } String _listItem(BusyBlock block, String marker, {String? contentPrefix}) { - final text = _inlineMarkdown(block.inlines, atBlockStart: true); + final text = _inlineMarkdown( + block.inlines, + atBlockStart: true, + readableHardBreakRuns: true, + ); final content = [ if (contentPrefix != null) contentPrefix, if (text.isNotEmpty) text, @@ -330,7 +335,7 @@ class BusyMarkMarkdownSerializer { String _blockquote(BusyBlock block) { final text = block.children.isEmpty - ? _inlineMarkdown(block.inlines) + ? _inlineMarkdown(block.inlines, readableHardBreakRuns: true) : block.children.map(serializeBlock).join('\n\n'); final quote = text .split('\n') @@ -448,6 +453,7 @@ class BusyMarkMarkdownSerializer { List inlines, { bool tableCell = false, bool atBlockStart = false, + bool readableHardBreakRuns = false, }) { final buffer = StringBuffer(); var nextAtBlockStart = atBlockStart; @@ -461,11 +467,32 @@ class BusyMarkMarkdownSerializer { } final count = runEnd - index; if (count > 1) { - // A whitespace-only line ends a CommonMark paragraph, so repeated - // two-space hard breaks do not round-trip. Raw inline
    elements - // are defined by CommonMark and preserve an intentional blank line. - buffer.write(List.filled(count, '
    ').join()); - nextAtBlockStart = false; + if (readableHardBreakRuns && !tableCell) { + final startsBlock = buffer.isEmpty; + if (!startsBlock && !nextAtBlockStart) { + buffer.write('\n'); + } + if (startsBlock) { + // Two tags on the marker-only first line keep CommonMark from + // treating a leading
    as a raw HTML block. + buffer.write('

    '); + for (var marker = 2; marker < count; marker++) { + buffer.write('\n
    '); + } + } else { + buffer.write(List.filled(count, '
    ').join('\n')); + } + final hasFollowingContent = runEnd < inlines.length; + if (hasFollowingContent) { + buffer.write('\n'); + } + nextAtBlockStart = hasFollowingContent; + } else { + // Physical newlines cannot safely occur in headings or table + // cells. Keep their repeated breaks in compact inline HTML. + buffer.write(List.filled(count, '
    ').join()); + nextAtBlockStart = false; + } index = runEnd - 1; continue; } @@ -474,6 +501,7 @@ class BusyMarkMarkdownSerializer { inline, tableCell: tableCell, atBlockStart: nextAtBlockStart, + readableHardBreakRuns: readableHardBreakRuns, followedByLink: index + 1 < inlines.length && inlines[index + 1].kind == BusyInlineKind.link, @@ -490,11 +518,16 @@ class BusyMarkMarkdownSerializer { BusyInline inline, { bool tableCell = false, bool atBlockStart = false, + bool readableHardBreakRuns = false, bool followedByLink = false, }) { final children = inline.children.isEmpty ? _escapeInlineText(inline.text, atBlockStart: atBlockStart) - : _inlineMarkdown(inline.children, tableCell: tableCell); + : _inlineMarkdown( + inline.children, + tableCell: tableCell, + readableHardBreakRuns: readableHardBreakRuns, + ); return switch (inline.kind) { BusyInlineKind.text => _escapeInlineText( inline.text, diff --git a/lib/src/markdown/raw_html_adapter.dart b/lib/src/markdown/raw_html_adapter.dart index 3949f0f1..236294be 100644 --- a/lib/src/markdown/raw_html_adapter.dart +++ b/lib/src/markdown/raw_html_adapter.dart @@ -43,7 +43,7 @@ class RawHtmlAdapter { if (!_mayContainHtml(text)) { return null; } - final fragment = _parseFragment(text); + final fragment = _parseFragment(_normalizeStandaloneBreakLayout(text)); if (fragment == null) { return null; } @@ -57,6 +57,26 @@ class RawHtmlAdapter { return _trimInlineEdges(_inlinesFromNodes(fragment.nodes)); } + String _normalizeStandaloneBreakLayout(String source) { + if (!source.contains('\n') && !source.contains('\r')) { + return source; + } + // A CommonMark soft line ending immediately beside an inline
    is + // layout whitespace, not another visible break. Remove only those source + // line endings before HTML whitespace collapsing so marker-only lines do + // not add spaces to the editable document model. Explicit indentation + // after a marker line remains intact. + return source + .replaceAll( + RegExp(r'\r?\n[ \t]*(?=)', caseSensitive: false), + '', + ) + .replaceAllMapped( + RegExp(r'()\r?\n', caseSensitive: false), + (match) => match.group(1)!, + ); + } + html.DocumentFragment? _parseFragment(String source) { try { return html_parser.parseFragment(source); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 213c693e..bfea3147 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -5228,7 +5228,7 @@ void main() { ); testWidgets( - 'explicit HTML breaks preserve one blank line in every document view', + 'standalone HTML breaks preserve one blank line in every document view', (tester) async { tester.view.physicalSize = const Size(1400, 800); tester.view.devicePixelRatio = 1; @@ -5237,7 +5237,7 @@ void main() { tester.view.resetDevicePixelRatio(); }); - const source = 'Before

    After\n'; + const source = 'Before\n
    \n
    \nAfter\n'; const renderedText = 'Before\n\nAfter'; final settingsStore = _MemorySettingsStore() ..value = AppSettings.defaults() diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 97baf1d4..0639fc72 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5537,7 +5537,7 @@ void main() {} await tester.tap(blankLineButton); await tester.pump(); - expect(markdown, 'Alpha

    Beta\n'); + expect(markdown, 'Alpha\n
    \n
    \n Beta\n'); expect(field.controller!.text, 'Alpha\n\n Beta'); expect(field.focusNode!.hasFocus, isTrue); expect( @@ -5686,7 +5686,7 @@ void main() {} expect(hardBreakController.markdown, 'Alpha \n Beta\n'); hardBreakController.insertHardBreak(hardBreakBlockId, 6); - expect(hardBreakController.markdown, 'Alpha

    Beta\n'); + expect(hardBreakController.markdown, 'Alpha\n
    \n
    \n Beta\n'); final blankLineRoundTrip = parser.parse( filePath: 'topic.md', source: hardBreakController.markdown, @@ -5698,8 +5698,49 @@ void main() {} ), hasLength(2), ); + expect( + blankLineRoundTrip.busyDocument.blocks.single.plainText, + 'Alpha\n\n Beta', + ); }); + test( + 'blank-line command keeps repeated terminal markers off the text line', + () { + final parsed = parser.parse( + filePath: 'topic.md', + source: 'Vancouver BC\n', + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final blockId = parsed.busyDocument.blocks.single.id; + + controller.insertBlankLine(blockId, 'Vancouver BC'.length); + expect(controller.markdown, 'Vancouver BC\n
    \n
    \n'); + + controller.insertBlankLine(blockId, 'Vancouver BC\n\n'.length); + expect(controller.markdown, 'Vancouver BC\n
    \n
    \n
    \n
    \n'); + expect(controller.markdown, isNot(contains('Vancouver BC
    '))); + + final roundTrip = parser.parse( + filePath: 'topic.md', + source: controller.markdown, + ); + expect(roundTrip.busyDocument.blocks, hasLength(1)); + expect( + roundTrip.busyDocument.blocks.single.inlines.where( + (inline) => inline.kind == BusyInlineKind.hardBreak, + ), + hasLength(4), + ); + expect( + roundTrip.busyDocument.blocks.single.plainText, + 'Vancouver BC\n\n\n\n', + ); + }, + ); + test('WYSIWYG table and code language commands serialize Markdown', () { final parsed = parser.parse(filePath: 'topic.md', source: 'Intro\n'); final controller = BusyMarkWysiwygDocumentController( diff --git a/test/src/html_export_test.dart b/test/src/html_export_test.dart index f9b10ebd..1584a0cf 100644 --- a/test/src/html_export_test.dart +++ b/test/src/html_export_test.dart @@ -156,7 +156,7 @@ Footnote[^note] and again[^note]. ); test('explicit blank lines export as consecutive HTML breaks', () async { - final result = await export('Before

    After\n'); + final result = await export('Before\n
    \n
    \nAfter\n'); final doc = html.parse(await File(result.entryPointPath).readAsString()); final paragraph = doc.querySelector('article p'); diff --git a/test/src/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index ad2776ec..d91e237e 100644 --- a/test/src/markdown_pdf_export_service_test.dart +++ b/test/src/markdown_pdf_export_service_test.dart @@ -126,7 +126,7 @@ void main() { await service.export( MarkdownPdfExportRequest( - source: 'Before

    After\n', + source: 'Before\n
    \n
    \nAfter\n', filePath: '/workspace/blank-line.md', workspaceRoot: '/workspace', destinationPath: p.join(temporaryDirectory.path, 'blank-line.pdf'), @@ -197,7 +197,7 @@ void main() { 'one-break', ); final twoBreaks = await textBaselineDelta( - 'Before

    After\n', + 'Before\n
    \n
    \nAfter\n', 'two-breaks', ); diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 24b5ab73..6aa9fd48 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -168,7 +168,7 @@ void main() { addTearDown(fixture.dispose); await File( p.join(fixture.module.path, 'topics', 'intro.md'), - ).writeAsString('# Introduction\n\nBefore

    After\n'); + ).writeAsString('# Introduction\n\nBefore\n
    \n
    \nAfter\n'); final exporter = _RecordingMarkdownExporter(); await WritersidePdfExportService(markdownExporter: exporter).export( From 8bd35a650b6d36e572012f8691f978858d04440a Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 10 Sep 2026 19:55:33 -0700 Subject: [PATCH 12/27] Add support for thematic break insertion in WYSIWYG editor - Introduced shortcut (Ctrl+Shift+-) and command for thematic break insertion. - Updated toolbar, shortcuts, and editor command handling to implement the feature. - Enhanced focus restoration logic after toolbar actions for better usability. --- lib/src/app/busymark_shortcuts.dart | 13 +++ lib/src/editor/wysiwyg/wysiwyg_editor.dart | 102 ++++++++++++++++--- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 1 + test/src/app_smoke_test.dart | 10 ++ test/src/busymark_document_test.dart | 103 ++++++++++++++++++++ test/src/source_audit_test.dart | 2 +- 6 files changed, 215 insertions(+), 16 deletions(-) diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index 94e814f4..d6334fb0 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -544,6 +544,7 @@ abstract final class BusyMarkEditorShortcuts { static const blockquoteLabel = 'Ctrl+Shift+Q'; static const codeBlockLabel = 'Ctrl+Shift+K'; static const imageLabel = 'Ctrl+Shift+I'; + static const thematicBreakLabel = 'Ctrl+Shift+-'; static const hardLineBreakLabel = 'Shift+Enter'; static const refineWithAi = BusyMarkShortcutDefinition( @@ -702,6 +703,14 @@ abstract final class BusyMarkEditorShortcuts { shift: true, ), ); + static const thematicBreak = BusyMarkShortcutDefinition( + label: thematicBreakLabel, + activator: SingleActivator( + LogicalKeyboardKey.minus, + control: true, + shift: true, + ), + ); static const hardLineBreak = BusyMarkShortcutDefinition( label: hardLineBreakLabel, activator: SingleActivator(LogicalKeyboardKey.enter, shift: true), @@ -731,6 +740,7 @@ abstract final class BusyMarkEditorShortcuts { BusyMarkEditorShortcutAction.blockquote: blockquote, BusyMarkEditorShortcutAction.codeBlock: codeBlock, BusyMarkEditorShortcutAction.image: image, + BusyMarkEditorShortcutAction.thematicBreak: thematicBreak, BusyMarkEditorShortcutAction.hardLineBreak: hardLineBreak, }; } @@ -762,6 +772,7 @@ abstract final class BusyMarkEditorShortcutLabels { static const blockquote = BusyMarkEditorShortcuts.blockquoteLabel; static const codeBlock = BusyMarkEditorShortcuts.codeBlockLabel; static const image = BusyMarkEditorShortcuts.imageLabel; + static const thematicBreak = BusyMarkEditorShortcuts.thematicBreakLabel; static const hardLineBreak = BusyMarkEditorShortcuts.hardLineBreakLabel; } @@ -833,6 +844,8 @@ abstract final class BusyMarkEditorShortcutActivators { static ShortcutActivator get codeBlock => BusyMarkEditorShortcuts.codeBlock.activator; static ShortcutActivator get image => BusyMarkEditorShortcuts.image.activator; + static ShortcutActivator get thematicBreak => + BusyMarkEditorShortcuts.thematicBreak.activator; static ShortcutActivator get hardLineBreak => BusyMarkEditorShortcuts.hardLineBreak.activator; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index a5dd0a5b..92c1b343 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -595,29 +595,44 @@ class _BusyMarkWysiwygEditorState extends State { widget.toolbarPlacement, widget.toolbarDirection, ), - onBlockCommand: _applyBlockCommand, + onBlockCommand: _applyToolbarBlockCommand, isBlockCommandEnabled: _canApplyBlockCommand, - onAdmonitionCommand: _applyAdmonitionCommand, + onAdmonitionCommand: (style) => _applyToolbarCommand( + () => _applyAdmonitionCommand(style), + ), admonitionCommandsEnabled: _canApplyAdmonitionCommand(), inlineCommandsEnabled: _hasInlineCommandTarget, lineBreakCommandsEnabled: _activeCellId == null, admonitionsEnabled: _documentController.document.mode == MarkdownMode.writersideMarkdown, - onInlineCommand: _applyInlineCommand, - onLinkCommand: () => unawaited(_applyLinkCommand()), - onInlineMathCommand: _applyInlineMathCommand, - onDisplayMathCommand: _applyDisplayMathCommand, - onImageCommand: () => unawaited(_applyImageCommand()), + onInlineCommand: (command) => _applyToolbarCommand( + () => _applyInlineCommand(command), + ), + onLinkCommand: () => + _applyAsyncToolbarCommand(_applyLinkCommand), + onInlineMathCommand: () => + _applyToolbarCommand(_applyInlineMathCommand), + onDisplayMathCommand: () => + _applyToolbarCommand(_applyDisplayMathCommand), + onImageCommand: () => + _applyAsyncToolbarCommand(_applyImageCommand), onInlineImageCommand: () => - unawaited(_applyInlineImageCommand()), - onTableCommand: () => unawaited(_applyTableCommand()), - onHtmlCommand: () => unawaited(_applyHtmlCommand()), - onIndentCommand: _applyIndentCommand, - onOutdentCommand: _applyOutdentCommand, - onToggleTaskCommand: _applyToggleTaskCommand, - onHardBreakCommand: _applyHardBreakCommand, - onBlankLineCommand: _applyBlankLineCommand, + _applyAsyncToolbarCommand(_applyInlineImageCommand), + onTableCommand: () => + _applyAsyncToolbarCommand(_applyTableCommand), + onHtmlCommand: () => + _applyAsyncToolbarCommand(_applyHtmlCommand), + onIndentCommand: () => + _applyToolbarCommand(_applyIndentCommand), + onOutdentCommand: () => + _applyToolbarCommand(_applyOutdentCommand), + onToggleTaskCommand: () => + _applyToolbarCommand(_applyToggleTaskCommand), + onHardBreakCommand: () => + _applyToolbarCommand(_applyHardBreakCommand), + onBlankLineCommand: () => + _applyToolbarCommand(_applyBlankLineCommand), ), ), ], @@ -3494,6 +3509,63 @@ class _BusyMarkWysiwygEditorState extends State { targets.every(busyMarkWysiwygCanApplyAdmonitionStyle); } + void _applyToolbarBlockCommand(BusyWysiwygBlockCommand command) { + final selectedBlocks = _selectedBlocks(); + final activeBlock = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + final commandTargets = selectedBlocks.isNotEmpty + ? selectedBlocks + : [if (activeBlock != null) activeBlock]; + final opensCodeLanguageDialog = + command == BusyWysiwygBlockCommand.codeBlock && + commandTargets.isNotEmpty && + commandTargets.every((block) => block.kind == BusyBlockKind.codeBlock); + if (opensCodeLanguageDialog) { + _applyAsyncToolbarCommand(_applyCodeLanguageCommand); + return; + } + _applyToolbarCommand(() => _applyBlockCommand(command)); + } + + void _applyToolbarCommand(VoidCallback command) { + command(); + _restoreEditingFocusAfterFrame(); + } + + void _applyAsyncToolbarCommand(Future Function() command) { + unawaited(command().whenComplete(_restoreEditingFocusAfterFrame)); + } + + void _restoreEditingFocusAfterFrame() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + if (_documentSelection != null) { + _selectionFocusNode.requestFocus(); + return; + } + final activeCellId = _activeCellId; + final cellFocusNode = activeCellId == null + ? null + : _tableCellFocusNodes[activeCellId]; + if (cellFocusNode != null) { + cellFocusNode.requestFocus(); + return; + } + final activeBlockId = _activeBlockId; + final blockFocusNode = activeBlockId == null + ? null + : _focusNodes[activeBlockId]; + if (blockFocusNode != null) { + blockFocusNode.requestFocus(); + return; + } + _focusActiveOrFirstBlock(); + }); + } + void _applyBlockCommand(BusyWysiwygBlockCommand command) { final selectedBlocks = _selectedBlocks(); final activeBlock = _activeBlockId == null diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index a87aa038..554b3d7c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -196,6 +196,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.thematicBreak, icon: BusyMarkGlyphs.thematicBreak, + shortcut: BusyMarkEditorShortcutLabels.thematicBreak, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.thematicBreak) ? () => onBlockCommand(BusyWysiwygBlockCommand.thematicBreak) diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index bfea3147..21cac935 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -97,6 +97,7 @@ void main() { BusyMarkEditorShortcutAction.blockquote: 'Ctrl+Shift+Q', BusyMarkEditorShortcutAction.codeBlock: 'Ctrl+Shift+K', BusyMarkEditorShortcutAction.image: 'Ctrl+Shift+I', + BusyMarkEditorShortcutAction.thematicBreak: 'Ctrl+Shift+-', BusyMarkEditorShortcutAction.hardLineBreak: 'Shift+Enter', }, ); @@ -4630,6 +4631,15 @@ void main() { await tester.enterText(sourceField, 'line'); await tester.pump(); + await pressShortcut(LogicalKeyboardKey.minus, control: true, shift: true); + expect( + container.read(workspaceControllerProvider).activeText, + 'line\n---\n', + ); + + await tester.enterText(sourceField, 'line'); + await tester.pump(); + await pressShortcut(LogicalKeyboardKey.enter, shift: true); expect(container.read(workspaceControllerProvider).activeText, 'line \n'); await tester.pump(const Duration(seconds: 2)); diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 0639fc72..d296328c 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -1593,6 +1593,49 @@ void main() {} expect(markdown, 'Title\n'); }); + testWidgets('Ctrl+Shift+- inserts a thematic break', (tester) async { + final parsed = parser.parse(filePath: 'topic.md', source: 'Intro\n'); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final field = tester.widget(find.byType(TextField).first); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.minus); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(markdown, 'Intro\n\n---\n'); + final emptyParagraph = tester + .widgetList(find.byType(TextField)) + .singleWhere( + (candidate) => candidate.controller?.text.isEmpty ?? false, + ); + expect(emptyParagraph.focusNode!.hasFocus, isTrue); + }); + testWidgets( 'WYSIWYG Tab nests eligible list items and Shift+Tab lifts them', (tester) async { @@ -4487,6 +4530,7 @@ void main() {} expect(destinationField, findsNothing); expect(markdown, '[Linked](https://example.com) word\n'); + expect(editorField.focusNode!.hasFocus, isTrue); }); for (final (selectionDescription, selection) in const [ @@ -5455,6 +5499,65 @@ void main() {} ); }); + testWidgets('formatting toolbar command retains editor focus and selection', ( + tester, + ) async { + final parsed = parser.parse(filePath: 'topic.md', source: 'Alpha Beta\n'); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + final fieldFinder = find.byType(TextField).first; + final field = tester.widget(fieldFinder); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 5, + ); + await tester.pump(); + final l10n = AppLocalizations.of(tester.element(fieldFinder)); + final boldButton = find.byWidgetPredicate( + (widget) => + widget is BusyMarkHeaderIconButton && widget.tooltip == l10n.bold, + ); + + expect(boldButton, findsOneWidget); + final buttonFocusNode = Focus.of( + tester.element( + find.descendant(of: boldButton, matching: find.byType(Icon)), + ), + ); + buttonFocusNode.requestFocus(); + await tester.pump(); + expect(field.focusNode!.hasFocus, isFalse); + + tester.widget(boldButton).onPressed!(); + await tester.pump(); + + expect(markdown, '**Alpha** Beta\n'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 5), + ); + }); + testWidgets('Shift+Enter retains focus and advances past the hard break', ( tester, ) async { diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 81c4b64f..ea9284ae 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1701,6 +1701,7 @@ void main() { 'BusyMarkEditorShortcutLabels.blockquote', 'BusyMarkEditorShortcutLabels.codeBlock', 'BusyMarkEditorShortcutLabels.image', + 'BusyMarkEditorShortcutLabels.thematicBreak', 'BusyMarkEditorShortcutLabels.hardLineBreak', ]) { expect(toolbar, contains(shortcut)); @@ -1711,7 +1712,6 @@ void main() { 'BusyMarkEditorShortcutLabels.inlineImage', 'BusyMarkEditorShortcutLabels.table', 'BusyMarkEditorShortcutLabels.htmlBlock', - 'BusyMarkEditorShortcutLabels.thematicBreak', ]) { expect(toolbar, isNot(contains(shortcut))); } From 89273b102d2b1dc4f9310cae1e021d225e42b5ae Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 14:33:33 -0700 Subject: [PATCH 13/27] Improve workspace refresh and Local History restore logic - Refactored `_workspaceRefreshRevision` handling to ensure consistent state management during workspace refresh operations. - Enhanced Local History restore process with better validation and rollback mechanisms for missing history destinations. - Removed outdated commands and control options related to clipboard and history actions in UI. - Added export and TOC options in sidebar and outline menus with feature-specific validations. --- docs/history-architecture.md | 59 +- docs/history.md | 45 +- docs/html-export.md | 5 +- docs/pdf-export.md | 10 +- docs/writerside-pdf-export.md | 6 +- lib/src/app/busymark_app.dart | 6 - lib/src/app/busymark_main_menu.dart | 52 +- .../clipboard_history_controller.dart | 4 +- lib/src/clipboard/clipboard_models.dart | 5 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 32 ++ .../local_history_controller.dart | 82 ++- .../local_history/local_history_panel.dart | 390 +++++++++---- .../platform/header_bar_configuration.dart | 32 -- .../platform/linux_header_bar_service.dart | 14 - .../presentation/settings_screen.dart | 11 - .../presentation/welcome_screen.dart | 11 - .../presentation/workspace_screen.dart | 218 ++++++-- lib/src/workspace/workspace_controller.dart | 525 ++++++++++++++---- linux/runner/my_application.cc | 74 --- test/src/app_smoke_test.dart | 22 +- test/src/clipboard_history_panel_test.dart | 131 ++++- test/src/clipboard_history_test.dart | 53 ++ test/src/header_bar_configuration_test.dart | 35 +- test/src/html_export_ui_test.dart | 121 ++-- test/src/linux_header_bar_service_test.dart | 20 +- test/src/local_history_controller_test.dart | 421 ++++++++++++++ test/src/local_history_workspace_test.dart | 204 +++++++ test/src/native_headerbar_audit_test.dart | 24 +- test/src/source_audit_test.dart | 8 +- test/src/workspace_controller_test.dart | 212 +++++++ test/src/wysiwyg_clipboard_test.dart | 198 ++++++- tools/history_visual_smoke.dart | 194 ++++++- 32 files changed, 2542 insertions(+), 682 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index 6a77bb1d..02294bd2 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -30,8 +30,13 @@ target lifetime around asynchronous work. Copy/cut capture happens only after successful publication. A cut publishes, then revalidates its selection before one deletion transaction. External -content is retained only after a successful document paste. History insertion -uses the normal Source or WYSIWYG transaction and asset-ingestion paths. +content is retained only after a successful document paste. The transient +current item carries supported external HTML without claiming BusyMark token +ownership and keeps its interoperable text separately. WYSIWYG history +insertion sends that HTML through `WysiwygClipboardHtml`, the same validated +conversion used by ordinary Paste; malformed or unsupported structure uses the +existing text fallback. History insertion uses the normal Source or WYSIWYG +transaction and asset-ingestion paths. ## Local History capture and identity @@ -72,6 +77,14 @@ to 30 days and 512 MiB. ## Comparison and restore safety +Ordinary Local History browsing is scoped to the active document buffer. A +scope generation invalidates outstanding revision reads and searches before a +new filename is displayed, so old results cannot land beneath a new header. +Store-wide document search is a temporary discovery mode for retained closed, +renamed, deleted, or untitled identities; selecting a normal document resumes +active-tab following. The revision list is timestamp-first and groups the same +localized timestamps by local calendar date. + `SourceComparisonInput` identifies immutable sources by ID, version, label, and text. The comparison uses unique patience anchors plus bounded local LCS and intraline refinement in Dart UTF-16 offsets. Separated edits are retained. @@ -80,19 +93,36 @@ simplified and exact region restore is disabled. Local History has its own `WorkspaceTabKind.localHistory`. The current side is the live unsaved buffer when present, then a fresh disk load, or an explicit -missing-file state. Async results remain tied to both source versions. Restore +missing-file state. An immutable comparison request owns the history document, +revision, current buffer, source revision, and source text; sidebar browsing is +not consulted later to redirect a pending comparison or restore. Restore captures the target identity/path/revision/selection and exact range, persists a protective snapshot, revalidates after awaits, and applies one `DocumentBuffer.edited` transaction without replacing format metadata or the undo stack. +Missing-file recovery keeps the source history identity separate from the +destination buffer. For an approved existing path, the normal guarded open flow +loads the destination's real source and format, protection must succeed, and +the retained source is one ordinary edit whose Undo baseline is the exact +destination content. No empty file is published. New-file recovery retains its +separate no-overwrite creation path. + +Workspace refresh records per-buffer request snapshots, then reconciles disk +loads against live membership, path, source revision/text, dirty and disk +state, and the loaded disk snapshot after every asynchronous parse boundary. +Buffers opened meanwhile survive, closed buffers are not resurrected, and +newer editor state and undo/redo history win over stale loads. Derived workspace +and preview content is published only when its active buffer and source still +match; otherwise the normal fresh-parse scheduler takes over. + ## Verification workflow Use the repository-pinned Flutter/Dart toolchain: ```bash flutter gen-l10n -dart format lib test/src +dart format flutter analyze flutter test flutter build linux --release @@ -122,20 +152,7 @@ fragment-restore, deletion, or recovery check fails. The current checkout is pinned to Flutter 3.47.2 / Dart 3.13.2. Available native validation is Linux-only because the repository contains no Windows -runner or Windows plugin registration path. - -Final verification for this implementation used the commands above plus the -cross-process writer/reader sequence on one X11 display. Localization -generation and formatting completed cleanly, `flutter analyze` reported no -issues, all 1,563 executed tests passed (with 12 existing intentional skips), -and the Linux release bundle built successfully. The compiled clipboard probe -reported: - -- same-process: text, HTML, and session-owned rich resolution all passed; -- cross-process: text and HTML passed, and the foreign token did not resolve; -- external plain replacement: text passed with no stale HTML or rich payload. - -The production-controller visual target completed both its Markdown light/en -scenario and its Writerside dark/ar scenario at 1280×800 and 1920×1080. Its -Markdown run also deleted, compared, and recovered a document. Reports and -captured frames are retained under `test-results/history-runtime/`. +runner or Windows plugin registration path. Verification artifacts are written +to the ignored `build/history-ui-evidence/` directory by the runtime +exercise; documentation does not treat an earlier committed test count or +screenshot as proof of the current checkout. diff --git a/docs/history.md b/docs/history.md index 6561e9ba..9571dcaa 100644 --- a/docs/history.md +++ b/docs/history.md @@ -7,8 +7,8 @@ compared and restored. ## Clipboard History -Open **Clipboard History** from the sidebar selector, main menu, command -palette, or a document editor's context menu. The panel stays open while you +Open **Clipboard History** from the sidebar selector, command palette, or a +document editor's context menu. The panel stays open while you move between documents and selections. Select an item to preview it, then use **Paste**, **Paste as Plain Text**, Enter, or double-click to insert it at the latest visible editable selection. Escape returns focus to the editor. @@ -20,10 +20,13 @@ and previewed without adding it to the retained list. Search examines the full text, not only the row excerpt. History can contain exact Source selections, BusyMark rich fragments, and -supported clipboard images. An image is retained as bytes; it is added to a -document's assets only when pasted. Rich fragments retain a source form for -cross-mode insertion. Unavailable local references are not silently resolved -against an unrelated destination. +supported clipboard images. Supported HTML copied from another application is +kept with its plain-text alternative before the first paste; normal WYSIWYG +insertion uses the same validated HTML conversion as ordinary Paste, while +**Paste as Plain Text** uses only the text alternative. An image is retained as +bytes; it is added to a document's assets only when pasted. Rich fragments +retain a source form for cross-mode insertion. Unavailable local references are +not silently resolved against an unrelated destination. Clipboard History is memory-only and is discarded when BusyMark exits. It keeps at most 100 entries and 64 MiB in total, evicting the oldest entries @@ -35,10 +38,18 @@ operating-system clipboard. Collection can be disabled under ## Local History Open **Local History** from the sidebar selector, command palette, document -tab or editor context menu, or a file-tree context menu. **Find in Local -History…** also finds closed, renamed, deleted, and untitled documents by -stored name, path, and revision content, even when their original workspace is -not open. +tab or editor context menu, or a file-tree context menu. In ordinary use its +plain filename header and revision list follow the active document tab; moving +focus to search, a revision, the comparison, or an action does not change that +scope. The header tooltip contains the full path when one exists. + +Use **Find in Local History…** from Local History's header action menu or the +command palette to find closed, renamed, deleted, and previously untitled +documents by stored name, path, and revision content, even when their original +workspace is not open. Selecting a result temporarily inspects that retained +document in the same sidebar. Its missing/deleted state is explicit, and the +local Back action returns to the active document. There is no store-wide +document selector in the ordinary history view. BusyMark records complete source text and its encoding/line-ending policy. A baseline is captured when an eligible existing document is first tracked. @@ -48,6 +59,12 @@ restore, delete, or external-change operations. The checkpoint deadline is not postponed by continued typing, and the ordinary 1.5-second autosave does not create a revision per write. Unchanged adjacent content is deduplicated. +Revisions are grouped by local calendar date and use the localized time, +including seconds, as the primary row label. Distinct events such as Saved, +Before restore, or External change appear as secondary metadata. Automatic and +baseline capture reasons remain available in the row's tooltip and accessible +description without becoming a repeated visible title. + Selecting a revision opens a read-only source comparison against the unsaved editor buffer when available, otherwise a fresh disk snapshot. Missing files are identified explicitly. You can copy selected source, restore the whole @@ -59,7 +76,13 @@ capture fails, the restore leaves the document unchanged. For a deleted file, use **Restore to Original Location** or **Restore to New Location…**. Existing destinations require the normal overwrite decision. -Cancelling or failing recovery does not remove the retained revision. +After approval, BusyMark opens and protects the destination's real current +content, then applies the retained source as one edit. With autosave disabled, +the existing disk file is untouched until Save, and Undo returns to the +destination's original editor content. Autosave may publish the recovered edit +through the ordinary save path, but recovery never publishes an intermediate +empty file. Cancelling or failing recovery changes neither destination nor the +retained revision. Local History defaults to 30-day retention and 512 MiB total storage. Either limit may remove older entries sooner. Under **Settings → History**, recording, diff --git a/docs/html-export.md b/docs/html-export.md index 5f809383..b2cf0fc4 100644 --- a/docs/html-export.md +++ b/docs/html-export.md @@ -1,7 +1,8 @@ # HTML export -Choose **Main menu → Export as HTML…** or use the command palette. HTML has no -default shortcut; **Ctrl+Shift+E** continues to export PDF. +For Markdown, choose **Outline → Actions → Export** or use the command palette. +For Writerside, choose **TOC → Actions → Export**. HTML has no default shortcut; +**Ctrl+Shift+E** continues to export PDF. Markdown exports the active document’s current editor text, including unsaved and untitled content. Writerside first offers Save, Discard, or Cancel for diff --git a/docs/pdf-export.md b/docs/pdf-export.md index c3d6f853..52b89c10 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -1,9 +1,11 @@ # PDF export -Choose **Main menu → Export as PDF…**, use the command palette, or press -**Ctrl+Shift+E**. Markdown exports the active editor text, including unsaved and -untitled documents. Writerside first offers Save, Discard, or Cancel for project -changes, then lets you select a non-library instance. +For Markdown, choose **Outline → Actions → Export**, use the command palette, +or press **Ctrl+Shift+E**. Markdown exports the active editor text, including +unsaved and untitled documents. For Writerside, choose **TOC → Actions → Export**; +BusyMark first offers Save, Discard, or Cancel for project changes, then lets +you select a non-library instance. The shortcut and command palette route use +the same document-versus-instance scope. The export settings are independent of the editor and preview appearance. Markdown and Writerside offer the same PDF controls: diff --git a/docs/writerside-pdf-export.md b/docs/writerside-pdf-export.md index 0382cddf..a6a7b8ef 100644 --- a/docs/writerside-pdf-export.md +++ b/docs/writerside-pdf-export.md @@ -6,9 +6,9 @@ are not required. ## Export -Open a Writerside module and select **Main menu → Export as PDF** or press -Ctrl+Shift+E. BusyMark first asks you to save or -discard unsaved project changes, then lets you select: +Open a Writerside module and select **TOC → Actions → Export**, use the command +palette, or press Ctrl+Shift+E. BusyMark first +asks you to save or discard unsaved project changes, then lets you select: - one non-library Writerside instance; - a generated table of contents with heading depth 1–6 and optional heading numbering; diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index b05ccd55..bc24bdf3 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -884,12 +884,6 @@ class BusyMarkApp extends ConsumerWidget { back: material.backButtonTooltip, backShortcut: shortcut(BusyMarkCommandIds.back), save: label(BusyMarkCommandIds.save), - clipboardHistory: label(BusyMarkCommandIds.clipboardHistory), - localHistory: label(BusyMarkCommandIds.localHistory), - findLocalHistory: label(BusyMarkCommandIds.findLocalHistory), - export: label(BusyMarkCommandIds.export), - exportShortcut: shortcut(BusyMarkCommandIds.export), - exportGtkAccelerator: accelerator(BusyMarkCommandIds.export), fullScreen: label(BusyMarkCommandIds.fullScreen), fullScreenShortcut: shortcut(BusyMarkCommandIds.fullScreen), fullScreenGtkAccelerator: accelerator(BusyMarkCommandIds.fullScreen), diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 3bac3e6b..b7190eea 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -10,11 +10,6 @@ import 'localization.dart'; import 'window_control_service.dart'; enum BusyMarkMainMenuAction { - export, - generateMarkdownToc, - clipboardHistory, - localHistory, - findLocalHistory, fullScreen, settings, keyboardShortcuts, @@ -25,18 +20,9 @@ enum BusyMarkMainMenuAction { } class BusyMarkMainMenuButton extends ConsumerWidget { - const BusyMarkMainMenuButton({ - super.key, - required this.onSelected, - this.canExportPdf = false, - this.canExportHtml = false, - this.canGenerateMarkdownToc = false, - }); + const BusyMarkMainMenuButton({super.key, required this.onSelected}); final ValueChanged onSelected; - final bool canExportPdf; - final bool canExportHtml; - final bool canGenerateMarkdownToc; @override Widget build(BuildContext context, WidgetRef ref) { @@ -52,19 +38,6 @@ class BusyMarkMainMenuButton extends ConsumerWidget { tooltip: l10n.mainMenu, icon: BusyMarkGlyphs.menuVertical, itemBuilder: (context) => [ - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.export, - label: command(BusyMarkCommandIds.export).label(context), - icon: BusyMarkGlyphs.exportPdf, - shortcut: command(BusyMarkCommandIds.export).shortcut?.label, - enabled: canExportPdf || canExportHtml, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.generateMarkdownToc, - label: l10n.generateOrUpdateMarkdownToc, - icon: BusyMarkGlyphs.orderedList, - enabled: canGenerateMarkdownToc, - ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.fullScreen, label: command(BusyMarkCommandIds.fullScreen).label(context), @@ -74,21 +47,6 @@ class BusyMarkMainMenuButton extends ConsumerWidget { trailingCheck: true, mutuallyExclusive: false, ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.clipboardHistory, - label: command(BusyMarkCommandIds.clipboardHistory).label(context), - icon: BusyMarkGlyphs.copy, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.localHistory, - label: command(BusyMarkCommandIds.localHistory).label(context), - icon: BusyMarkGlyphs.documentHistory, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.findLocalHistory, - label: command(BusyMarkCommandIds.findLocalHistory).label(context), - icon: BusyMarkGlyphs.search, - ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.settings, label: command(BusyMarkCommandIds.settings).label(context), @@ -128,14 +86,7 @@ class BusyMarkMainMenuButton extends ConsumerWidget { ], onSelected: (action) { final commandId = switch (action) { - BusyMarkMainMenuAction.export => BusyMarkCommandIds.export, BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen, - BusyMarkMainMenuAction.clipboardHistory => - BusyMarkCommandIds.clipboardHistory, - BusyMarkMainMenuAction.localHistory => - BusyMarkCommandIds.localHistory, - BusyMarkMainMenuAction.findLocalHistory => - BusyMarkCommandIds.findLocalHistory, BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, BusyMarkMainMenuAction.keyboardShortcuts => BusyMarkCommandIds.keyboardShortcuts, @@ -143,7 +94,6 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkCommandIds.commandPalette, BusyMarkMainMenuAction.syntaxReference => BusyMarkCommandIds.syntaxReference, - BusyMarkMainMenuAction.generateMarkdownToc || BusyMarkMainMenuAction.reportIssue || BusyMarkMainMenuAction.aboutBusyMark => null, }; diff --git a/lib/src/clipboard/clipboard_history_controller.dart b/lib/src/clipboard/clipboard_history_controller.dart index f454cb8d..e9fe9cce 100644 --- a/lib/src/clipboard/clipboard_history_controller.dart +++ b/lib/src/clipboard/clipboard_history_controller.dart @@ -168,7 +168,9 @@ class ClipboardHistoryController extends Notifier { if (generation != _refreshGeneration || !ref.mounted) return; BusyMarkClipboardCapture? capture; - if (first.richFragment != null || first.sourceText != null) { + if (first.richFragment != null || + first.sourceText != null || + first.html != null) { capture = BusyMarkClipboardCapture( kind: BusyMarkClipboardContentKind.richText, text: first.text, diff --git a/lib/src/clipboard/clipboard_models.dart b/lib/src/clipboard/clipboard_models.dart index a9cb66b5..df9ff64b 100644 --- a/lib/src/clipboard/clipboard_models.dart +++ b/lib/src/clipboard/clipboard_models.dart @@ -234,7 +234,10 @@ class BusyMarkClipboardCapture { BusyMarkClipboardContentKind.text => text != null || sourceText != null, BusyMarkClipboardContentKind.richText => mediaComplete && - (richFragment != null || sourceText != null || text != null), + (richFragment != null || + html != null || + sourceText != null || + text != null), BusyMarkClipboardContentKind.image => imageBytes != null && imageBytes!.isNotEmpty, }; diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 92c1b343..fa73fcca 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -6391,6 +6391,26 @@ class _BusyMarkWysiwygEditorState extends State { : ClipboardPasteResult.unsupported; } } + if (!plainText && payload.html != null) { + final fragment = const WysiwygClipboardHtml().decode( + payload.html!, + mode: _documentController.document.mode, + ); + if (fragment != null && _hasUsableClipboardHtmlContent(fragment)) { + if (!_isClipboardTargetCurrent(captured)) { + return ClipboardPasteResult.staleTarget; + } + final rebased = fragment.rebase(_documentController.document.filePath); + final inserted = _pasteStyledClipboardIntoActiveBlock( + rebased.blocks, + payload.text ?? + rebased.documentBlocks.map(_copyTextForBlock).join('\n\n'), + ); + return inserted + ? ClipboardPasteResult.inserted + : ClipboardPasteResult.unsupported; + } + } final text = payload.text ?? payload.sourceText; if (text == null) return ClipboardPasteResult.unsupported; final inserted = await _pastePlainTextIntoActiveBlock(textOverride: text); @@ -6399,6 +6419,17 @@ class _BusyMarkWysiwygEditorState extends State { : ClipboardPasteResult.staleTarget; } + bool _hasUsableClipboardHtmlContent(WysiwygClipboardFragment fragment) { + return fragment.documentBlocks.any( + (block) => + block.plainText.trim().isNotEmpty || + block.kind == BusyBlockKind.image || + block.kind == BusyBlockKind.video || + block.kind == BusyBlockKind.thematicBreak || + block.kind == BusyBlockKind.table, + ); + } + Future<({WysiwygClipboardFragment fragment, List assets})?> _prepareRetainedClipboardMedia( WysiwygClipboardFragment fragment, @@ -6966,6 +6997,7 @@ class _WysiwygClipboardInsertionTarget BusyMarkClipboardContentKind.richText => payload.mediaComplete && (payload.richFragment != null || + payload.html != null || payload.text != null || payload.sourceText != null), BusyMarkClipboardContentKind.text => diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index bd8ee980..906fa12c 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -75,6 +75,7 @@ class LocalHistoryState { this.searchMatches = const {}, this.documentSearchMatches = const {}, this.findingDocuments = false, + this.inspectingRetainedDocument = false, this.warning, }); @@ -88,6 +89,7 @@ class LocalHistoryState { final Set searchMatches; final Set documentSearchMatches; final bool findingDocuments; + final bool inspectingRetainedDocument; final LocalHistoryWarning? warning; LocalHistoryDocument? get selectedDocument => snapshot.documents @@ -113,6 +115,7 @@ class LocalHistoryState { Set? searchMatches, Set? documentSearchMatches, bool? findingDocuments, + bool? inspectingRetainedDocument, Object? warning = _unset, }) => LocalHistoryState( snapshot: snapshot ?? this.snapshot, @@ -131,6 +134,8 @@ class LocalHistoryState { searchMatches: searchMatches ?? this.searchMatches, documentSearchMatches: documentSearchMatches ?? this.documentSearchMatches, findingDocuments: findingDocuments ?? this.findingDocuments, + inspectingRetainedDocument: + inspectingRetainedDocument ?? this.inspectingRetainedDocument, warning: identical(warning, _unset) ? this.warning : warning as LocalHistoryWarning?, @@ -268,6 +273,7 @@ class LocalHistoryController extends Notifier { {}; var _loadGeneration = 0; var _searchGeneration = 0; + var _scopeGeneration = 0; @override LocalHistoryState build() { @@ -752,11 +758,27 @@ class LocalHistoryController extends Notifier { } Future selectDocumentForBuffer(DocumentBuffer buffer) async { + final generation = ++_scopeGeneration; + _loadGeneration++; + _searchGeneration++; + state = state.copyWith( + loading: true, + selectedDocumentId: null, + selectedRevisionId: null, + selectedRevision: null, + searchQuery: '', + searching: false, + searchMatches: const {}, + documentSearchMatches: const {}, + findingDocuments: false, + inspectingRetainedDocument: false, + ); await observeOpened(buffer); + if (!ref.mounted || generation != _scopeGeneration) return; final documentId = _documentIdsByBuffer[buffer.id]; - if (!ref.mounted) return; if (documentId == null) { await refresh(); + if (!ref.mounted || generation != _scopeGeneration) return; final byPath = state.snapshot.documents .where( (document) => @@ -766,31 +788,74 @@ class LocalHistoryController extends Notifier { ) .firstOrNull; if (byPath != null) { - selectDocument(byPath.id); + _selectDocument(byPath.id, inspectingRetainedDocument: false); } else { - state = state.copyWith(findingDocuments: false); + state = state.copyWith( + loading: false, + findingDocuments: false, + inspectingRetainedDocument: false, + ); } } else { - selectDocument(documentId); + if (!state.snapshot.documents.any( + (document) => document.id == documentId, + )) { + await refresh(); + if (!ref.mounted || generation != _scopeGeneration) return; + } + _selectDocument(documentId, inspectingRetainedDocument: false); } } void selectDocument(String documentId) { + _selectDocument(documentId, inspectingRetainedDocument: false); + } + + void clearDocumentScope() { + _scopeGeneration++; + _loadGeneration++; + _searchGeneration++; + state = state.copyWith( + loading: false, + selectedDocumentId: null, + selectedRevisionId: null, + selectedRevision: null, + searchQuery: '', + searching: false, + searchMatches: const {}, + documentSearchMatches: const {}, + findingDocuments: false, + inspectingRetainedDocument: false, + ); + } + + void inspectRetainedDocument(String documentId) { + _scopeGeneration++; + _selectDocument(documentId, inspectingRetainedDocument: true); + } + + void _selectDocument( + String documentId, { + required bool inspectingRetainedDocument, + }) { if (!state.snapshot.documents.any( (document) => document.id == documentId, )) { return; } _loadGeneration++; + _searchGeneration++; state = state.copyWith( loading: false, selectedDocumentId: documentId, selectedRevisionId: null, selectedRevision: null, searchQuery: '', + searching: false, searchMatches: const {}, documentSearchMatches: const {}, findingDocuments: false, + inspectingRetainedDocument: inspectingRetainedDocument, ); } @@ -798,6 +863,7 @@ class LocalHistoryController extends Notifier { /// No current editor is implied: closed, renamed, untitled, and deleted /// documents remain first-class results. void beginDocumentSearch() { + _scopeGeneration++; _loadGeneration++; _searchGeneration++; state = state.copyWith( @@ -810,6 +876,7 @@ class LocalHistoryController extends Notifier { searchMatches: const {}, documentSearchMatches: const {}, findingDocuments: true, + inspectingRetainedDocument: false, ); } @@ -886,6 +953,9 @@ class LocalHistoryController extends Notifier { .map((entry) => entry.key) .firstOrNull; + String? documentIdForBuffer(String bufferId) => + _documentIdsByBuffer[bufferId]; + Future search(String query) async { final normalized = query; final generation = ++_searchGeneration; @@ -897,8 +967,10 @@ class LocalHistoryController extends Notifier { ); if (normalized.isEmpty) return; final documentId = state.selectedDocumentId; - final summaries = documentId == null + final summaries = state.findingDocuments ? state.snapshot.revisions + : documentId == null + ? const [] : state.snapshot.revisionsFor(documentId); final matches = {}; final documentMatches = {}; diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index d9f1679c..5615ed3a 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:path/path.dart' as p; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; @@ -37,13 +38,20 @@ class _LocalHistoryPanelState extends ConsumerState { super.dispose(); } - Future _selectActive() async { - if (ref.read(localHistoryControllerProvider).findingDocuments) return; + Future _selectActive({bool preserveLookup = true}) async { + final history = ref.read(localHistoryControllerProvider); + if (preserveLookup && + (history.findingDocuments || history.inspectingRetainedDocument)) { + return; + } final buffer = ref.read(workspaceControllerProvider).activeBuffer; if (buffer != null) { await ref .read(localHistoryControllerProvider.notifier) .selectDocumentForBuffer(buffer); + } else if (!preserveLookup || + !ref.read(localHistoryControllerProvider).inspectingRetainedDocument) { + ref.read(localHistoryControllerProvider.notifier).clearDocumentScope(); } } @@ -51,6 +59,43 @@ class _LocalHistoryPanelState extends ConsumerState { Widget build(BuildContext context) { final state = ref.watch(localHistoryControllerProvider); final controller = ref.read(localHistoryControllerProvider.notifier); + final workspaceState = ref.watch(workspaceControllerProvider); + final activeBuffer = workspaceState.activeBuffer; + ref.listen<_LocalHistoryEditorScope>( + workspaceControllerProvider.select( + (value) => ( + workspaceId: value.workspace?.id, + bufferId: value.activeBuffer?.id, + path: value.activeBuffer?.filePath, + displayName: value.activeBuffer?.displayName, + ), + ), + (previous, next) { + if (previous == next) return; + if (next.bufferId == null) { + final history = ref.read(localHistoryControllerProvider); + if (!history.findingDocuments && + !history.inspectingRetainedDocument) { + controller.clearDocumentScope(); + } + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) { + unawaited(controller.selectDocumentForBuffer(buffer)); + } + }, + ); + ref.listen( + localHistoryControllerProvider.select((value) => value.searchQuery), + (previous, next) { + if (_searchController.text == next) return; + _searchController.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + }, + ); final documents = [...state.snapshot.documents] ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); final visibleDocuments = @@ -62,68 +107,52 @@ class _LocalHistoryPanelState extends ConsumerState { .toList(growable: false) : documents; final selected = state.selectedDocument; - final visibleRevisions = state.selectedRevisions - .where(state.revisionVisible) - .toList(growable: false); + final activeHistoryDocumentId = activeBuffer == null + ? null + : controller.documentIdForBuffer(activeBuffer.id); + final selectedMatchesActive = + selected != null && + activeBuffer != null && + (selected.id == activeHistoryDocumentId || + (selected.currentPath != null && + activeBuffer.filePath != null && + p.equals(selected.currentPath!, activeBuffer.filePath!))); + final selectedMatchesVisibleScope = + state.inspectingRetainedDocument || selectedMatchesActive; + final visibleRevisions = selectedMatchesVisibleScope + ? state.selectedRevisions + .where(state.revisionVisible) + .toList(growable: false) + : const []; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMarkSpacing.md, - BusyMarkSpacing.sm, - BusyMarkSpacing.md, - BusyMarkSpacing.xs, - ), - child: Row( - children: [ - Expanded( - child: PopupMenuButton( - tooltip: context.l10n.localHistory, - enabled: documents.isNotEmpty, - onSelected: controller.selectDocument, - itemBuilder: (context) => [ - for (final document in documents) - PopupMenuItem( - value: document.id, - child: _DocumentLabel(document: document), - ), - ], - child: InputDecorator( - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - ), - child: Row( - children: [ - const Icon(BusyMarkGlyphs.documentHistory, size: 18), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded( - child: selected == null - ? Text(context.l10n.localHistoryNoDocuments) - : _DocumentLabel(document: selected), - ), - const Icon(BusyMarkGlyphs.downArrow, size: 16), - ], - ), - ), - ), - ), - IconButton( - tooltip: MaterialLocalizations.of( - context, - ).refreshIndicatorSemanticLabel, - onPressed: state.loading ? null : controller.refresh, - icon: state.loading - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(BusyMarkGlyphs.refresh), - ), - ], - ), + _LocalHistoryHeader( + activeDocumentName: activeBuffer?.displayName, + activeDocumentPath: activeBuffer?.filePath, + selectedDocument: selected, + findingDocuments: state.findingDocuments, + inspectingRetainedDocument: state.inspectingRetainedDocument, + loading: state.loading, + canClearDocument: + selectedMatchesVisibleScope && state.selectedRevisions.isNotEmpty, + canClearAll: state.snapshot.revisions.isNotEmpty, + onBack: () => unawaited(_selectActive(preserveLookup: false)), + onRefresh: controller.refresh, + onAction: (action) { + switch (action) { + case _HistoryAction.find: + controller.beginDocumentSearch(); + case _HistoryAction.clearDocument: + final documentId = selected?.id; + if (documentId != null) { + unawaited(_confirmClear(action, documentId)); + } + case _HistoryAction.clearAll: + unawaited(_confirmClear(action, null)); + } + }, ), Padding( padding: const EdgeInsets.symmetric( @@ -163,6 +192,9 @@ class _LocalHistoryPanelState extends ConsumerState { : context.l10n.localHistoryNoMatches, ) : ListView.builder( + key: const ValueKey( + 'local-history-document-search-results', + ), itemCount: visibleDocuments.length, itemBuilder: (context, index) { final document = visibleDocuments[index]; @@ -188,13 +220,18 @@ class _LocalHistoryPanelState extends ConsumerState { trailing: document.deleted ? Text(context.l10n.localHistoryDeleted) : null, - onTap: () => - controller.selectDocument(document.id), + onTap: () => controller.inspectRetainedDocument( + document.id, + ), ), ); }, ) - : selected == null + : state.loading && + state.selectedDocumentId == null && + !state.inspectingRetainedDocument + ? const Center(child: CircularProgressIndicator()) + : activeBuffer == null && !state.inspectingRetainedDocument ? _HistoryEmpty(text: context.l10n.localHistoryNoDocuments) : visibleRevisions.isEmpty ? _HistoryEmpty( @@ -203,6 +240,9 @@ class _LocalHistoryPanelState extends ConsumerState { : context.l10n.localHistoryNoMatches, ) : ListView.builder( + key: ValueKey( + 'local-history-revisions-${selected?.id ?? 'none'}', + ), itemCount: visibleRevisions.length, itemBuilder: (context, index) { final revision = visibleRevisions[index]; @@ -213,9 +253,15 @@ class _LocalHistoryPanelState extends ConsumerState { previous == null || !_sameDate(previous.capturedAt, revision.capturedAt); final reason = _reason(context, revision.reason); - final timestamp = DateFormat.yMd( - Localizations.localeOf(context).toLanguageTag(), - ).add_Hms().format(revision.capturedAt.toLocal()); + final locale = Localizations.localeOf( + context, + ).toLanguageTag(); + final localTime = revision.capturedAt.toLocal(); + final timestamp = DateFormat.Hms(locale).format(localTime); + final fullTimestamp = DateFormat.yMd( + locale, + ).add_Hms().format(localTime); + final event = _visibleReason(context, revision.reason); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -239,16 +285,23 @@ class _LocalHistoryPanelState extends ConsumerState { button: true, label: context.l10n.localHistoryRevisionAt( reason, - timestamp, + fullTimestamp, ), excludeSemantics: true, - child: ListTile( - selected: revision.id == state.selectedRevisionId, - leading: const Icon(BusyMarkGlyphs.history), - title: Text(reason), - subtitle: Text(timestamp), - onTap: () => unawaited( - controller.selectRevision(revision.id), + child: Tooltip( + message: context.l10n.localHistoryRevisionAt( + reason, + fullTimestamp, + ), + child: ListTile( + dense: event == null, + selected: revision.id == state.selectedRevisionId, + leading: const Icon(BusyMarkGlyphs.history), + title: Text(timestamp), + subtitle: event == null ? null : Text(event), + onTap: () => unawaited( + controller.selectRevision(revision.id), + ), ), ), ), @@ -257,47 +310,26 @@ class _LocalHistoryPanelState extends ConsumerState { }, ), ), - if (selected != null) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: PopupMenuButton<_ClearAction>( - tooltip: context.l10n.actions, - onSelected: (action) => _confirmClear(action, selected.id), - itemBuilder: (context) => [ - PopupMenuItem( - value: _ClearAction.document, - child: Text(context.l10n.localHistoryClearDocument), - ), - PopupMenuItem( - value: _ClearAction.all, - child: Text(context.l10n.localHistoryClearAll), - ), - ], - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Icon(BusyMarkGlyphs.menuHorizontal), - const SizedBox(width: BusyMarkSpacing.xs), - Text(context.l10n.actions), - ], - ), - ), - ), ], ); } - Future _confirmClear(_ClearAction action, String documentId) async { + Future _confirmClear(_HistoryAction action, String? documentId) async { final confirmed = await showBusyMarkModalDialog( context, barrierDismissible: false, builder: (dialogContext) => AlertDialog( title: Text( - action == _ClearAction.document + action == _HistoryAction.clearDocument ? dialogContext.l10n.localHistoryClearDocumentTitle : dialogContext.l10n.localHistoryClearAllTitle, ), - content: Text(dialogContext.l10n.localHistoryClearConfirmation), + content: Text( + action == _HistoryAction.clearAll + ? '${dialogContext.l10n.localHistoryClearAll}.\n\n' + '${dialogContext.l10n.localHistoryClearConfirmation}' + : dialogContext.l10n.localHistoryClearConfirmation, + ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), @@ -312,7 +344,7 @@ class _LocalHistoryPanelState extends ConsumerState { ); if (confirmed != true || !mounted) return; final controller = ref.read(localHistoryControllerProvider.notifier); - if (action == _ClearAction.document) { + if (action == _HistoryAction.clearDocument && documentId != null) { await controller.clearDocument(documentId); } else { await controller.clearAll(); @@ -320,35 +352,139 @@ class _LocalHistoryPanelState extends ConsumerState { } } -enum _ClearAction { document, all } +typedef _LocalHistoryEditorScope = ({ + String? workspaceId, + String? bufferId, + String? path, + String? displayName, +}); -class _DocumentLabel extends StatelessWidget { - const _DocumentLabel({required this.document}); +enum _HistoryAction { find, clearDocument, clearAll } - final LocalHistoryDocument document; +class _LocalHistoryHeader extends StatelessWidget { + const _LocalHistoryHeader({ + required this.activeDocumentName, + required this.activeDocumentPath, + required this.selectedDocument, + required this.findingDocuments, + required this.inspectingRetainedDocument, + required this.loading, + required this.canClearDocument, + required this.canClearAll, + required this.onBack, + required this.onRefresh, + required this.onAction, + }); + + final String? activeDocumentName; + final String? activeDocumentPath; + final LocalHistoryDocument? selectedDocument; + final bool findingDocuments; + final bool inspectingRetainedDocument; + final bool loading; + final bool canClearDocument; + final bool canClearAll; + final VoidCallback onBack; + final Future Function() onRefresh; + final ValueChanged<_HistoryAction> onAction; @override Widget build(BuildContext context) { - return Tooltip( - message: - document.currentPath ?? document.historicalPaths.lastOrNull ?? '', + final temporary = findingDocuments || inspectingRetainedDocument; + final selectedPath = + selectedDocument?.currentPath ?? + selectedDocument?.historicalPaths.lastOrNull; + final title = findingDocuments + ? context.l10n.findLocalHistoryEllipsis + : inspectingRetainedDocument + ? selectedDocument?.displayName ?? context.l10n.localHistoryNoDocuments + : activeDocumentName ?? context.l10n.localHistoryNoDocuments; + final path = inspectingRetainedDocument ? selectedPath : activeDocumentPath; + return Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.xs, + ), child: Row( - mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Text( - document.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (temporary) ...[ + BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + transparent: true, + onPressed: onBack, ), - ), - if (document.deleted) ...[ const SizedBox(width: BusyMarkSpacing.xs), - Text( - context.l10n.localHistoryDeleted, - style: Theme.of(context).textTheme.bodySmall, - ), + ] else ...[ + const Icon(BusyMarkGlyphs.documentHistory, size: 18), + const SizedBox(width: BusyMarkSpacing.sm), ], + Expanded( + child: Tooltip( + message: path == null + ? title + : busyMarkLtrIsolateFor(context, path), + child: Row( + children: [ + Expanded( + child: Text( + busyMarkLtrIsolateFor(context, title), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (inspectingRetainedDocument && + selectedDocument?.deleted == true) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + Text( + context.l10n.localHistoryDeleted, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ), + BusyMarkHeaderIconButton( + tooltip: MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel, + icon: BusyMarkGlyphs.refresh, + transparent: true, + onPressed: loading ? null : () => unawaited(onRefresh()), + ), + BusyMarkHeaderPopupMenuButton<_HistoryAction>( + key: const ValueKey('local-history-actions-menu'), + tooltip: context.l10n.actions, + icon: BusyMarkGlyphs.menuVertical, + transparent: true, + borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, + itemBuilder: (context) => [ + BusyMarkPopupMenuItem( + value: _HistoryAction.find, + label: context.l10n.findLocalHistoryEllipsis, + icon: BusyMarkGlyphs.search, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _HistoryAction.clearDocument, + label: context.l10n.localHistoryClearDocument, + icon: BusyMarkGlyphs.delete, + enabled: canClearDocument, + ), + BusyMarkPopupMenuItem( + value: _HistoryAction.clearAll, + label: context.l10n.localHistoryClearAll, + icon: BusyMarkGlyphs.clearAll, + enabled: canClearAll, + ), + ], + onSelected: onAction, + ), ], ), ); @@ -389,6 +525,14 @@ String _reason(BuildContext context, LocalHistoryCaptureReason reason) { }; } +String? _visibleReason(BuildContext context, LocalHistoryCaptureReason reason) { + return switch (reason) { + LocalHistoryCaptureReason.baseline || + LocalHistoryCaptureReason.automaticCheckpoint => null, + _ => _reason(context, reason), + }; +} + bool _sameDate(DateTime left, DateTime right) { final localLeft = left.toLocal(); final localRight = right.toLocal(); diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 91980912..db0333fd 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -32,12 +32,6 @@ class HeaderBarLabels { required this.back, required this.backShortcut, required this.save, - required this.clipboardHistory, - required this.localHistory, - required this.findLocalHistory, - required this.export, - required this.exportShortcut, - required this.exportGtkAccelerator, required this.fullScreen, required this.fullScreenShortcut, required this.fullScreenGtkAccelerator, @@ -76,12 +70,6 @@ class HeaderBarLabels { final String back; final String backShortcut; final String save; - final String clipboardHistory; - final String localHistory; - final String findLocalHistory; - final String export; - final String exportShortcut; - final String exportGtkAccelerator; final String fullScreen; final String fullScreenShortcut; final String fullScreenGtkAccelerator; @@ -120,12 +108,6 @@ class HeaderBarLabels { 'back': back, 'backShortcut': backShortcut, 'save': save, - 'clipboardHistory': clipboardHistory, - 'localHistory': localHistory, - 'findLocalHistory': findLocalHistory, - 'export': export, - 'exportShortcut': exportShortcut, - 'exportGtkAccelerator': exportGtkAccelerator, 'fullScreen': fullScreen, 'fullScreenShortcut': fullScreenShortcut, 'fullScreenGtkAccelerator': fullScreenGtkAccelerator, @@ -303,8 +285,6 @@ class HeaderBarConfiguration { required this.searchQuery, required this.textDirection, required this.canRefresh, - this.canExportPdf = false, - this.canExportHtml = false, required this.documentControlsVisible, required this.searchActive, required this.searchVisible, @@ -325,8 +305,6 @@ class HeaderBarConfiguration { final String searchQuery; final TextDirection textDirection; final bool canRefresh; - final bool canExportPdf; - final bool canExportHtml; final bool documentControlsVisible; final bool searchActive; final bool searchVisible; @@ -348,8 +326,6 @@ class HeaderBarConfiguration { String? searchQuery, TextDirection? textDirection, bool? canRefresh, - bool? canExportPdf, - bool? canExportHtml, bool? documentControlsVisible, bool? searchActive, bool? searchVisible, @@ -369,8 +345,6 @@ class HeaderBarConfiguration { searchQuery: searchQuery ?? this.searchQuery, textDirection: textDirection ?? this.textDirection, canRefresh: canRefresh ?? this.canRefresh, - canExportPdf: canExportPdf ?? this.canExportPdf, - canExportHtml: canExportHtml ?? this.canExportHtml, documentControlsVisible: documentControlsVisible ?? this.documentControlsVisible, searchActive: searchActive ?? this.searchActive, @@ -393,8 +367,6 @@ class HeaderBarConfiguration { 'searchQuery': searchQuery, 'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr', 'canRefresh': canRefresh, - 'canExportPdf': canExportPdf, - 'canExportHtml': canExportHtml, 'documentControlsVisible': documentControlsVisible, 'searchActive': searchActive, 'searchVisible': searchVisible, @@ -415,8 +387,6 @@ class HeaderBarConfiguration { searchQuery == other.searchQuery && textDirection == other.textDirection && canRefresh == other.canRefresh && - canExportPdf == other.canExportPdf && - canExportHtml == other.canExportHtml && documentControlsVisible == other.documentControlsVisible && searchActive == other.searchActive && searchVisible == other.searchVisible && @@ -446,8 +416,6 @@ class HeaderBarConfiguration { searchQuery, textDirection, canRefresh, - canExportPdf, - canExportHtml, documentControlsVisible, searchActive, searchVisible, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 9642cd64..ae687640 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -16,10 +16,6 @@ enum HeaderBarAction { search, refresh, save, - clipboardHistory, - localHistory, - findLocalHistory, - export, fullScreen, menu, settings, @@ -158,10 +154,6 @@ class LinuxHeaderBarService extends ChangeNotifier { return _invokeLegacy('setCanRefresh', value); } - Future setCanExportPdf(bool value) { - return _invokeLegacy('setCanExportPdf', value); - } - Future setDocumentControlsVisible(bool value) { return _invokeLegacy('setDocumentControlsVisible', value); } @@ -293,8 +285,6 @@ class LinuxHeaderBarService extends ChangeNotifier { ('setTitleRange', configuration.title), ('setViewMode', configuration.viewMode.name), ('setCanRefresh', configuration.canRefresh), - ('setCanExportPdf', configuration.canExportPdf), - ('setCanExportHtml', configuration.canExportHtml), ('setDocumentControlsVisible', configuration.documentControlsVisible), ('setSearchVisible', configuration.searchVisible), ('setSidebarVisible', configuration.sidebarVisible), @@ -394,10 +384,6 @@ class LinuxHeaderBarService extends ChangeNotifier { 'search' => HeaderBarAction.search, 'refresh' => HeaderBarAction.refresh, 'save' => HeaderBarAction.save, - 'clipboardHistory' => HeaderBarAction.clipboardHistory, - 'localHistory' => HeaderBarAction.localHistory, - 'findLocalHistory' => HeaderBarAction.findLocalHistory, - 'export' => HeaderBarAction.export, 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, 'settings' => HeaderBarAction.settings, diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 71dfaf00..3f36361d 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -374,10 +374,6 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: - case HeaderBarAction.clipboardHistory: - case HeaderBarAction.localHistory: - case HeaderBarAction.findLocalHistory: - case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -398,13 +394,6 @@ class _SettingsScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - case BusyMarkMainMenuAction.generateMarkdownToc: - break; - case BusyMarkMainMenuAction.clipboardHistory: - case BusyMarkMainMenuAction.localHistory: - case BusyMarkMainMenuAction.findLocalHistory: - break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index b95b2307..3f4ba9e2 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -255,10 +255,6 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: - case HeaderBarAction.clipboardHistory: - case HeaderBarAction.localHistory: - case HeaderBarAction.findLocalHistory: - case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -288,13 +284,6 @@ class _WelcomeScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - case BusyMarkMainMenuAction.generateMarkdownToc: - break; - case BusyMarkMainMenuAction.clipboardHistory: - case BusyMarkMainMenuAction.localHistory: - case BusyMarkMainMenuAction.findLocalHistory: - break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 6eb04d30..577b0589 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -554,6 +554,11 @@ class WorkspaceScreen extends ConsumerWidget { final sidebarVisible = settings.sidebarVisible && _hasWorkspaceSidebar(workspace); final documentOutline = _activeDocumentOutline(state); + final canExportPdf = canExportWorkspacePdf(state); + final canExportHtml = canExportWorkspaceHtml(state); + final canGenerateMarkdownToc = + _activeWorkspaceDocumentKind(workspace)?.supportsAiMarkdownEditing ?? + false; final sidebar = SizedBox( width: BusyMarkSizes.sidebarWidth, child: _Sidebar( @@ -562,6 +567,10 @@ class WorkspaceScreen extends ConsumerWidget { searchState: searchState, searchResults: searchResults, onOpenSearchResult: (result) => _openSearchResult(context, ref, result), + canExport: canExportPdf || canExportHtml, + canGenerateMarkdownToc: canGenerateMarkdownToc, + onExport: () => unawaited(exportWorkspace(context, ref)), + onGenerateMarkdownToc: () => _generateOrUpdateMarkdownToc(context, ref), ), ); final workspaceContent = Expanded( @@ -698,19 +707,12 @@ class WorkspaceScreen extends ConsumerWidget { ? '*${_activeFileName(context, workspace)}' : _activeFileName(context, workspace); final hasSidebar = _hasWorkspaceSidebar(workspace); - final canExportPdf = canExportWorkspacePdf(state); - final canExportHtml = canExportWorkspaceHtml(state); - final canGenerateMarkdownToc = - _activeWorkspaceDocumentKind(workspace)?.supportsAiMarkdownEditing ?? - false; final headerConfiguration = HeaderBarConfigurationDefaults.of(context) .copyWith( title: busyMarkBidiIsolateFor(context, title), viewMode: _headerBarViewMode(settings.documentViewMode), searchQuery: searchState.query, canRefresh: true, - canExportPdf: canExportPdf, - canExportHtml: canExportHtml, documentControlsVisible: true, searchActive: searchState.active, searchVisible: true, @@ -883,9 +885,6 @@ class WorkspaceScreen extends ConsumerWidget { }, ), BusyMarkMainMenuButton( - canExportPdf: canExportPdf, - canExportHtml: canExportHtml, - canGenerateMarkdownToc: canGenerateMarkdownToc, onSelected: (action) => _handleMainMenuAction(context, ref, action), ), @@ -1023,14 +1022,6 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(_validateActiveAndShowProblems(context, ref)); case HeaderBarAction.save: execute(BusyMarkCommandIds.save); - case HeaderBarAction.clipboardHistory: - execute(BusyMarkCommandIds.clipboardHistory); - case HeaderBarAction.localHistory: - execute(BusyMarkCommandIds.localHistory); - case HeaderBarAction.findLocalHistory: - execute(BusyMarkCommandIds.findLocalHistory); - case HeaderBarAction.export: - execute(BusyMarkCommandIds.export); case HeaderBarAction.fullScreen: execute(BusyMarkCommandIds.fullScreen); case HeaderBarAction.settings: @@ -1076,14 +1067,6 @@ class WorkspaceScreen extends ConsumerWidget { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - unawaited(exportWorkspace(context, ref)); - case BusyMarkMainMenuAction.generateMarkdownToc: - _generateOrUpdateMarkdownToc(context, ref); - case BusyMarkMainMenuAction.clipboardHistory: - case BusyMarkMainMenuAction.localHistory: - case BusyMarkMainMenuAction.findLocalHistory: - return; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -1666,6 +1649,15 @@ Future _performWorkspacePathAction( enum _PathMenuAction { copyName, copyPath, openInFiles, refineWithAi } +enum _OutlineDocumentAction { + copyName, + copyPath, + openInFiles, + refineWithAi, + generateMarkdownToc, + export, +} + List> _sidebarPathMenuItems( BuildContext context, { String? copyNameLabel, @@ -1701,6 +1693,53 @@ List> _sidebarPathMenuItems( ]; } +List> _outlineDocumentMenuItems( + BuildContext context, { + required bool pathActionsEnabled, + required bool canGenerateMarkdownToc, + required bool showExport, + required bool canExport, +}) { + return [ + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.copyName, + label: context.l10n.copyFileName, + icon: BusyMarkGlyphs.copy, + ), + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.copyPath, + label: context.l10n.copyPath, + icon: BusyMarkGlyphs.copy, + enabled: pathActionsEnabled, + ), + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.openInFiles, + label: context.l10n.openInFiles, + icon: BusyMarkGlyphs.folderOpen, + enabled: pathActionsEnabled, + ), + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.refineWithAi, + label: context.l10n.aiRefineWithAi, + icon: BusyMarkGlyphs.ai, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.generateMarkdownToc, + label: context.l10n.generateOrUpdateMarkdownToc, + icon: BusyMarkGlyphs.orderedList, + enabled: canGenerateMarkdownToc, + ), + if (showExport) + BusyMarkPopupMenuItem( + value: _OutlineDocumentAction.export, + label: context.l10n.export, + icon: BusyMarkGlyphs.exportPdf, + enabled: canExport, + ), + ]; +} + Future<_PathMenuAction?> _showSidebarPathMenu( BuildContext context, Offset position, { @@ -1986,6 +2025,10 @@ class _Sidebar extends ConsumerStatefulWidget { required this.searchState, required this.searchResults, required this.onOpenSearchResult, + required this.canExport, + required this.canGenerateMarkdownToc, + required this.onExport, + required this.onGenerateMarkdownToc, }); final Workspace workspace; @@ -1993,6 +2036,10 @@ class _Sidebar extends ConsumerStatefulWidget { final _WorkspaceSearchState searchState; final List<_WorkspaceSearchResult> searchResults; final Future Function(_WorkspaceSearchResult result) onOpenSearchResult; + final bool canExport; + final bool canGenerateMarkdownToc; + final VoidCallback onExport; + final VoidCallback onGenerateMarkdownToc; @override ConsumerState<_Sidebar> createState() => _SidebarState(); @@ -2070,6 +2117,10 @@ class _SidebarState extends ConsumerState<_Sidebar> { _performWorkspaceGitAction(menuContext, ref, action), onRefineActiveDocument: () => unawaited(_refineActiveDocumentWithAi(context)), + canExport: widget.canExport, + canGenerateMarkdownToc: widget.canGenerateMarkdownToc, + onExport: widget.onExport, + onGenerateMarkdownToc: widget.onGenerateMarkdownToc, ), Expanded( child: widget.searchState.active @@ -2097,6 +2148,8 @@ class _SidebarState extends ConsumerState<_Sidebar> { ), _SidebarTab.toc => _TocTab( workspace: widget.workspace, + canExport: widget.canExport, + onExport: widget.onExport, onShowFileHistory: _showFileHistory, onRequestTopicRemoval: (target) => _runWritersideTopicRemoval(context, target), @@ -2691,6 +2744,10 @@ class _SidebarHeader extends StatelessWidget { required this.loadGitMenuItems, required this.onGitAction, required this.onRefineActiveDocument, + required this.canExport, + required this.canGenerateMarkdownToc, + required this.onExport, + required this.onGenerateMarkdownToc, }); final Workspace workspace; @@ -2707,6 +2764,10 @@ class _SidebarHeader extends StatelessWidget { final Future Function(BuildContext context, _GitMenuAction action) onGitAction; final VoidCallback onRefineActiveDocument; + final bool canExport; + final bool canGenerateMarkdownToc; + final VoidCallback onExport; + final VoidCallback onGenerateMarkdownToc; @override Widget build(BuildContext context) { @@ -2753,6 +2814,7 @@ class _SidebarHeader extends StatelessWidget { if (showTabMenu && selectedTab != null) ...[ const SizedBox(width: BusyMarkSpacing.sm), BusyMarkHeaderPopupMenuButton<_SidebarTab>( + key: const ValueKey('workspace-sidebar-view-menu'), tooltip: context.l10n.sidebarViewMenu, icon: _sidebarTabIcon( selectedTab!, @@ -2856,28 +2918,52 @@ class _SidebarHeader extends StatelessWidget { ), ), const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkHeaderPopupMenuButton<_PathMenuAction>( + BusyMarkHeaderPopupMenuButton<_OutlineDocumentAction>( key: const ValueKey('workspace-sidebar-outline-file-menu'), tooltip: context.l10n.actions, icon: BusyMarkGlyphs.menuVertical, transparent: true, borderRadius: BusyMarkRadius.nativeHeaderButton, highlightWhenOpen: false, - itemBuilder: (menuContext) => _sidebarPathMenuItems( + itemBuilder: (menuContext) => _outlineDocumentMenuItems( menuContext, - copyNameLabel: menuContext.l10n.copyFileName, pathActionsEnabled: hasActiveDocumentPath, - showRefineWithAi: true, - ), - onSelected: (action) => unawaited( - _performWorkspacePathAction( - context, - name: activeDocumentName, - path: activeDocumentPath, - action: action, - onRefineWithAi: onRefineActiveDocument, - ), + canGenerateMarkdownToc: canGenerateMarkdownToc, + showExport: + workspace.kind != WorkspaceKind.writersideModule, + canExport: canExport, ), + onSelected: (action) { + switch (action) { + case _OutlineDocumentAction.copyName: + case _OutlineDocumentAction.copyPath: + case _OutlineDocumentAction.openInFiles: + case _OutlineDocumentAction.refineWithAi: + unawaited( + _performWorkspacePathAction( + context, + name: activeDocumentName, + path: activeDocumentPath, + action: switch (action) { + _OutlineDocumentAction.copyName => + _PathMenuAction.copyName, + _OutlineDocumentAction.copyPath => + _PathMenuAction.copyPath, + _OutlineDocumentAction.openInFiles => + _PathMenuAction.openInFiles, + _OutlineDocumentAction.refineWithAi => + _PathMenuAction.refineWithAi, + _ => throw StateError('unreachable'), + }, + onRefineWithAi: onRefineActiveDocument, + ), + ); + case _OutlineDocumentAction.generateMarkdownToc: + onGenerateMarkdownToc(); + case _OutlineDocumentAction.export: + onExport(); + } + }, ), ], ), @@ -4019,9 +4105,10 @@ class _FilesTabState extends ConsumerState<_FilesTab> { await widget.onShowFileHistory(file); } case _FileTreeAction.localHistory: - await ref - .read(localHistoryControllerProvider.notifier) - .selectDocumentForPath(path); + final opened = await ref + .read(workspaceControllerProvider.notifier) + .openActiveFile(path); + if (!opened || !mounted) return; ref .read(_sidebarShortcutRequestProvider.notifier) .select(_SidebarTab.localHistory); @@ -5005,11 +5092,15 @@ Set _activeFileAncestorPaths(Workspace workspace) { class _TocTab extends ConsumerStatefulWidget { const _TocTab({ required this.workspace, + required this.canExport, + required this.onExport, required this.onShowFileHistory, required this.onRequestTopicRemoval, }); final Workspace workspace; + final bool canExport; + final VoidCallback onExport; final Future Function(DocumentFile file) onShowFileHistory; final Future Function( _WritersideTopicRemovalTarget target, @@ -5324,6 +5415,8 @@ class _TocTabState extends ConsumerState<_TocTab> { ), onOpenTocFile: () => _openInstanceTree(context, instance.sourceTreePath), + canExport: widget.canExport, + onExport: widget.onExport, ); } final entry = entries[index - 1]; @@ -6502,6 +6595,7 @@ enum _TocHeaderAction { newLibrary, editInstance, openTocFile, + export, } class _TocHeader extends StatelessWidget { @@ -6518,6 +6612,8 @@ class _TocHeader extends StatelessWidget { required this.onCreateLibrary, required this.onEditInstance, required this.onOpenTocFile, + required this.canExport, + required this.onExport, }); final List<({String id, String label})> modules; @@ -6532,6 +6628,8 @@ class _TocHeader extends StatelessWidget { final VoidCallback onCreateLibrary; final VoidCallback onEditInstance; final VoidCallback onOpenTocFile; + final bool canExport; + final VoidCallback onExport; @override Widget build(BuildContext context) { @@ -6631,6 +6729,13 @@ class _TocHeader extends StatelessWidget { label: context.l10n.openTocFile, icon: BusyMarkGlyphs.documentOpen, ), + const PopupMenuDivider(), + BusyMarkPopupMenuItem( + value: _TocHeaderAction.export, + label: context.l10n.export, + icon: BusyMarkGlyphs.exportPdf, + enabled: canExport, + ), ], onSelected: (action) { switch (action) { @@ -6644,6 +6749,8 @@ class _TocHeader extends StatelessWidget { onEditInstance(); case _TocHeaderAction.openTocFile: onOpenTocFile(); + case _TocHeaderAction.export: + onExport(); } }, ), @@ -8471,12 +8578,30 @@ class _EditorTabStrip extends ConsumerWidget { case WorkspaceTabKind.file: ref.read(localHistoryControllerProvider.notifier).clearComparison(); if (entry.bufferId == state.activeBufferId) { + final buffer = state.activeBuffer; + if (buffer != null) { + unawaited( + ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(buffer), + ); + } gitController.deactivateDiffFile(); return; } - await ref + final activated = await ref .read(workspaceControllerProvider.notifier) .activateDocumentBuffer(entry.bufferId!); + if (activated) { + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) { + unawaited( + ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(buffer), + ); + } + } gitController.deactivateDiffFile(); case WorkspaceTabKind.gitDiff: ref.read(localHistoryControllerProvider.notifier).clearComparison(); @@ -8555,9 +8680,10 @@ class _EditorTabStrip extends ConsumerWidget { .where((candidate) => candidate.id == entry.bufferId) .firstOrNull; if (buffer == null) return; - await ref - .read(localHistoryControllerProvider.notifier) - .selectDocumentForBuffer(buffer); + final activated = await ref + .read(workspaceControllerProvider.notifier) + .activateDocumentBuffer(buffer.id); + if (!activated || !context.mounted) return; ref .read(_sidebarShortcutRequestProvider.notifier) .select(_SidebarTab.localHistory); diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index b0f5e833..2e7c8ed8 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -190,6 +190,7 @@ class WorkspaceController extends Notifier { _ActivePreviewRevision? _activePreviewRevision; var _editRevision = 0; var _activeDocumentRevision = 0; + var _workspaceRefreshRevision = 0; var _untitledSequence = 0; final _intentionallyRemovedPaths = {}; late Future _recoveryStart; @@ -3374,76 +3375,142 @@ class WorkspaceController extends Notifier { } final existed = await _service.pathExists(path); if (existed != overwriteExisting) return false; - WorkspaceFileLoad? previous; - if (existed) { - try { - previous = await _service.loadTextWithSnapshot(path); - } on Object { - return false; - } - if (!await _localHistory.capturePathBeforeLoss( - path: path, - text: previous.text, - format: previous.format, - reason: LocalHistoryCaptureReason.beforeRestore, - )) { - return false; - } - if (await _service.fileChangedSince(path, previous.snapshot)) { - return false; - } - } - WorkspaceFileSnapshot? published; + WorkspaceFileSnapshot? createdSnapshot; try { - if (existed) { - published = await _service.saveFormattedTextReplacingPath( - path, - '', - format: previous!.format, - ); - } else { - published = await _service.saveNewFormattedText( + if (!existed) { + createdSnapshot = await _service.saveNewFormattedText( path, '', format: revision.format, ); } - final workspace = state.workspace; - final insideCurrentWorkspace = - workspace != null && - workspace.rootPath.isNotEmpty && - (p.equals(workspace.rootPath, path) || - p.isWithin(workspace.rootPath, path)); - if (insideCurrentWorkspace) { - await refreshWorkspaceFromDiskPreservingOpenTabs(); - if (!await _openActiveFile(path)) { - await _rollbackMissingHistoryDestination(path, previous, published); + var destination = state.bufferForPath(path); + if (destination == null) { + if (!await _openLocalHistoryRestoreDestination(path)) { + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination( + path, + null, + createdSnapshot, + ); + } return false; } - } else { - await openPath(path); + destination = state.bufferForPath(path); + } else if (state.activeBufferId != destination.id) { + if (!await activateDocumentBuffer(destination.id)) { + return false; + } + destination = state.bufferForPath(path); + } + if (destination == null || + destination.filePath == null || + !p.equals(destination.filePath!, path) || + destination.diskState != DocumentDiskState.present) { + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination(path, null, createdSnapshot); + } + return false; + } + if (destination.text == revision.source) { + return true; } - final destinationDocument = document.copyWith( - currentPath: path, - displayName: p.basename(path), - deleted: false, + final target = _LocalHistoryRestoreTarget( + workspaceId: state.workspace?.id, + bufferId: destination.id, + path: destination.filePath!, + revision: destination.revision, + text: destination.text, + snapshot: destination.diskSnapshot, + diskState: destination.diskState, + format: destination.format, + editorState: destination.editorState, ); - final restored = await restoreLocalHistoryRevision( - document: destinationDocument, - revision: revision, + if (!await _localHistory.captureProtective( + LocalHistoryBufferSnapshot.fromBuffer(destination), + LocalHistoryCaptureReason.beforeRestore, + )) { + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination(path, null, createdSnapshot); + } + return false; + } + if (!_isLocalHistoryRestoreTargetCurrent(target) || + await _service.fileChangedSince(path, target.snapshot) || + !_isLocalHistoryRestoreTargetCurrent(target)) { + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination(path, null, createdSnapshot); + } + return false; + } + final current = state.documentBuffers + .where((buffer) => buffer.id == target.bufferId) + .firstOrNull!; + final restored = current + .edited( + revision.source, + previousSelection: current.editorState.selection, + nextSelection: TextSelection.collapsed( + offset: revision.source.length, + ), + ) + .copyWith(format: current.format); + _localHistory.observeEdit(current, restored); + state = state.copyWith( + documentBuffers: _replaceBuffer(state.documentBuffers, restored), + clearMessage: true, ); - if (!restored) { - await _rollbackMissingHistoryDestination(path, previous, published); + _schedulePersistence(); + if (state.activeBufferId == restored.id) { + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); } - return restored; + _scheduleAutoSave(restored.id); + return true; } on Object { - if (published != null) { - await _rollbackMissingHistoryDestination(path, previous, published); + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination(path, null, createdSnapshot); } return false; } } + Future _openLocalHistoryRestoreDestination(String path) async { + final workspace = state.workspace; + final insideCurrentWorkspace = + workspace != null && + workspace.rootPath.isNotEmpty && + (p.equals(workspace.rootPath, path) || + p.isWithin(workspace.rootPath, path)); + if (insideCurrentWorkspace) { + return _openActiveFile(path); + } + if (state.hasUnsavedChanges) { + return false; + } + await openPath(path); + final activePath = state.activeBuffer?.filePath; + return activePath != null && p.equals(activePath, path); + } + + bool _isLocalHistoryRestoreTargetCurrent(_LocalHistoryRestoreTarget target) { + final current = state.documentBuffers + .where((buffer) => buffer.id == target.bufferId) + .firstOrNull; + return current != null && + state.workspace?.id == target.workspaceId && + current.filePath != null && + p.equals(current.filePath!, target.path) && + current.revision == target.revision && + current.text == target.text && + _sameFileSnapshot(current.diskSnapshot, target.snapshot) && + current.diskState == target.diskState && + identical(current.format, target.format) && + identical(current.editorState, target.editorState); + } + Future localHistoryRestorePathExists(String path) { return _service.pathExists(p.normalize(p.absolute(path))); } @@ -3670,7 +3737,8 @@ class WorkspaceController extends Notifier { if (workspace == null) { return false; } - final operationRevision = _invalidateActiveDocumentOperations(); + final refreshRevision = ++_workspaceRefreshRevision; + _invalidateActiveDocumentOperations(); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); state = state.copyWith(isLoading: true, clearMessage: true); @@ -3679,7 +3747,9 @@ class WorkspaceController extends Notifier { ? workspace.activeFilePath ?? workspace.rootPath : workspace.rootPath; final refreshed = await _service.openPath(openTarget); - if (!_isCurrentActiveDocumentOperation(operationRevision)) return false; + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } final refreshedWorkspace = workspace.kind == WorkspaceKind.singleMarkdown ? workspace.copyWith( files: _mergedDocumentFiles(workspace.files, refreshed.files), @@ -3690,11 +3760,14 @@ class WorkspaceController extends Notifier { final existingFiles = { for (final file in refreshed.files) file.absolutePath: file, }; - final buffers = []; - for (final buffer in state.documentBuffers) { - final path = buffer.filePath; + final requestedBuffers = List.of(state.documentBuffers); + final replacements = {}; + for (final requested in requestedBuffers) { + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + final path = requested.filePath; if (path == null) { - buffers.add(buffer); continue; } if (_intentionallyRemovedPaths.any( @@ -3705,73 +3778,170 @@ class WorkspaceController extends Notifier { final exists = workspace.kind == WorkspaceKind.singleMarkdown ? await _service.pathExists(path) : existingFiles.containsKey(path); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } if (!exists) { - buffers.add(buffer.copyWith(diskState: DocumentDiskState.deleted)); + replacements[requested.id] = _WorkspaceRefreshBufferReplacement( + requested: requested, + replacement: requested.copyWith( + diskState: DocumentDiskState.deleted, + ), + ); continue; } - if (buffer.isDirty) { - buffers.add(buffer); + if (requested.isDirty) { continue; } final load = await _service.loadTextWithSnapshot(path); - final sourceChanged = buffer.text != load.text; - buffers.add( - buffer.copyWith( + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + 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 ? buffer.revision + 1 : buffer.revision, + revision: sourceChanged + ? requested.revision + 1 + : requested.revision, diskState: DocumentDiskState.present, ), ); } - if (!_isCurrentActiveDocumentOperation(operationRevision)) return false; - var activeBuffer = buffers - .where((buffer) => buffer.id == state.activeBufferId) - .firstOrNull; - activeBuffer ??= buffers.firstOrNull; - if (activeBuffer == null && refreshed.activeFilePath != null) { + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + var buffers = _reconcileWorkspaceRefreshBuffers( + _workspaceRefreshLiveBuffers(), + replacements, + ); + var activeBuffer = _activeRefreshBuffer( + buffers, + state.activeBufferId, + refreshedWorkspace.activeFilePath, + ); + if (activeBuffer == null && + requestedBuffers.isEmpty && + state.documentBuffers.isEmpty && + refreshed.activeFilePath != null) { final load = await _service.loadTextWithSnapshot( refreshed.activeFilePath!, ); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id) || + state.documentBuffers.isNotEmpty) { + return false; + } activeBuffer = _fileBuffer( refreshed.activeFilePath!, load, mode: _settingsController.state.documentViewMode, ); - buffers.add(activeBuffer); + buffers = [activeBuffer]; } - final active = activeBuffer?.filePath; - final tabPaths = [ - for (final buffer in buffers) - if (buffer.filePath != null) buffer.filePath!, - ]; - final nextWorkspace = refreshedWorkspace.copyWith( - activeFilePath: active, - activeFileSnapshot: activeBuffer?.diskSnapshot, - openFilePaths: tabPaths, + var parseTarget = _WorkspaceRefreshParseTarget( + bufferId: activeBuffer?.id, + path: activeBuffer?.filePath, + revision: activeBuffer?.revision, + text: activeBuffer?.text ?? '', ); - final reparsed = activeBuffer == null - ? nextWorkspace.copyWith(markdown: null) - : await _service.reparseActive(nextWorkspace, activeBuffer.text); - if (!_isCurrentActiveDocumentOperation(operationRevision)) { + var reparsed = await _reparseWorkspaceRefresh( + refreshedWorkspace, + buffers, + activeBuffer, + ); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { return false; } - state = WorkspaceState( - workspace: reparsed, - activeText: activeBuffer?.text ?? '', - preview: activeBuffer == null - ? null - : _safePreview(reparsed, activeBuffer.text), - documentBuffers: buffers, - activeBufferId: activeBuffer?.id, + await _discardWorkspaceRefreshReplacementsWithStaleDisk(replacements); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + buffers = _reconcileWorkspaceRefreshBuffers( + _workspaceRefreshLiveBuffers(), + replacements, ); - _recordActivePreviewRevision(); + activeBuffer = _activeRefreshBuffer( + buffers, + state.activeBufferId, + refreshedWorkspace.activeFilePath, + ); + var finalTarget = _WorkspaceRefreshParseTarget( + bufferId: activeBuffer?.id, + path: activeBuffer?.filePath, + revision: activeBuffer?.revision, + text: activeBuffer?.text ?? '', + ); + if (finalTarget != parseTarget) { + parseTarget = finalTarget; + reparsed = await _reparseWorkspaceRefresh( + refreshedWorkspace, + buffers, + activeBuffer, + ); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + await _discardWorkspaceRefreshReplacementsWithStaleDisk(replacements); + if (!_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { + return false; + } + buffers = _reconcileWorkspaceRefreshBuffers( + _workspaceRefreshLiveBuffers(), + replacements, + ); + activeBuffer = _activeRefreshBuffer( + buffers, + state.activeBufferId, + refreshedWorkspace.activeFilePath, + ); + finalTarget = _WorkspaceRefreshParseTarget( + bufferId: activeBuffer?.id, + path: activeBuffer?.filePath, + revision: activeBuffer?.revision, + text: activeBuffer?.text ?? '', + ); + } + final tabPaths = _refreshTabPaths(buffers); + if (finalTarget == parseTarget) { + final publishedWorkspace = reparsed.copyWith( + activeFilePath: activeBuffer?.filePath, + activeFileSnapshot: activeBuffer?.diskSnapshot, + openFilePaths: tabPaths, + ); + state = state.copyWith( + workspace: publishedWorkspace, + preview: activeBuffer == null + ? null + : _safePreview(publishedWorkspace, activeBuffer.text), + documentBuffers: buffers, + activeBufferId: activeBuffer?.id, + isLoading: false, + clearMessage: true, + ); + _recordActivePreviewRevision(); + } else { + // The active source changed during both reparses. Keep the already + // current derived state and let the ordinary editor refresh pipeline + // calculate it from the surviving live buffer. + state = state.copyWith( + documentBuffers: buffers, + isLoading: false, + clearMessage: true, + ); + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); + } _fileMonitor.updateOpenFilePaths(tabPaths); _schedulePersistence(); _resetSaveTracking(); + _scheduleAutoSave(); return true; } on Object catch (error, stackTrace) { busyMarkDebugLogError( @@ -3780,7 +3950,7 @@ class WorkspaceController extends Notifier { stackTrace, context: {'root': busyMarkLogPath(workspace.rootPath)}, ); - if (_isCurrentActiveDocumentOperation(operationRevision)) { + if (_isCurrentWorkspaceRefresh(refreshRevision, workspace.id)) { state = state.copyWith( isLoading: false, message: WorkspaceMessage( @@ -3793,6 +3963,56 @@ class WorkspaceController extends Notifier { } } + bool _isCurrentWorkspaceRefresh(int revision, String workspaceId) { + return ref.mounted && + revision == _workspaceRefreshRevision && + state.workspace?.id == workspaceId; + } + + List _workspaceRefreshLiveBuffers() => [ + for (final buffer in state.documentBuffers) + if (buffer.filePath == null || + !_intentionallyRemovedPaths.any( + (removed) => + p.equals(buffer.filePath!, removed) || + p.isWithin(removed, buffer.filePath!), + )) + buffer, + ]; + + Future _discardWorkspaceRefreshReplacementsWithStaleDisk( + Map replacements, + ) async { + for (final entry in replacements.entries.toList(growable: false)) { + final replacement = entry.value.replacement; + final path = replacement.filePath; + final snapshot = replacement.diskSnapshot; + if (replacement.diskState != DocumentDiskState.present || + path == null || + snapshot == null) { + continue; + } + if (await _service.fileChangedSince(path, snapshot)) { + replacements.remove(entry.key); + } + } + } + + Future _reparseWorkspaceRefresh( + Workspace refreshedWorkspace, + List buffers, + DocumentBuffer? activeBuffer, + ) { + final nextWorkspace = refreshedWorkspace.copyWith( + activeFilePath: activeBuffer?.filePath, + activeFileSnapshot: activeBuffer?.diskSnapshot, + openFilePaths: _refreshTabPaths(buffers), + ); + return activeBuffer == null + ? Future.value(nextWorkspace.copyWith(markdown: null)) + : _service.reparseActive(nextWorkspace, activeBuffer.text); + } + Future _runWorkspaceFileOperation( Future Function(Workspace workspace) operation, ) async { @@ -4263,6 +4483,121 @@ List _replaceBuffer( ]); } +class _WorkspaceRefreshBufferReplacement { + const _WorkspaceRefreshBufferReplacement({ + required this.requested, + required this.replacement, + }); + + final DocumentBuffer requested; + final DocumentBuffer replacement; +} + +class _LocalHistoryRestoreTarget { + const _LocalHistoryRestoreTarget({ + required this.workspaceId, + required this.bufferId, + required this.path, + required this.revision, + required this.text, + required this.snapshot, + required this.diskState, + required this.format, + required this.editorState, + }); + + final String? workspaceId; + final String bufferId; + final String path; + final int revision; + final String text; + final WorkspaceFileSnapshot? snapshot; + final DocumentDiskState diskState; + final TextFormatMetadata format; + final DocumentEditorState editorState; +} + +class _WorkspaceRefreshParseTarget { + const _WorkspaceRefreshParseTarget({ + required this.bufferId, + required this.path, + required this.revision, + required this.text, + }); + + final String? bufferId; + final String? path; + final int? revision; + final String text; + + @override + bool operator ==(Object other) => + other is _WorkspaceRefreshParseTarget && + other.bufferId == bufferId && + other.path == path && + other.revision == revision && + other.text == text; + + @override + int get hashCode => Object.hash(bufferId, path, revision, text); +} + +List _reconcileWorkspaceRefreshBuffers( + List liveBuffers, + Map replacements, +) { + return List.unmodifiable([ + for (final live in liveBuffers) + if (replacements[live.id] case final result?) + if (_workspaceRefreshRequestStillMatches(live, result.requested)) + result.replacement.copyWith(editorState: live.editorState) + else + live + else + live, + ]); +} + +bool _workspaceRefreshRequestStillMatches( + DocumentBuffer live, + DocumentBuffer requested, +) { + return live.id == requested.id && + live.filePath == requested.filePath && + live.text == requested.text && + live.lastSavedText == requested.lastSavedText && + live.dirty == requested.dirty && + live.revision == requested.revision && + live.diskState == requested.diskState && + _sameFileSnapshot(live.diskSnapshot, requested.diskSnapshot); +} + +DocumentBuffer? _activeRefreshBuffer( + List buffers, + String? activeBufferId, + String? refreshedActivePath, +) { + final active = activeBufferId == null + ? null + : buffers.where((buffer) => buffer.id == activeBufferId).firstOrNull; + if (active != null) return active; + final refreshed = refreshedActivePath == null + ? null + : buffers + .where( + (buffer) => + buffer.filePath != null && + p.equals(buffer.filePath!, refreshedActivePath), + ) + .firstOrNull; + return refreshed ?? buffers.firstOrNull; +} + +List _refreshTabPaths(List buffers) => [ + for (final buffer in buffers) + if (buffer.filePath != null) buffer.filePath!, +]; + class _ActivePreviewRevision { const _ActivePreviewRevision({ required this.workspaceId, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e95de7ee..08e8168a 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -109,8 +109,6 @@ struct _MyApplication { GtkWidget* search_entry; gboolean document_controls_visible; gboolean search_visible; - gboolean can_export_pdf; - gboolean can_export_html; GtkWidget* view_mode_box; GtkWidget* view_mode_button; GtkWidget* view_mode_icon; @@ -157,8 +155,6 @@ struct HeaderBarConfiguration { const gchar* title; const gchar* view_mode; gboolean can_refresh; - gboolean can_export_pdf; - gboolean can_export_html; gboolean document_controls_visible; gboolean search_active; gboolean search_visible; @@ -1327,19 +1323,9 @@ static GtkWidget* create_header_toggle_button(const gchar* icon_name) { } static const gchar* main_menu_icon_name(const gchar* action) { - if (g_strcmp0(action, "export") == 0) { - return "document-save-as-symbolic"; - } if (g_strcmp0(action, "settings") == 0) { return "preferences-system-symbolic"; } - if (g_strcmp0(action, "clipboardHistory") == 0) { - return "edit-paste-symbolic"; - } - if (g_strcmp0(action, "localHistory") == 0 || - g_strcmp0(action, "findLocalHistory") == 0) { - return "document-open-recent-symbolic"; - } if (g_strcmp0(action, "keyboardShortcuts") == 0) { return "input-keyboard-symbolic"; } @@ -1388,30 +1374,11 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { return; } g_menu_remove_all(self->main_menu_model); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "export", ""), "header.export", - main_menu_icon_name("export"), - fl_lookup_string_arg(labels, "exportGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "fullScreen", ""), "header.full-screen", main_menu_icon_name("fullScreen"), fl_lookup_string_arg(labels, "fullScreenGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "clipboardHistory", ""), - "header.clipboard-history", main_menu_icon_name("clipboardHistory"), - nullptr); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "localHistory", ""), - "header.local-history", main_menu_icon_name("localHistory"), nullptr); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "findLocalHistory", ""), - "header.find-local-history", main_menu_icon_name("findLocalHistory"), - nullptr); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "settings", ""), "header.settings", @@ -1563,27 +1530,9 @@ static void add_header_gaction(MyApplication* self, g_object_unref(action); } -static void set_header_action_enabled(MyApplication* self, - const gchar* action_name, - gboolean enabled) { - if (self->header_action_group == nullptr) { - return; - } - GAction* action = g_action_map_lookup_action( - G_ACTION_MAP(self->header_action_group), action_name); - if (action != nullptr && G_IS_SIMPLE_ACTION(action)) { - g_simple_action_set_enabled(G_SIMPLE_ACTION(action), enabled); - } -} - static void setup_header_actions(MyApplication* self) { self->header_action_group = g_simple_action_group_new(); - add_header_gaction(self, "export", "export"); - set_header_action_enabled(self, "export", FALSE); add_header_gaction(self, "settings", "settings"); - add_header_gaction(self, "clipboard-history", "clipboardHistory"); - add_header_gaction(self, "local-history", "localHistory"); - add_header_gaction(self, "find-local-history", "findLocalHistory"); add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts"); add_header_gaction(self, "syntax-reference", "syntaxReference"); add_header_gaction(self, "report-issue", "reportIssue"); @@ -1899,9 +1848,6 @@ static gboolean decode_header_bar_configuration( configuration->sidebar_width <= 0 || !fl_lookup_optional_bool_arg(args, "canRefresh", &configuration->can_refresh) || - !fl_lookup_optional_bool_arg(args, "canExportHtml", &configuration->can_export_html) || - !fl_lookup_optional_bool_arg(args, "canExportPdf", - &configuration->can_export_pdf) || !fl_lookup_optional_bool_arg( args, "documentControlsVisible", &configuration->document_controls_visible) || @@ -1946,16 +1892,6 @@ static void apply_header_bar_configuration( gtk_label_set_text(GTK_LABEL(self->title_label), configuration.title); } set_widget_sensitive(self->refresh_button, configuration.can_refresh); - self->can_export_pdf = configuration.can_export_pdf; - self->can_export_html = configuration.can_export_html; - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - set_header_action_enabled(self, "clipboard-history", - configuration.document_controls_visible); - set_header_action_enabled(self, "local-history", - configuration.document_controls_visible); - set_header_action_enabled(self, "find-local-history", - configuration.document_controls_visible); set_sidebar_width(self, configuration.sidebar_width); set_text_direction(self, configuration.text_direction); set_sidebar_visible(self, configuration.sidebar_visible); @@ -2195,16 +2131,6 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setCanRefresh") == 0) { set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); respond_success(method_call); - } else if (strcmp(method, "setCanExportHtml") == 0) { - self->can_export_html = fl_method_bool_arg(args); - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - respond_success(method_call); - } else if (strcmp(method, "setCanExportPdf") == 0) { - self->can_export_pdf = fl_method_bool_arg(args); - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - respond_success(method_call); } else if (strcmp(method, "setDocumentControlsVisible") == 0) { set_document_controls_visible(self, fl_method_bool_arg(args)); respond_success(method_call); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 21cac935..5419d8d0 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -263,6 +263,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l10n.fullScreen), findsOneWidget); expect(find.text(BusyMarkAppShortcutLabels.fullScreen), findsOneWidget); + for (final documentCommand in { + l10n.clipboardHistory, + l10n.localHistory, + l10n.findLocalHistoryEllipsis, + l10n.generateOrUpdateMarkdownToc, + l10n.export, + }) { + expect(find.text(documentCommand), findsNothing); + } expect( tester .widget>(fullScreenMenuItem()) @@ -2138,6 +2147,7 @@ void main() { expect(find.text(l10n.newTocLibrary), findsOneWidget); expect(find.text(l10n.editInstance), findsOneWidget); expect(find.text(l10n.openTocFile), findsOneWidget); + expect(find.text(l10n.export), findsOneWidget); await tester.tap(find.text(l10n.editInstance)); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.instanceOutputSettings), findsOneWidget); @@ -2425,8 +2435,7 @@ void main() { ); expect(targetNode.children.single.topicFileName, 'loose.md'); - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.tocActions)); final exportItem = find.byWidgetPredicate( (widget) => widget is BusyMarkPopupMenuItem && @@ -3000,6 +3009,8 @@ void main() { expect(find.text(l10n.copyName), findsOneWidget); expect(find.text(l10n.copyPath), findsOneWidget); expect(find.text(l10n.openInFiles), findsOneWidget); + expect(find.text(l10n.generateOrUpdateMarkdownToc), findsNothing); + expect(find.text(l10n.export), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); @@ -3461,6 +3472,8 @@ void main() { expect(find.text(l10n.copyPath), findsOneWidget); expect(find.text(l10n.openInFiles), findsOneWidget); expect(find.text(l10n.aiRefineWithAi), findsOneWidget); + expect(find.text(l10n.generateOrUpdateMarkdownToc), findsOneWidget); + expect(find.text(l10n.export), findsOneWidget); await tester.tap(find.text(l10n.copyFileName)); await tester.pumpAndSettle(); @@ -3484,9 +3497,14 @@ void main() { expect(find.text(shortcut), findsOneWidget); expect(find.byTooltip('$label ($shortcut)'), findsNothing); } + expect(find.text(l10n.clipboardHistory), findsOneWidget); + expect(find.text(l10n.localHistory), findsOneWidget); expect(find.text(l10n.gitChanges), findsNothing); expect(find.text(l10n.gitFileHistory), findsNothing); expect(find.text(l10n.gitProjectHistory), findsNothing); + expect(find.text(l10n.findLocalHistoryEllipsis), findsNothing); + expect(find.text(l10n.generateOrUpdateMarkdownToc), findsNothing); + expect(find.text(l10n.export), findsNothing); }); testWidgets('Writerside sidebar shortcuts survive document view changes', ( diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index 8b29ccff..df95c54e 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -147,13 +147,126 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'current external HTML is transient until panel insertion and remains reusable', + (tester) async { + const html = + '

    External

    Bold and ' + 'linked

    • Item
    '; + final clipboard = _PanelClipboard( + const RichClipboardData( + text: 'External\nBold and linked\nItem', + html: html, + generation: 21, + ), + ); + final container = _container(clipboard: clipboard); + final target = _PanelInsertionTarget(); + container.read(clipboardInsertionRegistryProvider).register(target); + await _pumpPanel(tester, container); + expect( + container.read(clipboardHistoryControllerProvider).entries, + isEmpty, + ); + + final currentPaste = tester.widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ), + ); + expect(currentPaste.onPressed, isNotNull); + currentPaste.onPressed!.call(); + await tester.pumpAndSettle(); + expect(target.payloads.single.html, html); + expect(target.lastPlainText, isFalse); + expect( + container.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); + + clipboard.value = const RichClipboardData( + text: 'Replacement clipboard', + generation: 22, + ); + await container + .read(clipboardHistoryControllerProvider.notifier) + .refreshCurrentClipboard(); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'External'); + await tester.pumpAndSettle(); + final retainedPaste = tester.widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ), + ); + expect(retainedPaste.onPressed, isNotNull); + retainedPaste.onPressed!.call(); + await tester.pumpAndSettle(); + expect(target.payloads, hasLength(2)); + expect(target.payloads.last.html, html); + }, + ); + + testWidgets( + 'failed current-item paste is not retained and plain paste stays explicit', + (tester) async { + final clipboard = _PanelClipboard( + const RichClipboardData( + text: 'Readable fallback', + html: '

    Rich source

    ', + generation: 23, + ), + ); + final container = _container(clipboard: clipboard); + final failed = _PanelInsertionTarget( + result: ClipboardPasteResult.staleTarget, + ); + container.read(clipboardInsertionRegistryProvider).register(failed); + await _pumpPanel(tester, container); + + final failedPaste = tester.widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ), + ); + expect(failedPaste.onPressed, isNotNull); + failedPaste.onPressed!.call(); + await tester.pumpAndSettle(); + expect( + container.read(clipboardHistoryControllerProvider).entries, + isEmpty, + ); + + final successful = _PanelInsertionTarget(); + container.read(clipboardInsertionRegistryProvider).register(successful); + await tester.pump(); + final plainPaste = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is IconButton && widget.tooltip == 'Paste as Plain Text', + ), + ); + expect(plainPaste.onPressed, isNotNull); + plainPaste.onPressed!.call(); + await tester.pumpAndSettle(); + expect(successful.lastPlainText, isTrue); + expect(successful.payloads.single.text, 'Readable fallback'); + expect( + container.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); + }, + ); } -ProviderContainer _container() { +ProviderContainer _container({RichClipboardService? clipboard}) { final container = ProviderContainer( overrides: [ localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - richClipboardServiceProvider.overrideWithValue(_EmptyClipboard()), + richClipboardServiceProvider.overrideWithValue( + clipboard ?? _EmptyClipboard(), + ), clipboardAssetInputServiceProvider.overrideWithValue(_EmptyAssetInput()), ], ); @@ -205,6 +318,7 @@ class _PanelInsertionTarget final ClipboardPasteResult result; int pasteCalls = 0; bool? lastPlainText; + final payloads = []; @override String get documentId => 'panel-target'; @@ -234,6 +348,7 @@ class _PanelInsertionTarget }) async { pasteCalls++; lastPlainText = plainText; + payloads.add(payload); return result; } @@ -259,6 +374,18 @@ class _EmptyClipboard extends RichClipboardService { Future read() async => const RichClipboardData(); } +class _PanelClipboard extends RichClipboardService { + _PanelClipboard(this.value) + : super( + channel: const MethodChannel('busymark.test/current-panel-clipboard'), + ); + + RichClipboardData value; + + @override + Future read() async => value; +} + class _EmptyAssetInput extends AssetInputService { _EmptyAssetInput() : super(channel: const MethodChannel('busymark.test/panel-assets')); diff --git a/test/src/clipboard_history_test.dart b/test/src/clipboard_history_test.dart index 17a23b98..e4a6c55f 100644 --- a/test/src/clipboard_history_test.dart +++ b/test/src/clipboard_history_test.dart @@ -147,6 +147,59 @@ void main() { }, ); + test( + 'current external HTML keeps its rich and interoperable text representations', + () async { + const html = + '

    External

    Bold and ' + 'linked

    • Item
    '; + final scope = container( + current: const RichClipboardData( + text: 'External\nBold and linked\nItem', + html: html, + generation: 11, + ), + ); + await Future.delayed(Duration.zero); + final controller = scope.read( + clipboardHistoryControllerProvider.notifier, + ); + + await controller.refreshCurrentClipboard(); + final state = scope.read(clipboardHistoryControllerProvider); + final current = state.currentClipboard!; + expect(current.kind, BusyMarkClipboardContentKind.richText); + expect(current.html, html); + expect(current.text, 'External\nBold and linked\nItem'); + expect(current.sourceText, isNull); + expect(current.external, isTrue); + expect(state.entries, isEmpty); + }, + ); + + test( + 'current HTML-only clipboard is eligible without internal ownership', + () async { + final scope = container( + current: const RichClipboardData( + html: '

    HTML only

    Supported

    ', + generation: 12, + ), + ); + await Future.delayed(Duration.zero); + + await scope + .read(clipboardHistoryControllerProvider.notifier) + .refreshCurrentClipboard(); + final current = scope + .read(clipboardHistoryControllerProvider) + .currentClipboard!; + expect(current.kind, BusyMarkClipboardContentKind.richText); + expect(current.text, isNull); + expect(current.html, contains('HTML only')); + }, + ); + test('equal visible text with different structure stays distinct', () async { final scope = container(); await Future.delayed(Duration.zero); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 07962f72..f01dcd93 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -94,8 +94,6 @@ void main() { 'searchQuery': '', 'textDirection': 'ltr', 'canRefresh': false, - 'canExportPdf': false, - 'canExportHtml': false, 'documentControlsVisible': false, 'searchActive': false, 'searchVisible': false, @@ -429,13 +427,7 @@ void main() { expect(_tooltipTheme.toMap(), containsPair('minimumHeight', 30.0)); }); - test('native header labels expose the Syntax Reference command', () { - expect(_labels.toMap(), containsPair('export', 'Export')); - expect(_labels.toMap(), containsPair('exportShortcut', 'Ctrl+Shift+E')); - expect( - _labels.toMap(), - containsPair('exportGtkAccelerator', 'e'), - ); + test('native header labels expose only application-menu commands', () { expect( _labels.toMap(), containsPair('syntaxReference', 'Syntax Reference'), @@ -446,15 +438,16 @@ void main() { containsPair('syntaxReferenceGtkAccelerator', 'F1'), ); expect(_labels.toMap(), isNot(contains('markdownAndHtml'))); - expect( - _labels.toMap(), - containsPair('clipboardHistory', 'Clipboard History'), - ); - expect(_labels.toMap(), containsPair('localHistory', 'Local History')); - expect( - _labels.toMap(), - containsPair('findLocalHistory', 'Find Local History'), - ); + for (final removedLabel in { + 'export', + 'exportShortcut', + 'exportGtkAccelerator', + 'clipboardHistory', + 'localHistory', + 'findLocalHistory', + }) { + expect(_labels.toMap(), isNot(contains(removedLabel))); + } }); } @@ -510,12 +503,6 @@ const _labels = HeaderBarLabels( back: 'Back', backShortcut: 'Alt+Left', save: 'Save', - clipboardHistory: 'Clipboard History', - localHistory: 'Local History', - findLocalHistory: 'Find Local History', - export: 'Export', - exportShortcut: 'Ctrl+Shift+E', - exportGtkAccelerator: 'e', fullScreen: 'Full Screen', fullScreenShortcut: 'F11', fullScreenGtkAccelerator: 'F11', diff --git a/test/src/html_export_ui_test.dart b/test/src/html_export_ui_test.dart index 09345a33..2d6d3967 100644 --- a/test/src/html_export_ui_test.dart +++ b/test/src/html_export_ui_test.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; -import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_main_menu.dart'; import 'package:busymark/src/app/command_registry.dart'; import 'package:busymark/src/export/html_export_models.dart'; @@ -35,61 +34,27 @@ void main() { expect(await registry.execute(BusyMarkCommandIds.export), isTrue); expect(calls, 1); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('"header.export"')); + expect(native, isNot(contains('"header.export"'))); expect(native, isNot(contains('"header.export-html"'))); expect(native, isNot(contains('"header.export-pdf"'))); - expect(native, contains('configuration.can_export_html')); - expect(native, contains('"setCanExportHtml"')); + expect(native, isNot(contains('configuration.can_export_html'))); + expect(native, isNot(contains('"setCanExportHtml"'))); }); - testWidgets('Flutter HTML menu preserves enablement and dispatch', ( - tester, - ) async { - var selected = false; - Future menu(bool enabled) => tester.pumpWidget( + testWidgets('Flutter Main menu excludes document export', (tester) async { + await tester.pumpWidget( ProviderScope( child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: BusyMarkMainMenuButton( - canExportHtml: enabled, - onSelected: (action) => - selected = action == BusyMarkMainMenuAction.export, - ), - ), + home: Scaffold(body: BusyMarkMainMenuButton(onSelected: (_) {})), ), ), ); - await menu(false); await tester.tap(find.byType(BusyMarkMainMenuButton)); await tester.pumpAndSettle(); - final exportItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.value == BusyMarkMainMenuAction.export, - ); - expect( - tester - .widget>(exportItem) - .enabled, - isFalse, - ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await menu(true); - await tester.tap(find.byType(BusyMarkMainMenuButton)); - await tester.pumpAndSettle(); - expect( - tester - .widget>(exportItem) - .enabled, - isTrue, - ); - expect(find.text('Ctrl+Shift+E'), findsOneWidget); - await tester.tap(exportItem); - await tester.pumpAndSettle(); - expect(selected, isTrue); + expect(find.text('Export'), findsNothing); + expect(find.text('Full Screen'), findsOneWidget); }); testWidgets( @@ -227,42 +192,42 @@ void main() { } } -Widget _harness( - HtmlExportService service, - void Function(WidgetRef) ready, -) => ProviderScope( - overrides: [ - htmlExportServiceProvider.overrideWithValue(service), - localSettingsStoreProvider.overrideWithValue(_Settings()), - linuxHeaderBarServiceProvider.overrideWithValue( - LinuxHeaderBarService(channel: const MethodChannel('html-test-header')), - ), - ], - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: buildBusyMarkTheme( - brightness: Brightness.light, - accentColor: Colors.blue, - ), - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - ready(ref); - return Column( - children: [ - TextButton( - onPressed: () => exportWorkspaceToHtml(context, ref), - child: const Text('Run HTML'), - ), - BusyMarkMainMenuButton(canExportHtml: true, onSelected: (_) {}), - ], - ); - }, +Widget _harness(HtmlExportService service, void Function(WidgetRef) ready) => + ProviderScope( + overrides: [ + htmlExportServiceProvider.overrideWithValue(service), + localSettingsStoreProvider.overrideWithValue(_Settings()), + linuxHeaderBarServiceProvider.overrideWithValue( + LinuxHeaderBarService( + channel: const MethodChannel('html-test-header'), + ), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: Scaffold( + body: Consumer( + builder: (context, ref, child) { + ready(ref); + return Column( + children: [ + TextButton( + onPressed: () => exportWorkspaceToHtml(context, ref), + child: const Text('Run HTML'), + ), + BusyMarkMainMenuButton(onSelected: (_) {}), + ], + ); + }, + ), + ), ), - ), - ), -); + ); class _Settings implements LocalSettingsStore { Map data = AppSettings.defaults() diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index c1a61e7c..79a1069c 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -106,9 +106,6 @@ void main() { await sendNativeAction('sidebarToc'); await sendNativeAction('sidebarOutline'); await sendNativeAction('sidebarGit'); - await sendNativeAction('clipboardHistory'); - await sendNativeAction('localHistory'); - await sendNativeAction('findLocalHistory'); expect(events.map((event) => event.action), [ HeaderBarAction.save, @@ -119,23 +116,8 @@ void main() { HeaderBarAction.sidebarToc, HeaderBarAction.sidebarOutline, HeaderBarAction.sidebarGit, - HeaderBarAction.clipboardHistory, - HeaderBarAction.localHistory, - HeaderBarAction.findLocalHistory, - ]); - expect(events.map((event) => event.sequence), [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, ]); + expect(events.map((event) => event.sequence), [1, 2, 3, 4, 5, 6, 7, 8]); expect(events.first, isNot(events.last)); }); diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 10718f81..7af98d7b 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; @@ -9,9 +10,12 @@ import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:busymark/src/workspace/workspace_file_snapshot.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:path/path.dart' as p; void main() { test( @@ -204,6 +208,409 @@ void main() { }, ); + test( + 'retained-document lookup is temporary and normal document selection resumes following', + () async { + final store = MemoryLocalHistoryStore(); + await _capturePath(store, '/workspace/current.md', 'Current source'); + await _capturePath( + store, + '/retired/deleted.md', + 'Unique retained content', + ); + await store.markDeleted('/retired/deleted.md', recursive: false); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + final current = _fileBuffer( + 'current-buffer', + '/workspace/current.md', + 'Current source', + ); + await controller.selectDocumentForBuffer(current); + final currentId = container + .read(localHistoryControllerProvider) + .selectedDocumentId; + + controller.beginDocumentSearch(); + await controller.search('Unique retained'); + var state = container.read(localHistoryControllerProvider); + final deleted = state.snapshot.documents.singleWhere( + (document) => + document.currentPath == '/retired/deleted.md' || + document.historicalPaths.contains('/retired/deleted.md'), + ); + expect(state.documentSearchMatches, {deleted.id}); + controller.inspectRetainedDocument(deleted.id); + state = container.read(localHistoryControllerProvider); + expect(state.inspectingRetainedDocument, isTrue); + expect(state.selectedDocumentId, deleted.id); + expect(state.selectedDocument?.deleted, isTrue); + expect(state.searchQuery, isEmpty); + + await controller.selectDocumentForBuffer(current); + state = container.read(localHistoryControllerProvider); + expect(state.inspectingRetainedDocument, isFalse); + expect(state.findingDocuments, isFalse); + expect(state.selectedDocumentId, currentId); + expect(state.searchQuery, isEmpty); + }, + ); + + testWidgets( + 'panel construction preserves an explicit retained-document inspection', + (tester) async { + final directory = await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-history-inspect-'), + ); + final root = directory!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final currentPath = p.join(root.path, 'current.md'); + await tester.runAsync( + () => File(currentPath).writeAsString('Current source'), + ); + final store = MemoryLocalHistoryStore(); + await tester.runAsync( + () => _capturePath( + store, + '/retired/deleted.md', + 'Unique retained content', + ), + ); + await tester.runAsync( + () => store.markDeleted('/retired/deleted.md', recursive: false), + ); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 2), + ), + ], + ); + addTearDown(container.dispose); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.runAsync(() => workspace.openPath(currentPath)); + final history = container.read(localHistoryControllerProvider.notifier); + await tester.runAsync(history.refresh); + history.beginDocumentSearch(); + await tester.runAsync(() => history.search('Unique retained')); + final retained = container + .read(localHistoryControllerProvider) + .snapshot + .documents + .singleWhere((document) => document.deleted); + history.inspectRetainedDocument(retained.id); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: LocalHistoryPanel()), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = container.read(localHistoryControllerProvider); + expect(state.inspectingRetainedDocument, isTrue); + expect(state.selectedDocumentId, retained.id); + expect(find.text('deleted.md'), findsOneWidget); + }, + ); + + test( + 'delayed revision search cannot repopulate a newly selected document scope', + () async { + final memory = MemoryLocalHistoryStore(); + await _capturePath(memory, '/workspace/a.md', 'Only A matches'); + await _capturePath(memory, '/workspace/b.md', 'Only B matches'); + final snapshot = await memory.load(); + final documentA = snapshot.documents.singleWhere( + (document) => document.currentPath == '/workspace/a.md', + ); + final documentB = snapshot.documents.singleWhere( + (document) => document.currentPath == '/workspace/b.md', + ); + final revisionA = snapshot.revisionsFor(documentA.id).single; + final store = _BlockingRevisionReadStore(memory, revisionA.id); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + controller.selectDocument(documentA.id); + + final search = controller.search('Only A'); + await store.started.future; + controller.selectDocument(documentB.id); + expect(container.read(localHistoryControllerProvider).loading, isFalse); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + documentB.id, + ); + store.release.complete(); + await search; + + final state = container.read(localHistoryControllerProvider); + expect(state.selectedDocument?.currentPath, '/workspace/b.md'); + expect(state.searchQuery, isEmpty); + expect(state.searchMatches, isEmpty); + expect(state.documentSearchMatches, isEmpty); + expect(state.loading, isFalse); + }, + ); + + testWidgets( + 'Local History follows active tabs and clears the visible query without a document dropdown', + (tester) async { + final directory = await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-history-follow-'), + ); + final root = directory!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final aPath = p.join(root.path, 'A.md'); + final bPath = p.join(root.path, 'B.md'); + await tester.runAsync(() async { + await File(aPath).writeAsString('# Unique A\n'); + await File(bPath).writeAsString('# Unique B\n'); + }); + final store = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + ], + ); + addTearDown(container.dispose); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.runAsync(() => workspace.openPath(root.path)); + await tester.runAsync(() => workspace.openActiveFile(bPath)); + + tester.view.physicalSize = const Size(300, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: LocalHistoryPanel()), + ), + ), + ); + await tester.pump(); + await tester.runAsync( + () => _waitForHistory( + container, + (state) => state.selectedDocument?.currentPath == bPath, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('B.md'), findsOneWidget); + expect(find.byType(DropdownButton), findsNothing); + + final searchField = find.byType(TextField); + expect(searchField, findsOneWidget); + await tester.enterText(searchField, 'Unique B'); + await tester.runAsync( + () => _waitForHistory( + container, + (state) => !state.searching && state.searchQuery == 'Unique B', + ), + ); + final selectedBeforeFocus = container + .read(localHistoryControllerProvider) + .selectedDocumentId; + await tester.tap(searchField); + await tester.pump(); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + selectedBeforeFocus, + ); + + await tester.runAsync(() => workspace.openActiveFile(aPath)); + await tester.runAsync( + () => _waitForHistory( + container, + (state) => state.selectedDocument?.currentPath == aPath, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('A.md'), findsOneWidget); + expect( + container.read(localHistoryControllerProvider).searchQuery, + isEmpty, + ); + expect(tester.widget(searchField).controller!.text, isEmpty); + + await tester.tap( + find.byKey(const ValueKey('local-history-actions-menu')), + ); + await tester.pumpAndSettle(); + expect(find.text('Find in Local History…'), findsOneWidget); + expect(find.text('Clear This Document’s History'), findsOneWidget); + expect(find.text('Clear All Local History'), findsOneWidget); + + final aDocumentId = container + .read(localHistoryControllerProvider) + .selectedDocumentId!; + final bDocumentId = (await tester.runAsync( + store.load, + ))!.documents.singleWhere((document) => document.currentPath == bPath).id; + await tester.tap(find.text('Clear This Document’s History')); + await tester.pumpAndSettle(); + await tester.runAsync(() => workspace.openActiveFile(bPath)); + await tester.runAsync( + () => _waitForHistory( + container, + (state) => state.selectedDocumentId == bDocumentId, + ), + ); + await tester.pump(); + final dialogContext = tester.element(find.byType(AlertDialog)); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text(AppLocalizations.of(dialogContext).delete), + ), + ); + await tester.pumpAndSettle(); + final afterClear = await tester.runAsync(store.load); + expect(afterClear!.revisionsFor(aDocumentId), isEmpty); + expect(afterClear.revisionsFor(bDocumentId), isNotEmpty); + }, + ); + + testWidgets( + 'revision rows use local second-precision time, date groups, event metadata, and RTL', + (tester) async { + final directory = await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-history-rows-'), + ); + final root = directory!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final path = p.join(root.path, 'versions.md'); + await tester.runAsync(() => File(path).writeAsString('Disk source\n')); + final store = MemoryLocalHistoryStore(); + final beforeMidnight = DateTime(2026, 1, 1, 23, 59, 58); + final afterMidnight = DateTime(2026, 1, 2, 0, 0, 2); + await tester.runAsync(() async { + await store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: 'versions.md', + source: 'Automatic source', + format: TextFormatMetadata.utf8Lf, + capturedAt: beforeMidnight.toUtc(), + reason: LocalHistoryCaptureReason.automaticCheckpoint, + ), + const LocalHistoryPolicy(), + ); + await store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: 'versions.md', + source: 'Saved source', + format: TextFormatMetadata.utf8Lf, + capturedAt: afterMidnight.toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + }); + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + () => DateTime.utc(2026, 1, 3), + ), + ], + ); + addTearDown(container.dispose); + final workspace = container.read(workspaceControllerProvider.notifier); + await tester.runAsync(() => workspace.openPath(path)); + + tester.view.physicalSize = const Size(300, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('ar'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: LocalHistoryPanel()), + ), + ), + ); + await tester.pump(); + await tester.runAsync( + () => _waitForHistory( + container, + (state) => state.selectedDocument?.currentPath == path, + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(LocalHistoryPanel)); + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final beforeLabel = DateFormat.Hms(locale).format(beforeMidnight); + final afterLabel = DateFormat.Hms(locale).format(afterMidnight); + expect(Directionality.of(context), TextDirection.rtl); + expect(find.text(beforeLabel), findsOneWidget); + expect(find.text(afterLabel), findsOneWidget); + expect(find.text(l10n.localHistoryReasonSaved), findsOneWidget); + expect( + find.text(l10n.localHistoryReasonAutomaticCheckpoint), + findsNothing, + ); + expect(find.text(l10n.localHistoryReasonBaseline), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is Tooltip && + widget.message?.contains( + l10n.localHistoryReasonAutomaticCheckpoint, + ) == + true, + ), + findsOneWidget, + ); + expect( + tester + .getSemantics(find.text(afterLabel)) + .label + .contains(l10n.localHistoryReasonSaved), + isTrue, + ); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + testWidgets( 'a delayed revision read cannot cross the selected document or leave loading stuck', (tester) async { @@ -703,6 +1110,7 @@ ProviderContainer _historyContainer( overrides: [ localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue(() => DateTime.utc(2026, 1, 2)), localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { final timer = _FakeTimer(delay, callback); timers.add(timer); @@ -751,6 +1159,19 @@ Future> _revisionSources( return sources; } +Future _waitForHistory( + ProviderContainer container, + bool Function(LocalHistoryState state) condition, +) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition(container.read(localHistoryControllerProvider))) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Timed out waiting for Local History state'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + class _BlockingRevisionReadStore implements LocalHistoryStore { _BlockingRevisionReadStore(this.delegate, this.blockedRevisionId); diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 54a5c40b..bf9ee897 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -379,6 +379,165 @@ void main() { }, ); + test( + 'missing history restores over an existing file without touching disk and undoes to its real source', + () async { + final anchor = p.join(root.path, 'anchor.md'); + final destination = p.join(root.path, 'destination.md'); + await File(anchor).writeAsString('# Anchor\n'); + final originalBytes = [ + 0xef, + 0xbb, + 0xbf, + ...'Original destination\r\nSecond line'.codeUnits, + ]; + await File(destination).writeAsBytes(originalBytes); + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + p.join(root.path, 'deleted.md'), + '# Recovered\n\nHistorical content.\n', + reason: LocalHistoryCaptureReason.beforeDelete, + ); + final harness = await _harness(store); + await harness.controller.openPath(root.path); + final revision = (await store.readRevision(captured.revision!.id))!; + + expect( + await harness.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ), + isTrue, + ); + final restored = harness.state.activeBuffer!; + expect(restored.filePath, destination); + expect(restored.text, revision.source); + expect(restored.isDirty, isTrue); + expect(restored.format.hasUtf8Bom, isTrue); + expect(restored.format.lineEnding, DocumentLineEnding.crlf); + expect(restored.format.hasFinalNewline, isFalse); + expect(await File(destination).readAsBytes(), originalBytes); + + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, 'Original destination\nSecond line'); + expect(harness.controller.redoActiveBuffer(), isTrue); + expect(harness.state.activeText, revision.source); + }, + ); + + test( + 'existing open destination protects unsaved source as the restore undo baseline', + () async { + final sourcePath = p.join(root.path, 'deleted.md'); + final destination = p.join(root.path, 'open.md'); + final anchor = p.join(root.path, 'anchor.md'); + await File(destination).writeAsString('Disk destination\n'); + await File(anchor).writeAsString('Anchor\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, sourcePath, 'Recovered source\n'); + final harness = await _harness(store); + await harness.controller.openPath(root.path); + await harness.controller.openActiveFile(destination); + harness.controller.updateActiveText('Unsaved destination\n'); + expect(await harness.controller.openActiveFile(anchor), isTrue); + final revision = (await store.readRevision(captured.revision!.id))!; + + expect( + await harness.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ), + isTrue, + ); + expect(harness.state.activeBuffer?.filePath, destination); + expect(harness.state.activeText, 'Recovered source\n'); + expect(await File(destination).readAsString(), 'Disk destination\n'); + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, 'Unsaved destination\n'); + }, + ); + + test( + 'existing destination restore stops when protection fails or the target changes', + () async { + final sourcePath = p.join(root.path, 'deleted.md'); + final destination = p.join(root.path, 'protected.md'); + await File(destination).writeAsString('Protected destination\n'); + final memory = MemoryLocalHistoryStore(); + final captured = await _capture(memory, sourcePath, 'Recovered source\n'); + final revision = (await memory.readRevision(captured.revision!.id))!; + + final failing = await _harness(_FailingProtectiveStore(memory)); + await failing.controller.openPath(destination); + expect( + await failing.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ), + isFalse, + ); + expect(failing.state.activeText, 'Protected destination\n'); + expect(await File(destination).readAsString(), 'Protected destination\n'); + + final blockingStore = _BlockingProtectiveStore(memory); + final changing = await _harness(blockingStore); + await changing.controller.openPath(destination); + final restore = changing.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ); + await blockingStore.started.future; + changing.controller.updateActiveText('Newer destination edit\n'); + blockingStore.release.complete(); + expect(await restore, isFalse); + expect(changing.state.activeText, 'Newer destination edit\n'); + expect(await File(destination).readAsString(), 'Protected destination\n'); + }, + ); + + test( + 'autosaved existing-destination recovery never publishes an empty file', + () async { + final sourcePath = p.join(root.path, 'deleted.md'); + final destination = p.join(root.path, 'autosave.md'); + await File(destination).writeAsString('Original destination\n'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, sourcePath, 'Recovered source\n'); + final service = _RecordingRestoreWorkspaceService(); + final harness = await _harness(store, service: service); + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAutoSave(true); + await harness.controller.openPath(destination); + service.writes.clear(); + final revision = (await store.readRevision(captured.revision!.id))!; + + expect( + await harness.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ), + isTrue, + ); + expect(service.writes, isEmpty); + expect(await harness.controller.autoSaveActiveIfNeeded(), isTrue); + expect(service.writes, [revision.source]); + expect(service.writes, isNot(contains(''))); + expect(await File(destination).readAsString(), revision.source); + }, + ); + test( 'delayed explicit save records exactly the source that was written', () async { @@ -1331,6 +1490,51 @@ class _BlockingBeforeReloadStore extends _FailingProtectiveStore { } } +class _BlockingProtectiveStore extends _FailingProtectiveStore { + _BlockingProtectiveStore(super.delegate); + + 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 delegate.capture(request, policy); + } +} + +class _RecordingRestoreWorkspaceService extends WorkspaceService { + final writes = []; + + @override + Future saveText(String path, String text) async { + writes.add(text); + return super.saveText(path, text); + } + + @override + Future saveNewFormattedText( + String path, + String text, { + TextFormatMetadata? format, + LineEndingNormalization? mixedNormalization, + }) async { + writes.add(text); + return super.saveNewFormattedText( + path, + text, + format: format, + mixedNormalization: mixedNormalization, + ); + } +} + class _BlockingNextReparseWorkspaceService extends WorkspaceService { var _pauseNext = false; final started = Completer(); diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 40f3ac74..c6d716a2 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -90,7 +90,6 @@ void main() { 'title', 'viewMode', 'canRefresh', - 'canExportPdf', 'documentControlsVisible', 'searchActive', 'searchVisible', @@ -255,30 +254,37 @@ void main() { expect(mainMenu, contains('BusyMarkHeaderPopupMenuButton')); expect(mainMenu, isNot(contains('BusyMarkPopupSubmenu'))); expect(mainMenu, contains('tooltip: l10n.mainMenu')); - expect( - mainMenu, - contains('label: command(BusyMarkCommandIds.export).label(context)'), - ); expect(mainMenu, contains('label: l10n.reportIssue')); expect( mainMenu, contains('label: command(BusyMarkCommandIds.fullScreen).label(context)'), ); - expect(mainMenu, contains('enabled: canExportPdf || canExportHtml')); + for (final removed in [ + 'BusyMarkCommandIds.export', + 'generateOrUpdateMarkdownToc', + 'BusyMarkCommandIds.clipboardHistory', + 'BusyMarkCommandIds.localHistory', + 'BusyMarkCommandIds.findLocalHistory', + ]) { + expect(mainMenu, isNot(contains(removed)), reason: removed); + } expect(native, contains('GtkWidget* main_menu_button;')); expect(native, contains('GMenu* main_menu_model;')); expect(native, contains('GSimpleActionGroup* header_action_group;')); expect(native, contains('rebuild_main_menu_model')); expect(native, isNot(contains('g_menu_item_new_submenu'))); - expect(native, contains('localized_label_or(labels, "export", "")')); + expect(native, isNot(contains('localized_label_or(labels, "export", "")'))); expect(native, contains('"header.keyboard-shortcuts"')); - expect(native, contains('"header.export"')); + expect(native, isNot(contains('"header.export"'))); expect(native, isNot(contains('"header.export-pdf"'))); expect(native, isNot(contains('"header.export-html"'))); expect(native, contains('"header.full-screen"')); expect(native, contains('full_screen_gaction_activated_cb')); expect(native, contains('configuration.full_screen')); - expect(native, contains('configuration.can_export_pdf')); + expect(native, isNot(contains('configuration.can_export_pdf'))); + expect(native, isNot(contains('"header.clipboard-history"'))); + expect(native, isNot(contains('"header.local-history"'))); + expect(native, isNot(contains('"header.find-local-history"'))); expect(native, contains('"header.syntax-reference"')); expect(native, isNot(contains('"header.markdown-and-html"'))); expect(native, contains('"header.report-issue"')); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index ea9284ae..96f3fd4a 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1099,7 +1099,13 @@ void main() { workspace, contains("ValueKey('workspace-sidebar-outline-file-menu')"), ); - expect(workspace, contains('copyNameLabel: menuContext.l10n.copyFileName')); + expect(workspace, contains('_outlineDocumentMenuItems(')); + expect(workspace, contains('label: context.l10n.copyFileName')); + expect( + workspace, + contains('label: context.l10n.generateOrUpdateMarkdownToc'), + ); + expect(workspace, contains('label: context.l10n.export')); expect(workspace, contains('tooltip: context.l10n.actions')); expect(workspace, isNot(contains('tooltip: context.l10n.openInFiles'))); expect(workspace, contains('icon: WorkspaceGlyphs.branch')); diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 8c1dee21..8e542263 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -15,6 +15,7 @@ import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -1648,6 +1649,160 @@ void main() { expect(harness.controller.state.activeText, '# Original\n'); }); + test( + 'workspace refresh preserves an active edit and editor history made during reparse', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-refresh-active-edit-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'a.md'); + await File(path).writeAsString('# Disk\n'); + final service = _BlockingRefreshWorkspaceService(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + await harness.controller.openPath(directory.path); + + service.pauseNextReparse(); + final refresh = harness.controller.refreshWorkspaceFromDisk(); + await service.reparseStarted.future; + harness.controller.updateActiveEditorState( + harness.controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 2), + ), + ); + harness.controller.updateActiveSourceText('# Newer one\n'); + harness.controller.updateActiveEditorState( + harness.controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 7), + ), + ); + harness.controller.updateActiveSourceText('# Newer two\n'); + expect(harness.controller.undoActiveBuffer(), isTrue); + final duringRefresh = harness.controller.state.activeBuffer!; + expect(duringRefresh.editorState.undoState.redo, isNotEmpty); + + service.releaseReparse(); + expect(await refresh, isTrue); + final after = harness.controller.state.activeBuffer!; + expect(after.text, '# Newer one\n'); + expect(after.isDirty, isTrue); + expect(after.editorState.selection, duringRefresh.editorState.selection); + expect( + after.editorState.undoState.undo, + duringRefresh.editorState.undoState.undo, + ); + expect( + after.editorState.undoState.redo, + duringRefresh.editorState.undoState.redo, + ); + expect(harness.controller.redoActiveBuffer(), isTrue); + expect(harness.controller.state.activeText, '# Newer two\n'); + }, + ); + + test( + 'workspace refresh preserves inactive edits and does not resurrect a closed tab', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-refresh-tabs-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + final service = _BlockingRefreshWorkspaceService(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + await harness.controller.openPath(directory.path); + expect(await harness.controller.openActiveFile(bPath), isTrue); + final bId = harness.controller.state.activeBuffer!.id; + expect(await harness.controller.openActiveFile(aPath), isTrue); + final aId = harness.controller.state.activeBuffer!.id; + + service.pauseNextReparse(); + final refresh = harness.controller.refreshWorkspaceFromDisk(); + await service.reparseStarted.future; + expect(harness.controller.updateDocumentText(bId, '# B one\n'), isTrue); + expect(harness.controller.updateDocumentText(bId, '# B two\n'), isTrue); + expect(await harness.controller.activateDocumentBuffer(bId), isTrue); + expect(harness.controller.undoActiveBuffer(), isTrue); + final editedB = harness.controller.state.activeBuffer!; + expect(await harness.controller.activateDocumentBuffer(aId), isTrue); + expect(await harness.controller.closeDocumentBuffer(aId), isTrue); + + service.releaseReparse(); + expect(await refresh, isTrue); + expect( + harness.controller.state.documentBuffers.map((buffer) => buffer.id), + [bId], + ); + final after = harness.controller.state.activeBuffer!; + expect(after.text, '# B one\n'); + expect(after.isDirty, isTrue); + expect( + after.editorState.undoState.undo, + editedB.editorState.undoState.undo, + ); + expect( + after.editorState.undoState.redo, + editedB.editorState.undoState.redo, + ); + expect(harness.controller.state.workspace?.activeFilePath, bPath); + expect(harness.controller.state.workspace?.markdown?.filePath, bPath); + expect(harness.controller.state.workspace?.markdown?.source, '# B one\n'); + }, + ); + + test('workspace refresh rejects a disk load superseded by typing', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-refresh-load-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'a.md'); + await File(path).writeAsString('# Initial\n'); + final service = _BlockingRefreshWorkspaceService(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + await harness.controller.openPath(directory.path); + await File(path).writeAsString('# Disk replacement\n'); + + service.pauseNextLoad(path); + final refresh = harness.controller.refreshWorkspaceFromDisk(); + await service.loadStarted.future; + harness.controller.updateActiveEditorState( + harness.controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 3), + ), + ); + harness.controller.updateActiveSourceText('# Typed while loading\n'); + final edited = harness.controller.state.activeBuffer!; + service.releaseLoad(); + + expect(await refresh, isTrue); + final after = harness.controller.state.activeBuffer!; + expect(after.text, edited.text); + expect(after.isDirty, isTrue); + expect(after.editorState.selection, edited.editorState.selection); + expect(after.editorState.undoState.undo, edited.editorState.undoState.undo); + }); + test('external file moves remap the open document buffer', () async { final directory = await Directory.systemTemp.createTemp( 'busymark-external-move-', @@ -2175,6 +2330,9 @@ class _WorkspaceControllerDriver { Future closeAllOpenFileTabs() => _notifier.closeAllOpenFileTabs(); + Future activateDocumentBuffer(String bufferId) => + _notifier.activateDocumentBuffer(bufferId); + void updateActiveText(String text, {String? sourceFilePath}) { _notifier.updateActiveText(text, sourceFilePath: sourceFilePath); } @@ -2188,6 +2346,16 @@ class _WorkspaceControllerDriver { ); } + void updateActiveEditorState(DocumentEditorState editorState) => + _notifier.updateActiveEditorState(editorState); + + bool updateDocumentText(String bufferId, String text) => + _notifier.updateDocumentText(bufferId, text); + + bool undoActiveBuffer() => _notifier.undoActiveBuffer(); + + bool redoActiveBuffer() => _notifier.redoActiveBuffer(); + void updateActiveEditorMode(DocumentViewModePreference mode) { _notifier.updateActiveEditorMode(mode); } @@ -2607,3 +2775,47 @@ class _GatedRefreshWorkspaceService extends WorkspaceService { return super.openPath(path); } } + +class _BlockingRefreshWorkspaceService extends WorkspaceService { + var _pauseReparse = false; + String? _pausedLoadPath; + final reparseStarted = Completer(); + final loadStarted = Completer(); + final _reparseRelease = Completer(); + final _loadRelease = Completer(); + + void pauseNextReparse() => _pauseReparse = true; + + void releaseReparse() { + if (!_reparseRelease.isCompleted) _reparseRelease.complete(); + } + + void pauseNextLoad(String path) => _pausedLoadPath = p.normalize(path); + + void releaseLoad() { + if (!_loadRelease.isCompleted) _loadRelease.complete(); + } + + @override + Future loadTextWithSnapshot(String path) async { + final loaded = await super.loadTextWithSnapshot(path); + if (_pausedLoadPath != null && + p.equals(_pausedLoadPath!, p.normalize(path))) { + _pausedLoadPath = null; + if (!loadStarted.isCompleted) loadStarted.complete(); + await _loadRelease.future; + } + return loaded; + } + + @override + Future reparseActive(Workspace workspace, String source) async { + final reparsed = await super.reparseActive(workspace, source); + if (_pauseReparse) { + _pauseReparse = false; + if (!reparseStarted.isCompleted) reparseStarted.complete(); + await _reparseRelease.future; + } + return reparsed; + } +} diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 1d4d5b64..99dafc2a 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'dart:convert'; import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/assets/asset_input_service.dart'; import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; import 'package:busymark/src/clipboard/clipboard_insertion.dart'; import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart'; @@ -17,6 +20,7 @@ import 'package:busymark/src/platform/rich_clipboard_service.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:flutter_test/flutter_test.dart'; import 'package:html/parser.dart' as html; @@ -651,9 +655,42 @@ void main() { 'linked

    • Item
    ', 'text': 'External\nBold and linked\nItem', }; + final historyClipboard = _WysiwygHistoryClipboard( + const RichClipboardData( + html: + '

    External

    Bold and ' + 'linked

    • Item
    ', + text: 'External\nBold and linked\nItem', + generation: 31, + ), + ); + final historyScope = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _ClipboardMemorySettingsStore(), + ), + richClipboardServiceProvider.overrideWithValue(historyClipboard), + clipboardAssetInputServiceProvider.overrideWithValue( + _ClipboardEmptyAssetInput(), + ), + ], + ); + addTearDown(historyScope.dispose); + final history = historyScope.read( + clipboardHistoryControllerProvider.notifier, + ); + await history.refreshCurrentClipboard(); + final current = historyScope + .read(clipboardHistoryControllerProvider) + .currentClipboard!; + expect(current.html, contains('

    External

    ')); + expect( + historyScope.read(clipboardHistoryControllerProvider).entries, + isEmpty, + ); + final registry = BusyMarkClipboardInsertionRegistry(); addTearDown(registry.dispose); - BusyMarkClipboardCapture? capture; var first = ''; await mount( tester, @@ -661,14 +698,25 @@ void main() { 'Target\n', (value) => first = value, registry: registry, - onCaptured: (value) => capture = value, ); await key(tester, LogicalKeyboardKey.keyA); - await key(tester, LogicalKeyboardKey.keyV); + expect(await registry.paste(current), ClipboardPasteResult.inserted); + await tester.pump(); expect(first, contains('**Bold**')); - expect(capture?.richFragment, isNotNull); + expect(first, contains('[linked](https://example.com)')); + expect(first, contains('- Item')); + history.retainCurrentAfterPaste(current); + expect( + historyScope.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); systemData = {'text': 'Replacement clipboard'}; + historyClipboard.value = const RichClipboardData( + text: 'Replacement clipboard', + generation: 32, + ); + await history.refreshCurrentClipboard(); var retained = ''; await mount( tester, @@ -678,30 +726,107 @@ void main() { registry: registry, ); await key(tester, LogicalKeyboardKey.keyA); - final value = capture!; - expect( - await registry.paste( - BusyMarkClipboardPayload( - id: 'external-html', - acquiredAt: DateTime.utc(2026), - kind: value.kind, - text: value.text, - sourceText: value.sourceText, - html: value.html, - richFragment: value.richFragment, - origin: value.origin, - external: true, - ), - ), - ClipboardPasteResult.inserted, - ); + final value = historyScope + .read(clipboardHistoryControllerProvider) + .entries + .single; + expect(await registry.paste(value), ClipboardPasteResult.inserted); await tester.pump(); expect(retained, contains('# External')); expect(retained, contains('**Bold**')); expect(retained, contains('[linked](https://example.com)')); expect(retained, contains('- Item')); + + var plain = ''; + await mount( + tester, + 'plain', + 'Target\n', + (value) => plain = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + expect( + await registry.paste(current, plainText: true), + ClipboardPasteResult.inserted, + ); + await tester.pump(); + expect(plain, contains('External')); + expect(plain, contains('Bold and linked')); + expect(plain, contains('Item')); + expect(plain, isNot(contains('**Bold**'))); + expect(plain, isNot(contains('[linked]'))); }); + testWidgets( + 'current HTML-only and malformed HTML use ordinary importer fallbacks', + (tester) async { + final service = _WysiwygHistoryClipboard( + const RichClipboardData( + html: '

    HTML only

    Supported

    ', + generation: 33, + ), + ); + final historyScope = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + _ClipboardMemorySettingsStore(), + ), + richClipboardServiceProvider.overrideWithValue(service), + clipboardAssetInputServiceProvider.overrideWithValue( + _ClipboardEmptyAssetInput(), + ), + ], + ); + addTearDown(historyScope.dispose); + final history = historyScope.read( + clipboardHistoryControllerProvider.notifier, + ); + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + + await history.refreshCurrentClipboard(); + final htmlOnly = historyScope + .read(clipboardHistoryControllerProvider) + .currentClipboard!; + var htmlOnlyResult = ''; + await mount( + tester, + 'html-only-current', + 'Target\n', + (value) => htmlOnlyResult = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + expect(await registry.paste(htmlOnly), ClipboardPasteResult.inserted); + await tester.pump(); + expect(htmlOnlyResult, contains('## HTML only')); + expect(htmlOnlyResult, contains('*Supported*')); + + service.value = const RichClipboardData( + html: '

    ', + text: 'Safe text alternative', + generation: 34, + ); + await history.refreshCurrentClipboard(); + final malformed = historyScope + .read(clipboardHistoryControllerProvider) + .currentClipboard!; + var fallback = ''; + await mount( + tester, + 'malformed-current', + 'Target\n', + (value) => fallback = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + expect(await registry.paste(malformed), ClipboardPasteResult.inserted); + await tester.pump(); + expect(fallback, contains('Safe text alternative')); + }, + ); + testWidgets('Ctrl+Shift+V is not an Editor paste command', (tester) async { await copyAll(tester); var result = 'Target\n'; @@ -769,3 +894,34 @@ void main() { }); }); } + +class _ClipboardMemorySettingsStore implements LocalSettingsStore { + Map value = {}; + + @override + Future> load() async => value; + + @override + Future save(Map json) async => value = json; +} + +class _ClipboardEmptyAssetInput extends AssetInputService { + _ClipboardEmptyAssetInput() + : super(channel: const MethodChannel('busymark.test/wysiwyg-assets')); + + @override + Future> readClipboardImageFiles() async => const []; + + @override + Future readClipboardImagePng() async => null; +} + +class _WysiwygHistoryClipboard extends RichClipboardService { + _WysiwygHistoryClipboard(this.value) + : super(channel: const MethodChannel('busymark.test/wysiwyg-history')); + + RichClipboardData value; + + @override + Future read() async => value; +} diff --git a/tools/history_visual_smoke.dart b/tools/history_visual_smoke.dart index 246416b8..418479d8 100644 --- a/tools/history_visual_smoke.dart +++ b/tools/history_visual_smoke.dart @@ -8,15 +8,18 @@ import 'dart:ui' as ui; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/command_registry.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; +import 'package:busymark/src/clipboard/clipboard_history_panel.dart'; import 'package:busymark/src/clipboard/clipboard_models.dart'; import 'package:busymark/src/comparison/source_comparison.dart'; import 'package:busymark/src/git/application/git_controller.dart'; import 'package:busymark/src/git/data/git_cli_gateway.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/local_history/local_history_panel.dart'; import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/platform/rich_clipboard_service.dart'; @@ -59,7 +62,7 @@ Future main(List arguments) async { ? BusyMarkThemeModePreference.dark : BusyMarkThemeModePreference.light, localeTag: mode == 'writerside' ? 'ar' : 'en', - documentViewMode: DocumentViewModePreference.source, + documentViewMode: DocumentViewModePreference.editor, previewVisible: false, editorFontSize: mode == 'writerside' ? 20 : 15, autoSave: false, @@ -84,6 +87,9 @@ Future main(List arguments) async { documentRecoveryStoreProvider.overrideWithValue( MemoryDocumentRecoveryStore(), ), + richClipboardServiceProvider.overrideWithValue( + _RuntimeExternalClipboardService(), + ), localHistoryStoreProvider.overrideWithValue( FileLocalHistoryStore( rootDirectory: () async => Directory(p.join(output.path, 'store')), @@ -164,17 +170,38 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { () => ref.read(workspaceControllerProvider).activeBuffer != null, 'active document', ); + await _waitFor( + () => _hasWidgetNamed('_Sidebar'), + 'workspace sidebar listeners', + ); await Future.delayed(const Duration(milliseconds: 500)); await _seedClipboard(); - ref.read(clipboardHistoryOpenRequestProvider.notifier).request(); + _check( + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.clipboardHistory), + 'Clipboard History command', + ); + await _waitFor( + _hasWidget, + 'Clipboard History view', + ); await _capture('${widget.mode}-clipboard-history-1280x800.png'); + await _exerciseCurrentClipboard(); var buffer = ref.read(workspaceControllerProvider).activeBuffer!; await localHistory.observeOpened(buffer); final baseline = buffer.text; final saved = _savedVersion(baseline); workspace.updateActiveText(saved, sourceFilePath: buffer.filePath); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + _check( + await localHistory.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + ), + 'saved event captured', + ); _check( await workspace.saveActive(overwriteExternalChanges: true), 'explicit save captured', @@ -194,7 +221,22 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { buffer = ref.read(workspaceControllerProvider).activeBuffer!; await localHistory.selectDocumentForBuffer(buffer); await localHistory.refresh(); - ref.read(localHistoryOpenRequestProvider.notifier).request(); + _check( + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.localHistory), + 'Local History command', + ); + await _waitFor(_hasWidget, 'Local History view'); + await _waitFor( + () => + !ref.read(localHistoryControllerProvider).loading && + ref + .read(localHistoryControllerProvider) + .selectedRevisions + .isNotEmpty, + 'Local History revisions', + ); await _capture('${widget.mode}-local-history-1280x800.png'); final historyState = ref.read(localHistoryControllerProvider); @@ -261,15 +303,17 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { LocalHistoryCaptureReason.automaticCheckpoint, LocalHistoryCaptureReason.beforeRestore, }); + final passed = _checks.values.every((value) => value); await File( p.join(widget.output.path, '${widget.mode}-report.json'), ).writeAsString( const JsonEncoder.withIndent(' ').convert({ - 'passed': _checks.values.every((value) => value), + 'passed': passed, 'checks': _checks, 'screenshots': _screenshots, }), ); + if (!passed) exitCode = 1; await workspace.discardRecoveryForShutdown(); await Future.delayed(const Duration(milliseconds: 300)); await SystemNavigator.pop(); @@ -290,20 +334,28 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { documentPath: buffer.filePath, ); final controller = ref.read(clipboardHistoryControllerProvider.notifier); - const sourceSelection = - '## Safe deployment\n\nUse **staged rollout** with a rollback plan.'; + const externalText = + 'Safe deployment\n\nUse staged rollout with a rollback plan.\n\n' + 'Verify health\nKeep the prior package'; + const externalHtml = + '

    Safe deployment

    ' + '

    Use staged rollout with a ' + 'rollback plan.

    ' + '
    • Verify health
    • Keep the prior package
    '; final nativeWrite = await ref .read(richClipboardServiceProvider) - .write( - RichClipboardData( - text: 'Safe deployment\n\nUse staged rollout with a rollback plan.', - sourceText: sourceSelection, - html: - '

    Safe deployment

    Use staged rollout with a rollback plan.

    ', - origin: origin, - ), - ); + .write(const RichClipboardData(text: externalText, html: externalHtml)); _check(nativeWrite, 'native clipboard publication'); + await controller.refreshCurrentClipboard(); + final current = ref + .read(clipboardHistoryControllerProvider) + .currentClipboard; + _check( + current?.html == externalHtml && current?.text == externalText, + 'current external HTML retained before paste', + ); + const sourceSelection = + '## Safe deployment\n\nUse **staged rollout** with a rollback plan.'; controller.retain( BusyMarkClipboardCapture( kind: BusyMarkClipboardContentKind.richText, @@ -374,6 +426,68 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { } } + Future _exerciseCurrentClipboard() async { + const externalHtml = + '

    Safe deployment

    ' + '

    Use staged rollout with a ' + 'rollback plan.

    ' + '
    • Verify health
    • Keep the prior package
    '; + final controller = ref.read(clipboardHistoryControllerProvider.notifier); + final current = ref + .read(clipboardHistoryControllerProvider) + .currentClipboard!; + await _waitFor( + () => ref.read(clipboardInsertionRegistryProvider).target != null, + 'clipboard insertion target', + ); + final firstInsertion = await ref + .read(clipboardInsertionRegistryProvider) + .paste(current, plainText: false); + _check( + firstInsertion == ClipboardPasteResult.inserted, + 'current external HTML inserted', + ); + controller.retainCurrentAfterPaste(current); + await Clipboard.setData(const ClipboardData(text: 'Clipboard replaced')); + await Future.delayed(const Duration(milliseconds: 700)); + await WidgetsBinding.instance.endOfFrame; + await _waitFor( + () => ref.read(clipboardInsertionRegistryProvider).target != null, + 'clipboard insertion target after first paste', + ); + final retained = ref + .read(clipboardHistoryControllerProvider) + .entries + .firstWhere((entry) => entry.html == externalHtml); + final secondInsertion = await ref + .read(clipboardInsertionRegistryProvider) + .paste(retained, plainText: false); + _check( + secondInsertion == ClipboardPasteResult.inserted, + 'retained external HTML reused after clipboard replacement', + ); + final insertedSource = ref + .read(workspaceControllerProvider) + .activeBuffer! + .text; + _check( + RegExp( + r'^## Safe deployment$', + multiLine: true, + ).allMatches(insertedSource).length >= + 2, + 'external HTML heading structure preserved', + ); + _check( + insertedSource.contains('**staged rollout**') && + insertedSource.contains( + '[rollback plan](https://example.test/rollback)', + ) && + insertedSource.contains('- Verify health'), + 'external HTML inline and list structure preserved', + ); + } + Future _verifyDeletedRecovery( WorkspaceController workspace, LocalHistoryController localHistory, @@ -455,6 +569,36 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { _screenshots.add(path); } + bool _hasWidget() { + var found = false; + void visit(Element element) { + if (found) return; + if (element.widget is T) { + found = true; + return; + } + element.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return found; + } + + bool _hasWidgetNamed(String typeName) { + var found = false; + void visit(Element element) { + if (found) return; + if (element.widget.runtimeType.toString() == typeName) { + found = true; + return; + } + element.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement?.visitChildren(visit); + return found; + } + Future _waitFor(bool Function() condition, String description) async { for (var attempt = 0; attempt < 240; attempt++) { if (condition()) return; @@ -482,3 +626,23 @@ class _MemorySettingsStore implements LocalSettingsStore { value = json; } } + +class _RuntimeExternalClipboardService extends RichClipboardService { + final RichClipboardService _native = RichClipboardService(); + RichClipboardData? _externalRead; + + @override + Future write(RichClipboardData data) async { + final written = await _native.write(data); + if (written) { + _externalRead = RichClipboardData(text: data.text, html: data.html); + } + return written; + } + + @override + Future read() async { + final external = _externalRead; + return external ?? await _native.read(); + } +} From db3a987267f06e4de020a22e2367a6cde3bb333a Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 15:07:15 -0700 Subject: [PATCH 14/27] Add support for transient trailing paragraphs in WYSIWYG editor and enhance code block handling - Implemented transient trailing paragraphs to improve editor behavior after terminal non-paragraph blocks. - Enhanced logic for exiting code blocks, including automatic insertion of trailing paragraphs. - Refactored block attribute handling with `_sourceBackedSplitAttributes` for better consistency. - Added control key shortcuts to facilitate seamless transitions from code blocks. - Updated serializer to handle transient trailing paragraphs without altering the Markdown structure until content is entered. --- .../wysiwyg/wysiwyg_document_controller.dart | 169 +++++++++++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 24 ++ lib/src/markdown/busymark_document.dart | 8 + .../busymark_markdown_serializer.dart | 10 +- test/src/wysiwyg_code_block_editing_test.dart | 242 ++++++++++++++++++ 5 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 test/src/wysiwyg_code_block_editing_test.dart diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 7169c013..09520e9f 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -66,6 +66,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { String get markdown => _serializer.serialize(_document); + @override + void notifyListeners() { + _document = _ensureEditableDocument(_document); + super.notifyListeners(); + } + void replaceDocument(BusyDocument document) { _document = _ensureEditableDocument(document); notifyListeners(); @@ -646,7 +652,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { replacements.add( block.copyWith( inlines: inlines, - attributes: _attributesForText(block.attributes, block.kind, part), + attributes: _attributesForText( + _sourceBackedSplitAttributes(block.attributes), + block.kind, + part, + ), preserveRaw: false, dirty: true, ), @@ -659,7 +669,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { kind: splitKind, inlines: inlines, attributes: _attributesForText( - _splitAttributesFor(block, splitKind, orderedOffset: index), + _sourceBackedSplitAttributes( + _splitAttributesFor(block, splitKind, orderedOffset: index), + ), splitKind, part, ), @@ -1521,7 +1533,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ? _textInlines('') : inlinePartition.before, children: block.children, - attributes: _attributesForText(block.attributes, block.kind, leftText), + attributes: _attributesForText( + _sourceBackedSplitAttributes(block.attributes), + block.kind, + leftText, + ), dirty: true, ); final nextBlock = BusyBlock( @@ -1531,7 +1547,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ? _textInlines('') : inlinePartition.after, attributes: _attributesForText( - _splitAttributesFor(block, nextKind, orderedOffset: 1), + _sourceBackedSplitAttributes( + _splitAttributesFor(block, nextKind, orderedOffset: 1), + ), nextKind, rightText, ), @@ -1552,6 +1570,30 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (block == null) { return null; } + if (block.kind == BusyBlockKind.codeBlock) { + final text = block.plainText; + final safeOffset = offset.clamp(0, text.length).toInt(); + if (safeOffset == text.length && text.endsWith('\n\n')) { + final updated = _blockWithEditedText( + block, + text.substring(0, text.length - 2), + ); + final paragraphId = _exitCodeBlockAfter(block, replacement: updated); + return BusyWysiwygTextSplitResult(blockId: paragraphId, offset: 0); + } + final updated = _blockWithEditedText( + block, + text.replaceRange(safeOffset, safeOffset, '\n'), + ); + _document = _document.copyWith( + blocks: _replaceInBlocks(_document.blocks, blockId, (_) => updated), + ); + notifyListeners(); + return BusyWysiwygTextSplitResult( + blockId: blockId, + offset: safeOffset + 1, + ); + } if (_isListItemKind(block.kind) && block.plainText.trim().isEmpty) { _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); @@ -1563,6 +1605,46 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return BusyWysiwygTextSplitResult(blockId: nextBlockId, offset: 0); } + /// Creates a normal caret target after a code block without changing the + /// serialized Markdown until the user types into it. + String? exitCodeBlock(String blockId) { + final block = blockById(blockId); + if (block == null || block.kind != BusyBlockKind.codeBlock) { + return null; + } + return _exitCodeBlockAfter(block); + } + + String _exitCodeBlockAfter(BusyBlock block, {BusyBlock? replacement}) { + final following = _followingSibling(_document.blocks, block.id); + if (following != null && + following.kind == BusyBlockKind.paragraph && + following.plainText.isEmpty) { + if (replacement != null) { + _document = _document.copyWith( + blocks: _replaceInBlocks( + _document.blocks, + block.id, + (_) => replacement, + ), + ); + notifyListeners(); + } + return following.id; + } + final paragraph = _transientTrailingParagraph( + _nextGeneratedBlockId('paragraph'), + ); + _document = _document.copyWith( + blocks: _replaceBlockWithMany(_document.blocks, block.id, [ + replacement ?? block, + paragraph, + ]), + ); + notifyListeners(); + return paragraph.id; + } + BusyWysiwygTextSplitResult? applyBackspaceAtStart(String blockId) { final blocks = _document.blocks; final index = blocks.indexWhere((block) => block.id == blockId); @@ -2430,12 +2512,21 @@ Map _attributesForText( ) { final updated = {...attributes} ..remove(busyMarkPreserveEmptyParagraphAttribute); + if (text.isNotEmpty) { + updated.remove(busyMarkTransientTrailingParagraphAttribute); + } if (kind == BusyBlockKind.paragraph && text.isEmpty) { updated[busyMarkPreserveEmptyParagraphAttribute] = 'true'; } return updated; } +Map _sourceBackedSplitAttributes( + Map attributes, +) { + return {...attributes}..remove(busyMarkTransientTrailingParagraphAttribute); +} + Map _mathEditedBlockAttributes( BusyBlock current, BusyBlock parsed, { @@ -2888,7 +2979,8 @@ BusyBlock _blockWithCommand( ..remove(busyMarkWritersideAdmonitionAttribute) ..remove(busyMarkWritersideAdmonitionSourceFormAttribute) ..remove('style') - ..remove(busyMarkPreserveEmptyParagraphAttribute); + ..remove(busyMarkPreserveEmptyParagraphAttribute) + ..remove(busyMarkTransientTrailingParagraphAttribute); if (block.kind == BusyBlockKind.writersideAdmonition) { attributes.remove('element'); } @@ -3283,21 +3375,70 @@ BusyDocument _ensureEditableDocument(BusyDocument document) { !block.isSourceOnly && !block.isSourceProtected, ); - if (hasEditableBlock) { + if (!hasEditableBlock) { + return document.copyWith( + blocks: [ + ...document.blocks, + const BusyBlock( + id: 'empty-paragraph', + kind: BusyBlockKind.paragraph, + inlines: [BusyInline(kind: BusyInlineKind.text, text: '')], + ), + ], + ); + } + final visibleBlocks = document.blocks + .where( + (block) => + block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, + ) + .toList(growable: false); + if (visibleBlocks.isEmpty || + visibleBlocks.last.kind != BusyBlockKind.codeBlock) { return document; } + final ids = {for (final block in _flattenBlocks(document.blocks)) block.id}; + var suffix = 0; + var id = 'trailing-paragraph'; + while (ids.contains(id)) { + suffix++; + id = 'trailing-paragraph-$suffix'; + } return document.copyWith( - blocks: [ - ...document.blocks, - const BusyBlock( - id: 'empty-paragraph', - kind: BusyBlockKind.paragraph, - inlines: [BusyInline(kind: BusyInlineKind.text, text: '')], - ), - ], + blocks: [...document.blocks, _transientTrailingParagraph(id)], + ); +} + +BusyBlock _transientTrailingParagraph(String id) { + return BusyBlock( + id: id, + kind: BusyBlockKind.paragraph, + inlines: const [BusyInline(kind: BusyInlineKind.text, text: '')], + attributes: const {busyMarkTransientTrailingParagraphAttribute: 'true'}, ); } +Iterable _flattenBlocks(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _flattenBlocks(block.children); + } +} + +BusyBlock? _followingSibling(List blocks, String blockId) { + for (var index = 0; index < blocks.length; index++) { + final block = blocks[index]; + if (block.id == blockId) { + return index + 1 < blocks.length ? blocks[index + 1] : null; + } + final childResult = _followingSibling(block.children, blockId); + if (childResult != null) { + return childResult; + } + } + return null; +} + bool _selectionCoveredByKind( List ranges, BusyInlineKind kind, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index fa73fcca..d717b86c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -2608,6 +2608,21 @@ class _BusyMarkWysiwygEditorState extends State { _applyEditorShortcutAction(shortcutAction); return KeyEventResult.handled; } + if (event is KeyDownEvent && + key == LogicalKeyboardKey.enter && + keyboard.isControlPressed && + !keyboard.isAltPressed && + !keyboard.isMetaPressed && + !keyboard.isShiftPressed) { + final block = _documentController.blockById(blockId); + if (block?.kind == BusyBlockKind.codeBlock) { + final paragraphId = _documentController.exitCodeBlock(blockId); + if (paragraphId != null) { + _focusBlockAfterFrame(paragraphId, offset: 0); + return KeyEventResult.handled; + } + } + } if (keyboard.isControlPressed && !keyboard.isAltPressed && !keyboard.isMetaPressed && @@ -2731,6 +2746,15 @@ class _BusyMarkWysiwygEditorState extends State { return _moveCaretVertically(blockId, -1); } if (key == LogicalKeyboardKey.arrowDown) { + if (block.kind == BusyBlockKind.codeBlock && + offset == controller.text.length && + _relativeFocusableBlock(blockId, 1) == null) { + final paragraphId = _documentController.exitCodeBlock(blockId); + if (paragraphId != null) { + _focusBlockAfterFrame(paragraphId, offset: 0); + return KeyEventResult.handled; + } + } return _moveCaretVertically(blockId, 1); } if (key == previousBlockKey && offset == 0) { diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 0e628930..e9284a17 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -5,6 +5,14 @@ import 'markdown_model.dart'; /// Marks an empty WYSIWYG paragraph that must remain a source blank line. const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph'; +/// Marks the editor-only paragraph after a terminal non-paragraph block. +/// +/// The Markdown serializer ignores this block while it is empty. The marker is +/// removed as soon as the user enters content, at which point it becomes an +/// ordinary source-backed paragraph. +const busyMarkTransientTrailingParagraphAttribute = + 'transientTrailingParagraph'; + const busyMarkWritersideAdmonitionAttribute = 'writersideAdmonition'; const busyMarkWritersideAdmonitionSourceFormAttribute = 'writersideAdmonitionSourceForm'; diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 9c978bb1..8397b6b8 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -292,7 +292,15 @@ class BusyMarkMarkdownSerializer { } bool _isSourceBackedBlock(BusyBlock block) { - return block.kind != BusyBlockKind.frontMatter && !block.isGenerated; + return block.kind != BusyBlockKind.frontMatter && + !block.isGenerated && + !_isTransientTrailingParagraph(block); + } + + bool _isTransientTrailingParagraph(BusyBlock block) { + return block.kind == BusyBlockKind.paragraph && + block.plainText.isEmpty && + block.attributes[busyMarkTransientTrailingParagraphAttribute] == 'true'; } bool _isPreservedEmptyParagraph(BusyBlock block) { diff --git a/test/src/wysiwyg_code_block_editing_test.dart b/test/src/wysiwyg_code_block_editing_test.dart new file mode 100644 index 00000000..1ca8baa7 --- /dev/null +++ b/test/src/wysiwyg_code_block_editing_test.dart @@ -0,0 +1,242 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const parser = MarkdownParser(); + const source = '```javascript\nline();\n```\n'; + + BusyMarkWysiwygDocumentController open([String markdown = source]) { + return BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'code.md', source: markdown) + .busyDocument, + ); + } + + test('terminal code block gets a non-serializing trailing paragraph', () { + for (final markdown in [source, source.trimRight()]) { + final controller = open(markdown); + + expect(controller.document.blocks, hasLength(2)); + expect(controller.document.blocks.first.kind, BusyBlockKind.codeBlock); + expect(controller.document.blocks.last.kind, BusyBlockKind.paragraph); + expect( + controller + .document + .blocks + .last + .attributes[busyMarkTransientTrailingParagraphAttribute], + 'true', + ); + expect(controller.markdown, markdown); + } + }); + + test('Enter inserts a newline within one code block', () { + final controller = open(); + final code = controller.document.blocks.first; + + final result = controller.applyEnterAt(code.id, 4); + + expect(result?.blockId, code.id); + expect(result?.offset, 5); + expect(controller.blockText(code.id), 'line\n();'); + expect( + controller.document.blocks.where( + (block) => block.kind == BusyBlockKind.codeBlock, + ), + hasLength(1), + ); + expect(controller.markdown, '```javascript\nline\n();\n```\n'); + }); + + test('a code block created by editing also retains a trailing paragraph', () { + final document = parser + .parse(filePath: 'code.md', source: 'Paragraph\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + + controller.applyBlockCommand( + document.blocks.single.id, + BusyWysiwygBlockCommand.codeBlock, + ); + + expect(controller.document.blocks, hasLength(2)); + expect(controller.document.blocks.first.kind, BusyBlockKind.codeBlock); + expect(controller.document.blocks.last.kind, BusyBlockKind.paragraph); + expect( + controller + .document + .blocks + .last + .attributes[busyMarkTransientTrailingParagraphAttribute], + 'true', + ); + }); + + test('third terminal Enter exits code and removes the two empty lines', () { + final controller = open(); + final code = controller.document.blocks.first; + + var result = controller.applyEnterAt(code.id, code.plainText.length); + result = controller.applyEnterAt( + code.id, + controller.blockText(code.id).length, + ); + result = controller.applyEnterAt( + code.id, + controller.blockText(code.id).length, + ); + + expect(result?.blockId, controller.document.blocks.last.id); + expect(controller.blockText(code.id), 'line();'); + expect(controller.document.blocks, hasLength(2)); + expect(controller.markdown, source); + }); + + test('exiting before existing content inserts a transient paragraph', () { + const withFollowingParagraph = '```javascript\nline();\n```\n\nFollowing\n'; + final controller = open(withFollowingParagraph); + final code = controller.document.blocks.first; + + final paragraphId = controller.exitCodeBlock(code.id); + + expect(paragraphId, isNotNull); + expect(controller.document.blocks, hasLength(3)); + expect(controller.markdown, withFollowingParagraph); + + controller.updateBlockText(paragraphId!, 'Inserted'); + + expect( + controller.markdown, + '```javascript\nline();\n```\n\nInserted\n\nFollowing\n', + ); + }); + + test('Enter in the trailing paragraph records an intentional blank', () { + final controller = open(); + final trailing = controller.document.blocks.last; + + final result = controller.applyEnterAt(trailing.id, 0); + + expect(controller.document.blocks, hasLength(3)); + expect(result?.blockId, controller.document.blocks.last.id); + expect( + controller.document.blocks + .skip(1) + .every( + (block) => + block.attributes[busyMarkTransientTrailingParagraphAttribute] == + null, + ), + isTrue, + ); + expect(controller.markdown, '$source\n\n'); + }); + + testWidgets('code Enter stays in the multiline code field', (tester) async { + final document = parser.parse(filePath: 'code.md', source: source); + var markdown = source; + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document.busyDocument, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + + final codeField = tester.widget(find.byType(TextField).first); + codeField.focusNode!.requestFocus(); + codeField.controller!.selection = TextSelection.collapsed( + offset: codeField.controller!.text.length, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(codeField.focusNode!.hasFocus, isTrue); + expect(codeField.controller!.text, 'line();\n'); + expect(find.byType(TextField), findsNWidgets(2)); + expect(markdown, '```javascript\nline();\n\n```\n'); + }); + + testWidgets('Ctrl+Enter exits into the trailing paragraph', (tester) async { + final document = parser.parse(filePath: 'code.md', source: source); + var markdown = source; + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document.busyDocument, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + + final fields = find.byType(TextField); + final codeField = tester.widget(fields.first); + codeField.focusNode!.requestFocus(); + codeField.controller!.selection = TextSelection.collapsed( + offset: codeField.controller!.text.length, + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + final trailingField = tester.widget(fields.at(1)); + expect(trailingField.focusNode!.hasFocus, isTrue); + expect(markdown, source); + + await tester.enterText(fields.at(1), 'Following'); + await tester.pump(); + + expect(markdown, '```javascript\nline();\n```\n\nFollowing\n'); + }); + + testWidgets('Arrow Down exits a terminal code block', (tester) async { + final document = parser.parse(filePath: 'code.md', source: source); + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document.busyDocument, + onSourceChanged: (_, _) {}, + ), + ), + ); + await tester.pump(); + + final fields = find.byType(TextField); + final codeField = tester.widget(fields.first); + codeField.focusNode!.requestFocus(); + codeField.controller!.selection = TextSelection.collapsed( + offset: codeField.controller!.text.length, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(tester.widget(fields.at(1)).focusNode!.hasFocus, isTrue); + }); +} + +Widget _app(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SizedBox(width: 900, height: 640, child: child)), + ); +} From f40ce896bd5c301f9a02dffbd286fc7e83911507 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 15:19:32 -0700 Subject: [PATCH 15/27] Update localization strings for 'TOC' to 'Topics' across multiple languages --- README.md | 2 +- docs/html-export.md | 2 +- docs/pdf-export.md | 2 +- docs/writerside-instances.md | 2 +- docs/writerside-pdf-export.md | 2 +- lib/l10n/app_ar.arb | 4 ++-- lib/l10n/app_de.arb | 4 ++-- lib/l10n/app_en.arb | 6 +++--- lib/l10n/app_es.arb | 4 ++-- lib/l10n/app_et.arb | 4 ++-- lib/l10n/app_fa.arb | 4 ++-- lib/l10n/app_fr.arb | 4 ++-- lib/l10n/app_hi.arb | 4 ++-- lib/l10n/app_id.arb | 6 +++--- lib/l10n/app_it.arb | 4 ++-- lib/l10n/app_ja.arb | 6 +++--- lib/l10n/app_ko.arb | 6 +++--- lib/l10n/app_nb.arb | 4 ++-- lib/l10n/app_nl.arb | 6 +++--- lib/l10n/app_pl.arb | 4 ++-- lib/l10n/app_pt.arb | 4 ++-- lib/l10n/app_pt_BR.arb | 4 ++-- lib/l10n/app_ru.arb | 4 ++-- lib/l10n/app_tr.arb | 6 +++--- lib/l10n/app_uk.arb | 4 ++-- lib/l10n/app_vi.arb | 6 +++--- lib/l10n/app_zh.arb | 6 +++--- lib/l10n/app_zh_CN.arb | 6 +++--- lib/l10n/generated/app_localizations.dart | 6 +++--- lib/l10n/generated/app_localizations_ar.dart | 2 +- lib/l10n/generated/app_localizations_de.dart | 2 +- lib/l10n/generated/app_localizations_en.dart | 2 +- lib/l10n/generated/app_localizations_es.dart | 2 +- lib/l10n/generated/app_localizations_et.dart | 2 +- lib/l10n/generated/app_localizations_fa.dart | 2 +- lib/l10n/generated/app_localizations_fr.dart | 2 +- lib/l10n/generated/app_localizations_hi.dart | 2 +- lib/l10n/generated/app_localizations_id.dart | 2 +- lib/l10n/generated/app_localizations_it.dart | 2 +- lib/l10n/generated/app_localizations_ja.dart | 2 +- lib/l10n/generated/app_localizations_ko.dart | 2 +- lib/l10n/generated/app_localizations_nb.dart | 2 +- lib/l10n/generated/app_localizations_nl.dart | 2 +- lib/l10n/generated/app_localizations_pl.dart | 2 +- lib/l10n/generated/app_localizations_pt.dart | 4 ++-- lib/l10n/generated/app_localizations_ru.dart | 2 +- lib/l10n/generated/app_localizations_tr.dart | 2 +- lib/l10n/generated/app_localizations_uk.dart | 2 +- lib/l10n/generated/app_localizations_vi.dart | 2 +- lib/l10n/generated/app_localizations_zh.dart | 4 ++-- 50 files changed, 86 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index e6093f7f..f8c5cc26 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Markdown and Writerside editor for Linux. * **Markdown editing** — Source, Editor, Reading, and Split views with formatting tools, syntax highlighting, code folding, tables, images, links, code blocks, callouts, collapsible content, and document diagnostics. * **Writerside projects** — Open and create Writerside-compatible projects; edit Markdown and XML topics; manage instances, tables of contents, reusable TOC libraries, and project structure. -* **Project navigation and search** — Files, TOC, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed workspace-wide replacement, and Markdown TOC generation. +* **Project navigation and search** — Files, Topics, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed workspace-wide replacement, and Markdown TOC generation. * **Technical documentation** — Local rendering of Mermaid, PlantUML, D2, fenced OpenAPI specifications, and MathJax mathematical expressions. * **PDF and HTML publishing** — Export Markdown documents and Writerside instances to configurable PDF or portable offline HTML, with controls for layout, typography, tables of contents, heading numbering, and HTML styling. * **Git integration** — Review changes and diffs, stage and unstage files, commit, create and switch branches, fetch, pull, push, inspect file and project history, compare historical versions, and restore earlier file versions. diff --git a/docs/html-export.md b/docs/html-export.md index b2cf0fc4..70c848f7 100644 --- a/docs/html-export.md +++ b/docs/html-export.md @@ -1,7 +1,7 @@ # HTML export For Markdown, choose **Outline → Actions → Export** or use the command palette. -For Writerside, choose **TOC → Actions → Export**. HTML has no default shortcut; +For Writerside, choose **Topics → Actions → Export**. HTML has no default shortcut; **Ctrl+Shift+E** continues to export PDF. Markdown exports the active document’s current editor text, including unsaved diff --git a/docs/pdf-export.md b/docs/pdf-export.md index 52b89c10..32350230 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -2,7 +2,7 @@ For Markdown, choose **Outline → Actions → Export**, use the command palette, or press **Ctrl+Shift+E**. Markdown exports the active editor text, including -unsaved and untitled documents. For Writerside, choose **TOC → Actions → Export**; +unsaved and untitled documents. For Writerside, choose **Topics → Actions → Export**; BusyMark first offers Save, Discard, or Cancel for project changes, then lets you select a non-library instance. The shortcut and command palette route use the same document-versus-instance scope. diff --git a/docs/writerside-instances.md b/docs/writerside-instances.md index abd754aa..8a902f5a 100644 --- a/docs/writerside-instances.md +++ b/docs/writerside-instances.md @@ -7,7 +7,7 @@ introduce a BusyMark-specific project file. ## Instance actions -Open **Table of Contents**, select an instance from the visible **Instances** +Open **Topics**, select an instance from the visible **Instances** list, and use **TOC actions** to: - create an instance, either empty or from selected local Markdown files; diff --git a/docs/writerside-pdf-export.md b/docs/writerside-pdf-export.md index a6a7b8ef..1a28d03b 100644 --- a/docs/writerside-pdf-export.md +++ b/docs/writerside-pdf-export.md @@ -6,7 +6,7 @@ are not required. ## Export -Open a Writerside module and select **TOC → Actions → Export**, use the command +Open a Writerside module and select **Topics → Actions → Export**, use the command palette, or press Ctrl+Shift+E. BusyMark first asks you to save or discard unsaved project changes, then lets you select: diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0aa677cc..486e5b52 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "جدول المحتويات", + "toc": "موضوعات", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "إجراءات جدول المحتويات", "@tocActions": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2e6747e2..81c7eecc 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Inhaltsverzeichnis", + "toc": "Themen", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Inhaltsverzeichnisaktionen", "@tocActions": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0af64e34..72b4f3ed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -702,8 +702,8 @@ }, "files": "Files", "@files": {"description": "Files sidebar tab label."}, - "toc": "TOC", - "@toc": {"description": "Writerside table of contents sidebar tab label."}, + "toc": "Topics", + "@toc": {"description": "Writerside Topics sidebar tab label."}, "tocActions": "TOC actions", "@tocActions": {"description": "Tooltip for the Writerside TOC action menu button."}, "markdownUnsaved": "Markdown - unsaved", @@ -2020,7 +2020,7 @@ "shortcutExportPdfDescription": "Export the active document or Writerside module as a PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}, "instances": "Instances", - "@instances": {"description": "Heading for the Writerside instances shown in the TOC sidebar."}, + "@instances": {"description": "Heading for the Writerside instances shown in the Topics sidebar."}, "newInstance": "New instance", "@newInstance": {"description": "Action that creates a Writerside instance."}, "newTocLibrary": "New TOC library", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f7ad799c..62b05755 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Índice", + "toc": "Temas", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Acciones del índice", "@tocActions": { diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 955d0352..548274a2 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1294,9 +1294,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Sisukord", + "toc": "Teemad", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Sisukorra toimingud", "@tocActions": { diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index c50cf1a8..81518504 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1212,9 +1212,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "فهرست مطالب", + "toc": "موضوعات", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "عملیات فهرست مطالب", "@tocActions": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7bbc6257..485c6576 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Table des matières", + "toc": "Sujets", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Actions sur la table des matières", "@tocActions": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 93ad239d..d4eaaa93 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "TOC", + "toc": "विषय", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "विषय-सूची संबंधी कार्रवाइयाँ", "@tocActions": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index fa7c6e15..2b0a52be 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Daftar isi", + "toc": "Topik", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Tindakan TOC", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "Instance", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "Instance baru", "@newInstance": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index b24960df..d9e7619c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "TOC", + "toc": "Argomenti", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Azioni dell'indice", "@tocActions": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 4217d5f9..de5241ba 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "目次", + "toc": "トピック", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "目次の操作", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "インスタンス", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "新しいインスタンス", "@newInstance": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7f164d28..ff350bab 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "목차", + "toc": "토픽", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "목차 작업", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "인스턴스", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "새 인스턴스", "@newInstance": { diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 2365be6c..49d86fab 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Innholdsfortegnelse", + "toc": "Emner", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Handlinger for innholdsfortegnelsen", "@tocActions": { diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 1274b1b5..f25a48da 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "TOC", + "toc": "Onderwerpen", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "TOC-acties", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "Instanties", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "Nieuwe instantie", "@newInstance": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 97513971..7be4fbf5 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Spis treści", + "toc": "Tematy", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Działania dotyczące spisu treści", "@tocActions": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b3a37f49..366ff6ef 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "TOC", + "toc": "Tópicos", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Ações do sumário", "@tocActions": { diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index dd07209a..83415a10 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "TOC", + "toc": "Tópicos", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Ações do sumário", "@tocActions": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2ddf9c43..7e4e58a2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Оглавление", + "toc": "Темы", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Действия с оглавлением", "@tocActions": { diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 44c58aef..a31d22d5 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "İçindekiler", + "toc": "Konular", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "İçindekiler eylemleri", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "Örnekler", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "Yeni örnek", "@newInstance": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 7f4bd802..eae23be8 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1206,9 +1206,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Зміст", + "toc": "Теми", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Дії зі змістом", "@tocActions": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index a7fea7d8..6d2c78a4 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "Mục lục", + "toc": "Chủ đề", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "Thao tác với mục lục", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "Các thực thể", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "Thực thể mới", "@newInstance": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 744be947..db630567 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "目录", + "toc": "主题", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "目录操作", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "实例", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "新建实例", "@newInstance": { diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 361f30c4..1921b63f 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1382,9 +1382,9 @@ "@files": { "description": "Files sidebar tab label." }, - "toc": "目录", + "toc": "主题", "@toc": { - "description": "Writerside table of contents sidebar tab label." + "description": "Writerside Topics sidebar tab label." }, "tocActions": "目录操作", "@tocActions": { @@ -3833,7 +3833,7 @@ }, "instances": "实例", "@instances": { - "description": "Heading for the Writerside instances shown in the TOC sidebar." + "description": "Heading for the Writerside instances shown in the Topics sidebar." }, "newInstance": "新建实例", "@newInstance": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 99705ab3..a674dac4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2155,10 +2155,10 @@ abstract class AppLocalizations { /// **'Files'** String get files; - /// Writerside table of contents sidebar tab label. + /// Writerside Topics sidebar tab label. /// /// In en, this message translates to: - /// **'TOC'** + /// **'Topics'** String get toc; /// Tooltip for the Writerside TOC action menu button. @@ -5172,7 +5172,7 @@ abstract class AppLocalizations { /// **'Export the active document or Writerside module as a PDF.'** String get shortcutExportPdfDescription; - /// Heading for the Writerside instances shown in the TOC sidebar. + /// Heading for the Writerside instances shown in the Topics sidebar. /// /// In en, this message translates to: /// **'Instances'** diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 9dce9258..555df66b 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1123,7 +1123,7 @@ class AppLocalizationsAr extends AppLocalizations { String get files => 'ملفات'; @override - String get toc => 'جدول المحتويات'; + String get toc => 'موضوعات'; @override String get tocActions => 'إجراءات جدول المحتويات'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 74e9d6d9..375fcf64 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1140,7 +1140,7 @@ class AppLocalizationsDe extends AppLocalizations { String get files => 'Dateien'; @override - String get toc => 'Inhaltsverzeichnis'; + String get toc => 'Themen'; @override String get tocActions => 'Inhaltsverzeichnisaktionen'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 81186e0f..258b8c46 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1121,7 +1121,7 @@ class AppLocalizationsEn extends AppLocalizations { String get files => 'Files'; @override - String get toc => 'TOC'; + String get toc => 'Topics'; @override String get tocActions => 'TOC actions'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 774f7efa..54a27fc0 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1140,7 +1140,7 @@ class AppLocalizationsEs extends AppLocalizations { String get files => 'Archivos'; @override - String get toc => 'Índice'; + String get toc => 'Temas'; @override String get tocActions => 'Acciones del índice'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 86076bdb..f665f885 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1125,7 +1125,7 @@ class AppLocalizationsEt extends AppLocalizations { String get files => 'Failid'; @override - String get toc => 'Sisukord'; + String get toc => 'Teemad'; @override String get tocActions => 'Sisukorra toimingud'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 550048ea..e3b3b824 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1145,7 +1145,7 @@ class AppLocalizationsFa extends AppLocalizations { String get files => 'فایل‌ها'; @override - String get toc => 'فهرست مطالب'; + String get toc => 'موضوعات'; @override String get tocActions => 'عملیات فهرست مطالب'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 021823bd..98d65a19 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1138,7 +1138,7 @@ class AppLocalizationsFr extends AppLocalizations { String get files => 'Fichiers'; @override - String get toc => 'Table des matières'; + String get toc => 'Sujets'; @override String get tocActions => 'Actions sur la table des matières'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 1b6ad5e1..1640765d 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1120,7 +1120,7 @@ class AppLocalizationsHi extends AppLocalizations { String get files => 'फ़ाइलें'; @override - String get toc => 'TOC'; + String get toc => 'विषय'; @override String get tocActions => 'विषय-सूची संबंधी कार्रवाइयाँ'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 0da4f21e..5b2ab355 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -1127,7 +1127,7 @@ class AppLocalizationsId extends AppLocalizations { String get files => 'File'; @override - String get toc => 'Daftar isi'; + String get toc => 'Topik'; @override String get tocActions => 'Tindakan TOC'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index f4b5b017..615c377d 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1135,7 +1135,7 @@ class AppLocalizationsIt extends AppLocalizations { String get files => 'File'; @override - String get toc => 'TOC'; + String get toc => 'Argomenti'; @override String get tocActions => 'Azioni dell\'indice'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index b78e4a9e..0a8cb35d 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1087,7 +1087,7 @@ class AppLocalizationsJa extends AppLocalizations { String get files => 'ファイル'; @override - String get toc => '目次'; + String get toc => 'トピック'; @override String get tocActions => '目次の操作'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 15b33536..84e7ea94 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1085,7 +1085,7 @@ class AppLocalizationsKo extends AppLocalizations { String get files => '파일'; @override - String get toc => '목차'; + String get toc => '토픽'; @override String get tocActions => '목차 작업'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index a1502b95..3f8e29f0 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1126,7 +1126,7 @@ class AppLocalizationsNb extends AppLocalizations { String get files => 'Filer'; @override - String get toc => 'Innholdsfortegnelse'; + String get toc => 'Emner'; @override String get tocActions => 'Handlinger for innholdsfortegnelsen'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 2507e8e7..6b2366fc 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -1127,7 +1127,7 @@ class AppLocalizationsNl extends AppLocalizations { String get files => 'Bestanden'; @override - String get toc => 'TOC'; + String get toc => 'Onderwerpen'; @override String get tocActions => 'TOC-acties'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index cf479eab..65a64b46 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1143,7 +1143,7 @@ class AppLocalizationsPl extends AppLocalizations { String get files => 'Pliki'; @override - String get toc => 'Spis treści'; + String get toc => 'Tematy'; @override String get tocActions => 'Działania dotyczące spisu treści'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index b70627fc..d1c013aa 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1134,7 +1134,7 @@ class AppLocalizationsPt extends AppLocalizations { String get files => 'Ficheiros'; @override - String get toc => 'TOC'; + String get toc => 'Tópicos'; @override String get tocActions => 'Ações do sumário'; @@ -5364,7 +5364,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get files => 'Arquivos'; @override - String get toc => 'TOC'; + String get toc => 'Tópicos'; @override String get tocActions => 'Ações do sumário'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index e3f3189a..4658ac08 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1140,7 +1140,7 @@ class AppLocalizationsRu extends AppLocalizations { String get files => 'Файлы'; @override - String get toc => 'Оглавление'; + String get toc => 'Темы'; @override String get tocActions => 'Действия с оглавлением'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 02e44124..7db94730 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1127,7 +1127,7 @@ class AppLocalizationsTr extends AppLocalizations { String get files => 'Dosyalar'; @override - String get toc => 'İçindekiler'; + String get toc => 'Konular'; @override String get tocActions => 'İçindekiler eylemleri'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 8cb1cd3a..ccdcf32d 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1147,7 +1147,7 @@ class AppLocalizationsUk extends AppLocalizations { String get files => 'Файли'; @override - String get toc => 'Зміст'; + String get toc => 'Теми'; @override String get tocActions => 'Дії зі змістом'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index c1c92050..8e3fc4bf 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1120,7 +1120,7 @@ class AppLocalizationsVi extends AppLocalizations { String get files => 'Tệp'; @override - String get toc => 'Mục lục'; + String get toc => 'Chủ đề'; @override String get tocActions => 'Thao tác với mục lục'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index c13cf4f7..d43ba8c3 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1072,7 +1072,7 @@ class AppLocalizationsZh extends AppLocalizations { String get files => '文件'; @override - String get toc => '目录'; + String get toc => '主题'; @override String get tocActions => '目录操作'; @@ -5054,7 +5054,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get files => '文件'; @override - String get toc => '目录'; + String get toc => '主题'; @override String get tocActions => '目录操作'; From 59a0b2fec886a454516630813ce1b9f50e723615 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 15:30:57 -0700 Subject: [PATCH 16/27] Refactor `BusyMarkSearchField` with custom text controller and update workspace header logic to respect search state --- lib/src/app/busymark_search_field.dart | 124 ++++++++++++------ .../presentation/workspace_screen.dart | 124 ++++++++++-------- test/src/app_smoke_test.dart | 116 +++++++++++++++- test/src/busymark_search_field_test.dart | 41 ++++-- test/src/source_audit_test.dart | 9 +- 5 files changed, 306 insertions(+), 108 deletions(-) diff --git a/lib/src/app/busymark_search_field.dart b/lib/src/app/busymark_search_field.dart index 6a4eb72b..97970af3 100644 --- a/lib/src/app/busymark_search_field.dart +++ b/lib/src/app/busymark_search_field.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; -/// Flutter fallback for BusyMark's native Linux search entry. +/// Flutter counterpart to BusyMark's native Linux `GtkSearchEntry`. /// -/// Linux header bars use `GtkSearchEntry`. Flutter-owned layouts delegate -/// geometry, icons, focus presentation, and clear behavior to Yaru. +/// The native entry cannot be embedded in Flutter-owned layouts. This uses the +/// Yaru input theme shared by the rest of the application instead of +/// [YaruSearchField], whose borderless pill geometry is intended for Flutter +/// title bars rather than GTK-style entries inside a sidebar. class BusyMarkSearchField extends StatefulWidget { const BusyMarkSearchField({ super.key, @@ -24,7 +26,7 @@ class BusyMarkSearchField extends StatefulWidget { final String? hintText; final bool autofocus; - /// Increment this value to focus the Yaru-owned text entry again. + /// Increment this value to focus the themed text entry again. final int focusRequest; final ValueChanged? onChanged; @@ -38,25 +40,26 @@ class BusyMarkSearchField extends StatefulWidget { } class _BusyMarkSearchFieldState extends State { - final _focusScopeNode = FocusScopeNode( - debugLabel: 'BusyMarkSearchField scope', - ); - final _yaruKeyboardFocusNode = FocusNode( - debugLabel: 'BusyMarkSearchField keyboard listener', - skipTraversal: true, - ); + late TextEditingController _controller; + late bool _textIsEmpty; + final _focusNode = FocusNode(debugLabel: 'BusyMarkSearchField text entry'); @override void initState() { super.initState(); - if (widget.autofocus) { - _requestTextFocus(); - } + _attachController(); } @override void didUpdateWidget(covariant BusyMarkSearchField oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _controller.removeListener(_handleControllerChanged); + if (oldWidget.controller == null) { + _controller.dispose(); + } + _attachController(); + } if (oldWidget.focusRequest != widget.focusRequest) { _requestTextFocus(); } @@ -64,50 +67,95 @@ class _BusyMarkSearchFieldState extends State { @override void dispose() { - _focusScopeNode.dispose(); - _yaruKeyboardFocusNode.dispose(); + _controller.removeListener(_handleControllerChanged); + if (widget.controller == null) { + _controller.dispose(); + } + _focusNode.dispose(); super.dispose(); } + void _attachController() { + _controller = widget.controller ?? TextEditingController(); + _textIsEmpty = _controller.text.isEmpty; + _controller.addListener(_handleControllerChanged); + } + + void _handleControllerChanged() { + final textIsEmpty = _controller.text.isEmpty; + if (textIsEmpty != _textIsEmpty) { + setState(() => _textIsEmpty = textIsEmpty); + } + } + void _requestTextFocus() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - for (final node in _focusScopeNode.traversalDescendants) { - if (node.canRequestFocus) { - node.requestFocus(); - return; - } + if (mounted) { + _focusNode.requestFocus(); } }); } + void _clear() { + final hadText = _controller.text.isNotEmpty; + final onClear = widget.onClear; + if (onClear != null) { + onClear(); + } + _controller.clear(); + if (onClear == null && hadText) { + // TextField does not report programmatic controller changes through + // onChanged, so keep onChanged-only search owners in sync. + widget.onChanged?.call(''); + } + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final clearLabel = + widget.clearButtonSemanticLabel ?? + MaterialLocalizations.of(context).clearButtonTooltip; return Focus( - onKeyEvent: (node, event) { + onKeyEvent: (_, event) { if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape && - widget.onEscape != null) { - widget.onEscape!(); + event.logicalKey == LogicalKeyboardKey.escape) { + _clear(); + widget.onEscape?.call(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, - child: FocusScope( - node: _focusScopeNode, - child: YaruSearchField( - controller: widget.controller, - focusNode: _yaruKeyboardFocusNode, - hintText: widget.hintText, + child: SizedBox( + height: kYaruTitleBarItemHeight, + child: TextField( + controller: _controller, + focusNode: _focusNode, autofocus: widget.autofocus, + textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, + cursorWidth: 1, onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, - onClear: widget.onClear, - clearIconSemanticLabel: - widget.clearButtonSemanticLabel ?? - MaterialLocalizations.of(context).clearButtonTooltip, + decoration: InputDecoration( + hintText: widget.hintText, + filled: true, + fillColor: theme.colorScheme.surface, + prefixIcon: const Icon(YaruIcons.search), + prefixIconConstraints: const BoxConstraints.tightFor( + width: kYaruTitleBarItemHeight, + ), + suffixIcon: _textIsEmpty + ? null + : IconButton( + tooltip: clearLabel, + onPressed: _clear, + icon: const Icon(YaruIcons.edit_clear), + ), + suffixIconConstraints: const BoxConstraints.tightFor( + width: kYaruTitleBarItemHeight, + ), + ), ), ), ); diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 577b0589..b32e28c0 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -718,7 +718,7 @@ class WorkspaceScreen extends ConsumerWidget { searchVisible: true, sidebarVisible: sidebarVisible, sidebarToggleVisible: hasSidebar, - backVisible: true, + backVisible: !searchState.active, ); final commandRegistry = BusyMarkCommandRegistryScope.maybeOf(context) ?? @@ -780,21 +780,28 @@ class WorkspaceScreen extends ConsumerWidget { appBar: useNativeHeaderBar ? null : AppBar( - leading: Center( - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.welcome, - icon: BusyMarkGlyphs.home, - shortcut: commandRegistry[BusyMarkCommandIds.back] - ?.shortcut - ?.label, - onPressed: () async { - final router = GoRouter.of(context); - if (await confirmSafeToContinue(context, ref)) { - router.go('/'); - } - }, - ), - ), + automaticallyImplyLeading: false, + leading: searchState.active + ? null + : Center( + child: BusyMarkHeaderIconButton( + tooltip: context.l10n.welcome, + icon: BusyMarkGlyphs.home, + shortcut: + commandRegistry[BusyMarkCommandIds.back] + ?.shortcut + ?.label, + onPressed: () async { + final router = GoRouter.of(context); + if (await confirmSafeToContinue( + context, + ref, + )) { + router.go('/'); + } + }, + ), + ), title: searchState.active ? _HeaderSearchField( query: searchState.query, @@ -813,15 +820,17 @@ class WorkspaceScreen extends ConsumerWidget { dirty: state.isDirty, ), actions: [ - const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkHeaderIconButton( - tooltip: context.l10n.validate, - icon: BusyMarkGlyphs.diagnostics, - onPressed: () => unawaited( - _validateActiveAndShowProblems(context, ref), + if (!searchState.active) ...[ + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderIconButton( + tooltip: context.l10n.validate, + icon: BusyMarkGlyphs.diagnostics, + onPressed: () => unawaited( + _validateActiveAndShowProblems(context, ref), + ), ), - ), - const _HeaderSeparator(), + const _HeaderSeparator(), + ], BusyMarkHeaderIconButton( tooltip: settings.sidebarVisible ? context.l10n.hideSidebar @@ -851,39 +860,40 @@ class WorkspaceScreen extends ConsumerWidget { ?.label, onPressed: () => _toggleSearch(ref), ), - BusyMarkHeaderPopupMenuButton< - DocumentViewModePreference - >( - tooltip: context.l10n.viewMode, - icon: _documentViewModeIcon(documentViewMode), - shortcut: _documentViewModeShortcut( - documentViewMode, - commandRegistry, - ), - itemBuilder: (context) => [ - for (final mode - in DocumentViewModePreference.values) - BusyMarkPopupMenuItem( - value: mode, - label: _documentViewModeLabel(context, mode), - icon: _documentViewModeIcon(mode), - shortcut: _documentViewModeShortcut( - mode, - commandRegistry, + if (!searchState.active) + BusyMarkHeaderPopupMenuButton< + DocumentViewModePreference + >( + tooltip: context.l10n.viewMode, + icon: _documentViewModeIcon(documentViewMode), + shortcut: _documentViewModeShortcut( + documentViewMode, + commandRegistry, + ), + itemBuilder: (context) => [ + for (final mode + in DocumentViewModePreference.values) + BusyMarkPopupMenuItem( + value: mode, + label: _documentViewModeLabel(context, mode), + icon: _documentViewModeIcon(mode), + shortcut: _documentViewModeShortcut( + mode, + commandRegistry, + ), + checked: mode == documentViewMode, + trailingCheck: true, ), - checked: mode == documentViewMode, - trailingCheck: true, - ), - ], - onSelected: (mode) { - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(mode); - unawaited( - settingsController.setDocumentViewMode(mode), - ); - }, - ), + ], + onSelected: (mode) { + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(mode); + unawaited( + settingsController.setDocumentViewMode(mode), + ); + }, + ), BusyMarkMainMenuButton( onSelected: (action) => _handleMainMenuAction(context, ref, action), @@ -1055,7 +1065,7 @@ class WorkspaceScreen extends ConsumerWidget { case HeaderBarAction.sidebarGit: _selectSidebarShortcut(ref, _SidebarTab.git); case HeaderBarAction.search: - execute(BusyMarkCommandIds.search); + _toggleSearch(ref); case HeaderBarAction.menu: break; } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 5419d8d0..514a8d2a 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -8107,7 +8107,9 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. } }); - testWidgets('Ctrl+F opens search and Escape closes it', (tester) async { + testWidgets('header Search button toggles the expanded search field', ( + tester, + ) async { final service = _StartupWorkspaceService(); final container = ProviderContainer( overrides: [ @@ -8137,6 +8139,12 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. expect(find.text(l10n.workspaceKindSingleMarkdown), findsWidgets); final initialTextFields = find.byType(TextField).evaluate().length; + Finder headerButton(String tooltip) => find.byWidgetPredicate( + (widget) => + widget is IconButton && + (widget.tooltip == tooltip || + widget.tooltip?.startsWith('$tooltip (') == true), + ); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); @@ -8144,6 +8152,20 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(TextField).evaluate().length, initialTextFields + 1); + expect(headerButton(l10n.welcome), findsNothing); + expect(headerButton(l10n.validate), findsNothing); + expect(headerButton(l10n.viewMode), findsNothing); + + await tester.tap(headerButton(l10n.search)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(TextField).evaluate().length, initialTextFields); + expect(headerButton(l10n.welcome), findsOneWidget); + expect(headerButton(l10n.validate), findsOneWidget); + expect(headerButton(l10n.viewMode), findsOneWidget); + + await tester.tap(headerButton(l10n.search)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(TextField).evaluate().length, initialTextFields + 1); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); @@ -8157,6 +8179,98 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. expect(find.byType(TextField).evaluate().length, initialTextFields); }); + testWidgets( + 'native header Search action toggles search and Back visibility', + (tester) async { + if (!Platform.isLinux) { + return; + } + const channelName = 'test.busymark/headerbar-search-toggle'; + const channel = MethodChannel(channelName); + const codec = StandardMethodCodec(); + final nativeHeaderBarService = LinuxHeaderBarService( + channel: channel, + sessionId: 'search-toggle-session', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize' || call.method == 'focusSearch') { + return true; + } + if (call.method == 'applyConfiguration') { + final configuration = Map.from( + call.arguments! as Map, + ); + return configuration['revision']; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + channel.setMethodCallHandler(null); + }); + await nativeHeaderBarService.initialize(); + + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue( + nativeHeaderBarService, + ), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + workspaceServiceProvider.overrideWithValue( + _StartupWorkspaceService(), + ), + startupPathProvider.overrideWithValue( + 'test/fixtures/markdown/basic.md', + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var i = 0; i < 20; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + break; + } + } + + HeaderBarConfiguration configuration() => tester + .widget( + find.byType(HeaderBarConfigurationPublisher), + ) + .configuration; + + expect(configuration().searchActive, isFalse); + expect(configuration().backVisible, isTrue); + + Future pressNativeSearch() async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channelName, + codec.encodeMethodCall(const MethodCall('search')), + (_) {}, + ); + await tester.pump(const Duration(milliseconds: 100)); + } + + await pressNativeSearch(); + expect(configuration().searchActive, isTrue); + expect(configuration().backVisible, isFalse); + + await pressNativeSearch(); + expect(configuration().searchActive, isFalse); + expect(configuration().backVisible, isTrue); + }, + ); + testWidgets( 'workspace search awaits non-active file reads without blocking builds', (tester) async { diff --git a/test/src/busymark_search_field_test.dart b/test/src/busymark_search_field_test.dart index d1572598..6ef6f064 100644 --- a/test/src/busymark_search_field_test.dart +++ b/test/src/busymark_search_field_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_search_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -5,7 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { - testWidgets('search fallback delegates behavior and geometry to Yaru', ( + testWidgets('search fallback uses the normal Yaru-themed entry geometry', ( tester, ) async { final controller = TextEditingController(); @@ -15,6 +16,10 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.orange, + ), home: Scaffold( body: BusyMarkSearchField( controller: controller, @@ -26,20 +31,38 @@ void main() { ), ); - expect(find.byType(YaruSearchField), findsOneWidget); + expect(find.byType(YaruSearchField), findsNothing); + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.border, isNull); + expect(field.decoration?.enabledBorder, isNull); + expect(field.decoration?.focusedBorder, isNull); + expect(field.decoration?.filled, isTrue); + final resolvedDecoration = tester + .widget(find.byType(InputDecorator)) + .decoration; + expect(resolvedDecoration.enabledBorder, isA()); + expect(resolvedDecoration.focusedBorder, isA()); + expect(find.byIcon(YaruIcons.search), findsOneWidget); expect(find.text('Search documents'), findsOneWidget); await tester.enterText(find.byType(EditableText), 'native'); + await tester.pump(); expect(changedQuery, 'native'); + expect(find.byType(IconButton), findsOneWidget); + await tester.tap(find.byType(IconButton)); + await tester.pump(); + expect(controller.text, isEmpty); + expect(changedQuery, isEmpty); + + await tester.enterText(find.byType(EditableText), 'native'); + await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); expect(submittedQuery, 'native'); }); - testWidgets('focus request targets the Yaru-owned text entry', ( - tester, - ) async { + testWidgets('focus request targets the themed text entry', (tester) async { var focusRequest = 0; late StateSetter setState; @@ -64,12 +87,11 @@ void main() { expect(editable.focusNode.hasFocus, isTrue); }); - testWidgets('Escape keeps Yaru clear behavior and closes the owner', ( - tester, - ) async { + testWidgets('Escape clears the query and closes the owner', (tester) async { final controller = TextEditingController(text: 'query'); addTearDown(controller.dispose); var escapeCount = 0; + String? changedQuery; await tester.pumpWidget( MaterialApp( @@ -77,7 +99,7 @@ void main() { body: BusyMarkSearchField( controller: controller, autofocus: true, - onClear: controller.clear, + onChanged: (value) => changedQuery = value, onEscape: () => escapeCount++, ), ), @@ -89,6 +111,7 @@ void main() { await tester.pump(); expect(controller.text, isEmpty); + expect(changedQuery, isEmpty); expect(escapeCount, 1); }); } diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 96f3fd4a..336f7609 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -520,13 +520,16 @@ void main() { }, ); - test('native search action opens workspace search UI', () { + test('native search action toggles workspace search UI', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); - expect(workspace, contains('case HeaderBarAction.search:')); - expect(workspace, contains('_toggleSearch(ref)')); + expect( + workspace, + contains('case HeaderBarAction.search:\n _toggleSearch(ref);'), + ); + expect(workspace, contains('backVisible: !searchState.active')); expect(workspace, contains('_workspaceSearchProvider')); expect(workspace, contains('class _HeaderSearchField')); expect(workspace, contains('return BusyMarkSearchField(')); From d674f19669e8d2287335809dd427051fb3b54c71 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 18:04:57 -0700 Subject: [PATCH 17/27] Remove `WorkspaceIdentityRow`, enhance Local History integration and buffer handling - Deleted `WorkspaceIdentityRow` and replaced its references with reusable `BusyMarkSidebarRecordRow`. - Improved Local History integration with better buffer validation, revision handling, and checkpoint policies. - Enhanced logic for buffer autosaving, consistent state reconciliation, and document activation during workspace operation. - Updated sidebar actions and keyboard shortcuts for `Local History` and `Clipboard History`. - Cleaned up obsolete widgets and methods for improved code maintainability. --- docs/history-architecture.md | 43 + docs/history.md | 33 +- lib/src/app/busymark_design.dart | 259 ++++++ lib/src/app/busymark_dialogs.dart | 14 + lib/src/app/busymark_shortcuts.dart | 32 +- lib/src/app/command_registry.dart | 52 +- .../clipboard/clipboard_history_panel.dart | 388 ++++----- .../local_history_controller.dart | 674 +++++++++++++--- .../local_history/local_history_panel.dart | 221 +++--- .../platform/linux_header_bar_service.dart | 4 + .../presentation/settings_screen.dart | 2 + .../presentation/welcome_screen.dart | 145 +--- .../presentation/workspace_identity_row.dart | 70 -- .../presentation/workspace_screen.dart | 60 +- lib/src/workspace/workspace_controller.dart | 175 +++- linux/runner/my_application.cc | 6 + test/src/app_smoke_test.dart | 464 ++++++++++- test/src/clipboard_history_panel_test.dart | 254 ++++-- test/src/command_registry_test.dart | 16 + test/src/linux_header_bar_service_test.dart | 17 +- test/src/local_history_controller_test.dart | 751 +++++++++++++++++- test/src/native_headerbar_audit_test.dart | 18 +- test/src/source_audit_test.dart | 71 +- test/src/workspace_controller_test.dart | 208 +++++ tools/history_visual_smoke.dart | 121 ++- 25 files changed, 3254 insertions(+), 844 deletions(-) delete mode 100644 lib/src/workspace/presentation/workspace_identity_row.dart diff --git a/docs/history-architecture.md b/docs/history-architecture.md index 02294bd2..c3f56b5c 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -47,6 +47,25 @@ restore, before delete, and observed external change. Pending buffers are flushed on close, workspace replacement, and awaited shutdown. Autosave writes do not themselves create Saved entries. +The editor buffer is the source authority. Source and WYSIWYG callbacks, +selected-text formatting, inactive-buffer mutations, Undo, and Redo all report +the before/after buffer transition after one accepted document transaction. +Observation is queued per buffer and replaces that buffer's one pending full +source snapshot. The first edit anchors the checkpoint timer; later edits +replace the pending source without postponing the deadline. A flush is a queue +barrier, so observations accepted before it are captured before settlement is +reported. + +Automatic capture retains pending work until storage succeeds or adjacent +source deduplication validly settles it. Transient failure preserves one +bounded latest snapshot per buffer, keeps the capture warning visible, and +schedules a controlled retry using the configured checkpoint interval. Newer +accepted source wins over acknowledgements or failures from older writes. +Unsupported or over-limit content remains visibly failed without a retry loop. +Flush and identity-promotion results propagate content-capture failure to +lifecycle safety callers; a successful document save remains independent of a +failed history write. + History identity is stable independently of widget and transient buffer IDs. Known file and directory moves remap current and descendant paths while retaining historical paths. A first save of an untitled document continues its @@ -85,6 +104,22 @@ renamed, deleted, or untitled identities; selecting a normal document resumes active-tab following. The revision list is timestamp-first and groups the same localized timestamps by local calendar date. +Normal browsing retains the active buffer as its scope even before that buffer +has a stored history document ID. The first capture, a capture after Clear, or +an identity promotion reconciles the buffer-to-history association and +publishes the corresponding rows without a tab switch, sidebar reconstruction, +or Refresh. Captures for inactive buffers do not steal this scope. Explicit +tab, editor, or Files-context Local History commands leave retained lookup and +establish the requested buffer after guarded activation completes. + +Every published snapshot validates document/revision ownership. An active +revision-content query is recomputed against the new scoped snapshot, with +query, scope, and snapshot generations preventing delayed reads from replacing +newer matches. Search completion clears its loading state on success, scope +change, cancellation, and read failure. Explicit clear invalidates affected +queued work and closes a comparison it owns; retention loss instead reports the +existing missing-revision state. + `SourceComparisonInput` identifies immutable sources by ID, version, label, and text. The comparison uses unique patience anchors plus bounded local LCS and intraline refinement in Dart UTF-16 offsets. Separated edits are retained. @@ -116,6 +151,14 @@ newer editor state and undo/redo history win over stale loads. Derived workspace and preview content is published only when its active buffer and source still match; otherwise the normal fresh-parse scheduler takes over. +Ordinary tab activation follows the same live-buffer rule. It avoids reparsing +an already-active unchanged target; after an asynchronous reparse, it +revalidates workspace and target identity and republishes the current buffer +membership, source, editor selection, and undo/redo state rather than an +activation-time list. A stale derived result is discarded in favor of the +normal fresh-parse scheduler. Refresh reschedules eligible autosaves on both +success and failure because it cancels its incoming timers at the boundary. + ## Verification workflow Use the repository-pinned Flutter/Dart toolchain: diff --git a/docs/history.md b/docs/history.md index 9571dcaa..930d9e3a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -8,10 +8,12 @@ compared and restored. ## Clipboard History Open **Clipboard History** from the sidebar selector, command palette, or a -document editor's context menu. The panel stays open while you -move between documents and selections. Select an item to preview it, then use -**Paste**, **Paste as Plain Text**, Enter, or double-click to insert it at the -latest visible editable selection. Escape returns focus to the editor. +document editor's context menu. The panel stays open while you move between +documents and selections. Each row preserves up to three lines of the item's +source preview; its type, capture time, and origin are available in the row +tooltip. Use its action menu for **Paste** or **Paste as Plain Text**; Enter and +double-click insert the selected item at the latest visible editable selection. +Escape returns focus to the editor. BusyMark collects successful copy and cut operations from document editors, plus supported content copied in another application after it is successfully @@ -59,6 +61,23 @@ restore, delete, or external-change operations. The checkpoint deadline is not postponed by continued typing, and the ordinary 1.5-second autosave does not create a revision per write. Unchanged adjacent content is deduplicated. +Typing, deletion, paste, selected-text formatting, Undo, Redo, and source +changes made to an inactive open document update the authoritative editor +buffer immediately. Local History observes the complete serialized source and +keeps only the latest eligible source pending for that document's current +checkpoint window. Pending work is not presented as a stored revision. When a +checkpoint or explicit-save capture succeeds, the already-open revision list +and any active content filter reconcile automatically; **Refresh** only rereads +stored history and never forces a capture or document save. + +If storage fails, the panel reports the failure and keeps the latest eligible +source pending in memory for a controlled retry. A newer edit replaces an +older pending source. The revision is not durable and is not described as +saved until storage actually succeeds. A document file can still save +successfully when this separate history write fails; destructive history +restore operations that require a protective revision remain blocked by such +a failure. + Revisions are grouped by local calendar date and use the localized time, including seconds, as the primary row label. Distinct events such as Saved, Before restore, or External change appear as secondary metadata. Automatic and @@ -94,7 +113,11 @@ but leaves existing revisions available until retention or explicit clearing. Clearing one document's history or all Local History permanently removes only stored revisions. It does not delete project files, Git data, recovery data, or -the system clipboard. +the system clipboard. Clearing the revision currently being compared closes +that transient comparison and returns to the unchanged editor. Scheduled or +in-flight work accepted before the clear cannot recreate the erased history; +new edits after the clear begin a normal checkpoint window and automatically +reattach the active document's list. ## What each recovery tool means diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 6ca84c9b..ad18fd0d 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -2430,6 +2430,265 @@ class BusyMarkSidebarNavigationTile extends StatelessWidget { } } +typedef BusyMarkSidebarRecordMenuBuilder = + List> Function(BuildContext context); + +/// The interactive record surface used by persistent sidebar lists. +/// +/// This is the shared form of the Recent-workspace row: a transparent rounded +/// surface with native hover treatment and, when supplied, one overflow and +/// context-menu action model. Callers can use the standard title/subtitle +/// identity layout or provide domain-specific [content]. +class BusyMarkSidebarRecordRow extends StatefulWidget { + const BusyMarkSidebarRecordRow({ + super.key, + required this.icon, + required this.onTap, + this.title, + this.subtitle, + this.content, + this.selected = false, + this.onDoubleTap, + this.semanticsLabel, + this.tooltip, + this.menuTooltip, + this.menuItemsBuilder, + this.onMenuSelected, + this.onMenuOpening, + }) : assert( + (title == null) != (content == null), + 'Supply either the standard title or custom content.', + ), + assert( + (menuItemsBuilder == null) == (onMenuSelected == null), + 'Menu items and selection handling must be supplied together.', + ), + assert( + menuItemsBuilder == null || menuTooltip != null, + 'Rows with a menu need a tooltip for its trigger.', + ); + + final IconData icon; + final String? title; + final String? subtitle; + final Widget? content; + final bool selected; + final VoidCallback onTap; + final VoidCallback? onDoubleTap; + final String? semanticsLabel; + final String? tooltip; + final String? menuTooltip; + final BusyMarkSidebarRecordMenuBuilder? menuItemsBuilder; + final ValueChanged? onMenuSelected; + final VoidCallback? onMenuOpening; + + @override + State> createState() => + _BusyMarkSidebarRecordRowState(); +} + +class _BusyMarkSidebarRecordRowState + extends State> { + final _rowKey = GlobalKey(); + late final FocusNode _rowFocusNode; + var _contextMenuOpen = false; + DateTime? _lastTapAt; + + bool get _hasMenu => + widget.menuItemsBuilder != null && widget.onMenuSelected != null; + + @override + void initState() { + super.initState(); + _rowFocusNode = FocusNode(debugLabel: 'BusyMark sidebar record row'); + } + + @override + void dispose() { + _rowFocusNode.dispose(); + super.dispose(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (!_hasMenu || !isBusyMarkContextMenuKeyEvent(event)) { + return KeyEventResult.ignored; + } + unawaited(_showContextMenu()); + return KeyEventResult.handled; + } + + void _handleTap() { + final onDoubleTap = widget.onDoubleTap; + if (onDoubleTap == null) { + widget.onTap(); + return; + } + final now = DateTime.now(); + final previous = _lastTapAt; + final doubleTap = + previous != null && now.difference(previous) <= kDoubleTapTimeout; + _lastTapAt = doubleTap ? null : now; + widget.onTap(); + if (doubleTap) onDoubleTap(); + } + + Future _showContextMenu([Offset? position]) async { + if (!_hasMenu || _contextMenuOpen) return; + final rowContext = _rowKey.currentContext; + if (rowContext == null) return; + + widget.onMenuOpening?.call(); + setState(() => _contextMenuOpen = true); + T? action; + try { + final items = widget.menuItemsBuilder!(rowContext); + action = position == null + ? await showBusyMarkMenu( + context: rowContext, + anchorContext: rowContext, + items: items, + focusFirst: true, + ) + : await showBusyMarkContextMenu( + rowContext, + position, + items: items, + ); + } finally { + if (mounted) setState(() => _contextMenuOpen = false); + } + if (mounted && action != null) widget.onMenuSelected!(action); + } + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final subtitle = widget.subtitle?.trim(); + final customContent = widget.content; + final menuItems = widget.menuItemsBuilder?.call(context); + final trailing = menuItems == null || menuItems.isEmpty + ? null + : BusyMarkMenuButton( + tooltip: widget.menuTooltip!, + items: menuItems, + onSelected: widget.onMenuSelected!, + triggerBuilder: (context, trigger) => trigger.anchor( + child: BusyMarkCompactIconButton( + tooltip: widget.menuTooltip!, + icon: BusyMarkGlyphs.menuVertical, + focusNode: trigger.focusNode, + onPressed: trigger.onPressed == null + ? null + : () { + widget.onMenuOpening?.call(); + trigger.onPressed!(); + }, + ), + ), + ); + final content = ConstrainedBox( + constraints: BoxConstraints( + minHeight: BusyMarkSizes.sidebarTreeRowHeight * 2, + ), + child: Row( + children: [ + const SizedBox(width: BusyMarkSpacing.sm), + Icon( + widget.icon, + size: BusyMarkSizes.iconSm, + color: colors.mutedForeground, + ), + const SizedBox(width: BusyMarkSpacing.sm), + Expanded( + child: Padding( + padding: customContent == null + ? EdgeInsets.zero + : const EdgeInsets.symmetric(vertical: BusyMarkSpacing.sm), + child: + customContent ?? + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.foreground, + ), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: BusyMarkSpacing.xxs), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.mutedForeground), + ), + ], + ], + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + trailing, + ], + const SizedBox(width: BusyMarkSpacing.sm), + ], + ), + ); + final row = KeyedSubtree( + key: _rowKey, + child: Focus( + onKeyEvent: _handleKeyEvent, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: BusyMarkStroke.hairline, + ), + child: Material( + color: widget.selected || _contextMenuOpen + ? busyMarkRowHoverColor(context) + : BusyMarkLinuxPalette.transparent, + borderRadius: BorderRadius.circular(BusyMarkRadius.md), + clipBehavior: Clip.antiAlias, + child: InkWell( + hoverColor: busyMarkRowHoverColor(context), + focusNode: _rowFocusNode, + onTap: _handleTap, + onSecondaryTapUp: !_hasMenu + ? null + : (details) { + _rowFocusNode.requestFocus(); + unawaited(_showContextMenu(details.globalPosition)); + }, + child: content, + ), + ), + ), + ), + ); + final semanticRow = Semantics( + container: true, + selected: widget.selected, + button: true, + label: widget.semanticsLabel, + onTap: _handleTap, + child: row, + ); + final tooltip = widget.tooltip; + return tooltip == null || tooltip.isEmpty + ? semanticRow + : Tooltip( + message: tooltip, + excludeFromSemantics: true, + child: semanticRow, + ); + } +} + class BusyMarkGroupedList extends StatelessWidget { const BusyMarkGroupedList({ super.key, diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 76ab095a..7a0c939d 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -1004,6 +1004,20 @@ void showLegacyBusyMarkKeyboardShortcutsDialog(BuildContext context) { BusyMarkSidebarShortcutLabels.git, ), ), + BusyMarkActionRow( + title: context.l10n.localHistoryEllipsis, + leading: const Icon(BusyMarkGlyphs.documentHistory), + trailing: const _KeyboardShortcutBadge( + BusyMarkSidebarShortcutLabels.localHistory, + ), + ), + BusyMarkActionRow( + title: context.l10n.clipboardHistory, + leading: const Icon(BusyMarkGlyphs.copy), + trailing: const _KeyboardShortcutBadge( + BusyMarkSidebarShortcutLabels.clipboardHistory, + ), + ), BusyMarkActionRow( title: context.l10n.delete, subtitle: context.l10n.shortcutDeleteTreeItemDescription, diff --git a/lib/src/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index d6334fb0..7d9e628d 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -850,7 +850,14 @@ abstract final class BusyMarkEditorShortcutActivators { BusyMarkEditorShortcuts.hardLineBreak.activator; } -enum BusyMarkSidebarShortcutAction { files, toc, outline, git } +enum BusyMarkSidebarShortcutAction { + files, + toc, + outline, + git, + localHistory, + clipboardHistory, +} abstract final class BusyMarkSidebarShortcuts { const BusyMarkSidebarShortcuts._(); @@ -859,6 +866,8 @@ abstract final class BusyMarkSidebarShortcuts { static const tocLabel = 'Ctrl+2'; static const outlineLabel = 'Ctrl+3'; static const gitLabel = 'Ctrl+4'; + static const localHistoryLabel = 'Ctrl+5'; + static const clipboardHistoryLabel = 'Ctrl+6'; static const toggleSidebar = BusyMarkAppShortcuts.toggleSidebar; static const files = BusyMarkShortcutDefinition( @@ -877,6 +886,14 @@ abstract final class BusyMarkSidebarShortcuts { label: gitLabel, activator: SingleActivator(LogicalKeyboardKey.digit4, control: true), ); + static const localHistory = BusyMarkShortcutDefinition( + label: localHistoryLabel, + activator: SingleActivator(LogicalKeyboardKey.digit5, control: true), + ); + static const clipboardHistory = BusyMarkShortcutDefinition( + label: clipboardHistoryLabel, + activator: SingleActivator(LogicalKeyboardKey.digit6, control: true), + ); static const definitions = { @@ -884,6 +901,8 @@ abstract final class BusyMarkSidebarShortcuts { BusyMarkSidebarShortcutAction.toc: toc, BusyMarkSidebarShortcutAction.outline: outline, BusyMarkSidebarShortcutAction.git: git, + BusyMarkSidebarShortcutAction.localHistory: localHistory, + BusyMarkSidebarShortcutAction.clipboardHistory: clipboardHistory, }; } @@ -895,6 +914,9 @@ abstract final class BusyMarkSidebarShortcutLabels { static const toc = BusyMarkSidebarShortcuts.tocLabel; static const outline = BusyMarkSidebarShortcuts.outlineLabel; static const git = BusyMarkSidebarShortcuts.gitLabel; + static const localHistory = BusyMarkSidebarShortcuts.localHistoryLabel; + static const clipboardHistory = + BusyMarkSidebarShortcuts.clipboardHistoryLabel; } abstract final class BusyMarkSidebarShortcutActivators { @@ -908,6 +930,10 @@ abstract final class BusyMarkSidebarShortcutActivators { static ShortcutActivator get outline => BusyMarkSidebarShortcuts.outline.activator; static ShortcutActivator get git => BusyMarkSidebarShortcuts.git.activator; + static ShortcutActivator get localHistory => + BusyMarkSidebarShortcuts.localHistory.activator; + static ShortcutActivator get clipboardHistory => + BusyMarkSidebarShortcuts.clipboardHistory.activator; static BusyMarkSidebarShortcutAction? actionForKeyEvent( KeyEvent event, @@ -931,6 +957,10 @@ abstract final class BusyMarkSidebarShortcutActivators { LogicalKeyboardKey.numpad3 => BusyMarkSidebarShortcutAction.outline, LogicalKeyboardKey.digit4 || LogicalKeyboardKey.numpad4 => BusyMarkSidebarShortcutAction.git, + LogicalKeyboardKey.digit5 || + LogicalKeyboardKey.numpad5 => BusyMarkSidebarShortcutAction.localHistory, + LogicalKeyboardKey.digit6 || LogicalKeyboardKey.numpad6 => + BusyMarkSidebarShortcutAction.clipboardHistory, _ => null, }; } diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index cc1d6057..33766699 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -368,11 +368,17 @@ abstract final class BusyMarkCommandCatalog { ), for (final action in BusyMarkSidebarShortcutAction.values) command( - id: 'sidebar.${action.name}', + id: _sidebarId(action), label: (context) => _sidebarLabel(context, action), category: (context) => context.l10n.shortcutGroupSidebar, - scope: BusyMarkCommandScope.sidebar, + scope: switch (action) { + BusyMarkSidebarShortcutAction.localHistory || + BusyMarkSidebarShortcutAction.clipboardHistory => + BusyMarkCommandScope.application, + _ => BusyMarkCommandScope.sidebar, + }, shortcut: BusyMarkSidebarShortcuts.definitions[action], + description: (context) => _sidebarDescription(context, action), ), for (final action in BusyMarkTreeShortcutAction.values) command( @@ -384,20 +390,6 @@ abstract final class BusyMarkCommandCatalog { description: (context) => context.l10n.shortcutDeleteTreeItemDescription, ), - command( - id: BusyMarkCommandIds.clipboardHistory, - label: (context) => context.l10n.clipboardHistory, - category: (context) => context.l10n.shortcutGroupSidebar, - scope: BusyMarkCommandScope.application, - description: (context) => context.l10n.clipboardHistoryDescription, - ), - command( - id: BusyMarkCommandIds.localHistory, - label: (context) => context.l10n.localHistoryEllipsis, - category: (context) => context.l10n.shortcutGroupSidebar, - scope: BusyMarkCommandScope.application, - description: (context) => context.l10n.localHistoryDescription, - ), command( id: BusyMarkCommandIds.findLocalHistory, label: (context) => context.l10n.findLocalHistoryEllipsis, @@ -653,6 +645,34 @@ abstract final class BusyMarkCommandCatalog { BusyMarkSidebarShortcutAction.toc => context.l10n.toc, BusyMarkSidebarShortcutAction.outline => context.l10n.outline, BusyMarkSidebarShortcutAction.git => context.l10n.git, + BusyMarkSidebarShortcutAction.localHistory => + context.l10n.localHistoryEllipsis, + BusyMarkSidebarShortcutAction.clipboardHistory => + context.l10n.clipboardHistory, + }; + + static String _sidebarId(BusyMarkSidebarShortcutAction action) => + switch (action) { + BusyMarkSidebarShortcutAction.files => BusyMarkCommandIds.sidebarFiles, + BusyMarkSidebarShortcutAction.toc => BusyMarkCommandIds.sidebarToc, + BusyMarkSidebarShortcutAction.outline => + BusyMarkCommandIds.sidebarOutline, + BusyMarkSidebarShortcutAction.git => BusyMarkCommandIds.sidebarGit, + BusyMarkSidebarShortcutAction.localHistory => + BusyMarkCommandIds.localHistory, + BusyMarkSidebarShortcutAction.clipboardHistory => + BusyMarkCommandIds.clipboardHistory, + }; + + static String? _sidebarDescription( + BuildContext context, + BusyMarkSidebarShortcutAction action, + ) => switch (action) { + BusyMarkSidebarShortcutAction.localHistory => + context.l10n.localHistoryDescription, + BusyMarkSidebarShortcutAction.clipboardHistory => + context.l10n.clipboardHistoryDescription, + _ => null, }; static BuildContext? get _focusedContext => diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart index 37282077..2f0e1752 100644 --- a/lib/src/clipboard/clipboard_history_panel.dart +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../l10n/generated/app_localizations.dart'; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/busymark_search_field.dart'; @@ -66,14 +67,12 @@ class _ClipboardHistoryPanelState extends ConsumerState { _matches(entry, query)) entry, ]; - if (visible.isNotEmpty && + if (_selectedId != null && !visible.any((entry) => entry.id == _selectedId)) { - _selectedId = visible.first.id; + _selectedId = null; } final visibleIds = visible.map((entry) => entry.id).toSet(); _entryKeys.removeWhere((id, _) => !visibleIds.contains(id)); - final target = registry.target; - return Focus( focusNode: _listFocusNode, onKeyEvent: (_, event) => _handleKey(event, visible, registry), @@ -87,50 +86,50 @@ class _ClipboardHistoryPanelState extends ConsumerState { BusyMarkSpacing.md, BusyMarkSpacing.sm, ), - child: BusyMarkSearchField( - controller: _searchController, - hintText: context.l10n.search, - onChanged: (_) => setState(() {}), - onEscape: widget.onEscape, - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.md, - vertical: BusyMarkSpacing.xs, - ), - child: Text( - target == null - ? context.l10n.clipboardSessionOnly - : context.l10n.clipboardDestination(target.documentName), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), child: Row( + textDirection: TextDirection.ltr, children: [ - IconButton( - tooltip: MaterialLocalizations.of( - context, - ).refreshIndicatorSemanticLabel, - onPressed: state.refreshing - ? null - : controller.refreshCurrentClipboard, - icon: state.refreshing - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(BusyMarkGlyphs.refresh), + Expanded( + child: BusyMarkSearchField( + controller: _searchController, + hintText: context.l10n.search, + onChanged: (_) => setState(() {}), + onEscape: widget.onEscape, + ), ), - const Spacer(), - IconButton( - tooltip: context.l10n.clipboardClearAll, - onPressed: state.entries.isEmpty ? null : controller.clear, - icon: const Icon(BusyMarkGlyphs.clearAll), + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderPopupMenuButton<_ClipboardHistoryAction>( + key: const ValueKey('clipboard-history-actions-menu'), + tooltip: context.l10n.actions, + icon: BusyMarkGlyphs.menuVertical, + transparent: true, + borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, + itemBuilder: (context) => [ + BusyMarkPopupMenuItem( + value: _ClipboardHistoryAction.refresh, + label: MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel, + icon: BusyMarkGlyphs.refresh, + enabled: !state.refreshing, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _ClipboardHistoryAction.clear, + label: context.l10n.clipboardClearAll, + icon: BusyMarkGlyphs.clearAll, + enabled: state.entries.isNotEmpty, + ), + ], + onSelected: (action) { + switch (action) { + case _ClipboardHistoryAction.refresh: + unawaited(controller.refreshCurrentClipboard()); + case _ClipboardHistoryAction.clear: + controller.clear(); + } + }, ), ], ), @@ -155,39 +154,38 @@ class _ClipboardHistoryPanelState extends ConsumerState { ? context.l10n.clipboardNoItems : context.l10n.clipboardUnavailable, ) - : SingleChildScrollView( + : ListView( key: const ValueKey('clipboard-history-list'), controller: _listScrollController, - child: Column( - children: [ - for (final payload in visible) - _ClipboardEntryTile( - key: _entryKeys.putIfAbsent( - payload.id, - () => GlobalKey( - debugLabel: 'Clipboard entry ${payload.id}', - ), + padding: BusyMarkInsets.sidebarList, + children: [ + for (final payload in visible) + _ClipboardEntryTile( + key: _entryKeys.putIfAbsent( + payload.id, + () => GlobalKey( + debugLabel: 'Clipboard entry ${payload.id}', ), - payload: payload, - current: identical(payload, current), - selected: payload.id == _selectedId, - canPaste: registry.canPaste(payload), - onSelect: () { - setState(() => _selectedId = payload.id); - _listFocusNode.requestFocus(); - }, - onPaste: () => _paste(payload, plainText: false), - onPastePlain: - payload.hasMeaningfulTextRepresentation && - registry.canPaste(payload, plainText: true) - ? () => _paste(payload, plainText: true) - : null, - onRemove: identical(payload, current) - ? null - : () => controller.remove(payload.id), ), - ], - ), + payload: payload, + current: identical(payload, current), + selected: payload.id == _selectedId, + canPaste: registry.canPaste(payload), + onSelect: () { + setState(() => _selectedId = payload.id); + _listFocusNode.requestFocus(); + }, + onPaste: () => _paste(payload, plainText: false), + onPastePlain: + payload.hasMeaningfulTextRepresentation && + registry.canPaste(payload, plainText: true) + ? () => _paste(payload, plainText: true) + : null, + onRemove: identical(payload, current) + ? null + : () => controller.remove(payload.id), + ), + ], ), ), ], @@ -285,6 +283,10 @@ class _ClipboardHistoryPanelState extends ConsumerState { } } +enum _ClipboardHistoryAction { refresh, clear } + +enum _ClipboardEntryAction { paste, pastePlain, remove } + class _ClipboardEntryTile extends StatelessWidget { const _ClipboardEntryTile({ super.key, @@ -315,173 +317,95 @@ class _ClipboardEntryTile extends StatelessWidget { BusyMarkClipboardContentKind.richText => l10n.clipboardEntryRichText, BusyMarkClipboardContentKind.image => l10n.clipboardEntryImage, }; - final origin = payload.origin; - final tooltip = origin == null - ? null - : '${l10n.clipboardOrigin(origin.documentName)}\n' - '${origin.documentPath ?? ''}'; - return Semantics( + final status = current && payload.external + ? l10n.clipboardCurrentExternal + : current + ? l10n.clipboardCurrent + : kindLabel; + final preview = _clipboardEntryPreview(payload, kindLabel); + final metadata = '$status · ${_timestamp(context, payload.acquiredAt)}'; + final colors = BusyMarkSurfaceColors.of(context); + return BusyMarkSidebarRecordRow<_ClipboardEntryAction>( + icon: payload.kind == BusyMarkClipboardContentKind.image + ? BusyMarkGlyphs.image + : BusyMarkGlyphs.copy, + content: Text( + preview, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.foreground), + ), selected: selected, - button: true, - label: '$kindLabel ${_timestamp(context, payload.acquiredAt)}', - child: InkWell( - onTap: onSelect, - onDoubleTap: canPaste ? onPaste : null, - child: Container( - color: selected - ? Theme.of(context).colorScheme.primaryContainer - : null, - padding: const EdgeInsets.fromLTRB( - BusyMarkSpacing.md, - BusyMarkSpacing.sm, - BusyMarkSpacing.xs, - BusyMarkSpacing.sm, + semanticsLabel: '$preview\n$metadata', + tooltip: _clipboardEntryTooltip(payload, l10n, metadata), + onTap: onSelect, + onDoubleTap: canPaste ? onPaste : null, + menuTooltip: l10n.actions, + menuItemsBuilder: (context) => [ + BusyMarkPopupMenuItem( + value: _ClipboardEntryAction.paste, + label: context.l10n.paste, + icon: BusyMarkGlyphs.paste, + enabled: canPaste, + ), + if (onPastePlain != null) + BusyMarkPopupMenuItem( + value: _ClipboardEntryAction.pastePlain, + label: context.l10n.clipboardPastePlainText, + icon: BusyMarkGlyphs.text, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon( - payload.kind == BusyMarkClipboardContentKind.image - ? BusyMarkGlyphs.image - : BusyMarkGlyphs.copy, - size: 16, - ), - const SizedBox(width: BusyMarkSpacing.xs), - Expanded( - child: Text( - current && payload.external - ? l10n.clipboardCurrentExternal - : current - ? l10n.clipboardCurrent - : kindLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium, - ), - ), - Text( - _timestamp(context, payload.acquiredAt), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.xs), - Tooltip( - message: tooltip ?? '', - child: _ClipboardPreview(payload, expanded: selected), - ), - if (origin != null) ...[ - const SizedBox(height: BusyMarkSpacing.xs), - Text( - l10n.clipboardOrigin(origin.documentName), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - tooltip: l10n.paste, - onPressed: canPaste ? onPaste : null, - icon: const Icon(BusyMarkGlyphs.paste, size: 18), - ), - if (onPastePlain != null) - IconButton( - tooltip: l10n.clipboardPastePlainText, - onPressed: onPastePlain, - icon: const Icon(BusyMarkGlyphs.text, size: 18), - ), - if (onRemove != null) - IconButton( - tooltip: l10n.removeAction, - onPressed: onRemove, - icon: const Icon(BusyMarkGlyphs.delete, size: 18), - ), - ], - ), - ], + if (onRemove != null) ...[ + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _ClipboardEntryAction.remove, + label: context.l10n.removeAction, + icon: BusyMarkGlyphs.delete, ), - ), - ), + ], + ], + onMenuOpening: onSelect, + onMenuSelected: (action) { + switch (action) { + case _ClipboardEntryAction.paste: + onPaste(); + case _ClipboardEntryAction.pastePlain: + onPastePlain?.call(); + case _ClipboardEntryAction.remove: + onRemove?.call(); + } + }, ); } } -class _ClipboardPreview extends StatefulWidget { - const _ClipboardPreview(this.payload, {required this.expanded}); - - final BusyMarkClipboardPayload payload; - final bool expanded; - - @override - State<_ClipboardPreview> createState() => _ClipboardPreviewState(); +String _clipboardEntryPreview( + BusyMarkClipboardPayload payload, + String fallback, +) { + final value = + payload.imageDisplayName ?? + payload.preferredSourceText ?? + payload.text ?? + fallback; + final preview = value.trim(); + return preview.isEmpty ? fallback : preview; } -class _ClipboardPreviewState extends State<_ClipboardPreview> { - ImageProvider? _image; - - @override - void didUpdateWidget(covariant _ClipboardPreview oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.payload.id != widget.payload.id) { - _image?.evict(); - _image = null; - } - } - - @override - void dispose() { - _image?.evict(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final payload = widget.payload; - final bytes = payload.imageBytes; - if (bytes != null && widget.expanded) { - final image = _image ??= ResizeImage.resizeIfNeeded( - 512, - 512, - MemoryImage(bytes), - ); - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 128), - child: Image( - // At most one selected preview is decoded, and both dimensions are - // bounded. RGBA decoding therefore stays well below the policy's - // separate 8 MiB thumbnail-cache budget. - image: image, - fit: BoxFit.contain, - errorBuilder: (_, _, _) { - return const Center(child: Icon(BusyMarkGlyphs.imageMissing)); - }, - ), - ); - } - if (bytes != null) { - final prior = _image; - _image = null; - if (prior != null) unawaited(prior.evict()); - return Text( - payload.imageDisplayName ?? context.l10n.clipboardEntryImage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - return Text( - payload.preferredSourceText ?? payload.text ?? '', - maxLines: widget.expanded ? 6 : 2, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), - ); - } +String _clipboardEntryTooltip( + BusyMarkClipboardPayload payload, + AppLocalizations l10n, + String metadata, +) { + final origin = payload.origin; + final originPath = origin?.documentPath; + final parts = [ + metadata, + if (origin != null) l10n.clipboardOrigin(origin.documentName), + if (originPath != null && originPath.trim().isNotEmpty) originPath.trim(), + ]; + return parts.join('\n'); } class _PanelNotice extends StatelessWidget { diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 906fa12c..d24297b8 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -271,6 +271,11 @@ class LocalHistoryController extends Notifier { final _pathTransitions = {}; final _pendingUntitledPromotions = {}; + final _bufferGenerations = {}; + final _captureFailures = {}; + LocalHistoryBufferSnapshot? _normalBrowsingScope; + var _historyGeneration = 0; + var _snapshotGeneration = 0; var _loadGeneration = 0; var _searchGeneration = 0; var _scopeGeneration = 0; @@ -280,9 +285,10 @@ class LocalHistoryController extends Notifier { _store = ref.read(localHistoryStoreProvider); _clock = ref.read(localHistoryClockProvider); _timerFactory = ref.read(localHistoryTimerFactoryProvider); - ref.listen(appSettingsControllerProvider, (previous, next) { - if (!next.localHistoryRecordingEnabled) _cancelCheckpoints(); - }); + ref.listen( + appSettingsControllerProvider, + (previous, next) => _applyRecordingPolicy(next), + ); ref.onDispose(_cancelCheckpoints); Future.microtask(refresh); return const LocalHistoryState(loading: true); @@ -301,6 +307,9 @@ class LocalHistoryController extends Notifier { ); } + LocalHistoryBufferSnapshot? pendingSnapshotForBuffer(String bufferId) => + _pending[bufferId]; + Future refresh() async { final generation = ++_loadGeneration; if (ref.mounted) state = state.copyWith(loading: true); @@ -308,17 +317,8 @@ class LocalHistoryController extends Notifier { await _store.prune(policy, _clock()); final snapshot = await _store.load(); if (!ref.mounted || generation != _loadGeneration) return; - final selectedDocumentId = state.selectedDocumentId; - state = state.copyWith( - snapshot: snapshot, - loading: false, - selectedDocumentId: - selectedDocumentId != null && - snapshot.documents.any( - (document) => document.id == selectedDocumentId, - ) - ? selectedDocumentId - : null, + await _publishSnapshot( + snapshot, warning: snapshot.warning == null ? null : const LocalHistoryWarning(LocalHistoryWarningKind.indexRebuilt), @@ -348,7 +348,16 @@ class LocalHistoryController extends Notifier { final snapshot = LocalHistoryBufferSnapshot.fromBuffer(buffer); if (snapshot.untitled && snapshot.text.isEmpty) return; final adoptedPromotion = _adoptPendingPromotionForOpenedBuffer(snapshot); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); await _enqueue(snapshot.bufferId, () async { + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { + return; + } final promotion = _pendingUntitledPromotions[snapshot.bufferId]; if (promotion != null) { if (!_sameOptionalPath(snapshot.path, promotion.destinationPath)) { @@ -358,13 +367,10 @@ class LocalHistoryController extends Notifier { if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { return; } - final pending = _pending.remove(snapshot.bufferId); + final pending = _pending[snapshot.bufferId]; _checkpointTimers.remove(snapshot.bufferId)?.cancel(); if (pending != null) { - await _capture( - pending, - LocalHistoryCaptureReason.automaticCheckpoint, - ); + await _capturePending(snapshot.bufferId, pending); } await _capture(snapshot, LocalHistoryCaptureReason.baseline); return; @@ -379,14 +385,29 @@ class LocalHistoryController extends Notifier { if (!policy.recordingEnabled || policy.excludes(current.filePath)) return; final previousSnapshot = LocalHistoryBufferSnapshot.fromBuffer(previous); final currentSnapshot = LocalHistoryBufferSnapshot.fromBuffer(current); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(current.id); unawaited( _enqueue(current.id, () async { - if (!ref.mounted) return; + if (!_operationIsCurrent( + current.id, + historyGeneration, + bufferGeneration, + )) { + return; + } if (!_documentIdsByBuffer.containsKey(current.id) && !(previousSnapshot.untitled && previousSnapshot.text.isEmpty)) { await _capture(previousSnapshot, LocalHistoryCaptureReason.baseline); } - if (!ref.mounted) return; + if (!_operationIsCurrent( + current.id, + historyGeneration, + bufferGeneration, + )) { + return; + } + if (!_pendingIsEligible(currentSnapshot)) return; _pending[current.id] = currentSnapshot; _scheduleCheckpoint(current.id); }), @@ -446,10 +467,27 @@ class LocalHistoryController extends Notifier { } Future captureSaved(LocalHistoryBufferSnapshot snapshot) async { - return _enqueue( - snapshot.bufferId, - () => _capture(snapshot, LocalHistoryCaptureReason.saved), - ); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); + return _enqueue(snapshot.bufferId, () async { + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + final captured = await _capture( + snapshot, + LocalHistoryCaptureReason.saved, + ); + if (captured) { + _acknowledgePending(snapshot.bufferId, snapshot.revision); + } else { + _retainPending(snapshot); + } + return captured; + }); } Future captureProtective( @@ -521,13 +559,17 @@ class LocalHistoryController extends Notifier { if (pending != null && pending.revision <= source.revision && _sameOptionalPath(pending.path, source.path)) { - _pending.remove(source.bufferId); _checkpointTimers.remove(source.bufferId)?.cancel(); final pendingCaptured = await _capture( pending, LocalHistoryCaptureReason.automaticCheckpoint, allowDuringPathTransition: true, ); + if (pendingCaptured) { + _acknowledgePending(source.bufferId, pending.revision); + } else { + _retainPending(pending); + } if (!pendingCaptured && policy.recordingEnabled && !policy.excludes(pending.path) && @@ -554,11 +596,17 @@ class LocalHistoryController extends Notifier { if (!policy.recordingEnabled || policy.excludes(destination.path)) { return source.untitled && !destinationExisted && promotionCompleted; } - return _capture( + final savedCaptured = await _capture( destination, LocalHistoryCaptureReason.saved, ignoreBinding: !source.untitled || destinationExisted, ); + if (savedCaptured) { + _acknowledgePending(destination.bufferId, destination.revision); + } else { + _retainPending(destination); + } + return savedCaptured; }); if (captured) { final document = state.snapshot.documents @@ -588,18 +636,17 @@ class LocalHistoryController extends Notifier { required LocalHistoryCaptureReason reason, bool force = true, }) { - return _capture( - LocalHistoryBufferSnapshot( - bufferId: 'path:${p.normalize(path)}', - displayName: p.basename(path), - text: text, - format: format, - revision: 0, - path: path, - ), - reason, - force: force, - ignoreBinding: true, + final snapshot = LocalHistoryBufferSnapshot( + bufferId: 'path:${p.normalize(path)}', + displayName: p.basename(path), + text: text, + format: format, + revision: 0, + path: path, + ); + return _enqueue( + snapshot.bufferId, + () => _capture(snapshot, reason, force: force, ignoreBinding: true), ); } @@ -636,23 +683,20 @@ class LocalHistoryController extends Notifier { Future flushBuffer(DocumentBuffer buffer) async { _checkpointTimers.remove(buffer.id)?.cancel(); - final pending = _pending.remove(buffer.id); - if (pending != null) { - await _enqueue( - buffer.id, - () => _capture( - LocalHistoryBufferSnapshot.fromBuffer(buffer), - LocalHistoryCaptureReason.automaticCheckpoint, - ), - ); - } - if (_pendingUntitledPromotions.containsKey(buffer.id)) { - return _enqueue( - buffer.id, - () => _completePendingUntitledPromotion(buffer.id), - ); - } - return true; + return _enqueue(buffer.id, () async { + _checkpointTimers.remove(buffer.id)?.cancel(); + var pending = _pending[buffer.id]; + if (pending != null) { + final current = LocalHistoryBufferSnapshot.fromBuffer(buffer); + if (current.revision >= pending.revision) pending = current; + if (!await _capturePending(buffer.id, pending)) return false; + } + if (_pendingUntitledPromotions.containsKey(buffer.id) && + !await _completePendingUntitledPromotion(buffer.id)) { + return false; + } + return !_pending.containsKey(buffer.id); + }); } Future flushPendingIdentityPromotions() async { @@ -660,11 +704,10 @@ class LocalHistoryController extends Notifier { for (final bufferId in _pendingUntitledPromotions.keys.toList()) { if (!await _enqueue(bufferId, () async { if (!await _completePendingUntitledPromotion(bufferId)) return false; - final pending = _pending.remove(bufferId); + final pending = _pending[bufferId]; _checkpointTimers.remove(bufferId)?.cancel(); if (pending == null) return true; - await _capture(pending, LocalHistoryCaptureReason.automaticCheckpoint); - return true; + return _capturePending(bufferId, pending); })) { succeeded = false; } @@ -673,15 +716,19 @@ class LocalHistoryController extends Notifier { } Future flushAll(Iterable buffers) async { + final bufferList = buffers.toList(growable: false); var succeeded = true; - for (final buffer in buffers) { + for (final buffer in bufferList) { if (!await flushBuffer(buffer)) succeeded = false; } if (!await flushPendingIdentityPromotions()) succeeded = false; while (_bufferQueues.isNotEmpty) { await Future.wait(_bufferQueues.values.toList(growable: false)); } - return succeeded && _pendingUntitledPromotions.isEmpty; + final bufferIds = bufferList.map((buffer) => buffer.id).toSet(); + return succeeded && + _pendingUntitledPromotions.isEmpty && + _pending.keys.every((bufferId) => !bufferIds.contains(bufferId)); } Future remapPath(String sourcePath, String destinationPath) async { @@ -703,15 +750,17 @@ class LocalHistoryController extends Notifier { } } for (final entry in affected) { - _pending.remove(entry.key); _checkpointTimers.remove(entry.key)?.cancel(); - await _enqueue( + final captured = await _enqueue( entry.key, () => _capture( entry.value, LocalHistoryCaptureReason.automaticCheckpoint, ), ); + if (captured) { + _acknowledgePending(entry.key, entry.value.revision); + } } final affectedPromotions = _pendingUntitledPromotions.entries .where( @@ -742,6 +791,20 @@ class LocalHistoryController extends Notifier { } } await _store.remapPath(sourcePath, destinationPath); + for (final entry in affected) { + final pending = _pending[entry.key]; + final pendingPath = pending?.path; + if (pending == null || pendingPath == null) continue; + final remapped = _remapHistoryPath( + pendingPath, + sourcePath, + destinationPath, + ); + if (remapped != null) { + _pending[entry.key] = pending.atPath(remapped); + _scheduleCheckpoint(entry.key); + } + } await refresh(); } on Object catch (error) { _setWarning(LocalHistoryWarningKind.pathChange, error.toString()); @@ -759,6 +822,7 @@ class LocalHistoryController extends Notifier { Future selectDocumentForBuffer(DocumentBuffer buffer) async { final generation = ++_scopeGeneration; + _normalBrowsingScope = LocalHistoryBufferSnapshot.fromBuffer(buffer); _loadGeneration++; _searchGeneration++; state = state.copyWith( @@ -813,6 +877,7 @@ class LocalHistoryController extends Notifier { void clearDocumentScope() { _scopeGeneration++; + _normalBrowsingScope = null; _loadGeneration++; _searchGeneration++; state = state.copyWith( @@ -957,66 +1022,73 @@ class LocalHistoryController extends Notifier { _documentIdsByBuffer[bufferId]; Future search(String query) async { - final normalized = query; - final generation = ++_searchGeneration; - state = state.copyWith( - searchQuery: normalized, - searching: normalized.isNotEmpty, - searchMatches: const {}, - documentSearchMatches: const {}, - ); - if (normalized.isEmpty) return; - final documentId = state.selectedDocumentId; - final summaries = state.findingDocuments - ? state.snapshot.revisions - : documentId == null - ? const [] - : state.snapshot.revisionsFor(documentId); - final matches = {}; - final documentMatches = {}; - final needle = normalized.toLowerCase(); - if (state.findingDocuments) { - for (final document in state.snapshot.documents) { - if (document.displayName.toLowerCase().contains(needle) || - (document.currentPath?.toLowerCase().contains(needle) ?? false) || - document.historicalPaths.any( - (path) => path.toLowerCase().contains(needle), - )) { - documentMatches.add(document.id); - } - } - } - for (final summary in summaries) { - final revision = await _store.readRevision(summary.id); - if (!ref.mounted || generation != _searchGeneration) return; - if (revision?.source.toLowerCase().contains(needle) == true) { - matches.add(summary.id); - documentMatches.add(summary.documentId); - } - } - if (!ref.mounted || generation != _searchGeneration) return; - state = state.copyWith( - searching: false, - searchMatches: matches, - documentSearchMatches: documentMatches, - ); + await _runSearch(query, clearMatches: true); } Future clearDocument(String documentId) async { - await _store.clearDocument(documentId); + final document = state.snapshot.documents + .where((candidate) => candidate.id == documentId) + .firstOrNull; + final documentPaths = { + if (document?.currentPath case final path?) p.normalize(path), + for (final path in document?.historicalPaths ?? const []) + p.normalize(path), + }; + final affectedBuffers = { + ..._documentIdsByBuffer.entries + .where((entry) => entry.value == documentId) + .map((entry) => entry.key), + ..._pending.entries + .where( + (entry) => + entry.value.path != null && + documentPaths.any((path) => p.equals(path, entry.value.path!)), + ) + .map((entry) => entry.key), + for (final path in documentPaths) + if (_bufferQueues.containsKey('path:$path')) 'path:$path', + if (_normalBrowsingScope case final scope? + when scope.path != null && + documentPaths.any((path) => p.equals(path, scope.path!))) + scope.bufferId, + }.toList(growable: false); + for (final bufferId in affectedBuffers) { + _invalidateBufferWork(bufferId); + } + _loadGeneration++; + _searchGeneration++; _documentIdsByBuffer.removeWhere((_, value) => value == documentId); _pendingUntitledPromotions.removeWhere( (_, promotion) => promotion.documentId == documentId, ); - await refresh(); + await _runWithBlockedBufferQueues( + affectedBuffers, + () => _store.clearDocument(documentId), + ); + if (!ref.mounted) return; + final snapshot = await _store.load(); + if (!ref.mounted) return; + await _publishSnapshot(snapshot, explicitlyClearedDocumentId: documentId); } Future clearAll() async { - _cancelCheckpoints(); + final affectedBuffers = { + ..._documentIdsByBuffer.keys, + ..._pending.keys, + ..._bufferQueues.keys, + }; + _historyGeneration++; + _loadGeneration++; + _searchGeneration++; + _cancelCheckpoints(clearTransitions: false); + _captureFailures.clear(); _documentIdsByBuffer.clear(); _pendingUntitledPromotions.clear(); - await _store.clearAll(); - await refresh(); + await _runWithBlockedBufferQueues(affectedBuffers, _store.clearAll); + if (!ref.mounted) return; + final snapshot = await _store.load(); + if (!ref.mounted) return; + await _publishSnapshot(snapshot, clearAllComparisons: true); } LocalHistoryPendingIdentityPromotion? _adoptPendingPromotionForOpenedBuffer( @@ -1073,6 +1145,8 @@ class LocalHistoryController extends Notifier { bool allowPathChange = false, bool allowDuringPathTransition = false, }) async { + final acceptedHistoryGeneration = _historyGeneration; + final acceptedBufferGeneration = _bufferGeneration(snapshot.bufferId); if (reason == LocalHistoryCaptureReason.automaticCheckpoint && !allowDuringPathTransition && _pathTransitions.containsKey(snapshot.bufferId)) { @@ -1120,18 +1194,44 @@ class LocalHistoryController extends Notifier { ), currentPolicy, ); + if (!_operationIsCurrent( + snapshot.bufferId, + acceptedHistoryGeneration, + acceptedBufferGeneration, + )) { + return true; + } if (!ignoreBinding) { _documentIdsByBuffer[snapshot.bufferId] = result.document.id; } + _captureFailures.remove(snapshot.bufferId); if (ref.mounted) { final loaded = await _store.load(); - if (ref.mounted) { - state = state.copyWith(snapshot: loaded, warning: null); + if (_operationIsCurrent( + snapshot.bufferId, + acceptedHistoryGeneration, + acceptedBufferGeneration, + )) { + await _publishSnapshot( + loaded, + capturedBufferId: ignoreBinding ? null : snapshot.bufferId, + capturedSnapshot: ignoreBinding ? null : snapshot, + ); } } return true; } on Object catch (error) { - _setWarning(LocalHistoryWarningKind.capture, error.toString()); + if (_operationIsCurrent( + snapshot.bufferId, + acceptedHistoryGeneration, + acceptedBufferGeneration, + )) { + _captureFailures[snapshot.bufferId] = _LocalHistoryCaptureFailure( + detail: error.toString(), + retryable: _captureErrorIsRetryable(error), + ); + _showCaptureFailure(); + } return false; } } @@ -1155,7 +1255,7 @@ class LocalHistoryController extends Notifier { if (ref.mounted) { final loaded = await _store.load(); if (ref.mounted) { - state = state.copyWith(snapshot: loaded, warning: null); + await _publishSnapshot(loaded, capturedBufferId: bufferId); } } return true; @@ -1178,13 +1278,43 @@ class LocalHistoryController extends Notifier { return result; } - void _cancelCheckpoints() { + Future _runWithBlockedBufferQueues( + Iterable bufferIds, + Future Function() operation, + ) async { + final entered = >[]; + final gates = >[]; + final barriers = >[]; + for (final bufferId in bufferIds.toSet()) { + final didEnter = Completer(); + final gate = Completer(); + entered.add(didEnter.future); + gates.add(gate); + barriers.add( + _enqueue(bufferId, () async { + didEnter.complete(); + await gate.future; + }), + ); + } + try { + await Future.wait(entered); + await operation(); + } finally { + for (final gate in gates) { + if (!gate.isCompleted) gate.complete(); + } + await Future.wait(barriers); + } + } + + void _cancelCheckpoints({bool clearTransitions = true}) { for (final timer in _checkpointTimers.values) { timer.cancel(); } _checkpointTimers.clear(); _pending.clear(); - _pathTransitions.clear(); + if (clearTransitions) _pathTransitions.clear(); } void _scheduleCheckpoint(String bufferId) { @@ -1197,22 +1327,306 @@ class LocalHistoryController extends Notifier { () => _timerFactory(policy.checkpointInterval, () { _checkpointTimers.remove(bufferId); if (!ref.mounted || _pathTransitions.containsKey(bufferId)) return; - final latest = _pending.remove(bufferId); + final latest = _pending[bufferId]; if (latest != null) { unawaited( - _enqueue( - bufferId, - () => _capture( - latest, - LocalHistoryCaptureReason.automaticCheckpoint, - ), - ), + _enqueue(bufferId, () => _capturePending(bufferId, latest)), ); } }), ); } + Future _capturePending( + String bufferId, + LocalHistoryBufferSnapshot snapshot, + ) async { + if (!_pendingIsEligible(snapshot)) { + _pending.remove(bufferId); + _checkpointTimers.remove(bufferId)?.cancel(); + _captureFailures.remove(bufferId); + return true; + } + final captured = await _capture( + snapshot, + LocalHistoryCaptureReason.automaticCheckpoint, + ); + if (captured && !_pathTransitions.containsKey(bufferId)) { + _acknowledgePending(bufferId, snapshot.revision); + } + final remaining = _pending[bufferId]; + if (remaining != null) { + final failure = _captureFailures[bufferId]; + if (captured || failure?.retryable == true) { + _scheduleCheckpoint(bufferId); + } + } + return captured && !_pending.containsKey(bufferId); + } + + void _retainPending(LocalHistoryBufferSnapshot snapshot) { + if (!_pendingIsEligible(snapshot)) return; + final current = _pending[snapshot.bufferId]; + if (current == null || current.revision <= snapshot.revision) { + _pending[snapshot.bufferId] = snapshot; + } + if (_captureFailures[snapshot.bufferId]?.retryable != false) { + _scheduleCheckpoint(snapshot.bufferId); + } + } + + void _acknowledgePending(String bufferId, int capturedRevision) { + final pending = _pending[bufferId]; + if (pending != null && pending.revision <= capturedRevision) { + _pending.remove(bufferId); + _checkpointTimers.remove(bufferId)?.cancel(); + } + } + + bool _pendingIsEligible(LocalHistoryBufferSnapshot snapshot) { + final currentPolicy = policy; + return currentPolicy.recordingEnabled && + !currentPolicy.excludes(snapshot.path); + } + + int _bufferGeneration(String bufferId) => _bufferGenerations[bufferId] ?? 0; + + bool _operationIsCurrent( + String bufferId, + int historyGeneration, + int bufferGeneration, + ) => + ref.mounted && + historyGeneration == _historyGeneration && + bufferGeneration == _bufferGeneration(bufferId); + + void _invalidateBufferWork(String bufferId) { + _bufferGenerations[bufferId] = _bufferGeneration(bufferId) + 1; + _checkpointTimers.remove(bufferId)?.cancel(); + _pending.remove(bufferId); + _captureFailures.remove(bufferId); + } + + void _applyRecordingPolicy(AppSettings settings) { + if (!settings.localHistoryRecordingEnabled) { + _historyGeneration++; + _cancelCheckpoints(clearTransitions: false); + _captureFailures.clear(); + return; + } + for (final entry in _pending.entries.toList(growable: false)) { + if (policy.excludes(entry.value.path)) { + _invalidateBufferWork(entry.key); + } + } + } + + bool _captureErrorIsRetryable(Object error) { + if (error is UnsupportedLocalHistoryFormat) return false; + if (error is LocalHistoryStorageException && + error.message.contains('larger than the Local History storage limit')) { + return false; + } + return true; + } + + void _showCaptureFailure() { + final failure = _captureFailures.values.firstOrNull; + if (failure != null) { + _setWarning(LocalHistoryWarningKind.capture, failure.detail); + } + } + + Future _publishSnapshot( + LocalHistorySnapshot snapshot, { + String? capturedBufferId, + LocalHistoryBufferSnapshot? capturedSnapshot, + LocalHistoryWarning? warning, + String? explicitlyClearedDocumentId, + bool clearAllComparisons = false, + }) async { + if (!ref.mounted) return; + _snapshotGeneration++; + _searchGeneration++; + if (capturedBufferId != null && + _normalBrowsingScope?.bufferId == capturedBufferId && + capturedSnapshot != null && + capturedSnapshot.revision >= _normalBrowsingScope!.revision) { + _normalBrowsingScope = capturedSnapshot; + } + + var selectedDocumentId = state.selectedDocumentId; + if (!state.findingDocuments && !state.inspectingRetainedDocument) { + final scope = _normalBrowsingScope; + if (scope != null) { + selectedDocumentId = _documentIdsByBuffer[scope.bufferId]; + if (selectedDocumentId != null && + !snapshot.documents.any( + (document) => document.id == selectedDocumentId, + )) { + selectedDocumentId = null; + } + if (selectedDocumentId == null && scope.path != null) { + final byPath = snapshot.documents + .where( + (document) => + document.currentPath != null && + p.equals(document.currentPath!, scope.path!), + ) + .firstOrNull; + if (byPath != null) { + selectedDocumentId = byPath.id; + _documentIdsByBuffer[scope.bufferId] = byPath.id; + } + } + } + } + if (selectedDocumentId != null && + !snapshot.documents.any( + (document) => document.id == selectedDocumentId, + )) { + selectedDocumentId = null; + } + + var selectedRevisionId = state.selectedRevisionId; + var selectedRevision = state.selectedRevision; + var effectiveWarning = _captureFailures.isNotEmpty + ? LocalHistoryWarning( + LocalHistoryWarningKind.capture, + detail: _captureFailures.values.first.detail, + ) + : warning; + final selectedSummary = selectedRevisionId == null + ? null + : snapshot.revisions + .where((revision) => revision.id == selectedRevisionId) + .firstOrNull; + final comparisonWasExplicitlyCleared = + clearAllComparisons || + (explicitlyClearedDocumentId != null && + ((state.selectedDocumentId == explicitlyClearedDocumentId && + selectedRevisionId != null) || + selectedRevision?.summary.documentId == + explicitlyClearedDocumentId || + selectedSummary?.documentId == explicitlyClearedDocumentId)); + if (selectedRevisionId != null && + (selectedSummary == null || + selectedSummary.documentId != selectedDocumentId)) { + selectedRevisionId = null; + selectedRevision = null; + if (!comparisonWasExplicitlyCleared && effectiveWarning == null) { + effectiveWarning = const LocalHistoryWarning( + LocalHistoryWarningKind.revisionMissing, + ); + } + } + if (comparisonWasExplicitlyCleared) { + selectedRevisionId = null; + selectedRevision = null; + } + + final query = state.searchQuery; + state = state.copyWith( + snapshot: snapshot, + loading: false, + selectedDocumentId: selectedDocumentId, + selectedRevisionId: selectedRevisionId, + selectedRevision: selectedRevision, + warning: effectiveWarning, + ); + if (query.isNotEmpty) { + await _runSearch(query, clearMatches: false); + } else { + final revisionIds = snapshot.revisions + .map((revision) => revision.id) + .toSet(); + final documentIds = snapshot.documents + .map((document) => document.id) + .toSet(); + state = state.copyWith( + searchMatches: state.searchMatches.intersection(revisionIds), + documentSearchMatches: state.documentSearchMatches.intersection( + documentIds, + ), + ); + } + } + + Future _runSearch(String query, {required bool clearMatches}) async { + final generation = ++_searchGeneration; + final snapshotGeneration = _snapshotGeneration; + final documentId = state.selectedDocumentId; + final findingDocuments = state.findingDocuments; + state = state.copyWith( + searchQuery: query, + searching: query.isNotEmpty, + searchMatches: clearMatches ? const {} : state.searchMatches, + documentSearchMatches: clearMatches + ? const {} + : state.documentSearchMatches, + ); + if (query.isEmpty) { + state = state.copyWith( + searching: false, + searchMatches: const {}, + documentSearchMatches: const {}, + ); + return; + } + final snapshot = state.snapshot; + final summaries = findingDocuments + ? snapshot.revisions + : documentId == null + ? const [] + : snapshot.revisionsFor(documentId); + final matches = {}; + final documentMatches = {}; + final needle = query.toLowerCase(); + bool current() => + ref.mounted && + generation == _searchGeneration && + snapshotGeneration == _snapshotGeneration && + state.searchQuery == query && + state.selectedDocumentId == documentId && + state.findingDocuments == findingDocuments; + try { + if (findingDocuments) { + for (final document in snapshot.documents) { + if (document.displayName.toLowerCase().contains(needle) || + (document.currentPath?.toLowerCase().contains(needle) ?? false) || + document.historicalPaths.any( + (path) => path.toLowerCase().contains(needle), + )) { + documentMatches.add(document.id); + } + } + } + for (final summary in summaries) { + final revision = await _store.readRevision(summary.id); + if (!current()) return; + if (revision?.source.toLowerCase().contains(needle) == true) { + matches.add(summary.id); + documentMatches.add(summary.documentId); + } + } + if (!current()) return; + state = state.copyWith( + searching: false, + searchMatches: matches, + documentSearchMatches: documentMatches, + ); + } on Object catch (error) { + if (!current()) return; + state = state.copyWith( + searching: false, + warning: LocalHistoryWarning( + LocalHistoryWarningKind.revisionRead, + detail: error.toString(), + ), + ); + } + } + void _setWarning(LocalHistoryWarningKind kind, [String? detail]) { if (ref.mounted) { state = state.copyWith( @@ -1222,6 +1636,16 @@ class LocalHistoryController extends Notifier { } } +class _LocalHistoryCaptureFailure { + const _LocalHistoryCaptureFailure({ + required this.detail, + required this.retryable, + }); + + final String detail; + final bool retryable; +} + bool _sameOptionalPath(String? first, String? second) { if (first == null || second == null) return first == second; return p.equals(first, second); diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index 5615ed3a..fb8e01d8 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' show DateFormat; import 'package:path/path.dart' as p; import '../app/busymark_design.dart'; @@ -58,6 +58,12 @@ class _LocalHistoryPanelState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(localHistoryControllerProvider); + if (_searchController.text != state.searchQuery) { + _searchController.value = TextEditingValue( + text: state.searchQuery, + selection: TextSelection.collapsed(offset: state.searchQuery.length), + ); + } final controller = ref.read(localHistoryControllerProvider.notifier); final workspaceState = ref.watch(workspaceControllerProvider); final activeBuffer = workspaceState.activeBuffer; @@ -134,36 +140,79 @@ class _LocalHistoryPanelState extends ConsumerState { selectedDocument: selected, findingDocuments: state.findingDocuments, inspectingRetainedDocument: state.inspectingRetainedDocument, - loading: state.loading, - canClearDocument: - selectedMatchesVisibleScope && state.selectedRevisions.isNotEmpty, - canClearAll: state.snapshot.revisions.isNotEmpty, onBack: () => unawaited(_selectActive(preserveLookup: false)), - onRefresh: controller.refresh, - onAction: (action) { - switch (action) { - case _HistoryAction.find: - controller.beginDocumentSearch(); - case _HistoryAction.clearDocument: - final documentId = selected?.id; - if (documentId != null) { - unawaited(_confirmClear(action, documentId)); - } - case _HistoryAction.clearAll: - unawaited(_confirmClear(action, null)); - } - }, ), Padding( padding: const EdgeInsets.symmetric( horizontal: BusyMarkSpacing.md, vertical: BusyMarkSpacing.xs, ), - child: BusyMarkSearchField( - controller: _searchController, - focusRequest: widget.focusSearchRequest, - hintText: context.l10n.localHistorySearchHint, - onChanged: (value) => unawaited(controller.search(value)), + child: Row( + textDirection: TextDirection.ltr, + children: [ + Expanded( + child: BusyMarkSearchField( + controller: _searchController, + focusRequest: widget.focusSearchRequest, + hintText: context.l10n.localHistorySearchHint, + onChanged: (value) => unawaited(controller.search(value)), + ), + ), + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderPopupMenuButton<_HistoryAction>( + key: const ValueKey('local-history-actions-menu'), + tooltip: context.l10n.actions, + icon: BusyMarkGlyphs.menuVertical, + transparent: true, + borderRadius: BusyMarkRadius.nativeHeaderButton, + highlightWhenOpen: false, + itemBuilder: (context) => [ + BusyMarkPopupMenuItem( + value: _HistoryAction.refresh, + label: MaterialLocalizations.of( + context, + ).refreshIndicatorSemanticLabel, + icon: BusyMarkGlyphs.refresh, + enabled: !state.loading, + ), + BusyMarkPopupMenuItem( + value: _HistoryAction.find, + label: context.l10n.findLocalHistoryEllipsis, + icon: BusyMarkGlyphs.search, + ), + const PopupMenuDivider(height: BusyMarkSpacing.sm), + BusyMarkPopupMenuItem( + value: _HistoryAction.clearDocument, + label: context.l10n.localHistoryClearDocument, + icon: BusyMarkGlyphs.delete, + enabled: + selectedMatchesVisibleScope && + state.selectedRevisions.isNotEmpty, + ), + BusyMarkPopupMenuItem( + value: _HistoryAction.clearAll, + label: context.l10n.localHistoryClearAll, + icon: BusyMarkGlyphs.clearAll, + enabled: state.snapshot.revisions.isNotEmpty, + ), + ], + onSelected: (action) { + switch (action) { + case _HistoryAction.refresh: + unawaited(controller.refresh()); + case _HistoryAction.find: + controller.beginDocumentSearch(); + case _HistoryAction.clearDocument: + final documentId = selected?.id; + if (documentId != null) { + unawaited(_confirmClear(action, documentId)); + } + case _HistoryAction.clearAll: + unawaited(_confirmClear(action, null)); + } + }, + ), + ], ), ), if (state.warning != null) @@ -195,35 +244,26 @@ class _LocalHistoryPanelState extends ConsumerState { key: const ValueKey( 'local-history-document-search-results', ), + padding: BusyMarkInsets.sidebarList, itemCount: visibleDocuments.length, itemBuilder: (context, index) { final document = visibleDocuments[index]; final path = document.currentPath ?? document.historicalPaths.lastOrNull; - return Semantics( - button: true, - child: ListTile( - leading: Icon( - document.deleted - ? BusyMarkGlyphs.delete - : BusyMarkGlyphs.documentHistory, - ), - title: Text(document.displayName), - subtitle: path == null - ? null - : Text( - path, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: document.deleted - ? Text(context.l10n.localHistoryDeleted) - : null, - onTap: () => controller.inspectRetainedDocument( - document.id, - ), - ), + final subtitle = document.deleted + ? path == null + ? context.l10n.localHistoryDeleted + : '${context.l10n.localHistoryDeleted} · $path' + : path; + return BusyMarkSidebarRecordRow( + icon: document.deleted + ? BusyMarkGlyphs.delete + : BusyMarkGlyphs.documentHistory, + title: document.displayName, + subtitle: subtitle, + onTap: () => + controller.inspectRetainedDocument(document.id), ); }, ) @@ -243,6 +283,7 @@ class _LocalHistoryPanelState extends ConsumerState { key: ValueKey( 'local-history-revisions-${selected?.id ?? 'none'}', ), + padding: BusyMarkInsets.sidebarList, itemCount: visibleRevisions.length, itemBuilder: (context, index) { final revision = visibleRevisions[index]; @@ -268,42 +309,33 @@ class _LocalHistoryPanelState extends ConsumerState { if (startsDate) Padding( padding: const EdgeInsets.fromLTRB( - BusyMarkSpacing.md, - BusyMarkSpacing.md, - BusyMarkSpacing.md, + BusyMarkSpacing.xs, + BusyMarkSpacing.lg, + BusyMarkSpacing.xs, BusyMarkSpacing.xs, ), child: Text( MaterialLocalizations.of( context, ).formatFullDate(revision.capturedAt.toLocal()), - style: Theme.of(context).textTheme.labelMedium, + style: busyMarkSectionHeaderStyle(context), ), ), - Semantics( + BusyMarkSidebarRecordRow( + icon: BusyMarkGlyphs.history, + title: timestamp, + subtitle: event, selected: revision.id == state.selectedRevisionId, - button: true, - label: context.l10n.localHistoryRevisionAt( + semanticsLabel: context.l10n.localHistoryRevisionAt( reason, fullTimestamp, ), - excludeSemantics: true, - child: Tooltip( - message: context.l10n.localHistoryRevisionAt( - reason, - fullTimestamp, - ), - child: ListTile( - dense: event == null, - selected: revision.id == state.selectedRevisionId, - leading: const Icon(BusyMarkGlyphs.history), - title: Text(timestamp), - subtitle: event == null ? null : Text(event), - onTap: () => unawaited( - controller.selectRevision(revision.id), - ), - ), + tooltip: context.l10n.localHistoryRevisionAt( + reason, + fullTimestamp, ), + onTap: () => + unawaited(controller.selectRevision(revision.id)), ), ], ); @@ -359,7 +391,7 @@ typedef _LocalHistoryEditorScope = ({ String? displayName, }); -enum _HistoryAction { find, clearDocument, clearAll } +enum _HistoryAction { refresh, find, clearDocument, clearAll } class _LocalHistoryHeader extends StatelessWidget { const _LocalHistoryHeader({ @@ -368,12 +400,7 @@ class _LocalHistoryHeader extends StatelessWidget { required this.selectedDocument, required this.findingDocuments, required this.inspectingRetainedDocument, - required this.loading, - required this.canClearDocument, - required this.canClearAll, required this.onBack, - required this.onRefresh, - required this.onAction, }); final String? activeDocumentName; @@ -381,12 +408,7 @@ class _LocalHistoryHeader extends StatelessWidget { final LocalHistoryDocument? selectedDocument; final bool findingDocuments; final bool inspectingRetainedDocument; - final bool loading; - final bool canClearDocument; - final bool canClearAll; final VoidCallback onBack; - final Future Function() onRefresh; - final ValueChanged<_HistoryAction> onAction; @override Widget build(BuildContext context) { @@ -448,43 +470,6 @@ class _LocalHistoryHeader extends StatelessWidget { ), ), ), - BusyMarkHeaderIconButton( - tooltip: MaterialLocalizations.of( - context, - ).refreshIndicatorSemanticLabel, - icon: BusyMarkGlyphs.refresh, - transparent: true, - onPressed: loading ? null : () => unawaited(onRefresh()), - ), - BusyMarkHeaderPopupMenuButton<_HistoryAction>( - key: const ValueKey('local-history-actions-menu'), - tooltip: context.l10n.actions, - icon: BusyMarkGlyphs.menuVertical, - transparent: true, - borderRadius: BusyMarkRadius.nativeHeaderButton, - highlightWhenOpen: false, - itemBuilder: (context) => [ - BusyMarkPopupMenuItem( - value: _HistoryAction.find, - label: context.l10n.findLocalHistoryEllipsis, - icon: BusyMarkGlyphs.search, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _HistoryAction.clearDocument, - label: context.l10n.localHistoryClearDocument, - icon: BusyMarkGlyphs.delete, - enabled: canClearDocument, - ), - BusyMarkPopupMenuItem( - value: _HistoryAction.clearAll, - label: context.l10n.localHistoryClearAll, - icon: BusyMarkGlyphs.clearAll, - enabled: canClearAll, - ), - ], - onSelected: onAction, - ), ], ), ); diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index ae687640..fef1e7e5 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -31,6 +31,8 @@ enum HeaderBarAction { sidebarToc, sidebarOutline, sidebarGit, + sidebarLocalHistory, + sidebarClipboardHistory, } class HeaderBarActionEvent { @@ -399,6 +401,8 @@ class LinuxHeaderBarService extends ChangeNotifier { 'sidebarToc' => HeaderBarAction.sidebarToc, 'sidebarOutline' => HeaderBarAction.sidebarOutline, 'sidebarGit' => HeaderBarAction.sidebarGit, + 'sidebarLocalHistory' => HeaderBarAction.sidebarLocalHistory, + 'sidebarClipboardHistory' => HeaderBarAction.sidebarClipboardHistory, _ => null, }; } diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 3f36361d..e439e624 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -384,6 +384,8 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.sidebarToc: case HeaderBarAction.sidebarOutline: case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarLocalHistory: + case HeaderBarAction.sidebarClipboardHistory: break; } } diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 3f4ba9e2..11b90842 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -30,7 +30,6 @@ import '../workspace_controller.dart'; import '../workspace_glyphs.dart'; import '../workspace_message.dart'; import '../workspace_safety.dart'; -import 'workspace_identity_row.dart'; class WelcomeScreen extends ConsumerStatefulWidget { const WelcomeScreen({super.key}); @@ -265,6 +264,8 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.sidebarToc: case HeaderBarAction.sidebarOutline: case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarLocalHistory: + case HeaderBarAction.sidebarClipboardHistory: break; } } @@ -512,10 +513,17 @@ class _WelcomeSidebar extends StatelessWidget { title: context.l10n.recent, children: [ for (final recent in recentWorkspaces) - _WelcomeRecentRow( - recent: recent, + BusyMarkSidebarRecordRow<_RecentWorkspaceAction>( + icon: WorkspaceGlyphs.forRecent(recent), + title: busyMarkLtrIsolateFor( + context, + _displayPath(recent.path), + ), + subtitle: busyMarkLtrIsolateFor(context, recent.path), onTap: () => unawaited(onOpenRecent(recent.path)), - onAction: (action) => + menuTooltip: context.l10n.actions, + menuItemsBuilder: _recentWorkspaceMenuItems, + onMenuSelected: (action) => unawaited(onRecentAction(recent, action)), ), ], @@ -556,135 +564,6 @@ class _WelcomeSidebarSection extends StatelessWidget { enum _RecentWorkspaceAction { openInFiles, copyPath, removeFromRecent } -class _WelcomeRecentRow extends StatefulWidget { - const _WelcomeRecentRow({ - required this.recent, - required this.onTap, - required this.onAction, - }); - - final RecentWorkspace recent; - final VoidCallback onTap; - final ValueChanged<_RecentWorkspaceAction> onAction; - - @override - State<_WelcomeRecentRow> createState() => _WelcomeRecentRowState(); -} - -class _WelcomeRecentRowState extends State<_WelcomeRecentRow> { - final _rowKey = GlobalKey(); - late final FocusNode _rowFocusNode; - var _contextMenuOpen = false; - - @override - void initState() { - super.initState(); - _rowFocusNode = FocusNode(debugLabel: 'BusyMark recent workspace row'); - } - - @override - void dispose() { - _rowFocusNode.dispose(); - super.dispose(); - } - - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (!isBusyMarkContextMenuKeyEvent(event)) { - return KeyEventResult.ignored; - } - unawaited(_showContextMenu()); - return KeyEventResult.handled; - } - - Future _showContextMenu([Offset? position]) async { - if (_contextMenuOpen) { - return; - } - final rowContext = _rowKey.currentContext; - if (rowContext == null) { - return; - } - setState(() => _contextMenuOpen = true); - _RecentWorkspaceAction? action; - try { - final items = _recentWorkspaceMenuItems(rowContext); - action = position == null - ? await showBusyMarkMenu<_RecentWorkspaceAction>( - context: rowContext, - anchorContext: rowContext, - items: items, - focusFirst: true, - ) - : await showBusyMarkContextMenu<_RecentWorkspaceAction>( - rowContext, - position, - items: items, - ); - } finally { - if (mounted) { - setState(() => _contextMenuOpen = false); - } - } - if (mounted && action != null) { - widget.onAction(action); - } - } - - @override - Widget build(BuildContext context) { - final items = _recentWorkspaceMenuItems(context); - return KeyedSubtree( - key: _rowKey, - child: Focus( - onKeyEvent: _handleKeyEvent, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: BusyMarkStroke.hairline, - ), - child: Material( - color: _contextMenuOpen - ? busyMarkRowHoverColor(context) - : BusyMarkLinuxPalette.transparent, - borderRadius: BorderRadius.circular(BusyMarkRadius.md), - clipBehavior: Clip.antiAlias, - child: InkWell( - hoverColor: busyMarkRowHoverColor(context), - focusNode: _rowFocusNode, - onTap: widget.onTap, - onSecondaryTapUp: (details) { - _rowFocusNode.requestFocus(); - unawaited(_showContextMenu(details.globalPosition)); - }, - child: WorkspaceIdentityRow( - height: BusyMarkSizes.sidebarTreeRowHeight * 2, - icon: WorkspaceGlyphs.forRecent(widget.recent), - name: busyMarkLtrIsolateFor( - context, - _displayPath(widget.recent.path), - ), - path: busyMarkLtrIsolateFor(context, widget.recent.path), - trailing: BusyMarkMenuButton<_RecentWorkspaceAction>( - tooltip: context.l10n.actions, - items: items, - onSelected: widget.onAction, - triggerBuilder: (context, trigger) => trigger.anchor( - child: BusyMarkCompactIconButton( - tooltip: context.l10n.actions, - icon: BusyMarkGlyphs.menuVertical, - focusNode: trigger.focusNode, - onPressed: trigger.onPressed, - ), - ), - ), - ), - ), - ), - ), - ), - ); - } -} - List> _recentWorkspaceMenuItems( BuildContext context, ) { diff --git a/lib/src/workspace/presentation/workspace_identity_row.dart b/lib/src/workspace/presentation/workspace_identity_row.dart deleted file mode 100644 index 4f8c50f5..00000000 --- a/lib/src/workspace/presentation/workspace_identity_row.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/busymark_design.dart'; - -class WorkspaceIdentityRow extends StatelessWidget { - const WorkspaceIdentityRow({ - super.key, - required this.icon, - required this.name, - required this.path, - this.height, - this.horizontalPadding = BusyMarkSpacing.sm, - this.trailing, - }); - - final IconData icon; - final String name; - final String path; - final double? height; - final double horizontalPadding; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - final titleStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: colors.foreground); - final detailStyle = Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colors.mutedForeground); - final detail = path.trim(); - final row = Row( - children: [ - SizedBox(width: horizontalPadding), - Icon(icon, size: BusyMarkSizes.iconSm, color: colors.mutedForeground), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - if (detail.isNotEmpty) ...[ - const SizedBox(height: BusyMarkSpacing.xxs), - Text( - detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: detailStyle, - ), - ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: BusyMarkSpacing.xs), - trailing!, - ], - SizedBox(width: horizontalPadding), - ], - ); - return height == null ? row : SizedBox(height: height, child: row); - } -} diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index b32e28c0..d66fd52f 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -751,6 +751,17 @@ class WorkspaceScreen extends ConsumerWidget { const _SelectSidebarTabIntent(_SidebarTab.git), const SingleActivator(LogicalKeyboardKey.numpad4, control: true): const _SelectSidebarTabIntent(_SidebarTab.git), + commandRegistry[BusyMarkCommandIds.localHistory]!.shortcut!.activator: + const _SelectSidebarTabIntent(_SidebarTab.localHistory), + const SingleActivator(LogicalKeyboardKey.numpad5, control: true): + const _SelectSidebarTabIntent(_SidebarTab.localHistory), + commandRegistry[BusyMarkCommandIds.clipboardHistory]! + .shortcut! + .activator: const _SelectSidebarTabIntent( + _SidebarTab.clipboard, + ), + const SingleActivator(LogicalKeyboardKey.numpad6, control: true): + const _SelectSidebarTabIntent(_SidebarTab.clipboard), }, child: Actions( actions: { @@ -1064,6 +1075,10 @@ class WorkspaceScreen extends ConsumerWidget { _selectSidebarShortcut(ref, _SidebarTab.outline); case HeaderBarAction.sidebarGit: _selectSidebarShortcut(ref, _SidebarTab.git); + case HeaderBarAction.sidebarLocalHistory: + _selectSidebarShortcut(ref, _SidebarTab.localHistory); + case HeaderBarAction.sidebarClipboardHistory: + _selectSidebarShortcut(ref, _SidebarTab.clipboard); case HeaderBarAction.search: _toggleSearch(ref); case HeaderBarAction.menu: @@ -2602,7 +2617,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } -enum _SidebarTab { files, toc, outline, git, clipboard, localHistory } +enum _SidebarTab { files, toc, outline, git, localHistory, clipboard } int _preferredSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); @@ -2627,28 +2642,28 @@ List<_SidebarTab> _sidebarTabsFor(WorkspaceKind kind) { return switch (kind) { WorkspaceKind.untitledMarkdown => const [ _SidebarTab.outline, - _SidebarTab.clipboard, _SidebarTab.localHistory, + _SidebarTab.clipboard, ], WorkspaceKind.singleMarkdown => const [ _SidebarTab.outline, - _SidebarTab.clipboard, _SidebarTab.localHistory, + _SidebarTab.clipboard, ], WorkspaceKind.markdownFolder => const [ _SidebarTab.files, _SidebarTab.outline, _SidebarTab.git, - _SidebarTab.clipboard, _SidebarTab.localHistory, + _SidebarTab.clipboard, ], WorkspaceKind.writersideModule => const [ _SidebarTab.files, _SidebarTab.toc, _SidebarTab.outline, _SidebarTab.git, - _SidebarTab.clipboard, _SidebarTab.localHistory, + _SidebarTab.clipboard, ], }; } @@ -2659,8 +2674,8 @@ String _sidebarTabLabel(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => context.l10n.toc, _SidebarTab.outline => context.l10n.outline, _SidebarTab.git => context.l10n.git, - _SidebarTab.clipboard => context.l10n.clipboardHistory, _SidebarTab.localHistory => context.l10n.localHistory, + _SidebarTab.clipboard => context.l10n.clipboardHistory, }; } @@ -2670,8 +2685,8 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { _SidebarTab.toc => BusyMarkGlyphs.orderedList, _SidebarTab.outline => BusyMarkGlyphs.indentFor(direction), _SidebarTab.git => BusyMarkGlyphs.branch, - _SidebarTab.clipboard => BusyMarkGlyphs.copy, _SidebarTab.localHistory => BusyMarkGlyphs.documentHistory, + _SidebarTab.clipboard => BusyMarkGlyphs.copy, }; } @@ -2684,8 +2699,8 @@ String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => BusyMarkCommandIds.sidebarToc, _SidebarTab.outline => BusyMarkCommandIds.sidebarOutline, _SidebarTab.git => BusyMarkCommandIds.sidebarGit, - _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, _SidebarTab.localHistory => BusyMarkCommandIds.localHistory, + _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, }; return commands[id]?.shortcut?.label; } @@ -4119,6 +4134,19 @@ class _FilesTabState extends ConsumerState<_FilesTab> { .read(workspaceControllerProvider.notifier) .openActiveFile(path); if (!opened || !mounted) return; + final requestedBuffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where( + (buffer) => + buffer.filePath != null && p.equals(buffer.filePath!, path), + ) + .firstOrNull; + if (requestedBuffer == null) return; + await ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(requestedBuffer); + if (!mounted) return; ref .read(_sidebarShortcutRequestProvider.notifier) .select(_SidebarTab.localHistory); @@ -8686,14 +8714,24 @@ class _EditorTabStrip extends ConsumerWidget { if (action == null || !context.mounted) return; switch (action) { case _WorkspaceTabAction.localHistory: - final buffer = state.documentBuffers + final requestedBuffer = state.documentBuffers .where((candidate) => candidate.id == entry.bufferId) .firstOrNull; - if (buffer == null) return; + if (requestedBuffer == null) return; final activated = await ref .read(workspaceControllerProvider.notifier) - .activateDocumentBuffer(buffer.id); + .activateDocumentBuffer(requestedBuffer.id); if (!activated || !context.mounted) return; + final currentRequestedBuffer = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((candidate) => candidate.id == requestedBuffer.id) + .firstOrNull; + if (currentRequestedBuffer == null) return; + await ref + .read(localHistoryControllerProvider.notifier) + .selectDocumentForBuffer(currentRequestedBuffer); + if (!context.mounted) return; ref .read(_sidebarShortcutRequestProvider.notifier) .select(_SidebarTab.localHistory); diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 2e7c8ed8..5e5b3c9b 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1452,6 +1452,10 @@ class WorkspaceController extends Notifier { if (workspace == null || buffer == null) { return false; } + if (state.activeBufferId == bufferId && + workspace.activeFilePath == buffer.filePath) { + return true; + } return _activateBuffer( workspace, buffer, @@ -1789,7 +1793,21 @@ class WorkspaceController extends Notifier { if (closingBuffer?.isDirty == true) { return false; } - if (closingBuffer != null) await _localHistory.flushBuffer(closingBuffer); + if (closingBuffer != null) { + await _localHistory.flushBuffer(closingBuffer); + final currentClosingBuffer = state.documentBuffers + .where((candidate) => candidate.id == closingBuffer.id) + .firstOrNull; + if (currentClosingBuffer == null || + currentClosingBuffer.filePath != closingBuffer.filePath || + currentClosingBuffer.revision != closingBuffer.revision || + currentClosingBuffer.isDirty) { + if (currentClosingBuffer != null) { + _scheduleAutoSave(currentClosingBuffer.id); + } + return false; + } + } final closedIndex = workspace.openFilePaths.indexOf(path); final nextOpenFilePaths = [ for (final openPath in workspace.openFilePaths) @@ -1859,13 +1877,33 @@ class WorkspaceController extends Notifier { )) { return false; } - await _localHistory.flushBuffer(buffer); - if (buffer.filePath case final path?) { - if (buffer.isDirty) { + final afterProtection = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (afterProtection == null || + afterProtection.revision != buffer.revision || + afterProtection.text != buffer.text || + afterProtection.isDirty != buffer.isDirty) { + if (afterProtection != null) _scheduleAutoSave(afterProtection.id); + return false; + } + await _localHistory.flushBuffer(afterProtection); + final current = state.documentBuffers + .where((candidate) => candidate.id == bufferId) + .firstOrNull; + if (current == null || + current.revision != afterProtection.revision || + current.text != afterProtection.text || + current.isDirty != afterProtection.isDirty) { + if (current != null) _scheduleAutoSave(current.id); + return false; + } + if (current.filePath case final path?) { + if (current.isDirty) { state = state.copyWith( documentBuffers: _replaceBuffer( state.documentBuffers, - buffer.copyWith(dirty: false), + current.copyWith(dirty: false), ), ); } @@ -1961,11 +1999,18 @@ class WorkspaceController extends Notifier { .where((buffer) => buffer.filePath == path) .firstOrNull; if (existing != null) { + final requestedOpenFilePaths = + openFilePaths ?? _openFileTabPaths(workspace, path); + if (state.activeBufferId == existing.id && + workspace.activeFilePath == existing.filePath && + _samePathList(workspace.openFilePaths, requestedOpenFilePaths)) { + return true; + } return _activateBuffer( workspace, existing, documentBuffers: buffers, - openFilePaths: openFilePaths ?? _openFileTabPaths(workspace, path), + openFilePaths: requestedOpenFilePaths, ); } _cancelPendingDerivedRefresh(); @@ -2034,32 +2079,106 @@ class WorkspaceController extends Notifier { }) async { _cancelPendingDerivedRefresh(); final operationRevision = _invalidateActiveDocumentOperations(); + final workspaceId = workspace.id; + final targetBufferId = buffer.id; + final initialBufferIds = state.documentBuffers + .map((candidate) => candidate.id) + .toSet(); + final requestedBufferIds = documentBuffers + .map((candidate) => candidate.id) + .toSet(); + final intentionallyRemovedBufferIds = initialBufferIds.difference( + requestedBufferIds, + ); + final initialOpenPaths = + state.workspace?.openFilePaths.toSet() ?? const {}; + final requestedOpenPaths = openFilePaths.toSet(); + final intentionallyRemovedOpenPaths = initialOpenPaths.difference( + requestedOpenPaths, + ); final nextWorkspace = workspace.copyWith( activeFilePath: buffer.filePath, activeFileSnapshot: buffer.diskSnapshot, openFilePaths: openFilePaths, markdown: buffer.filePath == null ? workspace.markdown : null, ); - final reparsed = await _service.reparseActive(nextWorkspace, buffer.text); + var parsedBuffer = buffer; + var reparsed = await _service.reparseActive( + nextWorkspace, + parsedBuffer.text, + ); if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } + if (state.workspace?.id != workspaceId) return false; + var liveBuffer = state.documentBuffers + .where((candidate) => candidate.id == targetBufferId) + .firstOrNull; + if (liveBuffer == null) return false; + if (liveBuffer.revision != parsedBuffer.revision || + liveBuffer.text != parsedBuffer.text) { + parsedBuffer = liveBuffer; + reparsed = await _service.reparseActive(nextWorkspace, parsedBuffer.text); + if (!_isCurrentActiveDocumentOperation(operationRevision) || + state.workspace?.id != workspaceId) { + return false; + } + liveBuffer = state.documentBuffers + .where((candidate) => candidate.id == targetBufferId) + .firstOrNull; + if (liveBuffer == null) return false; + } + final sourceStayedCurrent = + liveBuffer.revision == parsedBuffer.revision && + liveBuffer.text == parsedBuffer.text; + final liveBuffers = state.documentBuffers; + final reconciledBuffers = [ + for (final candidate in liveBuffers) + if (!intentionallyRemovedBufferIds.contains(candidate.id)) candidate, + for (final requested in documentBuffers) + if (!liveBuffers.any((candidate) => candidate.id == requested.id)) + requested, + ]; + final reconciledOpenPaths = [ + for (final path in state.workspace?.openFilePaths ?? const []) + if (!intentionallyRemovedOpenPaths.contains(path)) path, + for (final path in openFilePaths) + if (!(state.workspace?.openFilePaths.contains(path) ?? false)) path, + ]; + final publishedWorkspace = (sourceStayedCurrent ? reparsed : nextWorkspace) + .copyWith( + activeFilePath: liveBuffer.filePath, + activeFileSnapshot: liveBuffer.diskSnapshot, + openFilePaths: reconciledOpenPaths, + ); state = state.copyWith( - workspace: reparsed, - preview: _safePreview(reparsed, buffer.text), - documentBuffers: documentBuffers, - activeBufferId: buffer.id, + workspace: publishedWorkspace, + preview: sourceStayedCurrent + ? _safePreview(publishedWorkspace, liveBuffer.text) + : null, + documentBuffers: reconciledBuffers, + activeBufferId: liveBuffer.id, clearMessage: true, ); - _recordActivePreviewRevision(); + if (sourceStayedCurrent) { + _recordActivePreviewRevision(); + } else { + _activePreviewRevision = null; + _requestDerivedRefresh( + rebuildPreview: _modeShowsPreview(liveBuffer.editorState.mode), + refreshOutline: !_modeShowsPreview(liveBuffer.editorState.mode), + ); + } _fileMonitor.updateOpenFilePaths( - documentBuffers + reconciledBuffers .map((candidate) => candidate.filePath) .whereType(), ); _schedulePersistence(); - _editRevision = buffer.revision; - unawaited(_settingsController.setDocumentViewMode(buffer.editorState.mode)); + _editRevision = liveBuffer.revision; + unawaited( + _settingsController.setDocumentViewMode(liveBuffer.editorState.mode), + ); return true; } @@ -2110,6 +2229,7 @@ class WorkspaceController extends Notifier { ); _localHistory.observeEdit(buffer, next); _schedulePersistence(); + _scheduleAutoSave(next.id); return true; } @@ -2146,6 +2266,7 @@ class WorkspaceController extends Notifier { byPath.putIfAbsent(edit.filePath, () => []).add(edit); } final staged = {}; + final previousByBufferId = {}; for (final entry in byPath.entries) { final buffer = initialBuffers @@ -2177,7 +2298,9 @@ class WorkspaceController extends Notifier { ); previousStart = span.startOffset; } - staged[entry.key] = buffer.edited(source); + final changed = buffer.edited(source); + staged[entry.key] = changed; + previousByBufferId[changed.id] = buffer; } if (!ref.mounted || state.workspace?.id != workspaceId || @@ -2197,6 +2320,14 @@ class WorkspaceController extends Notifier { ...staged.values, ]; state = state.copyWith(documentBuffers: buffers); + for (final entry in previousByBufferId.entries) { + final next = buffers + .where((buffer) => buffer.id == entry.key) + .firstOrNull; + if (next == null || identical(next, entry.value)) continue; + _localHistory.observeEdit(entry.value, next); + _scheduleAutoSave(next.id); + } _editRevision = state.activeBuffer?.revision ?? _editRevision; _schedulePersistence(); _fileMonitor.updateOpenFilePaths( @@ -2249,6 +2380,7 @@ class WorkspaceController extends Notifier { refreshOutline: !_activeModeShowsPreview, ); _schedulePersistence(); + _scheduleAutoSave(next.id); return true; } @@ -2278,11 +2410,13 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); + _localHistory.observeEdit(buffer, next); _requestDerivedRefresh( rebuildPreview: _activeModeShowsPreview, refreshOutline: !_activeModeShowsPreview, ); _schedulePersistence(); + _scheduleAutoSave(next.id); return true; } @@ -3958,6 +4092,7 @@ class WorkspaceController extends Notifier { error: error, ), ); + _scheduleAutoSave(); } return false; } @@ -4483,6 +4618,14 @@ List _replaceBuffer( ]); } +bool _samePathList(List first, List second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index++) { + if (!p.equals(first[index], second[index])) return false; + } + return true; +} + class _WorkspaceRefreshBufferReplacement { const _WorkspaceRefreshBufferReplacement({ required this.requested, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 08e8168a..f3986dd6 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1095,6 +1095,12 @@ static const gchar* sidebar_shortcut_action_for_key(guint keyval) { case GDK_KEY_4: case GDK_KEY_KP_4: return "sidebarGit"; + case GDK_KEY_5: + case GDK_KEY_KP_5: + return "sidebarLocalHistory"; + case GDK_KEY_6: + case GDK_KEY_KP_6: + return "sidebarClipboardHistory"; default: return nullptr; } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 514a8d2a..55c4adfa 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -21,6 +21,7 @@ import 'package:busymark/src/app/window_control_service.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/local_image_resolver.dart'; import 'package:busymark/src/core/source_span.dart'; +import 'package:busymark/src/clipboard/clipboard_history_panel.dart'; import 'package:busymark/src/editor/document_callout.dart'; import 'package:busymark/src/editor/document_code_block.dart'; import 'package:busymark/src/editor/document_layout.dart'; @@ -35,6 +36,9 @@ import 'package:busymark/src/export/export_options_editor.dart'; import 'package:busymark/src/git/application/git_controller.dart'; import 'package:busymark/src/git/domain/git_models.dart'; import 'package:busymark/src/git/presentation/git_diff_viewer.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_panel.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; @@ -1077,6 +1081,12 @@ void main() { expect(find.text(l10n.shortcutChecklistDescription), findsOneWidget); expect(find.text(l10n.shortcutGroupSidebar), findsOneWidget); expect(find.text(l10n.git), findsOneWidget); + expect(find.text(l10n.localHistoryEllipsis), findsOneWidget); + expect(find.text(l10n.clipboardHistory), findsOneWidget); + expect( + tester.getTopLeft(find.text(l10n.localHistoryEllipsis)).dy, + lessThan(tester.getTopLeft(find.text(l10n.clipboardHistory)).dy), + ); expect(find.text(l10n.gitChanges), findsNothing); expect(find.text(l10n.gitProjectHistory), findsNothing); expect(find.text(l10n.gitHistory), findsNothing); @@ -1114,6 +1124,26 @@ void main() { final shortcutRows = tester .widgetList(shortcutRowFinder) .toList(); + final localHistoryRow = shortcutRows.singleWhere( + (row) => row.title == l10n.localHistoryEllipsis, + ); + final clipboardHistoryRow = shortcutRows.singleWhere( + (row) => row.title == l10n.clipboardHistory, + ); + expect( + find.descendant( + of: find.byWidget(localHistoryRow.trailing!), + matching: find.text(BusyMarkSidebarShortcutLabels.localHistory), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byWidget(clipboardHistoryRow.trailing!), + matching: find.text(BusyMarkSidebarShortcutLabels.clipboardHistory), + ), + findsOneWidget, + ); final displayedShortcutLabels = []; for (final row in shortcutRows) { final trailing = row.trailing; @@ -3131,7 +3161,6 @@ void main() { ], ); addTearDown(container.dispose); - await tester.pumpWidget( UncontrolledProviderScope( container: container, @@ -3375,6 +3404,12 @@ void main() { expect(find.byTooltip(temp.path), findsOneWidget); expect(find.byTooltip(l10n.gitActions), findsNothing); + await pressControlShortcut(LogicalKeyboardKey.digit5); + expect(find.byType(LocalHistoryPanel), findsOneWidget); + + await pressControlShortcut(LogicalKeyboardKey.digit6); + expect(find.byType(ClipboardHistoryPanel), findsOneWidget); + await setDocumentViewMode(DocumentViewModePreference.preview); await pressControlShortcut(LogicalKeyboardKey.digit4); expect(find.text(l10n.gitNoChanges), findsOneWidget); @@ -3492,13 +3527,17 @@ void main() { (l10n.files, BusyMarkSidebarShortcutLabels.files), (l10n.outline, BusyMarkSidebarShortcutLabels.outline), (l10n.git, BusyMarkSidebarShortcutLabels.git), + (l10n.localHistory, BusyMarkSidebarShortcutLabels.localHistory), + (l10n.clipboardHistory, BusyMarkSidebarShortcutLabels.clipboardHistory), ]) { expect(find.text(label), findsOneWidget); expect(find.text(shortcut), findsOneWidget); expect(find.byTooltip('$label ($shortcut)'), findsNothing); } - expect(find.text(l10n.clipboardHistory), findsOneWidget); - expect(find.text(l10n.localHistory), findsOneWidget); + expect( + tester.getTopLeft(find.text(l10n.localHistory)).dy, + lessThan(tester.getTopLeft(find.text(l10n.clipboardHistory)).dy), + ); expect(find.text(l10n.gitChanges), findsNothing); expect(find.text(l10n.gitFileHistory), findsNothing); expect(find.text(l10n.gitProjectHistory), findsNothing); @@ -3507,6 +3546,402 @@ void main() { expect(find.text(l10n.export), findsNothing); }); + testWidgets( + 'real Source edits reach the visible Local History list on the fixed deadline', + (tester) async { + const nativeMenuChannel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativeMenuChannel, + (call) async => call.method == 'dismiss' ? true : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativeMenuChannel, + null, + ), + ); + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final temp = Directory.systemTemp.createTempSync( + 'busymark-editor-history-', + ); + final file = File(p.join(temp.path, 'history.md')) + ..writeAsStringSync('alpha beta\n'); + addTearDown(() { + if (temp.existsSync()) temp.deleteSync(recursive: true); + }); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + var now = DateTime.utc(2026, 3, 4, 10); + final timers = <_HistoryTestTimer>[]; + final historyStore = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService('alpha beta\n'), + ), + startupPathProvider.overrideWithValue(file.path), + localHistoryStoreProvider.overrideWithValue(historyStore), + localHistoryClockProvider.overrideWithValue(() => now), + localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { + final timer = _HistoryTestTimer(delay, callback); + timers.add(timer); + return timer; + }), + ], + ); + addTearDown(container.dispose); + Future waitForHistory( + bool Function(LocalHistoryState state) condition, + ) async { + await tester.runAsync(() async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition(container.read(localHistoryControllerProvider))) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Timed out waiting for Local History state'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await tester.pump(); + } + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 40; index += 1) { + await tester.pump(const Duration(milliseconds: 50)); + if (find.byType(BusyMarkSourceEditor).evaluate().isNotEmpty) break; + } + expect(find.byType(BusyMarkSourceEditor), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.digit5); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + await waitForHistory((history) => history.selectedRevisions.length == 1); + expect(find.byType(LocalHistoryPanel), findsOneWidget); + expect( + container.read(localHistoryControllerProvider).selectedRevisions, + hasLength(1), + ); + + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + await tester.tap(sourceField); + await tester.enterText(sourceField, 'alpha beta gamma\n'); + await tester.pump(); + await tester.enterText(sourceField, 'alpha gamma\n'); + await tester.pump(); + await tester.enterText(sourceField, '**alpha** gamma\n'); + await tester.pump(); + + const formatted = '**alpha** gamma\n'; + expect(container.read(workspaceControllerProvider).activeText, formatted); + final editedBufferId = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + await waitForHistory( + (_) => + container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(editedBufferId) + ?.text == + formatted, + ); + expect(timers, hasLength(1)); + expect(timers.single.duration, const Duration(seconds: 60)); + expect( + container.read(localHistoryControllerProvider).selectedRevisions, + hasLength(1), + reason: 'Source operations update pending state, not one row per edit.', + ); + + now = now.add(const Duration(seconds: 60)); + timers.single.fire(); + await waitForHistory((history) => history.selectedRevisions.length == 2); + var history = container.read(localHistoryControllerProvider); + expect(history.selectedRevisions, hasLength(2)); + expect( + find.descendant( + of: find.byType(LocalHistoryPanel), + matching: find.byWidgetPredicate( + (widget) => widget is BusyMarkSidebarRecordRow, + ), + ), + findsNWidgets(2), + ); + final capturedSources = []; + await tester.runAsync(() async { + for (final revision in history.selectedRevisions) { + capturedSources.add( + (await historyStore.readRevision(revision.id))!.source, + ); + } + }); + expect(capturedSources, contains(formatted)); + + final historySearch = find.descendant( + of: find.byType(LocalHistoryPanel), + matching: find.byType(TextField), + ); + await tester.enterText(historySearch, 'needle'); + await tester.pump(); + await tester.tap(sourceField); + await tester.enterText(sourceField, '**alpha** needle\n'); + await tester.pump(); + now = now.add(const Duration(seconds: 60)); + expect(timers.where((timer) => timer.isActive), hasLength(1)); + timers.lastWhere((timer) => timer.isActive).fire(); + await waitForHistory( + (history) => !history.searching && history.searchMatches.length == 1, + ); + history = container.read(localHistoryControllerProvider); + expect(history.searchQuery, 'needle'); + expect(history.searchMatches, hasLength(1)); + expect( + tester.widget(historySearch).controller!.text, + 'needle', + ); + expect( + find.descendant( + of: find.byType(LocalHistoryPanel), + matching: find.byWidgetPredicate( + (widget) => widget is BusyMarkSidebarRecordRow, + ), + ), + findsOneWidget, + ); + + await tester.tap(sourceField); + await tester.enterText(sourceField, '**alpha** needle redo-state\n'); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + container.read(workspaceControllerProvider).activeText, + '**alpha** needle\n', + ); + 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.pump(); + const redone = '**alpha** needle redo-state\n'; + expect(container.read(workspaceControllerProvider).activeText, redone); + final bufferId = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + await waitForHistory( + (_) => + container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(bufferId) + ?.text == + redone, + ); + expect( + container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(bufferId) + ?.text, + redone, + ); + now = now.add(const Duration(seconds: 60)); + timers.lastWhere((timer) => timer.isActive).fire(); + await waitForHistory( + (history) => + history.selectedRevisions.length == 4 && !history.searching, + ); + history = container.read(localHistoryControllerProvider); + final finalSources = []; + await tester.runAsync(() async { + for (final revision in history.selectedRevisions) { + finalSources.add( + (await historyStore.readRevision(revision.id))!.source, + ); + } + }); + expect(finalSources, contains(redone)); + expect(history.searchQuery, 'needle'); + expect(history.searchMatches, hasLength(2)); + }, + ); + + testWidgets( + 'selected WYSIWYG formatting reaches Local History through workspace callbacks', + (tester) async { + const nativeMenuChannel = MethodChannel('busymark/native_menus'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativeMenuChannel, + (call) async => call.method == 'dismiss' ? true : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + nativeMenuChannel, + null, + ), + ); + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final temp = Directory.systemTemp.createTempSync( + 'busymark-wysiwyg-history-', + ); + final file = File(p.join(temp.path, 'format.md')) + ..writeAsStringSync('First\n\nSecond\n\nThird\n'); + addTearDown(() { + if (temp.existsSync()) temp.deleteSync(recursive: true); + }); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + var now = DateTime.utc(2026, 3, 4, 11); + final timers = <_HistoryTestTimer>[]; + final historyStore = MemoryLocalHistoryStore(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService('First\n\nSecond\n\nThird\n'), + ), + startupPathProvider.overrideWithValue(file.path), + localHistoryStoreProvider.overrideWithValue(historyStore), + localHistoryClockProvider.overrideWithValue(() => now), + localHistoryTimerFactoryProvider.overrideWithValue((delay, callback) { + final timer = _HistoryTestTimer(delay, callback); + timers.add(timer); + return timer; + }), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 40; index += 1) { + await tester.pump(const Duration(milliseconds: 50)); + if (find + .byKey(const ValueKey('wysiwyg-document-content')) + .evaluate() + .isNotEmpty) { + break; + } + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.digit5); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + for (var index = 0; index < 40; index += 1) { + await tester.pump(const Duration(milliseconds: 25)); + if (container + .read(localHistoryControllerProvider) + .selectedRevisions + .length == + 1) { + break; + } + } + + final wysiwygFields = find.descendant( + of: find.byKey(const ValueKey('wysiwyg-document-content')), + matching: find.byType(TextField), + ); + expect(wysiwygFields, findsWidgets); + final firstField = tester.widget(wysiwygFields.first); + firstField.focusNode!.requestFocus(); + firstField.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 5, + ); + await tester.tap(find.byIcon(BusyMarkGlyphs.bold)); + await tester.pump(); + + const formatted = '**First**\n\nSecond\n\nThird\n'; + expect(container.read(workspaceControllerProvider).activeText, formatted); + final bufferId = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + for (var index = 0; index < 40; index += 1) { + await tester.pump(const Duration(milliseconds: 10)); + if (container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(bufferId) + ?.text == + formatted) { + break; + } + } + expect(timers, hasLength(1)); + expect(timers.single.duration, const Duration(seconds: 60)); + expect( + container.read(localHistoryControllerProvider).selectedRevisions, + hasLength(1), + ); + + now = now.add(const Duration(seconds: 60)); + timers.single.fire(); + for (var index = 0; index < 80; index += 1) { + await tester.pump(const Duration(milliseconds: 25)); + if (container + .read(localHistoryControllerProvider) + .selectedRevisions + .length == + 2) { + break; + } + } + final history = container.read(localHistoryControllerProvider); + expect(history.selectedRevisions, hasLength(2)); + final sources = []; + for (final revision in history.selectedRevisions) { + sources.add((await historyStore.readRevision(revision.id))!.source); + } + expect(sources, contains(formatted)); + expect( + find.descendant( + of: find.byType(LocalHistoryPanel), + matching: find.byWidgetPredicate( + (widget) => widget is BusyMarkSidebarRecordRow, + ), + ), + findsNWidgets(2), + ); + }, + ); + testWidgets('Writerside sidebar shortcuts survive document view changes', ( tester, ) async { @@ -9323,6 +9758,29 @@ class _FallbackHeaderBarService extends LinuxHeaderBarService { Stream get actions => const Stream.empty(); } +class _HistoryTestTimer implements Timer { + _HistoryTestTimer(this.duration, this.callback); + + final Duration duration; + final void Function() callback; + var _active = true; + + void fire() { + if (!_active) return; + _active = false; + callback(); + } + + @override + void cancel() => _active = false; + + @override + bool get isActive => _active; + + @override + int get tick => _active ? 0 : 1; +} + class _MutableWorkspaceController extends WorkspaceController { _MutableWorkspaceController(this.initialState); diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index df95c54e..7d01c8fb 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -2,6 +2,7 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_toast.dart'; import 'package:busymark/src/assets/asset_input_service.dart'; import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; @@ -17,6 +18,117 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('records preserve multiline source without visible metadata', ( + tester, + ) async { + const source = '## Introduction\n\nUse **visible tags**.\n- First item'; + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'Introduction\n\nUse visible tags.\nFirst item', + sourceText: source, + ), + ); + container + .read(clipboardInsertionRegistryProvider) + .register(_PanelInsertionTarget()); + await _pumpPanel(tester, container); + + final row = tester.widget( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + ); + expect(row.title, isNull); + expect(row.subtitle, isNull); + expect(row.content, isA()); + final preview = tester.widget(find.text(source)); + expect(preview.maxLines, 3); + expect( + find.text(AppLocalizationsEn().clipboardDestination('Target.md')), + findsNothing, + ); + expect(find.textContaining('Rich text ·'), findsNothing); + }); + + testWidgets('large records do not put clipboard content in the tooltip', ( + tester, + ) async { + final largeSource = List.filled( + 4000, + 'A long clipboard payload must stay in the row preview.', + ).join('\n'); + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: largeSource, + sourceText: largeSource, + ), + ); + await _pumpPanel(tester, container); + + final row = tester.widget( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + ); + expect(row.tooltip, isNot(contains(largeSource))); + expect(row.tooltip, contains(AppLocalizationsEn().clipboardEntryText)); + expect(row.tooltip!.length, lessThan(100)); + }); + + testWidgets('actions menu is right of search and owns refresh and clear', ( + tester, + ) async { + final clipboard = _PanelClipboard( + const RichClipboardData(text: 'current system clipboard'), + ); + final container = _container(clipboard: clipboard); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'retained item', + sourceText: 'retained item', + ), + ); + await _pumpPanel(tester, container, locale: const Locale('ar')); + + final actions = find.byKey( + const ValueKey('clipboard-history-actions-menu'), + ); + final search = find.byType(TextField); + final panelContext = tester.element(find.byType(ClipboardHistoryPanel)); + final refreshLabel = MaterialLocalizations.of( + panelContext, + ).refreshIndicatorSemanticLabel; + final l10n = AppLocalizations.of(panelContext); + expect(actions, findsOneWidget); + expect( + tester.getCenter(actions).dx, + greaterThan(tester.getTopRight(search).dx), + ); + expect(find.byTooltip(refreshLabel), findsNothing); + + final readsBeforeRefresh = clipboard.readCalls; + await tester.tap(actions); + await tester.pumpAndSettle(); + expect(find.text(refreshLabel), findsOneWidget); + expect(find.text(l10n.clipboardClearAll), findsOneWidget); + await tester.tap(find.text(refreshLabel)); + await tester.pumpAndSettle(); + expect(clipboard.readCalls, readsBeforeRefresh + 1); + + await tester.tap(actions); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.clipboardClearAll)); + await tester.pumpAndSettle(); + expect(container.read(clipboardHistoryControllerProvider).entries, isEmpty); + }); + testWidgets('keyboard navigation keeps the selected history entry visible', ( tester, ) async { @@ -36,15 +148,19 @@ void main() { .register(_PanelInsertionTarget()); await _pumpPanel(tester, container); - await tester.tap(find.text('entry 23')); + expect( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + findsWidgets, + ); + await tester.tap(find.text('entry 23').first); await tester.pump(); for (var index = 0; index < 14; index++) { await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pumpAndSettle(); } - final selected = find.text('entry 9'); - expect(selected, findsOneWidget); + final selected = find.text('entry 9').first; + expect(find.text('entry 9'), findsWidgets); final selectedRect = tester.getRect(selected); final listRect = tester.getRect( find.byKey(const ValueKey('clipboard-history-list')), @@ -72,11 +188,47 @@ void main() { .register(_PanelInsertionTarget(supportImages: false)); await _pumpPanel(tester, container); - final pasteButton = find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', + expect( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Paste', + ), + findsNothing, + ); + await _openEntryActions(tester); + final disabledPaste = find.ancestor( + of: find.text('Paste'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem && !widget.enabled, + ), ); - final button = tester.widget(pasteButton); - expect(button.onPressed, isNull); + expect(disabledPaste, findsOneWidget); + }); + + testWidgets('double-clicking a shared history row pastes the item', ( + tester, + ) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'double-click item', + sourceText: 'double-click item', + ), + ); + final target = _PanelInsertionTarget(); + container.read(clipboardInsertionRegistryProvider).register(target); + await _pumpPanel(tester, container); + + final rowTitle = find.text('double-click item').first; + await tester.tap(rowTitle); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(rowTitle); + await tester.pumpAndSettle(); + + expect(target.pasteCalls, 1); + expect(target.lastPlainText, isFalse); }); testWidgets( @@ -99,17 +251,17 @@ void main() { container.read(clipboardInsertionRegistryProvider).register(target); await _pumpPanel(tester, container); - final paste = find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', - ); - final pastePlain = find.byWidgetPredicate( - (widget) => - widget is IconButton && widget.tooltip == 'Paste as Plain Text', + await _openEntryActions(tester); + expect( + find.ancestor( + of: find.text('Paste'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem && !widget.enabled, + ), + ), + findsOneWidget, ); - expect(tester.widget(paste).onPressed, isNull); - expect(tester.widget(pastePlain).onPressed, isNotNull); - - await tester.tap(pastePlain); + await tester.tap(find.text('Paste as Plain Text')); await tester.pump(); expect(target.pasteCalls, 1); expect(target.lastPlainText, isTrue); @@ -133,13 +285,8 @@ void main() { container.read(clipboardInsertionRegistryProvider).register(target); await _pumpPanel(tester, container); - final pasteButton = find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', - ); - final button = tester.widget(pasteButton); - expect(button.onPressed, isNotNull); - button.onPressed!.call(); - await tester.pump(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); await tester.pump(const Duration(milliseconds: 200)); expect(target.pasteCalls, 1); expect( @@ -170,13 +317,8 @@ void main() { isEmpty, ); - final currentPaste = tester.widget( - find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', - ), - ); - expect(currentPaste.onPressed, isNotNull); - currentPaste.onPressed!.call(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(target.payloads.single.html, html); expect(target.lastPlainText, isFalse); @@ -195,13 +337,8 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).first, 'External'); await tester.pumpAndSettle(); - final retainedPaste = tester.widget( - find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', - ), - ); - expect(retainedPaste.onPressed, isNotNull); - retainedPaste.onPressed!.call(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(target.payloads, hasLength(2)); expect(target.payloads.last.html, html); @@ -225,13 +362,8 @@ void main() { container.read(clipboardInsertionRegistryProvider).register(failed); await _pumpPanel(tester, container); - final failedPaste = tester.widget( - find.byWidgetPredicate( - (widget) => widget is IconButton && widget.tooltip == 'Paste', - ), - ); - expect(failedPaste.onPressed, isNotNull); - failedPaste.onPressed!.call(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect( container.read(clipboardHistoryControllerProvider).entries, @@ -241,14 +373,8 @@ void main() { final successful = _PanelInsertionTarget(); container.read(clipboardInsertionRegistryProvider).register(successful); await tester.pump(); - final plainPaste = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is IconButton && widget.tooltip == 'Paste as Plain Text', - ), - ); - expect(plainPaste.onPressed, isNotNull); - plainPaste.onPressed!.call(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste as Plain Text')); await tester.pumpAndSettle(); expect(successful.lastPlainText, isTrue); expect(successful.payloads.single.text, 'Readable fallback'); @@ -260,6 +386,16 @@ void main() { ); } +Future _openEntryActions(WidgetTester tester) async { + final actions = find.descendant( + of: find.byKey(const ValueKey('clipboard-history-list')), + matching: find.byTooltip(AppLocalizationsEn().actions), + ); + expect(actions, findsOneWidget); + await tester.tap(actions); + await tester.pumpAndSettle(); +} + ProviderContainer _container({RichClipboardService? clipboard}) { final container = ProviderContainer( overrides: [ @@ -277,8 +413,9 @@ ProviderContainer _container({RichClipboardService? clipboard}) { Future _pumpPanel( WidgetTester tester, - ProviderContainer container, -) async { + ProviderContainer container, { + Locale? locale, +}) async { tester.view.physicalSize = const Size(600, 600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -287,6 +424,7 @@ Future _pumpPanel( UncontrolledProviderScope( container: container, child: MaterialApp( + locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) => BusyMarkToastOverlay(child: child!), @@ -381,9 +519,13 @@ class _PanelClipboard extends RichClipboardService { ); RichClipboardData value; + int readCalls = 0; @override - Future read() async => value; + Future read() async { + readCalls++; + return value; + } } class _EmptyAssetInput extends AssetInputService { diff --git a/test/src/command_registry_test.dart b/test/src/command_registry_test.dart index f920a4a9..3e149bc9 100644 --- a/test/src/command_registry_test.dart +++ b/test/src/command_registry_test.dart @@ -43,6 +43,22 @@ void main() { registry[BusyMarkCommandIds.syntaxReference]?.shortcut?.label, 'Ctrl+Alt+M', ); + expect( + registry[BusyMarkCommandIds.localHistory]?.shortcut?.label, + 'Ctrl+5', + ); + expect( + registry[BusyMarkCommandIds.clipboardHistory]?.shortcut?.label, + 'Ctrl+6', + ); + expect( + registry[BusyMarkCommandIds.localHistory]?.scope, + BusyMarkCommandScope.application, + ); + expect( + registry[BusyMarkCommandIds.clipboardHistory]?.scope, + BusyMarkCommandScope.application, + ); expect( registry.commands.map((command) => command.id).toSet().length, registry.commands.length, diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index 79a1069c..6ae8e190 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -106,6 +106,8 @@ void main() { await sendNativeAction('sidebarToc'); await sendNativeAction('sidebarOutline'); await sendNativeAction('sidebarGit'); + await sendNativeAction('sidebarLocalHistory'); + await sendNativeAction('sidebarClipboardHistory'); expect(events.map((event) => event.action), [ HeaderBarAction.save, @@ -116,8 +118,21 @@ void main() { HeaderBarAction.sidebarToc, HeaderBarAction.sidebarOutline, HeaderBarAction.sidebarGit, + HeaderBarAction.sidebarLocalHistory, + HeaderBarAction.sidebarClipboardHistory, + ]); + expect(events.map((event) => event.sequence), [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, ]); - expect(events.map((event) => event.sequence), [1, 2, 3, 4, 5, 6, 7, 8]); expect(events.first, isNot(events.last)); }); diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 7af98d7b..28dd5a3d 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui' as ui; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_design.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/local_history/local_history_panel.dart'; @@ -116,6 +118,425 @@ void main() { expect(snapshot.revisions, hasLength(4)); }); + test( + 'first checkpoint attaches an empty untitled browsing scope and publishes it', + () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final empty = DocumentBuffer.untitled( + id: 'empty-draft', + name: 'Draft.md', + ); + + await controller.selectDocumentForBuffer(empty); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + isNull, + ); + final edited = empty.edited('First retained source'); + controller.observeEdit(empty, edited); + await Future.delayed(Duration.zero); + expect(controller.pendingSnapshotForBuffer(empty.id)?.text, edited.text); + expect(timers, hasLength(1)); + + timers.single.fire(); + await _waitForHistory( + container, + (state) => state.selectedRevisions.length == 1, + ); + + final state = container.read(localHistoryControllerProvider); + expect(state.selectedDocument?.displayName, 'Draft.md'); + expect(state.selectedDocument?.untitled, isTrue); + expect( + state.selectedRevisions.single.reason, + LocalHistoryCaptureReason.automaticCheckpoint, + ); + expect( + (await store.readRevision(state.selectedRevisions.single.id))!.source, + edited.text, + ); + expect(controller.pendingSnapshotForBuffer(empty.id), isNull); + }, + ); + + test( + 'file store checkpoint persists across reopen and clear stays authoritative', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-history-controller-file-', + ); + addTearDown(() => root.delete(recursive: true)); + final documentFile = File(p.join(root.path, 'workspace', 'guide.md')); + await documentFile.parent.create(recursive: true); + await documentFile.writeAsString('baseline'); + final historyRoot = Directory(p.join(root.path, 'history')); + final store = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final timers = <_FakeTimer>[]; + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer( + 'file-store-buffer', + documentFile.path, + 'baseline', + ); + await controller.selectDocumentForBuffer(opened); + final edited = opened.edited('persisted checkpoint'); + controller.observeEdit(opened, edited); + await Future.delayed(Duration.zero); + timers.single.fire(); + await _waitForHistory( + container, + (state) => state.selectedRevisions.length == 2, + ); + + final reopened = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + var diskSnapshot = await reopened.load(); + expect(diskSnapshot.documents, hasLength(1)); + expect( + await _revisionSources( + reopened, + diskSnapshot.revisionsFor(diskSnapshot.documents.single.id), + ), + contains('persisted checkpoint'), + ); + + await controller.clearDocument(diskSnapshot.documents.single.id); + diskSnapshot = await reopened.load(); + expect(diskSnapshot.documents, isEmpty); + expect(diskSnapshot.revisions, isEmpty); + }, + ); + + test( + 'active revision search follows matching and nonmatching captures', + () async { + final timers = <_FakeTimer>[]; + final store = MemoryLocalHistoryStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer( + 'search-buffer', + '/workspace/search.md', + 'base', + ); + await controller.selectDocumentForBuffer(opened); + await controller.search('needle'); + expect( + container.read(localHistoryControllerProvider).searchMatches, + isEmpty, + ); + + final matching = opened.edited('base with needle'); + controller.observeEdit(opened, matching); + await Future.delayed(Duration.zero); + timers.last.fire(); + await _waitForHistory( + container, + (state) => + !state.searching && + state.searchQuery == 'needle' && + state.searchMatches.isNotEmpty, + ); + var state = container.read(localHistoryControllerProvider); + expect( + state.selectedRevisions.where(state.revisionVisible), + hasLength(1), + ); + + final nonmatching = matching.edited('different content'); + controller.observeEdit(matching, nonmatching); + await Future.delayed(Duration.zero); + timers.last.fire(); + await _waitForHistory( + container, + (value) => !value.searching && value.selectedRevisions.length == 3, + ); + state = container.read(localHistoryControllerProvider); + expect(state.searchQuery, 'needle'); + expect( + state.selectedRevisions.where(state.revisionVisible), + hasLength(1), + ); + }, + ); + + test( + 'failed automatic capture keeps pending source and retries without another edit', + () async { + final timers = <_FakeTimer>[]; + final memory = MemoryLocalHistoryStore(); + final store = _FailingSourceCaptureStore(memory, 'retry source'); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer('retry-buffer', '/workspace/retry.md', 'base'); + await controller.selectDocumentForBuffer(opened); + final edited = opened.edited('retry source'); + controller.observeEdit(opened, edited); + await Future.delayed(Duration.zero); + + timers.single.fire(); + await _waitForHistory( + container, + (state) => state.warning?.kind == LocalHistoryWarningKind.capture, + ); + expect(controller.pendingSnapshotForBuffer(opened.id)?.text, edited.text); + expect(timers, hasLength(2)); + + timers.last.fire(); + await _waitForHistory( + container, + (state) => state.selectedRevisions.length == 2, + ); + final state = container.read(localHistoryControllerProvider); + expect(state.warning, isNull); + expect(controller.pendingSnapshotForBuffer(opened.id), isNull); + expect( + await _revisionSources(store, state.selectedRevisions), + contains(edited.text), + ); + }, + ); + + test('a newer edit accepted during a failed write wins the retry', () async { + final timers = <_FakeTimer>[]; + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + final opened = _fileBuffer('newest-buffer', '/workspace/newest.md', 'base'); + await controller.selectDocumentForBuffer(opened); + final older = opened.edited('older pending source'); + controller.observeEdit(opened, older); + await Future.delayed(Duration.zero); + store.blockNextCapture = true; + store.failBlockedCapture = true; + timers.single.fire(); + await store.captureStarted.future; + + final newer = older.edited('newest pending source'); + controller.observeEdit(older, newer); + store.releaseCapture.complete(); + await _waitForHistory( + container, + (state) => + state.warning?.kind == LocalHistoryWarningKind.capture && + controller.pendingSnapshotForBuffer(opened.id)?.text == newer.text, + ); + + timers.lastWhere((timer) => timer.isActive).fire(); + await _waitForHistory( + container, + (state) => + state.warning == null && + controller.pendingSnapshotForBuffer(opened.id) == null, + ); + final snapshot = await memory.load(); + final sources = await _revisionSources( + memory, + snapshot.revisionsFor(snapshot.documents.single.id), + ); + expect(sources, contains(newer.text)); + expect(sources, isNot(contains(older.text))); + }); + + test( + 'flush waits behind queued observation and captures its latest source', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer('flush-buffer', '/workspace/flush.md', 'base'); + await controller.observeOpened(opened); + + store.blockNextCapture = true; + final intermediate = opened.edited('intermediate'); + final blockedSave = controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer(intermediate), + ); + await store.captureStarted.future; + final latest = intermediate.edited('accepted latest source'); + controller.observeEdit(intermediate, latest); + final flush = controller.flushBuffer(latest); + var flushCompleted = false; + unawaited(flush.then((_) => flushCompleted = true)); + await Future.delayed(Duration.zero); + expect(flushCompleted, isFalse); + + store.releaseCapture.complete(); + expect(await blockedSave, isTrue); + expect(await flush, isTrue); + final snapshot = await memory.load(); + expect( + await _revisionSources( + memory, + snapshot.revisionsFor(snapshot.documents.single.id), + ), + contains('accepted latest source'), + ); + }, + ); + + test( + 'clear wins over an in-flight capture and later edits record normally', + () async { + final timers = <_FakeTimer>[]; + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer('clear-buffer', '/workspace/clear.md', 'base'); + await controller.selectDocumentForBuffer(opened); + final documentId = container + .read(localHistoryControllerProvider) + .selectedDocumentId!; + store.blockNextCapture = true; + final firstEdit = opened.edited('must stay cleared'); + controller.observeEdit(opened, firstEdit); + await Future.delayed(Duration.zero); + timers.single.fire(); + await store.captureStarted.future; + + final clear = controller.clearDocument(documentId); + store.releaseCapture.complete(); + await clear; + expect((await memory.load()).revisions, isEmpty); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + isNull, + ); + + final laterEdit = firstEdit.edited('new history after clear'); + controller.observeEdit(firstEdit, laterEdit); + await Future.delayed(Duration.zero); + timers.last.fire(); + await _waitForHistory( + container, + (state) => state.selectedRevisions.length >= 2, + ); + final state = container.read(localHistoryControllerProvider); + expect(state.selectedDocument?.currentPath, opened.filePath); + expect( + await _revisionSources(store, state.selectedRevisions), + contains('new history after clear'), + ); + }, + ); + + test( + 'clearing the displayed comparison closes it without a missing warning', + () async { + final store = MemoryLocalHistoryStore(); + await _capturePath(store, '/workspace/compare.md', 'older source'); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final buffer = _fileBuffer( + 'comparison-buffer', + '/workspace/compare.md', + 'current source', + ); + await controller.selectDocumentForBuffer(buffer); + final before = container.read(localHistoryControllerProvider); + final documentId = before.selectedDocumentId!; + final revisionId = before.selectedRevisions.first.id; + await controller.selectRevision(revisionId); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + isNotNull, + ); + + await controller.clearDocument(documentId); + + final state = container.read(localHistoryControllerProvider); + expect(state.selectedDocumentId, isNull); + expect(state.selectedRevisionId, isNull); + expect(state.selectedRevision, isNull); + expect( + state.warning?.kind, + isNot(LocalHistoryWarningKind.revisionMissing), + ); + }, + ); + + test( + 'clear invalidates an in-flight path capture for the same document', + () async { + final memory = MemoryLocalHistoryStore(); + const path = '/workspace/path-clear.md'; + await _capturePath(memory, path, 'retained source'); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final documentId = container + .read(localHistoryControllerProvider) + .snapshot + .documents + .single + .id; + + store.blockNextCapture = true; + final capture = controller.capturePath( + path: path, + text: 'must not return after clear', + format: TextFormatMetadata.utf8Lf, + reason: LocalHistoryCaptureReason.beforeDelete, + ); + await store.captureStarted.future; + + final clear = controller.clearDocument(documentId); + store.releaseCapture.complete(); + expect(await capture, isTrue); + await clear; + + expect((await memory.load()).revisions, isEmpty); + expect( + container.read(localHistoryControllerProvider).snapshot.revisions, + isEmpty, + ); + }, + ); + test( 'recording off prevents baseline and checkpoints without deleting history', () async { @@ -308,6 +729,7 @@ void main() { .documents .singleWhere((document) => document.deleted); history.inspectRetainedDocument(retained.id); + await tester.runAsync(() => history.search('Unique retained')); await tester.pumpWidget( UncontrolledProviderScope( @@ -324,6 +746,11 @@ void main() { final state = container.read(localHistoryControllerProvider); expect(state.inspectingRetainedDocument, isTrue); expect(state.selectedDocumentId, retained.id); + expect(state.searchQuery, 'Unique retained'); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Unique retained', + ); expect(find.text('deleted.md'), findsOneWidget); }, ); @@ -372,6 +799,37 @@ void main() { }, ); + test( + 'revision search failure finishes searching and remains in scope', + () async { + final store = _FailingRevisionReadStore(); + await _capturePath(store, '/workspace/failure.md', 'matching source'); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + await controller.refresh(); + final document = container + .read(localHistoryControllerProvider) + .snapshot + .documents + .single; + controller.selectDocument(document.id); + + store.failReads = true; + await controller.search('matching'); + + final state = container.read(localHistoryControllerProvider); + expect(state.searching, isFalse); + expect(state.searchQuery, 'matching'); + expect(state.selectedDocumentId, document.id); + expect(state.searchMatches, isEmpty); + expect(state.warning?.kind, LocalHistoryWarningKind.revisionRead); + }, + ); + testWidgets( 'Local History follows active tabs and clears the visible query without a document dropdown', (tester) async { @@ -426,6 +884,13 @@ void main() { expect(find.byType(DropdownButton), findsNothing); final searchField = find.byType(TextField); + final actionsMenu = find.byKey( + const ValueKey('local-history-actions-menu'), + ); + expect( + tester.getCenter(actionsMenu).dx, + greaterThan(tester.getTopRight(searchField).dx), + ); expect(searchField, findsOneWidget); await tester.enterText(searchField, 'Unique B'); await tester.runAsync( @@ -459,10 +924,18 @@ void main() { ); expect(tester.widget(searchField).controller!.text, isEmpty); - await tester.tap( - find.byKey(const ValueKey('local-history-actions-menu')), + final panelContext = tester.element(find.byType(LocalHistoryPanel)); + final refreshLabel = MaterialLocalizations.of( + panelContext, + ).refreshIndicatorSemanticLabel; + expect( + tester.getCenter(actionsMenu).dx, + greaterThan(tester.getTopRight(searchField).dx), ); + expect(find.byTooltip(refreshLabel), findsNothing); + await tester.tap(actionsMenu); await tester.pumpAndSettle(); + expect(find.text(refreshLabel), findsOneWidget); expect(find.text('Find in Local History…'), findsOneWidget); expect(find.text('Clear This Document’s History'), findsOneWidget); expect(find.text('Clear All Local History'), findsOneWidget); @@ -580,8 +1053,23 @@ void main() { final beforeLabel = DateFormat.Hms(locale).format(beforeMidnight); final afterLabel = DateFormat.Hms(locale).format(afterMidnight); expect(Directionality.of(context), TextDirection.rtl); + final searchField = find.byType(TextField); + final actionsMenu = find.byKey( + const ValueKey('local-history-actions-menu'), + ); + expect(actionsMenu, findsOneWidget); + expect(searchField, findsOneWidget); + expect( + tester.getCenter(actionsMenu).dx, + lessThan(tester.getTopLeft(searchField).dx), + ); expect(find.text(beforeLabel), findsOneWidget); expect(find.text(afterLabel), findsOneWidget); + expect( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + findsWidgets, + ); + expect(find.byType(ListTile), findsNothing); expect(find.text(l10n.localHistoryReasonSaved), findsOneWidget); expect( find.text(l10n.localHistoryReasonAutomaticCheckpoint), @@ -599,11 +1087,13 @@ void main() { ), findsOneWidget, ); + final revisionSemantics = tester.getSemantics(find.text(afterLabel)); + expect( + revisionSemantics.label.contains(l10n.localHistoryReasonSaved), + isTrue, + ); expect( - tester - .getSemantics(find.text(afterLabel)) - .label - .contains(l10n.localHistoryReasonSaved), + revisionSemantics.getSemanticsData().hasAction(ui.SemanticsAction.tap), isTrue, ); expect(tester.takeException(), isNull); @@ -687,7 +1177,19 @@ void main() { final refresh = find.byWidgetPredicate( (widget) => widget is IconButton && widget.tooltip == refreshLabel, ); - expect(tester.widget(refresh).onPressed, isNotNull); + expect(refresh, findsNothing); + await tester.tap( + find.byKey(const ValueKey('local-history-actions-menu')), + ); + await tester.pumpAndSettle(); + final refreshItem = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && widget.label == refreshLabel, + ); + expect( + tester.widget>(refreshItem).enabled, + isTrue, + ); }, ); @@ -973,6 +1475,63 @@ void main() { }, ); + test( + 'flush reports a content failure after successfully retrying promotion', + () async { + final store = _FailingPromotionThenCaptureStore('pending named source'); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final untitled = DocumentBuffer.untitled( + id: 'promotion-content-failure', + name: 'Draft.md', + text: 'initial source', + ); + await controller.observeOpened(untitled); + final pending = untitled.edited('pending named source'); + controller.observeEdit(untitled, pending); + await Future.delayed(Duration.zero); + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(pending), + '/workspace/Promoted.md', + destinationExisted: false, + ), + isFalse, + ); + expect(controller.hasPendingIdentityPromotion(untitled.id), isTrue); + final named = pending.copyWith( + filePath: '/workspace/Promoted.md', + untitledName: null, + lastSavedText: pending.text, + dirty: false, + ); + + expect(await controller.flushBuffer(named), isFalse); + expect(controller.hasPendingIdentityPromotion(named.id), isFalse); + expect(controller.pendingSnapshotForBuffer(named.id), isNotNull); + expect( + container.read(localHistoryControllerProvider).warning?.kind, + LocalHistoryWarningKind.capture, + ); + + expect(await controller.flushBuffer(named), isTrue); + expect(controller.pendingSnapshotForBuffer(named.id), isNull); + final snapshot = await store.load(); + expect(snapshot.documents.single.currentPath, '/workspace/Promoted.md'); + expect( + await _revisionSources( + store, + snapshot.revisionsFor(snapshot.documents.single.id), + ), + contains(pending.text), + ); + }, + ); + test( 'remap settles pending first-save promotions for files and directories', () async { @@ -1231,6 +1790,143 @@ class _BlockingRevisionReadStore implements LocalHistoryStore { delegate.remapPath(sourcePath, destinationPath); } +class _FailingSourceCaptureStore implements LocalHistoryStore { + _FailingSourceCaptureStore(this.delegate, this.source); + + final LocalHistoryStore delegate; + final String source; + var failed = false; + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + if (!failed && request.source == source) { + failed = true; + throw const LocalHistoryStorageException('Injected transient failure'); + } + return delegate.capture(request, policy); + } + + @override + Future clearAll() => delegate.clearAll(); + + @override + Future clearDocument(String documentId) => + delegate.clearDocument(documentId); + + @override + Future load() => delegate.load(); + + @override + Future markDeleted(String path, {required bool recursive}) => + delegate.markDeleted(path, recursive: recursive); + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) => + delegate.prune(policy, now); + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) => delegate.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + + @override + Future readRevision(String revisionId) => + delegate.readRevision(revisionId); + + @override + Future remapPath(String sourcePath, String destinationPath) => + delegate.remapPath(sourcePath, destinationPath); +} + +class _FailingRevisionReadStore extends MemoryLocalHistoryStore { + var failReads = false; + + @override + Future readRevision(String revisionId) { + if (failReads) throw StateError('Injected revision read failure'); + return super.readRevision(revisionId); + } +} + +class _BlockingNextCaptureStore implements LocalHistoryStore { + _BlockingNextCaptureStore(this.delegate); + + final LocalHistoryStore delegate; + var blockNextCapture = false; + var failBlockedCapture = false; + var captureStarted = Completer(); + var releaseCapture = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (blockNextCapture) { + blockNextCapture = false; + if (!captureStarted.isCompleted) captureStarted.complete(); + await releaseCapture.future; + if (failBlockedCapture) { + failBlockedCapture = false; + throw const LocalHistoryStorageException( + 'Injected blocked capture failure', + ); + } + } + return delegate.capture(request, policy); + } + + @override + Future clearAll() => delegate.clearAll(); + + @override + Future clearDocument(String documentId) => + delegate.clearDocument(documentId); + + @override + Future load() => delegate.load(); + + @override + Future markDeleted(String path, {required bool recursive}) => + delegate.markDeleted(path, recursive: recursive); + + @override + Future prune(LocalHistoryPolicy policy, DateTime now) => + delegate.prune(policy, now); + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) => delegate.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + + @override + Future readRevision(String revisionId) => + delegate.readRevision(revisionId); + + @override + Future remapPath(String sourcePath, String destinationPath) => + delegate.remapPath(sourcePath, destinationPath); +} + class _FailingFirstPromotionStore extends MemoryLocalHistoryStore { var _failNextPromotion = true; @@ -1254,6 +1950,47 @@ class _FailingFirstPromotionStore extends MemoryLocalHistoryStore { } } +class _FailingPromotionThenCaptureStore extends MemoryLocalHistoryStore { + _FailingPromotionThenCaptureStore(this.failingSource); + + final String failingSource; + var _failPromotion = true; + var _matchingCaptureCount = 0; + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) { + if (_failPromotion) { + _failPromotion = false; + throw const LocalHistoryStorageException('Injected promotion failure'); + } + return super.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + } + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + if (request.source == failingSource) { + _matchingCaptureCount++; + } + if (_matchingCaptureCount <= 2 && request.source == failingSource) { + throw const LocalHistoryStorageException('Injected capture failure'); + } + return super.capture(request, policy); + } +} + class _MemorySettingsStore implements LocalSettingsStore { Map value = {}; diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index c6d716a2..8acf282c 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -1162,10 +1162,12 @@ void main() { expect(native, contains('sidebar_shortcut_action_for_key')); expect(native, contains('GDK_KEY_KP_1')); expect(native, contains('GDK_KEY_KP_4')); - expect(native, isNot(contains('GDK_KEY_KP_5'))); + expect(native, contains('GDK_KEY_KP_5')); + expect(native, contains('GDK_KEY_KP_6')); expect(native, contains('"sidebarFiles"')); expect(native, contains('"sidebarGit"')); - expect(native, isNot(contains('"sidebarHistory"'))); + expect(native, contains('"sidebarLocalHistory"')); + expect(native, contains('"sidebarClipboardHistory"')); expect(native, contains('modifiers != GDK_CONTROL_MASK')); expect( native, @@ -1744,15 +1746,19 @@ void main() { 'lib/src/platform/header_bar_configuration.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); expect(welcome, contains('_WelcomeSidebar')); - expect(welcome, contains('_WelcomeRecentRow')); - expect(welcome, contains('WorkspaceIdentityRow')); - expect(welcome, contains('WorkspaceGlyphs.forRecent(widget.recent)')); + expect( + welcome, + contains('BusyMarkSidebarRecordRow<_RecentWorkspaceAction>'), + ); + expect(welcome, contains('WorkspaceGlyphs.forRecent(recent)')); expect(welcome, contains('BusyMarkGlyphs.markdownFile')); expect(welcome, contains('BusyMarkGlyphs.folder')); expect(welcome, contains('BusyMarkGlyphs.writersideProject')); - expect(welcome, contains('BorderRadius.circular(BusyMarkRadius.md)')); + expect(design, contains('class BusyMarkSidebarRecordRow')); + expect(design, contains('BorderRadius.circular(BusyMarkRadius.md)')); expect(welcome, contains('if (!sidebarOnRight && sidebarVisible)')); expect(welcome, contains('if (sidebarOnRight && sidebarVisible)')); expect(welcome, contains('welcomeMainColor = colors.view')); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 336f7609..9e469118 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -781,6 +781,10 @@ void main() { singleMarkdownClause, isNot(contains('_SidebarTab.gitProjectHistory')), ); + expect( + singleMarkdownClause.indexOf('_SidebarTab.localHistory'), + lessThan(singleMarkdownClause.indexOf('_SidebarTab.clipboard')), + ); expect( workspace, contains('showTabMenu: !widget.searchState.active && tabs.length > 1'), @@ -808,10 +812,18 @@ void main() { expect(folderClause, contains('_SidebarTab.outline')); expect(folderClause, contains('_SidebarTab.git')); expect(folderClause, isNot(contains('_SidebarTab.toc'))); + expect( + folderClause.indexOf('_SidebarTab.localHistory'), + lessThan(folderClause.indexOf('_SidebarTab.clipboard')), + ); expect(writersideClause, contains('_SidebarTab.files')); expect(writersideClause, contains('_SidebarTab.toc')); expect(writersideClause, contains('_SidebarTab.outline')); expect(writersideClause, contains('_SidebarTab.git')); + expect( + writersideClause.indexOf('_SidebarTab.localHistory'), + lessThan(writersideClause.indexOf('_SidebarTab.clipboard')), + ); }); test('sidebar tabs and editor hover use neutral native surfaces', () { @@ -910,7 +922,8 @@ void main() { workspace, isNot(contains('BusyMarkSidebarShortcutActivators.history')), ); - expect(workspace, isNot(contains('LogicalKeyboardKey.numpad5'))); + expect(workspace, contains('LogicalKeyboardKey.numpad5')); + expect(workspace, contains('LogicalKeyboardKey.numpad6')); expect( workspace, contains('_SidebarTab.files => BusyMarkGlyphs.documentOpen'), @@ -1474,22 +1487,72 @@ void main() { final welcome = File( 'lib/src/workspace/presentation/welcome_screen.dart', ).readAsStringSync(); + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); final settings = File('lib/src/app/app_settings.dart').readAsStringSync(); expect(welcome, contains('enum _RecentWorkspaceAction')); expect(welcome, contains('_recentWorkspaceMenuItems')); - expect(welcome, contains('BusyMarkMenuButton<_RecentWorkspaceAction>')); expect( welcome, - contains('showBusyMarkContextMenu<_RecentWorkspaceAction>'), + contains('BusyMarkSidebarRecordRow<_RecentWorkspaceAction>'), ); - expect(welcome, contains('isBusyMarkContextMenuKeyEvent(event)')); + expect(welcome, contains('menuItemsBuilder: _recentWorkspaceMenuItems')); + expect(design, contains('class BusyMarkSidebarRecordRow')); + expect(design, contains('BusyMarkMenuButton')); + expect(design, contains('showBusyMarkContextMenu')); + expect(design, contains('isBusyMarkContextMenuKeyEvent(event)')); expect(welcome, contains('label: context.l10n.openInFiles')); expect(welcome, contains('label: context.l10n.copyPath')); expect(welcome, contains('label: context.l10n.removeFromRecent')); expect(settings, contains('removeRecentWorkspace(String path)')); }); + test('history records reuse the Recent sidebar row', () { + final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final clipboard = File( + 'lib/src/clipboard/clipboard_history_panel.dart', + ).readAsStringSync(); + final localHistory = File( + 'lib/src/local_history/local_history_panel.dart', + ).readAsStringSync(); + + expect(design, contains('class BusyMarkSidebarRecordRow')); + expect( + clipboard, + contains('BusyMarkSidebarRecordRow<_ClipboardEntryAction>'), + ); + expect(clipboard, contains('padding: BusyMarkInsets.sidebarList')); + expect(clipboard, contains('content: Text(')); + expect(clipboard, contains('maxLines: 3')); + expect( + clipboard, + isNot(contains('if (preview != null && preview.trim().isNotEmpty)')), + ); + expect(clipboard, isNot(contains('subtitle:'))); + expect(clipboard, isNot(contains('clipboardDestination('))); + expect( + clipboard, + contains('BusyMarkHeaderPopupMenuButton<_ClipboardHistoryAction>'), + ); + expect(clipboard, contains("'clipboard-history-actions-menu'")); + expect(clipboard, contains('textDirection: TextDirection.ltr')); + expect(clipboard, isNot(contains('colorScheme.primaryContainer'))); + expect(clipboard, isNot(contains('expandedChild:'))); + expect(clipboard, isNot(contains('_ClipboardPreview'))); + expect(clipboard, isNot(contains('_selectedId = visible.first.id'))); + expect(design, isNot(contains('final Widget? expandedChild'))); + expect(localHistory, contains('BusyMarkSidebarRecordRow')); + expect( + localHistory, + contains('BusyMarkHeaderPopupMenuButton<_HistoryAction>'), + ); + expect(localHistory, contains("'local-history-actions-menu'")); + expect(localHistory, contains('textDirection: TextDirection.ltr')); + expect(localHistory, contains('_HistoryAction.refresh')); + expect(localHistory, contains('padding: BusyMarkInsets.sidebarList')); + expect(localHistory, isNot(contains('child: ListTile('))); + }); + test('outline tree drives source and preview heading navigation', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 8e542263..34893842 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/core/source_span.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/local_history/local_history_store.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; @@ -1184,6 +1187,93 @@ void main() { settingsController.dispose(); }); + test( + 'Undo and Redo reschedule autosave after the prior save settled', + () async { + final service = _AutosaveWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final controller = harness.controller; + + await controller.openPath(service.path); + controller.updateActiveText('# Changed\n'); + await _waitFor(() => service.savedTexts.length == 1); + expect(controller.state.isDirty, isFalse); + + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# Initial\n'); + await _waitFor(() => service.savedTexts.length == 2); + expect(service.savedTexts.last, '# Initial\n'); + expect(controller.state.isDirty, isFalse); + + expect(controller.redoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# Changed\n'); + await _waitFor(() => service.savedTexts.length == 3); + expect(service.savedTexts.last, '# Changed\n'); + expect(controller.state.isDirty, isFalse); + }, + ); + + test( + 'inactive document updates schedule autosave without changing tabs', + () async { + final service = _DelayedValidationWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final controller = harness.controller; + + await harness.settingsController.setValidateOnEdit(false); + await controller.openPath(service.rootPath); + final aId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(service.bPath), isTrue); + final activeB = controller.state.activeBuffer!.id; + + expect(controller.updateDocumentText(aId, '# Inactive change\n'), isTrue); + expect(controller.state.activeBufferId, activeB); + await _waitFor(() => service.savedTexts.contains('# Inactive change\n')); + expect(controller.state.activeBufferId, activeB); + expect( + controller.state.documentBuffers + .singleWhere((buffer) => buffer.id == aId) + .isDirty, + isFalse, + ); + }, + ); + + test( + 'failed workspace refresh restores eligible autosave scheduling', + () async { + final service = _FailingRefreshAutosaveWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final controller = harness.controller; + + await controller.openPath(service.path); + controller.updateActiveText('# Save after refresh failure\n'); + service.failRefresh = true; + expect(await controller.refreshWorkspaceFromDisk(), isFalse); + expect(controller.state.isDirty, isTrue); + + await _waitFor(() => service.savedTexts.isNotEmpty); + expect(service.savedTexts, ['# Save after refresh failure\n']); + expect(controller.state.isDirty, isFalse); + }, + ); + + test('disabled autosave stays disabled after a failed refresh', () async { + final service = _FailingRefreshAutosaveWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final controller = harness.controller; + + await harness.settingsController.setAutoSave(false); + await controller.openPath(service.path); + controller.updateActiveText('# Remains dirty\n'); + service.failRefresh = true; + expect(await controller.refreshWorkspaceFromDisk(), isFalse); + await Future.delayed(const Duration(milliseconds: 1700)); + + expect(service.savedTexts, isEmpty); + expect(controller.state.isDirty, isTrue); + }); + test('auto save remains scheduled independently for inactive tabs', () async { final service = _DelayedValidationWorkspaceService(); final harness = await _createControllerHarness(service: service); @@ -1321,6 +1411,39 @@ void main() { ); }); + test( + 'discard aborts when the document changes during protective history capture', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-close-history-race-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'draft.md'); + await File(path).writeAsString('# Disk\n'); + final historyStore = _BlockingBeforeDiscardStore(); + final harness = await _createControllerHarness( + localHistoryStore: historyStore, + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(path); + controller.updateActiveText('# Approved discard state\n'); + final bufferId = controller.state.activeBuffer!.id; + + final close = controller.closeDocumentBuffer(bufferId, discard: true); + await historyStore.captureStarted.future; + controller.updateActiveText('# New edit while history is writing\n'); + historyStore.releaseCapture.complete(); + + expect(await close, isFalse); + final surviving = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == bufferId, + ); + expect(surviving.text, '# New edit while history is writing\n'); + expect(surviving.isDirty, isTrue); + }, + ); + test('discard waits for its buffer in-flight autosave', () async { final service = _AutosaveWorkspaceService(pauseFirstSave: true); final harness = await _createControllerHarness(service: service); @@ -1706,6 +1829,60 @@ void main() { }, ); + test( + 'tab activation reconciles edits accepted while its reparse is pending', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-activation-live-edits-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + final service = _BlockingRefreshWorkspaceService(); + final harness = await _createControllerHarness(service: service); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller; + await controller.openPath(directory.path); + final aId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(bPath), isTrue); + final bId = controller.state.activeBuffer!.id; + expect(await controller.activateDocumentBuffer(aId), isTrue); + + service.pauseNextReparse(); + final activation = controller.activateDocumentBuffer(bId); + await service.reparseStarted.future; + controller.updateActiveSourceText('# A edited while B activates\n'); + expect(controller.updateDocumentText(bId, '# B newest\n'), isTrue); + final liveA = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == aId, + ); + final liveB = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == bId, + ); + + service.releaseReparse(); + expect(await activation, isTrue); + expect(controller.state.activeBufferId, bId); + expect(controller.state.activeText, '# B newest\n'); + expect( + controller.state.documentBuffers + .singleWhere((buffer) => buffer.id == aId) + .text, + liveA.text, + ); + expect(controller.state.activeBuffer!.isDirty, isTrue); + expect( + controller.state.activeBuffer!.editorState.undoState.undo, + liveB.editorState.undoState.undo, + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# B\n'); + }, + ); + test( 'workspace refresh preserves inactive edits and does not resurrect a closed tab', () async { @@ -2225,6 +2402,7 @@ Future<_WorkspaceControllerHarness> _createControllerHarness({ WorkspaceFileMonitor? fileMonitor, DocumentSessionStore? sessionStore, DocumentRecoveryStore? recoveryStore, + LocalHistoryStore? localHistoryStore, }) async { final container = ProviderContainer( overrides: [ @@ -2236,6 +2414,8 @@ Future<_WorkspaceControllerHarness> _createControllerHarness({ documentSessionStoreProvider.overrideWithValue(sessionStore), if (recoveryStore != null) documentRecoveryStoreProvider.overrideWithValue(recoveryStore), + if (localHistoryStore != null) + localHistoryStoreProvider.overrideWithValue(localHistoryStore), ], ); addTearDown(container.dispose); @@ -2449,6 +2629,23 @@ class _MemorySettingsStore implements LocalSettingsStore { } } +class _BlockingBeforeDiscardStore extends MemoryLocalHistoryStore { + final captureStarted = Completer(); + final releaseCapture = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeDiscard) { + captureStarted.complete(); + await releaseCapture.future; + } + return super.capture(request, policy); + } +} + class _FailingDocumentSessionStore extends MemoryDocumentSessionStore { bool failSave = false; @@ -2634,6 +2831,17 @@ class _AutosaveWorkspaceService extends WorkspaceService { } } +class _FailingRefreshAutosaveWorkspaceService + extends _AutosaveWorkspaceService { + var failRefresh = false; + + @override + Future openPath(String path) { + if (failRefresh) throw StateError('injected refresh failure'); + return super.openPath(path); + } +} + class _DelayedValidationWorkspaceService extends WorkspaceService { final rootPath = '/tmp/busymark-delayed-validation'; late final aPath = p.join(rootPath, 'a.md'); diff --git a/tools/history_visual_smoke.dart b/tools/history_visual_smoke.dart index 418479d8..0ecf0ecf 100644 --- a/tools/history_visual_smoke.dart +++ b/tools/history_visual_smoke.dart @@ -54,6 +54,8 @@ Future main(List arguments) async { : p.normalize(p.absolute(arguments[3])); final output = Directory(p.normalize(p.absolute(arguments[4]))); await output.create(recursive: true); + final realPolicyTimer = + Platform.environment['BUSYMARK_HISTORY_REAL_POLICY'] == '1'; await windowManager.ensureInitialized(); await LinuxHeaderBarService.instance.initialize(); @@ -95,9 +97,10 @@ Future main(List arguments) async { rootDirectory: () async => Directory(p.join(output.path, 'store')), ), ), - localHistoryTimerFactoryProvider.overrideWithValue( - (_, callback) => Timer(const Duration(milliseconds: 450), callback), - ), + if (!realPolicyTimer) + localHistoryTimerFactoryProvider.overrideWithValue( + (_, callback) => Timer(const Duration(milliseconds: 450), callback), + ), ], child: _HistoryVisualHarness( mode: mode, @@ -105,6 +108,7 @@ Future main(List arguments) async { deletePath: deletePath, output: output, boundaryKey: boundaryKey, + realPolicyTimer: realPolicyTimer, ), ), ); @@ -124,6 +128,7 @@ class _HistoryVisualHarness extends ConsumerStatefulWidget { required this.deletePath, required this.output, required this.boundaryKey, + required this.realPolicyTimer, }); final String mode; @@ -131,6 +136,7 @@ class _HistoryVisualHarness extends ConsumerStatefulWidget { final String? deletePath; final Directory output; final GlobalKey boundaryKey; + final bool realPolicyTimer; @override ConsumerState<_HistoryVisualHarness> createState() => @@ -190,37 +196,6 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { await _capture('${widget.mode}-clipboard-history-1280x800.png'); await _exerciseCurrentClipboard(); - var buffer = ref.read(workspaceControllerProvider).activeBuffer!; - await localHistory.observeOpened(buffer); - final baseline = buffer.text; - final saved = _savedVersion(baseline); - workspace.updateActiveText(saved, sourceFilePath: buffer.filePath); - buffer = ref.read(workspaceControllerProvider).activeBuffer!; - _check( - await localHistory.captureSaved( - LocalHistoryBufferSnapshot.fromBuffer(buffer), - ), - 'saved event captured', - ); - _check( - await workspace.saveActive(overwriteExternalChanges: true), - 'explicit save captured', - ); - buffer = ref.read(workspaceControllerProvider).activeBuffer!; - workspace.updateActiveText( - _checkpointVersion(buffer.text), - sourceFilePath: buffer.filePath, - ); - await Future.delayed(const Duration(milliseconds: 850)); - await localHistory.refresh(); - buffer = ref.read(workspaceControllerProvider).activeBuffer!; - workspace.updateActiveText( - _currentVersion(buffer.text), - sourceFilePath: buffer.filePath, - ); - buffer = ref.read(workspaceControllerProvider).activeBuffer!; - await localHistory.selectDocumentForBuffer(buffer); - await localHistory.refresh(); _check( await ref .read(busyMarkCommandRegistryProvider) @@ -235,10 +210,51 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { .read(localHistoryControllerProvider) .selectedRevisions .isNotEmpty, - 'Local History revisions', + 'Local History baseline', + ); + var buffer = ref.read(workspaceControllerProvider).activeBuffer!; + final baseline = buffer.text; + final saved = _savedVersion(baseline); + workspace.updateActiveText(saved, sourceFilePath: buffer.filePath); + _check( + await workspace.saveActive(overwriteExternalChanges: true), + 'explicit save captured', + ); + await _waitForRevisionSource( + saved, + LocalHistoryCaptureReason.saved, + 'saved source publication', + ); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + final checkpoint = _checkpointVersion(buffer.text); + workspace.updateActiveText(checkpoint, sourceFilePath: buffer.filePath); + await _waitForRevisionSource( + checkpoint, + LocalHistoryCaptureReason.automaticCheckpoint, + 'automatic checkpoint publication', ); await _capture('${widget.mode}-local-history-1280x800.png'); + await localHistory.search('Monitoring'); + buffer = ref.read(workspaceControllerProvider).activeBuffer!; + final current = _currentVersion(buffer.text); + workspace.updateActiveText(current, sourceFilePath: buffer.filePath); + await _waitForRevisionSource( + current, + LocalHistoryCaptureReason.automaticCheckpoint, + 'filtered checkpoint publication', + ); + await _waitFor( + () => + !ref.read(localHistoryControllerProvider).searching && + ref.read(localHistoryControllerProvider).searchQuery == + 'Monitoring' && + ref.read(localHistoryControllerProvider).searchMatches.length >= 2, + 'live filtered Local History rows', + ); + await _capture('${widget.mode}-local-history-filtered-1280x800.png'); + await localHistory.search(''); + final historyState = ref.read(localHistoryControllerProvider); final historyDocument = historyState.selectedDocument!; final revisionSummary = historyState.selectedRevisions.lastWhere( @@ -282,7 +298,6 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { ), 'fragment restore', ); - await localHistory.refresh(); await _capture('${widget.mode}-fragment-restored-1920x1080.png'); if (widget.deletePath case final deletePath?) { @@ -291,6 +306,12 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { _checks['clipboardEntries'] = ref.read(clipboardHistoryControllerProvider).entries.length >= 4; + _checks['defaultCheckpointPolicy'] = + ref + .read(localHistoryControllerProvider.notifier) + .policy + .checkpointInterval == + const Duration(seconds: 60); _checks['revisionReasons'] = ref .read(localHistoryControllerProvider) .snapshot @@ -309,6 +330,9 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { ).writeAsString( const JsonEncoder.withIndent(' ').convert({ 'passed': passed, + 'timerMode': widget.realPolicyTimer + ? 'production-default' + : 'visual-smoke-450ms', 'checks': _checks, 'screenshots': _screenshots, }), @@ -494,13 +518,10 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { String deletePath, ) async { _check(await workspace.openActiveFile(deletePath), 'recovery file open'); - final buffer = ref.read(workspaceControllerProvider).activeBuffer!; - await localHistory.observeOpened(buffer); _check( await workspace.deleteWorkspaceEntity(deletePath), 'document delete', ); - await localHistory.refresh(); ref.read(localHistoryOpenRequestProvider.notifier).request(); await Future.delayed(const Duration(milliseconds: 300)); final deletedDocument = ref @@ -531,7 +552,6 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { ), 'deleted document recovery', ); - await localHistory.refresh(); await _capture('${widget.mode}-deleted-recovered-1920x1080.png'); _checks['deletedFileRecovered'] = await File(deletePath).exists(); } @@ -569,6 +589,27 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { _screenshots.add(path); } + Future _waitForRevisionSource( + String source, + LocalHistoryCaptureReason reason, + String description, + ) async { + final store = ref.read(localHistoryStoreProvider); + final maximumAttempts = widget.realPolicyTimer ? 900 : 240; + for (var attempt = 0; attempt < maximumAttempts; attempt++) { + final summaries = ref + .read(localHistoryControllerProvider) + .selectedRevisions + .where((revision) => revision.reason == reason); + for (final summary in summaries) { + final revision = await store.readRevision(summary.id); + if (revision?.source == source) return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + throw TimeoutException('Timed out waiting for $description.'); + } + bool _hasWidget() { var found = false; void visit(Element element) { From e92c0b053ed6cc1f28dbb3108addd810655ff66b Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 18:45:41 -0700 Subject: [PATCH 18/27] Update smoke test timer and refine Local History handling - Increased smoke test timer from 450ms to 2s for better evaluation. - Refined `LocalHistoryCaptureReason` handling to allow nullable reasoning. - Added `_waitForEditorSourceToSettle` for stability checks during clipboard operations. - Adjusted Local History save logic to handle production and testing timer modes. - Updated documentation to clarify toolbar actions and history commands. --- docs/history-architecture.md | 4 ++ docs/history.md | 8 +++ test/src/local_history_controller_test.dart | 2 +- tools/history_visual_smoke.dart | 58 +++++++++++++++------ 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index c3f56b5c..d7bb6e49 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -184,6 +184,10 @@ GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ markdown WORKSPACE PRIMARY_FILE DELETE_FILE OUTPUT_DIRECTORY GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ writerside WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY +# Omit the smoke timer override and exercise the real 60-second deadline: +BUSYMARK_HISTORY_REAL_POLICY=1 GDK_BACKEND=x11 \ + build/linux/x64/debug/bundle/busymark \ + markdown WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY ``` When a probe is launched through `xvfb-run`, explicitly set diff --git a/docs/history.md b/docs/history.md index 930d9e3a..dc987842 100644 --- a/docs/history.md +++ b/docs/history.md @@ -15,6 +15,10 @@ tooltip. Use its action menu for **Paste** or **Paste as Plain Text**; Enter and double-click insert the selected item at the latest visible editable selection. Escape returns focus to the editor. +The panel toolbar keeps the search field first and the contextual **Actions** +menu on its physical right. That menu owns **Refresh** and **Clear Clipboard +History**; these commands are not application Main-menu entries. + BusyMark collects successful copy and cut operations from document editors, plus supported content copied in another application after it is successfully pasted into a BusyMark document. The current system clipboard can be refreshed @@ -45,6 +49,10 @@ plain filename header and revision list follow the active document tab; moving focus to search, a revision, the comparison, or an action does not change that scope. The header tooltip contains the full path when one exists. +Its toolbar likewise places the contextual **Actions** menu to the physical +right of the search field. **Refresh**, **Find in Local History…**, and clear +commands stay in that menu rather than the application Main menu. + Use **Find in Local History…** from Local History's header action menu or the command palette to find closed, renamed, deleted, and previously untitled documents by stored name, path, and revision content, even when their original diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 28dd5a3d..d25e8f49 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1061,7 +1061,7 @@ void main() { expect(searchField, findsOneWidget); expect( tester.getCenter(actionsMenu).dx, - lessThan(tester.getTopLeft(searchField).dx), + greaterThan(tester.getTopRight(searchField).dx), ); expect(find.text(beforeLabel), findsOneWidget); expect(find.text(afterLabel), findsOneWidget); diff --git a/tools/history_visual_smoke.dart b/tools/history_visual_smoke.dart index 0ecf0ecf..60f660e9 100644 --- a/tools/history_visual_smoke.dart +++ b/tools/history_visual_smoke.dart @@ -99,7 +99,7 @@ Future main(List arguments) async { ), if (!realPolicyTimer) localHistoryTimerFactoryProvider.overrideWithValue( - (_, callback) => Timer(const Duration(milliseconds: 450), callback), + (_, callback) => Timer(const Duration(seconds: 2), callback), ), ], child: _HistoryVisualHarness( @@ -195,6 +195,7 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { ); await _capture('${widget.mode}-clipboard-history-1280x800.png'); await _exerciseCurrentClipboard(); + await _waitForEditorSourceToSettle(); _check( await ref @@ -220,11 +221,7 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { await workspace.saveActive(overwriteExternalChanges: true), 'explicit save captured', ); - await _waitForRevisionSource( - saved, - LocalHistoryCaptureReason.saved, - 'saved source publication', - ); + await _waitForRevisionSource(saved, null, 'saved source publication'); buffer = ref.read(workspaceControllerProvider).activeBuffer!; final checkpoint = _checkpointVersion(buffer.text); workspace.updateActiveText(checkpoint, sourceFilePath: buffer.filePath); @@ -239,11 +236,23 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { buffer = ref.read(workspaceControllerProvider).activeBuffer!; final current = _currentVersion(buffer.text); workspace.updateActiveText(current, sourceFilePath: buffer.filePath); - await _waitForRevisionSource( - current, - LocalHistoryCaptureReason.automaticCheckpoint, - 'filtered checkpoint publication', - ); + if (widget.realPolicyTimer) { + _check( + await workspace.saveActive(overwriteExternalChanges: true), + 'filtered explicit save captured', + ); + await _waitForRevisionSource( + current, + null, + 'filtered saved-source publication', + ); + } else { + await _waitForRevisionSource( + current, + LocalHistoryCaptureReason.automaticCheckpoint, + 'filtered checkpoint publication', + ); + } await _waitFor( () => !ref.read(localHistoryControllerProvider).searching && @@ -320,7 +329,6 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { .toSet() .containsAll({ LocalHistoryCaptureReason.baseline, - LocalHistoryCaptureReason.saved, LocalHistoryCaptureReason.automaticCheckpoint, LocalHistoryCaptureReason.beforeRestore, }); @@ -332,7 +340,7 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { 'passed': passed, 'timerMode': widget.realPolicyTimer ? 'production-default' - : 'visual-smoke-450ms', + : 'visual-smoke-2s', 'checks': _checks, 'screenshots': _screenshots, }), @@ -591,7 +599,7 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { Future _waitForRevisionSource( String source, - LocalHistoryCaptureReason reason, + LocalHistoryCaptureReason? reason, String description, ) async { final store = ref.read(localHistoryStoreProvider); @@ -600,7 +608,7 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { final summaries = ref .read(localHistoryControllerProvider) .selectedRevisions - .where((revision) => revision.reason == reason); + .where((revision) => reason == null || revision.reason == reason); for (final summary in summaries) { final revision = await store.readRevision(summary.id); if (revision?.source == source) return; @@ -610,6 +618,26 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { throw TimeoutException('Timed out waiting for $description.'); } + Future _waitForEditorSourceToSettle() async { + var stableChecks = 0; + var revision = ref.read(workspaceControllerProvider).activeBuffer!.revision; + for (var attempt = 0; attempt < 100; attempt++) { + await Future.delayed(const Duration(milliseconds: 100)); + final currentRevision = ref + .read(workspaceControllerProvider) + .activeBuffer! + .revision; + if (currentRevision == revision) { + stableChecks += 1; + if (stableChecks == 10) return; + } else { + revision = currentRevision; + stableChecks = 0; + } + } + throw TimeoutException('Timed out waiting for editor source to settle.'); + } + bool _hasWidget() { var found = false; void visit(Element element) { From f7ab37219e6a0e6357b5f795a5924ee712ed2afb Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 19:00:39 -0700 Subject: [PATCH 19/27] Improve Clipboard History UI and refine Local History header logic - Updated `BusyMarkSearchField` hint text for Clipboard History to improve clarity. - Enhanced `ClipboardHistoryPanel` entry display with additional metadata and origin details. - Simplified `_LocalHistoryHeader` by removing unused `activeDocumentName` and `activeDocumentPath`. - Adjusted header logic to conditionally display based on document state and streamlined layout. --- docs/history-architecture.md | 2 +- docs/history.md | 27 +++--- lib/l10n/app_ar.arb | 3 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 3 +- lib/l10n/app_es.arb | 3 +- lib/l10n/app_et.arb | 3 +- lib/l10n/app_fa.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_hi.arb | 3 +- lib/l10n/app_id.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_ja.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/l10n/app_nb.arb | 3 +- lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_pt_BR.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_tr.arb | 3 +- lib/l10n/app_uk.arb | 3 +- lib/l10n/app_vi.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/l10n/app_zh_CN.arb | 3 +- lib/l10n/generated/app_localizations.dart | 8 +- lib/l10n/generated/app_localizations_ar.dart | 5 +- lib/l10n/generated/app_localizations_de.dart | 5 +- lib/l10n/generated/app_localizations_en.dart | 5 +- lib/l10n/generated/app_localizations_es.dart | 6 +- lib/l10n/generated/app_localizations_et.dart | 5 +- lib/l10n/generated/app_localizations_fa.dart | 5 +- lib/l10n/generated/app_localizations_fr.dart | 6 +- lib/l10n/generated/app_localizations_hi.dart | 5 +- lib/l10n/generated/app_localizations_id.dart | 5 +- lib/l10n/generated/app_localizations_it.dart | 6 +- lib/l10n/generated/app_localizations_ja.dart | 5 +- lib/l10n/generated/app_localizations_ko.dart | 5 +- lib/l10n/generated/app_localizations_nb.dart | 5 +- lib/l10n/generated/app_localizations_nl.dart | 5 +- lib/l10n/generated/app_localizations_pl.dart | 5 +- lib/l10n/generated/app_localizations_pt.dart | 12 ++- lib/l10n/generated/app_localizations_ru.dart | 5 +- lib/l10n/generated/app_localizations_tr.dart | 5 +- lib/l10n/generated/app_localizations_uk.dart | 5 +- lib/l10n/generated/app_localizations_vi.dart | 5 +- lib/l10n/generated/app_localizations_zh.dart | 10 ++- .../clipboard/clipboard_history_panel.dart | 78 +++++++++++------ .../local_history/local_history_panel.dart | 46 ++++------ test/src/clipboard_history_panel_test.dart | 86 +++++++++++-------- test/src/local_history_controller_test.dart | 20 ++++- 51 files changed, 303 insertions(+), 153 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index d7bb6e49..e822dd30 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -98,7 +98,7 @@ to 30 days and 512 MiB. Ordinary Local History browsing is scoped to the active document buffer. A scope generation invalidates outstanding revision reads and searches before a -new filename is displayed, so old results cannot land beneath a new header. +new scope is displayed, so old results cannot land in the wrong document list. Store-wide document search is a temporary discovery mode for retained closed, renamed, deleted, or untitled identities; selecting a normal document resumes active-tab following. The revision list is timestamp-first and groups the same diff --git a/docs/history.md b/docs/history.md index dc987842..62ac3efe 100644 --- a/docs/history.md +++ b/docs/history.md @@ -9,11 +9,13 @@ compared and restored. Open **Clipboard History** from the sidebar selector, command palette, or a document editor's context menu. The panel stays open while you move between -documents and selections. Each row preserves up to three lines of the item's -source preview; its type, capture time, and origin are available in the row -tooltip. Use its action menu for **Paste** or **Paste as Plain Text**; Enter and -double-click insert the selected item at the latest visible editable selection. -Escape returns focus to the editor. +documents and selections. Each row preserves up to two lines of the item's +source preview, followed by its type or current-clipboard status and capture +date and time. Entries copied inside BusyMark also show their source document +and path. This information is part of the row rather than a hover tooltip. Use +its action menu for **Paste** or **Paste as Plain Text**; Enter and double-click +insert the selected item at the latest visible editable selection. Escape +returns focus to the editor. The panel toolbar keeps the search field first and the contextual **Actions** menu on its physical right. That menu owns **Refresh** and **Clear Clipboard @@ -45,21 +47,22 @@ operating-system clipboard. Collection can be disabled under Open **Local History** from the sidebar selector, command palette, document tab or editor context menu, or a file-tree context menu. In ordinary use its -plain filename header and revision list follow the active document tab; moving -focus to search, a revision, the comparison, or an action does not change that -scope. The header tooltip contains the full path when one exists. +revision list follows the active document tab; the active filename is not +repeated above the search field. Moving focus to search, a revision, the +comparison, or an action does not change that scope. Its toolbar likewise places the contextual **Actions** menu to the physical right of the search field. **Refresh**, **Find in Local History…**, and clear commands stay in that menu rather than the application Main menu. -Use **Find in Local History…** from Local History's header action menu or the +Use **Find in Local History…** from Local History's **Actions** menu or the command palette to find closed, renamed, deleted, and previously untitled documents by stored name, path, and revision content, even when their original workspace is not open. Selecting a result temporarily inspects that retained -document in the same sidebar. Its missing/deleted state is explicit, and the -local Back action returns to the active document. There is no store-wide -document selector in the ordinary history view. +document in the same sidebar. The temporary context row identifies that scope, +its tooltip exposes the retained path, its missing/deleted state is explicit, +and the local Back action returns to the active document. There is no +store-wide document selector in the ordinary history view. BusyMark records complete source text and its encoding/line-ending policy. A baseline is captured when an eligible existing document is first tracked. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 486e5b52..b240e129 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.", "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}.", "clipboardHistory": "سجل النسخ واللصق", + "clipboardHistorySearchHint": "البحث في سجل الحافظة", "clipboardHistoryDescription": "افتح سجل النسخ واللصق الخاص بالجلسة.", "clipboardCurrent": "النسخة واللصق الحالية", "clipboardCurrentExternal": "النسخة واللصق من تطبيق آخر", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "لا توجد مستندات لديها تاريخ محلي.", "localHistoryNoRevisions": "هذا المستند ليس لديه إصدارات في التاريخ المحلي.", "localHistoryNoMatches": "لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.", - "localHistorySearchHint": "البحث في محتوى الإصدار", + "localHistorySearchHint": "البحث في السجل المحلي", "localHistorySelectedRevision": "الإصدار المحدد", "localHistoryCurrentEditor": "محتوى المحرر الحالي", "localHistoryCurrentDisk": "المحتوى الحالي على القرص", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 81c7eecc..a76ae15b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Wählen Sie eine lesbare UTF-8-CSS-Datei bis 256 KiB ohne Importe, Ressourcen-URLs, HTML sowie ausführbares oder maskiertes CSS.", "exportInvalidRange": "{field}: Geben Sie eine Zahl zwischen {minimum} und {maximum} ein.", "clipboardHistory": "Zwischenablage-Historie", + "clipboardHistorySearchHint": "Zwischenablageverlauf durchsuchen", "clipboardHistoryDescription": "Öffne die Sitzungs-spezifische Zwischenablage-Historie.", "clipboardCurrent": "Aktuelle Zwischenablage", "clipboardCurrentExternal": "Aktuelle Zwischenablage aus einer anderen Anwendung", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Es gibt noch keine Dokumente mit Lokaler Historie.", "localHistoryNoRevisions": "Dieses Dokument hat keine Lokalen Historie Revisionen.", "localHistoryNoMatches": "Keine Lokalen Historie Revisionen passen zu dieser Suche.", - "localHistorySearchHint": "Revisionen durchsuchen", + "localHistorySearchHint": "Lokalen Verlauf durchsuchen", "localHistorySelectedRevision": "Ausgewählte Revision", "localHistoryCurrentEditor": "Aktueller Editor Inhalt", "localHistoryCurrentDisk": "Aktueller Inhalt auf der Festplatte", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 72b4f3ed..2f4005d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2448,6 +2448,7 @@ "exportInvalidColor": "Enter a color as #RRGGBB.", "exportInvalidCss": "Choose a readable UTF-8 .css file up to 256 KiB, without imports, resource URLs, HTML, or executable or escaped CSS.", "clipboardHistory": "Clipboard History", + "clipboardHistorySearchHint": "Search clipboard history", "clipboardHistoryDescription": "Open the session-local clipboard history.", "clipboardCurrent": "Current clipboard", "clipboardCurrentExternal": "Current clipboard from another application", @@ -2474,7 +2475,7 @@ "localHistoryNoDocuments": "No documents have Local History yet.", "localHistoryNoRevisions": "This document has no Local History revisions.", "localHistoryNoMatches": "No Local History revisions match this search.", - "localHistorySearchHint": "Search revision contents", + "localHistorySearchHint": "Search local history", "localHistorySelectedRevision": "Selected revision", "localHistoryCurrentEditor": "Current editor content", "localHistoryCurrentDisk": "Current content on disk", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 62b05755..c00ebc26 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Elija un archivo .css UTF-8 legible de hasta 256 KiB, sin importaciones, URL de recursos, HTML ni CSS ejecutable o escapado.", "exportInvalidRange": "{field}: introduzca un número entre {minimum} y {maximum}.", "clipboardHistory": "Historial de la Copia", + "clipboardHistorySearchHint": "Buscar en el historial del portapapeles", "clipboardHistoryDescription": "Abrir el historial de la copia local de la sesión.", "clipboardCurrent": "Copia actual", "clipboardCurrentExternal": "Copia actual de otra aplicación", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Ningún documento tiene Historial Local.", "localHistoryNoRevisions": "Este documento no tiene revisiones en el Historial Local.", "localHistoryNoMatches": "Ninguna revisión del Historial Local coincide con esta búsqueda.", - "localHistorySearchHint": "Buscar contenido de la revisión", + "localHistorySearchHint": "Buscar en el historial local", "localHistorySelectedRevision": "Revisión seleccionada", "localHistoryCurrentEditor": "Contenido del editor actual", "localHistoryCurrentDisk": "Contenido actual en el disco", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 548274a2..93cd8a7a 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -3610,6 +3610,7 @@ "exportInvalidCss": "Vali loetav UTF-8 .css-fail kuni 256 KiB, ilma importide, ressursside URL-ide, HTML-i ning käivitatava või paojadadega CSS-ita.", "exportInvalidRange": "{field}: sisesta arv vahemikus {minimum} kuni {maximum}.", "clipboardHistory": "Kleepimiskredi ajalugu", + "clipboardHistorySearchHint": "Otsi lõikepuhvri ajaloost", "clipboardHistoryDescription": "Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.", "clipboardCurrent": "Praegune kleepimiskredi", "clipboardCurrentExternal": "Praegune kleepimiskredi teistest rakendusest", @@ -3634,7 +3635,7 @@ "localHistoryNoDocuments": "Mida ei ole kohalist ajaloos.", "localHistoryNoRevisions": "Selle dokumendil pole kohalist ajaloos.", "localHistoryNoMatches": "Ei leidunud kohalise ajalooga versioone.", - "localHistorySearchHint": "Otsi versioonide sisu", + "localHistorySearchHint": "Otsi kohalikust ajaloost", "localHistorySelectedRevision": "Valitud versioon", "localHistoryCurrentEditor": "Praegune redaktoori sisu", "localHistoryCurrentDisk": "Praegune sisu faile", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 81518504..11de5e7f 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -3192,6 +3192,7 @@ "exportInvalidCss": "یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.", "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید.", "clipboardHistory": "تاریخچه کپی و پیست", + "clipboardHistorySearchHint": "جستجو در تاریخچه کلیپ‌بورد", "clipboardHistoryDescription": "باز کردن تاریخچه کپی و پیست مخصوص جلسه.", "clipboardCurrent": "کپی و پیست فعلی", "clipboardCurrentExternal": "کپی و پیست از برنامه دیگر", @@ -3216,7 +3217,7 @@ "localHistoryNoDocuments": "هنوز هیچ سند دارای تاریخ محلی نیست.", "localHistoryNoRevisions": "این سند دارای هیچ نسخه‌ای از تاریخ محلی نیست.", "localHistoryNoMatches": "هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.", - "localHistorySearchHint": "محتوای نسخه را جستجو کنید", + "localHistorySearchHint": "جستجو در تاریخچه محلی", "localHistorySelectedRevision": "نسخه انتخاب شده", "localHistoryCurrentEditor": "محتوای فعلی ویرایشگر", "localHistoryCurrentDisk": "محتوای فعلی روی دیسک", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 485c6576..f593a2db 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Choisissez un fichier .css UTF-8 lisible de 256 KiB maximum, sans imports, URL de ressources, HTML ni CSS exécutable ou échappé.", "exportInvalidRange": "{field} : saisissez un nombre entre {minimum} et {maximum}.", "clipboardHistory": "Historique de la presse-papiers", + "clipboardHistorySearchHint": "Rechercher dans l’historique du presse-papiers", "clipboardHistoryDescription": "Ouvrir l'historique de la presse-papiers local à la session.", "clipboardCurrent": "Presse-papiers actuel", "clipboardCurrentExternal": "Presse-papiers actuel provenant d'une autre application", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Aucun document n'a Historique Local.", "localHistoryNoRevisions": "Ce document n'a pas de révisions dans l'Historique Local.", "localHistoryNoMatches": "Aucune révision n'est trouvée dans cette recherche.", - "localHistorySearchHint": "Rechercher le contenu des révisions", + "localHistorySearchHint": "Rechercher dans l’historique local", "localHistorySelectedRevision": "Révision sélectionnée", "localHistoryCurrentEditor": "Contenu de l'éditeur actuel", "localHistoryCurrentDisk": "Contenu actuel sur le disque", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d4eaaa93..2cd1cee9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।", "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।", "clipboardHistory": "क्लिपबोर्ड इतिहास", + "clipboardHistorySearchHint": "क्लिपबोर्ड इतिहास खोजें", "clipboardHistoryDescription": "सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।", "clipboardCurrent": "वर्तमान क्लिपबोर्ड", "clipboardCurrentExternal": "किसी अन्य एप्लिकेशन से वर्तमान क्लिपबोर्ड", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "अभी तक किसी भी दस्तावेज़ में स्थानीय इतिहास नहीं है।", "localHistoryNoRevisions": "इस दस्तावेज़ में स्थानीय इतिहास में कोई संशोधन नहीं है।", "localHistoryNoMatches": "यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।", - "localHistorySearchHint": "संशोधन सामग्री खोजें", + "localHistorySearchHint": "स्थानीय इतिहास खोजें", "localHistorySelectedRevision": "चयनित संशोधन", "localHistoryCurrentEditor": "वर्तमान संपादक सामग्री", "localHistoryCurrentDisk": "डिस्क पर वर्तमान सामग्री", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2b0a52be..c2d4ea93 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "Pilih berkas .css UTF-8 yang dapat dibaca hingga 256 KiB, tanpa impor, URL sumber daya, HTML, atau CSS yang dapat dieksekusi atau memakai escape.", "exportInvalidRange": "{field}: masukkan angka antara {minimum} dan {maximum}.", "clipboardHistory": "Sejarah Tempel", + "clipboardHistorySearchHint": "Cari riwayat papan klip", "clipboardHistoryDescription": "Buka riwayat tempel lokal sesi.", "clipboardCurrent": "Tempel saat ini", "clipboardCurrentExternal": "Tempel saat ini dari aplikasi lain", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "Tidak ada dokumen yang memiliki Sejarah Lokal.", "localHistoryNoRevisions": "Dokumen ini tidak memiliki revisi Sejarah Lokal.", "localHistoryNoMatches": "Tidak ada revisi Sejarah Lokal yang cocok dengan pencarian ini.", - "localHistorySearchHint": "Cari isi revisi", + "localHistorySearchHint": "Cari riwayat lokal", "localHistorySelectedRevision": "Revisi yang dipilih", "localHistoryCurrentEditor": "Konten editor saat ini", "localHistoryCurrentDisk": "Konten saat ini di disk", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d9e7619c..ac829d20 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Scegli un file .css UTF-8 leggibile fino a 256 KiB, senza importazioni, URL di risorse, HTML o CSS eseguibile o con escape.", "exportInvalidRange": "{field}: inserisci un numero tra {minimum} e {maximum}.", "clipboardHistory": "Cronologia della Copia", + "clipboardHistorySearchHint": "Cerca nella cronologia degli appunti", "clipboardHistoryDescription": "Apri la cronologia della copia locale della sessione.", "clipboardCurrent": "Copia corrente", "clipboardCurrentExternal": "Copia corrente da un'altra applicazione", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Nessun documento ha ancora una cronologia locale.", "localHistoryNoRevisions": "Questo documento non ha revisioni nella cronologia locale.", "localHistoryNoMatches": "Nessuna revisione corrisponde a questa ricerca.", - "localHistorySearchHint": "Cerca i contenuti delle revisioni", + "localHistorySearchHint": "Cerca nella cronologia locale", "localHistorySelectedRevision": "Revisione selezionata", "localHistoryCurrentEditor": "Contenuto dell'editor corrente", "localHistoryCurrentDisk": "Contenuto corrente sul disco", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index de5241ba..b6e06e40 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。", "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。", "clipboardHistory": "コピーボード履歴", + "clipboardHistorySearchHint": "クリップボード履歴を検索", "clipboardHistoryDescription": "セッション固有のコピーボード履歴を開く。", "clipboardCurrent": "現在のコピーボード", "clipboardCurrentExternal": "別のアプリケーションからの現在のコピーボード", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "まだローカル履歴を持つドキュメントはありません。", "localHistoryNoRevisions": "このドキュメントにはローカル履歴の修正はありません。", "localHistoryNoMatches": "この検索に一致するローカル履歴の修正はありません。", - "localHistorySearchHint": "リビジョンの内容を検索", + "localHistorySearchHint": "ローカル履歴を検索", "localHistorySelectedRevision": "選択されたリビジョン", "localHistoryCurrentEditor": "現在の編集内容", "localHistoryCurrentDisk": "ディスク上の現在の内容", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ff350bab..0264e543 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.", "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요.", "clipboardHistory": "클립보드 기록", + "clipboardHistorySearchHint": "클립보드 기록 검색", "clipboardHistoryDescription": "세션별 클립보드 기록을 엽니다.", "clipboardCurrent": "현재 클립보드", "clipboardCurrentExternal": "다른 애플리케이션의 현재 클립보드", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "아직 로컬 히스토리가 있는 문서는 없습니다.", "localHistoryNoRevisions": "이 문서는 로컬 히스토리 수정 사항이 없습니다.", "localHistoryNoMatches": "이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.", - "localHistorySearchHint": "수정 내용 검색", + "localHistorySearchHint": "로컬 기록 검색", "localHistorySelectedRevision": "선택된 수정", "localHistoryCurrentEditor": "현재 편집 내용", "localHistoryCurrentDisk": "디스크에 있는 현재 내용", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 49d86fab..c7eff8ee 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Velg en lesbar UTF-8 .css-fil opptil 256 KiB uten importer, ressurs-URL-er, HTML eller kjørbar eller escaped CSS.", "exportInvalidRange": "{field}: skriv inn et tall mellom {minimum} og {maximum}.", "clipboardHistory": "Utklippshistorie", + "clipboardHistorySearchHint": "Søk i utklippstavlehistorikken", "clipboardHistoryDescription": "Åpne historien for utklippet som er lokal for sesjonen.", "clipboardCurrent": "Nåværende utklipp", "clipboardCurrentExternal": "Nåværende utklipp fra en annen applikasjon", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Ingen dokumenter har Lokal Historikk ennå.", "localHistoryNoRevisions": "Dette dokumentet har ingen revisjoner i Lokal Historikk.", "localHistoryNoMatches": "Ingen revisjoner matcher denne søken.", - "localHistorySearchHint": "Søk etter innhold i revisjon", + "localHistorySearchHint": "Søk i lokal historikk", "localHistorySelectedRevision": "Valgt revisjon", "localHistoryCurrentEditor": "Nåværende innhold i redigereren", "localHistoryCurrentDisk": "Nåværende innhold på disken", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index f25a48da..e6deed72 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "Kies een leesbaar UTF-8 .css-bestand tot 256 KiB zonder imports, bron-URL’s, HTML of uitvoerbare of ontsnapte CSS.", "exportInvalidRange": "{field}: voer een getal tussen {minimum} en {maximum} in.", "clipboardHistory": "Historie van de klembord", + "clipboardHistorySearchHint": "Klembordgeschiedenis doorzoeken", "clipboardHistoryDescription": "Open de klembordgeschiedenis per sessie.", "clipboardCurrent": "Huidig klembord", "clipboardCurrentExternal": "Huidig klembord van een ander programma", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "Er zijn nog geen documenten met Lokale Geschiedenis.", "localHistoryNoRevisions": "Dit document heeft geen revisies van de Lokale Geschiedenis.", "localHistoryNoMatches": "Geen revisies van de Lokale Geschiedenis komen overeen met deze zoekopdracht.", - "localHistorySearchHint": "Zoek naar inhoud van revisies", + "localHistorySearchHint": "Lokale geschiedenis doorzoeken", "localHistorySelectedRevision": "Geselecteerde revisie", "localHistoryCurrentEditor": "Huidige inhoud van de editor", "localHistoryCurrentDisk": "Huidige inhoud op schijf", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 7be4fbf5..957870c5 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3176,6 +3176,7 @@ "exportInvalidCss": "Wybierz czytelny plik .css UTF-8 do 256 KiB, bez importów, adresów zasobów, HTML oraz wykonywalnego CSS i sekwencji ucieczki.", "exportInvalidRange": "{field}: wpisz liczbę między {minimum} a {maximum}.", "clipboardHistory": "Historia w Koszyku", + "clipboardHistorySearchHint": "Przeszukaj historię schowka", "clipboardHistoryDescription": "Otwórz historię w koszyku lokalną dla sesji.", "clipboardCurrent": "Aktualny koszyk", "clipboardCurrentExternal": "Aktualny koszyk z innego programu", @@ -3200,7 +3201,7 @@ "localHistoryNoDocuments": "Żaden dokument nie ma Lokalnej Historii.", "localHistoryNoRevisions": "Ten dokument nie ma wersji w Lokalnej Historii.", "localHistoryNoMatches": "Żadne wersje nie pasują do tego wyszukiwania.", - "localHistorySearchHint": "Wyszukaj zawartość wersji", + "localHistorySearchHint": "Przeszukaj historię lokalną", "localHistorySelectedRevision": "Wybrana wersja", "localHistoryCurrentEditor": "Aktualny zawartość edytora", "localHistoryCurrentDisk": "Aktualna zawartość na dysku", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 366ff6ef..2e3cf6dd 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Escolha um ficheiro .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", "exportInvalidRange": "{field}: introduza um número entre {minimum} e {maximum}.", "clipboardHistory": "Histórico da Área de Transferência", + "clipboardHistorySearchHint": "Pesquisar no histórico da área de transferência", "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", "clipboardCurrent": "Área de transferência atual", "clipboardCurrentExternal": "Área de transferência de outro aplicativo", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Nenhum documento tem Histórico Local.", "localHistoryNoRevisions": "Este documento não tem revisões no Histórico Local.", "localHistoryNoMatches": "Nenhuma revisão corresponde à pesquisa.", - "localHistorySearchHint": "Procurar conteúdos de revisão", + "localHistorySearchHint": "Pesquisar no histórico local", "localHistorySelectedRevision": "Revisão selecionada", "localHistoryCurrentEditor": "Conteúdo atual do editor", "localHistoryCurrentDisk": "Conteúdo atual no disco", diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 83415a10..a967cf9c 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -3150,6 +3150,7 @@ "exportInvalidCss": "Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", "exportInvalidRange": "{field}: insira um número entre {minimum} e {maximum}.", "clipboardHistory": "Histórico da Área de Transferência", + "clipboardHistorySearchHint": "Pesquisar no histórico da área de transferência", "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", "clipboardCurrent": "Área de transferência atual", "clipboardCurrentExternal": "Área de transferência de outro aplicativo", @@ -3174,7 +3175,7 @@ "localHistoryNoDocuments": "Nenhum documento possui Histórico Local.", "localHistoryNoRevisions": "Este documento não possui revisões no Histórico Local.", "localHistoryNoMatches": "Nenhuma revisão no Histórico Local corresponde a esta pesquisa.", - "localHistorySearchHint": "Pesquisar conteúdo de revisões", + "localHistorySearchHint": "Pesquisar no histórico local", "localHistorySelectedRevision": "Revisão selecionada", "localHistoryCurrentEditor": "Conteúdo atual do editor", "localHistoryCurrentDisk": "Conteúdo atual no disco", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7e4e58a2..d2b54836 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3176,6 +3176,7 @@ "exportInvalidCss": "Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.", "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}.", "clipboardHistory": "История буфера обмена", + "clipboardHistorySearchHint": "Поиск в истории буфера обмена", "clipboardHistoryDescription": "Открыть историю буфера обмена, специфичную для сеанса.", "clipboardCurrent": "Текущий буфер обмена", "clipboardCurrentExternal": "Текущий буфер обмена из другого приложения", @@ -3200,7 +3201,7 @@ "localHistoryNoDocuments": "Ни один документ не имеет локальной истории.", "localHistoryNoRevisions": "Этот документ не имеет версий в локальной истории.", "localHistoryNoMatches": "Ни одна версия не соответствует этому поиску.", - "localHistorySearchHint": "Поиск содержимого версии", + "localHistorySearchHint": "Поиск в локальной истории", "localHistorySelectedRevision": "Выбранная версия", "localHistoryCurrentEditor": "Текущее содержимое редактора", "localHistoryCurrentDisk": "Текущее содержимое на диске", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index a31d22d5..4c327eee 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "İçe aktarım, kaynak URL’si, HTML, çalıştırılabilir veya kaçışlı CSS içermeyen, okunabilir UTF-8 .css dosyası seçin (en fazla 256 KiB).", "exportInvalidRange": "{field}: {minimum} ile {maximum} arasında bir sayı girin.", "clipboardHistory": "Kopyalama Geçmişi", + "clipboardHistorySearchHint": "Pano geçmişinde ara", "clipboardHistoryDescription": "Oturuma özgü kopyalama geçmişini aç.", "clipboardCurrent": "Mevcut kopyalama", "clipboardCurrentExternal": "Başka bir uygulamadan mevcut kopyalama", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "Henüz herhangi bir belge Yerel Geçmişe sahip değil.", "localHistoryNoRevisions": "Bu belge Yerel Geçmiş revizyonlarına sahip değil.", "localHistoryNoMatches": "Bu arama ile eşleşen Yerel Geçmiş revizyonu yok.", - "localHistorySearchHint": "Revizyon içeriğini arayın", + "localHistorySearchHint": "Yerel geçmişte ara", "localHistorySelectedRevision": "Seçilen revizyon", "localHistoryCurrentEditor": "Mevcut düzenleme içeriği", "localHistoryCurrentDisk": "Disk üzerinde mevcut içerik", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index eae23be8..d7751b94 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -3176,6 +3176,7 @@ "exportInvalidCss": "Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.", "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}.", "clipboardHistory": "Історія Вирізання", + "clipboardHistorySearchHint": "Пошук в історії буфера обміну", "clipboardHistoryDescription": "Відкрити історію вирізання, прив'язану до сесії.", "clipboardCurrent": "Поточне вирізання", "clipboardCurrentExternal": "Поточне вирізання з іншого додатку", @@ -3200,7 +3201,7 @@ "localHistoryNoDocuments": "Жоден документ поки не має локальної історії.", "localHistoryNoRevisions": "Цей документ не має версій локальної історії.", "localHistoryNoMatches": "Жодна версія локальної історії не відповідає цьому пошуку.", - "localHistorySearchHint": "Пошук вмісту версій", + "localHistorySearchHint": "Пошук у локальній історії", "localHistorySelectedRevision": "Обрана версія", "localHistoryCurrentEditor": "Поточний вміст редактора", "localHistoryCurrentDisk": "Поточний вміст на диску", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6d2c78a4..f6e92260 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "Chọn tệp .css UTF-8 đọc được tối đa 256 KiB, không có nhập tài nguyên, URL tài nguyên, HTML, CSS thực thi hoặc ký tự thoát.", "exportInvalidRange": "{field}: nhập số từ {minimum} đến {maximum}.", "clipboardHistory": "Lịch sử sao chép", + "clipboardHistorySearchHint": "Tìm kiếm lịch sử bảng nhớ tạm", "clipboardHistoryDescription": "Mở lịch sử sao chép cục bộ phiên.", "clipboardCurrent": "Lịch sử sao chép hiện tại", "clipboardCurrentExternal": "Lịch sử sao chép từ một ứng dụng khác", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "Chưa có tài liệu nào có Lịch sử cục bộ.", "localHistoryNoRevisions": "Tài liệu này không có các phiên bản Lịch sử cục bộ.", "localHistoryNoMatches": "Không có phiên bản nào trong Lịch sử cục bộ khớp với tìm kiếm này.", - "localHistorySearchHint": "Tìm kiếm nội dung phiên bản", + "localHistorySearchHint": "Tìm kiếm lịch sử cục bộ", "localHistorySelectedRevision": "Phiên bản đã chọn", "localHistoryCurrentEditor": "Nội dung trình soạn thảo hiện tại", "localHistoryCurrentDisk": "Nội dung hiện tại trên ổ đĩa", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index db630567..b44aab90 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", "clipboardHistory": "剪贴板历史", + "clipboardHistorySearchHint": "搜索剪贴板历史记录", "clipboardHistoryDescription": "打开会话本地剪贴板历史。", "clipboardCurrent": "当前剪贴板", "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "尚无文档具有本地历史。", "localHistoryNoRevisions": "此文档没有本地历史修订。", "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", - "localHistorySearchHint": "搜索修订内容", + "localHistorySearchHint": "搜索本地历史记录", "localHistorySelectedRevision": "已选择的修订", "localHistoryCurrentEditor": "当前编辑器内容", "localHistoryCurrentDisk": "磁盘上的当前内容", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 1921b63f..3e701ce1 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -4709,6 +4709,7 @@ "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", "clipboardHistory": "剪贴板历史", + "clipboardHistorySearchHint": "搜索剪贴板历史记录", "clipboardHistoryDescription": "打开会话本地剪贴板历史。", "clipboardCurrent": "当前剪贴板", "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", @@ -4733,7 +4734,7 @@ "localHistoryNoDocuments": "尚无文档具有本地历史。", "localHistoryNoRevisions": "此文档没有本地历史修订。", "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", - "localHistorySearchHint": "搜索修订内容", + "localHistorySearchHint": "搜索本地历史记录", "localHistorySelectedRevision": "已选择的修订", "localHistoryCurrentEditor": "当前编辑器内容", "localHistoryCurrentDisk": "磁盘上的当前内容", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index a674dac4..110be932 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -6569,6 +6569,12 @@ abstract class AppLocalizations { /// **'Clipboard History'** String get clipboardHistory; + /// No description provided for @clipboardHistorySearchHint. + /// + /// In en, this message translates to: + /// **'Search clipboard history'** + String get clipboardHistorySearchHint; + /// No description provided for @clipboardHistoryDescription. /// /// In en, this message translates to: @@ -6716,7 +6722,7 @@ abstract class AppLocalizations { /// No description provided for @localHistorySearchHint. /// /// In en, this message translates to: - /// **'Search revision contents'** + /// **'Search local history'** String get localHistorySearchHint; /// No description provided for @localHistorySelectedRevision. diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 555df66b..bfe28837 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3933,6 +3933,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get clipboardHistory => 'سجل النسخ واللصق'; + @override + String get clipboardHistorySearchHint => 'البحث في سجل الحافظة'; + @override String get clipboardHistoryDescription => 'افتح سجل النسخ واللصق الخاص بالجلسة.'; @@ -4018,7 +4021,7 @@ class AppLocalizationsAr extends AppLocalizations { 'لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.'; @override - String get localHistorySearchHint => 'البحث في محتوى الإصدار'; + String get localHistorySearchHint => 'البحث في السجل المحلي'; @override String get localHistorySelectedRevision => 'الإصدار المحدد'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 375fcf64..09b2173f 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3928,6 +3928,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get clipboardHistory => 'Zwischenablage-Historie'; + @override + String get clipboardHistorySearchHint => 'Zwischenablageverlauf durchsuchen'; + @override String get clipboardHistoryDescription => 'Öffne die Sitzungs-spezifische Zwischenablage-Historie.'; @@ -4017,7 +4020,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Keine Lokalen Historie Revisionen passen zu dieser Suche.'; @override - String get localHistorySearchHint => 'Revisionen durchsuchen'; + String get localHistorySearchHint => 'Lokalen Verlauf durchsuchen'; @override String get localHistorySelectedRevision => 'Ausgewählte Revision'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 258b8c46..97ffd16e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3922,6 +3922,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clipboardHistory => 'Clipboard History'; + @override + String get clipboardHistorySearchHint => 'Search clipboard history'; + @override String get clipboardHistoryDescription => 'Open the session-local clipboard history.'; @@ -4007,7 +4010,7 @@ class AppLocalizationsEn extends AppLocalizations { 'No Local History revisions match this search.'; @override - String get localHistorySearchHint => 'Search revision contents'; + String get localHistorySearchHint => 'Search local history'; @override String get localHistorySelectedRevision => 'Selected revision'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 54a27fc0..51ec4f24 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3931,6 +3931,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get clipboardHistory => 'Historial de la Copia'; + @override + String get clipboardHistorySearchHint => + 'Buscar en el historial del portapapeles'; + @override String get clipboardHistoryDescription => 'Abrir el historial de la copia local de la sesión.'; @@ -4019,7 +4023,7 @@ class AppLocalizationsEs extends AppLocalizations { 'Ninguna revisión del Historial Local coincide con esta búsqueda.'; @override - String get localHistorySearchHint => 'Buscar contenido de la revisión'; + String get localHistorySearchHint => 'Buscar en el historial local'; @override String get localHistorySelectedRevision => 'Revisión seleccionada'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index f665f885..1b7e7a67 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3893,6 +3893,9 @@ class AppLocalizationsEt extends AppLocalizations { @override String get clipboardHistory => 'Kleepimiskredi ajalugu'; + @override + String get clipboardHistorySearchHint => 'Otsi lõikepuhvri ajaloost'; + @override String get clipboardHistoryDescription => 'Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.'; @@ -3981,7 +3984,7 @@ class AppLocalizationsEt extends AppLocalizations { 'Ei leidunud kohalise ajalooga versioone.'; @override - String get localHistorySearchHint => 'Otsi versioonide sisu'; + String get localHistorySearchHint => 'Otsi kohalikust ajaloost'; @override String get localHistorySelectedRevision => 'Valitud versioon'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index e3b3b824..06f3e339 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3939,6 +3939,9 @@ class AppLocalizationsFa extends AppLocalizations { @override String get clipboardHistory => 'تاریخچه کپی و پیست'; + @override + String get clipboardHistorySearchHint => 'جستجو در تاریخچه کلیپ‌بورد'; + @override String get clipboardHistoryDescription => 'باز کردن تاریخچه کپی و پیست مخصوص جلسه.'; @@ -4025,7 +4028,7 @@ class AppLocalizationsFa extends AppLocalizations { 'هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.'; @override - String get localHistorySearchHint => 'محتوای نسخه را جستجو کنید'; + String get localHistorySearchHint => 'جستجو در تاریخچه محلی'; @override String get localHistorySelectedRevision => 'نسخه انتخاب شده'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 98d65a19..12a4546f 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3928,6 +3928,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get clipboardHistory => 'Historique de la presse-papiers'; + @override + String get clipboardHistorySearchHint => + 'Rechercher dans l’historique du presse-papiers'; + @override String get clipboardHistoryDescription => 'Ouvrir l\'historique de la presse-papiers local à la session.'; @@ -4016,7 +4020,7 @@ class AppLocalizationsFr extends AppLocalizations { 'Aucune révision n\'est trouvée dans cette recherche.'; @override - String get localHistorySearchHint => 'Rechercher le contenu des révisions'; + String get localHistorySearchHint => 'Rechercher dans l’historique local'; @override String get localHistorySelectedRevision => 'Révision sélectionnée'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 1640765d..7ebb36e9 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3887,6 +3887,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get clipboardHistory => 'क्लिपबोर्ड इतिहास'; + @override + String get clipboardHistorySearchHint => 'क्लिपबोर्ड इतिहास खोजें'; + @override String get clipboardHistoryDescription => 'सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।'; @@ -3975,7 +3978,7 @@ class AppLocalizationsHi extends AppLocalizations { 'यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।'; @override - String get localHistorySearchHint => 'संशोधन सामग्री खोजें'; + String get localHistorySearchHint => 'स्थानीय इतिहास खोजें'; @override String get localHistorySelectedRevision => 'चयनित संशोधन'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 5b2ab355..7e109903 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -3902,6 +3902,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get clipboardHistory => 'Sejarah Tempel'; + @override + String get clipboardHistorySearchHint => 'Cari riwayat papan klip'; + @override String get clipboardHistoryDescription => 'Buka riwayat tempel lokal sesi.'; @@ -3988,7 +3991,7 @@ class AppLocalizationsId extends AppLocalizations { 'Tidak ada revisi Sejarah Lokal yang cocok dengan pencarian ini.'; @override - String get localHistorySearchHint => 'Cari isi revisi'; + String get localHistorySearchHint => 'Cari riwayat lokal'; @override String get localHistorySelectedRevision => 'Revisi yang dipilih'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 615c377d..5e2ab72e 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3924,6 +3924,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get clipboardHistory => 'Cronologia della Copia'; + @override + String get clipboardHistorySearchHint => + 'Cerca nella cronologia degli appunti'; + @override String get clipboardHistoryDescription => 'Apri la cronologia della copia locale della sessione.'; @@ -4013,7 +4017,7 @@ class AppLocalizationsIt extends AppLocalizations { 'Nessuna revisione corrisponde a questa ricerca.'; @override - String get localHistorySearchHint => 'Cerca i contenuti delle revisioni'; + String get localHistorySearchHint => 'Cerca nella cronologia locale'; @override String get localHistorySelectedRevision => 'Revisione selezionata'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 0a8cb35d..2b80c4bf 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -3792,6 +3792,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get clipboardHistory => 'コピーボード履歴'; + @override + String get clipboardHistorySearchHint => 'クリップボード履歴を検索'; + @override String get clipboardHistoryDescription => 'セッション固有のコピーボード履歴を開く。'; @@ -3869,7 +3872,7 @@ class AppLocalizationsJa extends AppLocalizations { String get localHistoryNoMatches => 'この検索に一致するローカル履歴の修正はありません。'; @override - String get localHistorySearchHint => 'リビジョンの内容を検索'; + String get localHistorySearchHint => 'ローカル履歴を検索'; @override String get localHistorySelectedRevision => '選択されたリビジョン'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 84e7ea94..64355473 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -3788,6 +3788,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get clipboardHistory => '클립보드 기록'; + @override + String get clipboardHistorySearchHint => '클립보드 기록 검색'; + @override String get clipboardHistoryDescription => '세션별 클립보드 기록을 엽니다.'; @@ -3865,7 +3868,7 @@ class AppLocalizationsKo extends AppLocalizations { String get localHistoryNoMatches => '이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.'; @override - String get localHistorySearchHint => '수정 내용 검색'; + String get localHistorySearchHint => '로컬 기록 검색'; @override String get localHistorySelectedRevision => '선택된 수정'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 3f8e29f0..8a6a18da 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -3894,6 +3894,9 @@ class AppLocalizationsNb extends AppLocalizations { @override String get clipboardHistory => 'Utklippshistorie'; + @override + String get clipboardHistorySearchHint => 'Søk i utklippstavlehistorikken'; + @override String get clipboardHistoryDescription => 'Åpne historien for utklippet som er lokal for sesjonen.'; @@ -3981,7 +3984,7 @@ class AppLocalizationsNb extends AppLocalizations { String get localHistoryNoMatches => 'Ingen revisjoner matcher denne søken.'; @override - String get localHistorySearchHint => 'Søk etter innhold i revisjon'; + String get localHistorySearchHint => 'Søk i lokal historikk'; @override String get localHistorySelectedRevision => 'Valgt revisjon'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 6b2366fc..a3d559c2 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -3937,6 +3937,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get clipboardHistory => 'Historie van de klembord'; + @override + String get clipboardHistorySearchHint => 'Klembordgeschiedenis doorzoeken'; + @override String get clipboardHistoryDescription => 'Open de klembordgeschiedenis per sessie.'; @@ -4026,7 +4029,7 @@ class AppLocalizationsNl extends AppLocalizations { 'Geen revisies van de Lokale Geschiedenis komen overeen met deze zoekopdracht.'; @override - String get localHistorySearchHint => 'Zoek naar inhoud van revisies'; + String get localHistorySearchHint => 'Lokale geschiedenis doorzoeken'; @override String get localHistorySelectedRevision => 'Geselecteerde revisie'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 65a64b46..34cb290e 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3946,6 +3946,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get clipboardHistory => 'Historia w Koszyku'; + @override + String get clipboardHistorySearchHint => 'Przeszukaj historię schowka'; + @override String get clipboardHistoryDescription => 'Otwórz historię w koszyku lokalną dla sesji.'; @@ -4034,7 +4037,7 @@ class AppLocalizationsPl extends AppLocalizations { 'Żadne wersje nie pasują do tego wyszukiwania.'; @override - String get localHistorySearchHint => 'Wyszukaj zawartość wersji'; + String get localHistorySearchHint => 'Przeszukaj historię lokalną'; @override String get localHistorySelectedRevision => 'Wybrana wersja'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d1c013aa..94ec2bed 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3951,6 +3951,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get clipboardHistory => 'Histórico da Área de Transferência'; + @override + String get clipboardHistorySearchHint => + 'Pesquisar no histórico da área de transferência'; + @override String get clipboardHistoryDescription => 'Abrir o histórico da área de transferência local da sessão.'; @@ -4039,7 +4043,7 @@ class AppLocalizationsPt extends AppLocalizations { String get localHistoryNoMatches => 'Nenhuma revisão corresponde à pesquisa.'; @override - String get localHistorySearchHint => 'Procurar conteúdos de revisão'; + String get localHistorySearchHint => 'Pesquisar no histórico local'; @override String get localHistorySelectedRevision => 'Revisão selecionada'; @@ -8178,6 +8182,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get clipboardHistory => 'Histórico da Área de Transferência'; + @override + String get clipboardHistorySearchHint => + 'Pesquisar no histórico da área de transferência'; + @override String get clipboardHistoryDescription => 'Abrir o histórico da área de transferência local da sessão.'; @@ -8268,7 +8276,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 'Nenhuma revisão no Histórico Local corresponde a esta pesquisa.'; @override - String get localHistorySearchHint => 'Pesquisar conteúdo de revisões'; + String get localHistorySearchHint => 'Pesquisar no histórico local'; @override String get localHistorySelectedRevision => 'Revisão selecionada'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 4658ac08..f11124f8 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3947,6 +3947,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get clipboardHistory => 'История буфера обмена'; + @override + String get clipboardHistorySearchHint => 'Поиск в истории буфера обмена'; + @override String get clipboardHistoryDescription => 'Открыть историю буфера обмена, специфичную для сеанса.'; @@ -4036,7 +4039,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Ни одна версия не соответствует этому поиску.'; @override - String get localHistorySearchHint => 'Поиск содержимого версии'; + String get localHistorySearchHint => 'Поиск в локальной истории'; @override String get localHistorySelectedRevision => 'Выбранная версия'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 7db94730..1393dd50 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -3895,6 +3895,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get clipboardHistory => 'Kopyalama Geçmişi'; + @override + String get clipboardHistorySearchHint => 'Pano geçmişinde ara'; + @override String get clipboardHistoryDescription => 'Oturuma özgü kopyalama geçmişini aç.'; @@ -3984,7 +3987,7 @@ class AppLocalizationsTr extends AppLocalizations { 'Bu arama ile eşleşen Yerel Geçmiş revizyonu yok.'; @override - String get localHistorySearchHint => 'Revizyon içeriğini arayın'; + String get localHistorySearchHint => 'Yerel geçmişte ara'; @override String get localHistorySelectedRevision => 'Seçilen revizyon'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index ccdcf32d..c11412fc 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -3955,6 +3955,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get clipboardHistory => 'Історія Вирізання'; + @override + String get clipboardHistorySearchHint => 'Пошук в історії буфера обміну'; + @override String get clipboardHistoryDescription => 'Відкрити історію вирізання, прив\'язану до сесії.'; @@ -4043,7 +4046,7 @@ class AppLocalizationsUk extends AppLocalizations { 'Жодна версія локальної історії не відповідає цьому пошуку.'; @override - String get localHistorySearchHint => 'Пошук вмісту версій'; + String get localHistorySearchHint => 'Пошук у локальній історії'; @override String get localHistorySelectedRevision => 'Обрана версія'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 8e3fc4bf..9c418cd4 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -3890,6 +3890,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get clipboardHistory => 'Lịch sử sao chép'; + @override + String get clipboardHistorySearchHint => 'Tìm kiếm lịch sử bảng nhớ tạm'; + @override String get clipboardHistoryDescription => 'Mở lịch sử sao chép cục bộ phiên.'; @@ -3976,7 +3979,7 @@ class AppLocalizationsVi extends AppLocalizations { 'Không có phiên bản nào trong Lịch sử cục bộ khớp với tìm kiếm này.'; @override - String get localHistorySearchHint => 'Tìm kiếm nội dung phiên bản'; + String get localHistorySearchHint => 'Tìm kiếm lịch sử cục bộ'; @override String get localHistorySelectedRevision => 'Phiên bản đã chọn'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index d43ba8c3..cd35c44c 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -3727,6 +3727,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clipboardHistory => '剪贴板历史'; + @override + String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; + @override String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; @@ -3804,7 +3807,7 @@ class AppLocalizationsZh extends AppLocalizations { String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; @override - String get localHistorySearchHint => '搜索修订内容'; + String get localHistorySearchHint => '搜索本地历史记录'; @override String get localHistorySelectedRevision => '已选择的修订'; @@ -7709,6 +7712,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get clipboardHistory => '剪贴板历史'; + @override + String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; + @override String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; @@ -7786,7 +7792,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; @override - String get localHistorySearchHint => '搜索修订内容'; + String get localHistorySearchHint => '搜索本地历史记录'; @override String get localHistorySelectedRevision => '已选择的修订'; diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart index 2f0e1752..afba6708 100644 --- a/lib/src/clipboard/clipboard_history_panel.dart +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../l10n/generated/app_localizations.dart'; import '../app/busymark_design.dart'; import '../app/busymark_glyphs.dart'; import '../app/busymark_search_field.dart'; @@ -92,7 +91,7 @@ class _ClipboardHistoryPanelState extends ConsumerState { Expanded( child: BusyMarkSearchField( controller: _searchController, - hintText: context.l10n.search, + hintText: context.l10n.clipboardHistorySearchHint, onChanged: (_) => setState(() {}), onEscape: widget.onEscape, ), @@ -324,22 +323,64 @@ class _ClipboardEntryTile extends StatelessWidget { : kindLabel; final preview = _clipboardEntryPreview(payload, kindLabel); final metadata = '$status · ${_timestamp(context, payload.acquiredAt)}'; + final origin = payload.origin; + final originPath = origin?.documentPath?.trim(); final colors = BusyMarkSurfaceColors.of(context); return BusyMarkSidebarRecordRow<_ClipboardEntryAction>( icon: payload.kind == BusyMarkClipboardContentKind.image ? BusyMarkGlyphs.image : BusyMarkGlyphs.copy, - content: Text( - preview, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: colors.foreground), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.foreground), + ), + const SizedBox(height: BusyMarkSpacing.xxs), + Text( + metadata, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.mutedForeground), + ), + if (origin != null) ...[ + const SizedBox(height: BusyMarkSpacing.xxs), + Text( + l10n.clipboardOrigin(origin.documentName), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.mutedForeground), + ), + ], + if (originPath != null && originPath.isNotEmpty) + Text( + busyMarkLtrIsolateFor(context, originPath), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textDirection: TextDirection.ltr, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.mutedForeground), + ), + ], ), selected: selected, - semanticsLabel: '$preview\n$metadata', - tooltip: _clipboardEntryTooltip(payload, l10n, metadata), + semanticsLabel: [ + preview, + metadata, + if (origin != null) l10n.clipboardOrigin(origin.documentName), + if (originPath != null && originPath.isNotEmpty) originPath, + ].join('\n'), onTap: onSelect, onDoubleTap: canPaste ? onPaste : null, menuTooltip: l10n.actions, @@ -393,21 +434,6 @@ String _clipboardEntryPreview( return preview.isEmpty ? fallback : preview; } -String _clipboardEntryTooltip( - BusyMarkClipboardPayload payload, - AppLocalizations l10n, - String metadata, -) { - final origin = payload.origin; - final originPath = origin?.documentPath; - final parts = [ - metadata, - if (origin != null) l10n.clipboardOrigin(origin.documentName), - if (originPath != null && originPath.trim().isNotEmpty) originPath.trim(), - ]; - return parts.join('\n'); -} - class _PanelNotice extends StatelessWidget { const _PanelNotice({required this.icon, required this.text}); diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index fb8e01d8..0013c9da 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -134,14 +134,13 @@ class _LocalHistoryPanelState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _LocalHistoryHeader( - activeDocumentName: activeBuffer?.displayName, - activeDocumentPath: activeBuffer?.filePath, - selectedDocument: selected, - findingDocuments: state.findingDocuments, - inspectingRetainedDocument: state.inspectingRetainedDocument, - onBack: () => unawaited(_selectActive(preserveLookup: false)), - ), + if (state.findingDocuments || state.inspectingRetainedDocument) + _LocalHistoryHeader( + selectedDocument: selected, + findingDocuments: state.findingDocuments, + inspectingRetainedDocument: state.inspectingRetainedDocument, + onBack: () => unawaited(_selectActive(preserveLookup: false)), + ), Padding( padding: const EdgeInsets.symmetric( horizontal: BusyMarkSpacing.md, @@ -395,16 +394,12 @@ enum _HistoryAction { refresh, find, clearDocument, clearAll } class _LocalHistoryHeader extends StatelessWidget { const _LocalHistoryHeader({ - required this.activeDocumentName, - required this.activeDocumentPath, required this.selectedDocument, required this.findingDocuments, required this.inspectingRetainedDocument, required this.onBack, }); - final String? activeDocumentName; - final String? activeDocumentPath; final LocalHistoryDocument? selectedDocument; final bool findingDocuments; final bool inspectingRetainedDocument; @@ -412,17 +407,15 @@ class _LocalHistoryHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final temporary = findingDocuments || inspectingRetainedDocument; final selectedPath = selectedDocument?.currentPath ?? selectedDocument?.historicalPaths.lastOrNull; final title = findingDocuments ? context.l10n.findLocalHistoryEllipsis - : inspectingRetainedDocument - ? selectedDocument?.displayName ?? context.l10n.localHistoryNoDocuments - : activeDocumentName ?? context.l10n.localHistoryNoDocuments; - final path = inspectingRetainedDocument ? selectedPath : activeDocumentPath; + : selectedDocument?.displayName ?? context.l10n.localHistoryNoDocuments; + final path = inspectingRetainedDocument ? selectedPath : null; return Padding( + key: const ValueKey('local-history-context-header'), padding: const EdgeInsets.fromLTRB( BusyMarkSpacing.md, BusyMarkSpacing.sm, @@ -431,18 +424,13 @@ class _LocalHistoryHeader extends StatelessWidget { ), child: Row( children: [ - if (temporary) ...[ - BusyMarkHeaderIconButton( - tooltip: context.l10n.back, - icon: BusyMarkGlyphs.backFor(Directionality.of(context)), - transparent: true, - onPressed: onBack, - ), - const SizedBox(width: BusyMarkSpacing.xs), - ] else ...[ - const Icon(BusyMarkGlyphs.documentHistory, size: 18), - const SizedBox(width: BusyMarkSpacing.sm), - ], + BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + transparent: true, + onPressed: onBack, + ), + const SizedBox(width: BusyMarkSpacing.xs), Expanded( child: Tooltip( message: path == null diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index 7d01c8fb..3f0ed33c 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -18,41 +18,55 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('records preserve multiline source without visible metadata', ( - tester, - ) async { - const source = '## Introduction\n\nUse **visible tags**.\n- First item'; - final container = _container(); - container - .read(clipboardHistoryControllerProvider.notifier) - .retain( - const BusyMarkClipboardCapture( - kind: BusyMarkClipboardContentKind.richText, - text: 'Introduction\n\nUse visible tags.\nFirst item', - sourceText: source, - ), - ); - container - .read(clipboardInsertionRegistryProvider) - .register(_PanelInsertionTarget()); - await _pumpPanel(tester, container); + testWidgets( + 'records balance a two-line source preview with visible metadata', + (tester) async { + const source = '## Introduction\n\nUse **visible tags**.\n- First item'; + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.richText, + text: 'Introduction\n\nUse visible tags.\nFirst item', + sourceText: source, + origin: BusyMarkClipboardOrigin( + documentId: 'introduction', + documentName: 'Introduction.md', + documentPath: '/workspace/Introduction.md', + ), + ), + ); + container + .read(clipboardInsertionRegistryProvider) + .register(_PanelInsertionTarget()); + await _pumpPanel(tester, container); - final row = tester.widget( - find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), - ); - expect(row.title, isNull); - expect(row.subtitle, isNull); - expect(row.content, isA()); - final preview = tester.widget(find.text(source)); - expect(preview.maxLines, 3); - expect( - find.text(AppLocalizationsEn().clipboardDestination('Target.md')), - findsNothing, - ); - expect(find.textContaining('Rich text ·'), findsNothing); - }); + expect( + tester.widget(find.byType(TextField)).decoration?.hintText, + 'Search clipboard history', + ); + final row = tester.widget( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + ); + expect(row.title, isNull); + expect(row.subtitle, isNull); + expect(row.content, isA()); + expect(row.tooltip, isNull); + final preview = tester.widget(find.text(source)); + expect(preview.maxLines, 2); + expect(find.textContaining('Rich text ·'), findsOneWidget); + expect(find.text('Copied from Introduction.md'), findsOneWidget); + expect(find.text('/workspace/Introduction.md'), findsOneWidget); + expect(row.semanticsLabel, contains('/workspace/Introduction.md')); + expect( + find.text(AppLocalizationsEn().clipboardDestination('Target.md')), + findsNothing, + ); + }, + ); - testWidgets('large records do not put clipboard content in the tooltip', ( + testWidgets('large records use a short preview and do not create a tooltip', ( tester, ) async { final largeSource = List.filled( @@ -74,9 +88,9 @@ void main() { final row = tester.widget( find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), ); - expect(row.tooltip, isNot(contains(largeSource))); - expect(row.tooltip, contains(AppLocalizationsEn().clipboardEntryText)); - expect(row.tooltip!.length, lessThan(100)); + expect(row.tooltip, isNull); + expect(tester.widget(find.text(largeSource)).maxLines, 2); + expect(find.textContaining('Text ·'), findsOneWidget); }); testWidgets('actions menu is right of search and owns refresh and clear', ( diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index d25e8f49..1cbeea71 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -751,6 +751,10 @@ void main() { tester.widget(find.byType(TextField)).controller!.text, 'Unique retained', ); + expect( + find.byKey(const ValueKey('local-history-context-header')), + findsOneWidget, + ); expect(find.text('deleted.md'), findsOneWidget); }, ); @@ -880,7 +884,11 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text('B.md'), findsOneWidget); + expect( + find.byKey(const ValueKey('local-history-context-header')), + findsNothing, + ); + expect(find.text('B.md'), findsNothing); expect(find.byType(DropdownButton), findsNothing); final searchField = find.byType(TextField); @@ -892,6 +900,10 @@ void main() { greaterThan(tester.getTopRight(searchField).dx), ); expect(searchField, findsOneWidget); + expect( + tester.widget(searchField).decoration?.hintText, + 'Search local history', + ); await tester.enterText(searchField, 'Unique B'); await tester.runAsync( () => _waitForHistory( @@ -917,7 +929,11 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text('A.md'), findsOneWidget); + expect( + find.byKey(const ValueKey('local-history-context-header')), + findsNothing, + ); + expect(find.text('A.md'), findsNothing); expect( container.read(localHistoryControllerProvider).searchQuery, isEmpty, From 5ac76727ae2f263f869140680c0c024b131ff5db Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 19:23:13 -0700 Subject: [PATCH 20/27] Enhance Local History flushing and buffer closure logic - Improved consistency in buffer state reconciliation during Local History flush operations. - Added `handleBufferClosed` to manage unresolved history work upon tab closure. - Refined buffer checkpoint and promotion handling with generation-based state validation. - Optimized flush logic with improved operations for pending and retryable tasks. --- docs/history-architecture.md | 21 +- docs/history.md | 8 +- .../local_history_controller.dart | 171 +++++++++++-- lib/src/workspace/workspace_controller.dart | 148 ++++++++++-- test/src/local_history_controller_test.dart | 142 +++++++++++ test/src/source_audit_test.dart | 8 +- test/src/workspace_controller_test.dart | 225 ++++++++++++++++++ 7 files changed, 664 insertions(+), 59 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index e822dd30..263b35cd 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -62,9 +62,13 @@ bounded latest snapshot per buffer, keeps the capture warning visible, and schedules a controlled retry using the configured checkpoint interval. Newer accepted source wins over acknowledgements or failures from older writes. Unsupported or over-limit content remains visibly failed without a retry loop. -Flush and identity-promotion results propagate content-capture failure to -lifecycle safety callers; a successful document save remains independent of a -failed history write. +Transient first-save identity-promotion failures use the same retry cadence +before the pending checkpoint is captured. Flush and identity-promotion +results propagate content-capture failure to lifecycle safety callers; a +successful document save remains independent of a failed history write. A +closed editor does not remove unresolved pending work from controller +ownership: its retry continues while the process runs, and shutdown settlement +accounts for pending buffer IDs even when their tabs are no longer open. History identity is stable independently of widget and transient buffer IDs. Known file and directory moves remap current and descendant paths while @@ -117,7 +121,9 @@ revision-content query is recomputed against the new scoped snapshot, with query, scope, and snapshot generations preventing delayed reads from replacing newer matches. Search completion clears its loading state on success, scope change, cancellation, and read failure. Explicit clear invalidates affected -queued work and closes a comparison it owns; retention loss instead reports the +queued work at the point it was accepted, before store access, and closes a +comparison it owns. A queued automatic checkpoint cannot become current merely +because it begins executing after Clear. Retention loss instead reports the existing missing-revision state. `SourceComparisonInput` identifies immutable sources by ID, version, label, @@ -156,7 +162,12 @@ an already-active unchanged target; after an asynchronous reparse, it revalidates workspace and target identity and republishes the current buffer membership, source, editor selection, and undo/redo state rather than an activation-time list. A stale derived result is discarded in favor of the -normal fresh-parse scheduler. Refresh reschedules eligible autosaves on both +normal fresh-parse scheduler. The first-open path likewise appends a newly +loaded document to the live list instead of an earlier captured list. Only +documents that were genuine additions at activation start may be added during +reconciliation, so tabs closed while parsing stay closed. A close that removes +a document is aborted if that document's source or editor state changes during +the final activation await. Refresh reschedules eligible autosaves on both success and failure because it cancels its incoming timers at the boundary. ## Verification workflow diff --git a/docs/history.md b/docs/history.md index 62ac3efe..752d9967 100644 --- a/docs/history.md +++ b/docs/history.md @@ -85,9 +85,11 @@ If storage fails, the panel reports the failure and keeps the latest eligible source pending in memory for a controlled retry. A newer edit replaces an older pending source. The revision is not durable and is not described as saved until storage actually succeeds. A document file can still save -successfully when this separate history write fails; destructive history -restore operations that require a protective revision remain blocked by such -a failure. +successfully when this separate history write fails. Closing its editor does +not make unresolved work count as settled: controlled retry continues for a +retryable failure during the application session, and clean shutdown is +withheld while work remains pending. Destructive history restore operations +that require a protective revision remain blocked by such a failure. Revisions are grouped by local calendar date and use the localized time, including seconds, as the primary row label. Distinct events such as Saved, diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index d24297b8..2862d17b 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -717,18 +717,37 @@ class LocalHistoryController extends Notifier { Future flushAll(Iterable buffers) async { final bufferList = buffers.toList(growable: false); - var succeeded = true; for (final buffer in bufferList) { - if (!await flushBuffer(buffer)) succeeded = false; + await flushBuffer(buffer); + } + await flushPendingIdentityPromotions(); + while (_bufferQueues.isNotEmpty) { + await Future.wait(_bufferQueues.values.toList(growable: false)); + } + for (final bufferId in _pending.keys.toList(growable: false)) { + _checkpointTimers.remove(bufferId)?.cancel(); + await _enqueue(bufferId, () async { + final pending = _pending[bufferId]; + return pending == null + ? true + : await _capturePending(bufferId, pending); + }); } - if (!await flushPendingIdentityPromotions()) succeeded = false; while (_bufferQueues.isNotEmpty) { await Future.wait(_bufferQueues.values.toList(growable: false)); } - final bufferIds = bufferList.map((buffer) => buffer.id).toSet(); - return succeeded && - _pendingUntitledPromotions.isEmpty && - _pending.keys.every((bufferId) => !bufferIds.contains(bufferId)); + return _pendingUntitledPromotions.isEmpty && _pending.isEmpty; + } + + /// Keeps unresolved history work owned by this session after its editor tab + /// closes. Retryable work continues on the checkpoint cadence; all pending + /// work remains part of [flushAll] shutdown settlement. + void handleBufferClosed(String bufferId, {required bool historySettled}) { + if (historySettled) return; + final pending = _pending[bufferId]; + if (pending != null && _captureFailures[bufferId]?.retryable != false) { + _scheduleCheckpoint(bufferId); + } } Future remapPath(String sourcePath, String destinationPath) async { @@ -1144,9 +1163,20 @@ class LocalHistoryController extends Notifier { bool ignoreBinding = false, bool allowPathChange = false, bool allowDuringPathTransition = false, + int? acceptedHistoryGeneration, + int? acceptedBufferGeneration, }) async { - final acceptedHistoryGeneration = _historyGeneration; - final acceptedBufferGeneration = _bufferGeneration(snapshot.bufferId); + final captureHistoryGeneration = + acceptedHistoryGeneration ?? _historyGeneration; + final captureBufferGeneration = + acceptedBufferGeneration ?? _bufferGeneration(snapshot.bufferId); + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return true; + } if (reason == LocalHistoryCaptureReason.automaticCheckpoint && !allowDuringPathTransition && _pathTransitions.containsKey(snapshot.bufferId)) { @@ -1162,9 +1192,20 @@ class LocalHistoryController extends Notifier { _setWarning(LocalHistoryWarningKind.pathChange); return false; } - if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { + if (!await _completePendingUntitledPromotion( + snapshot.bufferId, + acceptedHistoryGeneration: captureHistoryGeneration, + acceptedBufferGeneration: captureBufferGeneration, + )) { return false; } + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return true; + } } final currentPolicy = policy; if (!currentPolicy.recordingEnabled || @@ -1196,8 +1237,8 @@ class LocalHistoryController extends Notifier { ); if (!_operationIsCurrent( snapshot.bufferId, - acceptedHistoryGeneration, - acceptedBufferGeneration, + captureHistoryGeneration, + captureBufferGeneration, )) { return true; } @@ -1209,8 +1250,8 @@ class LocalHistoryController extends Notifier { final loaded = await _store.load(); if (_operationIsCurrent( snapshot.bufferId, - acceptedHistoryGeneration, - acceptedBufferGeneration, + captureHistoryGeneration, + captureBufferGeneration, )) { await _publishSnapshot( loaded, @@ -1223,12 +1264,13 @@ class LocalHistoryController extends Notifier { } on Object catch (error) { if (_operationIsCurrent( snapshot.bufferId, - acceptedHistoryGeneration, - acceptedBufferGeneration, + captureHistoryGeneration, + captureBufferGeneration, )) { _captureFailures[snapshot.bufferId] = _LocalHistoryCaptureFailure( detail: error.toString(), retryable: _captureErrorIsRetryable(error), + stage: _LocalHistoryFailureStage.capture, ); _showCaptureFailure(); } @@ -1236,9 +1278,24 @@ class LocalHistoryController extends Notifier { } } - Future _completePendingUntitledPromotion(String bufferId) async { + Future _completePendingUntitledPromotion( + String bufferId, { + int? acceptedHistoryGeneration, + int? acceptedBufferGeneration, + }) async { final promotion = _pendingUntitledPromotions[bufferId]; if (promotion == null) return true; + final promotionHistoryGeneration = + acceptedHistoryGeneration ?? _historyGeneration; + final promotionBufferGeneration = + acceptedBufferGeneration ?? _bufferGeneration(bufferId); + if (!_operationIsCurrent( + bufferId, + promotionHistoryGeneration, + promotionBufferGeneration, + )) { + return true; + } try { final document = await _store.promoteUntitledDocument( documentId: promotion.documentId, @@ -1246,20 +1303,52 @@ class LocalHistoryController extends Notifier { displayName: promotion.displayName, updatedAt: _clock().toUtc(), ); + if (!_operationIsCurrent( + bufferId, + promotionHistoryGeneration, + promotionBufferGeneration, + ) || + !identical(_pendingUntitledPromotions[bufferId], promotion)) { + return true; + } if (document == null) { + _captureFailures[bufferId] = const _LocalHistoryCaptureFailure( + retryable: false, + stage: _LocalHistoryFailureStage.promotion, + ); _setWarning(LocalHistoryWarningKind.pathChange); return false; } _documentIdsByBuffer[bufferId] = document.id; _pendingUntitledPromotions.remove(bufferId); + if (_captureFailures[bufferId]?.stage == + _LocalHistoryFailureStage.promotion) { + _captureFailures.remove(bufferId); + } if (ref.mounted) { final loaded = await _store.load(); - if (ref.mounted) { + if (_operationIsCurrent( + bufferId, + promotionHistoryGeneration, + promotionBufferGeneration, + )) { await _publishSnapshot(loaded, capturedBufferId: bufferId); } } return true; } on Object catch (error) { + if (!_operationIsCurrent( + bufferId, + promotionHistoryGeneration, + promotionBufferGeneration, + )) { + return true; + } + _captureFailures[bufferId] = _LocalHistoryCaptureFailure( + detail: error.toString(), + retryable: _captureErrorIsRetryable(error), + stage: _LocalHistoryFailureStage.promotion, + ); _setWarning(LocalHistoryWarningKind.pathChange, error.toString()); return false; } @@ -1329,8 +1418,18 @@ class LocalHistoryController extends Notifier { if (!ref.mounted || _pathTransitions.containsKey(bufferId)) return; final latest = _pending[bufferId]; if (latest != null) { + final acceptedHistoryGeneration = _historyGeneration; + final acceptedBufferGeneration = _bufferGeneration(bufferId); unawaited( - _enqueue(bufferId, () => _capturePending(bufferId, latest)), + _enqueue( + bufferId, + () => _capturePending( + bufferId, + latest, + acceptedHistoryGeneration: acceptedHistoryGeneration, + acceptedBufferGeneration: acceptedBufferGeneration, + ), + ), ); } }), @@ -1339,8 +1438,21 @@ class LocalHistoryController extends Notifier { Future _capturePending( String bufferId, - LocalHistoryBufferSnapshot snapshot, - ) async { + LocalHistoryBufferSnapshot snapshot, { + int? acceptedHistoryGeneration, + int? acceptedBufferGeneration, + }) async { + final captureHistoryGeneration = + acceptedHistoryGeneration ?? _historyGeneration; + final captureBufferGeneration = + acceptedBufferGeneration ?? _bufferGeneration(bufferId); + if (!_operationIsCurrent( + bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return true; + } if (!_pendingIsEligible(snapshot)) { _pending.remove(bufferId); _checkpointTimers.remove(bufferId)?.cancel(); @@ -1350,7 +1462,16 @@ class LocalHistoryController extends Notifier { final captured = await _capture( snapshot, LocalHistoryCaptureReason.automaticCheckpoint, + acceptedHistoryGeneration: captureHistoryGeneration, + acceptedBufferGeneration: captureBufferGeneration, ); + if (!_operationIsCurrent( + bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return true; + } if (captured && !_pathTransitions.containsKey(bufferId)) { _acknowledgePending(bufferId, snapshot.revision); } @@ -1638,14 +1759,18 @@ class LocalHistoryController extends Notifier { class _LocalHistoryCaptureFailure { const _LocalHistoryCaptureFailure({ - required this.detail, + this.detail, required this.retryable, + required this.stage, }); - final String detail; + final String? detail; final bool retryable; + final _LocalHistoryFailureStage stage; } +enum _LocalHistoryFailureStage { capture, promotion } + bool _sameOptionalPath(String? first, String? second) { if (first == null || second == null) return first == second; return p.equals(first, second); diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 5e5b3c9b..2ded7919 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1480,8 +1480,15 @@ class WorkspaceController extends Notifier { if (state.hasUnsavedChanges) { return false; } - await _localHistory.flushAll(state.documentBuffers); + final closingBuffers = state.documentBuffers; + final historySettled = await _localHistory.flushAll(closingBuffers); _clearOpenFileTabs(workspace); + for (final buffer in closingBuffers) { + _localHistory.handleBufferClosed( + buffer.id, + historySettled: historySettled, + ); + } return true; } @@ -1793,8 +1800,9 @@ class WorkspaceController extends Notifier { if (closingBuffer?.isDirty == true) { return false; } + var historySettled = true; if (closingBuffer != null) { - await _localHistory.flushBuffer(closingBuffer); + historySettled = await _localHistory.flushBuffer(closingBuffer); final currentClosingBuffer = state.documentBuffers .where((candidate) => candidate.id == closingBuffer.id) .firstOrNull; @@ -1808,9 +1816,15 @@ class WorkspaceController extends Notifier { return false; } } - final closedIndex = workspace.openFilePaths.indexOf(path); + final closingWorkspace = state.workspace; + if (closingWorkspace == null || + closingWorkspace.id != workspace.id || + !closingWorkspace.openFilePaths.contains(path)) { + return false; + } + final closedIndex = closingWorkspace.openFilePaths.indexOf(path); final nextOpenFilePaths = [ - for (final openPath in workspace.openFilePaths) + for (final openPath in closingWorkspace.openFilePaths) if (openPath != path) openPath, ]; final remainingBuffers = [ @@ -1820,33 +1834,59 @@ class WorkspaceController extends Notifier { if (nextOpenFilePaths.isEmpty) { final nextUntitled = remainingBuffers.firstOrNull; if (nextUntitled == null) { - _clearOpenFileTabs(workspace); + _clearOpenFileTabs(closingWorkspace); + if (closingBuffer != null) { + _localHistory.handleBufferClosed( + closingBuffer.id, + historySettled: historySettled, + ); + } } else { - await _activateBuffer( - workspace, + final closed = await _activateBuffer( + closingWorkspace, nextUntitled, documentBuffers: remainingBuffers, openFilePaths: nextOpenFilePaths, ); + if (!closed) return false; + if (closingBuffer != null) { + _localHistory.handleBufferClosed( + closingBuffer.id, + historySettled: historySettled, + ); + } } return true; } - if (workspace.activeFilePath != path) { + if (closingWorkspace.activeFilePath != path) { state = state.copyWith( - workspace: workspace.copyWith(openFilePaths: nextOpenFilePaths), + workspace: closingWorkspace.copyWith(openFilePaths: nextOpenFilePaths), documentBuffers: remainingBuffers, clearMessage: true, ); + if (closingBuffer != null) { + _localHistory.handleBufferClosed( + closingBuffer.id, + historySettled: historySettled, + ); + } return true; } final nextIndex = closedIndex <= 0 ? 0 : math.min(closedIndex - 1, nextOpenFilePaths.length - 1); - return _openActiveFile( + final closed = await _openActiveFile( nextOpenFilePaths[nextIndex], openFilePaths: nextOpenFilePaths, documentBuffers: remainingBuffers, ); + if (closed && closingBuffer != null) { + _localHistory.handleBufferClosed( + closingBuffer.id, + historySettled: historySettled, + ); + } + return closed; } Future closeDocumentBuffer( @@ -1887,7 +1927,7 @@ class WorkspaceController extends Notifier { if (afterProtection != null) _scheduleAutoSave(afterProtection.id); return false; } - await _localHistory.flushBuffer(afterProtection); + final historySettled = await _localHistory.flushBuffer(afterProtection); final current = state.documentBuffers .where((candidate) => candidate.id == bufferId) .firstOrNull; @@ -1921,14 +1961,25 @@ class WorkspaceController extends Notifier { state = const WorkspaceState(); _fileMonitor.updateOpenFilePaths(const []); _schedulePersistence(); + _localHistory.handleBufferClosed( + bufferId, + historySettled: historySettled, + ); return true; } - return _activateBuffer( + final closed = await _activateBuffer( workspace, remaining.last, documentBuffers: remaining, openFilePaths: workspace.openFilePaths, ); + if (closed) { + _localHistory.handleBufferClosed( + bufferId, + historySettled: historySettled, + ); + } + return closed; } Future _activateOpenFileTab(int delta) async { @@ -1994,6 +2045,7 @@ class WorkspaceController extends Notifier { if (workspace == null) { return false; } + final workspaceId = workspace.id; final buffers = documentBuffers ?? state.documentBuffers; final existing = buffers .where((buffer) => buffer.filePath == path) @@ -2015,18 +2067,27 @@ class WorkspaceController extends Notifier { } _cancelPendingDerivedRefresh(); final operationRevision = _invalidateActiveDocumentOperations(); + final requestedOpenFilePaths = + openFilePaths ?? _openFileTabPaths(workspace, path); + final initialOpenPaths = workspace.openFilePaths.toSet(); + final addedOpenPaths = [ + for (final requestedPath in requestedOpenFilePaths) + if (!initialOpenPaths.contains(requestedPath)) requestedPath, + ]; try { final load = await _service.loadTextWithSnapshot(path); final nextWorkspace = workspace.copyWith( activeFilePath: path, activeFileSnapshot: load.snapshot, - openFilePaths: openFilePaths ?? _openFileTabPaths(workspace, path), + openFilePaths: requestedOpenFilePaths, ); - if (!_isCurrentActiveDocumentOperation(operationRevision)) { + if (!_isCurrentActiveDocumentOperation(operationRevision) || + state.workspace?.id != workspaceId) { return false; } final reparsed = await _service.reparseActive(nextWorkspace, load.text); - if (!_isCurrentActiveDocumentOperation(operationRevision)) { + if (!_isCurrentActiveDocumentOperation(operationRevision) || + state.workspace?.id != workspaceId) { return false; } final buffer = _fileBuffer( @@ -2034,21 +2095,37 @@ class WorkspaceController extends Notifier { load, mode: _settingsController.state.documentViewMode, ); + final liveBuffers = state.documentBuffers; + if (liveBuffers.any( + (candidate) => + candidate.id == buffer.id || candidate.filePath == buffer.filePath, + )) { + return false; + } + final reconciledBuffers = [...liveBuffers, buffer]; + final liveOpenPaths = state.workspace?.openFilePaths ?? const []; + final reconciledOpenPaths = [ + ...liveOpenPaths, + for (final addedPath in addedOpenPaths) + if (!liveOpenPaths.contains(addedPath)) addedPath, + ]; + final publishedWorkspace = reparsed.copyWith( + openFilePaths: reconciledOpenPaths, + ); state = state.copyWith( - workspace: reparsed, + workspace: publishedWorkspace, activeText: load.text, - preview: _safePreview(reparsed, load.text), - documentBuffers: [...buffers, buffer], + preview: _safePreview(publishedWorkspace, load.text), + documentBuffers: reconciledBuffers, activeBufferId: buffer.id, clearMessage: true, ); unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( - [ - ...buffers, - buffer, - ].map((candidate) => candidate.filePath).whereType(), + reconciledBuffers + .map((candidate) => candidate.filePath) + .whereType(), ); _resetSaveTracking(); return true; @@ -2084,18 +2161,29 @@ class WorkspaceController extends Notifier { final initialBufferIds = state.documentBuffers .map((candidate) => candidate.id) .toSet(); + final initialBuffersById = { + for (final candidate in state.documentBuffers) candidate.id: candidate, + }; final requestedBufferIds = documentBuffers .map((candidate) => candidate.id) .toSet(); final intentionallyRemovedBufferIds = initialBufferIds.difference( requestedBufferIds, ); + final intentionallyAddedBuffers = [ + for (final candidate in documentBuffers) + if (!initialBufferIds.contains(candidate.id)) candidate, + ]; final initialOpenPaths = state.workspace?.openFilePaths.toSet() ?? const {}; final requestedOpenPaths = openFilePaths.toSet(); final intentionallyRemovedOpenPaths = initialOpenPaths.difference( requestedOpenPaths, ); + final intentionallyAddedOpenPaths = [ + for (final path in openFilePaths) + if (!initialOpenPaths.contains(path)) path, + ]; final nextWorkspace = workspace.copyWith( activeFilePath: buffer.filePath, activeFileSnapshot: buffer.diskSnapshot, @@ -2132,17 +2220,25 @@ class WorkspaceController extends Notifier { liveBuffer.revision == parsedBuffer.revision && liveBuffer.text == parsedBuffer.text; final liveBuffers = state.documentBuffers; + for (final removedId in intentionallyRemovedBufferIds) { + final before = initialBuffersById[removedId]; + final live = liveBuffers + .where((candidate) => candidate.id == removedId) + .firstOrNull; + if (before != null && live != null && !identical(before, live)) { + return false; + } + } final reconciledBuffers = [ for (final candidate in liveBuffers) if (!intentionallyRemovedBufferIds.contains(candidate.id)) candidate, - for (final requested in documentBuffers) - if (!liveBuffers.any((candidate) => candidate.id == requested.id)) - requested, + for (final added in intentionallyAddedBuffers) + if (!liveBuffers.any((candidate) => candidate.id == added.id)) added, ]; final reconciledOpenPaths = [ for (final path in state.workspace?.openFilePaths ?? const []) if (!intentionallyRemovedOpenPaths.contains(path)) path, - for (final path in openFilePaths) + for (final path in intentionallyAddedOpenPaths) if (!(state.workspace?.openFilePaths.contains(path) ?? false)) path, ]; final publishedWorkspace = (sourceStayedCurrent ? reparsed : nextWorkspace) diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 1cbeea71..9b0dfdad 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -364,6 +364,77 @@ void main() { expect(sources, isNot(contains(older.text))); }); + test( + 'automatic checkpoint retries a transient first-save promotion failure', + () async { + final timers = <_FakeTimer>[]; + final store = _ControllablePromotionStore(); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final untitled = DocumentBuffer.untitled( + id: 'promotion-retry-buffer', + name: 'Draft.md', + text: 'initial source', + ); + await controller.observeOpened(untitled); + final originalDocumentId = (await store.load()).documents.single.id; + const destination = '/workspace/Promoted.md'; + expect( + await controller.captureSavedAs( + LocalHistoryBufferSnapshot.fromBuffer(untitled), + destination, + destinationExisted: false, + ), + isFalse, + ); + expect(store.promotionAttempts, 1); + expect(controller.hasPendingIdentityPromotion(untitled.id), isTrue); + + final named = untitled.copyWith( + filePath: destination, + untitledName: null, + lastSavedText: untitled.text, + dirty: false, + ); + final edited = named.edited('latest named source'); + controller.observeEdit(named, edited); + await Future.delayed(Duration.zero); + expect(timers, hasLength(1)); + + timers.single.fire(); + await _waitForHistory( + container, + (_) => store.promotionAttempts == 2 && timers.length == 2, + ); + expect(controller.pendingSnapshotForBuffer(edited.id)?.text, edited.text); + + store.failPromotions = false; + timers.last.fire(); + await _waitForHistory( + container, + (_) => + !controller.hasPendingIdentityPromotion(edited.id) && + controller.pendingSnapshotForBuffer(edited.id) == null, + ); + + final snapshot = await store.load(); + expect(snapshot.documents, hasLength(1)); + expect(snapshot.documents.single.id, originalDocumentId); + expect(snapshot.documents.single.currentPath, destination); + expect( + await _revisionSources( + store, + snapshot.revisionsFor(originalDocumentId), + ), + contains(edited.text), + ); + }, + ); + test( 'flush waits behind queued observation and captures its latest source', () async { @@ -456,6 +527,51 @@ void main() { }, ); + test( + 'clear invalidates an automatic checkpoint queued for an untitled draft', + () async { + final timers = <_FakeTimer>[]; + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = DocumentBuffer.untitled( + id: 'queued-clear-draft', + name: 'Draft.md', + text: 'retained baseline', + ); + await controller.observeOpened(opened); + final originalDocumentId = (await memory.load()).documents.single.id; + final edited = opened.edited('must remain cleared'); + controller.observeEdit(opened, edited); + await Future.delayed(Duration.zero); + expect(timers, hasLength(1)); + + store.blockNextCapture = true; + final precedingCapture = controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer(opened), + ); + await store.captureStarted.future; + timers.single.fire(); + final clear = controller.clearDocument(originalDocumentId); + await Future.delayed(Duration.zero); + + store.releaseCapture.complete(); + expect(await precedingCapture, isTrue); + await clear; + + final snapshot = await memory.load(); + expect(snapshot.documents, isEmpty); + expect(snapshot.revisions, isEmpty); + expect(controller.documentIdForBuffer(opened.id), isNull); + expect(controller.pendingSnapshotForBuffer(opened.id), isNull); + }, + ); + test( 'clearing the displayed comparison closes it without a missing warning', () async { @@ -1966,6 +2082,32 @@ class _FailingFirstPromotionStore extends MemoryLocalHistoryStore { } } +class _ControllablePromotionStore extends MemoryLocalHistoryStore { + var failPromotions = true; + var promotionAttempts = 0; + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) { + promotionAttempts++; + if (failPromotions) { + throw const LocalHistoryStorageException( + 'Injected transient promotion failure', + ); + } + return super.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + } +} + class _FailingPromotionThenCaptureStore extends MemoryLocalHistoryStore { _FailingPromotionThenCaptureStore(this.failingSource); diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 9e469118..35ad28e2 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -1522,8 +1522,12 @@ void main() { contains('BusyMarkSidebarRecordRow<_ClipboardEntryAction>'), ); expect(clipboard, contains('padding: BusyMarkInsets.sidebarList')); - expect(clipboard, contains('content: Text(')); - expect(clipboard, contains('maxLines: 3')); + expect(clipboard, contains('content: Column(')); + expect(clipboard, contains('maxLines: 2')); + expect(clipboard, contains("final metadata = '\$status · \${_timestamp")); + expect(clipboard, contains('l10n.clipboardOrigin(origin.documentName)')); + expect(clipboard, contains('busyMarkLtrIsolateFor(context, originPath)')); + expect(clipboard, isNot(contains('tooltip: _clipboardEntryTooltip'))); expect( clipboard, isNot(contains('if (preview != null && preview.trim().isNotEmpty)')), diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 34893842..9ecf443d 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -1571,6 +1571,61 @@ void main() { expect(service.diskText, '# Pending autosave\n'); }); + test( + 'pending history from a closed clean tab prevents a clean shutdown marker', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-closed-history-shutdown-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + const failedSource = '# Saved while history is unavailable\n'; + final historyStore = _PersistentSourceFailureStore(failedSource); + final recoveryStore = MemoryDocumentRecoveryStore(); + final harness = await _createControllerHarness( + localHistoryStore: historyStore, + recoveryStore: recoveryStore, + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(directory.path); + if (controller.state.activeBuffer?.filePath != aPath) { + expect(await controller.openActiveFile(aPath), isTrue); + } + final aId = controller.state.activeBuffer!.id; + controller.updateActiveText(failedSource); + + expect(await controller.saveActive(), isTrue); + expect(controller.state.activeBuffer?.isDirty, isFalse); + expect( + harness._container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(aId) + ?.text, + failedSource, + ); + + expect(await controller.openActiveFile(bPath), isTrue); + expect(await controller.closeOpenFileTab(aPath), isTrue); + expect( + controller.state.documentBuffers.any((buffer) => buffer.id == aId), + isFalse, + ); + + await controller.markCleanShutdown(); + expect(recoveryStore.value.cleanShutdown, isFalse); + expect( + harness._container + .read(localHistoryControllerProvider.notifier) + .pendingSnapshotForBuffer(aId), + isNotNull, + ); + }, + ); + test( 'save refuses to overwrite external file changes without force', () async { @@ -1883,6 +1938,157 @@ void main() { }, ); + test( + 'opening a new tab preserves live edits and editor history in the visible document', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-first-open-live-edits-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + final service = _BlockingRefreshWorkspaceService(); + final harness = await _createControllerHarness(service: service); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller; + await controller.openPath(directory.path); + final aId = controller.state.activeBuffer!.id; + + service.pauseNextReparse(); + final opening = controller.openActiveFile(bPath); + await service.reparseStarted.future; + controller.updateActiveEditorState( + controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 2), + ), + ); + controller.updateActiveSourceText('# A first edit\n'); + controller.updateActiveEditorState( + controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 8), + ), + ); + controller.updateActiveSourceText('# A second edit\n'); + final liveA = controller.state.activeBuffer!; + + service.releaseReparse(); + expect(await opening, isTrue); + final preservedA = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == aId, + ); + expect(preservedA.text, liveA.text); + expect(preservedA.isDirty, isTrue); + expect(preservedA.editorState.selection, liveA.editorState.selection); + expect( + preservedA.editorState.undoState, + same(liveA.editorState.undoState), + ); + expect(controller.state.activeBuffer?.filePath, bPath); + + expect(await controller.activateDocumentBuffer(aId), isTrue); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# A first edit\n'); + }, + ); + + test( + 'closing the active tab aborts if it changes during next-tab activation', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-close-activation-edit-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + final service = _BlockingRefreshWorkspaceService(); + final harness = await _createControllerHarness(service: service); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller; + await controller.openPath(directory.path); + final aId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(bPath), isTrue); + expect(await controller.activateDocumentBuffer(aId), isTrue); + + service.pauseNextReparse(); + final close = controller.closeOpenFileTab(aPath); + await service.reparseStarted.future; + controller.updateActiveEditorState( + controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 2), + ), + ); + controller.updateActiveSourceText('# A changed during close\n'); + final liveA = controller.state.activeBuffer!; + + service.releaseReparse(); + expect(await close, isFalse); + expect(controller.state.activeBufferId, aId); + expect(controller.state.workspace?.openFilePaths, contains(aPath)); + final preservedA = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == aId, + ); + expect(preservedA.text, liveA.text); + expect(preservedA.isDirty, isTrue); + expect(preservedA.editorState.selection, liveA.editorState.selection); + expect( + preservedA.editorState.undoState, + same(liveA.editorState.undoState), + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# A\n'); + }, + ); + + test( + 'tab activation does not resurrect another tab closed while waiting', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-activation-closed-tab-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + final cPath = p.join(directory.path, 'c.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + await File(cPath).writeAsString('# C\n'); + final service = _BlockingRefreshWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final controller = harness.controller; + await controller.openPath(directory.path); + final aId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(bPath), isTrue); + final bId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(cPath), isTrue); + final cId = controller.state.activeBuffer!.id; + expect(await controller.activateDocumentBuffer(aId), isTrue); + + service.pauseNextReparse(); + final activation = controller.activateDocumentBuffer(bId); + await service.reparseStarted.future; + expect(await controller.closeOpenFileTab(cPath), isTrue); + expect( + controller.state.documentBuffers.any((buffer) => buffer.id == cId), + isFalse, + ); + + service.releaseReparse(); + expect(await activation, isTrue); + expect(controller.state.activeBufferId, bId); + expect( + controller.state.documentBuffers.any((buffer) => buffer.id == cId), + isFalse, + ); + expect(controller.state.workspace?.openFilePaths, isNot(contains(cPath))); + }, + ); + test( 'workspace refresh preserves inactive edits and does not resurrect a closed tab', () async { @@ -2646,6 +2852,25 @@ class _BlockingBeforeDiscardStore extends MemoryLocalHistoryStore { } } +class _PersistentSourceFailureStore extends MemoryLocalHistoryStore { + _PersistentSourceFailureStore(this.failedSource); + + final String failedSource; + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) { + if (request.source == failedSource) { + throw const LocalHistoryStorageException( + 'Injected persistent capture failure', + ); + } + return super.capture(request, policy); + } +} + class _FailingDocumentSessionStore extends MemoryDocumentSessionStore { bool failSave = false; From b65af9e47d26b4070e3468e42144a26fc99f44f2 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 20:14:28 -0700 Subject: [PATCH 21/27] Refactor Local History handling to incorporate accepted history and buffer generations --- docs/history-architecture.md | 14 +- .../local_history_controller.dart | 140 +++++++++++++- lib/src/workspace/workspace_controller.dart | 47 +++-- test/src/local_history_controller_test.dart | 49 +++++ test/src/workspace_controller_test.dart | 175 ++++++++++++++++++ 5 files changed, 395 insertions(+), 30 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index 263b35cd..64f67358 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -123,8 +123,10 @@ newer matches. Search completion clears its loading state on success, scope change, cancellation, and read failure. Explicit clear invalidates affected queued work at the point it was accepted, before store access, and closes a comparison it owns. A queued automatic checkpoint cannot become current merely -because it begins executing after Clear. Retention loss instead reports the -existing missing-revision state. +because it begins executing after Clear. Explicit-save and Save As wrappers +revalidate the same acceptance generation after each capture or identity +prerequisite, so a stale failure cannot reinstall a pending snapshot or retry +timer. Retention loss instead reports the existing missing-revision state. `SourceComparisonInput` identifies immutable sources by ID, version, label, and text. The comparison uses unique patience anchors plus bounded local LCS @@ -167,8 +169,12 @@ loaded document to the live list instead of an earlier captured list. Only documents that were genuine additions at activation start may be added during reconciliation, so tabs closed while parsing stay closed. A close that removes a document is aborted if that document's source or editor state changes during -the final activation await. Refresh reschedules eligible autosaves on both -success and failure because it cancels its incoming timers at the boundary. +the final activation await. Discard authorization is carried to that removal +without temporarily marking unsaved text clean. Close All revalidates the exact +workspace and buffer instances after its history flush, so a new edit, tab, or +replacement workspace aborts the stale close. Refresh reschedules eligible +autosaves on both success and failure because it cancels its incoming timers at +the boundary. ## Verification workflow diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 2862d17b..5bb8903f 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -364,20 +364,53 @@ class LocalHistoryController extends Notifier { _setWarning(LocalHistoryWarningKind.pathChange); return; } - if (!await _completePendingUntitledPromotion(snapshot.bufferId)) { + if (!await _completePendingUntitledPromotion( + snapshot.bufferId, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + )) { + return; + } + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { return; } final pending = _pending[snapshot.bufferId]; _checkpointTimers.remove(snapshot.bufferId)?.cancel(); if (pending != null) { - await _capturePending(snapshot.bufferId, pending); + await _capturePending( + snapshot.bufferId, + pending, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); } - await _capture(snapshot, LocalHistoryCaptureReason.baseline); + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { + return; + } + await _capture( + snapshot, + LocalHistoryCaptureReason.baseline, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); return; } if (adoptedPromotion != null) return; if (_documentIdsByBuffer.containsKey(snapshot.bufferId)) return; - await _capture(snapshot, LocalHistoryCaptureReason.baseline); + await _capture( + snapshot, + LocalHistoryCaptureReason.baseline, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); }); } @@ -398,7 +431,12 @@ class LocalHistoryController extends Notifier { } if (!_documentIdsByBuffer.containsKey(current.id) && !(previousSnapshot.untitled && previousSnapshot.text.isEmpty)) { - await _capture(previousSnapshot, LocalHistoryCaptureReason.baseline); + await _capture( + previousSnapshot, + LocalHistoryCaptureReason.baseline, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); } if (!_operationIsCurrent( current.id, @@ -480,7 +518,16 @@ class LocalHistoryController extends Notifier { final captured = await _capture( snapshot, LocalHistoryCaptureReason.saved, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, ); + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } if (captured) { _acknowledgePending(snapshot.bufferId, snapshot.revision); } else { @@ -498,9 +545,17 @@ class LocalHistoryController extends Notifier { _setWarning(LocalHistoryWarningKind.recordingDisabled); return false; } + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); return _enqueue( snapshot.bufferId, - () => _capture(snapshot, reason, force: true), + () => _capture( + snapshot, + reason, + force: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), ); } @@ -512,9 +567,17 @@ class LocalHistoryController extends Notifier { LocalHistoryCaptureReason reason, ) async { if (!policy.recordingEnabled || policy.excludes(snapshot.path)) return true; + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); return _enqueue( snapshot.bufferId, - () => _capture(snapshot, reason, force: true), + () => _capture( + snapshot, + reason, + force: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), ); } @@ -523,6 +586,8 @@ class LocalHistoryController extends Notifier { String destinationPath, { required bool destinationExisted, }) async { + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(source.bufferId); final destination = LocalHistoryBufferSnapshot( bufferId: source.bufferId, displayName: p.basename(destinationPath), @@ -534,6 +599,13 @@ class LocalHistoryController extends Notifier { var promotionCompleted = false; var retainedSourcePromotion = false; final captured = await _enqueue(source.bufferId, () async { + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } // A named-file Save As is a fork. Resolve an earlier untitled-to-source // promotion before recording the destination. If storage is still // unavailable, detach that source association from the buffer so it can @@ -544,7 +616,16 @@ class LocalHistoryController extends Notifier { if (_sameOptionalPath(source.path, promotion.destinationPath)) { promotionCompleted = await _completePendingUntitledPromotion( source.bufferId, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } } if (_pendingUntitledPromotions.containsKey(source.bufferId)) { _detachPendingUntitledPromotion(source.bufferId); @@ -564,7 +645,16 @@ class LocalHistoryController extends Notifier { pending, LocalHistoryCaptureReason.automaticCheckpoint, allowDuringPathTransition: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } if (pendingCaptured) { _acknowledgePending(source.bufferId, pending.revision); } else { @@ -589,7 +679,16 @@ class LocalHistoryController extends Notifier { ); promotionCompleted = await _completePendingUntitledPromotion( source.bufferId, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } if (!promotionCompleted) return false; } @@ -600,7 +699,16 @@ class LocalHistoryController extends Notifier { destination, LocalHistoryCaptureReason.saved, ignoreBinding: !source.untitled || destinationExisted, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } if (savedCaptured) { _acknowledgePending(destination.bufferId, destination.revision); } else { @@ -608,6 +716,13 @@ class LocalHistoryController extends Notifier { } return savedCaptured; }); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } if (captured) { final document = state.snapshot.documents .where( @@ -644,9 +759,18 @@ class LocalHistoryController extends Notifier { revision: 0, path: path, ); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); return _enqueue( snapshot.bufferId, - () => _capture(snapshot, reason, force: force, ignoreBinding: true), + () => _capture( + snapshot, + reason, + force: force, + ignoreBinding: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), ); } diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 2ded7919..8d2a3a66 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1480,8 +1480,16 @@ class WorkspaceController extends Notifier { if (state.hasUnsavedChanges) { return false; } - final closingBuffers = state.documentBuffers; + final closingBuffers = state.documentBuffers.toList(growable: false); final historySettled = await _localHistory.flushAll(closingBuffers); + final liveBuffers = state.documentBuffers; + if (!identical(state.workspace, workspace) || + liveBuffers.length != closingBuffers.length || + liveBuffers.indexed.any( + (entry) => !identical(entry.$2, closingBuffers[entry.$1]), + )) { + return false; + } _clearOpenFileTabs(workspace); for (final buffer in closingBuffers) { _localHistory.handleBufferClosed( @@ -1791,25 +1799,32 @@ class WorkspaceController extends Notifier { } } - Future closeOpenFileTab(String path) async { + Future closeOpenFileTab(String path) => _closeOpenFileTabNow(path); + + Future _closeOpenFileTabNow( + String path, { + DocumentBuffer? discardAuthorization, + bool? preflushedHistorySettled, + }) async { final workspace = state.workspace; if (workspace == null || !workspace.openFilePaths.contains(path)) { return false; } final closingBuffer = state.bufferForPath(path); - if (closingBuffer?.isDirty == true) { + if (discardAuthorization != null && + !identical(closingBuffer, discardAuthorization)) { return false; } - var historySettled = true; - if (closingBuffer != null) { + if (closingBuffer?.isDirty == true && discardAuthorization == null) { + return false; + } + var historySettled = preflushedHistorySettled ?? true; + if (closingBuffer != null && preflushedHistorySettled == null) { historySettled = await _localHistory.flushBuffer(closingBuffer); final currentClosingBuffer = state.documentBuffers .where((candidate) => candidate.id == closingBuffer.id) .firstOrNull; - if (currentClosingBuffer == null || - currentClosingBuffer.filePath != closingBuffer.filePath || - currentClosingBuffer.revision != closingBuffer.revision || - currentClosingBuffer.isDirty) { + if (!identical(currentClosingBuffer, closingBuffer)) { if (currentClosingBuffer != null) { _scheduleAutoSave(currentClosingBuffer.id); } @@ -1939,15 +1954,11 @@ class WorkspaceController extends Notifier { return false; } if (current.filePath case final path?) { - if (current.isDirty) { - state = state.copyWith( - documentBuffers: _replaceBuffer( - state.documentBuffers, - current.copyWith(dirty: false), - ), - ); - } - return closeOpenFileTab(path); + return _closeOpenFileTabNow( + path, + discardAuthorization: current.isDirty ? current : null, + preflushedHistorySettled: historySettled, + ); } final workspace = state.workspace; if (workspace == null) { diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 9b0dfdad..8223e4f6 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -572,6 +572,55 @@ void main() { }, ); + for (final clearAll in [false, true]) { + test('failed explicit save cannot recreate history after ' + '${clearAll ? 'Clear All' : 'Clear Document'}', () async { + final timers = <_FakeTimer>[]; + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, timers); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final opened = _fileBuffer( + 'saved-clear-buffer', + '/workspace/saved-clear.md', + 'retained baseline', + ); + await controller.selectDocumentForBuffer(opened); + final documentId = controller.documentIdForBuffer(opened.id)!; + final saved = opened.edited('saved before clear'); + + store.blockNextCapture = true; + store.failBlockedCapture = true; + final capture = controller.captureSaved( + LocalHistoryBufferSnapshot.fromBuffer(saved), + ); + await store.captureStarted.future; + final clear = clearAll + ? controller.clearAll() + : controller.clearDocument(documentId); + await Future.delayed(Duration.zero); + + store.releaseCapture.complete(); + expect(await capture, isTrue); + await clear; + for (final timer in timers) { + timer.fire(); + } + await Future.delayed(Duration.zero); + + final snapshot = await memory.load(); + expect(snapshot.documents, isEmpty); + expect(snapshot.revisions, isEmpty); + expect(controller.documentIdForBuffer(opened.id), isNull); + expect(controller.pendingSnapshotForBuffer(opened.id), isNull); + expect(timers.where((timer) => timer.isActive), isEmpty); + }); + } + test( 'clearing the displayed comparison closes it without a missing warning', () async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 9ecf443d..a8c1b820 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -1080,6 +1080,94 @@ void main() { settingsController.dispose(); }); + test( + 'closing all tabs aborts when a document changes during history flush', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-close-all-edit-race-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'a.md'); + await File(path).writeAsString('# A\n'); + final historyStore = _BlockingHistoryCaptureStore(); + final harness = await _createControllerHarness( + localHistoryStore: historyStore, + ); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller; + await controller.openPath(directory.path); + await _waitFor(() => historyStore.captureCount > 0); + controller.updateActiveSourceText('# Pending checkpoint\n'); + expect(await controller.autoSaveActiveIfNeeded(), isTrue); + expect(controller.state.activeBuffer?.isDirty, isFalse); + + historyStore.blockNextCapture = true; + final close = controller.closeAllOpenFileTabs(); + await historyStore.captureStarted.future; + controller.updateActiveSourceText('# Changed while Close All waits\n'); + controller.updateActiveEditorState( + controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 9), + ), + ); + final live = controller.state.activeBuffer!; + + historyStore.releaseCapture.complete(); + expect(await close, isFalse); + final surviving = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == live.id, + ); + expect(surviving.text, live.text); + expect(surviving.isDirty, isTrue); + expect(surviving.editorState.selection, live.editorState.selection); + expect(surviving.editorState.undoState, same(live.editorState.undoState)); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# Pending checkpoint\n'); + }, + ); + + test( + 'closing all tabs cannot clear workspace state replaced during flush', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-close-all-workspace-race-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'a.md'); + await File(path).writeAsString('# A\n'); + final historyStore = _BlockingHistoryCaptureStore(); + final harness = await _createControllerHarness( + localHistoryStore: historyStore, + ); + final controller = harness.controller; + await controller.openPath(directory.path); + await _waitFor(() => historyStore.captureCount > 0); + controller.updateActiveSourceText('# Pending checkpoint\n'); + expect(await controller.autoSaveActiveIfNeeded(), isTrue); + final closingWorkspace = controller.state.workspace; + + historyStore.blockNextCapture = true; + final close = controller.closeAllOpenFileTabs(); + await historyStore.captureStarted.future; + await controller.createMarkdownFile(); + final replacementWorkspace = controller.state.workspace; + final replacementBuffer = controller.state.activeBuffer!; + expect(replacementWorkspace, isNot(same(closingWorkspace))); + expect(replacementBuffer.isUntitled, isTrue); + + historyStore.releaseCapture.complete(); + expect(await close, isFalse); + expect(controller.state.workspace, same(replacementWorkspace)); + expect( + controller.state.documentBuffers.any( + (buffer) => buffer.id == replacementBuffer.id, + ), + isTrue, + ); + expect(controller.state.activeBufferId, replacementBuffer.id); + }, + ); + test('failed open clears stale workspace state', () async { final harness = await _createControllerHarness(); final settingsController = harness.settingsController; @@ -2045,6 +2133,70 @@ void main() { }, ); + test( + 'aborted discard close preserves dirty state after an editor-only change', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-discard-activation-selection-', + ); + addTearDown(() => directory.delete(recursive: true)); + final aPath = p.join(directory.path, 'a.md'); + final bPath = p.join(directory.path, 'b.md'); + await File(aPath).writeAsString('# A\n'); + await File(bPath).writeAsString('# B\n'); + final service = _BlockingRefreshWorkspaceService(); + final harness = await _createControllerHarness(service: service); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + await harness.settingsController.setDocumentViewMode( + DocumentViewModePreference.source, + ); + final controller = harness.controller; + await controller.openPath(directory.path); + final aId = controller.state.activeBuffer!.id; + expect(await controller.openActiveFile(bPath), isTrue); + expect(await controller.activateDocumentBuffer(aId), isTrue); + final completedReparsesBeforeEdit = service.completedReparseCount; + controller.updateActiveSourceText('# Unsaved A\n'); + await _waitFor( + () => service.completedReparseCount > completedReparsesBeforeEdit, + ); + final dirtyA = controller.state.activeBuffer!; + expect(dirtyA.isDirty, isTrue); + expect(controller.state.workspace?.activeFilePath, aPath); + + service.pauseNextReparse(); + final close = controller.closeDocumentBuffer(aId, discard: true); + await service.reparseStarted.future; + controller.updateActiveEditorState( + controller.state.activeBuffer!.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 4), + ), + ); + final liveA = controller.state.activeBuffer!; + expect(liveA.text, dirtyA.text); + expect(liveA.isDirty, isTrue); + expect(liveA, isNot(same(dirtyA))); + expect(liveA.editorState.selection.baseOffset, 4); + + service.releaseReparse(); + expect(await close, isFalse); + final survivingA = controller.state.documentBuffers.singleWhere( + (buffer) => buffer.id == aId, + ); + expect(survivingA.text, dirtyA.text); + expect(survivingA.isDirty, isTrue); + expect(survivingA.lastSavedText, '# A\n'); + expect(survivingA.editorState.selection, liveA.editorState.selection); + expect( + survivingA.editorState.undoState, + same(liveA.editorState.undoState), + ); + expect(controller.undoActiveBuffer(), isTrue); + expect(controller.state.activeText, '# A\n'); + }, + ); + test( 'tab activation does not resurrect another tab closed while waiting', () async { @@ -2852,6 +3004,27 @@ class _BlockingBeforeDiscardStore extends MemoryLocalHistoryStore { } } +class _BlockingHistoryCaptureStore extends MemoryLocalHistoryStore { + var blockNextCapture = false; + var captureCount = 0; + final captureStarted = Completer(); + final releaseCapture = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + captureCount++; + if (blockNextCapture) { + blockNextCapture = false; + captureStarted.complete(); + await releaseCapture.future; + } + return super.capture(request, policy); + } +} + class _PersistentSourceFailureStore extends MemoryLocalHistoryStore { _PersistentSourceFailureStore(this.failedSource); @@ -3212,6 +3385,7 @@ class _GatedRefreshWorkspaceService extends WorkspaceService { class _BlockingRefreshWorkspaceService extends WorkspaceService { var _pauseReparse = false; String? _pausedLoadPath; + var completedReparseCount = 0; final reparseStarted = Completer(); final loadStarted = Completer(); final _reparseRelease = Completer(); @@ -3249,6 +3423,7 @@ class _BlockingRefreshWorkspaceService extends WorkspaceService { if (!reparseStarted.isCompleted) reparseStarted.complete(); await _reparseRelease.future; } + completedReparseCount++; return reparsed; } } From 4c1a47ae651d0b87ac097ae7f2aded97cc84002a Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 20:50:56 -0700 Subject: [PATCH 22/27] Enhance Local History handling with protection requirements and improve capture logic --- docs/history-architecture.md | 19 +- .../local_history_controller.dart | 222 ++++++++++-------- lib/src/workspace/workspace_controller.dart | 27 ++- test/src/local_history_controller_test.dart | 63 +++++ test/src/local_history_workspace_test.dart | 168 +++++++++++++ test/src/workspace_controller_test.dart | 114 +++++++++ 6 files changed, 509 insertions(+), 104 deletions(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index 64f67358..d85ac06e 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -81,6 +81,11 @@ Pending first-save associations are also matched by destination path when a tab is reopened, before baseline capture, and promotion refuses a destination already owned by another history document. +Once a named-file Save As has changed the buffer path, detaching its source +binding is unconditional finalization, not optional recording. Disabling +recording or clearing history during the destination capture cannot leave the +destination buffer bound to the source file's history. + ## Store and retention Production revisions live in `local_history` beneath the platform application @@ -144,6 +149,11 @@ a protective snapshot, revalidates after awaits, and applies one `DocumentBuffer.edited` transaction without replacing format metadata or the undo stack. +Cancellation is not successful protection: Clear or a recording-policy change +invalidating a required capture aborts the dependent restore, reload, discard, +or path replacement, even if the document itself has not changed. Optional +post-save history cancellation does not turn a completed file save into failure. + Missing-file recovery keeps the source history identity separate from the destination buffer. For an approved existing path, the normal guarded open flow loads the destination's real source and format, protection must succeed, and @@ -172,9 +182,12 @@ a document is aborted if that document's source or editor state changes during the final activation await. Discard authorization is carried to that removal without temporarily marking unsaved text clean. Close All revalidates the exact workspace and buffer instances after its history flush, so a new edit, tab, or -replacement workspace aborts the stale close. Refresh reschedules eligible -autosaves on both success and failure because it cancels its incoming timers at -the boundary. +replacement workspace aborts the stale close. An aborted individual close +reschedules normal autosave for a surviving eligible dirty buffer in that same +workspace. This finalization stays inside the write queue so shutdown retains +its final timer cancellation; successful discard does not schedule a save. +Refresh reschedules eligible autosaves on both success and failure because it +cancels its incoming timers at the boundary. ## Verification workflow diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 5bb8903f..ef9c30d0 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -553,6 +553,7 @@ class LocalHistoryController extends Notifier { snapshot, reason, force: true, + requireProtection: true, acceptedHistoryGeneration: historyGeneration, acceptedBufferGeneration: bufferGeneration, ), @@ -575,6 +576,7 @@ class LocalHistoryController extends Notifier { snapshot, reason, force: true, + requireProtection: true, acceptedHistoryGeneration: historyGeneration, acceptedBufferGeneration: bufferGeneration, ), @@ -599,21 +601,88 @@ class LocalHistoryController extends Notifier { var promotionCompleted = false; var retainedSourcePromotion = false; final captured = await _enqueue(source.bufferId, () async { - if (!_operationIsCurrent( - source.bufferId, - historyGeneration, - bufferGeneration, - )) { - return true; - } - // A named-file Save As is a fork. Resolve an earlier untitled-to-source - // promotion before recording the destination. If storage is still - // unavailable, detach that source association from the buffer so it can - // be retried independently without being retargeted to the copy. - if (!source.untitled && - _pendingUntitledPromotions.containsKey(source.bufferId)) { - final promotion = _pendingUntitledPromotions[source.bufferId]!; - if (_sameOptionalPath(source.path, promotion.destinationPath)) { + final sourceDocumentIdAtStart = _documentIdsByBuffer[source.bufferId]; + try { + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + // A named-file Save As is a fork. Resolve an earlier untitled-to-source + // promotion before recording the destination. If storage is still + // unavailable, detach that source association from the buffer so it can + // be retried independently without being retargeted to the copy. + if (!source.untitled && + _pendingUntitledPromotions.containsKey(source.bufferId)) { + final promotion = _pendingUntitledPromotions[source.bufferId]!; + if (_sameOptionalPath(source.path, promotion.destinationPath)) { + promotionCompleted = await _completePendingUntitledPromotion( + source.bufferId, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + } + if (_pendingUntitledPromotions.containsKey(source.bufferId)) { + _detachPendingUntitledPromotion(source.bufferId); + retainedSourcePromotion = true; + } + } + + // A checkpoint included in the Save As target belongs to the source + // lineage. Newer edits remain suspended until the caller publishes the + // destination path and finishes the path transition. + final pending = _pending[source.bufferId]; + if (pending != null && + pending.revision <= source.revision && + _sameOptionalPath(pending.path, source.path)) { + _checkpointTimers.remove(source.bufferId)?.cancel(); + final pendingCaptured = await _capture( + pending, + LocalHistoryCaptureReason.automaticCheckpoint, + allowDuringPathTransition: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + if (pendingCaptured) { + _acknowledgePending(source.bufferId, pending.revision); + } else { + _retainPending(pending); + } + if (!pendingCaptured && + policy.recordingEnabled && + !policy.excludes(pending.path) && + !source.untitled) { + return false; + } + } + + final sourceDocumentId = _documentIdsByBuffer[source.bufferId]; + if (source.untitled && + !destinationExisted && + sourceDocumentId != null) { + _pendingUntitledPromotions[source.bufferId] = + LocalHistoryPendingIdentityPromotion( + bufferId: source.bufferId, + documentId: sourceDocumentId, + destinationPath: p.normalize(destinationPath), + displayName: p.basename(destinationPath), + ); promotionCompleted = await _completePendingUntitledPromotion( source.bufferId, acceptedHistoryGeneration: historyGeneration, @@ -626,25 +695,16 @@ class LocalHistoryController extends Notifier { )) { return true; } + if (!promotionCompleted) return false; } - if (_pendingUntitledPromotions.containsKey(source.bufferId)) { - _detachPendingUntitledPromotion(source.bufferId); - retainedSourcePromotion = true; - } - } - // A checkpoint included in the Save As target belongs to the source - // lineage. Newer edits remain suspended until the caller publishes the - // destination path and finishes the path transition. - final pending = _pending[source.bufferId]; - if (pending != null && - pending.revision <= source.revision && - _sameOptionalPath(pending.path, source.path)) { - _checkpointTimers.remove(source.bufferId)?.cancel(); - final pendingCaptured = await _capture( - pending, - LocalHistoryCaptureReason.automaticCheckpoint, - allowDuringPathTransition: true, + if (!policy.recordingEnabled || policy.excludes(destination.path)) { + return source.untitled && !destinationExisted && promotionCompleted; + } + final savedCaptured = await _capture( + destination, + LocalHistoryCaptureReason.saved, + ignoreBinding: !source.untitled || destinationExisted, acceptedHistoryGeneration: historyGeneration, acceptedBufferGeneration: bufferGeneration, ); @@ -655,66 +715,29 @@ class LocalHistoryController extends Notifier { )) { return true; } - if (pendingCaptured) { - _acknowledgePending(source.bufferId, pending.revision); + if (savedCaptured) { + _acknowledgePending(destination.bufferId, destination.revision); } else { - _retainPending(pending); - } - if (!pendingCaptured && - policy.recordingEnabled && - !policy.excludes(pending.path) && - !source.untitled) { - return false; + _retainPending(destination); } - } - - final sourceDocumentId = _documentIdsByBuffer[source.bufferId]; - if (source.untitled && !destinationExisted && sourceDocumentId != null) { - _pendingUntitledPromotions[source.bufferId] = - LocalHistoryPendingIdentityPromotion( - bufferId: source.bufferId, - documentId: sourceDocumentId, - destinationPath: p.normalize(destinationPath), - displayName: p.basename(destinationPath), - ); - promotionCompleted = await _completePendingUntitledPromotion( - source.bufferId, - acceptedHistoryGeneration: historyGeneration, - acceptedBufferGeneration: bufferGeneration, - ); - if (!_operationIsCurrent( - source.bufferId, - historyGeneration, - bufferGeneration, - )) { - return true; + return savedCaptured; + } finally { + // Save As has already changed the filesystem and buffer path. A named + // copy (or an overwrite) must detach from its source even if recording + // was cancelled. Do this inside the queue, before later edits can use + // the old binding, without removing a newly established association. + if ((!source.untitled || destinationExisted) && + sourceDocumentIdAtStart != null && + _documentIdsByBuffer[source.bufferId] == sourceDocumentIdAtStart) { + if (_pendingUntitledPromotions[source.bufferId]?.documentId == + sourceDocumentIdAtStart) { + _detachPendingUntitledPromotion(source.bufferId); + retainedSourcePromotion = true; + } else { + _documentIdsByBuffer.remove(source.bufferId); + } } - if (!promotionCompleted) return false; - } - - if (!policy.recordingEnabled || policy.excludes(destination.path)) { - return source.untitled && !destinationExisted && promotionCompleted; - } - final savedCaptured = await _capture( - destination, - LocalHistoryCaptureReason.saved, - ignoreBinding: !source.untitled || destinationExisted, - acceptedHistoryGeneration: historyGeneration, - acceptedBufferGeneration: bufferGeneration, - ); - if (!_operationIsCurrent( - source.bufferId, - historyGeneration, - bufferGeneration, - )) { - return true; } - if (savedCaptured) { - _acknowledgePending(destination.bufferId, destination.revision); - } else { - _retainPending(destination); - } - return savedCaptured; }); if (!_operationIsCurrent( source.bufferId, @@ -750,6 +773,7 @@ class LocalHistoryController extends Notifier { required TextFormatMetadata format, required LocalHistoryCaptureReason reason, bool force = true, + bool requireProtection = false, }) { final snapshot = LocalHistoryBufferSnapshot( bufferId: 'path:${p.normalize(path)}', @@ -768,6 +792,7 @@ class LocalHistoryController extends Notifier { reason, force: force, ignoreBinding: true, + requireProtection: requireProtection, acceptedHistoryGeneration: historyGeneration, acceptedBufferGeneration: bufferGeneration, ), @@ -789,6 +814,7 @@ class LocalHistoryController extends Notifier { format: format, reason: reason, force: true, + requireProtection: true, ); } @@ -1287,6 +1313,7 @@ class LocalHistoryController extends Notifier { bool ignoreBinding = false, bool allowPathChange = false, bool allowDuringPathTransition = false, + bool requireProtection = false, int? acceptedHistoryGeneration, int? acceptedBufferGeneration, }) async { @@ -1299,7 +1326,7 @@ class LocalHistoryController extends Notifier { captureHistoryGeneration, captureBufferGeneration, )) { - return true; + return !requireProtection; } if (reason == LocalHistoryCaptureReason.automaticCheckpoint && !allowDuringPathTransition && @@ -1308,7 +1335,7 @@ class LocalHistoryController extends Notifier { if (pending == null || pending.revision <= snapshot.revision) { _pending[snapshot.bufferId] = snapshot; } - return true; + return !requireProtection; } final promotion = _pendingUntitledPromotions[snapshot.bufferId]; if (promotion != null) { @@ -1328,7 +1355,7 @@ class LocalHistoryController extends Notifier { captureHistoryGeneration, captureBufferGeneration, )) { - return true; + return !requireProtection; } } final currentPolicy = policy; @@ -1339,7 +1366,7 @@ class LocalHistoryController extends Notifier { if (snapshot.untitled && snapshot.text.isEmpty && reason == LocalHistoryCaptureReason.baseline) { - return true; + return !requireProtection; } try { final result = await _store.capture( @@ -1364,7 +1391,7 @@ class LocalHistoryController extends Notifier { captureHistoryGeneration, captureBufferGeneration, )) { - return true; + return !requireProtection; } if (!ignoreBinding) { _documentIdsByBuffer[snapshot.bufferId] = result.document.id; @@ -1384,7 +1411,14 @@ class LocalHistoryController extends Notifier { ); } } - return true; + // Optional recording may settle by cancellation; protection must still + // be valid after storage, snapshot loading, and any scoped search await. + return !requireProtection || + _operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + ); } on Object catch (error) { if (_operationIsCurrent( snapshot.bufferId, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 8d2a3a66..781bbd0f 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -1611,7 +1611,7 @@ class WorkspaceController extends Notifier { } try { final load = await _service.loadTextWithSnapshot(filePath); - if (!await _localHistory.capturePath( + if (!await _localHistory.capturePathBeforeLoss( path: filePath, text: load.text, format: load.format, @@ -1908,11 +1908,26 @@ class WorkspaceController extends Notifier { String bufferId, { bool discard = false, }) async { + final closingWorkspace = state.workspace; _cancelAutoSave(bufferId); - return _enqueueBufferWrite( - bufferId, - () => _closeDocumentBufferNow(bufferId, discard: discard), - ); + return _enqueueBufferWrite(bufferId, () async { + var closed = false; + try { + closed = await _closeDocumentBufferNow(bufferId, discard: discard); + return closed; + } finally { + // A rejected close leaves ordinary dirty work eligible for autosave. + // Finalize inside the write queue so shutdown's drain and final timer + // cancellation retain ownership; never schedule in a new workspace. + if (!closed && + ref.mounted && + closingWorkspace != null && + state.workspace?.id == closingWorkspace.id && + state.workspace?.openedAt == closingWorkspace.openedAt) { + _scheduleAutoSave(bufferId); + } + } + }); } Future _closeDocumentBufferNow( @@ -1939,7 +1954,6 @@ class WorkspaceController extends Notifier { afterProtection.revision != buffer.revision || afterProtection.text != buffer.text || afterProtection.isDirty != buffer.isDirty) { - if (afterProtection != null) _scheduleAutoSave(afterProtection.id); return false; } final historySettled = await _localHistory.flushBuffer(afterProtection); @@ -1950,7 +1964,6 @@ class WorkspaceController extends Notifier { current.revision != afterProtection.revision || current.text != afterProtection.text || current.isDirty != afterProtection.isDirty) { - if (current != null) _scheduleAutoSave(current.id); return false; } if (current.filePath case final path?) { diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 8223e4f6..f2d42265 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -702,6 +702,69 @@ void main() { }, ); + for (final clearAll in [false, true]) { + for (final pathProtection in [false, true]) { + for (final queued in [false, true]) { + test( + '${clearAll ? "Clear All" : "Clear Document"} rejects ' + '${queued ? "queued" : "in-flight"} ' + '${pathProtection ? "path" : "buffer"} replacement protection', + () async { + final memory = MemoryLocalHistoryStore(); + final store = _BlockingNextCaptureStore(memory); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final controller = container.read( + localHistoryControllerProvider.notifier, + ); + await Future.delayed(Duration.zero); + final buffer = _fileBuffer( + 'protected', + '/workspace/protected.md', + 'current source', + ); + await controller.observeOpened(buffer); + final documentId = controller.documentIdForBuffer(buffer.id)!; + final snapshot = LocalHistoryBufferSnapshot.fromBuffer(buffer); + store.blockNextCapture = true; + Future? preceding; + if (queued) { + preceding = pathProtection + ? controller.capturePath( + path: buffer.filePath!, + text: buffer.text, + format: buffer.format, + reason: LocalHistoryCaptureReason.externalChange, + ) + : controller.captureSaved(snapshot); + await store.captureStarted.future; + } + final protection = pathProtection + ? controller.capturePathBeforeLoss( + path: buffer.filePath!, + text: buffer.text, + format: buffer.format, + reason: LocalHistoryCaptureReason.beforeDiscard, + ) + : controller.captureBeforeLoss( + snapshot, + LocalHistoryCaptureReason.beforeReload, + ); + if (!queued) await store.captureStarted.future; + final clear = clearAll + ? controller.clearAll() + : controller.clearDocument(documentId); + store.releaseCapture.complete(); + if (preceding != null) expect(await preceding, isTrue); + expect(await protection, isFalse); + await clear; + expect((await memory.load()).revisions, isEmpty); + }, + ); + } + } + } + test( 'recording off prevents baseline and checkpoints without deleting history', () async { diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index bf9ee897..c75ec051 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -78,6 +78,59 @@ void main() { }, ); + for (final clearAll in [false, true]) { + test( + 'Clear ${clearAll ? "All" : "Document"} cancels queued restore protection', + () async { + final path = p.join(root.path, 'protected.md'); + await File(path).writeAsString('Original disk\n'); + final diskStore = FileLocalHistoryStore( + rootDirectory: () async => Directory(p.join(root.path, 'history')), + ); + final store = _BlockingSaveAsStore(diskStore, path); + final harness = await _harness(store); + await harness.controller.openPath(path); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + final baseline = await diskStore.load(); + final document = baseline.documents.single; + final revision = (await diskStore.readRevision( + baseline.revisions.single.id, + ))!; + harness.controller.updateActiveText('Current work to protect\n'); + final before = harness.state.activeBuffer!; + + // A real explicit save holds the per-buffer history queue. Restore + // must not treat its later, invalidated protection as a stored copy. + final save = harness.controller.saveActive(); + await store.started.future; + final restore = harness.controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + ); + final clear = clearAll + ? history.clearAll() + : history.clearDocument(document.id); + store.release.complete(); + expect(await save, isTrue); + expect(await restore, isFalse); + await clear; + + final current = harness.state.activeBuffer!; + expect(current.text, before.text); + expect(current.revision, before.revision); + expect(current.editorState.selection, before.editorState.selection); + expect( + current.editorState.undoState, + same(before.editorState.undoState), + ); + expect(await File(path).readAsString(), before.text); + expect((await diskStore.load()).revisions, isEmpty); + }, + ); + } + test('region restore is exact, undoable, and rejects stale ranges', () async { final path = p.join(root.path, 'regions.md'); await File(path).writeAsString('alpha\nnew phrase\nomega\n'); @@ -689,6 +742,121 @@ void main() { expect(harness.state.preview, same(previewAfterEdit)); }); + test( + 'cancelled named Save As detaches source history and reopens separately', + () async { + final sourcePath = p.join(root.path, 'A.md'); + final destinationPath = p.join(root.path, 'B.md'); + await File(sourcePath).writeAsString('Original A\n'); + final historyRoot = Directory(p.join(root.path, 'history')); + final diskStore = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final store = _BlockingSaveAsStore(diskStore, destinationPath); + final timers = <_FakeTimer>[]; + final harness = await _harness( + store, + timerFactory: (delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }, + ); + final settings = harness.container.read( + appSettingsControllerProvider.notifier, + ); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + await harness.controller.openPath(sourcePath); + final sourceId = (await diskStore.load()).documents.single.id; + harness.controller.updateActiveText('Save As B\n'); + final save = harness.controller.saveActiveAs(destinationPath); + await store.started.future; + expect(harness.state.activeBuffer!.filePath, destinationPath); + await settings.setLocalHistoryRecordingEnabled(false); + store.release.complete(); + expect(await save, isTrue); + expect( + history.documentIdForBuffer(harness.state.activeBuffer!.id), + isNot(sourceId), + ); + expect(await File(sourcePath).readAsString(), 'Original A\n'); + expect(await File(destinationPath).readAsString(), 'Save As B\n'); + + await settings.setLocalHistoryRecordingEnabled(true); + harness.controller.updateActiveText('Only B after cancellation\n'); + await _waitFor(() => timers.any((timer) => timer.isActive)); + for (final timer in List<_FakeTimer>.of(timers)) { + timer.fire(); + } + await _waitFor( + () => harness.container + .read(localHistoryControllerProvider) + .snapshot + .revisions + .any( + (revision) => + revision.reason == + LocalHistoryCaptureReason.automaticCheckpoint && + revision.historicalPath == destinationPath, + ), + ); + final snapshot = await diskStore.load(); + final destination = snapshot.documents.singleWhere( + (document) => document.currentPath == destinationPath, + ); + expect(destination.id, isNot(sourceId)); + expect( + await _revisionSources(diskStore, snapshot.revisionsFor(sourceId)), + isNot(contains('Only B after cancellation\n')), + ); + expect( + await _revisionSources( + diskStore, + snapshot.revisionsFor(destination.id), + ), + contains('Only B after cancellation\n'), + ); + expect( + history.documentIdForBuffer(harness.state.activeBuffer!.id), + destination.id, + ); + + final reopenedStore = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final reopened = await _harness(reopenedStore); + final reopenedHistory = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + await reopened.controller.openPath(sourcePath); + await _waitFor( + () => + reopenedHistory.documentIdForBuffer( + reopened.state.activeBuffer!.id, + ) != + null, + ); + expect( + reopenedHistory.documentIdForBuffer(reopened.state.activeBuffer!.id), + sourceId, + ); + expect(await reopened.controller.openActiveFile(destinationPath), isTrue); + await _waitFor( + () => + reopenedHistory.documentIdForBuffer( + reopened.state.activeBuffer!.id, + ) != + null, + ); + expect( + reopenedHistory.documentIdForBuffer(reopened.state.activeBuffer!.id), + destination.id, + ); + }, + ); + test( 'Save As keeps edits made during destination capture out of source history', () async { diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index a8c1b820..51cf4222 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -2197,6 +2197,103 @@ void main() { }, ); + for (final outcome in [ + 'aborted', + 'closed', + 'disabled', + 'shutdown', + 'replaced', + ]) { + testWidgets('discard close autosave disposition: $outcome', (tester) async { + final service = _ClosingAutosaveWorkspaceService(); + final creating = _createControllerHarness(service: service); + await tester.pump(Duration.zero); + final harness = await creating; + await harness.settingsController.setValidateOnEdit(false); + await harness.settingsController.setDocumentViewMode( + DocumentViewModePreference.source, + ); + if (outcome == 'disabled') { + await harness.settingsController.setAutoSave(false); + } + final controller = harness.controller; + await controller.openPath(service.path); + final aId = controller.state.activeBuffer!.id; + await controller.openActiveFile(service.bPath); + await controller.activateDocumentBuffer(aId); + controller.updateActiveSourceText('# Unsaved A\n'); + await tester.pump(const Duration(milliseconds: 500)); + final before = controller.state.activeBuffer!; + expect(before.isDirty, isTrue); + expect(service.savedTexts, isEmpty); + + service.pauseActivation = true; + final close = controller.closeDocumentBuffer(aId, discard: true); + await tester.pump(); + expect(service.activationStarted.isCompleted, isTrue); + if (outcome != 'closed') { + controller.updateActiveEditorState( + before.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 4), + ), + ); + } + var shutdownCompleted = false; + final shutdown = outcome == 'shutdown' + ? controller._notifier.markCleanShutdown().then((_) { + shutdownCompleted = true; + }) + : null; + if (outcome == 'replaced') { + await controller.openPath('/tmp/busymark-close-replacement.md'); + } + service.releaseActivation.complete(); + await tester.pump(); + expect(await close, outcome == 'closed'); + if (shutdown != null) { + for (var turn = 0; turn < 20 && !shutdownCompleted; turn++) { + await tester.pump(); + } + expect(shutdownCompleted, isTrue); + await shutdown; + } + + if (outcome != 'closed' && outcome != 'replaced') { + final surviving = controller.state.activeBuffer!; + expect(surviving.id, aId); + expect(surviving.text, before.text); + expect(surviving.isDirty, isTrue); + expect(surviving.editorState.selection.baseOffset, 4); + expect( + surviving.editorState.undoState, + same(before.editorState.undoState), + ); + } + // No more edits: only the normal 1.5-second autosave clock may write A. + await tester.pump(const Duration(milliseconds: 1500)); + if (outcome == 'aborted') { + expect(service.savedTexts, ['# Unsaved A\n']); + expect(controller.state.activeBuffer!.isDirty, isFalse); + } else { + expect(service.savedTexts, isEmpty); + } + if (outcome == 'closed') { + expect( + controller.state.documentBuffers.any((buffer) => buffer.id == aId), + isFalse, + ); + } + if (outcome == 'replaced') { + expect( + controller.state.workspace!.id, + '/tmp/busymark-close-replacement.md', + ); + expect(controller.state.activeText, '# Initial\n'); + } + harness._container.dispose(); + }); + } + test( 'tab activation does not resurrect another tab closed while waiting', () async { @@ -3229,6 +3326,23 @@ class _AutosaveWorkspaceService extends WorkspaceService { } } +class _ClosingAutosaveWorkspaceService extends _AutosaveWorkspaceService { + final bPath = '/tmp/busymark-close-autosave-b.md'; + var pauseActivation = false; + final activationStarted = Completer(); + final releaseActivation = Completer(); + + @override + Future reparseActive(Workspace workspace, String source) async { + if (pauseActivation && workspace.activeFilePath == bPath) { + pauseActivation = false; + activationStarted.complete(); + await releaseActivation.future; + } + return super.reparseActive(workspace, source); + } +} + class _FailingRefreshAutosaveWorkspaceService extends _AutosaveWorkspaceService { var failRefresh = false; From 08a219f03711ef4ff574c091f78eaca56863e9ae Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 11 Sep 2026 21:04:35 -0700 Subject: [PATCH 23/27] Enhance untitled document handling in Local History with identity transition support --- docs/history-architecture.md | 6 + .../local_history_controller.dart | 18 ++ test/src/local_history_workspace_test.dart | 254 +++++++++++++++++- 3 files changed, 277 insertions(+), 1 deletion(-) diff --git a/docs/history-architecture.md b/docs/history-architecture.md index d85ac06e..ead47452 100644 --- a/docs/history-architecture.md +++ b/docs/history-architecture.md @@ -85,6 +85,12 @@ Once a named-file Save As has changed the buffer path, detaching its source binding is unconditional finalization, not optional recording. Disabling recording or clearing history during the destination capture cannot leave the destination buffer bound to the source file's history. +An untitled first save likewise retains a pending identity promotion if +recording is cancelled before promotion registration. The existing retry and +session-association mechanisms carry the original history ID to the saved path; +this does not restart disabled content recording. Registration requires the +original binding to survive, so explicit Clear cannot recreate that association +or resurrect its deleted revisions. ## Store and retention diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index ef9c30d0..5c5d8b73 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -736,6 +736,24 @@ class LocalHistoryController extends Notifier { } else { _documentIdsByBuffer.remove(source.bufferId); } + } else if (source.untitled && + !destinationExisted && + !promotionCompleted && + sourceDocumentIdAtStart != null && + _documentIdsByBuffer[source.bufferId] == sourceDocumentIdAtStart) { + // First save commits an identity transition even if optional source + // capture was cancelled before promotion registration. Keep it for + // the existing retry/session path. Unlike disabling recording, Clear + // removes the binding, so it must never reinstall this association. + _pendingUntitledPromotions.putIfAbsent( + source.bufferId, + () => LocalHistoryPendingIdentityPromotion( + bufferId: source.bufferId, + documentId: sourceDocumentIdAtStart, + destinationPath: p.normalize(destinationPath), + displayName: p.basename(destinationPath), + ), + ); } } }); diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index c75ec051..0ff20440 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -857,6 +857,208 @@ void main() { }, ); + for (final cancellation in ['disable', 'clear document', 'clear all']) { + for (final failCapture in [false, true]) { + test('first-save pre-promotion cancellation: $cancellation, ' + '${failCapture ? "failed" : "completed"} pending capture', () async { + final historyRoot = Directory(p.join(root.path, 'history')); + final diskStore = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final store = _BlockingPrePromotionCaptureStore( + diskStore, + failCapture: failCapture, + ); + var now = DateTime.utc(2026, 1, 1); + final timers = <_FakeTimer>[]; + final sessions = MemoryDocumentSessionStore(); + final harness = await _harness( + store, + sessionStore: sessions, + clock: () => now, + timerFactory: (delay, callback) { + final timer = _FakeTimer(delay, callback); + timers.add(timer); + return timer; + }, + ); + final history = harness.container.read( + localHistoryControllerProvider.notifier, + ); + final settings = harness.container.read( + appSettingsControllerProvider.notifier, + ); + await harness.controller.createMarkdownFile(); + final bufferId = harness.state.activeBuffer!.id; + + // Establish history through an editor mutation and its real-policy + // checkpoint, without manually observing or storing the edit. + harness.controller.updateActiveText('Original retained draft\n'); + await _waitFor(() => timers.any((timer) => timer.isActive)); + final firstTimer = timers.singleWhere((timer) => timer.isActive); + expect(firstTimer.duration, const Duration(seconds: 60)); + now = now.add(firstTimer.duration); + firstTimer.fire(); + await _waitFor( + () => + history.documentIdForBuffer(bufferId) != null && + history.pendingSnapshotForBuffer(bufferId) == null, + ); + final original = (await diskStore.load()).documents.single; + final originalRevisionIds = (await diskStore.load()).revisions + .map((revision) => revision.id) + .toSet(); + expect(original.currentPath, isNull); + + harness.controller.updateActiveText('Pending at first save\n'); + await _waitFor( + () => + history.pendingSnapshotForBuffer(bufferId)?.text == + 'Pending at first save\n', + ); + store.blockNextUntitledCheckpoint = true; + final destination = p.join(root.path, 'Guide.md'); + final save = harness.controller.saveActiveAs(destination); + final blocked = await store.started.future; + expect(harness.state.activeBuffer!.filePath, destination); + expect( + await File(destination).readAsString(), + 'Pending at first save\n', + ); + expect(blocked.path, isNull); + expect(blocked.documentId, original.id); + expect(blocked.source, 'Pending at first save\n'); + expect(blocked.reason, LocalHistoryCaptureReason.automaticCheckpoint); + expect(history.pendingIdentityPromotions, isEmpty); + expect(store.promotionAttempts, 0); + + Future? clear; + if (cancellation == 'disable') { + await settings.setLocalHistoryRecordingEnabled(false); + } else { + clear = cancellation == 'clear all' + ? history.clearAll() + : history.clearDocument(original.id); + } + store.release.complete(); + expect(await save, isTrue); + if (clear != null) await clear; + expect(harness.state.activeBuffer!.isDirty, isFalse); + expect(harness.state.activeText, 'Pending at first save\n'); + expect(history.pendingSnapshotForBuffer(bufferId), isNull); + expect(timers.where((timer) => timer.isActive), isEmpty); + await harness.controller.flushPersistence(); + + if (cancellation == 'disable') { + expect(history.documentIdForBuffer(bufferId), original.id); + expect(history.pendingIdentityPromotions, hasLength(1)); + final promotion = history.pendingIdentityPromotions.single; + expect(promotion.bufferId, bufferId); + expect(promotion.documentId, original.id); + expect(promotion.destinationPath, destination); + expect( + sessions.value!.pendingLocalHistoryAssociations.single.documentId, + original.id, + ); + final retained = await diskStore.load(); + expect(retained.documents.single.id, original.id); + expect(retained.documents.single.currentPath, isNull); + expect( + retained.revisions.map((revision) => revision.id), + containsAll(originalRevisionIds), + ); + await settings.setLocalHistoryRecordingEnabled(true); + } else { + expect(history.documentIdForBuffer(bufferId), isNull); + expect(history.pendingIdentityPromotions, isEmpty); + expect(sessions.value!.pendingLocalHistoryAssociations, isEmpty); + now = now.add(const Duration(seconds: 60)); + for (final timer in List<_FakeTimer>.of(timers)) { + timer.fire(); + } + final cleared = await diskStore.load(); + expect(cleared.documents, isEmpty); + expect(cleared.revisions, isEmpty); + } + + // Only a new editor mutation and checkpoint may resume recording. + harness.controller.updateActiveText( + 'Named checkpoint after cancellation\n', + ); + await _waitFor(() => timers.any((timer) => timer.isActive)); + final namedTimer = timers.singleWhere((timer) => timer.isActive); + expect(namedTimer.duration, const Duration(seconds: 60)); + now = now.add(namedTimer.duration); + namedTimer.fire(); + await _waitFor( + () => history.pendingSnapshotForBuffer(bufferId) == null, + ); + final captured = await diskStore.load(); + final document = captured.documents.single; + expect(document.currentPath, destination); + expect(document.untitled, isFalse); + expect(history.documentIdForBuffer(bufferId), document.id); + expect(history.pendingIdentityPromotions, isEmpty); + final sources = await _revisionSources(diskStore, captured.revisions); + expect(sources, contains('Named checkpoint after cancellation\n')); + if (cancellation == 'disable') { + expect(document.id, original.id); + expect(store.promotionAttempts, 1); + expect( + captured.revisions.map((revision) => revision.id), + containsAll(originalRevisionIds), + ); + expect(sources, contains('Original retained draft\n')); + } else { + expect(document.id, isNot(original.id)); + expect(store.promotionAttempts, 0); + expect(sources, isNot(contains('Original retained draft\n'))); + } + expect( + captured.revisions.every( + (revision) => revision.documentId == document.id, + ), + isTrue, + ); + expect( + harness.container + .read(localHistoryControllerProvider) + .snapshot + .documents + .single + .currentPath, + destination, + ); + + final reopenedStore = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + ); + final reopened = await _harness(reopenedStore, clock: () => now); + final reopenedHistory = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + await reopened.controller.openPath(destination); + await _waitFor( + () => + reopenedHistory.documentIdForBuffer( + reopened.state.activeBuffer!.id, + ) != + null, + ); + expect( + reopenedHistory.documentIdForBuffer(reopened.state.activeBuffer!.id), + document.id, + ); + final reopenedSnapshot = await reopenedStore.load(); + expect(reopenedSnapshot.documents.single.id, document.id); + expect( + reopenedSnapshot.revisions.map((revision) => revision.id), + containsAll(captured.revisions.map((revision) => revision.id)), + ); + }); + } + } + test( 'Save As keeps edits made during destination capture out of source history', () async { @@ -1488,6 +1690,7 @@ Future<_Harness> _harness( LocalHistoryStore store, { WorkspaceService service = const WorkspaceService(), WorkspaceFileMonitor? fileMonitor, + LocalHistoryClock? clock, LocalHistoryTimerFactory? timerFactory, LocalHistoryComparisonComputer? comparisonComputer, DocumentSessionStore? sessionStore, @@ -1498,7 +1701,7 @@ Future<_Harness> _harness( localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), localHistoryStoreProvider.overrideWithValue(store), localHistoryClockProvider.overrideWithValue( - () => DateTime.utc(2026, 1, 1, 0, 1), + clock ?? () => DateTime.utc(2026, 1, 1, 0, 1), ), workspaceServiceProvider.overrideWithValue(service), if (sessionStore != null) @@ -1769,6 +1972,55 @@ class _BlockingRemapStore extends _FailingProtectiveStore { } } +class _BlockingPrePromotionCaptureStore extends _FailingProtectiveStore { + _BlockingPrePromotionCaptureStore( + super.delegate, { + required this.failCapture, + }); + + final bool failCapture; + var blockNextUntitledCheckpoint = false; + var promotionAttempts = 0; + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (blockNextUntitledCheckpoint && + request.path == null && + request.reason == LocalHistoryCaptureReason.automaticCheckpoint) { + blockNextUntitledCheckpoint = false; + started.complete(request); + await release.future; + if (failCapture) { + throw const LocalHistoryStorageException( + 'Injected pre-promotion capture failure', + ); + } + } + return delegate.capture(request, policy); + } + + @override + Future promoteUntitledDocument({ + required String documentId, + required String destinationPath, + required String displayName, + required DateTime updatedAt, + }) { + promotionAttempts++; + return super.promoteUntitledDocument( + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + updatedAt: updatedAt, + ); + } +} + class _BlockingSaveAsStore extends _FailingProtectiveStore { _BlockingSaveAsStore(super.delegate, this.destinationPath); From a57c03280a082a02d123677a7c39668929c498b3 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 14:27:58 -0700 Subject: [PATCH 24/27] fix search: make source and workspace search safe and consistent: - sync sidebar selection with replacement and retain position across result windows - run workspace matching and replacement planning in cancellable isolates - use Unicode regex semantics and reject surrogate-splitting replacement ranges - preserve exact queries and native submitted search text - search all open buffers, preserve queries across files, and replay cross-file targets - align search/replacement file scope and invalidate inactive-buffer edits - report invalid regexes, unsupported zero-length matches, skipped files, and partial results - fix exact-limit truncation and add search/replacement regression coverage --- lib/l10n/app_ar.arb | 4 + lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 4 + lib/l10n/app_et.arb | 4 + lib/l10n/app_fa.arb | 4 + lib/l10n/app_fr.arb | 4 + lib/l10n/app_hi.arb | 4 + lib/l10n/app_id.arb | 4 + lib/l10n/app_it.arb | 4 + lib/l10n/app_ja.arb | 4 + lib/l10n/app_ko.arb | 4 + lib/l10n/app_nb.arb | 4 + lib/l10n/app_nl.arb | 4 + lib/l10n/app_pl.arb | 4 + lib/l10n/app_pt.arb | 4 + lib/l10n/app_pt_BR.arb | 4 + lib/l10n/app_ru.arb | 4 + lib/l10n/app_tr.arb | 4 + lib/l10n/app_uk.arb | 4 + lib/l10n/app_vi.arb | 4 + lib/l10n/app_zh.arb | 4 + lib/l10n/app_zh_CN.arb | 4 + lib/l10n/generated/app_localizations.dart | 24 + lib/l10n/generated/app_localizations_ar.dart | 15 + lib/l10n/generated/app_localizations_de.dart | 15 + lib/l10n/generated/app_localizations_en.dart | 15 + lib/l10n/generated/app_localizations_es.dart | 15 + lib/l10n/generated/app_localizations_et.dart | 15 + lib/l10n/generated/app_localizations_fa.dart | 15 + lib/l10n/generated/app_localizations_fr.dart | 15 + lib/l10n/generated/app_localizations_hi.dart | 15 + lib/l10n/generated/app_localizations_id.dart | 15 + lib/l10n/generated/app_localizations_it.dart | 15 + lib/l10n/generated/app_localizations_ja.dart | 15 + lib/l10n/generated/app_localizations_ko.dart | 14 + lib/l10n/generated/app_localizations_nb.dart | 15 + lib/l10n/generated/app_localizations_nl.dart | 15 + lib/l10n/generated/app_localizations_pl.dart | 15 + lib/l10n/generated/app_localizations_pt.dart | 30 + lib/l10n/generated/app_localizations_ru.dart | 15 + lib/l10n/generated/app_localizations_tr.dart | 15 + lib/l10n/generated/app_localizations_uk.dart | 15 + lib/l10n/generated/app_localizations_vi.dart | 15 + lib/l10n/generated/app_localizations_zh.dart | 26 + lib/src/editor/source/source_editor.dart | 99 +++- lib/src/editor/source/source_search.dart | 47 +- lib/src/search/search_replace_service.dart | 286 ++++++---- lib/src/search/workspace_search_scope.dart | 16 + .../presentation/workspace_screen.dart | 511 +++++++++++------ test/src/app_smoke_test.dart | 526 +++++++++++++++++- test/src/search_replace_service_test.dart | 179 ++++++ test/src/source_editor_widget_test.dart | 148 +++++ test/src/source_search_test.dart | 54 ++ 54 files changed, 2034 insertions(+), 288 deletions(-) create mode 100644 lib/src/search/workspace_search_scope.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b240e129..2c6ff704 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "لا تُدعم مطابقات التعبير النمطي ذات الطول الصفري (مثل ^ أو $ أو الاستباق الذي يطابق موضعًا فقط).", + "workspaceSearchIncomplete": "تظهر بعض النتائج فقط. اعرض المزيد أو ضيّق نطاق البحث.", + "workspaceSearchShowMore": "عرض المزيد من النتائج", + "workspaceSearchSkippedFiles": "البحث غير مكتمل. تجاوز حجم هذه الملفات 1 MiB أو تعذّرت قراءتها:", "@@locale": "ar", "reportIssue": "الإبلاغ عن مشكلة", "feedbackCategory": "الفئة", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a76ae15b..f4eac6d5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Regex-Treffer ohne Zeichen werden nicht unterstützt (z. B. ^, $ oder Lookaheads, die nur eine Position treffen).", + "workspaceSearchIncomplete": "Es wird nur ein Teil der Ergebnisse angezeigt. Zeigen Sie mehr an oder grenzen Sie die Suche ein.", + "workspaceSearchShowMore": "Weitere Ergebnisse anzeigen", + "workspaceSearchSkippedFiles": "Die Suche ist unvollständig. Diese Dateien sind größer als 1 MiB oder konnten nicht gelesen werden:", "@@locale": "de", "reportIssue": "Problem melden", "feedbackCategory": "Kategorie", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f4005d0..c82c96a9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -995,6 +995,10 @@ "saveAs": "Save As", "@saveAs": {"description": "Action that saves the current document to another path."}, "sourceSearchInvalidRegex": "Invalid regular expression", + "sourceSearchZeroLengthUnsupported": "Zero-length regex matches are not supported (for example ^, $, or lookaheads that match only a position).", + "workspaceSearchIncomplete": "Only some results are shown. Show more or narrow your search.", + "workspaceSearchShowMore": "Show more results", + "workspaceSearchSkippedFiles": "Search is incomplete. These files exceeded 1 MiB or could not be read:", "@sourceSearchInvalidRegex": {"description": "Source search status shown when the regular expression is invalid."}, "sourceLargeFileFeaturesPaused": "Large file: highlighting and folding are paused", "@sourceLargeFileFeaturesPaused": {"description": "Status banner shown when source highlighting and folding are disabled for a large file."}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c00ebc26..71ede500 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "No se admiten coincidencias de longitud cero (por ejemplo, ^, $ o anticipaciones que solo coinciden con una posición).", + "workspaceSearchIncomplete": "Solo se muestran algunos resultados. Muestre más o limite la búsqueda.", + "workspaceSearchShowMore": "Mostrar más resultados", + "workspaceSearchSkippedFiles": "La búsqueda está incompleta. Estos archivos superan 1 MiB o no se pudieron leer:", "@@locale": "es", "reportIssue": "Informar de un problema", "feedbackCategory": "Categoría", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 93cd8a7a..655b304e 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Nullpikkusega regulaaravaldiste vasteid ei toetata (näiteks ^, $ või ainult asukohta sobitavad ettevaated).", + "workspaceSearchIncomplete": "Kuvatakse ainult osa tulemustest. Kuva rohkem või kitsenda otsingut.", + "workspaceSearchShowMore": "Kuva rohkem tulemusi", + "workspaceSearchSkippedFiles": "Otsing pole täielik. Need failid on suuremad kui 1 MiB või neid ei saanud lugeda:", "@@locale": "et", "reportIssue": "Teata probleemist", "@reportIssue": { diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 11de5e7f..1edfe04e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "تطبیق‌های با طول صفر در عبارت منظم پشتیبانی نمی‌شوند (مانند ^، $ یا نگاه‌به‌جلو که فقط با یک موقعیت تطبیق دارد).", + "workspaceSearchIncomplete": "فقط بخشی از نتایج نمایش داده می‌شود. نتایج بیشتری نمایش دهید یا جستجو را محدود کنید.", + "workspaceSearchShowMore": "نمایش نتایج بیشتر", + "workspaceSearchSkippedFiles": "جستجو کامل نیست. حجم این فایل‌ها بیش از 1 MiB است یا خواندن آن‌ها ممکن نبود:", "@@locale": "fa", "reportIssue": "گزارش مشکل", "feedbackCategory": "دسته‌بندی", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f593a2db..7fbda41e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Les correspondances de longueur nulle ne sont pas prises en charge (par exemple ^, $ ou les assertions qui ne correspondent qu’à une position).", + "workspaceSearchIncomplete": "Seule une partie des résultats est affichée. Affichez-en davantage ou affinez la recherche.", + "workspaceSearchShowMore": "Afficher plus de résultats", + "workspaceSearchSkippedFiles": "La recherche est incomplète. Ces fichiers dépassent 1 MiB ou n’ont pas pu être lus :", "@@locale": "fr", "reportIssue": "Signaler un problème", "feedbackCategory": "Catégorie", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 2cd1cee9..8ab7c306 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "शून्य लंबाई वाले रेगेक्स मिलान समर्थित नहीं हैं (जैसे ^, $ या केवल स्थिति से मेल खाने वाले लुकअहेड)।", + "workspaceSearchIncomplete": "केवल कुछ परिणाम दिखाए गए हैं। अधिक परिणाम देखें या खोज को सीमित करें।", + "workspaceSearchShowMore": "अधिक परिणाम दिखाएँ", + "workspaceSearchSkippedFiles": "खोज अधूरी है। ये फ़ाइलें 1 MiB से बड़ी हैं या पढ़ी नहीं जा सकीं:", "@@locale": "hi", "reportIssue": "समस्या की रिपोर्ट करें", "feedbackCategory": "श्रेणी", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c2d4ea93..b2535bdc 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Kecocokan regex dengan panjang nol tidak didukung (misalnya ^, $, atau lookahead yang hanya cocok dengan posisi).", + "workspaceSearchIncomplete": "Hanya sebagian hasil yang ditampilkan. Tampilkan lebih banyak atau persempit pencarian.", + "workspaceSearchShowMore": "Tampilkan lebih banyak hasil", + "workspaceSearchSkippedFiles": "Pencarian belum lengkap. Berkas ini melebihi 1 MiB atau tidak dapat dibaca:", "@@locale": "id", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ac829d20..e4cab1bd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Le corrispondenze regex di lunghezza zero non sono supportate (ad esempio ^, $ o lookahead che corrispondono solo a una posizione).", + "workspaceSearchIncomplete": "È mostrata solo una parte dei risultati. Mostrane altri o restringi la ricerca.", + "workspaceSearchShowMore": "Mostra altri risultati", + "workspaceSearchSkippedFiles": "La ricerca è incompleta. Questi file superano 1 MiB o non è stato possibile leggerli:", "@@locale": "it", "reportIssue": "Segnala un problema", "feedbackCategory": "Categoria", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b6e06e40..c0906e97 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "長さがゼロの正規表現の一致には対応していません(^、$、位置のみに一致する先読みなど)。", + "workspaceSearchIncomplete": "一部の結果のみ表示しています。さらに表示するか、検索を絞り込んでください。", + "workspaceSearchShowMore": "さらに結果を表示", + "workspaceSearchSkippedFiles": "検索は未完了です。次のファイルは 1 MiB を超えているか、読み取れませんでした:", "@@locale": "ja", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0264e543..2d097145 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "길이가 0인 정규식 일치는 지원하지 않습니다(예: ^, $, 위치에만 일치하는 전방 탐색).", + "workspaceSearchIncomplete": "일부 결과만 표시됩니다. 더 표시하거나 검색 범위를 좁히세요.", + "workspaceSearchShowMore": "결과 더 보기", + "workspaceSearchSkippedFiles": "검색이 완료되지 않았습니다. 다음 파일은 1 MiB를 초과하거나 읽을 수 없습니다:", "@@locale": "ko", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index c7eff8ee..2a40bf80 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Regex-treff med null lengde støttes ikke (for eksempel ^, $ eller fremoversøk som bare samsvarer med en posisjon).", + "workspaceSearchIncomplete": "Bare noen av resultatene vises. Vis flere eller avgrens søket.", + "workspaceSearchShowMore": "Vis flere resultater", + "workspaceSearchSkippedFiles": "Søket er ufullstendig. Disse filene er større enn 1 MiB eller kunne ikke leses:", "@@locale": "nb", "reportIssue": "Rapporter et problem", "feedbackCategory": "Kategori", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index e6deed72..ddd73fe1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Regex-overeenkomsten met lengte nul worden niet ondersteund (zoals ^, $ of lookaheads die alleen een positie vinden).", + "workspaceSearchIncomplete": "Slechts een deel van de resultaten wordt getoond. Toon meer of verfijn de zoekopdracht.", + "workspaceSearchShowMore": "Meer resultaten tonen", + "workspaceSearchSkippedFiles": "De zoekopdracht is onvolledig. Deze bestanden zijn groter dan 1 MiB of konden niet worden gelezen:", "@@locale": "nl", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 957870c5..bb55ee60 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Dopasowania wyrażeń regularnych o zerowej długości nie są obsługiwane (np. ^, $ lub asercje dopasowujące tylko pozycję).", + "workspaceSearchIncomplete": "Wyświetlono tylko część wyników. Pokaż więcej lub zawęź wyszukiwanie.", + "workspaceSearchShowMore": "Pokaż więcej wyników", + "workspaceSearchSkippedFiles": "Wyszukiwanie jest niepełne. Te pliki przekraczają 1 MiB lub nie można ich odczytać:", "@@locale": "pl", "reportIssue": "Zgłoś problem", "feedbackCategory": "Kategoria", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 2e3cf6dd..9287c2a0 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Não são suportadas correspondências regex de comprimento zero (por exemplo, ^, $ ou antecipações que correspondem apenas a uma posição).", + "workspaceSearchIncomplete": "Só são apresentados alguns resultados. Mostre mais ou restrinja a pesquisa.", + "workspaceSearchShowMore": "Mostrar mais resultados", + "workspaceSearchSkippedFiles": "A pesquisa está incompleta. Estes ficheiros excedem 1 MiB ou não foi possível lê-los:", "@@locale": "pt", "reportIssue": "Comunicar um problema", "feedbackCategory": "Categoria", diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index a967cf9c..7edb8da6 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Não há suporte para correspondências regex de comprimento zero (por exemplo, ^, $ ou antecipações que correspondem apenas a uma posição).", + "workspaceSearchIncomplete": "Apenas alguns resultados são exibidos. Mostre mais ou restrinja a pesquisa.", + "workspaceSearchShowMore": "Mostrar mais resultados", + "workspaceSearchSkippedFiles": "A pesquisa está incompleta. Estes arquivos excedem 1 MiB ou não puderam ser lidos:", "@@locale": "pt_BR", "reportIssue": "Comunicar um problema", "feedbackCategory": "Categoria", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d2b54836..77a33d5b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Совпадения регулярных выражений нулевой длины не поддерживаются (например, ^, $ или проверки вперёд, совпадающие только с позицией).", + "workspaceSearchIncomplete": "Показана только часть результатов. Покажите больше или уточните поиск.", + "workspaceSearchShowMore": "Показать больше результатов", + "workspaceSearchSkippedFiles": "Поиск неполный. Эти файлы превышают 1 MiB или не могут быть прочитаны:", "@@locale": "ru", "reportIssue": "Сообщить о проблеме", "feedbackCategory": "Категория", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 4c327eee..36e0713a 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Sıfır uzunluklu düzenli ifade eşleşmeleri desteklenmez (örneğin ^, $ veya yalnızca bir konumla eşleşen ileri bakışlar).", + "workspaceSearchIncomplete": "Sonuçların yalnızca bir kısmı gösteriliyor. Daha fazlasını gösterin veya aramayı daraltın.", + "workspaceSearchShowMore": "Daha fazla sonuç göster", + "workspaceSearchSkippedFiles": "Arama eksik. Bu dosyalar 1 MiB boyutunu aşıyor veya okunamadı:", "@@locale": "tr", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index d7751b94..3e627dec 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Збіги регулярних виразів нульової довжини не підтримуються (наприклад, ^, $ або перевірки вперед, що збігаються лише з позицією).", + "workspaceSearchIncomplete": "Показано лише частину результатів. Покажіть більше або звузьте пошук.", + "workspaceSearchShowMore": "Показати більше результатів", + "workspaceSearchSkippedFiles": "Пошук неповний. Ці файли перевищують 1 MiB або їх не вдалося прочитати:", "@@locale": "uk", "reportIssue": "Повідомити про проблему", "feedbackCategory": "Категорія", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index f6e92260..47ccd5d4 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "Không hỗ trợ kết quả khớp regex có độ dài bằng 0 (ví dụ ^, $ hoặc lookahead chỉ khớp với một vị trí).", + "workspaceSearchIncomplete": "Chỉ hiển thị một phần kết quả. Hiển thị thêm hoặc thu hẹp tìm kiếm.", + "workspaceSearchShowMore": "Hiển thị thêm kết quả", + "workspaceSearchSkippedFiles": "Tìm kiếm chưa đầy đủ. Các tệp này vượt quá 1 MiB hoặc không thể đọc được:", "@@locale": "vi", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b44aab90..5929bcf8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。", + "workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。", + "workspaceSearchShowMore": "显示更多结果", + "workspaceSearchSkippedFiles": "搜索不完整。以下文件超过 1 MiB 或无法读取:", "@@locale": "zh", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 3e701ce1..760dfa9b 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -1,4 +1,8 @@ { + "sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。", + "workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。", + "workspaceSearchShowMore": "显示更多结果", + "workspaceSearchSkippedFiles": "搜索不完整。以下文件超过 1 MiB 或无法读取:", "@@locale": "zh_CN", "appTitle": "BusyMark", "@appTitle": { diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 110be932..74ee4668 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2863,6 +2863,30 @@ abstract class AppLocalizations { /// **'Invalid regular expression'** String get sourceSearchInvalidRegex; + /// No description provided for @sourceSearchZeroLengthUnsupported. + /// + /// In en, this message translates to: + /// **'Zero-length regex matches are not supported (for example ^, \$, or lookaheads that match only a position).'** + String get sourceSearchZeroLengthUnsupported; + + /// No description provided for @workspaceSearchIncomplete. + /// + /// In en, this message translates to: + /// **'Only some results are shown. Show more or narrow your search.'** + String get workspaceSearchIncomplete; + + /// No description provided for @workspaceSearchShowMore. + /// + /// In en, this message translates to: + /// **'Show more results'** + String get workspaceSearchShowMore; + + /// No description provided for @workspaceSearchSkippedFiles. + /// + /// In en, this message translates to: + /// **'Search is incomplete. These files exceeded 1 MiB or could not be read:'** + String get workspaceSearchSkippedFiles; + /// Status banner shown when source highlighting and folding are disabled for a large file. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index bfe28837..c3a7b289 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1566,6 +1566,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'تعبير نمطي غير صالح'; + @override + String get sourceSearchZeroLengthUnsupported => + 'لا تُدعم مطابقات التعبير النمطي ذات الطول الصفري (مثل ^ أو \$ أو الاستباق الذي يطابق موضعًا فقط).'; + + @override + String get workspaceSearchIncomplete => + 'تظهر بعض النتائج فقط. اعرض المزيد أو ضيّق نطاق البحث.'; + + @override + String get workspaceSearchShowMore => 'عرض المزيد من النتائج'; + + @override + String get workspaceSearchSkippedFiles => + 'البحث غير مكتمل. تجاوز حجم هذه الملفات 1 MiB أو تعذّرت قراءتها:'; + @override String get sourceLargeFileFeaturesPaused => 'ملف كبير: تم إيقاف التمييز والطي مؤقتًا'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 09b2173f..18d2b9b1 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1580,6 +1580,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Ungültiger regulärer Ausdruck'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Regex-Treffer ohne Zeichen werden nicht unterstützt (z. B. ^, \$ oder Lookaheads, die nur eine Position treffen).'; + + @override + String get workspaceSearchIncomplete => + 'Es wird nur ein Teil der Ergebnisse angezeigt. Zeigen Sie mehr an oder grenzen Sie die Suche ein.'; + + @override + String get workspaceSearchShowMore => 'Weitere Ergebnisse anzeigen'; + + @override + String get workspaceSearchSkippedFiles => + 'Die Suche ist unvollständig. Diese Dateien sind größer als 1 MiB oder konnten nicht gelesen werden:'; + @override String get sourceLargeFileFeaturesPaused => 'Große Datei: Hervorhebung und Faltung sind pausiert'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 97ffd16e..5382e3d7 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1560,6 +1560,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Invalid regular expression'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Zero-length regex matches are not supported (for example ^, \$, or lookaheads that match only a position).'; + + @override + String get workspaceSearchIncomplete => + 'Only some results are shown. Show more or narrow your search.'; + + @override + String get workspaceSearchShowMore => 'Show more results'; + + @override + String get workspaceSearchSkippedFiles => + 'Search is incomplete. These files exceeded 1 MiB or could not be read:'; + @override String get sourceLargeFileFeaturesPaused => 'Large file: highlighting and folding are paused'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 51ec4f24..7f22ea0a 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1577,6 +1577,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Expresión regular no válida'; + @override + String get sourceSearchZeroLengthUnsupported => + 'No se admiten coincidencias de longitud cero (por ejemplo, ^, \$ o anticipaciones que solo coinciden con una posición).'; + + @override + String get workspaceSearchIncomplete => + 'Solo se muestran algunos resultados. Muestre más o limite la búsqueda.'; + + @override + String get workspaceSearchShowMore => 'Mostrar más resultados'; + + @override + String get workspaceSearchSkippedFiles => + 'La búsqueda está incompleta. Estos archivos superan 1 MiB o no se pudieron leer:'; + @override String get sourceLargeFileFeaturesPaused => 'Archivo grande: el resaltado y el plegado están en pausa'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 1b7e7a67..6cbb1541 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -1557,6 +1557,21 @@ class AppLocalizationsEt extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Vigane regulaaravaldis'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Nullpikkusega regulaaravaldiste vasteid ei toetata (näiteks ^, \$ või ainult asukohta sobitavad ettevaated).'; + + @override + String get workspaceSearchIncomplete => + 'Kuvatakse ainult osa tulemustest. Kuva rohkem või kitsenda otsingut.'; + + @override + String get workspaceSearchShowMore => 'Kuva rohkem tulemusi'; + + @override + String get workspaceSearchSkippedFiles => + 'Otsing pole täielik. Need failid on suuremad kui 1 MiB või neid ei saanud lugeda:'; + @override String get sourceLargeFileFeaturesPaused => 'Suur fail: esiletõstmine ja voltimine on peatatud'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 06f3e339..403d1538 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -1595,6 +1595,21 @@ class AppLocalizationsFa extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'عبارت منظم نامعتبر است'; + @override + String get sourceSearchZeroLengthUnsupported => + 'تطبیق‌های با طول صفر در عبارت منظم پشتیبانی نمی‌شوند (مانند ^، \$ یا نگاه‌به‌جلو که فقط با یک موقعیت تطبیق دارد).'; + + @override + String get workspaceSearchIncomplete => + 'فقط بخشی از نتایج نمایش داده می‌شود. نتایج بیشتری نمایش دهید یا جستجو را محدود کنید.'; + + @override + String get workspaceSearchShowMore => 'نمایش نتایج بیشتر'; + + @override + String get workspaceSearchSkippedFiles => + 'جستجو کامل نیست. حجم این فایل‌ها بیش از 1 MiB است یا خواندن آن‌ها ممکن نبود:'; + @override String get sourceLargeFileFeaturesPaused => 'فایل بزرگ: برجسته‌سازی و جمع‌کردن موقتاً متوقف شده‌اند'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 12a4546f..0a349c0a 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1580,6 +1580,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Expression régulière non valide'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Les correspondances de longueur nulle ne sont pas prises en charge (par exemple ^, \$ ou les assertions qui ne correspondent qu’à une position).'; + + @override + String get workspaceSearchIncomplete => + 'Seule une partie des résultats est affichée. Affichez-en davantage ou affinez la recherche.'; + + @override + String get workspaceSearchShowMore => 'Afficher plus de résultats'; + + @override + String get workspaceSearchSkippedFiles => + 'La recherche est incomplète. Ces fichiers dépassent 1 MiB ou n’ont pas pu être lus :'; + @override String get sourceLargeFileFeaturesPaused => 'Fichier volumineux : la coloration et le repliage sont suspendus'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 7ebb36e9..8a41f375 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -1554,6 +1554,21 @@ class AppLocalizationsHi extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'अमान्य रेगुलर एक्सप्रेशन'; + @override + String get sourceSearchZeroLengthUnsupported => + 'शून्य लंबाई वाले रेगेक्स मिलान समर्थित नहीं हैं (जैसे ^, \$ या केवल स्थिति से मेल खाने वाले लुकअहेड)।'; + + @override + String get workspaceSearchIncomplete => + 'केवल कुछ परिणाम दिखाए गए हैं। अधिक परिणाम देखें या खोज को सीमित करें।'; + + @override + String get workspaceSearchShowMore => 'अधिक परिणाम दिखाएँ'; + + @override + String get workspaceSearchSkippedFiles => + 'खोज अधूरी है। ये फ़ाइलें 1 MiB से बड़ी हैं या पढ़ी नहीं जा सकीं:'; + @override String get sourceLargeFileFeaturesPaused => 'बड़ी फ़ाइल: हाइलाइटिंग और फ़ोल्डिंग अस्थायी रूप से रुकी हुई हैं'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 7e109903..926dbe68 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -1560,6 +1560,21 @@ class AppLocalizationsId extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Ekspresi reguler tidak valid'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Kecocokan regex dengan panjang nol tidak didukung (misalnya ^, \$, atau lookahead yang hanya cocok dengan posisi).'; + + @override + String get workspaceSearchIncomplete => + 'Hanya sebagian hasil yang ditampilkan. Tampilkan lebih banyak atau persempit pencarian.'; + + @override + String get workspaceSearchShowMore => 'Tampilkan lebih banyak hasil'; + + @override + String get workspaceSearchSkippedFiles => + 'Pencarian belum lengkap. Berkas ini melebihi 1 MiB atau tidak dapat dibaca:'; + @override String get sourceLargeFileFeaturesPaused => 'File besar: penyorotan dan pelipatan dijeda'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 5e2ab72e..a48c6de5 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1574,6 +1574,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Espressione regolare non valida'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Le corrispondenze regex di lunghezza zero non sono supportate (ad esempio ^, \$ o lookahead che corrispondono solo a una posizione).'; + + @override + String get workspaceSearchIncomplete => + 'È mostrata solo una parte dei risultati. Mostrane altri o restringi la ricerca.'; + + @override + String get workspaceSearchShowMore => 'Mostra altri risultati'; + + @override + String get workspaceSearchSkippedFiles => + 'La ricerca è incompleta. Questi file superano 1 MiB o non è stato possibile leggerli:'; + @override String get sourceLargeFileFeaturesPaused => 'File di grandi dimensioni: evidenziazione e ripiegamento sono sospesi'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 2b80c4bf..b7d5094e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1510,6 +1510,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get sourceSearchInvalidRegex => '無効な正規表現'; + @override + String get sourceSearchZeroLengthUnsupported => + '長さがゼロの正規表現の一致には対応していません(^、\$、位置のみに一致する先読みなど)。'; + + @override + String get workspaceSearchIncomplete => + '一部の結果のみ表示しています。さらに表示するか、検索を絞り込んでください。'; + + @override + String get workspaceSearchShowMore => 'さらに結果を表示'; + + @override + String get workspaceSearchSkippedFiles => + '検索は未完了です。次のファイルは 1 MiB を超えているか、読み取れませんでした:'; + @override String get sourceLargeFileFeaturesPaused => '大きなファイル:ハイライトと折りたたみを一時停止しています'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 64355473..5020fd71 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1508,6 +1508,20 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sourceSearchInvalidRegex => '잘못된 정규식'; + @override + String get sourceSearchZeroLengthUnsupported => + '길이가 0인 정규식 일치는 지원하지 않습니다(예: ^, \$, 위치에만 일치하는 전방 탐색).'; + + @override + String get workspaceSearchIncomplete => '일부 결과만 표시됩니다. 더 표시하거나 검색 범위를 좁히세요.'; + + @override + String get workspaceSearchShowMore => '결과 더 보기'; + + @override + String get workspaceSearchSkippedFiles => + '검색이 완료되지 않았습니다. 다음 파일은 1 MiB를 초과하거나 읽을 수 없습니다:'; + @override String get sourceLargeFileFeaturesPaused => '대용량 파일: 강조 표시 및 접기가 일시 중지됩니다.'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 8a6a18da..05ccbee8 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1561,6 +1561,21 @@ class AppLocalizationsNb extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Ugyldig regulært uttrykk'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Regex-treff med null lengde støttes ikke (for eksempel ^, \$ eller fremoversøk som bare samsvarer med en posisjon).'; + + @override + String get workspaceSearchIncomplete => + 'Bare noen av resultatene vises. Vis flere eller avgrens søket.'; + + @override + String get workspaceSearchShowMore => 'Vis flere resultater'; + + @override + String get workspaceSearchSkippedFiles => + 'Søket er ufullstendig. Disse filene er større enn 1 MiB eller kunne ikke leses:'; + @override String get sourceLargeFileFeaturesPaused => 'Stor fil: utheving og folding er satt på pause'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index a3d559c2..ec88b5aa 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -1569,6 +1569,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Ongeldige reguliere expressie'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Regex-overeenkomsten met lengte nul worden niet ondersteund (zoals ^, \$ of lookaheads die alleen een positie vinden).'; + + @override + String get workspaceSearchIncomplete => + 'Slechts een deel van de resultaten wordt getoond. Toon meer of verfijn de zoekopdracht.'; + + @override + String get workspaceSearchShowMore => 'Meer resultaten tonen'; + + @override + String get workspaceSearchSkippedFiles => + 'De zoekopdracht is onvolledig. Deze bestanden zijn groter dan 1 MiB of konden niet worden gelezen:'; + @override String get sourceLargeFileFeaturesPaused => 'Groot bestand: markeren en vouwen worden gepauzeerd'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 34cb290e..401758c7 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1585,6 +1585,21 @@ class AppLocalizationsPl extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Nieprawidłowe wyrażenie regularne'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Dopasowania wyrażeń regularnych o zerowej długości nie są obsługiwane (np. ^, \$ lub asercje dopasowujące tylko pozycję).'; + + @override + String get workspaceSearchIncomplete => + 'Wyświetlono tylko część wyników. Pokaż więcej lub zawęź wyszukiwanie.'; + + @override + String get workspaceSearchShowMore => 'Pokaż więcej wyników'; + + @override + String get workspaceSearchSkippedFiles => + 'Wyszukiwanie jest niepełne. Te pliki przekraczają 1 MiB lub nie można ich odczytać:'; + @override String get sourceLargeFileFeaturesPaused => 'Duży plik: podświetlanie i zwijanie są wstrzymane'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 94ec2bed..c6d679a0 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1571,6 +1571,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Expressão regular inválida'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Não são suportadas correspondências regex de comprimento zero (por exemplo, ^, \$ ou antecipações que correspondem apenas a uma posição).'; + + @override + String get workspaceSearchIncomplete => + 'Só são apresentados alguns resultados. Mostre mais ou restrinja a pesquisa.'; + + @override + String get workspaceSearchShowMore => 'Mostrar mais resultados'; + + @override + String get workspaceSearchSkippedFiles => + 'A pesquisa está incompleta. Estes ficheiros excedem 1 MiB ou não foi possível lê-los:'; + @override String get sourceLargeFileFeaturesPaused => 'Ficheiro grande: o realce e o recolhimento estão pausados'; @@ -5805,6 +5820,21 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get sourceSearchInvalidRegex => 'Expressão regular inválida'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Não há suporte para correspondências regex de comprimento zero (por exemplo, ^, \$ ou antecipações que correspondem apenas a uma posição).'; + + @override + String get workspaceSearchIncomplete => + 'Apenas alguns resultados são exibidos. Mostre mais ou restrinja a pesquisa.'; + + @override + String get workspaceSearchShowMore => 'Mostrar mais resultados'; + + @override + String get workspaceSearchSkippedFiles => + 'A pesquisa está incompleta. Estes arquivos excedem 1 MiB ou não puderam ser lidos:'; + @override String get sourceLargeFileFeaturesPaused => 'Arquivo grande: o realce e o recolhimento estão pausados'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index f11124f8..513224ce 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1580,6 +1580,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Некорректное регулярное выражение'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Совпадения регулярных выражений нулевой длины не поддерживаются (например, ^, \$ или проверки вперёд, совпадающие только с позицией).'; + + @override + String get workspaceSearchIncomplete => + 'Показана только часть результатов. Покажите больше или уточните поиск.'; + + @override + String get workspaceSearchShowMore => 'Показать больше результатов'; + + @override + String get workspaceSearchSkippedFiles => + 'Поиск неполный. Эти файлы превышают 1 MiB или не могут быть прочитаны:'; + @override String get sourceLargeFileFeaturesPaused => 'Большой файл: подсветка и сворачивание приостановлены'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 1393dd50..c7e6ab7e 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1557,6 +1557,21 @@ class AppLocalizationsTr extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Geçersiz düzenli ifade'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Sıfır uzunluklu düzenli ifade eşleşmeleri desteklenmez (örneğin ^, \$ veya yalnızca bir konumla eşleşen ileri bakışlar).'; + + @override + String get workspaceSearchIncomplete => + 'Sonuçların yalnızca bir kısmı gösteriliyor. Daha fazlasını gösterin veya aramayı daraltın.'; + + @override + String get workspaceSearchShowMore => 'Daha fazla sonuç göster'; + + @override + String get workspaceSearchSkippedFiles => + 'Arama eksik. Bu dosyalar 1 MiB boyutunu aşıyor veya okunamadı:'; + @override String get sourceLargeFileFeaturesPaused => 'Büyük dosya: vurgulama ve katlama duraklatıldı'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index c11412fc..5b1790d9 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1588,6 +1588,21 @@ class AppLocalizationsUk extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Некоректний регулярний вираз'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Збіги регулярних виразів нульової довжини не підтримуються (наприклад, ^, \$ або перевірки вперед, що збігаються лише з позицією).'; + + @override + String get workspaceSearchIncomplete => + 'Показано лише частину результатів. Покажіть більше або звузьте пошук.'; + + @override + String get workspaceSearchShowMore => 'Показати більше результатів'; + + @override + String get workspaceSearchSkippedFiles => + 'Пошук неповний. Ці файли перевищують 1 MiB або їх не вдалося прочитати:'; + @override String get sourceLargeFileFeaturesPaused => 'Великий файл: підсвічування та згортання призупинено'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 9c418cd4..68003a57 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -1553,6 +1553,21 @@ class AppLocalizationsVi extends AppLocalizations { @override String get sourceSearchInvalidRegex => 'Biểu thức chính quy không hợp lệ'; + @override + String get sourceSearchZeroLengthUnsupported => + 'Không hỗ trợ kết quả khớp regex có độ dài bằng 0 (ví dụ ^, \$ hoặc lookahead chỉ khớp với một vị trí).'; + + @override + String get workspaceSearchIncomplete => + 'Chỉ hiển thị một phần kết quả. Hiển thị thêm hoặc thu hẹp tìm kiếm.'; + + @override + String get workspaceSearchShowMore => 'Hiển thị thêm kết quả'; + + @override + String get workspaceSearchSkippedFiles => + 'Tìm kiếm chưa đầy đủ. Các tệp này vượt quá 1 MiB hoặc không thể đọc được:'; + @override String get sourceLargeFileFeaturesPaused => 'Tệp lớn: đã tạm dừng tô sáng và thu gọn'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index cd35c44c..2c7851cc 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1490,6 +1490,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sourceSearchInvalidRegex => '无效的正则表达式'; + @override + String get sourceSearchZeroLengthUnsupported => + '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; + + @override + String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; + + @override + String get workspaceSearchShowMore => '显示更多结果'; + + @override + String get workspaceSearchSkippedFiles => '搜索不完整。以下文件超过 1 MiB 或无法读取:'; + @override String get sourceLargeFileFeaturesPaused => '大文件:已暂停语法突出显示和折叠'; @@ -5475,6 +5488,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get sourceSearchInvalidRegex => '无效的正则表达式'; + @override + String get sourceSearchZeroLengthUnsupported => + '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; + + @override + String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; + + @override + String get workspaceSearchShowMore => '显示更多结果'; + + @override + String get workspaceSearchSkippedFiles => '搜索不完整。以下文件超过 1 MiB 或无法读取:'; + @override String get sourceLargeFileFeaturesPaused => '大文件:已暂停语法突出显示和折叠'; diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index cd30c2b7..419deaa4 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -151,6 +151,8 @@ class BusyMarkSourceEditorState extends State { final _searchController = SourceSearchController(); final _searchWorker = SourceSearchWorker(); final _replacementWorker = SearchReplacementWorker(); + bool _applyingSearchReplacement = false; + ({int start, int end})? _requestedSearchRange; final _intrinsicWidthCache = SourceIntrinsicWidthCache(); final _lineLayoutCache = SourceLineLayoutCache(); final _autocompleteProvider = const SourceAutocompleteProvider(); @@ -235,6 +237,7 @@ class BusyMarkSourceEditorState extends State { if (widget.searchActive != oldWidget.searchActive || widget.searchOptions != oldWidget.searchOptions || authoritativeDocumentChanged) { + _requestedSearchRange = null; _syncSearchOptions(); } if (widget.wordWrap && !oldWidget.wordWrap) { @@ -336,11 +339,34 @@ class BusyMarkSourceEditorState extends State { _unfoldSourceRange(startOffset, endOffset); final start = startOffset.clamp(0, _controller.fullText.length).toInt(); final end = endOffset.clamp(start, _controller.fullText.length).toInt(); + _replacementWorker.cancel(); + _requestedSearchRange = start < end ? (start: start, end: end) : null; _focusNode.requestFocus(); _controller.fullSelection = TextSelection( baseOffset: start, extentOffset: end, ); + final result = _searchController.result; + final localIndex = result.matches.indexWhere( + (match) => match.fullStart == start && match.fullEnd == end, + ); + if (localIndex >= 0) { + _requestedSearchRange = null; + _searchController.setCurrentMatchIndex( + result.firstMatchIndex + localIndex, + ); + _controller.setSearchResult(_searchController.result); + setState(() {}); + } else if (start < end) { + _scheduleSearch( + minimumFullOffset: start, + revealCurrentAfterRefresh: true, + wrapIfOffsetMissing: false, + ); + } else { + _searchController.setCurrentMatchIndex(null); + _controller.setSearchResult(_searchController.result); + } WidgetsBinding.instance.addPostFrameCallback((_) { _animateScrollToLine(line); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -877,7 +903,21 @@ class BusyMarkSourceEditorState extends State { return; } _searchController.acceptResult(result); - if (minimumFullOffset != null && result.matches.isNotEmpty) { + final requestedRange = _requestedSearchRange; + if (requestedRange != null) { + final localIndex = result.matches.indexWhere( + (match) => + match.fullStart == requestedRange.start && + match.fullEnd == requestedRange.end, + ); + if (localIndex >= 0) { + _searchController.setCurrentMatchIndex( + result.firstMatchIndex + localIndex, + ); + _requestedSearchRange = null; + } + } else if (minimumFullOffset != null && + result.matches.isNotEmpty) { _searchController.setCurrentMatchIndex(result.firstMatchIndex); } else if (revealCurrentAfterRefresh && _searchController.result.currentMatch == null && @@ -917,6 +957,7 @@ class BusyMarkSourceEditorState extends State { } void _nextSearchMatch() { + _requestedSearchRange = null; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -929,6 +970,7 @@ class BusyMarkSourceEditorState extends State { } void _previousSearchMatch() { + _requestedSearchRange = null; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -960,7 +1002,8 @@ class BusyMarkSourceEditorState extends State { } Future _replaceCurrentSearchMatch({bool findNext = false}) async { - if (_searchController.result.invalidRegex) { + if (_searchController.result.invalidRegex || + _requestedSearchRange != null) { return; } if (_searchController.result.currentMatchIndex == null) { @@ -986,6 +1029,7 @@ class BusyMarkSourceEditorState extends State { !identical(document, _controller.document) || options != widget.searchOptions || replacement != widget.searchReplacement || + _searchController.result.currentMatchIndex != currentIndex || preview.invalidRegex || preview.matches.isEmpty) { return; @@ -996,27 +1040,25 @@ class BusyMarkSourceEditorState extends State { match.end, match.replacement, ); - _applyFullEditingValue( - TextEditingValue( - text: nextText, - selection: TextSelection( - baseOffset: match.start, - extentOffset: match.start + match.replacement.length, + _applyingSearchReplacement = true; + try { + _applyFullEditingValue( + TextEditingValue( + text: nextText, + selection: TextSelection( + baseOffset: match.start, + extentOffset: match.start + match.replacement.length, + ), ), - ), - ); - final replacementEnd = match.start + match.replacement.length; - if (findNext) { - _refreshSearch( - minimumFullOffset: replacementEnd, - revealCurrentAfterRefresh: true, - ); - } else { - _refreshSearch( - currentIndex: currentIndex, - firstMatchIndex: _searchController.result.firstMatchIndex, ); + } finally { + _applyingSearchReplacement = false; } + final replacementEnd = match.start + match.replacement.length; + _refreshSearch( + minimumFullOffset: findNext ? replacementEnd : match.start, + revealCurrentAfterRefresh: true, + ); } Future _replaceAllSearchMatches() async { @@ -1066,10 +1108,8 @@ class BusyMarkSourceEditorState extends State { }); return; } - final currentIndex = _searchController.result.currentMatchIndex; if (match.hidden) { _unfoldSourceRange(match.fullStart, match.fullEnd); - _refreshSearch(currentIndex: currentIndex); } final line = _controller.document.lineIndex.lineNumberAtOffset( match.fullStart, @@ -1181,10 +1221,12 @@ class BusyMarkSourceEditorState extends State { final currentSearchIndex = _searchController.result.currentMatchIndex; final firstMatchIndex = _searchController.result.firstMatchIndex; _scheduleFoldRefresh(); - _refreshSearch( - currentIndex: currentSearchIndex, - firstMatchIndex: firstMatchIndex, - ); + if (!_applyingSearchReplacement) { + _refreshSearch( + currentIndex: currentSearchIndex, + firstMatchIndex: firstMatchIndex, + ); + } final transactionalCallback = widget.onTransactionalChanged; if (transactionalCallback == null) { widget.onChanged(_controller.fullText, widget.filePath); @@ -2559,6 +2601,11 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (result.hasZeroLengthMatches) + Text( + context.l10n.sourceSearchZeroLengthUnsupported, + style: Theme.of(context).textTheme.labelSmall, + ), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/src/editor/source/source_search.dart b/lib/src/editor/source/source_search.dart index 67395748..82d37974 100644 --- a/lib/src/editor/source/source_search.dart +++ b/lib/src/editor/source/source_search.dart @@ -76,6 +76,7 @@ class SourceSearchResult { int? totalMatchCount, this.firstMatchIndex = 0, this.invalidRegex = false, + this.hasZeroLengthMatches = false, }) : totalMatchCount = totalMatchCount ?? matches.length; static final empty = SourceSearchResult( @@ -93,6 +94,7 @@ class SourceSearchResult { /// The complete-result index represented by [matches.first]. final int firstMatchIndex; final bool invalidRegex; + final bool hasZeroLengthMatches; SourceSearchMatch? get currentMatch { final index = currentMatchIndex; @@ -114,6 +116,7 @@ class SourceSearchResult { totalMatchCount: totalMatchCount, firstMatchIndex: firstMatchIndex, invalidRegex: invalidRegex, + hasZeroLengthMatches: hasZeroLengthMatches, ); } @@ -125,6 +128,7 @@ class SourceSearchResult { other.totalMatchCount == totalMatchCount && other.firstMatchIndex == firstMatchIndex && other.invalidRegex == invalidRegex && + other.hasZeroLengthMatches == hasZeroLengthMatches && _matchesEqual(other.matches, matches); } @@ -135,6 +139,7 @@ class SourceSearchResult { totalMatchCount, firstMatchIndex, invalidRegex, + hasZeroLengthMatches, Object.hashAll(matches.map(_matchHash)), ); } @@ -263,6 +268,7 @@ class SourceSearchWorker { int firstMatchIndex = 0, int? minimumFullOffset, int maximumMatches = sourceInteractiveSearchMatchLimit, + bool stopAfterMaximumMatches = false, }) { cancel(); final generation = ++_generation; @@ -295,6 +301,7 @@ class SourceSearchWorker { 'firstMatchIndex': firstMatchIndex, 'minimumFullOffset': minimumFullOffset, 'maximumMatches': maximumMatches, + 'stopAfterMaximumMatches': stopAfterMaximumMatches, }; Isolate.spawn>( _sourceSearchWorkerMain, @@ -373,9 +380,11 @@ void _sourceSearchWorkerMain(List payload) { firstMatchIndex: request['firstMatchIndex']! as int, minimumFullOffset: request['minimumFullOffset'] as int?, maximumMatches: request['maximumMatches']! as int, + stopAfterMaximumMatches: request['stopAfterMaximumMatches']! as bool, ); sendPort.send({ 'invalidRegex': result.invalidRegex, + 'hasZeroLengthMatches': result.hasZeroLengthMatches, 'currentMatchIndex': result.currentMatchIndex, 'totalMatchCount': result.totalMatchCount, 'firstMatchIndex': result.firstMatchIndex, @@ -413,6 +422,7 @@ SourceSearchResult _decodeSearchResult( totalMatchCount: payload['totalMatchCount']! as int, firstMatchIndex: payload['firstMatchIndex']! as int, invalidRegex: payload['invalidRegex']! as bool, + hasZeroLengthMatches: payload['hasZeroLengthMatches']! as bool, ); } @@ -425,6 +435,7 @@ bool sourceSearchOptionsHaveInvalidRegex(SourceSearchOptions options) { options.query, caseSensitive: options.caseSensitive, multiLine: true, + unicode: true, ); return false; } on FormatException { @@ -475,6 +486,7 @@ SourceSearchResult searchSourceDocument( int firstMatchIndex = 0, int? minimumFullOffset, int? maximumMatches, + bool stopAfterMaximumMatches = false, }) { final query = options.query; if (query.isEmpty) { @@ -485,7 +497,12 @@ SourceSearchResult searchSourceDocument( if (options.regex) { try { rawMatches = - RegExp(query, caseSensitive: options.caseSensitive, multiLine: true) + RegExp( + query, + caseSensitive: options.caseSensitive, + multiLine: true, + unicode: true, + ) .allMatches(document.fullText) .map((match) => (start: match.start, end: match.end)); } on FormatException { @@ -505,8 +522,17 @@ SourceSearchResult searchSourceDocument( var totalMatchCount = 0; var storedFirstMatchIndex = requestedFirstMatchIndex; var foundOffsetWindow = minimumFullOffset == null; + var hasZeroLengthMatches = false; for (final match in rawMatches) { if (match.start == match.end) { + hasZeroLengthMatches = true; + continue; + } + if (!sourceSearchRangeHasSafeBoundaries( + document.fullText, + match.start, + match.end, + )) { continue; } if (options.wholeWord && @@ -537,6 +563,11 @@ SourceSearchResult searchSourceDocument( visible.range.start == visible.range.end, ), ); + if (stopAfterMaximumMatches && + matchLimit != null && + matches.length >= matchLimit) { + break; + } } if (!foundOffsetWindow) { storedFirstMatchIndex = totalMatchCount; @@ -554,9 +585,23 @@ SourceSearchResult searchSourceDocument( : null, totalMatchCount: totalMatchCount, firstMatchIndex: storedFirstMatchIndex, + hasZeroLengthMatches: hasZeroLengthMatches, ); } +/// Search and replacement offsets are UTF-16 offsets, but must never bisect +/// a character represented by a surrogate pair. +bool sourceSearchRangeHasSafeBoundaries(String source, int start, int end) { + bool safe(int offset) => + offset >= 0 && + offset <= source.length && + (offset == 0 || + offset == source.length || + !_isHighSurrogate(source.codeUnitAt(offset - 1)) || + !_isLowSurrogate(source.codeUnitAt(offset))); + return start <= end && safe(start) && safe(end); +} + Iterable<({int start, int end})> _plainMatches( String source, String query, diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart index 109fd150..0599a21b 100644 --- a/lib/src/search/search_replace_service.dart +++ b/lib/src/search/search_replace_service.dart @@ -9,6 +9,7 @@ import '../editor/source/source_search.dart'; import '../workspace/text_format_metadata.dart'; import '../workspace/workspace_model.dart'; import '../workspace/workspace_service.dart'; +import 'workspace_search_scope.dart'; class TextReplacementMatch { const TextReplacementMatch({ @@ -34,6 +35,7 @@ class TextReplacementPreview { required this.matches, this.invalidRegex = false, this.truncated = false, + this.hasZeroLengthMatches = false, }); final String source; @@ -42,6 +44,7 @@ class TextReplacementPreview { final List matches; final bool invalidRegex; final bool truncated; + final bool hasZeroLengthMatches; String apply({Set? selectedMatchIds}) { if (matches.isEmpty) { @@ -53,6 +56,11 @@ class TextReplacementPreview { if (selectedMatchIds != null && !selectedMatchIds.contains(match.id)) { continue; } + if (match.start < sourceOffset || + !sourceSearchRangeHasSafeBoundaries(source, match.start, match.end) || + source.substring(match.start, match.end) != match.original) { + throw StateError('Replacement range no longer matches the source.'); + } output ..write(source.substring(sourceOffset, match.start)) ..write(match.replacement); @@ -69,6 +77,9 @@ class TextReplacementPreview { enum WorkspaceReplacementSourceKind { disk, dirtyBuffer } enum WorkspaceReplacementIssueKind { + cancelled, + invalidRegex, + zeroLengthMatches, oversized, unreadable, invalidUtf8, @@ -133,7 +144,10 @@ class WorkspaceReplacementPreview { files.fold(0, (total, file) => total + file.matches.length); bool get isComplete => !issues.any( - (issue) => issue.kind == WorkspaceReplacementIssueKind.truncated, + (issue) => + issue.kind == WorkspaceReplacementIssueKind.truncated || + issue.kind == WorkspaceReplacementIssueKind.cancelled || + issue.kind == WorkspaceReplacementIssueKind.invalidRegex, ); } @@ -169,6 +183,7 @@ class SearchReplacementService { SourceDocument(fullText: source), options, maximumMatches: matchLimit + 1, + stopAfterMaximumMatches: true, ); if (search.invalidRegex) { return TextReplacementPreview( @@ -185,6 +200,7 @@ class SearchReplacementService { options.query, caseSensitive: options.caseSensitive, multiLine: true, + unicode: true, ); } final matches = []; @@ -216,6 +232,7 @@ class SearchReplacementService { replacement: replacement, matches: List.unmodifiable(matches), truncated: search.totalMatchCount > matchLimit, + hasZeroLengthMatches: search.hasZeroLengthMatches, ); } @@ -226,7 +243,9 @@ class SearchReplacementService { required int start, required int end, }) { - if (start < 0 || end <= start || end > source.length) { + if (end <= start || + !sourceSearchRangeHasSafeBoundaries(source, start, end) || + options.query.isEmpty) { return TextReplacementPreview( source: source, options: options, @@ -241,6 +260,7 @@ class SearchReplacementService { options.query, caseSensitive: options.caseSensitive, multiLine: true, + unicode: true, ); final match = expression.matchAsPrefix(source, start); if (match is! RegExpMatch || match.end != end) { @@ -261,6 +281,20 @@ class SearchReplacementService { invalidRegex: true, ); } + } else { + final match = RegExp( + RegExp.escape(options.query), + caseSensitive: options.caseSensitive, + unicode: true, + ).matchAsPrefix(source, start); + if (match == null || match.end != end) { + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: const [], + ); + } } return TextReplacementPreview( source: source, @@ -283,115 +317,173 @@ class SearchReplacementService { required WorkspaceService workspaceService, required SourceSearchOptions options, required String replacement, + WorkspaceReplacementCancellation? cancellation, }) async { - final workspace = state.workspace; - if (workspace == null || options.query.isEmpty) { - return WorkspaceReplacementPreview( - options: options, - replacement: replacement, - files: const [], - issues: const [], - ); - } - final files = []; - final issues = []; - final candidates = { - for (final file in workspace.files) - if (_isReplaceableTextPath(file.absolutePath)) - file.absolutePath: file.relativePath, - for (final buffer in state.documentBuffers) - if (buffer.filePath case final path?) - path: p.relative(path, from: workspace.rootPath), - }; - var remaining = maximumMatches; - final sortedPaths = candidates.keys.toList()..sort(); - for (final path in sortedPaths) { - final buffer = state.bufferForPath(path); - late final String text; - late final TextFormatMetadata format; - WorkspaceFileSnapshot? snapshot; - if (buffer != null) { - text = buffer.text; - format = buffer.format; - snapshot = buffer.diskSnapshot; - } else { - final metadata = workspace.files - .where((file) => p.equals(file.absolutePath, path)) - .firstOrNull; - if (metadata != null && metadata.size > maximumFileBytes) { + final operation = cancellation ?? WorkspaceReplacementCancellation(); + try { + final workspace = state.workspace; + if (workspace == null || options.query.isEmpty) { + return WorkspaceReplacementPreview( + options: options, + replacement: replacement, + files: const [], + issues: const [], + ); + } + final files = []; + final issues = []; + final candidates = { + for (final file in workspace.files) + if (isSearchableWorkspaceDocument(file)) + file.absolutePath: file.relativePath, + }; + var remaining = maximumMatches.clamp(0, 0x7ffffffe).toInt(); + final sortedPaths = candidates.keys.toList()..sort(); + for (final path in sortedPaths) { + if (operation.isCancelled) { issues.add( WorkspaceReplacementIssue( - kind: WorkspaceReplacementIssueKind.oversized, + kind: WorkspaceReplacementIssueKind.cancelled, filePath: path, ), ); - continue; + break; + } + final buffer = state.bufferForPath(path); + late final String text; + late final TextFormatMetadata format; + WorkspaceFileSnapshot? snapshot; + if (buffer != null) { + text = buffer.text; + format = buffer.format; + snapshot = buffer.diskSnapshot; + } else { + final metadata = workspace.files + .where((file) => p.equals(file.absolutePath, path)) + .firstOrNull; + if (metadata != null && metadata.size > maximumFileBytes) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.oversized, + filePath: path, + ), + ); + continue; + } + try { + final loaded = await workspaceService.loadTextWithSnapshot(path); + text = loaded.text; + format = loaded.format; + snapshot = loaded.snapshot; + } on FormatException { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.invalidUtf8, + filePath: path, + ), + ); + continue; + } on FileSystemException { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.unreadable, + filePath: path, + ), + ); + continue; + } } - try { - final loaded = await workspaceService.loadTextWithSnapshot(path); - text = loaded.text; - format = loaded.format; - snapshot = loaded.snapshot; - } on FormatException { + if (operation.isCancelled) { issues.add( WorkspaceReplacementIssue( - kind: WorkspaceReplacementIssueKind.invalidUtf8, + kind: WorkspaceReplacementIssueKind.cancelled, filePath: path, ), ); - continue; - } on FileSystemException { + break; + } + final preview = await operation.worker.previewText( + source: text, + options: options, + replacement: replacement, + maximumMatches: remaining, + ); + if (preview == null || operation.isCancelled) { issues.add( WorkspaceReplacementIssue( - kind: WorkspaceReplacementIssueKind.unreadable, + kind: WorkspaceReplacementIssueKind.cancelled, filePath: path, ), ); + break; + } + if (preview.invalidRegex) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.invalidRegex, + filePath: path, + ), + ); + break; + } + if (preview.hasZeroLengthMatches) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.zeroLengthMatches, + filePath: path, + ), + ); + } + if (preview.matches.isEmpty && !preview.truncated) { continue; } + final retained = [ + for (final match in preview.matches) + TextReplacementMatch( + id: '$path:${match.id}', + start: match.start, + end: match.end, + original: match.original, + replacement: match.replacement, + ), + ]; + if (retained.isNotEmpty) { + files.add( + WorkspaceReplacementFilePreview( + filePath: path, + relativePath: candidates[path]!, + sourceKind: buffer?.isDirty == true + ? WorkspaceReplacementSourceKind.dirtyBuffer + : WorkspaceReplacementSourceKind.disk, + originalText: text, + matches: retained, + format: format, + bufferId: buffer?.id, + bufferRevision: buffer?.revision, + diskSnapshot: snapshot, + ), + ); + } + remaining -= retained.length; + if (preview.truncated) { + issues.add( + WorkspaceReplacementIssue( + kind: WorkspaceReplacementIssueKind.truncated, + filePath: path, + ), + ); + break; + } } - final preview = previewText( - source: text, + return WorkspaceReplacementPreview( options: options, replacement: replacement, - idPrefix: path, - ); - if (preview.matches.isEmpty) { - continue; - } - final retained = preview.matches.take(remaining).toList(growable: false); - files.add( - WorkspaceReplacementFilePreview( - filePath: path, - relativePath: candidates[path]!, - sourceKind: buffer?.isDirty == true - ? WorkspaceReplacementSourceKind.dirtyBuffer - : WorkspaceReplacementSourceKind.disk, - originalText: text, - matches: retained, - format: format, - bufferId: buffer?.id, - bufferRevision: buffer?.revision, - diskSnapshot: snapshot, - ), + files: List.unmodifiable(files), + issues: List.unmodifiable(issues), ); - remaining -= retained.length; - if (remaining <= 0) { - issues.add( - WorkspaceReplacementIssue( - kind: WorkspaceReplacementIssueKind.truncated, - filePath: path, - ), - ); - break; - } + } finally { + operation.worker.dispose(); } - return WorkspaceReplacementPreview( - options: options, - replacement: replacement, - files: List.unmodifiable(files), - issues: List.unmodifiable(issues), - ); } Future applyWorkspace({ @@ -408,10 +500,7 @@ class SearchReplacementService { return WorkspaceReplacementApplyResult( appliedFiles: 0, appliedMatches: 0, - issues: List.unmodifiable([ - for (final issue in preview.issues) - if (issue.kind == WorkspaceReplacementIssueKind.truncated) issue, - ]), + issues: List.unmodifiable([for (final issue in preview.issues) issue]), ); } final state = currentState(); @@ -641,12 +730,15 @@ class SearchReplacementService { final unit = value.codeUnitAt(0); return unit >= 48 && unit <= 57; } +} - bool _isReplaceableTextPath(String path) { - return switch (p.extension(path).toLowerCase()) { - '.md' || '.markdown' || '.xml' || '.topic' || '.tree' || '.html' => true, - _ => false, - }; +class WorkspaceReplacementCancellation { + final worker = SearchReplacementWorker(); + bool isCancelled = false; + + void cancel() { + isCancelled = true; + worker.cancel(); } } @@ -780,6 +872,7 @@ void _replacementWorkerMain(List payload) { sendPort.send({ 'invalidRegex': preview.invalidRegex, 'truncated': preview.truncated, + 'hasZeroLengthMatches': preview.hasZeroLengthMatches, 'matches': [ for (final match in preview.matches) [ @@ -816,6 +909,7 @@ TextReplacementPreview _decodeReplacementPreview( ]), invalidRegex: payload['invalidRegex']! as bool, truncated: payload['truncated']! as bool, + hasZeroLengthMatches: payload['hasZeroLengthMatches']! as bool, ); } diff --git a/lib/src/search/workspace_search_scope.dart b/lib/src/search/workspace_search_scope.dart new file mode 100644 index 00000000..1adb5653 --- /dev/null +++ b/lib/src/search/workspace_search_scope.dart @@ -0,0 +1,16 @@ +import '../workspace/workspace_model.dart'; + +/// The project text documents eligible for both search and replacement. +/// Opening a tab does not expand this scope. +bool isSearchableWorkspaceDocument(DocumentFile file) => switch (file.kind) { + DocumentKind.markdown || + DocumentKind.writersideMarkdownTopic || + DocumentKind.writersideXmlTopic || + DocumentKind.tree || + DocumentKind.config || + DocumentKind.variables || + DocumentKind.categories || + DocumentKind.gitIgnore || + DocumentKind.resource => true, + DocumentKind.image || DocumentKind.unknown => false, +}; diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index d66fd52f..d7222f75 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -78,6 +78,7 @@ import '../../local_history/local_history_controller.dart'; import '../../local_history/local_history_panel.dart'; import '../../platform/linux_header_bar_service.dart'; import '../../search/search_replace_service.dart'; +import '../../search/workspace_search_scope.dart'; import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_model.dart'; @@ -163,6 +164,8 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { static const _debounceDelay = Duration(milliseconds: 120); Timer? _debounce; + final _worker = SourceSearchWorker(); + int _resultLimit = _maxWorkspaceSearchResults; late Future Function(String path) _loadText; var _request = 0; var _disposed = false; @@ -174,14 +177,14 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { final activeBufferChanged = previous?.activeBufferId != next.activeBufferId; var shouldRefresh = _workspaceSearchInputsChanged(previous, next); - if (activeBufferChanged) { + if (activeBufferChanged && !state.active) { final options = next.activeBuffer?.editorState.searchOptions; if (options != null) { state = state .withOptions(options) .copyWith(matches: const [], searching: false); } - } else { + } else if (!activeBufferChanged) { final previousOptions = previous?.activeBuffer?.editorState.searchOptions; final nextOptions = next.activeBuffer?.editorState.searchOptions; @@ -202,6 +205,7 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { _disposed = true; _request += 1; _debounce?.cancel(); + _worker.dispose(); }); return const _WorkspaceSearchState(); } @@ -213,6 +217,7 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { state.wholeWord != searchState.wholeWord || state.regex != searchState.regex || state.active != searchState.active; + if (inputChanged) _resultLimit = _maxWorkspaceSearchResults; state = searchState.copyWith( matches: inputChanged ? const [] : state.matches, searching: false, @@ -241,6 +246,7 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { Future submit() async { _debounce?.cancel(); + _worker.cancel(); final request = ++_request; final workspaceState = ref.read(workspaceControllerProvider); final options = state.options; @@ -260,6 +266,7 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { void _schedule(WorkspaceState workspaceState) { _debounce?.cancel(); + _worker.cancel(); final request = ++_request; final workspace = workspaceState.workspace; final options = state.options; @@ -273,7 +280,14 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { } return; } - state = state.copyWith(matches: const [], searching: true); + state = state.copyWith( + matches: const [], + searching: true, + truncated: false, + skippedFiles: const [], + invalidRegex: false, + hasZeroLengthMatches: false, + ); _debounce = Timer(_debounceDelay, () { _debounce = null; unawaited( @@ -292,23 +306,41 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { required SourceSearchOptions options, }) async { try { - final matches = await _loadWorkspaceSearchMatches( + final result = await _loadWorkspaceSearchMatches( workspaceState, options, loadText: _loadText, isCancelled: () => _disposed || request != _request, + worker: _worker, + maximumResults: _resultLimit, ); if (_disposed || request != _request) { return; } - state = state.copyWith(matches: matches, searching: false); + state = state.copyWith( + matches: result.matches, + searching: false, + truncated: result.truncated, + skippedFiles: result.skippedFiles, + invalidRegex: result.invalidRegex, + hasZeroLengthMatches: result.hasZeroLengthMatches, + ); } on Object { if (_disposed || request != _request) { return; } - state = state.copyWith(matches: const [], searching: false); + state = state.copyWith( + matches: const [], + searching: false, + skippedFiles: [workspaceState.workspace?.rootPath ?? ''], + ); } } + + void showMore() { + _resultLimit += _maxWorkspaceSearchResults; + _schedule(ref.read(workspaceControllerProvider)); + } } class _SearchNavigationTargetController @@ -390,6 +422,10 @@ class _WorkspaceSearchState { this.regex = false, this.matches = const [], this.searching = false, + this.truncated = false, + this.skippedFiles = const [], + this.invalidRegex = false, + this.hasZeroLengthMatches = false, }); final bool active; @@ -399,9 +435,13 @@ class _WorkspaceSearchState { final bool regex; final List<_WorkspaceSearchMatch> matches; final bool searching; + final bool truncated; + final List skippedFiles; + final bool invalidRegex; + final bool hasZeroLengthMatches; SourceSearchOptions get options => SourceSearchOptions( - query: query.trim(), + query: query, caseSensitive: caseSensitive, wholeWord: wholeWord, regex: regex, @@ -415,6 +455,10 @@ class _WorkspaceSearchState { bool? regex, List<_WorkspaceSearchMatch>? matches, bool? searching, + bool? truncated, + List? skippedFiles, + bool? invalidRegex, + bool? hasZeroLengthMatches, }) { return _WorkspaceSearchState( active: active ?? this.active, @@ -424,6 +468,10 @@ class _WorkspaceSearchState { regex: regex ?? this.regex, matches: matches ?? this.matches, searching: searching ?? this.searching, + truncated: truncated ?? this.truncated, + skippedFiles: skippedFiles ?? this.skippedFiles, + invalidRegex: invalidRegex ?? this.invalidRegex, + hasZeroLengthMatches: hasZeroLengthMatches ?? this.hasZeroLengthMatches, ); } @@ -456,6 +504,19 @@ bool _workspaceSearchInputsChanged( previous.activeText != next.activeText) { return true; } + if (previous.documentBuffers.length != next.documentBuffers.length) { + return true; + } + for (var index = 0; index < next.documentBuffers.length; index++) { + final before = previous.documentBuffers[index]; + final after = next.documentBuffers[index]; + if (before.id != after.id || + before.filePath != after.filePath || + before.revision != after.revision || + before.text != after.text) { + return true; + } + } final previousWorkspace = previous.workspace; final nextWorkspace = next.workspace; if (identical(previousWorkspace, nextWorkspace)) { @@ -644,7 +705,12 @@ class WorkspaceScreen extends ConsumerWidget { .read(_workspaceSearchProvider.notifier) .set(current.copyWith(active: true, query: query)); unawaited(settingsController.setSidebarVisible(true)); - case HeaderBarSearchSubmitted(): + case HeaderBarSearchSubmitted(:final query): + final current = ref.read(_workspaceSearchProvider); + ref + .read(_workspaceSearchProvider.notifier) + .set(current.copyWith(active: true, query: query)); + unawaited(settingsController.setSidebarVisible(true)); unawaited(_submitSearch(context, ref)); case HeaderBarSearchCleared(): _clearSearchQuery(ref); @@ -1230,19 +1296,23 @@ class WorkspaceScreen extends ConsumerWidget { _WorkspaceSearchResult result, ) async { final workspace = ref.read(workspaceControllerProvider).workspace; + final searchOptions = ref.read(_workspaceSearchProvider).options; if (workspace == null) { return; } final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath; if (activePath != result.filePath) { - await ref + final opened = await ref .read(workspaceControllerProvider.notifier) .openActiveFile(result.filePath); + if (!opened) return; } - _clearGitDetailSelection(ref); - if (!context.mounted) { + if (!context.mounted || + ref.read(workspaceControllerProvider).workspace?.id != workspace.id || + ref.read(_workspaceSearchProvider).options != searchOptions) { return; } + _clearGitDetailSelection(ref); final previous = ref.read(_searchNavigationTargetProvider); ref .read(_searchNavigationTargetProvider.notifier) @@ -2075,6 +2145,13 @@ class _SidebarState extends ConsumerState<_Sidebar> { late String _workspaceId; String? _activeFilePath; _WritersideTopicUsageReview? _topicUsageReview; + WorkspaceReplacementCancellation? _replacementCancellation; + + @override + void dispose() { + _replacementCancellation?.cancel(); + super.dispose(); + } @override void initState() { @@ -2110,6 +2187,17 @@ class _SidebarState extends ConsumerState<_Sidebar> { @override Widget build(BuildContext context) { final tabs = _sidebarTabsFor(widget.workspace.kind); + ref.listen(workspaceControllerProvider, (previous, next) { + if (_workspaceSearchInputsChanged(previous, next)) { + _replacementCancellation?.cancel(); + } + }); + ref.listen(_workspaceSearchProvider, (previous, next) { + if (previous?.options != next.options || + previous?.active != next.active) { + _replacementCancellation?.cancel(); + } + }); final gitState = ref.watch(gitControllerProvider); final repositoryInfo = gitState.attachedWorkspace?.id == widget.workspace.id ? gitState.repositoryInfo @@ -2153,6 +2241,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { query: widget.searchState.query, results: widget.searchResults, searching: widget.searchState.searching, + state: widget.searchState, + onShowMore: () => + ref.read(_workspaceSearchProvider.notifier).showMore(), onOpenResult: widget.onOpenSearchResult, onReviewReplacement: _reviewWorkspaceReplacement, ) @@ -2480,13 +2571,35 @@ class _SidebarState extends ConsumerState<_Sidebar> { return; } final service = const SearchReplacementService(); - final preview = await service.previewWorkspace( + _replacementCancellation?.cancel(); + final cancellation = WorkspaceReplacementCancellation(); + _replacementCancellation = cancellation; + final previewFuture = service.previewWorkspace( state: ref.read(workspaceControllerProvider), workspaceService: ref.read(workspaceServiceProvider), options: widget.searchState.options, replacement: requested, + cancellation: cancellation, ); - if (!mounted) { + final preview = + await showBusyMarkModalEditorDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + maxWidth: BusyMarkSizes.dialogCompact, + builder: (dialogContext) => _WorkspaceReplacementProgress( + future: previewFuture, + cancellation: cancellation, + ), + ); + cancellation.cancel(); + if (identical(_replacementCancellation, cancellation)) { + _replacementCancellation = null; + } + if (!mounted || + preview == null || + preview.issues.any( + (issue) => issue.kind == WorkspaceReplacementIssueKind.cancelled, + )) { return; } if (preview.files.isEmpty && preview.issues.isEmpty) { @@ -4850,18 +4963,7 @@ IconData _fileTreeIcon(_FileTreeNode node, {required bool expanded}) { } bool _isOpenableTextDocument(DocumentFile file) { - return switch (file.kind) { - DocumentKind.markdown || - DocumentKind.writersideMarkdownTopic || - DocumentKind.writersideXmlTopic || - DocumentKind.tree || - DocumentKind.config || - DocumentKind.variables || - DocumentKind.categories || - DocumentKind.gitIgnore || - DocumentKind.resource => true, - DocumentKind.image || DocumentKind.unknown => false, - }; + return isSearchableWorkspaceDocument(file); } class _FileTreeNode { @@ -10064,6 +10166,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _wysiwygSearchQuery; BusyMarkWysiwygSourceRange? _wysiwygSearchRange; var _wysiwygScrollRequest = 0; + var _lastSearchNavigationRequest = 0; @override void initState() { @@ -10176,16 +10279,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } }); }); - ref.listen(_searchNavigationTargetProvider, (previous, next) { - if (next == null) { - return; - } - if (next.filePath != widget.state.workspace?.activeFilePath && - next.filePath != widget.state.workspace?.markdown?.filePath) { - return; - } - _scrollToSearchTarget(next); - }); + final searchTarget = ref.watch(_searchNavigationTargetProvider); + if (searchTarget != null && + ref.read(_workspaceSearchProvider).active && + ref.read(_workspaceSearchProvider).query == searchTarget.query && + searchTarget.request != _lastSearchNavigationRequest) { + _scrollToSearchTarget(searchTarget); + } final colors = BusyMarkSurfaceColors.of(context); final settings = ref.watch(appSettingsControllerProvider); final headerBar = ref.watch(linuxHeaderBarServiceProvider); @@ -10958,9 +11058,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { void _scrollToSearchTarget(_SearchNavigationTarget target) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || + ref.read(_searchNavigationTargetProvider)?.request != + target.request || + _lastSearchNavigationRequest == target.request || + (target.filePath != widget.state.workspace?.activeFilePath && + target.filePath != widget.state.workspace?.markdown?.filePath)) { return; } + _lastSearchNavigationRequest = target.request; final editorVisible = widget.viewMode == DocumentViewModePreference.editor; final wysiwygVisible = @@ -11077,7 +11183,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } bool _scrollPreviewToSearchTarget(_SearchNavigationTarget target) { - final query = target.query.trim(); + final query = target.query; if (query.isEmpty || !_previewScrollController.isAttached) { return false; } @@ -12365,7 +12471,7 @@ class _PreviewInlineText extends ConsumerWidget { final allowRemoteImages = settings.allowsRemoteImagesForWorkspace( _remoteImageWorkspacePath(workspace), ); - final highlightQuery = searchState.active ? searchState.query.trim() : ''; + final highlightQuery = searchState.active ? searchState.query : ''; final inlines = block.inlines.isEmpty ? [PreviewInline(kind: PreviewInlineKind.text, text: block.text)] : block.inlines; @@ -12496,7 +12602,7 @@ int _searchResultOrdinalInSource( String source, _SearchNavigationTarget target, ) { - final normalizedQuery = target.query.trim().toLowerCase(); + final normalizedQuery = target.query.toLowerCase(); if (normalizedQuery.isEmpty) { return 0; } @@ -12525,7 +12631,7 @@ int? _previewSearchBlockIndexForOrdinal( String query, int ordinal, ) { - final normalizedQuery = query.trim().toLowerCase(); + final normalizedQuery = query.toLowerCase(); if (normalizedQuery.isEmpty) { return null; } @@ -13286,15 +13392,15 @@ List? _highlightedPreviewTextSpans( MouseCursor? mouseCursor, GestureRecognizer? Function()? recognizerBuilder, }) { - final normalizedQuery = query.trim().toLowerCase(); - if (normalizedQuery.isEmpty) { - return null; - } - final normalizedText = text.toLowerCase(); - final firstMatch = normalizedText.indexOf(normalizedQuery); - if (firstMatch < 0) { + if (query.isEmpty) { return null; } + final matches = RegExp( + RegExp.escape(query), + caseSensitive: false, + unicode: true, + ).allMatches(text); + if (matches.isEmpty) return null; final highlightStyle = style?.merge( TextStyle( @@ -13310,29 +13416,27 @@ List? _highlightedPreviewTextSpans( ); final spans = []; var cursor = 0; - var match = firstMatch; - while (match >= 0) { - if (match > cursor) { + for (final match in matches) { + if (match.start > cursor) { spans.add( TextSpan( - text: text.substring(cursor, match), + text: text.substring(cursor, match.start), style: style, mouseCursor: mouseCursor, recognizer: recognizerBuilder?.call(), ), ); } - final end = match + normalizedQuery.length; + final end = match.end; spans.add( TextSpan( - text: text.substring(match, end), + text: text.substring(match.start, end), style: highlightStyle, mouseCursor: mouseCursor, recognizer: recognizerBuilder?.call(), ), ); cursor = end; - match = normalizedText.indexOf(normalizedQuery, cursor); } if (cursor < text.length) { spans.add( @@ -13683,6 +13787,8 @@ class _SearchSidebar extends StatelessWidget { required this.searching, required this.onOpenResult, required this.onReviewReplacement, + required this.state, + required this.onShowMore, }); final String query; @@ -13690,10 +13796,12 @@ class _SearchSidebar extends StatelessWidget { final bool searching; final Future Function(_WorkspaceSearchResult result) onOpenResult; final Future Function() onReviewReplacement; + final _WorkspaceSearchState state; + final VoidCallback onShowMore; @override Widget build(BuildContext context) { - final normalizedQuery = query.trim(); + final normalizedQuery = query; if (normalizedQuery.isEmpty) { return _SidebarEmptyState( icon: BusyMarkGlyphs.search, @@ -13708,7 +13816,15 @@ class _SearchSidebar extends StatelessWidget { ); } final colors = BusyMarkSurfaceColors.of(context); - if (results.isEmpty) { + if (state.invalidRegex) { + return _SidebarEmptyState( + icon: BusyMarkGlyphs.searchUnavailable, + title: context.l10n.sourceSearchInvalidRegex, + ); + } + if (results.isEmpty && + state.skippedFiles.isEmpty && + !state.hasZeroLengthMatches) { return _SidebarEmptyState( icon: BusyMarkGlyphs.searchUnavailable, title: context.l10n.noResults, @@ -13734,8 +13850,35 @@ class _SearchSidebar extends StatelessWidget { ), Expanded( child: ListView( + key: const ValueKey('workspace-search-results'), padding: BusyMarkInsets.sidebarList, children: [ + if (state.hasZeroLengthMatches) + Padding( + padding: BusyMarkInsets.searchResultRow, + child: Text(context.l10n.sourceSearchZeroLengthUnsupported), + ), + if (state.truncated) + Padding( + padding: BusyMarkInsets.searchResultRow, + child: Column( + children: [ + Text(context.l10n.workspaceSearchIncomplete), + TextButton( + onPressed: onShowMore, + child: Text(context.l10n.workspaceSearchShowMore), + ), + ], + ), + ), + if (state.skippedFiles.isNotEmpty) + Padding( + padding: BusyMarkInsets.searchResultRow, + child: Text( + '${context.l10n.workspaceSearchSkippedFiles}\n' + '${state.skippedFiles.join('\n')}', + ), + ), for (final group in groups) ...[ Padding( padding: const EdgeInsets.fromLTRB( @@ -13774,6 +13917,55 @@ class _SearchSidebar extends StatelessWidget { } } +class _WorkspaceReplacementProgress extends StatefulWidget { + const _WorkspaceReplacementProgress({ + required this.future, + required this.cancellation, + }); + + final Future future; + final WorkspaceReplacementCancellation cancellation; + + @override + State<_WorkspaceReplacementProgress> createState() => + _WorkspaceReplacementProgressState(); +} + +class _WorkspaceReplacementProgressState + extends State<_WorkspaceReplacementProgress> { + @override + void initState() { + super.initState(); + widget.future.then( + (preview) { + if (mounted) Navigator.pop(context, preview); + }, + onError: (Object error, StackTrace stack) { + if (mounted) Navigator.pop(context); + }, + ); + } + + @override + void dispose() { + widget.cancellation.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BusyMarkModalEditorScaffold( + title: context.l10n.reviewReplacements, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.reviewReplacements, + onSave: null, + onCancel: () { + widget.cancellation.cancel(); + Navigator.pop(context); + }, + children: const [Center(child: CircularProgressIndicator())], + ); +} + class _WorkspaceReplacementReviewDialog extends StatefulWidget { const _WorkspaceReplacementReviewDialog({required this.preview}); @@ -13881,6 +14073,11 @@ String _workspaceReplacementIssueLabel( WorkspaceReplacementIssue issue, ) { return switch (issue.kind) { + WorkspaceReplacementIssueKind.cancelled => context.l10n.cancel, + WorkspaceReplacementIssueKind.invalidRegex => + context.l10n.sourceSearchInvalidRegex, + WorkspaceReplacementIssueKind.zeroLengthMatches => + context.l10n.sourceSearchZeroLengthUnsupported, WorkspaceReplacementIssueKind.oversized => context.l10n.workspaceReplaceIssueOversized, WorkspaceReplacementIssueKind.unreadable => @@ -14105,138 +14302,134 @@ List<_WorkspaceSearchResult> _workspaceSearchResults( const int _maxWorkspaceSearchResults = 80; const int _maxWorkspaceSearchFileBytes = 1024 * 1024; -Future> _loadWorkspaceSearchMatches( +class _WorkspaceSearchOutcome { + const _WorkspaceSearchOutcome({ + this.matches = const [], + this.truncated = false, + this.skippedFiles = const [], + this.invalidRegex = false, + this.hasZeroLengthMatches = false, + }); + + final List<_WorkspaceSearchMatch> matches; + final bool truncated; + final List skippedFiles; + final bool invalidRegex; + final bool hasZeroLengthMatches; +} + +Future<_WorkspaceSearchOutcome> _loadWorkspaceSearchMatches( WorkspaceState state, SourceSearchOptions options, { required Future Function(String path) loadText, required bool Function() isCancelled, + required SourceSearchWorker worker, + required int maximumResults, }) async { final workspace = state.workspace; - final trimmedQuery = options.query.trim(); - if (workspace == null || trimmedQuery.isEmpty || isCancelled()) { - return const []; + if (workspace == null || options.query.isEmpty || isCancelled()) { + return const _WorkspaceSearchOutcome(); } - final normalizedOptions = options.copyWith(query: trimmedQuery); - final results = <_WorkspaceSearchMatch>[]; - final activePath = workspace.activeFilePath ?? workspace.markdown?.filePath; - final sortedFiles = [...workspace.files] - ..sort((a, b) => a.relativePath.compareTo(b.relativePath)); - if (activePath != null && - sortedFiles.every((file) => file.absolutePath != activePath)) { - _addWorkspaceSearchTextMatches( - file: DocumentFile( - absolutePath: activePath, - relativePath: p.basename(activePath), - kind: DocumentKind.markdown, - size: state.activeText.length, - lastModified: DateTime.fromMillisecondsSinceEpoch(0), - ), - text: state.activeText, - options: normalizedOptions, - results: results, - ); - if (results.length >= _maxWorkspaceSearchResults) { - return List.unmodifiable(results); - } + if (sourceSearchOptionsHaveInvalidRegex(options)) { + return const _WorkspaceSearchOutcome(invalidRegex: true); } + final results = <_WorkspaceSearchMatch>[]; + final skippedFiles = []; + var hasZeroLengthMatches = false; + final sortedFiles = + workspace.files.where(isSearchableWorkspaceDocument).toList() + ..sort((a, b) => a.relativePath.compareTo(b.relativePath)); for (final file in sortedFiles) { - if (isCancelled()) { - return const []; - } - if (!_isOpenableTextDocument(file)) { - continue; - } - final String? text; - if (file.absolutePath == activePath) { + if (isCancelled()) return const _WorkspaceSearchOutcome(); + final buffer = state.bufferForPath(file.absolutePath); + String? text; + if (buffer != null) { + text = buffer.text; + } else if (file.absolutePath == + (workspace.activeFilePath ?? workspace.markdown?.filePath)) { text = state.activeText; } else if (file.size > _maxWorkspaceSearchFileBytes) { - text = null; + skippedFiles.add(file.relativePath); } else { - String? loadedText; try { - loadedText = await loadText(file.absolutePath); + text = await loadText(file.absolutePath); } on Object { - // Keep filename matching available when a document cannot be read. + skippedFiles.add(file.relativePath); } - if (isCancelled()) { - return const []; - } - text = loadedText; } + if (isCancelled()) return const _WorkspaceSearchOutcome(); if (text != null) { - _addWorkspaceSearchTextMatches( - file: file, - text: text, - options: normalizedOptions, - results: results, + final document = SourceDocument(fullText: text); + final search = await worker.search( + document, + options, + maximumMatches: maximumResults + 1 - results.length, + stopAfterMaximumMatches: true, ); - if (results.length >= _maxWorkspaceSearchResults) { - return List.unmodifiable(results); + if (isCancelled()) return const _WorkspaceSearchOutcome(); + if (search == null) { + skippedFiles.add(file.relativePath); + } else { + hasZeroLengthMatches |= search.hasZeroLengthMatches; + for (final match in search.matches) { + final line = document.lineIndex.lineNumberAtOffset(match.fullStart); + results.add( + _WorkspaceSearchMatch( + kind: _WorkspaceSearchMatchKind.text, + filePath: file.absolutePath, + relativePath: file.relativePath, + fileKind: file.kind, + line: line, + startOffset: match.fullStart, + endOffset: match.fullEnd, + query: options.query, + lineText: document.lineIndex.lineAt(line).text, + ), + ); + } } } - if (!_fileNameMatchesSearch(file.relativePath, normalizedOptions)) { - continue; - } - results.add( - _WorkspaceSearchMatch( - kind: _WorkspaceSearchMatchKind.fileName, - filePath: file.absolutePath, - relativePath: file.relativePath, - fileKind: file.kind, - line: 1, - startOffset: 0, - endOffset: 0, - query: trimmedQuery, - ), - ); - if (results.length >= _maxWorkspaceSearchResults) { - return List.unmodifiable(results); + if (results.length <= maximumResults && + _fileNameMatchesSearch(file.relativePath, options)) { + results.add( + _WorkspaceSearchMatch( + kind: _WorkspaceSearchMatchKind.fileName, + filePath: file.absolutePath, + relativePath: file.relativePath, + fileKind: file.kind, + line: 1, + startOffset: 0, + endOffset: 0, + query: options.query, + ), + ); } - } - return List.unmodifiable(results); -} - -void _addWorkspaceSearchTextMatches({ - required DocumentFile file, - required String text, - required SourceSearchOptions options, - required List<_WorkspaceSearchMatch> results, -}) { - final document = SourceDocument(fullText: text); - final searchResult = searchSourceDocument(document, options); - for (final match in searchResult.matches) { - final lineNumber = document.lineIndex.lineNumberAtOffset(match.fullStart); - final line = document.lineIndex.lineAt(lineNumber).text; - results.add( - _WorkspaceSearchMatch( - kind: _WorkspaceSearchMatchKind.text, - filePath: file.absolutePath, - relativePath: file.relativePath, - fileKind: file.kind, - line: lineNumber, - startOffset: match.fullStart, - endOffset: match.fullEnd, - query: options.query, - lineText: line, - ), - ); - if (results.length >= _maxWorkspaceSearchResults) { - return; + // Only report truncation once an additional result was actually found. + if (results.length > maximumResults) { + return _WorkspaceSearchOutcome( + matches: List.unmodifiable(results.take(maximumResults)), + truncated: true, + skippedFiles: List.unmodifiable(skippedFiles), + hasZeroLengthMatches: hasZeroLengthMatches, + ); } } + return _WorkspaceSearchOutcome( + matches: List.unmodifiable(results), + skippedFiles: List.unmodifiable(skippedFiles), + hasZeroLengthMatches: hasZeroLengthMatches, + ); } bool _fileNameMatchesSearch(String relativePath, SourceSearchOptions options) { if (options.regex || options.wholeWord) { return false; } - final haystack = options.caseSensitive - ? relativePath - : relativePath.toLowerCase(); - final needle = options.caseSensitive - ? options.query - : options.query.toLowerCase(); - return haystack.contains(needle); + return RegExp( + RegExp.escape(options.query), + caseSensitive: options.caseSensitive, + unicode: true, + ).hasMatch(relativePath); } String _searchResultTitle(BuildContext context, String line) { diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 55c4adfa..406bad2b 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -17,6 +17,7 @@ import 'package:busymark/src/app/busymark_dialog_identity.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; import 'package:busymark/src/app/startup_path.dart'; +import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/app/window_control_service.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/local_image_resolver.dart'; @@ -30,6 +31,9 @@ import 'package:busymark/src/editor/document_text_geometry.dart'; import 'package:busymark/src/editor/document_thematic_break.dart'; import 'package:busymark/src/editor/markdown_image_view.dart'; import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/source/source_search.dart'; +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/feedback/presentation/feedback_dialog.dart'; import 'package:busymark/src/export/export_options_editor.dart'; @@ -48,6 +52,7 @@ import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_file_monitor.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:flutter/gestures.dart'; @@ -8719,6 +8724,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), + workspaceFileMonitorProvider.overrideWith( + (ref) => _SearchTestFileMonitor(), + ), linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8749,7 +8758,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.pump(); await tester.enterText(find.byType(TextField), 'needle'); await tester.pump(const Duration(milliseconds: 150)); - + await _waitForSearchCondition(tester, () => service.searchReadCount == 1); expect(service.searchReadCount, 1); expect( find.byKey(const ValueKey('workspace-search-progress')), @@ -8757,8 +8766,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); service.completeSearchRead('Delayed needle result'); - await tester.pump(); - await tester.pump(); + await _waitForSearchCondition( + tester, + () => find.text('Delayed needle result').evaluate().isNotEmpty, + ); expect( find.byKey(const ValueKey('workspace-search-progress')), @@ -8801,10 +8812,357 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.pump(const Duration(milliseconds: 150)); await tester.pump(); + await _waitForSearchCondition(tester, () => service.searchReadCount == 2); expect(service.searchReadCount, 2); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'workspace search native submit uses exact payload and selected sidebar occurrence', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'cat\n cat \n cat \n', + }); + session.events.add(const HeaderBarSearchSubmitted(' cat ')); + await _waitForSearchCondition( + tester, + () => _searchSourceController(tester).searchResult.totalMatchCount == 2, + ); + await _waitForSearchCondition( + tester, + () => find.text(l10n.searchResultLine('a.md', 3)).evaluate().isNotEmpty, + ); + expect( + tester + .widget( + find.byType(HeaderBarConfigurationPublisher), + ) + .configuration + .searchQuery, + ' cat ', + ); + await tester.tap(find.text(l10n.searchResultLine('a.md', 3))); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).searchResult.currentMatchIndex == 1, + ); + expect( + _searchSourceController(tester).fullSelection, + const TextSelection(baseOffset: 10, extentOffset: 15), + ); + await tester.enterText( + find.byKey(const ValueKey('source-search-replacement')), + ' dog ', + ); + await tester.pump(); + await tester.tap(find.byTooltip(l10n.sourceSearchReplaceCurrent)); + await _waitForSearchCondition( + tester, + () => + session.container.read(workspaceControllerProvider).activeText == + 'cat\n cat \n dog \n', + ); + session.events.add(const HeaderBarSearchSubmitted(' ')); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).searchResult.options.query == ' ' && + _searchSourceController(tester).searchResult.totalMatchCount == 4, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'workspace search preserves query and lands on unopened destination', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'First file\n', + 'b.md': 'Before\nsecond needle result\n', + }); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find.text('second needle result').evaluate().isNotEmpty, + ); + await tester.tap(find.text('second needle result')); + await _waitForSearchCondition( + tester, + () => + session.container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath + ?.endsWith('/b.md') == + true, + ); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).searchResult.currentMatchIndex == 0, + ); + final source = _searchSourceController(tester); + expect( + source.fullSelection, + const TextSelection(baseOffset: 14, extentOffset: 20), + ); + expect(source.searchResult.options.query, 'needle'); + await _waitForSearchCondition( + tester, + () => find.text('second needle result').evaluate().isNotEmpty, + ); + expect( + tester + .widget( + find.byType(HeaderBarConfigurationPublisher), + ) + .configuration + .searchQuery, + 'needle', + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets('workspace search observes edits to inactive buffers', ( + tester, + ) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'First file\n', + 'b.md': 'disk needle\n', + }); + final controller = session.container.read( + workspaceControllerProvider.notifier, + ); + await controller.openActiveFile('${_SearchRegressionService.root}/b.md'); + await tester.pump(); + final buffer = session.container + .read(workspaceControllerProvider) + .activeBuffer!; + controller.updateDocumentEditorState( + buffer.id, + buffer.editorState.copyWith( + searchOptions: const SourceSearchOptions( + query: 'old query', + caseSensitive: true, + wholeWord: true, + regex: true, + ), + ), + ); + controller.updateDocumentText(buffer.id, 'unsaved needle\n'); + await controller.openActiveFile('${_SearchRegressionService.root}/a.md'); + await tester.pump(); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find.text('unsaved needle').evaluate().isNotEmpty, + ); + expect(find.text('disk needle'), findsNothing); + controller.updateDocumentText(buffer.id, 'removed\n'); + await _waitForSearchCondition( + tester, + () => find.text(l10n.noResults).evaluate().isNotEmpty, + ); + controller.updateDocumentText(buffer.id, 'added needle\n'); + await _waitForSearchCondition( + tester, + () => find.text('added needle').evaluate().isNotEmpty, + ); + await tester.tap(find.text('added needle')); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).fullSelection == + const TextSelection(baseOffset: 6, extentOffset: 12), + ); + expect( + _searchSourceController(tester).searchResult.options.query, + 'needle', + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets( + 'workspace search reports limits and continues into later files', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': List.generate(80, (i) => 'needle $i').join('\n'), + 'b.md': 'later needle\n', + }); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.workspaceSearchShowMore).evaluate().isNotEmpty, + ); + expect(find.text(l10n.workspaceSearchIncomplete), findsOneWidget); + await tester.tap(find.text(l10n.workspaceSearchShowMore)); + await _waitForSearchCondition( + tester, + () => find + .byKey(const ValueKey('workspace-search-progress')) + .evaluate() + .isEmpty, + ); + expect(find.text(l10n.workspaceSearchIncomplete), findsNothing); + final sidebarList = find.byKey( + const ValueKey('workspace-search-results'), + ); + final scrollable = find + .descendant(of: sidebarList, matching: find.byType(Scrollable)) + .first; + await tester.scrollUntilVisible( + find.text('later needle'), + 400, + scrollable: scrollable, + maxScrolls: 60, + ); + expect(find.text('later needle'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); }, ); + testWidgets('workspace search exposes skipped files and regex diagnostics', ( + tester, + ) async { + final session = await _pumpSearchRegression( + tester, + headerBarService, + { + 'a.md': 'active\n', + 'large.md': 'large needle', + 'unreadable.md': 'needle', + }, + oversized: {'large.md'}, + unreadable: {'unreadable.md'}, + ); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find + .textContaining(l10n.workspaceSearchSkippedFiles) + .evaluate() + .isNotEmpty, + ); + expect(find.text(l10n.noResults), findsNothing); + expect(find.textContaining('large.md\nunreadable.md'), findsOneWidget); + await tester.tap(find.byTooltip(l10n.sourceSearchRegex)); + session.events.add(const HeaderBarSearchQueryChanged('[')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.sourceSearchInvalidRegex).evaluate().length == 2, + ); + expect(find.text(l10n.noResults), findsNothing); + session.events.add(const HeaderBarSearchQueryChanged('^')); + await _waitForSearchCondition( + tester, + () => + find.text(l10n.sourceSearchZeroLengthUnsupported).evaluate().length == + 2, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets( + 'workspace search cancels expensive regex while UI keeps building', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'active\n', + 'b.md': '${'a' * 100}b\n', + }); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await tester.pump(); + await tester.tap(find.byTooltip(l10n.sourceSearchRegex)); + session.events.add(const HeaderBarSearchQueryChanged(r'^(a+)+$')); + await tester.pump(const Duration(milliseconds: 150)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('workspace-search-progress')), + findsOneWidget, + ); + await session.container + .read(appSettingsControllerProvider.notifier) + .setWordWrap(false); + await tester.pump(); + session.events.add(const HeaderBarSearchQueryChanged('active')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.searchResultLine('a.md', 1)).evaluate().isNotEmpty, + ); + expect( + find.byKey(const ValueKey('workspace-search-progress')), + findsNothing, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'workspace search replacement review loads an asynchronous plan', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'cat cat\n', + 'b.md': 'cat\n', + }); + session.events.add(const HeaderBarSearchQueryChanged('cat')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.workspaceReplace).evaluate().isNotEmpty, + ); + await tester.tap(find.text(l10n.workspaceReplace)); + await tester.pumpAndSettle(); + final entry = find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(TextField), + ); + await tester.enterText(entry, 'dog'); + await tester.tap(find.text(l10n.reviewReplacements)); + await _waitForSearchCondition( + tester, + () => find.text(l10n.applyReplacements).evaluate().isNotEmpty, + ); + expect(find.text('cat → dog'), findsNWidgets(3)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + expect( + session.container.read(workspaceControllerProvider).activeText, + 'cat cat\n', + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets('preview search highlights preserve Unicode offsets', ( + tester, + ) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'İ needle\n', + }, mode: DocumentViewModePreference.preview); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.searchResultLine('a.md', 1)).evaluate().isNotEmpty, + ); + expect(tester.takeException(), isNull); + expect(find.text('İ needle', findRichText: true), findsWidgets); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('preview search result clicks move preview scroll repeatedly', ( tester, ) async { @@ -8868,7 +9226,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.enterText(find.byType(TextField), 'needle'); await tester.pump(const Duration(milliseconds: 150)); - await tester.pump(); + await _waitForSearchCondition( + tester, + () => find.text('Second needle target').evaluate().isNotEmpty, + ); expect( find.text('- **First [needle target](https://example.com)**'), @@ -9026,7 +9387,14 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.enterText(find.byType(TextField), 'code needle'); await tester.pump(const Duration(milliseconds: 150)); - await tester.pump(); + await _waitForSearchCondition( + tester, + () => find + .textContaining('second code needle target') + .hitTestable() + .evaluate() + .isNotEmpty, + ); await _tapLeftmostText(tester, 'second code needle target'); await tester.pump(); @@ -9090,7 +9458,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.enterText(find.byType(TextField), 'ac'); await tester.pump(const Duration(milliseconds: 150)); - await tester.pump(); + await _waitForSearchCondition( + tester, + () => find.textContaining(target).hitTestable().evaluate().isNotEmpty, + ); await _tapLeftmostText(tester, target); await tester.pump(); @@ -10148,6 +10519,149 @@ GitFileStatus _gitStatusFile( ); } +Future _waitForSearchCondition( + WidgetTester tester, + bool Function() condition, +) async { + for (var attempt = 0; attempt < 300; attempt++) { + await tester.pump(const Duration(milliseconds: 20)); + if (condition()) return; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + fail('Timed out waiting for workspace search.'); +} + +BusyMarkSourceEditingController _searchSourceController(WidgetTester tester) => + tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + +Future< + ({ProviderContainer container, StreamController events}) +> +_pumpSearchRegression( + WidgetTester tester, + LinuxHeaderBarService headerBar, + Map sources, { + Set oversized = const {}, + Set unreadable = const {}, + DocumentViewModePreference mode = DocumentViewModePreference.source, +}) async { + final events = StreamController.broadcast(); + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(autoSave: false, documentViewMode: mode) + .toJson(); + final container = ProviderContainer( + overrides: [ + systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), + workspaceFileMonitorProvider.overrideWith( + (ref) => _SearchTestFileMonitor(), + ), + linuxHeaderBarServiceProvider.overrideWithValue(headerBar), + headerBarSearchEventsProvider.overrideWith((ref) => events.stream), + localSettingsStoreProvider.overrideWithValue(settings), + workspaceServiceProvider.overrideWithValue( + _SearchRegressionService( + sources, + oversized: oversized, + unreadable: unreadable, + ), + ), + startupPathProvider.overrideWithValue( + '${_SearchRegressionService.root}/a.md', + ), + ], + ); + addTearDown(() { + container.dispose(); + unawaited(events.close()); + }); + await tester.pumpWidget( + UncontrolledProviderScope(container: container, child: const BusyMarkApp()), + ); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeBuffer != null && + find.byType(HeaderBarConfigurationPublisher).evaluate().isNotEmpty, + ); + return (container: container, events: events); +} + +class _SearchTestFileMonitor extends WorkspaceFileMonitor { + @override + Future start({ + required String rootPath, + required Iterable openFilePaths, + }) async {} +} + +class _SearchRegressionService extends WorkspaceService { + _SearchRegressionService( + this.sources, { + this.oversized = const {}, + this.unreadable = const {}, + }); + static const root = '/tmp/busymark-search-regression'; + final Map sources; + final Set oversized; + final Set unreadable; + + @override + Future openPath(String path) async => Workspace( + id: root, + rootPath: root, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + activeFilePath: path, + files: [ + for (final entry in sources.entries) + DocumentFile( + absolutePath: '$root/${entry.key}', + relativePath: entry.key, + kind: entry.key.endsWith('.cfg') + ? DocumentKind.config + : DocumentKind.markdown, + size: oversized.contains(entry.key) + ? 1024 * 1024 + 1 + : entry.value.length, + lastModified: DateTime(2026), + ), + ], + diagnostics: const [], + markdown: markdownParser.parse( + filePath: path, + source: sources[p.basename(path)]!, + ), + ); + + @override + Future loadText(String path) async { + if (unreadable.contains(p.basename(path))) { + throw FileSystemException('unreadable', path); + } + return sources[p.basename(path)]!; + } + + @override + Future loadTextWithSnapshot(String path) async { + final text = await loadText(path); + return WorkspaceFileLoad( + text: text, + snapshot: WorkspaceFileSnapshot( + modifiedAt: DateTime(2026), + size: text.length, + contentHash: text, + ), + ); + } +} + class _SearchWorkspaceService extends WorkspaceService { const _SearchWorkspaceService(this.source, {this.writerside = false}); diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index 273b0ac2..212f3eb2 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -12,6 +12,185 @@ import 'package:path/path.dart' as p; void main() { const replacementService = SearchReplacementService(); + test('Unicode regex replacement preserves complete characters', () async { + const options = SourceSearchOptions(query: '.', regex: true); + final worker = SearchReplacementWorker(); + addTearDown(worker.dispose); + final all = await worker.previewText( + source: '😀😀', + options: options, + replacement: 'X', + ); + expect(all!.apply(), 'XX'); + final current = await worker.previewText( + source: '😀😀', + options: options, + replacement: 'X', + targetStart: 0, + targetEnd: 2, + ); + expect(current!.apply(), 'X😀'); + for (final range in [(0, 1), (1, 2), (1, 3)]) { + final unsafe = replacementService.previewMatch( + source: '😀😀', + options: options, + replacement: 'X', + start: range.$1, + end: range.$2, + ); + expect(unsafe.matches, isEmpty); + expect(unsafe.apply(), '😀😀'); + } + }); + + test('zero-length replacement limitation survives worker transfer', () async { + final worker = SearchReplacementWorker(); + addTearDown(worker.dispose); + final preview = await worker.previewText( + source: 'cat', + options: const SourceSearchOptions(query: '^', regex: true), + replacement: 'X', + ); + expect(preview!.hasZeroLengthMatches, isTrue); + expect(preview.apply(), 'cat'); + }); + + for (final count in [4999, 5000, 5001]) { + test('workspace replacement limit at $count actual matches', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-search-limit-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'a.md')); + final tail = File(p.join(directory.path, 'b.md')); + await file.writeAsString('cat ' * (count > 5000 ? 5000 : count)); + await tail.writeAsString(count > 5000 ? 'cat' : 'nothing'); + final state = WorkspaceState( + workspace: Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: [ + await _documentFile(file, directory.path), + await _documentFile(tail, directory.path), + ], + diagnostics: const [], + ), + ); + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: const WorkspaceService(), + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + expect(preview.matchCount, count > 5000 ? 5000 : count); + expect(preview.isComplete, count <= 5000); + }); + } + + test('workspace replacement cancels a running expensive regex', () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-search-cancel-', + ); + addTearDown(() => directory.delete(recursive: true)); + final file = File(p.join(directory.path, 'a.md')); + await file.writeAsString('${'a' * 100}b'); + final state = WorkspaceState( + workspace: Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: [await _documentFile(file, directory.path)], + diagnostics: const [], + ), + ); + final cancellation = WorkspaceReplacementCancellation(); + addTearDown(cancellation.cancel); + final pending = replacementService.previewWorkspace( + state: state, + workspaceService: const WorkspaceService(), + options: const SourceSearchOptions(query: r'^(a+)+$', regex: true), + replacement: 'X', + cancellation: cancellation, + ); + await Future.delayed(const Duration(milliseconds: 100)); + cancellation.cancel(); + final preview = await pending.timeout(const Duration(seconds: 5)); + expect(preview.isComplete, isFalse); + expect(preview.issues.single.kind, WorkspaceReplacementIssueKind.cancelled); + }); + + test( + 'workspace scope includes closed Writerside documents and ignores unrelated open files', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-search-scope-', + ); + addTearDown(() => directory.delete(recursive: true)); + final config = File(p.join(directory.path, 'writerside.cfg')); + final image = File(p.join(directory.path, 'image.png')); + final unknown = File(p.join(directory.path, 'unknown.bin')); + for (final file in [config, image, unknown]) { + await file.writeAsString('cat'); + } + final openLoad = await const WorkspaceService().loadTextWithSnapshot( + unknown.path, + ); + final buffer = DocumentBuffer.file( + id: 'unknown', + filePath: unknown.path, + text: openLoad.text, + snapshot: openLoad.snapshot, + format: openLoad.format, + ); + final state = WorkspaceState( + workspace: Workspace( + id: directory.path, + rootPath: directory.path, + kind: WorkspaceKind.writersideModule, + openedAt: DateTime(2026), + files: [ + for (final entry in [ + (config, DocumentKind.config), + (image, DocumentKind.image), + (unknown, DocumentKind.unknown), + ]) + DocumentFile( + absolutePath: entry.$1.path, + relativePath: p.basename(entry.$1.path), + kind: entry.$2, + size: 3, + lastModified: DateTime(2026), + ), + ], + diagnostics: const [], + ), + documentBuffers: [buffer], + activeBufferId: buffer.id, + ); + final preview = await replacementService.previewWorkspace( + state: state, + workspaceService: const WorkspaceService(), + options: const SourceSearchOptions(query: 'cat'), + replacement: 'dog', + ); + expect(preview.files.single.filePath, config.path); + final result = await replacementService.applyWorkspace( + preview: preview, + selectedMatchIds: {preview.files.single.matches.single.id}, + currentState: () => state, + updateBuffer: (_, _) => fail('Unrelated open buffer must be excluded'), + workspaceService: const WorkspaceService(), + ); + expect(result.appliedMatches, 1); + expect(await config.readAsString(), 'dog'); + expect(await image.readAsString(), 'cat'); + expect(await unknown.readAsString(), 'cat'); + }, + ); + test('previews and applies plain whole-word replacements', () { final preview = replacementService.previewText( source: 'cat scatter Cat', diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 8c7a6e92..a7d98cd1 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -40,6 +40,154 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { + for (final count in [3, 3000]) { + testWidgets('Replace current follows selected result with $count matches', ( + tester, + ) async { + final key = GlobalKey(); + final selectedIndex = count == 3 ? 1 : 2048; + var currentText = List.filled(count, 'cat').join(' '); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => BusyMarkSourceEditor( + key: key, + text: currentText, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + searchReplacement: 'dog', + onSearchReplacementChanged: (_) {}, + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => setState(() => currentText = text), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == count, + ); + // Establish a different current index before using the sidebar's API. + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchNextMatch), + ); + await tester.pump(); + key.currentState!.scrollToSearchRange( + line: 1, + startOffset: selectedIndex * 4, + endOffset: selectedIndex * 4 + 3, + ); + await _pumpUntil( + tester, + () => controller.searchResult.currentMatchIndex == selectedIndex, + ); + await tester.pump(); + expect(controller.fullSelection.start, selectedIndex * 4); + expect(find.text('${selectedIndex + 1} / $count'), findsOneWidget); + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchReplaceCurrent), + ); + await _pumpUntil( + tester, + () => currentText.split(' ')[selectedIndex] == 'dog', + ); + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == count - 1, + ); + expect( + controller.searchResult.currentMatch!.fullStart, + (selectedIndex + 1) * 4, + ); + expect(controller.fullSelection.start, (selectedIndex + 1) * 4); + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchReplaceCurrent), + ); + await _pumpUntil( + tester, + () => currentText.split(' ')[selectedIndex + 1] == 'dog', + ); + expect(currentText.split(' ').take(selectedIndex), everyElement('cat')); + expect( + currentText.split(' ').where((word) => word == 'dog'), + hasLength(2), + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + } + + testWidgets('search navigation retains a later window when unfolding', ( + tester, + ) async { + final source = '# Section\n${'cat\n' * 3000}'; + final region = sourceFoldRegions( + source, + SourceSyntaxLanguage.markdown, + ).first; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + initialFoldedRegionKeys: {region.key}, + onSearchOptionsChanged: (_) {}, + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == 3000, + ); + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchPreviousMatch), + ); + await _pumpUntil( + tester, + () => + controller.searchResult.currentMatchIndex == 2999 && + controller.searchResult.currentMatch?.hidden == false, + ); + expect(controller.fullSelection.start, source.lastIndexOf('cat')); + expect(controller.searchResult.firstMatchIndex, greaterThan(0)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + testWidgets('source AI action applies a selection through the editor path', ( tester, ) async { diff --git a/test/src/source_search_test.dart b/test/src/source_search_test.dart index c78c99ec..d7c18a17 100644 --- a/test/src/source_search_test.dart +++ b/test/src/source_search_test.dart @@ -4,6 +4,60 @@ import 'package:busymark/src/editor/source/source_search.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('regex uses Unicode code points and diagnoses zero-length matches', () { + final document = SourceDocument(fullText: '😀'); + final result = searchSourceDocument( + document, + const SourceSearchOptions(query: '.', regex: true), + ); + expect(result.matches.single.fullStart, 0); + expect(result.matches.single.fullEnd, 2); + for (final query in ['^', r'$', '(?=😀)']) { + final positions = searchSourceDocument( + document, + SourceSearchOptions(query: query, regex: true), + ); + expect(positions.matches, isEmpty); + expect(positions.hasZeroLengthMatches, isTrue); + } + const invalidInUnicodeMode = SourceSearchOptions(query: r'\q', regex: true); + expect(sourceSearchOptionsHaveInvalidRegex(invalidInUnicodeMode), isTrue); + expect( + searchSourceDocument(document, invalidInUnicodeMode).invalidRegex, + isTrue, + ); + }); + + test('workspace-style bounded matching stops after the sentinel match', () { + final result = searchSourceDocument( + SourceDocument(fullText: 'cat ' * 3000), + const SourceSearchOptions(query: 'cat'), + maximumMatches: 81, + stopAfterMaximumMatches: true, + ); + expect(result.matches, hasLength(81)); + expect(result.totalMatchCount, 81); + }); + + test('search worker terminates a running backtracking expression', () async { + final worker = SourceSearchWorker(); + addTearDown(worker.dispose); + final slow = worker.search( + SourceDocument(fullText: '${'a' * 100}b'), + const SourceSearchOptions(query: r'^(a+)+$', regex: true), + ); + await Future.delayed(const Duration(milliseconds: 100)); + final current = worker.search( + SourceDocument(fullText: 'needle'), + const SourceSearchOptions(query: 'needle'), + ); + expect(await slow.timeout(const Duration(seconds: 5)), isNull); + expect( + (await current.timeout(const Duration(seconds: 5)))!.totalMatchCount, + 1, + ); + }); + test('plain search finds case-insensitive matches by default', () { final result = searchSourceDocument( SourceDocument(fullText: 'Alpha alpha'), From 506721606e886e7a66c52436b50e2ace3622b930 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 15:00:02 -0700 Subject: [PATCH 25/27] Fix search: add failure tracking and ensure safe navigation, replacement limits, and snippets - Track `_searchNavigationFailed` to handle unsuccessful search navigation. - Ensure `Replace Current` respects valid states by introducing `canReplaceCurrent` checks. - Refine `_boundedSearchSnippet` to enforce boundary safety and improve snippet generation. - Update workspace search to handle zero-length matches and truncate snippets with safe boundaries. - Reset navigation failure and requested range states during key search operations. --- lib/src/editor/source/source_editor.dart | 29 ++++- .../presentation/workspace_screen.dart | 27 ++++- test/src/app_smoke_test.dart | 42 ++++++++ test/src/source_editor_widget_test.dart | 100 ++++++++++++++++++ 4 files changed, 191 insertions(+), 7 deletions(-) diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 419deaa4..f1c30e91 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -153,6 +153,7 @@ class BusyMarkSourceEditorState extends State { final _replacementWorker = SearchReplacementWorker(); bool _applyingSearchReplacement = false; ({int start, int end})? _requestedSearchRange; + bool _searchNavigationFailed = false; final _intrinsicWidthCache = SourceIntrinsicWidthCache(); final _lineLayoutCache = SourceLineLayoutCache(); final _autocompleteProvider = const SourceAutocompleteProvider(); @@ -238,6 +239,7 @@ class BusyMarkSourceEditorState extends State { widget.searchOptions != oldWidget.searchOptions || authoritativeDocumentChanged) { _requestedSearchRange = null; + _searchNavigationFailed = false; _syncSearchOptions(); } if (widget.wordWrap && !oldWidget.wordWrap) { @@ -341,6 +343,7 @@ class BusyMarkSourceEditorState extends State { final end = endOffset.clamp(start, _controller.fullText.length).toInt(); _replacementWorker.cancel(); _requestedSearchRange = start < end ? (start: start, end: end) : null; + _searchNavigationFailed = false; _focusNode.requestFocus(); _controller.fullSelection = TextSelection( baseOffset: start, @@ -741,6 +744,8 @@ class BusyMarkSourceEditorState extends State { ), ), replacement: widget.searchReplacement, + canReplaceCurrent: + _requestedSearchRange == null && !_searchNavigationFailed, onReplacementChanged: widget.onSearchReplacementChanged ?? (_) {}, onReplaceCurrent: () => unawaited(_replaceCurrentSearchMatch()), @@ -905,16 +910,21 @@ class BusyMarkSourceEditorState extends State { _searchController.acceptResult(result); final requestedRange = _requestedSearchRange; if (requestedRange != null) { + _requestedSearchRange = null; final localIndex = result.matches.indexWhere( (match) => match.fullStart == requestedRange.start && match.fullEnd == requestedRange.end, ); + _searchNavigationFailed = localIndex < 0; if (localIndex >= 0) { _searchController.setCurrentMatchIndex( result.firstMatchIndex + localIndex, ); - _requestedSearchRange = null; + } else { + // A completed lookup with no exact match must not silently + // turn Replace Current into replacement of the first result. + _searchController.setCurrentMatchIndex(null); } } else if (minimumFullOffset != null && result.matches.isNotEmpty) { @@ -958,6 +968,7 @@ class BusyMarkSourceEditorState extends State { void _nextSearchMatch() { _requestedSearchRange = null; + _searchNavigationFailed = false; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -971,6 +982,7 @@ class BusyMarkSourceEditorState extends State { void _previousSearchMatch() { _requestedSearchRange = null; + _searchNavigationFailed = false; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -1003,7 +1015,8 @@ class BusyMarkSourceEditorState extends State { Future _replaceCurrentSearchMatch({bool findNext = false}) async { if (_searchController.result.invalidRegex || - _requestedSearchRange != null) { + _requestedSearchRange != null || + _searchNavigationFailed) { return; } if (_searchController.result.currentMatchIndex == null) { @@ -1209,6 +1222,10 @@ class BusyMarkSourceEditorState extends State { void _handleSourceChanged() { _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. + _requestedSearchRange = null; + _searchNavigationFailed = false; final visibleEdit = _controller.lastVisibleEdit; final selection = _controller.fullSelection; final previousSelection = @@ -2532,6 +2549,7 @@ class _SourceSearchPanel extends StatefulWidget { required this.onToggleWholeWord, required this.onToggleRegex, required this.replacement, + required this.canReplaceCurrent, required this.onReplacementChanged, required this.onReplaceCurrent, required this.onReplaceAndFindNext, @@ -2546,6 +2564,7 @@ class _SourceSearchPanel extends StatefulWidget { final VoidCallback onToggleWholeWord; final VoidCallback onToggleRegex; final String replacement; + final bool canReplaceCurrent; final ValueChanged onReplacementChanged; final VoidCallback onReplaceCurrent; final VoidCallback onReplaceAndFindNext; @@ -2683,14 +2702,16 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { _SearchPanelIconButton( tooltip: context.l10n.sourceSearchReplaceCurrent, icon: BusyMarkGlyphs.edit, - onPressed: result.totalMatchCount == 0 + onPressed: + result.totalMatchCount == 0 || !widget.canReplaceCurrent ? null : widget.onReplaceCurrent, ), _SearchPanelIconButton( tooltip: context.l10n.sourceSearchReplaceAndFindNext, icon: BusyMarkGlyphs.forwardFor(Directionality.of(context)), - onPressed: result.totalMatchCount == 0 + onPressed: + result.totalMatchCount == 0 || !widget.canReplaceCurrent ? null : widget.onReplaceAndFindNext, ), diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index d7222f75..a26bb696 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -14373,6 +14373,7 @@ Future<_WorkspaceSearchOutcome> _loadWorkspaceSearchMatches( hasZeroLengthMatches |= search.hasZeroLengthMatches; for (final match in search.matches) { final line = document.lineIndex.lineNumberAtOffset(match.fullStart); + final sourceLine = document.lineIndex.lineAt(line); results.add( _WorkspaceSearchMatch( kind: _WorkspaceSearchMatchKind.text, @@ -14383,7 +14384,10 @@ Future<_WorkspaceSearchOutcome> _loadWorkspaceSearchMatches( startOffset: match.fullStart, endOffset: match.fullEnd, query: options.query, - lineText: document.lineIndex.lineAt(line).text, + lineText: _boundedSearchSnippet( + sourceLine.text, + matchStart: match.fullStart - sourceLine.startOffset, + ), ), ); } @@ -14437,11 +14441,28 @@ String _searchResultTitle(BuildContext context, String line) { if (trimmed.length <= 120) { return trimmed; } - return '${trimmed.substring(0, 117)}...'; + var end = 117; + if (!sourceSearchRangeHasSafeBoundaries(trimmed, end, end)) end--; + return '${trimmed.substring(0, end)}...'; +} + +// Limit the input to Markdown cleanup, whose regexes can backtrack on malformed +// markup. Keep the match near the beginning so it survives the title's shorter +// display limit. The two ellipses are included in the 512-code-unit budget. +String _boundedSearchSnippet(String line, {int matchStart = 0}) { + const maximumLength = 512; + if (line.length <= 120) return line; + var start = math.max(0, matchStart.clamp(0, line.length) - 40); + if (!sourceSearchRangeHasSafeBoundaries(line, start, start)) start--; + var end = math.min(line.length, start + maximumLength - 2); + if (!sourceSearchRangeHasSafeBoundaries(line, end, end)) end--; + return '${start > 0 ? '…' : ''}${line.substring(start, end)}' + '${end < line.length ? '…' : ''}'; } String _stripMarkdownForSearchResult(String line, {BuildContext? context}) { - var value = line.trim(); + // Preview navigation also calls this helper directly with source lines. + var value = _boundedSearchSnippet(line).trim(); final fence = RegExp( r'^(```+|~~~+)\s*([A-Za-z0-9_+\-#.]*)', ).firstMatch(value); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 406bad2b..81bef8b1 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -9163,6 +9163,48 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.pump(const Duration(seconds: 1)); }); + testWidgets( + 'literal workspace search bounds malformed Markdown snippets around the match', + (tester) async { + final session = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'Active document\n', + 'b.md': 'First line\n${'[' * 40000}needle\n', + }); + session.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => find.text(l10n.searchResultLine('b.md', 2)).evaluate().isNotEmpty, + ); + final results = find.byKey(const ValueKey('workspace-search-results')); + final title = tester + .widgetList( + find.descendant(of: results, matching: find.byType(Text)), + ) + .singleWhere((text) => text.data?.contains('needle') == true) + .data!; + expect(title.length, lessThanOrEqualTo(120)); + expect(title, startsWith('…')); + expect(title, endsWith('needle')); + expect( + session.container.read(workspaceControllerProvider).documentBuffers, + hasLength(1), + ); + // Result titles are also formatted during unrelated workspace rebuilds. + final rebuild = Stopwatch()..start(); + final settings = session.container.read(appSettingsControllerProvider); + await session.container + .read(appSettingsControllerProvider.notifier) + .setWordWrap(!settings.wordWrap); + await tester.pump(); + rebuild.stop(); + expect(rebuild.elapsed, lessThan(const Duration(seconds: 1))); + expect(find.text(title), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + testWidgets('preview search result clicks move preview scroll repeatedly', ( tester, ) async { diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index a7d98cd1..3f7eb7b0 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -40,6 +40,106 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { + for (final lookupFinished in [false, true]) { + for (final findNext in [false, true]) { + testWidgets( + 'search replacement recovers after editing a ${lookupFinished ? 'missing' : 'pending'} result (${findNext ? 'find next' : 'current'})', + (tester) async { + final key = GlobalKey(); + var text = lookupFinished ? 'dog cat cat' : 'cat cat cat'; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => BusyMarkSourceEditor( + key: key, + text: text, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + searchReplacement: 'bat', + onSearchReplacementChanged: (_) {}, + onSearchOptionsChanged: (_) {}, + onChanged: (value, _) => setState(() => text = value), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + // Click before the initial search debounce expires, as happens when + // entering a previously unopened document through the sidebar. + key.currentState!.scrollToSearchRange( + line: 1, + startOffset: 0, + endOffset: 3, + ); + await tester.pump(); + final en = AppLocalizationsEn(); + YaruIconButton button(String tooltip) => tester + .widgetList(find.byType(YaruIconButton)) + .singleWhere((button) => button.tooltip == tooltip); + if (lookupFinished) { + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == 2, + ); + await tester.pump(); + // A missing target is settled with replacement unavailable, not + // silently redirected to a different occurrence. + expect(controller.searchResult.currentMatchIndex, isNull); + expect(button(en.sourceSearchReplaceCurrent).onPressed, isNull); + expect(button(en.sourceSearchReplaceAndFindNext).onPressed, isNull); + expect(button(en.sourceSearchNextMatch).onPressed, isNotNull); + expect( + controller.fullSelection, + const TextSelection(baseOffset: 0, extentOffset: 3), + ); + } else { + expect(controller.searchResult.matches, isEmpty); + } + final edited = lookupFinished ? 'fox cat cat' : 'dog cat cat'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: edited, + selection: const TextSelection.collapsed(offset: 3), + ), + ); + await tester.pump(); + expect(text, edited); + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == 2, + ); + await tester.pump(); + final tooltip = findNext + ? en.sourceSearchReplaceAndFindNext + : en.sourceSearchReplaceCurrent; + expect(button(tooltip).onPressed, isNotNull); + await tester.tap(find.byTooltip(tooltip)); + await _pumpUntil( + tester, + () => text == edited.replaceRange(4, 7, 'bat'), + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + } + } + for (final count in [3, 3000]) { testWidgets('Replace current follows selected result with $count matches', ( tester, From 10ac024296569d9e4a3f7cc661fa36a5aa0cb6a4 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 15:14:21 -0700 Subject: [PATCH 26/27] Update docs --- README.md | 140 +++++---- docs/README.md | 39 +++ docs/clipboard-fix-plan.md | 88 ------ docs/development/ai.md | 62 ++++ docs/development/export-verification.md | 83 ++++++ docs/development/history.md | 144 +++++++++ docs/development/visualizations.md | 89 ++++++ docs/development/writerside-verification.md | 51 ++++ docs/history-architecture.md | 241 --------------- docs/history.md | 221 ++++++-------- docs/html-export-verification.md | 110 ------- docs/local-ai.md | 306 +++++++------------- docs/search-and-replace.md | 78 +++++ docs/visualizations.md | 284 ++++++------------ docs/writerside-instances.md | 8 - docs/writerside-support.md | 50 +--- 16 files changed, 916 insertions(+), 1078 deletions(-) create mode 100644 docs/README.md delete mode 100644 docs/clipboard-fix-plan.md create mode 100644 docs/development/ai.md create mode 100644 docs/development/export-verification.md create mode 100644 docs/development/history.md create mode 100644 docs/development/visualizations.md create mode 100644 docs/development/writerside-verification.md delete mode 100644 docs/history-architecture.md delete mode 100644 docs/html-export-verification.md create mode 100644 docs/search-and-replace.md diff --git a/README.md b/README.md index f8c5cc26..d1dafd74 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,73 @@ Markdown and Writerside editor for Linux. * **AI-assisted editing** — Optional Ollama, OpenAI, and Gemini integration with explicit edit scope and shared context, proposal review before changes are applied, and AI-assisted Git commit-message drafting. * **Workspace reliability** — Detect files changed outside BusyMark, recover unsaved documents, restore previous workspace sessions, manage remote-image permissions, and protect Git operations behind workspace trust. +## Installation + +Install BusyMark from the Snap Store: + +```bash +sudo snap install busymark --beta +``` + +The Snap uses strict confinement. See [Snap confinement](docs/snap-confinement.md) +for filesystem and Git-integration details. + +## Run from source + +BusyMark currently uses Flutter 3.47.2. Install the Linux packages required by +Flutter and BusyMark's CMake configuration: + +```bash +sudo apt-get install -y \ + clang \ + cmake \ + curl \ + fonts-noto-core \ + fonts-noto-mono \ + libgtk-3-dev \ + libhandy-1-dev \ + libsecret-1-dev \ + libwebkit2gtk-4.1-dev \ + ninja-build \ + pkg-config \ + xz-utils +``` + +Node.js 22 or newer with npm is also required to build the bundled web +components. + +Prepare and run the application: + +```bash +flutter doctor +flutter pub get +flutter run -d linux +``` + +Pass a Markdown file or documentation directory after `--` to open it at +startup: + +```bash +flutter run -d linux -- /absolute/path/to/document.md +flutter run -d linux -- /absolute/path/to/documentation +``` + +Build and run the standard checks with: + +```bash +flutter build linux +flutter analyze +flutter test +``` + +The build assembles BusyMark's runtime components. Packaged users do not need +separate Typst, Java, Node.js, or Chromium installations. + +The CI workflow installs additional packages for headless X11/Wayland, browser, +PDF, and sandbox verification. Those packages are not required for an ordinary +local build; see [the Linux workflow](.github/workflows/flutter-linux.yml) when +reproducing release verification. + ## Screenshots @@ -75,6 +142,11 @@ Markdown and Writerside editor for Linux.
    +## Documentation + +The [documentation index](docs/README.md) lists user guides and the separate +developer notes. + ## Markdown and Writerside BusyMark opens Markdown files using `.md` and `.markdown` extensions and can work with ordinary documentation folders. @@ -83,11 +155,18 @@ Writerside-compatible projects are recognized through `writerside.cfg` and the o More information: +* [Writerside support and limitations](docs/writerside-support.md) * [Writerside instances](docs/writerside-instances.md) * [Writerside videos](docs/videos.md) * [Admonitions](docs/admonitions.md) * [Collapsible elements](docs/collapsible-elements.md) +## Search and replace + +BusyMark supports active-document replacement and reviewed workspace-wide +replacement. See [Search and replace](docs/search-and-replace.md) for search +options, scope, preview behavior, and stale-result handling. + ## Diagrams and mathematics BusyMark renders Mermaid, PlantUML, D2, and fenced OpenAPI content locally. Mathematical expressions are rendered with the bundled MathJax environment. @@ -122,66 +201,7 @@ See [AI editing](docs/local-ai.md) for configuration, privacy, provider behavior See [Clipboard History and Local History](docs/history.md) for collection scope, limits, retention, restoration, exclusions, and how these tools differ from undo, crash recovery, and Git. Implementation and verification details are in -[the history architecture note](docs/history-architecture.md). - -## Installation - -Install BusyMark from the Snap Store: - -```bash -sudo snap install busymark --beta -``` - -The Snap uses strict confinement. See [Snap confinement notes](docs/snap-confinement.md) for details about filesystem and Git integration. - -## Run from source - -BusyMark currently uses Flutter 3.47.2. - -Install the required Linux development packages: - -```bash -sudo apt-get install \ - curl \ - libhandy-1-dev \ - xz-utils \ - libwebkit2gtk-4.1-dev \ - fonts-noto-core \ - fonts-noto-mono -``` - -Node.js 22 or newer with npm is also required when building the bundled web components. - -Prepare and run the application: - -```bash -flutter doctor -flutter pub get -flutter run -d linux -``` - -A Markdown file or documentation folder can be opened directly from the command line: - -```bash -flutter run -d linux -``` - -## Build - -Build the Linux desktop application with: - -```bash -flutter build linux -``` - -The build assembles the runtime components required by BusyMark. Packaged users do not need separate Typst, Java, Node.js, or Chromium installations. - -## Test - -```bash -flutter analyze -flutter test -``` +[the history implementation notes](docs/development/history.md). ## Contributing diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..674884f6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,39 @@ +# BusyMark documentation + +This directory contains user guides for BusyMark and a separate set of notes +for contributors. Start with the workflow you need; syntax and limitation pages +are intentionally independent so they can be referenced directly. + +## Everyday workflows + +- [Search and replace](search-and-replace.md) +- [AI editing](local-ai.md) +- [Clipboard History and Local History](history.md) +- [Snap confinement](snap-confinement.md) + +## Writerside + +- [Supported content and limitations](writerside-support.md) +- [Instances and TOC libraries](writerside-instances.md) +- [Admonitions](admonitions.md) +- [Collapsible elements](collapsible-elements.md) +- [Videos](videos.md) + +## Technical content + +- [Mathematical expressions](math.md) +- [Offline visualizations](visualizations.md) + +## Export + +- [PDF export](pdf-export.md) +- [Writerside PDF export](writerside-pdf-export.md) +- [HTML export](html-export.md) + +## Developer notes + +- [AI implementation and qualification](development/ai.md) +- [Export verification](development/export-verification.md) +- [History implementation](development/history.md) +- [Visualization implementation and verification](development/visualizations.md) +- [Writerside verification](development/writerside-verification.md) diff --git a/docs/clipboard-fix-plan.md b/docs/clipboard-fix-plan.md deleted file mode 100644 index 559776fa..00000000 --- a/docs/clipboard-fix-plan.md +++ /dev/null @@ -1,88 +0,0 @@ -# Editor clipboard fix - -Status: implemented for the Linux desktop host. - -Editor copy previously published only plain text. Formatting stayed in a private -cache inside one Editor widget. Copy now publishes interoperable text and HTML -plus an opaque BusyMark ownership token in one GTK clipboard operation, and -ordinary paste uses the richest safely recognized representation. - -| Representation | Purpose | -| --- | --- | -| `application/x-busymark-token` | Resolve immutable structured content only inside the BusyMark process/session that created it. | -| `text/html` | Preserve headings, emphasis, links, lists, tables, and code in compatible external applications. Tasks have visible checked/unchecked symbols. | -| Standard GTK text targets | Support text-only destinations and Paste without formatting. | - -## Implementation - -- `rich_clipboard_service.dart` serializes competing writes and coherent reads. - It keeps a bounded, process-session map from opaque tokens to immutable rich - fragments; unknown tokens never deserialize internal object graphs. A native - write publishes all formats together and is never followed by a plain write - that would erase them. -- `rich_clipboard_host.cc` owns immutable payload bytes independently of the - originating widget. Reads are asynchronous and reject data from an owner that - changes during the read. Transfers are limited to 16 MiB. The host advertises - all formats for clipboard-manager storage using - [`gtk_clipboard_set_can_store`](https://docs.gtk.org/gtk3/method.Clipboard.set_can_store.html). - GTK's existing application shutdown performs the storage request. -- `wysiwyg_clipboard_fragment.dart` uses a versioned, bounded, validated JSON - schema inside the owning process. Block IDs are regenerated during insertion. - Relative links are rebased and retained local media bytes are ingested for the - destination document when a Clipboard History item is inserted. -- `wysiwyg_clipboard_html.dart` exports semantic HTML directly from the copied - blocks. On import it normalizes supported CSS emphasis and checkbox state, - strips executable markup and unsafe URLs, and uses the existing HTML adapter. - It does not change the document HTML policy or write publication assets. -- Editor copy snapshots the selection before asynchronous work. Cut deletes only - after a successful write. Paste and cut recheck the file, document generation, - active block/cell, and selection before changing the document. Nested children - are copied once; complete blockquotes retain their container. -- Insertion preserves inline nodes, including image references, and supports - partial selections and one-step undo/redo. Table cells retain supported inline - styles while block boundaries become spaces. Cells displayed as math source - retain their existing text insertion behavior. - -## Validation - -- 238 editor/model regression tests pass, including fresh Editor instances, - same-Editor cut/paste, partial selections, nested lists, blockquotes, tasks, - tables, code, Writerside, images, RTL, math, undo/redo, malformed fragments, - serialized writes, failed cut, delayed paste, and a later plain-text copy. -- A document-wide selection has its own right-click menu for Cut, Copy, Paste, - Select all, and Refine with AI when available. The selected blocks stay active - while the menu opens, including when the pointer is over a table cell. -- Both reported files, `issues.md` and `writerside-support.md`, passed a read-only - codec and insertion probe. Heading, task, table, blockquote, and code content - survived; exported HTML also imported successfully. -- Separate compiled BusyMark processes exchanged interoperable HTML and plain - text on isolated X11. The foreign opaque token deliberately did not resolve, - while a same-process round trip resolved its rich fragment. A subsequent - Flutter plain copy cleared the rich formats. -- LibreOffice Writer pasted a Heading 1 paragraph, bold text, and visible checked - and unchecked task states from the compiled Linux implementation. - -The native probe entrypoint is `tools/clipboard_smoke.dart`. Build it with -`flutter build linux --debug -t tools/clipboard_smoke.dart`. Run `roundtrip` to -verify same-process token resolution. Then run `write` and, while it owns the -clipboard, run `read` from a second process to verify interoperable HTML plus -non-resolution of the foreign token. Run `plain` afterward to verify stale rich -representations are gone. Use the same temporary output directory on an isolated -display; creating a `stop` file in that directory closes the writer. Rebuild with -the default entrypoint afterward. Under `xvfb-run`, set `GDK_BACKEND=x11` -explicitly so an inherited `WAYLAND_DISPLAY` cannot select an unrelated Wayland -connection. Wayland native behavior requires a compositor-driven run with real -input focus; the isolated automated probe in this checkout verifies X11. - -## Interoperability limits - -Clipboard persistence after the source process exits depends on the desktop's -clipboard manager and the formats it retains. That exit scenario remains a manual -check. Source-widget disposal does not discard clipboard payloads. External -applications choose which offered format to consume; plain-text applications -will still paste plain text. Writerside-specific structures remain editable -through the owning session's immutable payload and use readable HTML fallbacks -externally. Clipboard History packages supported local media bytes within its -stated budget. - -Hosts without the Linux method channel currently fall back to plain text. diff --git a/docs/development/ai.md b/docs/development/ai.md new file mode 100644 index 00000000..9d6cd223 --- /dev/null +++ b/docs/development/ai.md @@ -0,0 +1,62 @@ +# AI implementation and qualification + +This note describes the engineering boundaries behind BusyMark's user-facing +[AI editing workflow](../local-ai.md). Keep provider setup and editing +instructions in the user guide; keep transport, policy, and release checks here. + +## Components and invariants + +The provider-neutral implementation lives under `lib/src/ai/`: + +- provider adapters normalize OpenAI and Gemini SSE and Ollama NDJSON into one + typed event stream; +- `AiCoordinator` owns provider-local model fallback, retry, concurrency, + cancellation, and latest-request-wins behavior; +- `AiMarkdownEditResolver` converts the selected target and context into exact + source ranges and safe block insertions; +- `AiPromptBuilder`, `AiPolicy`, and `AiMarkdownGuard` enforce request budgets + and validate the complete candidate document independently of the provider; +- the Linux credential host stores cloud keys with libsecret without opening + the global Secret Service collection. + +Routing and retries never cross provider boundaries. Transient `408`, `409`, +`429`, and selected `5xx` responses may be retried before output is exposed. +`Retry-After` is honored, with bounded exponential backoff and jitter. A request +is never restarted after text has streamed because doing so could duplicate +output. + +Input, prompt, instruction, output-token, generated-byte, transport-byte, and +absolute-duration limits are enforced separately. At most two generations run +concurrently. A newer request for the same target cancels the older request. + +## Deterministic checks + +The automated tests cover fragmented and malformed streams, redirects, response +bounds, provider isolation, fallback, cancellation, deadlines, retries, +credential redaction, context budgets, Markdown invariants, Source and Editor +application paths, commit-message validation, and packaging. + +Run the normal repository checks first: + +```bash +flutter analyze +flutter test +``` + +## Local Ollama qualification + +Release qualification must exercise each exact provider/model combination that +will be supported. Cloud runs require an intentionally supplied test credential; +CI must not enable them implicitly. Generated prose is probabilistic, so review +task quality as well as structural validity. + +The maintained local corpus covers all five edit targets, different context +choices, complete-document editing, and staged-diff commit-message generation: + +```bash +OLLAMA_NO_CLOUD=1 ollama serve +dart run tools/ai_ollama_qualification.dart --model +``` + +The command prints proposals for human review and exits unsuccessfully when a +request fails or a structural check rejects the result. diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md new file mode 100644 index 00000000..ffca9676 --- /dev/null +++ b/docs/development/export-verification.md @@ -0,0 +1,83 @@ +# Export verification + +This is the maintained verification procedure for BusyMark's PDF and HTML +export paths. User-facing settings and limitations are documented in +[PDF export](../pdf-export.md), [HTML export](../html-export.md), and +[Writerside PDF export](../writerside-pdf-export.md). + +## Implementation map + +- `lib/src/export/export_options.dart` defines validated, serializable options + shared by the export dialogs. +- `lib/src/export/export_options_editor.dart` provides the Markdown and + Writerside settings editors. +- `lib/src/export/typst_payload_builder.dart`, + `lib/src/export/markdown_export_mapper.dart`, and `assets/export/markdown.typ` + implement the native PDF model and layout. +- `lib/src/export/html_document_writer.dart`, + `lib/src/export/html_export_styles.dart`, and + `lib/src/export/html_export_assets.dart` implement offline HTML output. +- `lib/src/visualization/visualization_release_smoke.dart` exercises the bundled + visualization, math, PDF, and HTML product paths through the release binary. + +## Repository checks + +Use the repository-pinned Flutter and Dart versions: + +```bash +flutter gen-l10n +dart format --set-exit-if-changed . +flutter analyze +flutter test +flutter build linux --release +``` + +The tests cover option parsing and persistence, dialog behavior, output bounds, +metadata, TOC hierarchy and numbering, CSS validation, embedded and external +assets, math and diagram rendering, Writerside resolution, cancellation, and +preservation of existing output after failure. + +## Release product-path smoke + +The release-only smoke entry point is gated by `BUSYMARK_RELEASE_SMOKE=1`. Write +artifacts to a fresh temporary directory: + +```bash +verification_dir="$(mktemp -d)" +report="$verification_dir/report.json" +BUSYMARK_RELEASE_SMOKE=1 \ + xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 build/linux/x64/release/bundle/busymark \ + --visualization-release-smoke="$report" +``` + +Require a successful exit, `"ok": true` in `report.json`, non-empty PDF output, +and the expected Markdown and Writerside HTML directories. Retain artifacts only +when they are needed to diagnose a failure; temporary paths are not release +evidence by themselves. + +## Relocated browser verification + +`tools/verify_html_export_browser.py` copies the generated HTML away from its +source location, opens it through both `file:` and a local HTTP server, and +checks offline resources, navigation, anchors, print behavior, color schemes, +custom CSS, CSP enforcement, and layout. It requires Selenium, Chromium or +Chrome, Firefox with geckodriver, and `pdftotext`: + +```bash +browser_output="$verification_dir/browser" +python3 tools/verify_html_export_browser.py "$verification_dir" \ + --output "$browser_output" +``` + +Review `browser-report.json` and representative screenshots in the output +directory. No unexpected external request is acceptable. Browser checks do not +replace a manual inspection of representative PDF and HTML documents, and they +do not certify physical-printer or assistive-technology behavior. + +## CI environment + +The Linux workflow in `.github/workflows/flutter-linux.yml` is authoritative for +native packages, environment flags, browser setup, X11 and Wayland runs, and the +strict Snap verification. Keep ordinary build prerequisites in the main README; +do not copy the larger CI-only dependency set into user installation steps. diff --git a/docs/development/history.md b/docs/development/history.md new file mode 100644 index 00000000..76d13d23 --- /dev/null +++ b/docs/development/history.md @@ -0,0 +1,144 @@ +# History implementation notes + +This document records the invariants behind the user-facing +[Clipboard History and Local History](../history.md) features. + +## Clipboard publication and ownership + +`RichClipboardService` serializes clipboard reads and writes and owns the +current BusyMark rich payload independently of `ClipboardHistoryController`. +Each publication sends three interoperable representations in one Linux host +operation: + +| Representation | Contract | +| --- | --- | +| `application/x-busymark-token` | Resolves immutable structured content only in the originating BusyMark process and session. | +| `text/html` | Preserves supported semantic formatting for other applications. | +| Standard GTK text targets | Supports text-only destinations and paste without formatting. | + +The token maps to a bounded in-memory payload; unknown or stale tokens are +external data, and matching text is never proof of ownership. Clearing retained +history does not invalidate the current rich clipboard. + +The GTK host owns immutable payload bytes, rejects a read if the clipboard owner +changes during transfer, and limits transfers to 16 MiB. It advertises formats +for clipboard-manager persistence. Hosts without the Linux method channel fall +back to Flutter's plain-text clipboard. + +`WysiwygClipboardFragment` uses a bounded, versioned, validated JSON schema. +Insertion regenerates block IDs, rebases relative links, and ingests retained +local media through the destination document's asset path. `WysiwygClipboardHtml` +exports semantic HTML and imports only supported structure, safe URLs, emphasis, +and task state; executable markup is discarded. Unsupported input follows the +existing plain-text fallback. + +Copy and cut snapshot the selection before asynchronous publication. Cut deletes +only after a successful write and after revalidating the document, active target, +and selection. Paste uses the same revalidation and normal one-step undo path. + +## Clipboard retention + +`ClipboardHistoryController` retains successful BusyMark copy/cut payloads and +supported external content only after a successful paste. Payloads contain their +origin metadata, available representations, a SHA-256 equivalence fingerprint, +and byte accounting. The default policy is 100 entries, 64 MiB total payload, +and 8 MiB of decoded thumbnails. Deduplication compares every meaningful +representation. + +The insertion registry exposes only the latest mounted editable surface and uses +identity-safe unregistering. An adapter must capture and revalidate its document, +revision, mode, selection generation, and target lifetime around asynchronous +work. + +## Local History capture and identity + +`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. +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. + +History identity is independent of widgets and temporary buffer IDs. Known file +and directory moves remap paths while preserving earlier paths. The first save +of an untitled document continues its lineage. Save As keeps the source lineage +separate and starts or appends to the destination lineage, protecting an +existing destination before publication. + +## Store and retention + +Production revisions live under `local_history` in the platform application +support directory. Store format 1 uses an index, independently recoverable and +checksummed full-source JSON records, and a tombstone journal. Record identifiers +and paths are validated beneath the history root. + +Writes are serialized in process and protected by an exclusive cross-process +file lock. A complete record is published on the same filesystem before its +index entry. Recovery scans intact records and observes tombstones so retention +and explicit clearing cannot resurrect removed content. Unknown future store +versions fail explicitly. Retention runs after successful capture and protects +the newly written or protective record. + +## Comparison and restore safety + +Local History scopes ordinary browsing to the active buffer. Scope, snapshot, +and query generations prevent a delayed read or search from publishing into a +new document scope. Store-wide document search is a temporary discovery mode +for retained identities that no longer have an open file. + +`SourceComparisonInput` identifies immutable source versions. Comparison uses +patience anchors, bounded local LCS, and intraline refinement in Dart UTF-16 +offsets. When an unanchored region exceeds the work bound, the comparison is +marked simplified and exact region restore is disabled. + +A restore request owns the history document, revision, current buffer, source +revision, source text, and selected range. It first persists a protective +snapshot, then revalidates those values after every await and applies one +`DocumentBuffer.edited` transaction. Cancellation of a required protective +capture is a failed restore, not successful protection. + +Lifecycle operations flush accepted history work before close, workspace +replacement, or shutdown. Clear invalidates affected queued work when the clear +is accepted, so an older checkpoint cannot recreate erased revisions. + +## Verification + +Run the standard checks with the repository-pinned toolchain: + +```bash +flutter analyze +flutter test +``` + +The native clipboard probe verifies same-process rich-token resolution and +cross-process HTML/plain-text interoperability: + +```bash +flutter build linux --debug -t tools/clipboard_smoke.dart +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + roundtrip OUTPUT_DIRECTORY +``` + +For the inter-process case, run `write OUTPUT_DIRECTORY`, keep it running, and +run `read OUTPUT_DIRECTORY` from a second process on the same display. Create +`OUTPUT_DIRECTORY/stop` to close the writer, then run `plain OUTPUT_DIRECTORY` +to confirm that an external plain-text owner does not inherit stale rich +formats. Rebuild the default entry point afterward. + +The production-controller visual probe uses disposable workspaces: + +```bash +flutter build linux --debug -t tools/history_visual_smoke.dart +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + markdown WORKSPACE PRIMARY_FILE DELETE_FILE OUTPUT_DIRECTORY +GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ + writerside WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY +``` + +Under `xvfb-run`, set `GDK_BACKEND=x11` explicitly so an inherited +`WAYLAND_DISPLAY` cannot select an unrelated Wayland connection. Clipboard +behavior under Wayland requires a compositor-driven run with real input focus. diff --git a/docs/development/visualizations.md b/docs/development/visualizations.md new file mode 100644 index 00000000..77a247c6 --- /dev/null +++ b/docs/development/visualizations.md @@ -0,0 +1,89 @@ +# Visualization implementation and verification + +This note covers the engineering behind [offline visualizations](../visualizations.md). +User syntax, behavior, and limitations belong in that guide. + +## Architecture + +`lib/src/visualization/` owns renderer contracts, typed results, diagnostics, +revision cancellation, scheduling, generated-SVG normalization, OpenAPI +dependency resolution, and bounded memory and disk caches. Cache keys include +the engine and sanitizer versions, source, theme, render profile, options, and +hashes of local dependencies. + +The Linux runner exposes a first-party platform channel backed by WebKitGTK 4.1. +One reusable hidden render view handles Mermaid, PlantUML, OpenAPI parsing, SVG +rasterization, and MathJax. BusyMark-owned windows are created only for the full +interactive OpenAPI reference. Inline previews are Flutter SVG or PNG widgets, +not embedded live browser views. + +The WebKit host uses an ephemeral context and an allow-listed private resource +scheme. It disables storage, cookies, media, WebRTC, developer tools, popups, +permissions, context menus, and unapproved navigation. Its content security +policy blocks networking, frames, objects, forms, plugins, and remote fonts. +Engine SVG is treated as untrusted and normalized before Flutter or Typst sees +it. The WebKit subprocess sandbox remains enabled for ordinary Linux packages +and the strict Snap. + +D2 runs its pinned Linux executable directly, never through a shell. Source, +time, output, dimensions, and environment are bounded, and each execution uses +a fresh temporary directory. Safe SVG remains vector output; unsupported CSS, +embedded fonts, conflicting cascade behavior, animation, or `foreignObject` +content is sanitized and rasterized locally. + +OpenAPI parsing accepts only bounded local references anchored to the canonical +workspace. The interactive Scalar view receives bundled content with agent, +telemetry, authentication persistence, proxying, API requests, remote fonts, +and custom fetches disabled. + +## Dependencies and packaging + +Do not duplicate pinned versions or hashes in documentation. The authoritative +sources are: + +- `tools/visualization/package.json` and `package-lock.json` for web engines and + transitive packages; +- `tools/fetch_d2.sh` for the D2 release, architecture, and verified hashes; +- `tools/fetch_typst.sh` for Typst release artifacts and hashes; +- `linux/CMakeLists.txt` and `snap/snapcraft.yaml` for bundle layout; and +- `.github/workflows/flutter-linux.yml` for release and Snap verification. + +The web bundle is built with `npm ci --ignore-scripts`. Build steps verify direct +upstream artifacts and stage package metadata, licenses, and notices. Node.js is +a build dependency only. + +## Verification + +Run the deterministic Dart suite with the bundled executables after building: + +```bash +flutter analyze +BUSYMARK_D2_PATH=build/linux/x64/debug/bundle/libexec/busymark/d2 \ +BUSYMARK_TYPST_PATH=build/linux/x64/debug/bundle/libexec/busymark/typst \ + flutter test +``` + +Run the real WebKit and D2 corpus under X11: + +```bash +xvfb-run -a -s '-screen 0 1280x1024x24' \ + env WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ + /usr/bin/python3 -u tools/visualization_smoke.py \ + --assets build/linux/x64/debug/visualization/web \ + --d2 build/linux/x64/debug/d2/linux-x86_64/d2 \ + --demo test/fixtures/markdown/basic.md \ + --plantuml-corpus test/fixtures/visualization/plantuml-conformance.md +``` + +The release binary has a gated product-path smoke entry point: + +```bash +verification_dir="$(mktemp -d)" +BUSYMARK_RELEASE_SMOKE=1 \ + build/linux/x64/release/bundle/busymark \ + --visualization-release-smoke="$verification_dir/report.json" +``` + +The report must contain `"ok": true`; the same directory receives the generated +PDF and HTML fixtures. The Linux workflow is authoritative for compositor setup, +X11 and Wayland coverage, the strict Snap run, and required environment flags. diff --git a/docs/development/writerside-verification.md b/docs/development/writerside-verification.md new file mode 100644 index 00000000..cdbc1fd1 --- /dev/null +++ b/docs/development/writerside-verification.md @@ -0,0 +1,51 @@ +# Writerside verification + +The user-facing [Writerside support](../writerside-support.md) page describes the +supported semantic surface and known limitations. This note contains the +maintainer checks behind those claims. + +## Automated coverage + +The Writerside tests cover resolution, source preservation, anchored file +access, instance changes, source assistance, widgets, editing, and export. A +native Typst integration test runs when the bundled compiler is present and is +explicitly skipped otherwise. API unit tests inject the platform parser +interface; they do not claim to exercise the native WebKit parser. + +Run the focused suite with: + +```bash +flutter test --no-pub test/src/writerside*_test.dart \ + test/src/source_autocomplete_test.dart \ + test/src/source_editor_widget_test.dart \ + test/src/workspace_controller_test.dart \ + test/src/markdown_export_mapper_test.dart +``` + +## Official-builder conformance + +`test/fixtures/writerside/conformance_semantics.json` is a normalized semantic +snapshot extracted from the official Writerside builder version named in the +user guide. BusyMark compares representative paragraphs, quotes, shortcuts, +tooltips, table structure, and related links with that snapshot. Passing the +fixture is not a claim of complete website-artifact parity. + +Run the builder comparison with: + +```bash +bash tools/validate_writerside_conformance.sh +``` + +The script uses temporary source copies and validates both the builder report +and normalized semantic snapshot. Set `WRITERSIDE_CONFORMANCE_OUTPUT` only when +generated artifacts must be retained for diagnosis. + +Regenerate the authoring schema from an intentionally selected official XSD: + +```bash +python3 tools/generate_writerside_schema.py /path/to/topic.v2.xsd +dart format lib/src/writerside/writerside_schema_data.dart +``` + +The generated source records its XSD URL and SHA-256. Review both values when +updating the supported Writerside baseline. diff --git a/docs/history-architecture.md b/docs/history-architecture.md deleted file mode 100644 index ead47452..00000000 --- a/docs/history-architecture.md +++ /dev/null @@ -1,241 +0,0 @@ -# History implementation notes - -## Clipboard ownership and insertion - -`RichClipboardService` owns the current BusyMark rich payload for the process -session, independently of `ClipboardHistoryController`. Every publication -creates an opaque UUID token and publishes the token, interoperable text, and -HTML together through the native boundary. Tokens resolve only through the -service's bounded immutable payload map; unknown, malformed, lost, or stale -tokens are external data. Text equality is never proof of ownership, and -clearing retained history does not invalidate the current rich clipboard. - -The Linux runner uses `application/x-busymark-token` alongside GTK text and -HTML targets and reports an owner generation for coherent reads. The shared -Dart boundary falls back only to Flutter's interoperable plain text when the -native method is unavailable. This checkout has no `windows/` runner, so no -Windows channel registration or registered-format host can be built or -runtime-verified here; generating a new runner would expand this work into an -unrelated platform-stack rewrite. - -Retained `BusyMarkClipboardPayload` objects have UUID identities, acquisition -and origin metadata, exact text/source/HTML/rich/image representations, a -SHA-256 equivalence fingerprint, and UTF-8/image-byte accounting. The policy -defaults are 100 entries, 64 MiB total payload bytes, and 8 MiB for decoded -thumbnails. Deduplication compares every meaningful representation. The -insertion registry holds only the latest mounted editable surface, uses -identity-safe unregistering, and requires each adapter to capture and -revalidate document ID, buffer revision, mode, selection generation, and -target lifetime around asynchronous work. - -Copy/cut capture happens only after successful publication. A cut publishes, -then revalidates its selection before one deletion transaction. External -content is retained only after a successful document paste. The transient -current item carries supported external HTML without claiming BusyMark token -ownership and keeps its interoperable text separately. WYSIWYG history -insertion sends that HTML through `WysiwygClipboardHtml`, the same validated -conversion used by ordinary Paste; malformed or unsupported structure uses the -existing text fallback. History insertion uses the normal Source or WYSIWYG -transaction and asset-ingestion paths. - -## Local History capture and identity - -`LocalHistoryController` observes document buffers and schedules a fixed -checkpoint window per changed buffer. Capture events are baseline, explicit -saved content, automatic checkpoint, before reload, before discard, before -restore, before delete, and observed external change. Pending buffers are -flushed on close, workspace replacement, and awaited shutdown. Autosave writes -do not themselves create Saved entries. - -The editor buffer is the source authority. Source and WYSIWYG callbacks, -selected-text formatting, inactive-buffer mutations, Undo, and Redo all report -the before/after buffer transition after one accepted document transaction. -Observation is queued per buffer and replaces that buffer's one pending full -source snapshot. The first edit anchors the checkpoint timer; later edits -replace the pending source without postponing the deadline. A flush is a queue -barrier, so observations accepted before it are captured before settlement is -reported. - -Automatic capture retains pending work until storage succeeds or adjacent -source deduplication validly settles it. Transient failure preserves one -bounded latest snapshot per buffer, keeps the capture warning visible, and -schedules a controlled retry using the configured checkpoint interval. Newer -accepted source wins over acknowledgements or failures from older writes. -Unsupported or over-limit content remains visibly failed without a retry loop. -Transient first-save identity-promotion failures use the same retry cadence -before the pending checkpoint is captured. Flush and identity-promotion -results propagate content-capture failure to lifecycle safety callers; a -successful document save remains independent of a failed history write. A -closed editor does not remove unresolved pending work from controller -ownership: its retry continues while the process runs, and shutdown settlement -accounts for pending buffer IDs even when their tabs are no longer open. - -History identity is stable independently of widget and transient buffer IDs. -Known file and directory moves remap current and descendant paths while -retaining historical paths. A first save of an untitled document continues its -lineage. Named-file Save As retains the source lineage separately and starts or -appends to the destination lineage; an overwritten destination is protected -before publication. Path transitions explicitly distinguish moves from Save As -so an unresolved first-save association is never retargeted to a later copy. -Pending first-save associations are also matched by destination path when a tab -is reopened, before baseline capture, and promotion refuses a destination -already owned by another history document. - -Once a named-file Save As has changed the buffer path, detaching its source -binding is unconditional finalization, not optional recording. Disabling -recording or clearing history during the destination capture cannot leave the -destination buffer bound to the source file's history. -An untitled first save likewise retains a pending identity promotion if -recording is cancelled before promotion registration. The existing retry and -session-association mechanisms carry the original history ID to the saved path; -this does not restart disabled content recording. Registration requires the -original binding to survive, so explicit Clear cannot recreate that association -or resurrect its deleted revisions. - -## Store and retention - -Production revisions live in `local_history` beneath the platform application -support directory. Tests inject either an isolated directory or the memory -store. Store format version 1 uses a small index, independently recoverable -checksummed full-source JSON revision records, and an independent tombstone -journal. IDs are validated and all record paths are constructed under the -history root. - -Writes are serialized in-process and guarded by an exclusive file lock across -processes. A full record is staged and published on the same filesystem before -its index entry. Index recovery scans intact records and observes tombstones so -retention and explicit clears cannot resurrect deleted data. Unknown future -versions fail explicitly. Retention runs only after a successful capture, -counts actual record lengths, protects the new/protective record, and defaults -to 30 days and 512 MiB. - -## Comparison and restore safety - -Ordinary Local History browsing is scoped to the active document buffer. A -scope generation invalidates outstanding revision reads and searches before a -new scope is displayed, so old results cannot land in the wrong document list. -Store-wide document search is a temporary discovery mode for retained closed, -renamed, deleted, or untitled identities; selecting a normal document resumes -active-tab following. The revision list is timestamp-first and groups the same -localized timestamps by local calendar date. - -Normal browsing retains the active buffer as its scope even before that buffer -has a stored history document ID. The first capture, a capture after Clear, or -an identity promotion reconciles the buffer-to-history association and -publishes the corresponding rows without a tab switch, sidebar reconstruction, -or Refresh. Captures for inactive buffers do not steal this scope. Explicit -tab, editor, or Files-context Local History commands leave retained lookup and -establish the requested buffer after guarded activation completes. - -Every published snapshot validates document/revision ownership. An active -revision-content query is recomputed against the new scoped snapshot, with -query, scope, and snapshot generations preventing delayed reads from replacing -newer matches. Search completion clears its loading state on success, scope -change, cancellation, and read failure. Explicit clear invalidates affected -queued work at the point it was accepted, before store access, and closes a -comparison it owns. A queued automatic checkpoint cannot become current merely -because it begins executing after Clear. Explicit-save and Save As wrappers -revalidate the same acceptance generation after each capture or identity -prerequisite, so a stale failure cannot reinstall a pending snapshot or retry -timer. Retention loss instead reports the existing missing-revision state. - -`SourceComparisonInput` identifies immutable sources by ID, version, label, -and text. The comparison uses unique patience anchors plus bounded local LCS -and intraline refinement in Dart UTF-16 offsets. Separated edits are retained. -When an unanchored comparison exceeds the work bound, the result is labeled -simplified and exact region restore is disabled. - -Local History has its own `WorkspaceTabKind.localHistory`. The current side is -the live unsaved buffer when present, then a fresh disk load, or an explicit -missing-file state. An immutable comparison request owns the history document, -revision, current buffer, source revision, and source text; sidebar browsing is -not consulted later to redirect a pending comparison or restore. Restore -captures the target identity/path/revision/selection and exact range, persists -a protective snapshot, revalidates after awaits, and applies one -`DocumentBuffer.edited` transaction without replacing format metadata or the -undo stack. - -Cancellation is not successful protection: Clear or a recording-policy change -invalidating a required capture aborts the dependent restore, reload, discard, -or path replacement, even if the document itself has not changed. Optional -post-save history cancellation does not turn a completed file save into failure. - -Missing-file recovery keeps the source history identity separate from the -destination buffer. For an approved existing path, the normal guarded open flow -loads the destination's real source and format, protection must succeed, and -the retained source is one ordinary edit whose Undo baseline is the exact -destination content. No empty file is published. New-file recovery retains its -separate no-overwrite creation path. - -Workspace refresh records per-buffer request snapshots, then reconciles disk -loads against live membership, path, source revision/text, dirty and disk -state, and the loaded disk snapshot after every asynchronous parse boundary. -Buffers opened meanwhile survive, closed buffers are not resurrected, and -newer editor state and undo/redo history win over stale loads. Derived workspace -and preview content is published only when its active buffer and source still -match; otherwise the normal fresh-parse scheduler takes over. - -Ordinary tab activation follows the same live-buffer rule. It avoids reparsing -an already-active unchanged target; after an asynchronous reparse, it -revalidates workspace and target identity and republishes the current buffer -membership, source, editor selection, and undo/redo state rather than an -activation-time list. A stale derived result is discarded in favor of the -normal fresh-parse scheduler. The first-open path likewise appends a newly -loaded document to the live list instead of an earlier captured list. Only -documents that were genuine additions at activation start may be added during -reconciliation, so tabs closed while parsing stay closed. A close that removes -a document is aborted if that document's source or editor state changes during -the final activation await. Discard authorization is carried to that removal -without temporarily marking unsaved text clean. Close All revalidates the exact -workspace and buffer instances after its history flush, so a new edit, tab, or -replacement workspace aborts the stale close. An aborted individual close -reschedules normal autosave for a surviving eligible dirty buffer in that same -workspace. This finalization stays inside the write queue so shutdown retains -its final timer cancellation; successful discard does not schedule a save. -Refresh reschedules eligible autosaves on both success and failure because it -cancels its incoming timers at the boundary. - -## Verification workflow - -Use the repository-pinned Flutter/Dart toolchain: - -```bash -flutter gen-l10n -dart format -flutter analyze -flutter test -flutter build linux --release -flutter build linux --debug -t tools/clipboard_smoke.dart -GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ - roundtrip OUTPUT_DIRECTORY -# For inter-process ownership, run `write OUTPUT_DIRECTORY` in the background, -# wait for write.ready, then run `read OUTPUT_DIRECTORY` on the same X display. -# Stop the writer by creating OUTPUT_DIRECTORY/stop, then run -# `plain OUTPUT_DIRECTORY` to verify that an external plain-text owner does not -# inherit stale BusyMark formats. - -# Native production-controller visual exercise (use disposable workspaces): -flutter build linux --debug -t tools/history_visual_smoke.dart -GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ - markdown WORKSPACE PRIMARY_FILE DELETE_FILE OUTPUT_DIRECTORY -GDK_BACKEND=x11 build/linux/x64/debug/bundle/busymark \ - writerside WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY -# Omit the smoke timer override and exercise the real 60-second deadline: -BUSYMARK_HISTORY_REAL_POLICY=1 GDK_BACKEND=x11 \ - build/linux/x64/debug/bundle/busymark \ - markdown WORKSPACE PRIMARY_FILE - OUTPUT_DIRECTORY -``` - -When a probe is launched through `xvfb-run`, explicitly set -`GDK_BACKEND=x11`; a stale inherited `WAYLAND_DISPLAY` can otherwise make GTK -select Wayland, where clipboard publication requires a compositor input serial -that a headless probe does not have. The visual target writes PNGs and a JSON -report, and exits nonzero if any production clipboard, capture, comparison, -fragment-restore, deletion, or recovery check fails. - -The current checkout is pinned to Flutter 3.47.2 / Dart 3.13.2. Available -native validation is Linux-only because the repository contains no Windows -runner or Windows plugin registration path. Verification artifacts are written -to the ignored `build/history-ui-evidence/` directory by the runtime -exercise; documentation does not treat an earlier committed test count or -screenshot as proof of the current checkout. diff --git a/docs/history.md b/docs/history.md index 752d9967..88be4aa9 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,146 +1,105 @@ # Clipboard History and Local History -BusyMark provides two separate history tools. Clipboard History helps you -reuse copied material during the current application session. Local History -keeps source revisions on this device so that earlier document content can be -compared and restored. +BusyMark provides two independent history tools. Clipboard History keeps copied +material for reuse during the current application session. Local History keeps +document revisions on this device for comparison and recovery. ## Clipboard History -Open **Clipboard History** from the sidebar selector, command palette, or a -document editor's context menu. The panel stays open while you move between -documents and selections. Each row preserves up to two lines of the item's -source preview, followed by its type or current-clipboard status and capture -date and time. Entries copied inside BusyMark also show their source document -and path. This information is part of the row rather than a hover tooltip. Use -its action menu for **Paste** or **Paste as Plain Text**; Enter and double-click -insert the selected item at the latest visible editable selection. Escape -returns focus to the editor. - -The panel toolbar keeps the search field first and the contextual **Actions** -menu on its physical right. That menu owns **Refresh** and **Clear Clipboard -History**; these commands are not application Main-menu entries. - -BusyMark collects successful copy and cut operations from document editors, -plus supported content copied in another application after it is successfully -pasted into a BusyMark document. The current system clipboard can be refreshed -and previewed without adding it to the retained list. Search examines the full -text, not only the row excerpt. - -History can contain exact Source selections, BusyMark rich fragments, and -supported clipboard images. Supported HTML copied from another application is -kept with its plain-text alternative before the first paste; normal WYSIWYG -insertion uses the same validated HTML conversion as ordinary Paste, while -**Paste as Plain Text** uses only the text alternative. An image is retained as -bytes; it is added to a document's assets only when pasted. Rich fragments -retain a source form for cross-mode insertion. Unavailable local references are -not silently resolved against an unrelated destination. - -Clipboard History is memory-only and is discarded when BusyMark exits. It -keeps at most 100 entries and 64 MiB in total, evicting the oldest entries -first. An item larger than the total budget can still be copied or pasted but -is not retained. **Clear Clipboard History** clears BusyMark's list, not the -operating-system clipboard. Collection can be disabled under -**Settings → History** without clearing existing session entries. +Open **Clipboard History** from the sidebar, command palette, or an editor +context menu. Search examines each item's complete text. Select an item and use +**Paste** or **Paste as Plain Text**; Enter and double-click also insert the +selected item at the latest editable selection. + +BusyMark collects successful copy and cut operations from its editors. Supported +content copied in another application is added after it is successfully pasted +into BusyMark. Refresh can inspect the current system clipboard without adding +it to the retained list. + +Entries can contain Source selections, BusyMark rich fragments, supported HTML, +plain-text alternatives, and images. Rich content retains Markdown structure +for insertion between BusyMark editors. Supported external HTML goes through +the same safe conversion as an ordinary paste. Image bytes are retained and are +added to a document's assets only when you paste them. + +Clipboard History is memory-only and disappears when BusyMark exits. It keeps +up to 100 entries and 64 MiB in total, evicting the oldest entries first. An +oversized item can still be copied or pasted but is not retained. Clearing +Clipboard History does not clear the operating-system clipboard. Collection can +be disabled under **Settings → History** without removing existing session +entries. + +Clipboard persistence after the source application exits depends on the Linux +desktop's clipboard manager. External applications decide whether to consume +HTML or plain text, and Writerside-specific structures use readable HTML or +plain-text fallbacks outside BusyMark. ## Local History -Open **Local History** from the sidebar selector, command palette, document -tab or editor context menu, or a file-tree context menu. In ordinary use its -revision list follows the active document tab; the active filename is not -repeated above the search field. Moving focus to search, a revision, the -comparison, or an action does not change that scope. - -Its toolbar likewise places the contextual **Actions** menu to the physical -right of the search field. **Refresh**, **Find in Local History…**, and clear -commands stay in that menu rather than the application Main menu. - -Use **Find in Local History…** from Local History's **Actions** menu or the -command palette to find closed, renamed, deleted, and previously untitled -documents by stored name, path, and revision content, even when their original -workspace is not open. Selecting a result temporarily inspects that retained -document in the same sidebar. The temporary context row identifies that scope, -its tooltip exposes the retained path, its missing/deleted state is explicit, -and the local Back action returns to the active document. There is no -store-wide document selector in the ordinary history view. - -BusyMark records complete source text and its encoding/line-ending policy. A -baseline is captured when an eligible existing document is first tracked. -Later revisions record successful explicit saves, 60-second automatic -checkpoint windows, and content protected before known reload, discard, -restore, delete, or external-change operations. The checkpoint deadline is -not postponed by continued typing, and the ordinary 1.5-second autosave does -not create a revision per write. Unchanged adjacent content is deduplicated. - -Typing, deletion, paste, selected-text formatting, Undo, Redo, and source -changes made to an inactive open document update the authoritative editor -buffer immediately. Local History observes the complete serialized source and -keeps only the latest eligible source pending for that document's current -checkpoint window. Pending work is not presented as a stored revision. When a -checkpoint or explicit-save capture succeeds, the already-open revision list -and any active content filter reconcile automatically; **Refresh** only rereads -stored history and never forces a capture or document save. - -If storage fails, the panel reports the failure and keeps the latest eligible -source pending in memory for a controlled retry. A newer edit replaces an -older pending source. The revision is not durable and is not described as -saved until storage actually succeeds. A document file can still save -successfully when this separate history write fails. Closing its editor does -not make unresolved work count as settled: controlled retry continues for a -retryable failure during the application session, and clean shutdown is -withheld while work remains pending. Destructive history restore operations -that require a protective revision remain blocked by such a failure. - -Revisions are grouped by local calendar date and use the localized time, -including seconds, as the primary row label. Distinct events such as Saved, -Before restore, or External change appear as secondary metadata. Automatic and -baseline capture reasons remain available in the row's tooltip and accessible -description without becoming a repeated visible title. - -Selecting a revision opens a read-only source comparison against the unsaved -editor buffer when available, otherwise a fresh disk snapshot. Missing files -are identified explicitly. You can copy selected source, restore the whole -revision, or restore an exact change when the comparison is current and exact. -Each restore is one ordinary undoable document edit and follows the existing -autosave setting. Before a restore, BusyMark must persist a protective snapshot -of the current content. If recording is disabled, the path is excluded, or the -capture fails, the restore leaves the document unchanged. +Open **Local History** from the sidebar, command palette, an editor or document +tab context menu, or a file-tree context menu. The revision list normally follows +the active document. + +Use **Find in Local History…** to locate retained closed, renamed, deleted, and +previously untitled documents by name, path, or revision content. Selecting a +result temporarily inspects that document; use Back to return to the active +document. + +BusyMark stores complete source text together with its encoding and line-ending +policy. It records: + +- a baseline when an eligible existing document is first tracked; +- successful explicit saves; +- automatic checkpoints, every 60 seconds by default; and +- protective revisions before reload, discard, restore, delete, or a known + external change. + +Continued typing does not postpone an active checkpoint window, and ordinary +autosave does not create a revision for every write. Adjacent revisions with +identical content are deduplicated. + +Select a revision to compare it with the current unsaved editor buffer, or with +a fresh disk snapshot when the document is not open. You can copy source, +restore the complete revision, or restore an exact changed region when the +comparison is current and precise. A restore is one normal undoable edit and +follows the current autosave setting. + +Before restoring content, BusyMark must save a protective revision of the +current document. If Local History recording is disabled, the path is excluded, +or storage fails, BusyMark leaves the document unchanged. For a deleted file, use **Restore to Original Location** or **Restore to New -Location…**. Existing destinations require the normal overwrite decision. -After approval, BusyMark opens and protects the destination's real current -content, then applies the retained source as one edit. With autosave disabled, -the existing disk file is untouched until Save, and Undo returns to the -destination's original editor content. Autosave may publish the recovered edit -through the ordinary save path, but recovery never publishes an intermediate -empty file. Cancelling or failing recovery changes neither destination nor the -retained revision. - -Local History defaults to 30-day retention and 512 MiB total storage. Either -limit may remove older entries sooner. Under **Settings → History**, recording, -the checkpoint interval, age, storage limit, and exclusions are configurable. -Each exclusion is one absolute file or directory path; a directory excludes -all descendants using the host platform's path rules. Relative and blank lines -are ignored. Disabling recording stops new automatic and protective captures -but leaves existing revisions available until retention or explicit clearing. - -Clearing one document's history or all Local History permanently removes only -stored revisions. It does not delete project files, Git data, recovery data, or -the system clipboard. Clearing the revision currently being compared closes -that transient comparison and returns to the unchanged editor. Scheduled or -in-flight work accepted before the clear cannot recreate the erased history; -new edits after the clear begin a normal checkpoint window and automatically -reattach the active document's list. - -## What each recovery tool means +Location…**. An existing destination uses the normal overwrite confirmation and +is protected before the retained source is applied. With autosave disabled, the +disk file remains unchanged until you save. + +## Retention, failures, and clearing + +Local History defaults to 30 days and 512 MiB. Either limit can remove older +entries sooner. Under **Settings → History**, you can change recording, the +checkpoint interval, age and storage limits, and excluded paths. Each exclusion +must be an absolute file or directory path; a directory excludes its descendants. +Blank and relative entries are ignored. + +When a history write fails, BusyMark reports the failure and may retry the latest +pending source during the current session. The document itself can still save, +but an operation that requires a protective revision remains blocked until that +revision is safely stored. + +Clearing one document or all Local History permanently removes only stored +revisions. It does not delete project files, Git data, crash-recovery data, or +clipboard content. New edits after a clear begin a new checkpoint window. + +## Choosing the right recovery tool - **Undo/redo** is the current document's bounded in-memory edit sequence. -- **Crash recovery** protects the newest unsaved buffers after an interrupted +- **Crash recovery** protects recent unsaved buffers after an interrupted session; it is not a revision timeline. -- **Local History** is a timestamped, on-device source revision timeline. It is - not encrypted, a backup, or an audit log. -- **Git File History and Project History** remain repository history. Local - History never creates commits, refs, or sidecar repositories. +- **Local History** is a timestamped, on-device source timeline. It is not + encrypted, a backup, or an audit log. +- **Git history** is repository history. Local History never creates commits, + refs, or sidecar repositories. Clipboard and Local History content is not sent to AI providers, telemetry, or -diagnostic logs by these features. +diagnostic logs by these features. Contributor-facing storage and transaction +details are in [History implementation notes](development/history.md). diff --git a/docs/html-export-verification.md b/docs/html-export-verification.md deleted file mode 100644 index 2bfcf0c6..00000000 --- a/docs/html-export-verification.md +++ /dev/null @@ -1,110 +0,0 @@ -# PDF and HTML export verification — September 5, 2026 - -Validation used Flutter 3.47.0 on Linux, bundled Typst 0.15.1, the bundled Noto -fonts, and BusyMark’s existing local math and visualization engines. User-facing -settings are documented in [PDF export](pdf-export.md), [HTML export](html-export.md), -and [Writerside PDF export](writerside-pdf-export.md). - -## Implementation areas - -- `lib/src/export/export_options.dart` supplies shared content options and one - validated, serializable option model per format. `app_settings.dart` persists - the last confirmed values through the existing settings store. -- `export_options_editor.dart` supplies the reusable modal editors for Markdown - and Writerside. Existing PDF and HTML export UI entry points use those editors; - Writerside retains its workspace-specific instance and Save/Discard/Cancel flow. -- `typst_payload_builder.dart`, `markdown_math_export.dart`, `typst_compiler.dart`, - and `assets/export/markdown.typ` carry PDF geometry, typography, native TOC, - numbering, accent, and running text through payload schema 2. The semantic - `markdown_export_mapper.dart` no longer sets PDF math metrics. -- `html_document_writer.dart`, `html_export_styles.dart`, `html_export_assets.dart`, - and the HTML service/rich-content preparation apply themes, typography, semantic - TOCs, numbering, bounded custom CSS, and both resource packaging modes. - `export_metadata_mapper.dart` provides common resolved metadata. -- Linux and Snap build configuration stages Noto Sans, Serif, Mono and available - Noto script fallbacks. Localization sources and generated output cover all new - settings. Focused model, widget, payload, native PDF, HTML, math and Writerside - tests sit beside the existing export tests. - -## Executed checks - -| Check | Result | -| --- | --- | -| Changed Dart sources formatted; `git diff --check`; Python browser script syntax | Passed | -| Repository-wide `flutter analyze --no-pub` | Passed; no issues | -| `flutter gen-l10n` and localization audit | Passed | -| Full `flutter test --no-pub` with bundled Typst and D2 paths | **1,469 passed; no skips** | -| Native PDF customization tests | Six profiles compiled; page geometry, embedded Noto families, body/code sizes, native outline entries/page references, heading numbering, running titles and counter placement verified | -| Workspace refresh regression | Reproduced before the fix; all 63 controller tests pass after the fix | -| `flutter build linux --release --no-pub` | Passed; final release bundle includes Noto fonts and licenses | -| Release application smoke under Xvfb | Passed; MathJax 4.1.3, Mermaid, PlantUML, D2, static OpenAPI, default/custom PDFs and Markdown/Writerside HTML; zero HTML warnings | -| Relocated browser verification | **46 passed** in Chrome 151.0.7922.137 and Firefox 154.0.1 | - -The new tests cover absent/malformed persisted settings, validation and geometry, -all four dialogs’ reuse of remembered format options, dependent controls, Reset, -cancellation, CSS validation and ordering, embedded and external assets, output -bounds, existing-output preservation, metadata, TOC hierarchy/depth, numbering, -custom math metrics, and unchanged Writerside resolution and publication order. -Existing PDF, HTML, native menu, source-preservation and editor tests remain part -of the complete suite. - -Full-suite verification exposed an existing filesystem-monitor race: a background -refresh could invalidate a foreground Writerside operation. A controlled regression -test reproduced that failure. Monitor refreshes now wait for active loading to -finish, and stale/disposed refreshes stop before changing controller state. - -Historical note: at the time of this HTML-export verification, the base `pt` -and `zh` catalogs were still partial while `pt_BR` and `zh_CN` were complete. -That limitation has since been superseded: localization policy now requires -both base and regional catalogs to contain the complete English message-key set. - -## Browser and output verification - -Browser checks open relocated Markdown and Writerside pages through both `file:` -URLs and a local static HTTP server, with external networking unavailable. They -verify image loading, CSP/style hashing, metadata, custom CSS overrides, typography, -automatic light/dark preference, embedded resources, anchors, footnotes, navigation, -keyboard focus, disclosure, overflow, script blocking and printing. Request -observation recorded no unexpected external resources; local favicon 404 probes -are not document dependencies. Representative screenshots were visually inspected. -Print checks include disclosure bodies and every Writerside tab. - -Representative outputs generated by the release application: - -- `/tmp/busymark-custom-output/offline.html` and `offline.assets/`: default - Markdown output with Unicode headings, nested/task lists, tables, disclosure, - local SVG, inline/display math, three diagram engines, static API and footnotes. -- `/tmp/busymark-custom-output/automatic-single.html`: automatic theme, embedded - resources, 20 px serif text, customized width/accent, numbered headings and CSS. -- `/tmp/busymark-custom-output/dark-assets.html`: dark appearance with companion - resources using relative URLs. -- `/tmp/busymark-custom-output/writerside/index.html`: default multipage instance - with includes, variables, all tabs, procedures, custom filenames, hidden topics - and work-in-progress status. -- `/tmp/busymark-custom-output/writerside-embedded/index.html`: the same instance - with shared customization and embedded resources in each topic page. -- `/tmp/busymark-custom-output/visualization-smoke.pdf`: default PDF rich content. -- `/tmp/busymark-custom-output/customized.pdf`: 240 × 320 mm pages, independent - margins, 14 pt Noto Sans, 11 pt code, purple accent, numbered native TOC, - title headers/footers, right-aligned page numbers and first-page suppression. - -Native results are in `/tmp/busymark-custom-output/report.json`. Browser results, -screenshots and printed PDFs are under `/tmp/busymark-custom-browser-final/`; -`browser-report.json` identifies the separate relocated root. The source fixtures -and renderer temporary directories were removed before opening relocated copies. -These temporary artifact paths are not export dependencies and may be cleaned up. - -To reproduce with a release bundle and the browser-check dependencies installed: - -```sh -BUSYMARK_RELEASE_SMOKE=1 xvfb-run -a build/linux/x64/release/bundle/busymark \ - --visualization-release-smoke=/tmp/busymark-custom-output/report.json -python3 tools/verify_html_export_browser.py /tmp/busymark-custom-output \ - --output /tmp/busymark-custom-browser-final -``` - -Browser and print verification ran headlessly. Physical-printer output, assistive -technology certification, and a complete Snap package build were not performed; -the Linux bundle and its staged Noto fonts/licenses were verified. The existing -HTML exporter has no client-side syntax highlighter, so this change retains its -code rendering and supplies a theme-aware code palette without another dependency. diff --git a/docs/local-ai.md b/docs/local-ai.md index 7985438c..5353ce9a 100644 --- a/docs/local-ai.md +++ b/docs/local-ai.md @@ -1,209 +1,119 @@ # AI editing -BusyMark provides explicit, reviewable AI editing with Local Ollama, OpenAI, -or Google Gemini. AI is disabled by default. BusyMark never starts a request in -the background and never applies generated content without review. +BusyMark can refine Markdown with Local Ollama, OpenAI, or Google Gemini. AI is +disabled by default. Every request is initiated explicitly, shows the content +that will be shared, and produces a proposal that you must review before it can +change the document. ## Configure a provider -Open **Settings → AI**, then choose exactly one provider: +Open **Settings → AI**, enable a provider, and choose a model-routing mode: -- **Local Ollama** uses a loopback origin, normally - `http://127.0.0.1:11434`. BusyMark permits only loopback origins, follows no - redirects, and rejects Ollama cloud models. +- **Local Ollama** connects only to a loopback origin, normally + `http://127.0.0.1:11434`. BusyMark rejects redirects and Ollama cloud models. - **OpenAI** uses the Responses API with `store: false`. -- **Google Gemini** uses the stable Interactions API with `store: false`. - -Selecting a cloud provider requires an explicit content-disclosure consent. -The API key is stored by the operating-system credential service through -libsecret. It is never written to BusyMark settings, a workspace, logs, or the -application bundle. A desktop process must still read the key to make a direct -BYOK request, so OS credential storage protects the key at rest; it does not -make a compromised desktop process trustworthy. - -BusyMark uses libsecret's password API through the desktop secret service. -Access depends on the credential service made available to the packaged -application. BusyMark does not write API keys into settings, workspaces, logs, -or its application bundle. - -Choose **Automatic model selection** to let BusyMark route the task among the -approved models of the selected provider, or **Fixed model** to use only the -chosen model. Routing and retries never cross provider boundaries. BusyMark -does not silently send content from Ollama to a cloud provider or from one -cloud provider to another. - -**Test connection** performs an actual bounded generation and accepts the -provider only when the selected model returns the required test value. For -Ollama, BusyMark also inspects `/api/show` to verify text-generation capability -and the advertised context limit. A local model cold start is allowed up to the -documented test deadline. - -Current API and privacy behavior is described by the providers in the -[OpenAI Responses API](https://developers.openai.com/api/docs/guides/responses), -[OpenAI API data controls](https://developers.openai.com/api/docs/guides/your-data), -[Gemini Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview), -and [Ollama API](https://docs.ollama.com/api/introduction) documentation. -`store: false` disables provider conversation-state storage for these BusyMark -requests; it is not a promise about every form of provider retention or the -terms of a particular account. - -## Editing workflow - -AI editing is available only for Markdown files and Writerside Markdown topics -in **Source** view. It is deliberately unavailable for Writerside XML, trees, -configuration, variables, categories, resources, images, and unknown text -formats. The WYSIWYG editor does not expose AI editing because flattening its -structured blocks to plain text could lose Markdown semantics. - -The supported document actions are: - -- Rewrite -- Shorten -- Summarize -- Change tone -- Translate -- Proofread -- Draft -- Explain code block -- Improve code block - -Rewrite, Shorten, Change tone, Translate, and Proofread require a selection. -Summarize expands a partial selection to complete Markdown blocks and otherwise -summarizes the active document. Draft keeps selected notes as context and -inserts the generated Markdown after them; without a selection it uses the -nearest safe prose block as local context. Insertions move to a Markdown block -boundary rather than splitting front matter, a fence, raw markup, or another -protected construct. Code actions require the cursor or selection to be inside -one complete fenced code block. Explain inserts prose after the fence; Improve -proposes a replacement for that fence. - -Every action follows the same flow: - -1. BusyMark identifies the exact context and proposed replacement range. -2. The dialog discloses the provider, model, and content category being sent. -3. Output streams into a temporary proposal buffer, never the document. -4. BusyMark validates the complete candidate document and shows a unified diff. -5. The user copies, rejects, or applies the proposal. -6. An accepted proposal becomes an ordinary editor edit with normal undo and - save behavior. - -If the document or selection changes while generation is running, the proposal -is stale and cannot be applied. - -## Git commit drafts - -The Changes sidebar can draft a Git commit message from the complete staged -repository diff. BusyMark sends the staged patch only: it does not send the -working tree, history, unstaged content, or repository files. The generated -subject is limited to 72 characters and an optional body must be separated by -a blank line. Accepting the proposal only fills the existing commit-message -field; it never stages or commits anything. -Immediately before Apply, BusyMark reads the complete staged patch again and -compares its SHA-256 fingerprint with the exact patch used for generation. A -changed index makes the proposal stale and requires a new draft. - -## Markdown integrity - -For transformations of existing Markdown, BusyMark builds the full candidate -document and reparses it with the same GFM parser used by the editor. It rejects -a proposal that changes protected structure or associations, including: - -- YAML front matter; -- heading, list, table, and inline-Markdown structure; -- URLs associated with links and images; -- reference-link and footnote identifiers; -- autolinks and heading attributes/IDs; -- inline code with arbitrary backtick delimiters and fenced code; -- raw HTML and Writerside markup or variables. - -Improve code is the narrow exception: exactly one complete fenced block may -change, while its fence declaration and language remain fixed. The validator -is intentionally conservative. A safe prose edit can be rejected, but a model -response is never treated as trusted Markdown merely because it looks valid. - -## Budgets, retries, and privacy - -- Feature-specific input, total-prompt, instruction, provider output-token, - generated-output byte, transport-byte, and absolute request-time limits are - enforced independently. Provider-neutral prompt estimates count Unicode - text, not UTF-8 transport bytes; providers receive output limits in tokens. -- Large whole-document summaries use at most 16 bounded section summaries and - a final synthesis. Other actions reject oversized input rather than silently - sending more context. -- Provider `429`, `408`, `409`, and transient `5xx` responses may be retried - before any output is shown. BusyMark honors `Retry-After` and uses bounded - exponential backoff with jitter. -- A request is never restarted after text has streamed because restarted output - could be duplicated. -- At most two generations run concurrently. A newer request for the same edit - target cancels the older request, and dialogs cancel by request ID. -- BusyMark stores only monthly aggregate request/input/output token counts by - provider. Prompts, responses, file paths, document names, model names, and - credentials are not written to the usage ledger. - -For genuinely offline use, select Local Ollama and disable Ollama cloud support -itself with `OLLAMA_NO_CLOUD=1` or `disable_ollama_cloud: true`, as documented in +- **Google Gemini** uses the Interactions API with `store: false`. + +Cloud providers require explicit consent before BusyMark sends content. Their +API keys are stored through the Linux system credential service (libsecret), not +in BusyMark settings, project files, logs, or the application bundle. Credential +storage protects a key at rest; a running desktop process still needs access to +the key to make a request. + +Choose **Automatic by task** to let BusyMark select from compatible models for +the enabled provider, or **Use selected model** to use the preferred model. +Routing never crosses provider boundaries. Use **Test connection** to verify +that the configured provider and model can generate text. + +## Refine Markdown + +**Refine with AI** is available for ordinary Markdown files and Writerside +Markdown topics. It is not available for Writerside XML, project trees, +configuration files, variables, categories, resources, images, or unknown text +formats. + +You can start it in either Source or Editor view: + +1. Select content and choose **Refine with AI** from the editor context menu. + You can also use the document or Outline actions to refine an entire document + or a selected section. +2. Enter a specific instruction, such as “Make this paragraph concise while + keeping its meaning.” +3. Confirm the provider and model for this request. +4. Choose **What may change** and **Context shared with AI**. These choices are + independent: for example, BusyMark can change one block while sharing the + complete document as context. +5. Inspect **Content to change** and **Content sent to AI**, then choose + **Generate proposal**. +6. Review the original and suggested content. Copy the proposal, cancel it, or + choose **Apply proposal**. + +The available change targets depend on where you opened the action: + +| Change target | Result | +| --- | --- | +| Selected content | Replaces the current selection. | +| Insert after current block | Inserts new Markdown at a safe block boundary. | +| Current block | Replaces the block at the selection or caret. | +| Current section | Replaces the current heading and its section content. | +| Complete document | Replaces the complete Markdown source. | + +Shared context can be none, the selection, the current block, the current +section, or the complete document. BusyMark displays the exact context and its +character count before sending it. A target or context choice is omitted when +it is not available at the current location. + +Applying a proposal creates a normal editor edit with ordinary undo and save +behavior. If the document changes while generation or review is in progress, +the proposal becomes stale and BusyMark will not apply it. + +## Markdown safeguards + +BusyMark validates the complete candidate document before enabling Apply. For +edits to existing text, it protects Markdown structure and associations such as +front matter, headings, lists, tables, links, images, identifiers, footnotes, +inline and fenced code, raw HTML, and Writerside markup or variables. + +The validator is intentionally conservative, so it can reject a reasonable +prose edit that also changes protected syntax. An insertion after the current +block may introduce new Markdown, but BusyMark still parses the complete result +and places the insertion at a safe block boundary. + +## Git commit-message drafts + +In the Changes sidebar, **Draft with AI** sends the complete staged patch and +returns a proposed commit message. It does not send unstaged content, repository +history, or unrelated files. Applying the proposal only fills the commit-message +field; it never stages or commits changes. + +BusyMark checks the staged patch again before Apply. If the index changed while +the message was generated, the proposal is stale and must be regenerated. + +## Privacy and limits + +- Only the context displayed for the current request is sent to the selected + provider. BusyMark does not silently fall back to a different provider. +- `store: false` disables provider conversation-state storage for BusyMark's + cloud requests. It does not replace the provider's retention terms or the + policies of your account. +- Prompts, responses, file paths, document names, model names, and credentials + are not written to BusyMark's usage ledger. The ledger stores only monthly + aggregate request and token counts by provider. +- Requests and responses have bounded size and duration. A request can be + retried only before generated text has been shown. + +For fully local use, select Local Ollama and disable cloud support in Ollama +itself with `OLLAMA_NO_CLOUD=1` or `disable_ollama_cloud: true`, as described in the [Ollama FAQ](https://docs.ollama.com/faq#how-can-i-disable-ollamas-cloud-features). -## Deterministic smart editing - -Two related features do not invoke AI: - -- **Generate/update table of contents** creates a parser-derived, - marker-delimited TOC and updates the same generated region on later runs. -- Markdown diagnostics identify skipped heading levels, empty or vague link - text, and empty table-header cells. - -Keeping these checks deterministic follows established accessibility guidance -for [nested headings](https://www.w3.org/WAI/WCAG22/Techniques/general/G141), -[descriptive link purpose](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html), -and [table structure](https://www.w3.org/WAI/WCAG22/Techniques/). - -## Implementation and release qualification - -The provider-neutral implementation lives under `lib/src/ai/`: - -- provider adapters map OpenAI SSE, Gemini SSE, and Ollama NDJSON into one typed - stream; -- the coordinator owns routing, retry, concurrency, cancellation, usage, and - latest-write-wins behavior; -- policy and Markdown validation run independently of the provider; -- the first-party Linux credential host uses portal-compatible libsecret - password operations without opening the global Secret Service collection; - -Deterministic tests cover arbitrary UTF-8 stream boundaries, malformed and -incomplete events, redirects, response bounds, provider isolation, model -fallback, cancellation, deadlines, retries and `Retry-After`, context budgets, -credential redaction, Markdown structural invariants, source-editor apply, Git -message validation, TOC generation, and accessibility diagnostics. - -Release qualification must additionally run [the AI demo](../demo/ai-editing.md) -against each exact provider/model combination intended for release. Cloud -qualification requires a deliberately supplied test credential; it is never -enabled by CI secrets implicitly. Local Ollama qualification must run with -cloud features disabled. Generated prose remains probabilistic, so release -review evaluates structural invariants and task quality rather than exact -golden strings. - -The local end-to-end corpus can be executed against an installed Ollama model: - -```bash -OLLAMA_NO_CLOUD=1 ollama serve -dart run tools/ai_ollama_qualification.dart --model -``` - -It performs a real generation health check and exercises all seven editing -commands, whole-document summary, both fenced-code actions, Markdown structural -validation, and staged-diff commit-message generation. It prints proposals for -human quality review and exits unsuccessfully if any structural check fails. - -## Authoritative references - -- [OpenAI Responses streaming](https://developers.openai.com/api/docs/guides/streaming-responses) -- [OpenAI models](https://developers.openai.com/api/docs/models) -- [Gemini Interactions streaming](https://ai.google.dev/gemini-api/docs/streaming) -- [Gemini models](https://ai.google.dev/gemini-api/docs/models) -- [Ollama chat API](https://docs.ollama.com/api/chat) -- [Ollama model details API](https://docs.ollama.com/api/show) +Maintainers can find implementation invariants and qualification commands in +[AI implementation notes](development/ai.md). + +## Provider references + +- [OpenAI Responses API](https://developers.openai.com/api/docs/guides/responses) +- [OpenAI API data controls](https://developers.openai.com/api/docs/guides/your-data) +- [Gemini Interactions API](https://ai.google.dev/gemini-api/docs/interactions-overview) +- [Ollama API](https://docs.ollama.com/api/introduction) - [Libsecret password storage](https://gnome.pages.gitlab.gnome.org/libsecret/libsecret/password-storage.html) - [Snap Secret Portal](https://snapcraft.io/docs/how-to-guides/snap-development/use-the-secret-portal/) diff --git a/docs/search-and-replace.md b/docs/search-and-replace.md new file mode 100644 index 00000000..8fdd98d2 --- /dev/null +++ b/docs/search-and-replace.md @@ -0,0 +1,78 @@ +# Search and replace + +Press **Ctrl+F**, choose **Search**, or use the command palette to search the +open workspace. The search controls use the same query and options for the +active document and workspace results. + +## Search options + +- **Case sensitive** distinguishes uppercase and lowercase letters. +- **Whole word** excludes matches that are part of a longer word. +- **Regex** treats the query as a Dart regular expression. Invalid expressions + are reported instead of being searched. + +Zero-length regular-expression matches, such as `^`, `$`, or a lookahead that +matches only a position, are not supported for search or replacement. + +## Active document + +In Source or Split view, the search panel shows the match count and provides +previous and next navigation. Its replacement field supports: + +- replacing the current match; +- replacing the current match and moving to the next one; and +- replacing every match in the active document. + +These commands edit the current in-memory document. They do not save it +immediately unless autosave is enabled, and they participate in normal undo and +save behavior. + +Editor and Reading views show workspace search results and navigate to matching +content, but the active-document replacement controls are available in Source +or Split view. + +## Workspace search + +The sidebar groups matches by file. Selecting a result opens that document and +navigates to the matching source. Workspace search covers supported text files +already known to the open workspace, including Markdown, Writerside Markdown +and XML topics, trees, configuration, variables, categories, `.gitignore`, and +text resources. Images and unknown file types are excluded. + +For open documents, search uses the current editor buffer, including unsaved +changes. For unopened documents, it reads the saved file. Unopened files larger +than 1 MiB or files that cannot be read are listed as skipped. BusyMark +initially limits the displayed results; choose **Show more results** or narrow +the query when the result list is incomplete. + +## Replace in the workspace + +Workspace replacement is always reviewed: + +1. Run a workspace search and choose **Replace in Workspace**. +2. Enter the replacement text and choose **Review replacements**. +3. Review matches grouped by file. Clear individual matches or a whole file to + exclude them. +4. Choose **Apply replacements**. + +With Regex enabled, replacement text supports `$1` through `$99` for numbered +capture groups, `${name}` for named groups, `$&` for the complete match, and +`$$` for a literal dollar sign. + +BusyMark rechecks every selected file and open buffer against the reviewed +preview before changing it. Replacements in open documents update their editor +buffers and follow normal save or autosave behavior. Replacements in unopened +documents are written to disk while preserving their encoding and line-ending +format; mixed line endings require you to choose LF or CRLF first. + +## Skipped and stale results + +A skipped result means BusyMark could not safely apply the reviewed change. A +file may have changed on disk, an open buffer may have changed or closed, the +file may be unreadable or invalid UTF-8, or the preview may have reached its +match limit. Resolve the reported cause and run the search again. + +BusyMark does not apply an incomplete preview. If a concurrent change prevents +a safe file transaction, the result identifies the affected path and, when +necessary, the preserved displaced content. Always read the final replacement +summary before continuing. diff --git a/docs/visualizations.md b/docs/visualizations.md index f083618b..6d8b659f 100644 --- a/docs/visualizations.md +++ b/docs/visualizations.md @@ -1,28 +1,72 @@ # Offline visualizations -BusyMark renders Mermaid, PlantUML, D2, and fenced OpenAPI documents locally. -The Markdown parser and serializer still treat every visualizer as an ordinary -fenced code block. A `VisualizationDescriptor` is derived for preview and PDF -export, so the original fence, language spelling, source span, and text remain -authoritative. +BusyMark renders Mermaid, PlantUML, D2, and fenced OpenAPI content locally. +The original fenced source remains authoritative: editing and history views use +the source, and saving preserves the fence and language spelling. ## Supported fences -| Renderer | Fence identifiers | Preview | PDF | +| Content | Fence identifiers | Reading and Editor views | PDF export | | --- | --- | --- | --- | -| Mermaid | `mermaid` | Sanitized SVG when styling is vector-safe; otherwise PNG | Vector SVG when styling is vector-safe; otherwise high-resolution PNG | -| PlantUML | `plantuml`, `puml` | Sanitized SVG when styling is vector-safe; otherwise PNG | Vector SVG when styling is vector-safe; otherwise high-resolution PNG | -| D2 | `d2` | Sanitized SVG when all styling is vector-safe; otherwise PNG | Vector SVG when all styling is vector-safe; otherwise high-resolution PNG | -| OpenAPI | `openapi`, `oas`, `swagger` | Native summary and a BusyMark-owned Scalar window | Static, selectable reference content | +| Mermaid | `mermaid` | Sanitized SVG or PNG | Vector SVG when safe; otherwise high-resolution PNG | +| PlantUML | `plantuml`, `puml` | Sanitized SVG or PNG | Vector SVG when safe; otherwise high-resolution PNG | +| D2 | `d2` | Sanitized SVG or PNG | Vector SVG when safe; otherwise high-resolution PNG | +| OpenAPI | `openapi`, `oas`, `swagger` | Summary with a BusyMark-owned reference window | Static, selectable API reference | -Identifiers are classified case-insensitively. Saving preserves the exact -source fence. History and diff views show source rather than generated output. -Whole-file YAML/JSON OpenAPI editing is not part of this feature. +Fence identifiers are case-insensitive. A whole YAML or JSON file is not +automatically treated as OpenAPI; put the specification in a recognized fence. + +## Diagram examples + +````markdown +```mermaid +flowchart LR + Source --> Preview +``` + +```plantuml +@startuml +Alice -> Bob: Hello +@enduml +``` + +```d2 +source -> preview +``` +```` + +BusyMark renders common Mermaid diagram families and the PlantUML families +available in the packaged MIT browser engine. D2 imports and icon or image assets +inside a D2 fence are not supported. The bundled D2 executable is currently +available only for Linux amd64. + +## OpenAPI + +Use YAML or JSON inside an `openapi`, `oas`, or `swagger` fence: + +````markdown +```openapi +openapi: 3.1.0 +info: + title: Example API + version: 1.0.0 +paths: {} +``` +```` + +BusyMark accepts OpenAPI 3.2, 3.1, and 3.0 and Swagger 2.0. The interactive +reference receives bundled content rather than a URL and cannot issue API +requests. Relative specification references must remain inside the open +workspace. Remote references, absolute paths, traversal, symlink escapes, +oversized files, and excessive or circular dependency graphs are rejected. + +PDF and HTML export use static API content instead of a screenshot of the +interactive reference. Schema objects are expanded within the reference; BusyMark +does not generate a separate website page for every schema. ## Writerside diagram forms -Writerside Markdown and XML topics can also use semantic code blocks for -Mermaid, PlantUML, and D2: +Writerside Markdown and XML topics can use semantic code blocks: ```xml flowchart LR @@ -37,8 +81,8 @@ A -> B a -> b ``` -Writerside's referenced-source form is supported for all three renderers. The -path is relative to the current topic and must remain inside the open +Writerside's referenced-source forms are supported for all three diagram +renderers. Paths are relative to the topic and must remain inside the open Writerside project: ````markdown @@ -49,196 +93,30 @@ Writerside project: { src="../codeSnippets/flow.mmd" } ```` -Referenced files are read as strict UTF-8, size-limited, and resolved through -BusyMark's anchored-path checks. Absolute paths, URI schemes, traversal outside -the project, and symlink escapes are rejected. Semantic tags and the `src` -attribute form are enabled only for Writerside projects; ordinary Markdown -keeps them as ordinary HTML/text. These forms track the official Writerside -documentation for [D2](https://www.jetbrains.com/help/writerside/d2-diagrams.html), -[PlantUML](https://www.jetbrains.com/help/writerside/plantuml-diagrams.html), -and [Mermaid](https://www.jetbrains.com/help/writerside/mermaid-diagrams.html). - -The automated conformance corpus is stored in: - -- [`test/fixtures/markdown/basic.md`](../test/fixtures/markdown/basic.md) -- [`test/fixtures/visualization/openapi-local-reference.md`](../test/fixtures/visualization/openapi-local-reference.md) -- [`test/fixtures/visualization/plantuml-conformance.md`](../test/fixtures/visualization/plantuml-conformance.md) - -## Runtime design - -`lib/src/visualization/` owns renderer contracts, typed results, diagnostics, -revision cancellation, priority scheduling, memory/disk LRU caches, generated -SVG normalization, D2 execution, and OpenAPI dependency resolution. Cache keys -include the renderer and sanitizer versions, source, theme, preview/PDF profile, -options, and hashes of local dependencies. The disk cache is stored below -`$XDG_CACHE_HOME/busymark/visualizations` for both conventional packages and -the strict Snap. - -The Linux runner provides a first-party Flutter platform-channel host backed by -WebKitGTK 4.1. It uses one reusable hidden render view in an ephemeral WebKit -context and creates BusyMark-owned views only for full Scalar references. -Inline previews are Flutter SVG/PNG widgets, not live browser views. - -The host: - -- serves an allow-listed bundle through the private `busymark-render:` scheme; -- uses an ephemeral data manager and rejects cookies; -- disables local storage, databases, media, WebRTC, developer tools, popups, - permissions, context menus, and unapproved navigation; -- applies a CSP with no network, frames, objects, forms, plugins, or remote - fonts; -- recreates the hidden view after WebKit process termination; and -- treats engine SVG as untrusted input before it reaches Flutter or Typst. - -The CSP permits WebAssembly evaluation for the official PlantUML/Viz.js build -and JavaScript evaluation for Scalar's bundled schema validator. Those -permissions are confined to the private, allow-listed, no-network harness. - -WebKit's subprocess sandbox remains enabled for ordinary Linux packages and -the strict Snap. The private rendering scheme, ephemeral context, disabled -storage, and no-network content policy apply in both package forms. - -## Renderer policy - -Mermaid uses its programmatic `render` API with automatic scanning disabled, -strict security, HTML labels and error drawings disabled, deterministic IDs, -bounded text/edge counts, and BusyMark light/dark themes. - -PlantUML uses the official MIT `@plantuml/core` TeaVM browser engine and its -bundled Viz.js layout runtime. BusyMark's release corpus covers sequence, class, -component, deployment, state, activity, use-case, entity relationship, mind -map, WBS, Gantt, JSON, and YAML diagrams in WebKitGTK. Sudoku is intentionally -absent from the MIT browser build, as documented by PlantUML. - -D2 uses the official Linux amd64 executable directly, never through a shell. -Execution has bounded source, time, stdout, stderr, dimensions, and output, and -uses a fresh temporary working directory with a minimal environment. Fenced D2 -imports and icon/image assets are disabled in the first release. BusyMark asks -D2 only for SVG: safe CSS is inlined, executable or remote content and -animations are removed. Vector output is accepted only when every remaining CSS -rule can be represented without changing its meaning. Embedded fonts, -unsupported selectors or declarations, conflicting cascade rules, and -`` content retain their sanitized browser styling and are -rasterized by the local WebKit host. BusyMark never silently drops styling to -claim that an SVG is vector-safe. Raster fallback prefers 2× preview and 3× PDF -output, then reduces the scale when necessary to stay within WebKit's 8192-pixel -dimension and 64,000,000-pixel area limits. Stored raster metadata uses the -host's actual ceiling-rounded pixel dimensions. The existing external-SVG -policy is unchanged. - -OpenAPI uses Scalar's parser for OpenAPI 3.2, 3.1, 3.0, and Swagger 2.0, -Scalar's official JSON bundler for local references, and the YAML parser's exact -source locations for diagnostics. Only relative files anchored within the -canonical workspace are accepted. Absolute, remote, traversal, symlink-escape, -oversized, and excessive dependency graphs are rejected. The Scalar window is -given bundled content, not a URL; Agent, telemetry, authentication persistence, -API requests, developer tools, plugins, proxying, remote fonts, and custom -fetches are disabled. - -## PDF export - -`MarkdownPdfExportService` renders recognized fences before mapping the export -model. Generated assets are stored by SHA-256 under the temporary -`generated-assets` directory and then placed by the existing Typst template. -OpenAPI is mapped to headings, tables, paragraphs, operations, parameters, -request bodies, responses, security schemes, and schemas instead of a Scalar -screenshot. A renderer failure preserves the original fenced source and adds a -warning; it does not abort the document export. - -## Pinned dependencies - -| Component | Version | Verified artifact SHA-256 | -| --- | --- | --- | -| Mermaid source | 11.16.1 | `0ee99b3bb82766e5d6c34b8cc768b8530ce8f1aaa13790ae368aebeef3de9d11` | -| `@plantuml/core` | 1.2026.6 | `798f99592eb03a6446519d2becf78e6f1008d0d25c75d60b37a0f46e39e3c413` | -| `@scalar/openapi-parser` | 0.28.14 | `993bb7ebb3480cc574665b0eac52d9cd4a817fdf5b4444894bb70e174880513d` | -| `@scalar/api-reference` | 1.65.1 | `68b6f22ca530ac50e3cd034c5189d89cc5457c3c2d325b44e90db05c9f08c573` | -| `@scalar/json-magic` | 0.13.0 | `f1adefc461f3594afd4ad16974820a5a88b271f7e8051045c2ac7a34eb974d33` | -| YAML | 2.9.0 | `008fa204cb1ba700e0272ba045abbf09a6ffe63456e8146ba97cac6c2ad1ef91` | -| D2 Linux amd64 archive | 0.7.1 | `eb172adf59f38d1e5a70ab177591356754ffaf9bebb84e0ca8b767dfb421dad7` | -| D2 Linux amd64 executable | 0.7.1 | `48db68dfb42b76970a6769f038ec60da932adbb058257e07c50f5baaa3046016` | - -The web build also pins all transitive packages in -`tools/visualization/package-lock.json` and runs `npm ci --ignore-scripts`. -Build scripts verify the direct upstream archives before installation and copy -package metadata, distributed licenses, and a consolidated notice into the -application bundle. The build requires Node.js 22 or newer; the core24 Snap -recipe uses the official `node/24/stable` build snap. Node.js and npm are build -tools only. Runtime rendering does not require Node.js, Chromium, Java, a -public rendering service, or a first-run download. - -BusyMark builds the complete Mermaid source profile with Mermaid's internal -math rendering disabled. This keeps the existing diagram families while -removing KaTeX from both the locked dependency graph and the shipped web -bundle; mathematical document content is rendered only by MathJax. - -D2 is packaged only for Linux amd64. BusyMark must not advertise another -architecture until the Snap platform, upstream artifact, and full corpus are -all added and tested for it. - -## Verification - -Run the deterministic unit/widget/export suite with: - -```bash -flutter analyze -BUSYMARK_D2_PATH=build/linux/x64/debug/d2/linux-x86_64/d2 \ -BUSYMARK_TYPST_PATH=build/linux/x64/debug/bundle/libexec/busymark/typst \ - flutter test -``` +Referenced files must be valid UTF-8 and stay within the configured size limit. +Absolute paths, URI schemes, traversal, and symlink escapes are rejected. +Semantic tags and `src` attributes are enabled only for Writerside projects; +ordinary Markdown treats them as ordinary markup. -Run the real offline WebKit/D2 conformance corpus under X11 with: +## Export and failures -```bash -xvfb-run -a -s '-screen 0 1280x1024x24' \ - env WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ - /usr/bin/python3 -u tools/visualization_smoke.py \ - --assets build/linux/x64/debug/visualization/web \ - --d2 build/linux/x64/debug/d2/linux-x86_64/d2 -``` +Generated diagrams and API references are included in PDF and offline HTML +exports. When a renderer fails, BusyMark keeps the original fenced source as a +readable fallback and reports a warning instead of aborting the document export. -The release binary has a CI-only verification entry point. It is accepted by -the native host only when `BUSYMARK_RELEASE_SMOKE=1` is set and writes a JSON -report plus a real Typst PDF: +Rendering does not require Java, Chromium, a public rendering service, or a +first-run download. D2 runs as a bounded local process. Mermaid, PlantUML, and +OpenAPI run in BusyMark's packaged, no-network WebKit environment. Generated SVG +is sanitized before display or export, and styling that cannot be preserved +safely as vector content is rasterized rather than silently removed. -```bash -BUSYMARK_RELEASE_SMOKE=1 \ - build/linux/x64/release/bundle/busymark \ - --visualization-release-smoke=/tmp/busymark-visualization-report.json -``` +Maintainers can find architecture, dependency, and verification details in +[Visualization implementation and verification](development/visualizations.md). + +## Syntax references -The Linux workflow installs WebKitGTK 4.1 development files explicitly, builds -the release bundle, runs all Flutter tests with the bundled D2 and Typst paths, -and runs the real engine corpus under X11 and Wayland. It then exercises the -actual Dart coordinator, native WebKit channel, D2 CSS and `foreignObject` -raster paths, OpenAPI model, live WebKit process termination/recovery, and Typst -PDF export through the release executable. - -The same workflow builds the final strict Snap in a clean core24 build -environment, installs it as an unasserted confined package, and runs that -release verification under both X11 and Wayland. Automated suites also cover -cancellation, stale-result rejection, timeout wiring, sanitization and external -resources, traversal and symlink escapes, circular OpenAPI references, input -limits, both themes, cache-version invalidation, D2 raster snapshots, and a -rasterized visual assertion of generated SVG content in the PDF. Human release -review should still inspect the fixture documents and PDF for visual quality; it -is not a substitute for these automated product-path checks. - -## Authoritative references - -- [Flutter Linux platform channels](https://docs.flutter.dev/platform-integration/platform-channels) -- [WebKitGTK ephemeral contexts](https://webkitgtk.org/reference/webkit2gtk/stable/ctor.WebContext.new_ephemeral.html) -- [WebKitGTK subprocess sandbox](https://webkitgtk.org/reference/webkit2gtk/stable/method.WebContext.set_sandbox_enabled.html) -- [WebKitGTK web-process termination](https://webkitgtk.org/reference/webkit2gtk/stable/method.WebView.terminate_web_process.html) -- [Mermaid programmatic usage and strict security](https://mermaid.js.org/config/usage.html) -- [PlantUML official npm publishing and MIT build](https://github.com/plantuml/plantuml/blob/master/PUBLISHING_NPM.md) -- [D2 SVG and PNG export behavior](https://d2lang.com/tour/exports/) +- [Writerside D2 diagrams](https://www.jetbrains.com/help/writerside/d2-diagrams.html) +- [Writerside PlantUML diagrams](https://www.jetbrains.com/help/writerside/plantuml-diagrams.html) +- [Writerside Mermaid diagrams](https://www.jetbrains.com/help/writerside/mermaid-diagrams.html) - [D2 imports](https://d2lang.com/tour/imports/) - [D2 icons and images](https://d2lang.com/tour/icons/) -- [Scalar OpenAPI parser](https://github.com/scalar/scalar/blob/main/packages/openapi-parser/README.md) -- [Scalar API Reference configuration](https://scalar.com/products/api-references/configuration) -- [Snap confinement](https://snapcraft.io/docs/explanation/security/snap-confinement/) -- [Snapcraft GNOME extension](https://documentation.ubuntu.com/snapcraft/latest/reference/extensions/gnome-extension/) -- [Snapcraft project-file `grade` semantics](https://documentation.ubuntu.com/snapcraft/latest/reference/project-file/snapcraft-yaml/) -- [GitHub Ubuntu 24.04 runner image](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md) -- [Ubuntu 24.04 WebKitGTK 4.1 development package](https://packages.ubuntu.com/noble-updates/libwebkit2gtk-4.1-dev) diff --git a/docs/writerside-instances.md b/docs/writerside-instances.md index 8a902f5a..1b0d7d81 100644 --- a/docs/writerside-instances.md +++ b/docs/writerside-instances.md @@ -54,14 +54,6 @@ identified, but are not expanded when only one help module is open. The selected instance and icon colors are local BusyMark preferences. They do not modify or add undocumented Writerside project metadata. -## Example - -[`demo/writerside-instances`](../demo/writerside-instances) is an openable -Writerside module with two output instances, a TOC library, instance groups, -custom filters, a cross-instance topic reference, per-instance build settings, -release/EAP statuses, hidden and work-in-progress topics, an external TOC -link, and redirect metadata. - ## Authoritative references - [Instances](https://www.jetbrains.com/help/writerside/instances.html) diff --git a/docs/writerside-support.md b/docs/writerside-support.md index 44f34804..41eadc0e 100644 --- a/docs/writerside-support.md +++ b/docs/writerside-support.md @@ -1,8 +1,8 @@ # Writerside support BusyMark's semantic baseline is Writerside **2026.08.0328**, documented by -JetBrains on September 1, 2026. Preview and native PDF export use the same -`WritersideDocumentResolver` and `WritersideDocumentRenderer`. +JetBrains on September 1, 2026. Reading view and native PDF export resolve the +same supported Writerside content. ## Resolved content @@ -42,9 +42,7 @@ JetBrains on September 1, 2026. Preview and native PDF export use the same | Unsupported content | Visible source fallback and source-linked diagnostic | Visible fallback with a warning, or an explicit resolution failure | Native export resolves local topic links to unique PDF destinations, including -non-heading IDs. Source references carry resolved text, while image/resource -locations carry paths; asset rewriting therefore cannot turn a diagram reference -into a rejected `file:` source. Export requires saved project changes, as before. +non-heading IDs. Export requires project changes to be saved first. OpenAPI schema depth controls inline expansion; this editor does not generate website pages for schema objects. File resources in a PDF remain local links, @@ -59,41 +57,15 @@ Usages searches all indexed topics, including those outside the selected instance. Rename verifies every source range and stages normal undoable edits in document tabs. Existing topic-file and instance operations remain available. -Completion filters by syntactic context and prefix before limiting results. -Attribute values use reference kinds and, for element IDs, the referenced topic. -The authoring registry incorporates JetBrains' official topic XSD and documented -release overlays such as sortable tables. Recognizing an element in this -registry does not imply a semantic renderer: unsupported content is diagnosed. -Build-profile validation accepts `true` and rejects the old -`single-file` form. +Completion uses the current syntax, prefix, reference kind, and referenced topic +to narrow its suggestions. It incorporates JetBrains' official topic XSD and +documented release additions such as sortable tables. A recognized completion +does not necessarily have a semantic renderer; unsupported content remains +visible and produces a diagnostic. Build-profile validation accepts +`true` and rejects the old `single-file` form. -## Verification - -`test/src/writerside_*_test.dart` covers resolution, source preservation, file -bounds, instance changes, widgets, editing and export. A native Typst integration -test runs when the bundled compiler is present; otherwise it is explicitly skipped. -API unit tests inject the existing platform parser interface; they do not claim -to run that native parser. - -`test/fixtures/writerside/conformance_semantics.json` was extracted from the -**2026.08.0328** official builder output. The fixture's builder report had 181 -passing checks with no warnings or errors. BusyMark tests compare paragraphs, -quotes, shortcuts, tooltip descriptions, table cell structure and See also links -against this snapshot. This is a comparison for that fixture, not a claim of -complete website artifact parity. - -```bash -flutter test --no-pub test/src/writerside*_test.dart \ - test/src/source_autocomplete_test.dart test/src/source_editor_widget_test.dart \ - test/src/workspace_controller_test.dart test/src/markdown_export_mapper_test.dart -bash tools/validate_writerside_conformance.sh -``` - -The builder check uses temporary source copies and checks both the builder report -and normalized semantic snapshot. Set `WRITERSIDE_CONFORMANCE_OUTPUT` to retain -its generated artifacts. Schema extraction is reproducible with -`python3 tools/generate_writerside_schema.py /path/to/topic.v2.xsd`; the generated -file records the XSD URL and SHA-256. Format the result with `dart format`. +Maintainers can find the conformance fixture, schema, and test workflow in +[Writerside verification](development/writerside-verification.md). ## Official references From 8f3c08ab4bc58f1b145992173647dac1392a28a6 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 16:03:00 -0700 Subject: [PATCH 27/27] Disable hardware acceleration for GtkOffscreenWindow in WebKit WebView rendering setup --- .github/workflows/flutter-linux.yml | 13 ++-- docs/development/visualizations.md | 10 +++- linux/runner/web_render_host.cc | 7 +++ .../visualization_packaging_audit_test.dart | 59 +++++++++++++++++++ tools/visualization_smoke.py | 5 ++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index a712d7eb..dd993135 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -111,7 +111,7 @@ jobs: - name: Run real WebKit, PlantUML, Mermaid, OpenAPI, and D2 smoke tests run: | xvfb-run -a -s '-screen 0 1280x1024x24' \ - env WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ + env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ /usr/bin/python3 -u tools/visualization_smoke.py \ --assets build/linux/x64/release/bundle/share/busymark/visualization \ --d2 build/linux/x64/release/bundle/libexec/busymark/d2 \ @@ -123,7 +123,7 @@ jobs: report="${RUNNER_TEMP}/visualization-release-x11.json" timeout --signal=TERM 300s \ xvfb-run -a -s '-screen 0 1280x1024x24' \ - env BUSYMARK_RELEASE_SMOKE=1 WEBKIT_DISABLE_COMPOSITING_MODE=1 \ + env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ LIBGL_ALWAYS_SOFTWARE=1 \ build/linux/x64/release/bundle/busymark \ --visualization-release-smoke="$report" @@ -151,8 +151,7 @@ jobs: done test -S "$runtime_dir/wayland-99" XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ - GDK_BACKEND=wayland WEBKIT_DISABLE_COMPOSITING_MODE=1 \ - LIBGL_ALWAYS_SOFTWARE=1 \ + GDK_BACKEND=wayland LIBGL_ALWAYS_SOFTWARE=1 \ /usr/bin/python3 -u tools/visualization_smoke.py \ --assets build/linux/x64/release/bundle/share/busymark/visualization \ --d2 build/linux/x64/release/bundle/libexec/busymark/d2 \ @@ -161,7 +160,7 @@ jobs: report="${RUNNER_TEMP}/visualization-release-wayland.json" XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ GDK_BACKEND=wayland BUSYMARK_RELEASE_SMOKE=1 \ - WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 \ timeout --signal=TERM 300s \ build/linux/x64/release/bundle/busymark \ --visualization-release-smoke="$report" @@ -228,7 +227,7 @@ jobs: mkdir -p "$(dirname "$report")" timeout --signal=TERM 300s \ xvfb-run -a -s '-screen 0 1280x1024x24' \ - env BUSYMARK_RELEASE_SMOKE=1 WEBKIT_DISABLE_COMPOSITING_MODE=1 \ + env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ LIBGL_ALWAYS_SOFTWARE=1 snap run busymark \ --visualization-release-smoke="$report" python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report' "$report" @@ -257,7 +256,7 @@ jobs: report="$HOME/snap/busymark/common/visualization-release-wayland.json" XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ GDK_BACKEND=wayland BUSYMARK_RELEASE_SMOKE=1 \ - WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 \ timeout --signal=TERM 300s snap run busymark \ --visualization-release-smoke="$report" python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report' "$report" diff --git a/docs/development/visualizations.md b/docs/development/visualizations.md index 77a247c6..bd821752 100644 --- a/docs/development/visualizations.md +++ b/docs/development/visualizations.md @@ -67,7 +67,7 @@ Run the real WebKit and D2 corpus under X11: ```bash xvfb-run -a -s '-screen 0 1280x1024x24' \ - env WEBKIT_DISABLE_COMPOSITING_MODE=1 LIBGL_ALWAYS_SOFTWARE=1 \ + env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ /usr/bin/python3 -u tools/visualization_smoke.py \ --assets build/linux/x64/debug/visualization/web \ --d2 build/linux/x64/debug/d2/linux-x86_64/d2 \ @@ -87,3 +87,11 @@ BUSYMARK_RELEASE_SMOKE=1 \ The report must contain `"ok": true`; the same directory receives the generated PDF and HTML fixtures. The Linux workflow is authoritative for compositor setup, X11 and Wayland coverage, the strict Snap run, and required environment flags. + +The native host and Python corpus harness disable hardware compositing only for +their offscreen WebKit views, before realization and after process recovery. +GTK offscreen windows cannot provide the GL context that accelerated compositing +requires. Normal startup does not require a WebKit environment override, and +Flutter and visible API-reference windows retain their normal rendering settings. +Smoke tests must not globally disable WebKit compositing; `LIBGL_ALWAYS_SOFTWARE` +is only used to provide Mesa rendering on headless CI displays. diff --git a/linux/runner/web_render_host.cc b/linux/runner/web_render_host.cc index 327375c0..e1e6fa58 100644 --- a/linux/runner/web_render_host.cc +++ b/linux/runner/web_render_host.cc @@ -561,6 +561,13 @@ void recreate_render_view(BusyMarkWebRenderHost* self) { self->web_view = WEBKIT_WEB_VIEW( webkit_web_view_new_with_context(self->context)); configure_web_view(self->web_view); + // GtkOffscreenWindow cannot provide a GL context for compositing. Set this + // before realizing/loading the view, including after WebKit process recovery. + // Keep the policy local: visible reference windows and Flutter still use + // their normal rendering settings. + webkit_settings_set_hardware_acceleration_policy( + webkit_web_view_get_settings(self->web_view), + WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); g_signal_connect(self->web_view, "load-changed", G_CALLBACK(render_load_changed_cb), self); g_signal_connect(self->web_view, "web-process-terminated", diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 81537a27..d4fed33b 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -184,6 +184,14 @@ void main() { ), ); expect(workflow, contains('GDK_BACKEND=wayland')); + expect(workflow, contains('GDK_BACKEND=x11')); + expect( + workflow, + isNot(contains('WEBKIT_DISABLE_COMPOSITING_MODE')), + reason: + 'Native smoke tests must exercise the offscreen view policy ' + 'without globally disabling WebKit compositing.', + ); expect(workflow, contains('snapcore/action-build@v1')); expect(workflow, contains("if: steps.snapcraft.outcome == 'failure'")); expect(workflow, contains(r'${PRIMARY_SNAP:-$RETRY_SNAP}')); @@ -210,6 +218,57 @@ void main() { expect(smoke, contains('Raster snapshot lacked visual variation')); }); + test('only offscreen WebKit views disable hardware compositing', () { + final native = File('linux/runner/web_render_host.cc').readAsStringSync(); + final recreate = native + .split('void recreate_render_view(BusyMarkWebRenderHost* self) {')[1] + .split('gboolean recreate_render_view_cb(')[0]; + const policy = 'webkit_settings_set_hardware_acceleration_policy('; + final policyIndex = recreate.indexOf(policy); + + expect(policyIndex, greaterThanOrEqualTo(0)); + expect( + policyIndex, + greaterThan(recreate.indexOf('configure_web_view(self->web_view);')), + reason: + 'Apply the policy to the final per-view settings on every ' + 'creation, including WebKit process recovery.', + ); + expect( + policyIndex, + lessThan(recreate.indexOf('gtk_widget_show_all(')), + reason: + 'An offscreen view must not request a GL context before the ' + 'policy is applied.', + ); + expect( + recreate, + contains( + RegExp( + r'webkit_settings_set_hardware_acceleration_policy\(\s*' + r'webkit_web_view_get_settings\(self->web_view\),\s*' + r'WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER\)', + ), + ), + ); + expect( + policy.allMatches(native), + hasLength(1), + reason: 'Do not change the policy of visible API reference windows.', + ); + expect(native, isNot(contains('WEBKIT_DISABLE_COMPOSITING_MODE'))); + + final smoke = File('tools/visualization_smoke.py').readAsStringSync(); + final createView = smoke + .split('def _create_view(')[1] + .split('def _serve(')[0]; + expect(createView, contains('WebKit2.HardwareAccelerationPolicy.NEVER')); + expect( + createView.indexOf('set_hardware_acceleration_policy('), + lessThan(createView.indexOf('view.set_settings(settings)')), + ); + }); + test( 'WebKit host and harness disable persistence and external resources', () { diff --git a/tools/visualization_smoke.py b/tools/visualization_smoke.py index c489b114..4aee7658 100755 --- a/tools/visualization_smoke.py +++ b/tools/visualization_smoke.py @@ -60,6 +60,11 @@ def __init__(self, assets: Path, cases: list[dict[str, object]]) -> None: def _create_view(self) -> WebKit2.WebView: view = WebKit2.WebView.new_with_context(self.context) settings = WebKit2.Settings() + # Mirror the native offscreen view policy, including process recovery. + # Gtk.OffscreenWindow cannot supply a GL context for compositing. + settings.set_hardware_acceleration_policy( + WebKit2.HardwareAccelerationPolicy.NEVER + ) settings.set_enable_javascript(True) settings.set_enable_html5_local_storage(False) settings.set_enable_html5_database(False)

    Bold and ' + 'linked