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/README.md b/README.md index 2326f36b..d1dafd74 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,81 @@ 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. +* **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. +## 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 @@ -74,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. @@ -82,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. @@ -116,64 +196,12 @@ 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. -## 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.0. - -Install the required Linux development packages: +## History and recovery -```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 -``` +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 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 c40bbf0e..00000000 --- a/docs/clipboard-fix-plan.md +++ /dev/null @@ -1,77 +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, 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. - -| Representation | Purpose | -| --- | --- | -| `application/x-busymark-fragment+json` | Preserve BusyMark structure, inline formatting, task states, tables, and Writerside content between instances. | -| `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_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. Block IDs are regenerated during insertion. Relative links are rebased - and resolved local media paths are carried between documents/projects. -- `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 JSON, HTML, and plain text on - isolated X11 and headless GNOME Wayland displays. 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. - -## 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 BusyMark format and use readable HTML fallbacks externally. Local -media references point to existing files and do not bundle their bytes. - -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..bd821752 --- /dev/null +++ b/docs/development/visualizations.md @@ -0,0 +1,97 @@ +# 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 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 \ + --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. + +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/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.md b/docs/history.md new file mode 100644 index 00000000..88be4aa9 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,105 @@ +# Clipboard History and Local History + +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, 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, 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…**. 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 recent unsaved buffers after an interrupted + session; it is not a revision timeline. +- **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. 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/html-export.md b/docs/html-export.md index 5f809383..70c848f7 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 **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 and untitled content. Writerside first offers Save, Discard, or Cancel for 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/pdf-export.md b/docs/pdf-export.md index 39b77dca..32350230 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 **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. The export settings are independent of the editor and preview appearance. Markdown and Writerside offer the same PDF controls: @@ -39,6 +41,17 @@ 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 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. + ## Offline content PDF export works locally with BusyMark’s included PDF, math, and diagram tools. 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 abd754aa..1b0d7d81 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; @@ -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-pdf-export.md b/docs/writerside-pdf-export.md index 0382cddf..1a28d03b 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 **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: - one non-library Writerside instance; - a generated table of contents with heading depth 1–6 and optional heading numbering; 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 diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 6353995b..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": "الفئة", @@ -977,6 +981,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "إدراج سطر فارغ", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "نمط النص", "@textStyle": { "description": "Text style menu tooltip." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "exportInvalidGeometry": "اترك عرضًا وارتفاعًا للمحتوى لا يقلان عن 20 مم بعد الهوامش.", "exportInvalidColor": "أدخل لونًا بصيغة #RRGGBB.", "exportInvalidCss": "اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.", - "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}." + "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}.", + "clipboardHistory": "سجل النسخ واللصق", + "clipboardHistorySearchHint": "البحث في سجل الحافظة", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "clipboardHistorySearchHint": "Zwischenablageverlauf durchsuchen", + "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": "Lokalen Verlauf 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..c82c96a9 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", @@ -700,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", @@ -993,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."}, @@ -2018,7 +2024,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", @@ -2445,6 +2451,97 @@ "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", + "clipboardHistorySearchHint": "Search 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 local history", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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 en el historial local", + "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..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": { @@ -1065,6 +1069,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." @@ -1290,9 +1298,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": { @@ -3604,5 +3612,82 @@ "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", + "clipboardHistorySearchHint": "Otsi lõikepuhvri ajaloost", + "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 kohalikust ajaloost", + "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..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": "دسته‌بندی", @@ -978,6 +982,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "درج خط خالی", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "سبک متن", "@textStyle": { "description": "Text style menu tooltip." @@ -1208,9 +1216,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": { @@ -3186,5 +3194,82 @@ "exportInvalidGeometry": "پس از حاشیه‌ها دست‌کم ۲۰ میلی‌متر عرض و ارتفاع برای محتوا باقی بگذارید.", "exportInvalidColor": "رنگ را به‌صورت #RRGGBB وارد کنید.", "exportInvalidCss": "یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.", - "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید." + "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید.", + "clipboardHistory": "تاریخچه کپی و پیست", + "clipboardHistorySearchHint": "جستجو در تاریخچه کلیپ‌بورد", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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 dans l’historique local", + "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..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": "श्रेणी", @@ -977,6 +981,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "खाली पंक्ति डालें", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "टेक्स्ट शैली", "@textStyle": { "description": "Text style menu tooltip." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "exportInvalidGeometry": "मार्जिन के बाद सामग्री के लिए कम से कम 20 mm चौड़ाई और ऊँचाई छोड़ें।", "exportInvalidColor": "रंग #RRGGBB के रूप में दर्ज करें।", "exportInvalidCss": "256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।", - "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।" + "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।", + "clipboardHistory": "क्लिपबोर्ड इतिहास", + "clipboardHistorySearchHint": "क्लिपबोर्ड इतिहास खोजें", + "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..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": { @@ -1137,6 +1141,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." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "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", + "clipboardHistorySearchHint": "Cari riwayat papan klip", + "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 riwayat lokal", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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 nella cronologia locale", + "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..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": { @@ -1137,6 +1141,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "空行を挿入", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "テキストスタイル", "@textStyle": { "description": "Text style menu tooltip." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "exportInvalidGeometry": "余白を除いたコンテンツの幅と高さをそれぞれ 20 mm 以上にしてください。", "exportInvalidColor": "色を #RRGGBB 形式で入力してください。", "exportInvalidCss": "読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。", - "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。" + "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。", + "clipboardHistory": "コピーボード履歴", + "clipboardHistorySearchHint": "クリップボード履歴を検索", + "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..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": { @@ -1137,6 +1141,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "빈 줄 삽입", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "텍스트 스타일", "@textStyle": { "description": "Text style menu tooltip." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "exportInvalidGeometry": "여백을 제외한 콘텐츠 너비와 높이를 각각 20 mm 이상 확보하세요.", "exportInvalidColor": "#RRGGBB 형식으로 색상을 입력하세요.", "exportInvalidCss": "가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.", - "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요." + "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요.", + "clipboardHistory": "클립보드 기록", + "clipboardHistorySearchHint": "클립보드 기록 검색", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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 i lokal historikk", + "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..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": { @@ -1137,6 +1141,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." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "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", + "clipboardHistorySearchHint": "Klembordgeschiedenis doorzoeken", + "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": "Lokale geschiedenis doorzoeken", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3170,5 +3178,82 @@ "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", + "clipboardHistorySearchHint": "Przeszukaj historię schowka", + "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": "Przeszukaj historię lokalną", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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": "Pesquisar no histórico local", + "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..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", @@ -977,6 +981,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." @@ -1202,9 +1210,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": { @@ -3144,5 +3152,82 @@ "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", + "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", + "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 no histórico local", + "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..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": "Категория", @@ -977,6 +981,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Вставить пустую строку", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Стиль текста", "@textStyle": { "description": "Text style menu tooltip." @@ -1202,9 +1210,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": { @@ -3170,5 +3178,82 @@ "exportInvalidGeometry": "После вычета полей оставьте не менее 20 мм ширины и высоты содержимого.", "exportInvalidColor": "Введите цвет в формате #RRGGBB.", "exportInvalidCss": "Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.", - "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}." + "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}.", + "clipboardHistory": "История буфера обмена", + "clipboardHistorySearchHint": "Поиск в истории буфера обмена", + "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..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": { @@ -1137,6 +1141,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." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "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", + "clipboardHistorySearchHint": "Pano geçmişinde ara", + "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": "Yerel geçmişte ara", + "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..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": "Категорія", @@ -977,6 +981,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "Вставити порожній рядок", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "Стиль тексту", "@textStyle": { "description": "Text style menu tooltip." @@ -1202,9 +1210,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": { @@ -3170,5 +3178,82 @@ "exportInvalidGeometry": "Після віднімання полів залиште щонайменше 20 мм ширини й висоти вмісту.", "exportInvalidColor": "Введіть колір у форматі #RRGGBB.", "exportInvalidCss": "Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.", - "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}." + "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}.", + "clipboardHistory": "Історія Вирізання", + "clipboardHistorySearchHint": "Пошук в історії буфера обміну", + "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..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": { @@ -1137,6 +1141,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." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "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", + "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", + "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 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", + "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..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": { @@ -1137,6 +1141,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "插入空行", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "文本样式", "@textStyle": { "description": "Text style menu tooltip." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "exportInvalidGeometry": "扣除边距后,请至少保留 20 mm 的内容宽度和高度。", "exportInvalidColor": "请输入 #RRGGBB 格式的颜色。", "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", - "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。" + "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", + "clipboardHistory": "剪贴板历史", + "clipboardHistorySearchHint": "搜索剪贴板历史记录", + "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..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": { @@ -1137,6 +1141,10 @@ "@hardLineBreak": { "description": "Hard line break command label." }, + "insertBlankLine": "插入空行", + "@insertBlankLine": { + "description": "Insert blank line command label." + }, "textStyle": "文本样式", "@textStyle": { "description": "Text style menu tooltip." @@ -1378,9 +1386,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": { @@ -3829,7 +3837,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": { @@ -4703,5 +4711,82 @@ "exportInvalidGeometry": "扣除边距后,请至少保留 20 mm 的内容宽度和高度。", "exportInvalidColor": "请输入 #RRGGBB 格式的颜色。", "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", - "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。" + "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", + "clipboardHistory": "剪贴板历史", + "clipboardHistorySearchHint": "搜索剪贴板历史记录", + "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..74ee4668 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: @@ -2149,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. @@ -2857,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: @@ -5166,7 +5196,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'** @@ -6557,6 +6587,468 @@ 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 @clipboardHistorySearchHint. + /// + /// In en, this message translates to: + /// **'Search clipboard history'** + String get clipboardHistorySearchHint; + + /// 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 local history'** + 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..c3a7b289 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 => 'نمط النص'; @@ -1120,7 +1123,7 @@ class AppLocalizationsAr extends AppLocalizations { String get files => 'ملفات'; @override - String get toc => 'جدول المحتويات'; + String get toc => 'موضوعات'; @override String get tocActions => 'إجراءات جدول المحتويات'; @@ -1563,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 => 'ملف كبير: تم إيقاف التمييز والطي مؤقتًا'; @@ -3927,6 +3945,281 @@ class AppLocalizationsAr extends AppLocalizations { String get exportInvalidCss => 'اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.'; + @override + String get clipboardHistory => 'سجل النسخ واللصق'; + + @override + String get clipboardHistorySearchHint => 'البحث في سجل الحافظة'; + + @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..18d2b9b1 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'; @@ -1137,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'; @@ -1577,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'; @@ -3922,6 +3940,290 @@ 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 clipboardHistorySearchHint => 'Zwischenablageverlauf durchsuchen'; + + @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 => 'Lokalen Verlauf 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..5382e3d7 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'; @@ -1118,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'; @@ -1557,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'; @@ -3916,6 +3934,282 @@ 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 clipboardHistorySearchHint => 'Search 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 local history'; + + @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..7f22ea0a 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'; @@ -1137,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'; @@ -1574,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'; @@ -3925,6 +3943,291 @@ 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 clipboardHistorySearchHint => + 'Buscar en el historial del portapapeles'; + + @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 en el historial local'; + + @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..6cbb1541 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'; @@ -1122,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'; @@ -1554,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'; @@ -3887,6 +3905,287 @@ 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 clipboardHistorySearchHint => 'Otsi lõikepuhvri ajaloost'; + + @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 kohalikust ajaloost'; + + @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..403d1538 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 => 'سبک متن'; @@ -1142,7 +1145,7 @@ class AppLocalizationsFa extends AppLocalizations { String get files => 'فایل‌ها'; @override - String get toc => 'فهرست مطالب'; + String get toc => 'موضوعات'; @override String get tocActions => 'عملیات فهرست مطالب'; @@ -1592,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 => 'فایل بزرگ: برجسته‌سازی و جمع‌کردن موقتاً متوقف شده‌اند'; @@ -3933,6 +3951,282 @@ class AppLocalizationsFa extends AppLocalizations { String get exportInvalidCss => 'یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.'; + @override + String get clipboardHistory => 'تاریخچه کپی و پیست'; + + @override + String get clipboardHistorySearchHint => 'جستجو در تاریخچه کلیپ‌بورد'; + + @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..0a349c0a 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'; @@ -1135,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'; @@ -1577,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'; @@ -3922,6 +3940,292 @@ 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 clipboardHistorySearchHint => + 'Rechercher dans l’historique du 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 dans l’historique local'; + + @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..8a41f375 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 => 'टेक्स्ट शैली'; @@ -1117,7 +1120,7 @@ class AppLocalizationsHi extends AppLocalizations { String get files => 'फ़ाइलें'; @override - String get toc => 'TOC'; + String get toc => 'विषय'; @override String get tocActions => 'विषय-सूची संबंधी कार्रवाइयाँ'; @@ -1551,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 => 'बड़ी फ़ाइल: हाइलाइटिंग और फ़ोल्डिंग अस्थायी रूप से रुकी हुई हैं'; @@ -3881,6 +3899,285 @@ class AppLocalizationsHi extends AppLocalizations { String get exportInvalidCss => '256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।'; + @override + String get clipboardHistory => 'क्लिपबोर्ड इतिहास'; + + @override + String get clipboardHistorySearchHint => 'क्लिपबोर्ड इतिहास खोजें'; + + @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..926dbe68 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'; @@ -1124,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'; @@ -1557,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'; @@ -3896,6 +3914,282 @@ 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 clipboardHistorySearchHint => 'Cari riwayat papan klip'; + + @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 riwayat lokal'; + + @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..a48c6de5 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'; @@ -1132,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'; @@ -1571,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'; @@ -3918,6 +3936,293 @@ 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 clipboardHistorySearchHint => + 'Cerca nella cronologia degli appunti'; + + @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 nella cronologia locale'; + + @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..b7d5094e 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 => 'テキストスタイル'; @@ -1084,7 +1087,7 @@ class AppLocalizationsJa extends AppLocalizations { String get files => 'ファイル'; @override - String get toc => '目次'; + String get toc => 'トピック'; @override String get tocActions => '目次の操作'; @@ -1507,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 => '大きなファイル:ハイライトと折りたたみを一時停止しています'; @@ -3786,6 +3804,271 @@ class AppLocalizationsJa extends AppLocalizations { String get exportInvalidCss => '読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。'; + @override + String get clipboardHistory => 'コピーボード履歴'; + + @override + String get clipboardHistorySearchHint => 'クリップボード履歴を検索'; + + @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..5020fd71 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 => '텍스트 스타일'; @@ -1082,7 +1085,7 @@ class AppLocalizationsKo extends AppLocalizations { String get files => '파일'; @override - String get toc => '목차'; + String get toc => '토픽'; @override String get tocActions => '목차 작업'; @@ -1505,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 => '대용량 파일: 강조 표시 및 접기가 일시 중지됩니다.'; @@ -3782,6 +3799,271 @@ class AppLocalizationsKo extends AppLocalizations { String get exportInvalidCss => '가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.'; + @override + String get clipboardHistory => '클립보드 기록'; + + @override + String get clipboardHistorySearchHint => '클립보드 기록 검색'; + + @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..05ccbee8 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'; @@ -1123,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'; @@ -1558,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'; @@ -3888,6 +3906,286 @@ 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 clipboardHistorySearchHint => 'Søk i utklippstavlehistorikken'; + + @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 i lokal historikk'; + + @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..ec88b5aa 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'; @@ -1124,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'; @@ -1566,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'; @@ -3931,6 +3949,291 @@ 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 clipboardHistorySearchHint => 'Klembordgeschiedenis doorzoeken'; + + @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 => 'Lokale geschiedenis doorzoeken'; + + @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..401758c7 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'; @@ -1140,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'; @@ -1582,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'; @@ -3940,6 +3958,289 @@ 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 clipboardHistorySearchHint => 'Przeszukaj historię schowka'; + + @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 => 'Przeszukaj historię lokalną'; + + @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..c6d679a0 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'; @@ -1131,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'; @@ -1568,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'; @@ -3945,6 +3963,291 @@ 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 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.'; + + @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 => 'Pesquisar no histórico local'; + + @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.'; @@ -4894,6 +5197,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'; @@ -5077,7 +5383,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'; @@ -5514,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'; @@ -7888,6 +8209,292 @@ 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 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.'; + + @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 no histórico local'; + + @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..513224ce 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 => 'Стиль текста'; @@ -1137,7 +1140,7 @@ class AppLocalizationsRu extends AppLocalizations { String get files => 'Файлы'; @override - String get toc => 'Оглавление'; + String get toc => 'Темы'; @override String get tocActions => 'Действия с оглавлением'; @@ -1577,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 => 'Большой файл: подсветка и сворачивание приостановлены'; @@ -3941,6 +3959,289 @@ class AppLocalizationsRu extends AppLocalizations { String get exportInvalidCss => 'Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.'; + @override + String get clipboardHistory => 'История буфера обмена'; + + @override + String get clipboardHistorySearchHint => 'Поиск в истории буфера обмена'; + + @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..c7e6ab7e 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'; @@ -1124,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'; @@ -1554,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ı'; @@ -3889,6 +3907,289 @@ 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 clipboardHistorySearchHint => 'Pano geçmişinde ara'; + + @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 => 'Yerel geçmişte ara'; + + @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..5b1790d9 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 => 'Стиль тексту'; @@ -1144,7 +1147,7 @@ class AppLocalizationsUk extends AppLocalizations { String get files => 'Файли'; @override - String get toc => 'Зміст'; + String get toc => 'Теми'; @override String get tocActions => 'Дії зі змістом'; @@ -1585,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 => 'Великий файл: підсвічування та згортання призупинено'; @@ -3949,6 +3967,289 @@ class AppLocalizationsUk extends AppLocalizations { String get exportInvalidCss => 'Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.'; + @override + String get clipboardHistory => 'Історія Вирізання'; + + @override + String get clipboardHistorySearchHint => 'Пошук в історії буфера обміну'; + + @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..68003a57 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'; @@ -1117,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'; @@ -1550,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'; @@ -3884,6 +3902,284 @@ 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 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.'; + + @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 lịch sử cục bộ'; + + @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..2c7851cc 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 => '文本样式'; @@ -1069,7 +1072,7 @@ class AppLocalizationsZh extends AppLocalizations { String get files => '文件'; @override - String get toc => '目录'; + String get toc => '主题'; @override String get tocActions => '目录操作'; @@ -1487,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 => '大文件:已暂停语法突出显示和折叠'; @@ -3721,6 +3737,267 @@ class AppLocalizationsZh extends AppLocalizations { String get exportInvalidCss => '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; + @override + String get clipboardHistory => '剪贴板历史'; + + @override + String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; + + @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 之间的数字。'; @@ -4609,6 +4886,9 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get hardLineBreak => '硬换行'; + @override + String get insertBlankLine => '插入空行'; + @override String get textStyle => '文本样式'; @@ -4790,7 +5070,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get files => '文件'; @override - String get toc => '目录'; + String get toc => '主题'; @override String get tocActions => '目录操作'; @@ -5208,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 => '大文件:已暂停语法突出显示和折叠'; @@ -7442,6 +7735,267 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get exportInvalidCss => '请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。'; + @override + String get clipboardHistory => '剪贴板历史'; + + @override + String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; + + @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_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/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..bc24bdf3 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,9 +884,6 @@ class BusyMarkApp extends ConsumerWidget { back: material.backButtonTooltip, backShortcut: shortcut(BusyMarkCommandIds.back), save: label(BusyMarkCommandIds.save), - export: label(BusyMarkCommandIds.export), - exportShortcut: shortcut(BusyMarkCommandIds.export), - exportGtkAccelerator: accelerator(BusyMarkCommandIds.export), fullScreen: label(BusyMarkCommandIds.fullScreen), fullScreenShortcut: shortcut(BusyMarkCommandIds.fullScreen), fullScreenGtkAccelerator: accelerator(BusyMarkCommandIds.fullScreen), @@ -1148,6 +1212,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_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 1266c196..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, @@ -1599,6 +1613,13 @@ class _SyntaxReferenceDialogState extends State<_SyntaxReferenceDialog> { scope: l10n.markdown, documentationUri: _htmlDocumentationUri, ), + _SyntaxReferenceEntry( + title: l10n.insertBlankLine, + example: 'First line\n
\n
\nSecond line', + identifiers: '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/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 1e6a842e..b7190eea 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -10,8 +10,6 @@ import 'localization.dart'; import 'window_control_service.dart'; enum BusyMarkMainMenuAction { - export, - generateMarkdownToc, fullScreen, settings, keyboardShortcuts, @@ -22,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) { @@ -49,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), @@ -110,7 +86,6 @@ class BusyMarkMainMenuButton extends ConsumerWidget { ], onSelected: (action) { final commandId = switch (action) { - BusyMarkMainMenuAction.export => BusyMarkCommandIds.export, BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen, BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, BusyMarkMainMenuAction.keyboardShortcuts => @@ -119,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/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/app/busymark_shortcuts.dart b/lib/src/app/busymark_shortcuts.dart index 94e814f4..7d9e628d 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,11 +844,20 @@ 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; } -enum BusyMarkSidebarShortcutAction { files, toc, outline, git } +enum BusyMarkSidebarShortcutAction { + files, + toc, + outline, + git, + localHistory, + clipboardHistory, +} abstract final class BusyMarkSidebarShortcuts { const BusyMarkSidebarShortcuts._(); @@ -846,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( @@ -864,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 = { @@ -871,6 +901,8 @@ abstract final class BusyMarkSidebarShortcuts { BusyMarkSidebarShortcutAction.toc: toc, BusyMarkSidebarShortcutAction.outline: outline, BusyMarkSidebarShortcutAction.git: git, + BusyMarkSidebarShortcutAction.localHistory: localHistory, + BusyMarkSidebarShortcutAction.clipboardHistory: clipboardHistory, }; } @@ -882,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 { @@ -895,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, @@ -918,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 3813b741..33766699 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'; } @@ -365,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( @@ -381,6 +390,13 @@ abstract final class BusyMarkCommandCatalog { description: (context) => context.l10n.shortcutDeleteTreeItemDescription, ), + 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); } @@ -629,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_controller.dart b/lib/src/clipboard/clipboard_history_controller.dart new file mode 100644 index 00000000..e9fe9cce --- /dev/null +++ b/lib/src/clipboard/clipboard_history_controller.dart @@ -0,0 +1,292 @@ +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() { + _clipboard.discardObsoleteOwnership(); + 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 || + first.html != 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..afba6708 --- /dev/null +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -0,0 +1,476 @@ +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/busymark_toast.dart'; +import '../app/localization.dart'; +import 'clipboard_history_controller.dart'; +import 'clipboard_insertion.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 _listScrollController = ScrollController(); + final _listFocusNode = FocusNode(debugLabel: 'Clipboard History list'); + final _entryKeys = {}; + String? _selectedId; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited( + ref + .read(clipboardHistoryControllerProvider.notifier) + .refreshCurrentClipboard(), + ); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + _listScrollController.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 (_selectedId != null && + !visible.any((entry) => entry.id == _selectedId)) { + _selectedId = null; + } + final visibleIds = visible.map((entry) => entry.id).toSet(); + _entryKeys.removeWhere((id, _) => !visibleIds.contains(id)); + return Focus( + focusNode: _listFocusNode, + onKeyEvent: (_, event) => _handleKey(event, visible, registry), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + BusyMarkSpacing.md, + BusyMarkSpacing.sm, + ), + child: Row( + textDirection: TextDirection.ltr, + children: [ + Expanded( + child: BusyMarkSearchField( + controller: _searchController, + hintText: context.l10n.clipboardHistorySearchHint, + onChanged: (_) => setState(() {}), + onEscape: widget.onEscape, + ), + ), + 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(); + } + }, + ), + ], + ), + ), + 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( + key: const ValueKey('clipboard-history-list'), + controller: _listScrollController, + 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), + ), + ], + ), + ), + ], + ), + ); + } + + 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, + BusyMarkClipboardInsertionRegistry registry, + ) { + 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; + final nextIndex = (index < 0 ? 0 : index + delta).clamp( + 0, + visible.length - 1, + ); + final nextId = visible[nextIndex].id; + setState(() { + _selectedId = nextId; + }); + _ensureSelectionVisible(nextId); + return KeyEventResult.handled; + } + final selected = index < 0 ? visible.first : visible[index]; + if (event.logicalKey == LogicalKeyboardKey.enter && + registry.canPaste(selected)) { + 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; + } + + 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, + }) async { + final result = await ref + .read(clipboardInsertionRegistryProvider) + .paste(payload, plainText: plainText); + if (result == ClipboardPasteResult.inserted && payload.external) { + ref + .read(clipboardHistoryControllerProvider.notifier) + .retainCurrentAfterPaste(payload); + } else if (result != ClipboardPasteResult.inserted && mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.clipboardUnavailable, + priority: BusyMarkToastPriority.high, + ); + } + } +} + +enum _ClipboardHistoryAction { refresh, clear } + +enum _ClipboardEntryAction { paste, pastePlain, remove } + +class _ClipboardEntryTile extends StatelessWidget { + const _ClipboardEntryTile({ + super.key, + 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 status = current && payload.external + ? l10n.clipboardCurrentExternal + : current + ? l10n.clipboardCurrent + : 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: 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, + 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, + 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, + ), + 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(); + } + }, + ); + } +} + +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 _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..f538520e --- /dev/null +++ b/lib/src/clipboard/clipboard_insertion.dart @@ -0,0 +1,94 @@ +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(); +} + +/// 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. +class BusyMarkClipboardInsertionRegistry extends ChangeNotifier { + BusyMarkClipboardInsertionTarget? _target; + bool _notificationScheduled = false; + bool _disposed = false; + + 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; + _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 || + (captured is BusyMarkClipboardInsertionCapabilities && + !(captured as BusyMarkClipboardInsertionCapabilities).canPaste( + payload, + plainText: plainText, + ))) { + 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..df9ff64b --- /dev/null +++ b/lib/src/clipboard/clipboard_models.dart @@ -0,0 +1,274 @@ +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 || + html != 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_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 9dec96e5..f1c30e91 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; 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,10 +16,15 @@ 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'; +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'; @@ -81,6 +88,15 @@ class BusyMarkSourceEditor extends StatefulWidget { this.onSessionChanged, this.autocompleteContext = const SourceAutocompleteContext(), this.onSymbolAction, + this.clipboardService, + this.clipboardInsertionRegistry, + this.onClipboardCaptured, + this.workspaceRoot, + this.writersideRoot, + this.imagesDir = 'images', + this.assetWorkspaceKind, + this.assetIngestionService = const AssetIngestionService(), + this.onAssetSaveRequired, }); final String text; @@ -110,6 +126,15 @@ 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; + final String? workspaceRoot; + final String? writersideRoot; + final String imagesDir; + final AssetWorkspaceKind? assetWorkspaceKind; + final AssetIngestionService assetIngestionService; + final VoidCallback? onAssetSaveRequired; @override State createState() => BusyMarkSourceEditorState(); @@ -126,6 +151,9 @@ class BusyMarkSourceEditorState extends State { final _searchController = SourceSearchController(); final _searchWorker = SourceSearchWorker(); final _replacementWorker = SearchReplacementWorker(); + bool _applyingSearchReplacement = false; + ({int start, int end})? _requestedSearchRange; + bool _searchNavigationFailed = false; final _intrinsicWidthCache = SourceIntrinsicWidthCache(); final _lineLayoutCache = SourceLineLayoutCache(); final _autocompleteProvider = const SourceAutocompleteProvider(); @@ -142,6 +170,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 +182,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 +200,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) { @@ -199,6 +238,8 @@ class BusyMarkSourceEditorState extends State { if (widget.searchActive != oldWidget.searchActive || widget.searchOptions != oldWidget.searchOptions || authoritativeDocumentChanged) { + _requestedSearchRange = null; + _searchNavigationFailed = false; _syncSearchOptions(); } if (widget.wordWrap && !oldWidget.wordWrap) { @@ -212,6 +253,7 @@ class BusyMarkSourceEditorState extends State { @override void dispose() { + widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); _searchDebounce?.cancel(); _foldRefreshDebounce?.cancel(); _searchWorker.dispose(); @@ -299,11 +341,35 @@ 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; + _searchNavigationFailed = false; _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((_) { @@ -372,6 +438,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 +581,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 +698,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(), ), @@ -635,6 +744,8 @@ class BusyMarkSourceEditorState extends State { ), ), replacement: widget.searchReplacement, + canReplaceCurrent: + _requestedSearchRange == null && !_searchNavigationFailed, onReplacementChanged: widget.onSearchReplacementChanged ?? (_) {}, onReplaceCurrent: () => unawaited(_replaceCurrentSearchMatch()), @@ -797,7 +908,26 @@ class BusyMarkSourceEditorState extends State { return; } _searchController.acceptResult(result); - if (minimumFullOffset != null && result.matches.isNotEmpty) { + 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, + ); + } 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) { _searchController.setCurrentMatchIndex(result.firstMatchIndex); } else if (revealCurrentAfterRefresh && _searchController.result.currentMatch == null && @@ -837,6 +967,8 @@ class BusyMarkSourceEditorState extends State { } void _nextSearchMatch() { + _requestedSearchRange = null; + _searchNavigationFailed = false; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -849,6 +981,8 @@ class BusyMarkSourceEditorState extends State { } void _previousSearchMatch() { + _requestedSearchRange = null; + _searchNavigationFailed = false; final result = _searchController.result; if (result.totalMatchCount == 0) { _revealSearchMatch(null); @@ -880,7 +1014,9 @@ class BusyMarkSourceEditorState extends State { } Future _replaceCurrentSearchMatch({bool findNext = false}) async { - if (_searchController.result.invalidRegex) { + if (_searchController.result.invalidRegex || + _requestedSearchRange != null || + _searchNavigationFailed) { return; } if (_searchController.result.currentMatchIndex == null) { @@ -906,6 +1042,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; @@ -916,27 +1053,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 { @@ -986,10 +1121,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, @@ -1089,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 = @@ -1101,10 +1238,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); @@ -1415,15 +1554,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 +1674,309 @@ class BusyMarkSourceEditorState extends State { } } + _SourceClipboardOperationTarget _captureClipboardTarget() => + _SourceClipboardOperationTarget( + documentId: widget.documentId ?? widget.filePath ?? '', + filePath: widget.filePath, + language: widget.language, + text: _controller.fullText, + selection: _controller.fullSelection, + ); + + bool _isClipboardTargetCurrent(_SourceClipboardOperationTarget target) => + mounted && + target.documentId == (widget.documentId ?? widget.filePath ?? '') && + target.filePath == widget.filePath && + target.language == widget.language && + 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) { + 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) { + 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 _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, + 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, + ) { + 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( @@ -2115,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, @@ -2129,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; @@ -2184,6 +2620,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: [ @@ -2261,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, ), @@ -2479,6 +2922,70 @@ class _SourceSessionSnapshot { } } +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; +} + +class _SourceClipboardInsertionTarget + implements + BusyMarkClipboardInsertionTarget, + BusyMarkClipboardInsertionCapabilities { + 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 + bool canPaste(BusyMarkClipboardPayload payload, {required bool plainText}) { + 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; + } + + @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/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/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index 7f83f52e..e8f41000 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -135,20 +135,28 @@ class WysiwygClipboardFragment { } /// Rebase authored references, without opening or fetching clipboard URLs. - WysiwygClipboardFragment rebase(String targetPath) { - if (sourcePath.isEmpty || - targetPath.isEmpty || - p.equals(sourcePath, targetPath)) { + WysiwygClipboardFragment rebase( + String targetPath, { + Map mediaDestinations = const {}, + }) { + final rebaseRelativeReferences = + sourcePath.isNotEmpty && + targetPath.isNotEmpty && + !p.equals(sourcePath, targetPath); + if (!rebaseRelativeReferences && mediaDestinations.isEmpty) { return this; } 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(); } } + 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); @@ -194,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_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 666ea622..09520e9f 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'; @@ -67,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(); @@ -647,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, ), @@ -660,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, ), @@ -880,15 +891,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), @@ -1514,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( @@ -1524,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, ), @@ -1545,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); @@ -1556,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); @@ -2423,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, { @@ -2881,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'); } @@ -3268,155 +3367,78 @@ 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 withBlankParagraphs.copyWith( - blocks: [ - ...withBlankParagraphs.blocks, - const BusyBlock( - id: 'empty-paragraph', - kind: BusyBlockKind.paragraph, - inlines: [BusyInline(kind: BusyInlineKind.text, text: '')], - ), - ], - ); -} - -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, - ]; + if (!hasEditableBlock) { return document.copyWith( blocks: [ - ...frontMatterBlocks, - for (final (index, offset) in blankOffsets.indexed) - _sourceBlankParagraph(document, offset, index), - ...generatedBlocks, + ...document.blocks, + const BusyBlock( + id: 'empty-paragraph', + kind: BusyBlockKind.paragraph, + inlines: [BusyInline(kind: BusyInlineKind.text, text: '')], + ), ], ); } - 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) { + 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 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; + 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: [...frontMatterBlocks, ...expanded, ...generatedBlocks], + blocks: [...document.blocks, _transientTrailingParagraph(id)], ); } -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) { +BusyBlock _transientTrailingParagraph(String id) { return BusyBlock( - id: '\u0000source-blank:$offset:$index', + id: id, 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, - ), + 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 48683671..d717b86c 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, @@ -580,27 +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, + _applyAsyncToolbarCommand(_applyInlineImageCommand), + onTableCommand: () => + _applyAsyncToolbarCommand(_applyTableCommand), + onHtmlCommand: () => + _applyAsyncToolbarCommand(_applyHtmlCommand), + onIndentCommand: () => + _applyToolbarCommand(_applyIndentCommand), + onOutdentCommand: () => + _applyToolbarCommand(_applyOutdentCommand), + onToggleTaskCommand: () => + _applyToolbarCommand(_applyToggleTaskCommand), + onHardBreakCommand: () => + _applyToolbarCommand(_applyHardBreakCommand), + onBlankLineCommand: () => + _applyToolbarCommand(_applyBlankLineCommand), ), ), ], @@ -2576,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 && @@ -2699,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) { @@ -3477,6 +3533,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 @@ -4049,9 +4162,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 +4178,7 @@ class _BusyMarkWysiwygEditorState extends State { data.text ?? fragment.documentBlocks.map(_copyTextForBlock).join('\n\n'), )) { + _retainExternalClipboardData(data, fragment: fragment); return; } } @@ -4076,11 +4190,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 +4212,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: fragment?.encode() ?? 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 +4329,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 +4370,7 @@ class _BusyMarkWysiwygEditorState extends State { blockId, nextText, ); + return true; } bool _replaceActiveTableCellSelection( @@ -4516,6 +4695,25 @@ class _BusyMarkWysiwygEditorState extends State { _recordUndoSnapshot(); _documentController.insertHardBreak(blockId, offset); _emitMarkdown(); + _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 { @@ -4681,6 +4879,7 @@ class _BusyMarkWysiwygEditorState extends State { String sourcePath, AssetIngestionOrigin origin, { bool reportInvalidImage = true, + _ClipboardTarget? clipboardTarget, }) async { try { final asset = await widget.assetIngestionService.ingestFile( @@ -4688,14 +4887,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 +4913,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 +4923,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 +4943,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 +4963,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 +4989,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 +5845,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 +5874,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 +6228,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 +6246,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 +6275,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 +6301,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 +6349,174 @@ 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; + } + } + 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); + return inserted + ? ClipboardPasteResult.inserted + : 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, + 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 +6622,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 +6985,82 @@ class _ClipboardTarget { final TextSelection? textSelection; } +class _WysiwygClipboardInsertionTarget + implements + BusyMarkClipboardInsertionTarget, + BusyMarkClipboardInsertionCapabilities { + 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 + 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.html != null || + payload.text != null || + payload.sourceText != null), + BusyMarkClipboardContentKind.text => + payload.text != null || payload.sourceText != null, + }; + } + + @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 +7068,8 @@ enum _DocumentSelectionMenuAction { paste, selectAll, refineWithAi, + clipboardHistory, + localHistory, } class _ContinuousTextEdit { diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 5d2c2b3d..554b3d7c 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, ), ], [ @@ -182,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/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/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 8a5a89ce..387f63a2 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 { 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..ce5f0260 --- /dev/null +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -0,0 +1,766 @@ +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_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}); + + @override + ConsumerState createState() => + _LocalHistoryComparisonViewState(); +} + +class _LocalHistoryComparisonViewState + extends ConsumerState { + Future<_ComparisonSnapshot?>? _future; + _ComparisonRequest? _request; + + @override + Widget build(BuildContext context) { + final history = ref.watch(localHistoryControllerProvider); + ref.watch(workspaceControllerProvider); + final document = history.selectedDocument; + final revision = history.selectedRevision; + if (document == null || + revision == null || + revision.summary.documentId != document.id) { + return const SizedBox.shrink(); + } + final matchingBuffer = ref + .read(workspaceControllerProvider.notifier) + .localHistoryBufferForDocument(document, revision); + final request = _ComparisonRequest( + documentId: document.id, + revisionId: revision.summary.id, + revisionVersion: revision.summary.capturedAt.microsecondsSinceEpoch, + currentBufferId: matchingBuffer?.id, + currentSourceVersion: matchingBuffer?.revision, + currentSource: matchingBuffer?.text, + currentPath: document.currentPath, + ); + if (_request != request) { + _request = request; + _future = _buildSnapshot(document, revision, request); + } + 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()); + } + 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: actionsAreCurrent && data.canRestore + ? () => _restore(document, revision) + : null, + onRestoreChange: actionsAreCurrent && data.canRestoreChange + ? (change) => _restore( + document, + revision, + comparison: data.comparison, + change: change, + ) + : null, + onRestoreOriginal: + actionsAreCurrent && + data.missing && + (document.currentPath ?? revision.summary.historicalPath) != + null + ? () => _restoreMissing( + document, + revision, + document.currentPath ?? revision.summary.historicalPath!, + ) + : null, + onRestoreNewLocation: actionsAreCurrent && data.missing + ? () => _chooseRestoreLocation(document, revision) + : null, + ); + }, + ); + } + + Future<_ComparisonSnapshot?> _buildSnapshot( + LocalHistoryDocument document, + LocalHistoryRevision revision, + _ComparisonRequest request, + ) 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 ref.read(localHistoryComparisonComputerProvider)( + oldInput, + currentInput, + ); + return _ComparisonSnapshot( + request: request, + 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 _ComparisonRequest { + const _ComparisonRequest({ + required this.documentId, + required this.revisionId, + required this.revisionVersion, + required this.currentBufferId, + required this.currentSourceVersion, + required this.currentSource, + required this.currentPath, + }); + + final String documentId; + final String revisionId; + final int revisionVersion; + final String? currentBufferId; + final int? currentSourceVersion; + final String? currentSource; + 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.currentSource == currentSource && + other.currentPath == currentPath; + + @override + int get hashCode => Object.hash( + documentId, + revisionId, + revisionVersion, + currentBufferId, + currentSourceVersion, + currentSource, + 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 && + comparison.currentInput.source == currentBuffer.text && + request.currentSource == currentBuffer.text; + } +} + +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..5c5d8b73 --- /dev/null +++ b/lib/src/local_history/local_history_controller.dart @@ -0,0 +1,1973 @@ +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.inspectingRetainedDocument = 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 bool inspectingRetainedDocument; + 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, + bool? inspectingRetainedDocument, + 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, + inspectingRetainedDocument: + inspectingRetainedDocument ?? this.inspectingRetainedDocument, + 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; + + LocalHistoryBufferSnapshot atPath(String destinationPath) => + LocalHistoryBufferSnapshot( + bufferId: bufferId, + displayName: p.basename(destinationPath), + text: text, + format: format, + 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 { + 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), + ); + + LocalHistoryPendingIdentityPromotion forBuffer(String id) => + LocalHistoryPendingIdentityPromotion( + bufferId: id, + documentId: documentId, + destinationPath: destinationPath, + displayName: displayName, + ); +} + +class LocalHistoryController extends Notifier { + late LocalHistoryStore _store; + late LocalHistoryClock _clock; + late LocalHistoryTimerFactory _timerFactory; + final _documentIdsByBuffer = {}; + final _pending = {}; + final _checkpointTimers = {}; + final _bufferQueues = >{}; + 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; + + @override + LocalHistoryState build() { + _store = ref.read(localHistoryStoreProvider); + _clock = ref.read(localHistoryClockProvider); + _timerFactory = ref.read(localHistoryTimerFactoryProvider); + ref.listen( + appSettingsControllerProvider, + (previous, next) => _applyRecordingPolicy(next), + ); + 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, + ); + } + + LocalHistoryBufferSnapshot? pendingSnapshotForBuffer(String bufferId) => + _pending[bufferId]; + + 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; + await _publishSnapshot( + snapshot, + 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; + 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)) { + _setWarning(LocalHistoryWarningKind.pathChange); + return; + } + 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, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + } + 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, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + }); + } + + void observeEdit(DocumentBuffer previous, DocumentBuffer current) { + 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 (!_operationIsCurrent( + current.id, + historyGeneration, + bufferGeneration, + )) { + return; + } + if (!_documentIdsByBuffer.containsKey(current.id) && + !(previousSnapshot.untitled && previousSnapshot.text.isEmpty)) { + await _capture( + previousSnapshot, + LocalHistoryCaptureReason.baseline, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + } + if (!_operationIsCurrent( + current.id, + historyGeneration, + bufferGeneration, + )) { + return; + } + if (!_pendingIsEligible(currentSnapshot)) return; + _pending[current.id] = currentSnapshot; + _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, + LocalHistoryBufferPathTransitionKind kind = + LocalHistoryBufferPathTransitionKind.move, + }) { + final transition = LocalHistoryBufferPathTransition._( + bufferId: bufferId, + sourcePath: sourcePath, + destinationPath: p.normalize(destinationPath), + kind: kind, + ); + _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, + ); + } + final promotion = _pendingUntitledPromotions[transition.bufferId]; + if (committed && + transition.kind == LocalHistoryBufferPathTransitionKind.move && + promotion != null && + _sameOptionalPath(promotion.destinationPath, transition.sourcePath)) { + _pendingUntitledPromotions[transition.bufferId] = promotion.atPath( + transition.destinationPath, + ); + } + _scheduleCheckpoint(transition.bufferId); + } + + Future captureSaved(LocalHistoryBufferSnapshot snapshot) async { + 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, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ); + if (!_operationIsCurrent( + snapshot.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + if (captured) { + _acknowledgePending(snapshot.bufferId, snapshot.revision); + } else { + _retainPending(snapshot); + } + return captured; + }); + } + + Future captureProtective( + LocalHistoryBufferSnapshot snapshot, + LocalHistoryCaptureReason reason, + ) async { + if (!policy.recordingEnabled || policy.excludes(snapshot.path)) { + _setWarning(LocalHistoryWarningKind.recordingDisabled); + return false; + } + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); + return _enqueue( + snapshot.bufferId, + () => _capture( + snapshot, + reason, + force: true, + requireProtection: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), + ); + } + + /// 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; + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); + return _enqueue( + snapshot.bufferId, + () => _capture( + snapshot, + reason, + force: true, + requireProtection: true, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), + ); + } + + Future captureSavedAs( + LocalHistoryBufferSnapshot source, + String destinationPath, { + required bool destinationExisted, + }) async { + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(source.bufferId); + final destination = LocalHistoryBufferSnapshot( + bufferId: source.bufferId, + displayName: p.basename(destinationPath), + text: source.text, + format: source.format, + revision: source.revision, + path: destinationPath, + ); + var promotionCompleted = false; + var retainedSourcePromotion = false; + final captured = await _enqueue(source.bufferId, () async { + 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, + acceptedBufferGeneration: bufferGeneration, + ); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return true; + } + 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; + } 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); + } + } 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), + ), + ); + } + } + }); + if (!_operationIsCurrent( + source.bufferId, + historyGeneration, + bufferGeneration, + )) { + return 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; + } 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); + } + if (retainedSourcePromotion) { + _setWarning(LocalHistoryWarningKind.pathChange); + } + return captured; + } + + Future capturePath({ + required String path, + required String text, + required TextFormatMetadata format, + required LocalHistoryCaptureReason reason, + bool force = true, + bool requireProtection = false, + }) { + final snapshot = LocalHistoryBufferSnapshot( + bufferId: 'path:${p.normalize(path)}', + displayName: p.basename(path), + text: text, + format: format, + revision: 0, + path: path, + ); + final historyGeneration = _historyGeneration; + final bufferGeneration = _bufferGeneration(snapshot.bufferId); + return _enqueue( + snapshot.bufferId, + () => _capture( + snapshot, + reason, + force: force, + ignoreBinding: true, + requireProtection: requireProtection, + acceptedHistoryGeneration: historyGeneration, + acceptedBufferGeneration: bufferGeneration, + ), + ); + } + + 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, + requireProtection: true, + ); + } + + 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(); + 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 { + var succeeded = true; + for (final bufferId in _pendingUntitledPromotions.keys.toList()) { + if (!await _enqueue(bufferId, () async { + if (!await _completePendingUntitledPromotion(bufferId)) return false; + final pending = _pending[bufferId]; + _checkpointTimers.remove(bufferId)?.cancel(); + if (pending == null) return true; + return _capturePending(bufferId, pending); + })) { + succeeded = false; + } + } + return succeeded && _pendingUntitledPromotions.isEmpty; + } + + Future flushAll(Iterable buffers) async { + final bufferList = buffers.toList(growable: false); + for (final buffer in bufferList) { + 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); + }); + } + while (_bufferQueues.isNotEmpty) { + await Future.wait(_bufferQueues.values.toList(growable: false)); + } + 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 { + 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) { + if (_pathTransitions.containsKey(entry.key)) continue; + final path = entry.value.path; + if (path != null && + (p.equals(path, sourcePath) || p.isWithin(sourcePath, path))) { + affected.add(entry); + } + } + for (final entry in affected) { + _checkpointTimers.remove(entry.key)?.cancel(); + final captured = await _enqueue( + entry.key, + () => _capture( + entry.value, + LocalHistoryCaptureReason.automaticCheckpoint, + ), + ); + if (captured) { + _acknowledgePending(entry.key, entry.value.revision); + } + } + 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); + 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()); + } + } + + 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 { + final generation = ++_scopeGeneration; + _normalBrowsingScope = LocalHistoryBufferSnapshot.fromBuffer(buffer); + _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 (documentId == null) { + await refresh(); + if (!ref.mounted || generation != _scopeGeneration) return; + 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, inspectingRetainedDocument: false); + } else { + state = state.copyWith( + loading: false, + findingDocuments: false, + inspectingRetainedDocument: false, + ); + } + } else { + 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++; + _normalBrowsingScope = null; + _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, + ); + } + + /// 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() { + _scopeGeneration++; + _loadGeneration++; + _searchGeneration++; + state = state.copyWith( + loading: false, + selectedDocumentId: null, + selectedRevisionId: null, + selectedRevision: null, + searchQuery: '', + searching: false, + searchMatches: const {}, + documentSearchMatches: const {}, + findingDocuments: true, + inspectingRetainedDocument: false, + ); + } + + 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 || + state.selectedDocumentId != documentId || + state.selectedRevisionId != revisionId || + (revision != null && revision.summary.documentId != documentId)) { + 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 || + state.selectedDocumentId != documentId || + state.selectedRevisionId != revisionId) { + return; + } + state = state.copyWith( + loading: false, + warning: LocalHistoryWarning( + LocalHistoryWarningKind.revisionRead, + detail: error.toString(), + ), + ); + } + } + + void clearComparison() { + _loadGeneration++; + state = state.copyWith( + loading: false, + selectedRevisionId: null, + selectedRevision: null, + ); + } + + String? bufferIdForDocument(String documentId) => _documentIdsByBuffer.entries + .where((entry) => entry.value == documentId) + .map((entry) => entry.key) + .firstOrNull; + + String? documentIdForBuffer(String bufferId) => + _documentIdsByBuffer[bufferId]; + + Future search(String query) async { + await _runSearch(query, clearMatches: true); + } + + Future clearDocument(String documentId) async { + 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 _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 { + final affectedBuffers = { + ..._documentIdsByBuffer.keys, + ..._pending.keys, + ..._bufferQueues.keys, + }; + _historyGeneration++; + _loadGeneration++; + _searchGeneration++; + _cancelCheckpoints(clearTransitions: false); + _captureFailures.clear(); + _documentIdsByBuffer.clear(); + _pendingUntitledPromotions.clear(); + 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( + 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, { + bool force = false, + bool ignoreBinding = false, + bool allowPathChange = false, + bool allowDuringPathTransition = false, + bool requireProtection = false, + int? acceptedHistoryGeneration, + int? acceptedBufferGeneration, + }) async { + final captureHistoryGeneration = + acceptedHistoryGeneration ?? _historyGeneration; + final captureBufferGeneration = + acceptedBufferGeneration ?? _bufferGeneration(snapshot.bufferId); + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return !requireProtection; + } + 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 !requireProtection; + } + final promotion = _pendingUntitledPromotions[snapshot.bufferId]; + if (promotion != null) { + if (!_sameOptionalPath(snapshot.path, promotion.destinationPath)) { + _setWarning(LocalHistoryWarningKind.pathChange); + return false; + } + if (!await _completePendingUntitledPromotion( + snapshot.bufferId, + acceptedHistoryGeneration: captureHistoryGeneration, + acceptedBufferGeneration: captureBufferGeneration, + )) { + return false; + } + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return !requireProtection; + } + } + final currentPolicy = policy; + if (!currentPolicy.recordingEnabled || + currentPolicy.excludes(snapshot.path)) { + return false; + } + if (snapshot.untitled && + snapshot.text.isEmpty && + reason == LocalHistoryCaptureReason.baseline) { + return !requireProtection; + } + 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, + allowPathChange: allowPathChange, + ), + currentPolicy, + ); + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return !requireProtection; + } + if (!ignoreBinding) { + _documentIdsByBuffer[snapshot.bufferId] = result.document.id; + } + _captureFailures.remove(snapshot.bufferId); + if (ref.mounted) { + final loaded = await _store.load(); + if (_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + await _publishSnapshot( + loaded, + capturedBufferId: ignoreBinding ? null : snapshot.bufferId, + capturedSnapshot: ignoreBinding ? null : snapshot, + ); + } + } + // 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, + captureHistoryGeneration, + captureBufferGeneration, + )) { + _captureFailures[snapshot.bufferId] = _LocalHistoryCaptureFailure( + detail: error.toString(), + retryable: _captureErrorIsRetryable(error), + stage: _LocalHistoryFailureStage.capture, + ); + _showCaptureFailure(); + } + return false; + } + } + + 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, + destinationPath: promotion.destinationPath, + 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 (_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; + } + } + + 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; + } + + 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(); + if (clearTransitions) _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[bufferId]; + if (latest != null) { + final acceptedHistoryGeneration = _historyGeneration; + final acceptedBufferGeneration = _bufferGeneration(bufferId); + unawaited( + _enqueue( + bufferId, + () => _capturePending( + bufferId, + latest, + acceptedHistoryGeneration: acceptedHistoryGeneration, + acceptedBufferGeneration: acceptedBufferGeneration, + ), + ), + ); + } + }), + ); + } + + Future _capturePending( + String bufferId, + 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(); + _captureFailures.remove(bufferId); + return true; + } + 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); + } + 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( + warning: LocalHistoryWarning(kind, detail: detail), + ); + } + } +} + +class _LocalHistoryCaptureFailure { + const _LocalHistoryCaptureFailure({ + this.detail, + required this.retryable, + required this.stage, + }); + + 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); +} + +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/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart new file mode 100644 index 00000000..d69e206f --- /dev/null +++ b/lib/src/local_history/local_history_models.dart @@ -0,0 +1,289 @@ +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, + this.allowPathChange = 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; + + /// 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 +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..0013c9da --- /dev/null +++ b/lib/src/local_history/local_history_panel.dart @@ -0,0 +1,545 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:path/path.dart' as p; + +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({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(); + } + } + + @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; + 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 = + state.findingDocuments && state.searchQuery.isNotEmpty + ? documents + .where( + (document) => state.documentSearchMatches.contains(document.id), + ) + .toList(growable: false) + : documents; + final selected = state.selectedDocument; + 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: [ + 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, + vertical: BusyMarkSpacing.xs, + ), + 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) + 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( + 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; + 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), + ); + }, + ) + : state.loading && + state.selectedDocumentId == null && + !state.inspectingRetainedDocument + ? const Center(child: CircularProgressIndicator()) + : activeBuffer == null && !state.inspectingRetainedDocument + ? _HistoryEmpty(text: context.l10n.localHistoryNoDocuments) + : visibleRevisions.isEmpty + ? _HistoryEmpty( + text: state.searchQuery.isEmpty + ? context.l10n.localHistoryNoRevisions + : context.l10n.localHistoryNoMatches, + ) + : ListView.builder( + key: ValueKey( + 'local-history-revisions-${selected?.id ?? 'none'}', + ), + padding: BusyMarkInsets.sidebarList, + 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 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: [ + if (startsDate) + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMarkSpacing.xs, + BusyMarkSpacing.lg, + BusyMarkSpacing.xs, + BusyMarkSpacing.xs, + ), + child: Text( + MaterialLocalizations.of( + context, + ).formatFullDate(revision.capturedAt.toLocal()), + style: busyMarkSectionHeaderStyle(context), + ), + ), + BusyMarkSidebarRecordRow( + icon: BusyMarkGlyphs.history, + title: timestamp, + subtitle: event, + selected: revision.id == state.selectedRevisionId, + semanticsLabel: context.l10n.localHistoryRevisionAt( + reason, + fullTimestamp, + ), + tooltip: context.l10n.localHistoryRevisionAt( + reason, + fullTimestamp, + ), + onTap: () => + unawaited(controller.selectRevision(revision.id)), + ), + ], + ); + }, + ), + ), + ], + ); + } + + Future _confirmClear(_HistoryAction action, String? documentId) async { + final confirmed = await showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text( + action == _HistoryAction.clearDocument + ? dialogContext.l10n.localHistoryClearDocumentTitle + : dialogContext.l10n.localHistoryClearAllTitle, + ), + content: Text( + action == _HistoryAction.clearAll + ? '${dialogContext.l10n.localHistoryClearAll}.\n\n' + '${dialogContext.l10n.localHistoryClearConfirmation}' + : 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 == _HistoryAction.clearDocument && documentId != null) { + await controller.clearDocument(documentId); + } else { + await controller.clearAll(); + } + } +} + +typedef _LocalHistoryEditorScope = ({ + String? workspaceId, + String? bufferId, + String? path, + String? displayName, +}); + +enum _HistoryAction { refresh, find, clearDocument, clearAll } + +class _LocalHistoryHeader extends StatelessWidget { + const _LocalHistoryHeader({ + required this.selectedDocument, + required this.findingDocuments, + required this.inspectingRetainedDocument, + required this.onBack, + }); + + final LocalHistoryDocument? selectedDocument; + final bool findingDocuments; + final bool inspectingRetainedDocument; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + final selectedPath = + selectedDocument?.currentPath ?? + selectedDocument?.historicalPaths.lastOrNull; + final title = findingDocuments + ? context.l10n.findLocalHistoryEllipsis + : 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, + BusyMarkSpacing.sm, + BusyMarkSpacing.xs, + ), + child: Row( + children: [ + 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 + ? 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, + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +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, + }; +} + +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(); + 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..7c1bc97d --- /dev/null +++ b/lib/src/local_history/local_history_store.dart @@ -0,0 +1,1053 @@ +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); + + /// 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}); + + 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, + ); + 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) + .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: identity.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 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 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, + 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) { + 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, { + required String? path, + required bool preserveIdentityMetadata, +}) { + return document.copyWith( + displayName: preserveIdentityMetadata + ? document.displayName + : request.displayName, + currentPath: path, + historicalPaths: path == null + ? document.historicalPaths + : _uniquePaths([...document.historicalPaths, path]), + updatedAt: request.capturedAt.toUtc(), + deleted: false, + 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; + 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, + ); + 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 + .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: identity.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 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 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, + 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()) { + 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/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 83006d9d..8397b6b8 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, @@ -287,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) { @@ -330,7 +343,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,15 +461,55 @@ class BusyMarkMarkdownSerializer { List inlines, { bool tableCell = false, bool atBlockStart = false, + bool readableHardBreakRuns = false, }) { final buffer = StringBuffer(); 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) { + 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; + } + } final source = _inline( inline, tableCell: tableCell, atBlockStart: nextAtBlockStart, + readableHardBreakRuns: readableHardBreakRuns, followedByLink: index + 1 < inlines.length && inlines[index + 1].kind == BusyInlineKind.link, @@ -473,11 +526,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/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 0ad1759a..db0333fd 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -32,9 +32,6 @@ class HeaderBarLabels { required this.back, required this.backShortcut, required this.save, - required this.export, - required this.exportShortcut, - required this.exportGtkAccelerator, required this.fullScreen, required this.fullScreenShortcut, required this.fullScreenGtkAccelerator, @@ -73,9 +70,6 @@ class HeaderBarLabels { final String back; final String backShortcut; final String save; - final String export; - final String exportShortcut; - final String exportGtkAccelerator; final String fullScreen; final String fullScreenShortcut; final String fullScreenGtkAccelerator; @@ -114,9 +108,6 @@ class HeaderBarLabels { 'back': back, 'backShortcut': backShortcut, 'save': save, - 'export': export, - 'exportShortcut': exportShortcut, - 'exportGtkAccelerator': exportGtkAccelerator, 'fullScreen': fullScreen, 'fullScreenShortcut': fullScreenShortcut, 'fullScreenGtkAccelerator': fullScreenGtkAccelerator, @@ -294,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, @@ -316,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; @@ -339,8 +326,6 @@ class HeaderBarConfiguration { String? searchQuery, TextDirection? textDirection, bool? canRefresh, - bool? canExportPdf, - bool? canExportHtml, bool? documentControlsVisible, bool? searchActive, bool? searchVisible, @@ -360,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, @@ -384,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, @@ -406,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 && @@ -437,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 0e22370d..fef1e7e5 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -16,7 +16,6 @@ enum HeaderBarAction { search, refresh, save, - export, fullScreen, menu, settings, @@ -32,6 +31,8 @@ enum HeaderBarAction { sidebarToc, sidebarOutline, sidebarGit, + sidebarLocalHistory, + sidebarClipboardHistory, } class HeaderBarActionEvent { @@ -155,10 +156,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); } @@ -290,8 +287,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), @@ -391,7 +386,6 @@ class LinuxHeaderBarService extends ChangeNotifier { 'search' => HeaderBarAction.search, 'refresh' => HeaderBarAction.refresh, 'save' => HeaderBarAction.save, - 'export' => HeaderBarAction.export, 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, 'settings' => HeaderBarAction.settings, @@ -407,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/platform/rich_clipboard_service.dart b/lib/src/platform/rich_clipboard_service.dart index e3bdfb0a..5e531efe 100644 --- a/lib/src/platform/rich_clipboard_service.dart +++ b/lib/src/platform/rich_clipboard_service.dart @@ -1,23 +1,58 @@ import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; 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; +const maxRichClipboardOwnershipBytes = 64 * 1024 * 1024; +const maxRichClipboardOwnershipEntries = 128; 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,11 +60,35 @@ class RichClipboardData { class RichClipboardService { RichClipboardService({ MethodChannel channel = const MethodChannel(richClipboardChannelName), - }) : _channel = channel; + String Function()? createToken, + int maximumOwnershipBytes = maxRichClipboardOwnershipBytes, + }) : _channel = channel, + _createToken = createToken ?? const Uuid().v4, + _maximumOwnershipBytes = maximumOwnershipBytes, + assert(maximumOwnershipBytes > 0); final MethodChannel _channel; + final String Function() _createToken; + 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((_) {}); @@ -43,13 +102,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 +138,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]?.data; + 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 +172,35 @@ class RichClipboardService { } } + void _remember(String token, RichClipboardData data) { + _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 { @@ -99,4 +217,69 @@ 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) + entry.key: Uint8List.fromList(entry.value), + }); + final busyMarkRichClipboardService = RichClipboardService(); 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/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 04fe49e9..e439e624 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,7 +374,6 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: - case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -329,6 +384,8 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.sidebarToc: case HeaderBarAction.sidebarOutline: case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarLocalHistory: + case HeaderBarAction.sidebarClipboardHistory: break; } } @@ -339,9 +396,6 @@ class _SettingsScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - case BusyMarkMainMenuAction.generateMarkdownToc: - break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -363,10 +417,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 +514,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 +531,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 +544,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..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}); @@ -255,7 +254,6 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: - case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -266,6 +264,8 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.sidebarToc: case HeaderBarAction.sidebarOutline: case HeaderBarAction.sidebarGit: + case HeaderBarAction.sidebarLocalHistory: + case HeaderBarAction.sidebarClipboardHistory: break; } } @@ -285,9 +285,6 @@ class _WelcomeScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - case BusyMarkMainMenuAction.generateMarkdownToc: - break; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -516,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)), ), ], @@ -560,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 df59d13e..a26bb696 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,8 +73,12 @@ 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 '../../search/workspace_search_scope.dart'; import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_model.dart'; @@ -158,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; @@ -169,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; @@ -197,6 +205,7 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { _disposed = true; _request += 1; _debounce?.cancel(); + _worker.dispose(); }); return const _WorkspaceSearchState(); } @@ -208,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, @@ -236,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; @@ -255,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; @@ -268,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( @@ -287,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 @@ -385,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; @@ -394,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, @@ -410,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, @@ -419,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, ); } @@ -451,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)) { @@ -539,6 +605,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); @@ -548,6 +615,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( @@ -556,15 +628,25 @@ 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( 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, @@ -623,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); @@ -644,6 +731,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) { @@ -660,25 +773,18 @@ 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, sidebarVisible: sidebarVisible, sidebarToggleVisible: hasSidebar, - backVisible: true, + backVisible: !searchState.active, ); final commandRegistry = BusyMarkCommandRegistryScope.maybeOf(context) ?? @@ -711,6 +817,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: { @@ -740,21 +857,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, @@ -773,15 +897,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 @@ -811,43 +937,41 @@ 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( - canExportPdf: canExportPdf, - canExportHtml: canExportHtml, - canGenerateMarkdownToc: canGenerateMarkdownToc, onSelected: (action) => _handleMainMenuAction(context, ref, action), ), @@ -866,6 +990,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,8 +1109,6 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(_validateActiveAndShowProblems(context, ref)); case HeaderBarAction.save: execute(BusyMarkCommandIds.save); - case HeaderBarAction.export: - execute(BusyMarkCommandIds.export); case HeaderBarAction.fullScreen: execute(BusyMarkCommandIds.fullScreen); case HeaderBarAction.settings: @@ -1011,8 +1141,12 @@ 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: - execute(BusyMarkCommandIds.search); + _toggleSearch(ref); case HeaderBarAction.menu: break; } @@ -1024,10 +1158,6 @@ class WorkspaceScreen extends ConsumerWidget { BusyMarkMainMenuAction action, ) { switch (action) { - case BusyMarkMainMenuAction.export: - unawaited(exportWorkspace(context, ref)); - case BusyMarkMainMenuAction.generateMarkdownToc: - _generateOrUpdateMarkdownToc(context, ref); case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -1166,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) @@ -1351,7 +1485,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( @@ -1605,6 +1744,15 @@ Future _performWorkspacePathAction( enum _PathMenuAction { copyName, copyPath, openInFiles, refineWithAi } +enum _OutlineDocumentAction { + copyName, + copyPath, + openInFiles, + refineWithAi, + generateMarkdownToc, + export, +} + List> _sidebarPathMenuItems( BuildContext context, { String? copyNameLabel, @@ -1640,6 +1788,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, { @@ -1925,6 +2120,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; @@ -1932,6 +2131,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(); @@ -1942,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() { @@ -1977,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 @@ -2009,6 +2230,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 @@ -2016,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, ) @@ -2036,6 +2264,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), @@ -2057,6 +2287,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 +2318,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 { @@ -2327,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) { @@ -2464,7 +2730,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { } } -enum _SidebarTab { files, toc, outline, git } +enum _SidebarTab { files, toc, outline, git, localHistory, clipboard } int _preferredSidebarTabIndex(Workspace workspace) { final tabs = _sidebarTabsFor(workspace.kind); @@ -2487,18 +2753,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.localHistory, + _SidebarTab.clipboard, + ], + WorkspaceKind.singleMarkdown => const [ + _SidebarTab.outline, + _SidebarTab.localHistory, + _SidebarTab.clipboard, + ], WorkspaceKind.markdownFolder => const [ _SidebarTab.files, _SidebarTab.outline, _SidebarTab.git, + _SidebarTab.localHistory, + _SidebarTab.clipboard, ], WorkspaceKind.writersideModule => const [ _SidebarTab.files, _SidebarTab.toc, _SidebarTab.outline, _SidebarTab.git, + _SidebarTab.localHistory, + _SidebarTab.clipboard, ], }; } @@ -2509,6 +2787,8 @@ String _sidebarTabLabel(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => context.l10n.toc, _SidebarTab.outline => context.l10n.outline, _SidebarTab.git => context.l10n.git, + _SidebarTab.localHistory => context.l10n.localHistory, + _SidebarTab.clipboard => context.l10n.clipboardHistory, }; } @@ -2518,6 +2798,8 @@ IconData _sidebarTabIcon(_SidebarTab tab, TextDirection direction) { _SidebarTab.toc => BusyMarkGlyphs.orderedList, _SidebarTab.outline => BusyMarkGlyphs.indentFor(direction), _SidebarTab.git => BusyMarkGlyphs.branch, + _SidebarTab.localHistory => BusyMarkGlyphs.documentHistory, + _SidebarTab.clipboard => BusyMarkGlyphs.copy, }; } @@ -2530,6 +2812,8 @@ String? _sidebarTabShortcut(BuildContext context, _SidebarTab tab) { _SidebarTab.toc => BusyMarkCommandIds.sidebarToc, _SidebarTab.outline => BusyMarkCommandIds.sidebarOutline, _SidebarTab.git => BusyMarkCommandIds.sidebarGit, + _SidebarTab.localHistory => BusyMarkCommandIds.localHistory, + _SidebarTab.clipboard => BusyMarkCommandIds.clipboardHistory, }; return commands[id]?.shortcut?.label; } @@ -2598,6 +2882,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; @@ -2614,6 +2902,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) { @@ -2660,6 +2952,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!, @@ -2763,28 +3056,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(); + } + }, ), ], ), @@ -3925,6 +4242,27 @@ class _FilesTabState extends ConsumerState<_FilesTab> { if (file != null && canUseGitFileActions) { await widget.onShowFileHistory(file); } + case _FileTreeAction.localHistory: + final opened = await ref + .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); } } @@ -4077,6 +4415,7 @@ enum _FileTreeAction { copyPath, openInFiles, fileHistory, + localHistory, } Future<_FileTreeAction?> _showFileTreeMenu( @@ -4159,6 +4498,12 @@ Future<_FileTreeAction?> _showFileTreeMenu( icon: BusyMarkGlyphs.documentHistory, enabled: enableGitActions, ), + if (showHistory) + BusyMarkPopupMenuItem( + value: _FileTreeAction.localHistory, + label: context.l10n.localHistoryEllipsis, + icon: BusyMarkGlyphs.history, + ), ], ); } @@ -4618,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 { @@ -4898,11 +5232,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, @@ -5217,6 +5555,8 @@ class _TocTabState extends ConsumerState<_TocTab> { ), onOpenTocFile: () => _openInstanceTree(context, instance.sourceTreePath), + canExport: widget.canExport, + onExport: widget.onExport, ); } final entry = entries[index - 1]; @@ -6395,6 +6735,7 @@ enum _TocHeaderAction { newLibrary, editInstance, openTocFile, + export, } class _TocHeader extends StatelessWidget { @@ -6411,6 +6752,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; @@ -6425,6 +6768,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) { @@ -6524,6 +6869,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) { @@ -6537,6 +6889,8 @@ class _TocHeader extends StatelessWidget { onEditInstance(); case _TocHeaderAction.openTocFile: onOpenTocFile(); + case _TocHeaderAction.export: + onExport(); } }, ), @@ -8221,21 +8575,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 +8613,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 +8649,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 +8663,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 +8685,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 +8694,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,19 +8716,41 @@ 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) { + 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(); if (entry.path.isEmpty) { return; } await gitController.activateDiffFile(entry.path); + case WorkspaceTabKind.localHistory: + return; } } @@ -8380,10 +8782,69 @@ 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 requestedBuffer = state.documentBuffers + .where((candidate) => candidate.id == entry.bufferId) + .firstOrNull; + if (requestedBuffer == null) return; + final activated = await ref + .read(workspaceControllerProvider.notifier) + .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); + case _WorkspaceTabAction.close: + await _closeTab(context, ref, workspace, entry); } } } +enum _WorkspaceTabAction { localHistory, close } + class _WorkspaceTabButton extends StatelessWidget { const _WorkspaceTabButton({ required this.title, @@ -8393,6 +8854,7 @@ class _WorkspaceTabButton extends StatelessWidget { required this.dirty, required this.onSelected, required this.onClose, + required this.onSecondaryTapUp, }); final String title; @@ -8402,6 +8864,7 @@ class _WorkspaceTabButton extends StatelessWidget { final bool dirty; final VoidCallback onSelected; final VoidCallback onClose; + final GestureTapUpCallback onSecondaryTapUp; @override Widget build(BuildContext context) { @@ -8422,6 +8885,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), @@ -9702,6 +10166,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _wysiwygSearchQuery; BusyMarkWysiwygSourceRange? _wysiwygSearchRange; var _wysiwygScrollRequest = 0; + var _lastSearchNavigationRequest = 0; @override void initState() { @@ -9814,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); @@ -9909,6 +10371,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 +10492,37 @@ 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, + 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 [], @@ -10559,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 = @@ -10678,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; } @@ -11966,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; @@ -12097,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; } @@ -12126,7 +12631,7 @@ int? _previewSearchBlockIndexForOrdinal( String query, int ordinal, ) { - final normalizedQuery = query.trim().toLowerCase(); + final normalizedQuery = query.toLowerCase(); if (normalizedQuery.isEmpty) { return null; } @@ -12887,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( @@ -12911,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( @@ -13284,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; @@ -13291,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, @@ -13309,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, @@ -13335,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( @@ -13375,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}); @@ -13482,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 => @@ -13706,138 +14302,138 @@ 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. - } - if (isCancelled()) { - return const []; + skippedFiles.add(file.relativePath); } - 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); + final sourceLine = document.lineIndex.lineAt(line); + 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: _boundedSearchSnippet( + sourceLine.text, + matchStart: match.fullStart - sourceLine.startOffset, + ), + ), + ); + } } } - 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) { @@ -13845,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/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 b8d6534b..781bbd0f 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 = >{}; @@ -167,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; @@ -215,6 +239,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)), ); @@ -244,6 +269,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. @@ -377,6 +415,9 @@ class WorkspaceController extends Notifier { ) : null, ); + for (final buffer in buffers) { + unawaited(_localHistory.observeOpened(buffer)); + } _recordActivePreviewRevision(); _editRevision = active.revision; await _startMonitoring(reparsed); @@ -430,7 +471,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( @@ -513,9 +558,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) { @@ -549,16 +614,19 @@ class WorkspaceController extends Notifier { editorState: buffer.editorState, ), ], + pendingLocalHistoryAssociations: pendingHistoryAssociations, ), ); } Future markCleanShutdown() async { await _settleWritesForShutdown(); + 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; } @@ -567,8 +635,14 @@ class WorkspaceController extends Notifier { Future discardRecoveryForShutdown() async { await _settleWritesForShutdown(); + 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 { @@ -651,19 +725,44 @@ 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(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; } - if (current.isDirty) { + await _localHistory.capturePath( + path: path, + text: disk.text, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + ); + latest = _externalOperationBuffer(current, path); + if (latest == null) return; + if (latest.isDirty) { + _localHistory.observeEdit( + latest.copyWith(text: latest.lastSavedText), + latest, + ); _updateBufferFromMonitor( - current.copyWith( + latest.copyWith( diskState: DocumentDiskState.conflict, diskVersionText: disk.text, diskVersionSnapshot: disk.snapshot, @@ -671,13 +770,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, @@ -698,14 +797,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, @@ -714,33 +833,51 @@ 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; final contentChanged = !_sameFileSnapshot( current.diskSnapshot, disk.snapshot, ); - final remapped = current.copyWith( + await _localHistory.remapPath(oldPath, destinationPath); + if (_externalOperationBuffer(current, oldPath) == null) return; + if (contentChanged) { + await _localHistory.capturePath( + path: destinationPath, + text: disk.text, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + ); + } + 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, ); @@ -751,7 +888,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( @@ -762,22 +899,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 == current.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), ); @@ -786,11 +946,21 @@ 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. + } finally { + if (!historyTransitionFinished) { + await _localHistory.finishBufferPathTransition( + historyTransition, + committed: historyPathCommitted, + ); + } } } @@ -812,7 +982,24 @@ 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, + format: disk.format, + reason: LocalHistoryCaptureReason.externalChange, + force: false, + ); + if (!_bufferOperationTargetIsCurrent(buffer)) return false; final reloaded = buffer.copyWith( text: disk.text, lastSavedText: disk.text, @@ -826,20 +1013,49 @@ 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, ); - state = state.copyWith( - workspace: workspace, - preview: _safePreview(workspace, disk.text), - ); - _recordActivePreviewRevision(); + 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(); + } } 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) @@ -930,6 +1146,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, isLoading: false, ); + unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); await _startMonitoring(workspace); _schedulePersistence(); @@ -937,6 +1154,7 @@ class WorkspaceController extends Notifier { } Future openPath(String path) async { + await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -970,6 +1188,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 +1222,7 @@ class WorkspaceController extends Notifier { Future createWritersideProject( WritersideProjectCreateRequest request, ) async { + await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -1036,6 +1256,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 +1349,9 @@ class WorkspaceController extends Notifier { documentBuffers: buffers, activeBufferId: buffer?.id, ); + if (buffer != null && existing == null) { + unawaited(_localHistory.observeOpened(buffer)); + } _recordActivePreviewRevision(); await _startMonitoring(tabbedWorkspace); _schedulePersistence(); @@ -1228,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, @@ -1252,7 +1480,23 @@ class WorkspaceController extends Notifier { if (state.hasUnsavedChanges) { return false; } + 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( + buffer.id, + historySettled: historySettled, + ); + } return true; } @@ -1273,8 +1517,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); - 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, + ); + } }); } @@ -1289,23 +1544,88 @@ class WorkspaceController extends Notifier { sourcePath, targetDirectoryPath, ); - _remapOpenWorkspacePaths(workspace, 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, + ); + } }); } 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 || + !_isEligibleLocalHistoryPath(filePath) || + historyPolicy.excludes(filePath) || + !(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)) || + !_isEligibleLocalHistoryPath(filePath) || + historyPolicy.excludes(filePath) || + !(p.equals(filePath, path) || p.isWithin(path, filePath))) { + continue; + } + try { + final load = await _service.loadTextWithSnapshot(filePath); + if (!await _localHistory.capturePathBeforeLoss( + path: filePath, + text: load.text, + format: load.format, + reason: LocalHistoryCaptureReason.beforeDelete, + )) { + return false; + } + } on Object { + return false; + } + } + return true; + } + Future moveWritersideTocEntry({ required String treePath, required List sourcePath, @@ -1386,8 +1706,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, + ); + } }); } @@ -1468,18 +1799,47 @@ 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; + } + if (closingBuffer?.isDirty == true && discardAuthorization == null) { return false; } - final closedIndex = workspace.openFilePaths.indexOf(path); + 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 (!identical(currentClosingBuffer, closingBuffer)) { + if (currentClosingBuffer != null) { + _scheduleAutoSave(currentClosingBuffer.id); + } + return false; + } + } + 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 = [ @@ -1489,44 +1849,85 @@ 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( 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( @@ -1539,16 +1940,38 @@ class WorkspaceController extends Notifier { if (buffer == null || (buffer.isDirty && !discard)) { return false; } - if (buffer.filePath case final path?) { - if (buffer.isDirty) { - state = state.copyWith( - documentBuffers: _replaceBuffer( - state.documentBuffers, - buffer.copyWith(dirty: false), - ), - ); - } - return closeOpenFileTab(path); + if (buffer.isDirty && + !await _localHistory.captureBeforeLoss( + LocalHistoryBufferSnapshot.fromBuffer(buffer), + LocalHistoryCaptureReason.beforeDiscard, + )) { + return false; + } + 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) { + return false; + } + final historySettled = 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) { + return false; + } + if (current.filePath case final path?) { + return _closeOpenFileTabNow( + path, + discardAuthorization: current.isDirty ? current : null, + preflushedHistorySettled: historySettled, + ); } final workspace = state.workspace; if (workspace == null) { @@ -1562,14 +1985,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 { @@ -1635,32 +2069,49 @@ 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) .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(); 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( @@ -1668,20 +2119,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; @@ -1712,32 +2180,125 @@ 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 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, 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; + 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 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 intentionallyAddedOpenPaths) + 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; } @@ -1786,7 +2347,9 @@ class WorkspaceController extends Notifier { state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); + _localHistory.observeEdit(buffer, next); _schedulePersistence(); + _scheduleAutoSave(next.id); return true; } @@ -1823,6 +2386,7 @@ class WorkspaceController extends Notifier { byPath.putIfAbsent(edit.filePath, () => []).add(edit); } final staged = {}; + final previousByBufferId = {}; for (final entry in byPath.entries) { final buffer = initialBuffers @@ -1854,7 +2418,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 || @@ -1874,6 +2440,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( @@ -1920,11 +2494,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; } @@ -1954,11 +2530,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; } @@ -2122,6 +2700,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 +3034,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 +3218,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; @@ -2668,7 +3267,33 @@ class WorkspaceController extends Notifier { required bool overwriteExisting, required LineEndingNormalization? mixedLineEndingNormalization, }) async { + LocalHistoryBufferPathTransition? historyTransition = _localHistory + .beginBufferPathTransition( + bufferId: target.bufferId, + sourcePath: target.path, + destinationPath: path, + kind: LocalHistoryBufferPathTransitionKind.saveAs, + ); + var historyPathCommitted = false; 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( @@ -2757,6 +3382,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 && @@ -2794,6 +3444,14 @@ class WorkspaceController extends Notifier { ); } return false; + } finally { + final unfinishedTransition = historyTransition; + if (unfinishedTransition != null) { + await _localHistory.finishBufferPathTransition( + unfinishedTransition, + committed: historyPathCommitted, + ); + } } } @@ -2832,6 +3490,401 @@ class WorkspaceController extends Notifier { ); } + Future restoreLocalHistoryRevision({ + required LocalHistoryDocument document, + required LocalHistoryRevision revision, + SourceComparison? comparison, + SourceComparisonChange? change, + }) async { + if (revision.summary.documentId != document.id) return false; + final path = document.currentPath ?? revision.summary.historicalPath; + var buffer = localHistoryBufferForDocument(document, revision); + 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 { + 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)) { + 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 { + 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)) { + 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; + WorkspaceFileSnapshot? createdSnapshot; + try { + if (!existed) { + createdSnapshot = await _service.saveNewFormattedText( + path, + '', + format: revision.format, + ); + } + var destination = state.bufferForPath(path); + if (destination == null) { + if (!await _openLocalHistoryRestoreDestination(path)) { + if (createdSnapshot != null) { + await _rollbackMissingHistoryDestination( + path, + null, + createdSnapshot, + ); + } + return false; + } + 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 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, + ); + 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, + ); + _schedulePersistence(); + if (state.activeBufferId == restored.id) { + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); + } + _scheduleAutoSave(restored.id); + return true; + } on Object { + 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))); + } + + 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, + 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; + } + if (!_bufferOperationTargetIsCurrent(buffer)) 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; + } + 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. + } 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 +3901,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; @@ -2931,7 +3991,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); @@ -2940,7 +4001,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), @@ -2951,11 +4014,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( @@ -2966,71 +4032,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); - 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 + ? 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( @@ -3039,7 +4204,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( @@ -3047,11 +4212,62 @@ class WorkspaceController extends Notifier { error: error, ), ); + _scheduleAutoSave(); } return false; } } + 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 { @@ -3126,6 +4342,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 { @@ -3383,6 +4631,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); @@ -3436,6 +4705,7 @@ class WorkspaceController extends Notifier { DocumentBuffer _fileBuffer( String path, WorkspaceFileLoad load, { + String? id, DocumentViewModePreference mode = DocumentViewModePreference.editor, }) { final format = @@ -3449,7 +4719,7 @@ DocumentBuffer _fileBuffer( ) : load.format; return DocumentBuffer.file( - id: 'file:$path', + id: id ?? 'file:$path', filePath: path, text: load.text, snapshot: load.snapshot, @@ -3468,6 +4738,129 @@ 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, + 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, @@ -3518,6 +4911,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/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/linux/runner/my_application.cc b/linux/runner/my_application.cc index 20b6d289..f3986dd6 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; @@ -1099,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; } @@ -1327,9 +1329,6 @@ 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"; } @@ -1381,11 +1380,6 @@ 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", @@ -1542,23 +1536,8 @@ 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, "keyboard-shortcuts", "keyboardShortcuts"); add_header_gaction(self, "syntax-reference", "syntaxReference"); @@ -1875,9 +1854,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) || @@ -1922,10 +1898,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_sidebar_width(self, configuration.sidebar_width); set_text_direction(self, configuration.text_direction); set_sidebar_visible(self, configuration.sidebar_visible); @@ -2165,16 +2137,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/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/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/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-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 00000000..bacfbdba Binary files /dev/null and b/test-results/history-runtime/markdown-clipboard-history-1280x800.png differ diff --git a/test-results/history-runtime/markdown-comparison-1920x1080.png b/test-results/history-runtime/markdown-comparison-1920x1080.png new file mode 100644 index 00000000..3c27fc2e Binary files /dev/null and b/test-results/history-runtime/markdown-comparison-1920x1080.png differ 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 00000000..4c3762eb Binary files /dev/null and b/test-results/history-runtime/markdown-deleted-comparison-1920x1080.png differ 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 00000000..a14f7b04 Binary files /dev/null and b/test-results/history-runtime/markdown-deleted-recovered-1920x1080.png differ 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 00000000..06e5926a Binary files /dev/null and b/test-results/history-runtime/markdown-fragment-restored-1920x1080.png differ 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 00000000..3731346f Binary files /dev/null and b/test-results/history-runtime/markdown-local-history-1280x800.png differ diff --git a/test-results/history-runtime/markdown-report.json b/test-results/history-runtime/markdown-report.json new file mode 100644 index 00000000..feba0bc2 --- /dev/null +++ b/test-results/history-runtime/markdown-report.json @@ -0,0 +1,23 @@ +{ + "passed": true, + "checks": { + "native clipboard publication": true, + "explicit save captured": true, + "exact comparison region available": true, + "fragment restore": true, + "recovery file open": true, + "document delete": true, + "deleted document recovery": true, + "deletedFileRecovered": true, + "clipboardEntries": true, + "revisionReasons": true + }, + "screenshots": [ + "markdown-clipboard-history-1280x800.png", + "markdown-local-history-1280x800.png", + "markdown-comparison-1920x1080.png", + "markdown-fragment-restored-1920x1080.png", + "markdown-deleted-comparison-1920x1080.png", + "markdown-deleted-recovered-1920x1080.png" + ] +} diff --git a/test-results/history-runtime/writerside-clipboard-history-1280x800.png b/test-results/history-runtime/writerside-clipboard-history-1280x800.png new file mode 100644 index 00000000..da669115 Binary files /dev/null and b/test-results/history-runtime/writerside-clipboard-history-1280x800.png differ diff --git a/test-results/history-runtime/writerside-comparison-1920x1080.png b/test-results/history-runtime/writerside-comparison-1920x1080.png new file mode 100644 index 00000000..ced6b5fd Binary files /dev/null and b/test-results/history-runtime/writerside-comparison-1920x1080.png differ 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 00000000..c81f68fb Binary files /dev/null and b/test-results/history-runtime/writerside-fragment-restored-1920x1080.png differ diff --git a/test-results/history-runtime/writerside-local-history-1280x800.png b/test-results/history-runtime/writerside-local-history-1280x800.png new file mode 100644 index 00000000..d29fd14a Binary files /dev/null and b/test-results/history-runtime/writerside-local-history-1280x800.png differ 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..81bef8b1 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -17,10 +17,12 @@ 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'; 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'; @@ -29,12 +31,18 @@ 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'; 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'; @@ -44,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'; @@ -97,6 +106,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', }, ); @@ -262,6 +272,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()) @@ -1067,6 +1086,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); @@ -1104,6 +1129,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; @@ -1416,6 +1461,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); @@ -2136,6 +2182,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); @@ -2423,8 +2470,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 && @@ -2998,6 +3044,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(); @@ -3118,7 +3166,6 @@ void main() { ], ); addTearDown(container.dispose); - await tester.pumpWidget( UncontrolledProviderScope( container: container, @@ -3362,6 +3409,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); @@ -3459,6 +3512,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(); @@ -3477,16 +3532,421 @@ 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( + 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); + expect(find.text(l10n.findLocalHistoryEllipsis), findsNothing); + expect(find.text(l10n.generateOrUpdateMarkdownToc), findsNothing); + 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 { @@ -4629,6 +5089,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)); @@ -5090,49 +5559,275 @@ void main() { ); }); - testWidgets('Editor and Preview share task-list presentation and wrapping', ( - tester, - ) async { - tester.view.physicalSize = const Size(1400, 800); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - - const paragraph = - 'P1 — Git push can target the wrong workspace. ' - 'lib/src/git/application/git_controller.dart captures repository A, ' - 'awaits its remotes, then reads the current repository again. ' - 'Switching repositories during that await can target the wrong remote.'; - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(documentViewMode: DocumentViewModePreference.editor) - .toJson(); - const service = _SearchWorkspaceService( - '- [x] $paragraph\n' - '- [ ] Pending task\n', - ); - final container = ProviderContainer( - overrides: [ - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(service), - startupPathProvider.overrideWithValue('/tmp/wrap-parity.md'), - ], - ); - addTearDown(container.dispose); + 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(); + }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - for (var i = 0; i < 30; i += 1) { - await tester.pump(const Duration(milliseconds: 100)); - if (find - .byWidgetPredicate( + 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( + 'standalone 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\n
\n
\nAfter\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 { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + const paragraph = + 'P1 — Git push can target the wrong workspace. ' + 'lib/src/git/application/git_controller.dart captures repository A, ' + 'awaits its remotes, then reads the current repository again. ' + 'Switching repositories during that await can target the wrong remote.'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.editor) + .toJson(); + const service = _SearchWorkspaceService( + '- [x] $paragraph\n' + '- [ ] Pending task\n', + ); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/wrap-parity.md'), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 30; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find + .byWidgetPredicate( (widget) => widget is TextField && widget.controller?.text == paragraph, ) @@ -6138,7 +6833,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); @@ -7852,7 +8547,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: [ @@ -7882,6 +8579,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); @@ -7889,6 +8592,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); @@ -7902,6 +8619,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 { @@ -7915,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), @@ -7945,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')), @@ -7953,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')), @@ -7997,7 +8812,396 @@ 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( + '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)); }, ); @@ -8064,7 +9268,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)**'), @@ -8222,7 +9429,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(); @@ -8286,7 +9500,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(); @@ -8954,6 +10171,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); @@ -9321,6 +10561,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/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 7e906a94..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 { @@ -2140,9 +2183,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 +2210,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', ( @@ -4499,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 [ @@ -5044,42 +5076,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 +5128,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', () { @@ -5427,6 +5451,213 @@ 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('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 { + 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( + '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\n
\n
\n 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, @@ -5556,8 +5787,63 @@ void main() {} hardBreakController.insertHardBreak(hardBreakBlockId, 5); expect(hardBreakController.markdown, 'Alpha \n Beta\n'); + + hardBreakController.insertHardBreak(hardBreakBlockId, 6); + expect(hardBreakController.markdown, 'Alpha\n
\n
\n 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), + ); + 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/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/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart new file mode 100644 index 00000000..3f0ed33c --- /dev/null +++ b/test/src/clipboard_history_panel_test.dart @@ -0,0 +1,554 @@ +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'; +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( + '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); + + 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 use a short preview and do not create a 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, 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', ( + 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 { + 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); + + 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').first; + expect(find.text('entry 9'), findsWidgets); + 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); + + 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, + ), + ); + 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( + '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); + + await _openEntryActions(tester); + expect( + find.ancestor( + of: find.text('Paste'), + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem && !widget.enabled, + ), + ), + findsOneWidget, + ); + await tester.tap(find.text('Paste as Plain Text')); + await tester.pump(); + expect(target.pasteCalls, 1); + expect(target.lastPlainText, isTrue); + }, + ); + + 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); + + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); + await tester.pump(const Duration(milliseconds: 200)); + expect(target.pasteCalls, 1); + expect( + find.text(AppLocalizationsEn().clipboardUnavailable), + 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, + ); + + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); + 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(); + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); + 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); + + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); + await tester.pumpAndSettle(); + expect( + container.read(clipboardHistoryControllerProvider).entries, + isEmpty, + ); + + final successful = _PanelInsertionTarget(); + container.read(clipboardInsertionRegistryProvider).register(successful); + await tester.pump(); + 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'); + expect( + container.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); + }, + ); +} + +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: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + richClipboardServiceProvider.overrideWithValue( + clipboard ?? _EmptyClipboard(), + ), + clipboardAssetInputServiceProvider.overrideWithValue(_EmptyAssetInput()), + ], + ); + addTearDown(container.dispose); + container.read(clipboardHistoryControllerProvider.notifier); + return container; +} + +Future _pumpPanel( + WidgetTester tester, + ProviderContainer container, { + Locale? locale, +}) 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( + locale: locale, + 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.normalPasteAvailable, + this.plainTextPasteAvailable, + this.result = ClipboardPasteResult.inserted, + }); + + final bool supportImages; + final bool? normalPasteAvailable; + final bool? plainTextPasteAvailable; + final ClipboardPasteResult result; + int pasteCalls = 0; + bool? lastPlainText; + final payloads = []; + + @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}) { + final configured = plainText + ? plainTextPasteAvailable + : normalPasteAvailable; + return configured ?? + (supportImages || payload.kind != BusyMarkClipboardContentKind.image); + } + + @override + Future paste( + BusyMarkClipboardPayload payload, { + required bool plainText, + }) async { + pasteCalls++; + lastPlainText = plainText; + payloads.add(payload); + 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 _PanelClipboard extends RichClipboardService { + _PanelClipboard(this.value) + : super( + channel: const MethodChannel('busymark.test/current-panel-clipboard'), + ); + + RichClipboardData value; + int readCalls = 0; + + @override + Future read() async { + readCalls++; + return value; + } +} + +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 new file mode 100644 index 00000000..e4a6c55f --- /dev/null +++ b/test/src/clipboard_history_test.dart @@ -0,0 +1,501 @@ +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( + '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); + 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); + }, + ); + + 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 { + 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/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/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/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/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index 3842c10a..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,6 +438,16 @@ void main() { containsPair('syntaxReferenceGtkAccelerator', 'F1'), ); expect(_labels.toMap(), isNot(contains('markdownAndHtml'))); + for (final removedLabel in { + 'export', + 'exportShortcut', + 'exportGtkAccelerator', + 'clipboardHistory', + 'localHistory', + 'findLocalHistory', + }) { + expect(_labels.toMap(), isNot(contains(removedLabel))); + } }); } @@ -501,9 +503,6 @@ const _labels = HeaderBarLabels( back: 'Back', backShortcut: 'Alt+Left', save: 'Save', - export: 'Export', - exportShortcut: 'Ctrl+Shift+E', - exportGtkAccelerator: 'e', fullScreen: 'Full Screen', fullScreenShortcut: 'F11', fullScreenGtkAccelerator: 'F11', diff --git a/test/src/html_export_test.dart b/test/src/html_export_test.dart index 68181283..1584a0cf 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\n
\n
\nAfter\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/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 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 new file mode 100644 index 00000000..f2d42265 --- /dev/null +++ b/test/src/local_history_controller_test.dart @@ -0,0 +1,2295 @@ +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'; +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( + '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( + '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( + '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 { + 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( + '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); + }, + ); + + 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 { + 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, + ); + }, + ); + + 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 { + 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); + }, + ); + + 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.runAsync(() => history.search('Unique retained')); + + 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(state.searchQuery, 'Unique retained'); + expect( + 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); + }, + ); + + 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); + }, + ); + + 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 { + 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.byKey(const ValueKey('local-history-context-header')), + findsNothing, + ); + expect(find.text('B.md'), findsNothing); + 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); + expect( + tester.widget(searchField).decoration?.hintText, + 'Search local history', + ); + 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.byKey(const ValueKey('local-history-context-header')), + findsNothing, + ); + expect(find.text('A.md'), findsNothing); + expect( + container.read(localHistoryControllerProvider).searchQuery, + isEmpty, + ); + expect(tester.widget(searchField).controller!.text, isEmpty); + + 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); + + 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); + 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, + greaterThan(tester.getTopRight(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), + findsNothing, + ); + expect(find.text(l10n.localHistoryReasonBaseline), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is Tooltip && + widget.message?.contains( + l10n.localHistoryReasonAutomaticCheckpoint, + ) == + true, + ), + findsOneWidget, + ); + final revisionSemantics = tester.getSemantics(find.text(afterLabel)); + expect( + revisionSemantics.label.contains(l10n.localHistoryReasonSaved), + isTrue, + ); + expect( + revisionSemantics.getSemanticsData().hasAction(ui.SemanticsAction.tap), + isTrue, + ); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + + 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(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(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, + ); + }, + ); + + 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( + '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( + '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( + '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 { + 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 { + 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), + localHistoryClockProvider.overrideWithValue(() => DateTime.utc(2026, 1, 2)), + 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; +} + +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); + + 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 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 _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; + + @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 _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); + + 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 = {}; + + @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..bfd3957e --- /dev/null +++ b/test/src/local_history_store_test.dart @@ -0,0 +1,575 @@ +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( + '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( + '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 { + 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('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([ + 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..0ff20440 --- /dev/null +++ b/test/src/local_history_workspace_test.dart @@ -0,0 +1,2113 @@ +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/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'; +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'); + }, + ); + + 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'); + 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( + '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( + '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')); + 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 { + 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( + '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 { + 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, + ); + }, + ); + + 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( + '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, + ); + }, + ); + + 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 { + 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']), + ); + }, + ); + + 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( + '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 { + 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 { + 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); + }, + ); + + 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); + }, + ); + + 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( + 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> _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, + 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(), + WorkspaceFileMonitor? fileMonitor, + LocalHistoryClock? clock, + LocalHistoryTimerFactory? timerFactory, + LocalHistoryComparisonComputer? comparisonComputer, + DocumentSessionStore? sessionStore, + DocumentRecoveryStore? recoveryStore, +}) async { + final container = ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryClockProvider.overrideWithValue( + clock ?? () => 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) + localHistoryTimerFactoryProvider.overrideWithValue(timerFactory), + if (comparisonComputer != null) + localHistoryComparisonComputerProvider.overrideWithValue( + comparisonComputer, + ), + ], + ); + 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 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 _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 _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); + + 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 _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(); + 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; + + SourceComparisonInput get replacementCurrent => _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 _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); + + 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(); + + 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/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index ff8c3f6a..d91e237e 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'; @@ -10,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(); @@ -48,6 +51,228 @@ 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('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\n
\n
\nAfter\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\n
\n
\nAfter\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 { + 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 +458,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/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 40f3ac74..8acf282c 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"')); @@ -1156,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, @@ -1738,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/preview_builder_test.dart b/test/src/preview_builder_test.dart index 07dedfc4..b4e9cbb5 100644 --- a/test/src/preview_builder_test.dart +++ b/test/src/preview_builder_test.dart @@ -17,6 +17,71 @@ 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 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/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_audit_test.dart b/test/src/source_audit_test.dart index 81c4b64f..35ad28e2 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(')); @@ -778,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'), @@ -805,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', () { @@ -907,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'), @@ -1099,7 +1115,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')); @@ -1465,22 +1487,76 @@ 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: 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)')), + ); + 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', @@ -1701,6 +1777,7 @@ void main() { 'BusyMarkEditorShortcutLabels.blockquote', 'BusyMarkEditorShortcutLabels.codeBlock', 'BusyMarkEditorShortcutLabels.image', + 'BusyMarkEditorShortcutLabels.thematicBreak', 'BusyMarkEditorShortcutLabels.hardLineBreak', ]) { expect(toolbar, contains(shortcut)); @@ -1711,7 +1788,6 @@ void main() { 'BusyMarkEditorShortcutLabels.inlineImage', 'BusyMarkEditorShortcutLabels.table', 'BusyMarkEditorShortcutLabels.htmlBlock', - 'BusyMarkEditorShortcutLabels.thematicBreak', ]) { expect(toolbar, isNot(contains(shortcut))); } 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_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..3f7eb7b0 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'dart:ui' show BoxHeightStyle; import 'package:busymark/l10n/generated/app_localizations.dart'; @@ -6,6 +9,9 @@ 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'; import 'package:busymark/src/core/source_span.dart'; import 'package:busymark/src/editor/document_text_geometry.dart'; @@ -18,7 +24,13 @@ 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'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -28,6 +40,254 @@ 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, + ) 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 { @@ -139,6 +399,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 +1710,321 @@ 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'); + }); + + 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, + ); + }); + + 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) { + 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'; @@ -1520,6 +2098,97 @@ Future _pressControlSpace(WidgetTester tester) async { await tester.pump(); } +Future _pumpClipboardSourceEditor( + WidgetTester tester, { + required String source, + required _SourceTestClipboard clipboard, + BusyMarkClipboardInsertionRegistry? registry, + ValueChanged? onCaptured, + BusyMarkSourceChanged? onChanged, + BusyMarkSourceTransactionalChanged? onTransactionalChanged, + TextEditingValue? Function()? onUndo, + String? filePath = '/project/source.md', + String? workspaceRoot, + AssetWorkspaceKind? assetWorkspaceKind, + VoidCallback? onAssetSaveRequired, +}) 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: filePath, + documentId: 'source-document', + workspaceRoot: workspaceRoot, + assetWorkspaceKind: assetWorkspaceKind, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: onChanged ?? (_, _) {}, + onTransactionalChanged: onTransactionalChanged, + onUndo: onUndo, + onOpenSearch: () {}, + onCloseSearch: () {}, + clipboardService: clipboard, + clipboardInsertionRegistry: registry, + onClipboardCaptured: onCaptured, + onAssetSaveRequired: onAssetSaveRequired, + ), + ), + ), + ), + ); + 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/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'), diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 12e7ae0c..d4fed33b 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('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/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 8c1dee21..51cf4222 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'; @@ -15,6 +18,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; @@ -1076,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; @@ -1183,6 +1275,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); @@ -1320,6 +1499,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); @@ -1447,6 +1659,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 { @@ -1648,6 +1915,526 @@ 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( + '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( + '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( + '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'); + }, + ); + + 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 { + 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 { + 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-', @@ -2070,6 +2857,7 @@ Future<_WorkspaceControllerHarness> _createControllerHarness({ WorkspaceFileMonitor? fileMonitor, DocumentSessionStore? sessionStore, DocumentRecoveryStore? recoveryStore, + LocalHistoryStore? localHistoryStore, }) async { final container = ProviderContainer( overrides: [ @@ -2081,6 +2869,8 @@ Future<_WorkspaceControllerHarness> _createControllerHarness({ documentSessionStoreProvider.overrideWithValue(sessionStore), if (recoveryStore != null) documentRecoveryStoreProvider.overrideWithValue(recoveryStore), + if (localHistoryStore != null) + localHistoryStoreProvider.overrideWithValue(localHistoryStore), ], ); addTearDown(container.dispose); @@ -2175,6 +2965,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 +2981,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); } @@ -2281,6 +3084,63 @@ 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 _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); + + 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; @@ -2466,6 +3326,34 @@ 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; + + @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'); @@ -2607,3 +3495,49 @@ class _GatedRefreshWorkspaceService extends WorkspaceService { return super.openPath(path); } } + +class _BlockingRefreshWorkspaceService extends WorkspaceService { + var _pauseReparse = false; + String? _pausedLoadPath; + var completedReparseCount = 0; + 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; + } + completedReparseCount++; + return reparsed; + } +} diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 42b1b3cb..6aa9fd48 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\n
\n
\nAfter\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 { 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..99dafc2a 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -2,7 +2,12 @@ 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'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_clipboard_html.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; @@ -15,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; @@ -142,6 +148,28 @@ 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)'), + ); + 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', () { @@ -351,8 +379,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, @@ -361,6 +391,8 @@ void main() { body: BusyMarkWysiwygEditor( key: ValueKey(id), clipboardService: RichClipboardService(), + clipboardInsertionRegistry: registry, + onClipboardCaptured: onCaptured, document: _parser .parse( filePath: '/$id.md', @@ -391,7 +423,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 +500,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 +513,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 +547,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 +596,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')); @@ -609,6 +646,187 @@ void main() { expect(result, contains('**Bold**')); }); + testWidgets('retained external HTML preserves structure on history paste', ( + tester, + ) async { + systemData = { + 'html': + '

External

Bold and ' + '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); + var first = ''; + await mount( + tester, + 'first', + 'Target\n', + (value) => first = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + expect(await registry.paste(current), ClipboardPasteResult.inserted); + await tester.pump(); + expect(first, contains('**Bold**')); + 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, + 'retained', + 'Target\n', + (value) => retained = value, + registry: registry, + ); + await key(tester, LogicalKeyboardKey.keyA); + 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'; @@ -676,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/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)), + ); +} 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..60f660e9 --- /dev/null +++ b/tools/history_visual_smoke.dart @@ -0,0 +1,717 @@ +// 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/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'; +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); + final realPolicyTimer = + Platform.environment['BUSYMARK_HISTORY_REAL_POLICY'] == '1'; + + 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.editor, + 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(), + ), + richClipboardServiceProvider.overrideWithValue( + _RuntimeExternalClipboardService(), + ), + localHistoryStoreProvider.overrideWithValue( + FileLocalHistoryStore( + rootDirectory: () async => Directory(p.join(output.path, 'store')), + ), + ), + if (!realPolicyTimer) + localHistoryTimerFactoryProvider.overrideWithValue( + (_, callback) => Timer(const Duration(seconds: 2), callback), + ), + ], + child: _HistoryVisualHarness( + mode: mode, + primaryPath: primaryPath, + deletePath: deletePath, + output: output, + boundaryKey: boundaryKey, + realPolicyTimer: realPolicyTimer, + ), + ), + ); + 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, + required this.realPolicyTimer, + }); + + final String mode; + final String primaryPath; + final String? deletePath; + final Directory output; + final GlobalKey boundaryKey; + final bool realPolicyTimer; + + @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 _waitFor( + () => _hasWidgetNamed('_Sidebar'), + 'workspace sidebar listeners', + ); + await Future.delayed(const Duration(milliseconds: 500)); + + await _seedClipboard(); + _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(); + await _waitForEditorSourceToSettle(); + + _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 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, null, '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); + 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 && + 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( + (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 _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['defaultCheckpointPolicy'] = + ref + .read(localHistoryControllerProvider.notifier) + .policy + .checkpointInterval == + const Duration(seconds: 60); + _checks['revisionReasons'] = ref + .read(localHistoryControllerProvider) + .snapshot + .revisions + .map((revision) => revision.reason) + .toSet() + .containsAll({ + LocalHistoryCaptureReason.baseline, + 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': passed, + 'timerMode': widget.realPolicyTimer + ? 'production-default' + : 'visual-smoke-2s', + 'checks': _checks, + 'screenshots': _screenshots, + }), + ); + if (!passed) exitCode = 1; + 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 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(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, + 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 _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, + String deletePath, + ) async { + _check(await workspace.openActiveFile(deletePath), 'recovery file open'); + _check( + await workspace.deleteWorkspaceEntity(deletePath), + 'document delete', + ); + 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 _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 _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) => reason == null || 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.'); + } + + 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) { + 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; + 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; + } +} + +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(); + } +} 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)