From ff709e4ff79f6d1dbf9c422d186490a8859bcda1 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 15 Oct 2026 01:34:29 -0700 Subject: [PATCH 1/4] Bump version to 0.6.3 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 6 +++--- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index eb14e97a..c0593c74 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.6.2'; +const busyMarkAppVersion = '0.6.3'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 9166eb5c..de562c23 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -88,6 +88,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 6af14e7c..677032fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.6.2 +version: 0.6.3 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 5c3d1462..12d80dbd 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.6.2" +version: "0.6.3" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 761c0cdc..00cdf671 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.6\.2$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.6\.3$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.6\.2"$', multiLine: true)), + contains(RegExp(r'^version: "0\.6\.3"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,7 +57,7 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Sat, 17 Oct 2026 14:52:37 -0700 Subject: [PATCH 2/4] Feature/scoped sidebar search (#54) * Extend Recent history with qualified document identities * Unify scoped search in the header and sidebar * Document and exercise unified sidebar search workflows * Fix full-document Find and Replace navigation * Correct scoped sidebar search, Recent ordering and History integration * Qualify sidebar search workflows and document History navigation * Fix closed-tab search navigation and History restore initialization Resolve retained workspace text results against live file buffers or reopen their durable paths, then validate exact source digests and offsets. Suppress feedback from cancelled activation requests. Allow Editor session initialization and equivalent format metadata during protected History recovery. Preserve source, Undo, disk and format validation; latch target and view changes until completion and dispose the listener. Cover closed and reopened tabs, changed and deleted files, asynchronous cancellation, and Editor/Reading recovery with exact contents, Undo/Redo, concurrent-change rejection and production native workflows. * Correct unified search matching and sidebar presentation * Add effective keyboard shortcuts and grouped sidebar Actions * Fix effective shortcut dispatch and search presentation * Clean up history panels, shortcut browsing, and search defaults * Use borderless themed triggers for shared popup selectors * Use native list rows for sidebar record identities * Remove obsolete Notes search option guidance --- README.md | 15 +- docs/development/history.md | 20 +- docs/development/nextcloud-notes.md | 62 +- docs/history.md | 37 +- docs/references.md | 2 +- docs/search-and-replace.md | 228 +- lib/l10n/app_ar.arb | 86 +- lib/l10n/app_de.arb | 86 +- lib/l10n/app_en.arb | 109 +- lib/l10n/app_es.arb | 86 +- lib/l10n/app_et.arb | 86 +- lib/l10n/app_fa.arb | 86 +- lib/l10n/app_fr.arb | 86 +- lib/l10n/app_hi.arb | 86 +- lib/l10n/app_id.arb | 95 +- lib/l10n/app_it.arb | 86 +- lib/l10n/app_ja.arb | 95 +- lib/l10n/app_ko.arb | 95 +- lib/l10n/app_nb.arb | 86 +- lib/l10n/app_nl.arb | 95 +- lib/l10n/app_pl.arb | 86 +- lib/l10n/app_pt.arb | 86 +- lib/l10n/app_pt_BR.arb | 86 +- lib/l10n/app_ru.arb | 86 +- lib/l10n/app_tr.arb | 95 +- lib/l10n/app_uk.arb | 86 +- lib/l10n/app_vi.arb | 95 +- lib/l10n/app_zh.arb | 95 +- lib/l10n/app_zh_CN.arb | 95 +- lib/l10n/generated/app_localizations.dart | 414 +- lib/l10n/generated/app_localizations_ar.dart | 216 +- lib/l10n/generated/app_localizations_de.dart | 221 +- lib/l10n/generated/app_localizations_en.dart | 216 +- lib/l10n/generated/app_localizations_es.dart | 221 +- lib/l10n/generated/app_localizations_et.dart | 219 +- lib/l10n/generated/app_localizations_fa.dart | 217 +- lib/l10n/generated/app_localizations_fr.dart | 221 +- lib/l10n/generated/app_localizations_hi.dart | 219 +- lib/l10n/generated/app_localizations_id.dart | 219 +- lib/l10n/generated/app_localizations_it.dart | 221 +- lib/l10n/generated/app_localizations_ja.dart | 212 +- lib/l10n/generated/app_localizations_ko.dart | 212 +- lib/l10n/generated/app_localizations_nb.dart | 217 +- lib/l10n/generated/app_localizations_nl.dart | 217 +- lib/l10n/generated/app_localizations_pl.dart | 219 +- lib/l10n/generated/app_localizations_pt.dart | 441 +- lib/l10n/generated/app_localizations_ru.dart | 220 +- lib/l10n/generated/app_localizations_tr.dart | 217 +- lib/l10n/generated/app_localizations_uk.dart | 218 +- lib/l10n/generated/app_localizations_vi.dart | 217 +- lib/l10n/generated/app_localizations_zh.dart | 418 +- lib/src/app/app_router.dart | 28 + lib/src/app/app_settings.dart | 237 +- lib/src/app/busymark_app.dart | 498 +- lib/src/app/busymark_design.dart | 209 +- lib/src/app/busymark_dialogs.dart | 697 +- lib/src/app/busymark_main_menu.dart | 45 +- lib/src/app/busymark_search_field.dart | 126 +- lib/src/app/busymark_shortcuts.dart | 354 +- lib/src/app/command_palette.dart | 128 - lib/src/app/command_presentation.dart | 37 + lib/src/app/command_registry.dart | 569 +- lib/src/app/command_shortcuts.dart | 86 + lib/src/app/keyboard_shortcut_badge.dart | 40 + lib/src/app/keymap_controller.dart | 269 + lib/src/app/linux/linux_page_frame.dart | 60 +- lib/src/app/path_label.dart | 67 + lib/src/app/quick_open.dart | 201 +- lib/src/app/shortcut_binding.dart | 200 + lib/src/app/shortcut_capture.dart | 83 + .../clipboard/clipboard_history_panel.dart | 41 +- lib/src/editor/editor_text_context_menu.dart | 9 + lib/src/editor/source/source_editor.dart | 655 +- lib/src/editor/source/source_search.dart | 255 +- .../editor/wysiwyg/writerside_properties.dart | 15 +- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 196 +- lib/src/editor/wysiwyg/wysiwyg_toolbar.dart | 196 +- .../local_history_controller.dart | 252 +- .../local_history/local_history_models.dart | 26 + .../local_history/local_history_panel.dart | 262 +- lib/src/markdown/document_outline.dart | 31 + .../application/nextcloud_connection.dart | 4 + .../application/notes_search_controller.dart | 7 +- lib/src/nextcloud_notes/data/notes_store.dart | 6 +- .../nextcloud_notes/domain/notes_search.dart | 44 +- .../presentation/nextcloud_settings.dart | 24 +- .../presentation/notes_sidebar.dart | 25 +- .../presentation/notes_workspace_ui.dart | 399 +- .../platform/header_bar_configuration.dart | 9 - .../platform/linux_header_bar_service.dart | 2 - lib/src/platform/native_menu_service.dart | 6 + lib/src/platform/user_directories.dart | 14 + .../search/search_action_availability.dart | 42 + lib/src/search/search_activation.dart | 357 + lib/src/search/search_replace_service.dart | 183 +- .../search/search_result_presentation.dart | 147 + lib/src/search/search_result_preview.dart | 55 + lib/src/search/search_session.dart | 1515 +++ lib/src/search/search_sidebar.dart | 726 ++ lib/src/search/welcome_notes_action.dart | 141 + lib/src/search/workspace_text_search.dart | 173 + .../spellcheck/spelling_release_smoke.dart | 2 - lib/src/workspace/document_buffer.dart | 16 - lib/src/workspace/document_navigation.dart | 17 + lib/src/workspace/outline_navigation.dart | 196 + .../keyboard_shortcuts_settings.dart | 437 + .../presentation/settings_screen.dart | 29 +- .../presentation/welcome_screen.dart | 298 +- .../presentation/workspace_screen.dart | 2037 +--- .../presentation/writerside_toc_dialogs.dart | 41 +- lib/src/workspace/recent_navigation.dart | 192 + lib/src/workspace/recent_presentation.dart | 71 + lib/src/workspace/workspace_controller.dart | 253 +- lib/src/workspace/workspace_glyphs.dart | 4 +- linux/runner/my_application.cc | 68 +- linux/runner/native_shortcut_metadata.h | 90 + test/src/ai_edit_ui_test.dart | 1 - test/src/app_router_test.dart | 14 +- test/src/app_settings_test.dart | 654 +- test/src/app_smoke_test.dart | 8798 +++++++++++------ test/src/busymark_design_test.dart | 201 +- test/src/busymark_dialogs_test.dart | 82 +- test/src/busymark_document_test.dart | 4 +- test/src/clipboard_history_panel_test.dart | 170 +- test/src/command_registry_test.dart | 549 +- test/src/editor_ui_primitives_audit_test.dart | 11 +- test/src/header_bar_configuration_test.dart | 6 +- test/src/keyboard_shortcut_badge_test.dart | 39 + test/src/local_history_controller_test.dart | 209 +- test/src/local_history_workspace_test.dart | 109 + test/src/localization_audit_test.dart | 3 +- test/src/native_headerbar_audit_test.dart | 6 +- test/src/nextcloud_editor_boundary_test.dart | 1 - .../notes_workspace_workflows_test.dart | 14 +- test/src/nextcloud_notes_ui_test.dart | 375 +- test/src/notes_workspace_m2_ui_test.dart | 125 +- test/src/recent_presentation_test.dart | 169 + test/src/references/reference_ui_test.dart | 11 +- test/src/search_replace_service_test.dart | 71 +- test/src/shortcut_capture_test.dart | 102 + test/src/sidebar_search_session_test.dart | 1834 ++++ test/src/source_audit_test.dart | 153 +- test/src/source_editor_widget_test.dart | 376 +- test/src/source_search_test.dart | 132 +- .../src/spelling_editor_integration_test.dart | 1 - test/src/spelling_service_test.dart | 3 - test/src/workspace_controller_test.dart | 81 +- test/src/workspace_document_context_test.dart | 36 + .../writerside_toc_source_widget_test.dart | 1 - tools/history_visual_smoke.dart | 183 +- tools/native_shortcut_metadata_probe.cc | 26 + tools/nextcloud_notes_desktop_acceptance.dart | 384 +- tools/nextcloud_notes_native_key.py | 30 + tools/sidebar_search_smoke.dart | 2866 ++++++ tools/test_native_shortcut_metadata.sh | 10 + 155 files changed, 28798 insertions(+), 10393 deletions(-) delete mode 100644 lib/src/app/command_palette.dart create mode 100644 lib/src/app/command_presentation.dart create mode 100644 lib/src/app/command_shortcuts.dart create mode 100644 lib/src/app/keyboard_shortcut_badge.dart create mode 100644 lib/src/app/keymap_controller.dart create mode 100644 lib/src/app/path_label.dart create mode 100644 lib/src/app/shortcut_binding.dart create mode 100644 lib/src/app/shortcut_capture.dart create mode 100644 lib/src/platform/user_directories.dart create mode 100644 lib/src/search/search_action_availability.dart create mode 100644 lib/src/search/search_activation.dart create mode 100644 lib/src/search/search_result_presentation.dart create mode 100644 lib/src/search/search_result_preview.dart create mode 100644 lib/src/search/search_session.dart create mode 100644 lib/src/search/search_sidebar.dart create mode 100644 lib/src/search/welcome_notes_action.dart create mode 100644 lib/src/search/workspace_text_search.dart create mode 100644 lib/src/workspace/document_navigation.dart create mode 100644 lib/src/workspace/outline_navigation.dart create mode 100644 lib/src/workspace/presentation/keyboard_shortcuts_settings.dart create mode 100644 lib/src/workspace/recent_navigation.dart create mode 100644 lib/src/workspace/recent_presentation.dart create mode 100644 linux/runner/native_shortcut_metadata.h create mode 100644 test/src/keyboard_shortcut_badge_test.dart create mode 100644 test/src/recent_presentation_test.dart create mode 100644 test/src/shortcut_capture_test.dart create mode 100644 test/src/sidebar_search_session_test.dart create mode 100644 tools/native_shortcut_metadata_probe.cc create mode 100644 tools/sidebar_search_smoke.dart create mode 100755 tools/test_native_shortcut_metadata.sh diff --git a/README.md b/README.md index 28f8ad60..4f4d7b7c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Markdown, Writerside, and Nextcloud Notes editor for Linux. * **Writerside projects** — Open and create Writerside-compatible projects; edit Markdown and XML topics; manage instances, tables of contents, reusable TOC libraries, and project structure. * **Nextcloud Notes** — Connect to a Nextcloud server, create and edit notes offline, synchronize changes, search and filter notes, manage categories and favorites, and work with attachments. * **References and links** — Incoming/outgoing references, document/topic/heading target chooser, reviewed local Markdown rename/move, and portable Nextcloud note URLs. See [reference workflows](docs/references.md). -* **Navigation and search** — Files, Topics, and Outline views, tabbed documents, command palette, keyboard shortcuts, document search and replace, reviewed replacement across local workspaces, and Markdown TOC generation. +* **Navigation and search** — Files, Topics, and Outline views, tabbed documents, Actions search, configurable keyboard shortcuts, document search and replace, reviewed replacement across local workspaces, 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 for local workspaces** — 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. @@ -145,9 +145,10 @@ a release, a security update, or validation of `stage-packages` changes. Reading view for rendered documentation. - BusyMark keyboard shortcuts dialog + Keyboard Shortcuts in Settings
- Keyboard shortcuts reference. + Find actions or record a combination, assign shortcuts, resolve conflicts, + and restore defaults. Shortcut guide. @@ -202,17 +203,17 @@ BusyMark can open a **Nextcloud Notes** workspace alongside its local Markdown a ### Connect your account 1. Ensure the **Notes** app is enabled on your Nextcloud server. -2. On BusyMark’s welcome screen, choose **Nextcloud Notes**. You can also open **Settings → Nextcloud Notes**. +2. On BusyMark’s welcome screen, choose **Browse Notes** or **New note** (both Nextcloud actions). You can also open **Settings → Nextcloud Notes**. 3. Enter the full **HTTPS** address of your Nextcloud server and click **Connect**. 4. Complete authorization in your default web browser. BusyMark verifies Notes compatibility and opens the note workspace. -Once connected, select **Nextcloud Notes** on the welcome screen to reopen the workspace. Use **Settings → Nextcloud Notes** to open it, reconnect, or disconnect the account. **One Nextcloud account can be connected at a time.** +Once connected, select **Browse Notes** on Welcome to reopen normal Notes navigation, or **New note** to create and open one note. Individual notes also reopen through Recent or Documents search. Use **Settings → Nextcloud Notes** to open it, reconnect, or disconnect the account. **One Nextcloud account can be connected at a time.** ### Work with notes * **Create and edit:** Create notes from the Notes sidebar and edit them using the normal Markdown editor. Use each note’s menu to change its title, category, or favorite status, save a separate local copy, or delete the note. * **Find and organize:** Use All notes, Favorites, Uncategorized, Recovery, or a category and its descendants. Sort by activity or title. Ctrl-click toggles selection, Shift-click selects a range, and Ctrl+A selects the current list; the selected-notes action reviews batch favorite/category changes and reports partial outcomes. -* **Quick Open and search:** Ctrl+P finds titles/categories, including unopened cached notes; Ctrl+Shift+P opens the command palette. Notes search combines terms with AND and supports quoted phrases, `title:` / `category:` restrictions, whole words, snippets, and navigation to the match. Unsaved open text is included without forcing a save. +* **Sidebar search:** Ctrl+Shift+D searches Documents by titles/categories, including unopened cached notes; Ctrl+Shift+P searches Actions. Ctrl+F searches only the current note’s buffer, while Ctrl+Shift+F searches the active account. Notes search combines terms with AND and supports quoted phrases, `title:` / `category:` restrictions, snippets, and navigation to the match. Unsaved open text is included without forcing a save. * **Work offline:** Edits and new notes are saved locally before synchronization and survive restart. **Make available offline** retains a note or category/subtree's supported managed media. Its status lists available/missing bytes and offers deliberate retry/cancellation. Offline availability, freshness, and acknowledged synchronization are separate states; external dependencies remain identified. * **Review conflicts:** If a note changes on the server while you have local edits, BusyMark preserves the competing versions for review instead of silently overwriting one. The comparison workflow supports choosing a version, merging changes, or recovering content as a separate note. Ambiguous note-creation results also require explicit review. * **Use attachments:** Add files to notes, view supported images and media, and include managed resources when exporting Markdown or HTML. PDF export includes supported images but does not embed arbitrary file attachments. New uploads are limited to **100 MiB per file**. @@ -231,7 +232,7 @@ See the [Notes workspace guide](docs/development/nextcloud-notes.md#everyday-not BusyMark supports active-document replacement and reviewed workspace-wide replacement for local workspaces. Nextcloud Notes supports replacement within individual notes, but not workspace-wide replacement. See [Search and replace](docs/search-and-replace.md) for search -options, scope, preview behavior, and stale-result handling. +matching, scopes, preview behavior, and stale-result handling. ## Diagrams and mathematics diff --git a/docs/development/history.md b/docs/development/history.md index ab34f5fe..ee2609b1 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -134,8 +134,8 @@ the newly written or protective record. 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. +new document scope. Shared Local History search discovers retained identities independently of +ordinary browsing, including 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 @@ -214,3 +214,19 @@ JSON reports for inspection. Its optional `--application` mode opens a supplied file in the full application and requires the exact synthetic table fixture checked by the driver; use an isolated application profile and disposable files for that mode. + +## Shared Local History search + +`SearchCategory.localHistory` owns the header query and optional retained-document +ID. `findRetained()` reads the existing store and returns bounded document or +revision metadata without changing browsing, comparison, recording or buffer +ownership. Each request checks cancellation and the retained snapshot generation; +clear and account removal invalidate affected results. Revision bodies are read +sequentially and discarded after matching, rather than kept in widget state. + +Activation revalidates stable document/revision IDs and checksum before the +existing comparison workflow reads a revision. Its selection callback also checks +the originating search request after the read. Ordinary History navigation keeps +the complete timeline and has no search entry points or private query controller. +The shared coordinator retains explicit removable document scopes independently +of the browsing panel. diff --git a/docs/development/nextcloud-notes.md b/docs/development/nextcloud-notes.md index 051c285b..aae4aae6 100644 --- a/docs/development/nextcloud-notes.md +++ b/docs/development/nextcloud-notes.md @@ -498,23 +498,33 @@ Read-only notes support favorite changes. Partial results retain successes and identify skipped, conflicting or failed notes; resolve conflicts through the existing review workflow. -Ctrl+P opens Quick Open for the active workspace. Cached unopened Notes titles -and categories participate alongside local Markdown/Writerside files in their -own workspaces. Exact and prefix title matches rank first. Arrow keys choose, -Enter opens, and Escape dismisses. Ctrl+Shift+P still opens the command palette. - -The sidebar Search control and workspace search use one indexed Notes search -workflow. Terms combine with AND; quote a phrase, use `title:meeting` or -`category:"Work/Team"`, and select Whole word when needed. Ordinary punctuation, +Ctrl+Shift+D opens Documents in the shared search sidebar for the active workspace. +Cached unopened Notes titles and categories participate within their originating +account; document search never matches note bodies. Exact and prefix title +matches rank first. Arrow keys select, Enter opens, and Escape closes search. +Ctrl+Shift+P opens Actions in the same sidebar. Ctrl+Shift+R searches the shared +Recent documents and workspaces, including individual offline-created notes. + +Ctrl+F searches the current note's in-memory text. Ctrl+Shift+F and the Notes +sidebar Search button explicitly choose Workspace Text and use the +existing indexed Notes search. Terms combine with AND; quote a phrase, use `title:meeting` or +`category:"Work/Team"`. Ordinary punctuation, SQL/FTS operators and wildcard characters are literal. Partial terms, short queries, Unicode and decomposed accents are supported. Results include local unsynchronized text and overlay dirty open buffers without saving. Results show context, individual occurrences, loading/indexing/errors and Show more when -truncated. Opening a changed result rechecks its revision and content digest and -relocates the match. The Source location button explicitly selects source for +truncated. Opening a result rechecks its identity and content digest before +navigating to the exact occurrence; changed sources require fresh results. +The Source location button explicitly selects source for syntax that the rendered view cannot represent. Remote workspace replacement remains disabled. A missing derived index rebuilds without changing notes/outbox. +Welcome offers Nextcloud **New note** and **Browse Notes**. New note creates one +individual note through the existing offline creation workflow; Browse Notes +opens ordinary Notes navigation. Without an account, connection settings retain +the requested operation only until a successful connection or cancellation. +Opening settings alone does not create a note. + Make available offline retains an individual note or a category and descendants. Individual and category requirements are independent and survive restart. Moving notes into a retained category or adding supported managed references schedules @@ -595,3 +605,35 @@ and opens their recovery preview. Ordinary Notes search excludes these records. The preview continues to offer retained Local History revisions and recovery as a new note; it does not access server history or trash. Deleted attachments retained for recovery do not count as live offline availability. + +## Native sidebar search checks + +Build the maintained native probe with +`flutter build linux --release --no-pub -t tools/sidebar_search_smoke.dart`. +Run the resulting executable on an isolated X11 display, passing an absolute, +disposable output directory outside the checkout. Add `--large` to exercise 202 +local documents, 10,001 cached notes in the active account and a second offline +account. The probe uses the application UI, native shortcut delivery and existing +repository workflows; unexpected Flutter or Dart errors fail the run. + +The output directory contains temporary screenshots and timings, including rapid +query changes, account switching and repeated search opening/closing. It contains +only generated fixture data. Rebuild the normal Linux release afterward to +restore the production entry point. + +The desktop target also accepts `--unified-search` for a fresh, disposable +connected profile. Its private credentials JSON includes `secondaryLoginName` +and `secondaryAppPassword` for a second authorized disposable user on the same +server. Supply the test CA through the existing argument. This scenario checks +file/workspace/Documents scope, offline creation followed by synchronization, +stable Recent-note identity, direct reopening across accounts, and removal of the +originating account. It never uses production accounts or embeds credentials. + +The sidebar probe’s application timings observe search-session events, excluding +key injection and polling sleeps while including debounce. They distinguish first +useful results from completion, count file/revision reads, cancel active scans, +and compare file descriptors and RSS across repeated opening/closing. Local +search rereads eligible unopened files for each query; it retains eight-file +read-ahead and worker reuse and publishes bounded progress. Local History reads +revision bodies sequentially through the existing store and retains only result +metadata. Neither probe creates a persistent search cache or index. diff --git a/docs/history.md b/docs/history.md index f9046884..338d8524 100644 --- a/docs/history.md +++ b/docs/history.md @@ -6,8 +6,9 @@ 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 +Open **Clipboard History** from the sidebar, Actions search, or an editor +context menu. The panel shows the current clipboard followed by retained entries, +without a search field. 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. @@ -48,14 +49,30 @@ 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. +Open **Local History** from the sidebar (Ctrl+5 by default), Actions search, 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…** (Ctrl+Shift+L by default) to search retained closed, +renamed, deleted and previously untitled documents by name, current or historical +path, or revision content. The shared header owns the query and results appear +in the search sidebar. Scoped history results retain removable document context; +clear that context to search retained history generally. The ordinary History +panel has no Search or Find action; its management menu provides Refresh and +confirmed clearing operations. +An empty query waits for input and does not enumerate retained documents or read +revision contents. Clearing the query cancels matching and removes its results. + +A document result opens its retained timeline. A content result opens that exact +revision for comparison and leaves the search sidebar available. Inspection never +restores content or opens a current file merely because it has the same path. +Missing revisions produce feedback rather than selecting another revision. + +Opening ordinary Local History shows its full timeline, with no private search +field or remembered filter. Search retains its own query and selection for return +through the Search button. Both views use the same retained identities and +comparison operations. Use Back to return from retained inspection to the active +document. History matching is literal and case-insensitive, without regex options. BusyMark stores complete source text together with its encoding and line-ending policy. It records: diff --git a/docs/references.md b/docs/references.md index 85ae9c96..ef6a7ae7 100644 --- a/docs/references.md +++ b/docs/references.md @@ -1,6 +1,6 @@ # References and links -Open **References** from the sidebar menu or Command Palette. The panel follows +Open **References** from the sidebar menu or Search Actions. The panel follows the current document and shows its workspace/account scope. Switch between Incoming and Outgoing, or choose a parsed heading, topic element, or reusable fragment. Rows show the authored context, destination, and resolution status. diff --git a/docs/search-and-replace.md b/docs/search-and-replace.md index 75119d04..7ce533a7 100644 --- a/docs/search-and-replace.md +++ b/docs/search-and-replace.md @@ -1,45 +1,162 @@ # Search and replace -Press **Ctrl+F**, choose **Search**, or use the command palette to search the -open workspace. For local Markdown and Writerside workspaces, the search controls -use the same query and options for the active document and workspace results. +Search uses one query field in the header and one results sidebar. The compact +native-styled **Search:** selector above the results selects a category. +Normal Files, Topics, Notes and other browsing remain available when search +closes. At narrow window sizes the same sidebar temporarily overlays the leading +side of the document. -## Quick Open and Notes search +| Category | Scope | Shortcut | +| --- | --- | --- | +| Document Text | Active document’s current buffer | Ctrl+F | +| Workspace Text | Current local workspace or Notes account | Ctrl+Shift+F | +| Headings | Headings in the current document | Ctrl+Shift+H | +| Documents | Names, paths or Notes titles/categories in the active workspace | Ctrl+Shift+D | +| Topics | Topics across the current Writerside project, including outside the selected TOC | Ctrl+Shift+T | +| Recent | Chronological document and workspace history | Ctrl+Shift+R | +| Local History | Retained document names, historical paths and revision contents | Ctrl+Shift+L | +| Actions | Application commands, including commands without shortcuts | Ctrl+Shift+P | -**Ctrl+P** opens Quick Open for titles and local paths or Notes categories. -Exact/prefix title matches appear first. Use the arrow keys and Enter to open a -result, or Escape to dismiss; **Ctrl+Shift+P** remains the command palette. +Categories appear in that order; unavailable categories are omitted. Welcome +provides Recent and Actions. Retained documents behind Welcome are not search +or action targets. There is no combined category or search-results dialog. +**Settings → Keyboard Shortcuts** configures bindings. Its Actions command is +unassigned by default and opens the same Settings page, preserving the return +destination. It has no standalone main-menu entry or built-in shortcut. Explicit +user assignments remain available. The shortcuts listed here are defaults; +assignments can be changed. -In a Nextcloud Notes workspace, the sidebar search and workspace Search action -open the same indexed search dialog, including unopened cached notes, saved -pending edits, and unsaved open buffers. Terms use AND; quotes match phrases, +The Search icon toggles search and restores the last applicable category. +A category shortcut always opens its designated category and focuses the query; +Ctrl+F always chooses the current document, even after using Actions or opening +a Nextcloud note. Queries remain separate for each category within the window +session. They are not saved to settings or recovery metadata. +The clear button clears the query. Escape closes an open popup first, then +closes search and restores valid editing/navigation focus. Closing search also +restores the previous sidebar destination and visibility for the current workspace. + +Up and Down select a result without opening it. Enter activates the highlighted +result; Tab and Shift+Tab traverse controls. Disabled or stale Actions do not +execute a different result. Document formatting actions use the editor selection +captured before the query received focus and reject a changed target. +Save, Export and workspace actions also validate their original resource. +Existing Notes synchronization, folder/file import and export workflows are +available through Actions when their Notes workspace is active. + +## Document and workspace search + +**Search in Document** searches the live buffer, including unsaved text, +untitled Markdown and individual Nextcloud notes. **Search in Workspace** explicitly +widens the scope to the known local file inventory or active Notes account. An +unrelated external tab does not enlarge the local workspace scope. Text results +are occurrences; filename discovery belongs to Documents. + +For Nextcloud workspace text, the existing local Notes index includes cached, +unopened notes and dirty-buffer overlays. Terms use AND; quotes match phrases, `title:` restricts a term to the title, and `category:` restricts the category. -For example, `"alpha beta" café category:"Work/子"` combines all three restrictions. -Whole word is optional. Ordinary punctuation and technical identifiers are -literal; Notes search does not expose regular-expression or FTS operators. +For example, `"alpha beta" café category:"Work/子"` combines these restrictions. +The Notes workspace provider keeps its phrase and field query language. +Search in Document for Notes uses the normal buffer matching rules. +Documents searches note titles and categories, never note bodies. + +Loading/indexing, empty results, errors, skipped files and incomplete results are +shown in the sidebar. **Show more results** extends a bounded search. Activating +a text result validates its resource and source digest before applying exact +coordinates. A changed source requires a fresh result. A Notes result also has +an explicit source-location action for content without a rendered counterpart. +Recovery Search uses this same sidebar with an explicit **Recovery** context and +opens the existing recovery preview. Recovery records stay outside ordinary searches. + +## Outline, Topics and Recent + +Ctrl+3 and the Outline sidebar button open ordinary Outline without a query or +remembered filter. Choosing Headings in the search category menu filters the same +outline model. Explicit browsing closes search and focuses the Outline tree. + +Outline search waits for a query, then displays matching headings in document +order with their levels and ancestor context. It uses the same live model and +validated heading navigation, including unsaved headings. Ordinary Outline owns +its independent tree expansion, selection and section actions; search does not +filter or change that browsing state. +Topics searches source titles, topic IDs, filenames and project-relative paths +across modules. Rows distinguish module ownership and open the actual topic. + +Recent rows show a resource name and its containing location on two lines. +Locations inside the actual home use `~`; owned topics show their workspace and +containing folder. Outside locations remain absolute. One measured path elision +keeps useful trailing folders visible in narrow rows. +The tooltip, accessible description and Copy Path retain the complete location. + +Recent is one chronological collection of documents and workspaces, ordered by +last successful opening, with twelve entries in total. Explicit successful +openings move resources to the top. Local topics retain their project owner; individual Notes use the +originating account and stable local note identity, including offline-created +notes that later receive a server ID. Removing or clearing Recent only removes +navigation metadata. Missing resources produce feedback rather than opening a +similarly named document. Removing an account removes its Recent metadata. + +On Welcome, **New note** creates one Nextcloud note through the existing offline +creation workflow; **Browse Notes** opens normal Notes navigation. Both identify +the Nextcloud destination. Without an account they enter connection settings; +a successful connection resumes the requested operation, while cancellation or +changing context cancels it. Merely opening settings does not create a note. -Results show contextual highlights. Select a result to navigate in the current -view where supported, or choose its source-location action for Markdown syntax -that has no rendered counterpart. Changed documents are checked before applying -stored offsets. Loading/indexing, errors, empty results, and **Show more** are -explicit. Workspace-wide replacement remains unavailable for Notes. +## Local History search and browsing -The remaining sections describe local workspace search and replacement. +**Find in Local History…** opens the shared header query and sidebar. It searches +retained documents across the application, including closed, renamed, deleted and +previously untitled owners. Names and current or historical paths lead to the +retained timeline. Content results identify an exact revision and timestamp and +open its existing comparison view while keeping search results available. -## Search options +When History search has an explicit document scope, its name appears as +removable context. Clear that context to search all retained history. The +ordinary History panel has no Search or Find controls. Matching is literal and +case-insensitive. An empty History query waits for input. +Search reads retained revisions without recording, restoring, rebinding a live +buffer or substituting a same-named file. Missing revisions report failure. -- **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. +The Local History sidebar button and its navigation shortcut show the ordinary, +unfiltered timeline. The panel has no private search field or hidden filter. +Returning through Search restores the shared query and selection. Clearing or +pruning retained history invalidates affected results and comparisons. -Zero-length regular-expression matches, such as `^`, `$`, or a lookahead that -matches only a position, are not supported for search or replacement. +## Literal matching and initial lists + +Document and local-workspace text search use case-insensitive literal substring +matching. `cat` finds `Cat` and `catalog`; `.*` finds those literal characters. +Whitespace is searchable. Source coordinates remain offsets into the original +buffer. Replacement text is literal too: `$1` inserts `$1`, without capture-group +substitution. There are no case, whole-word or regex toggles in the sidebar or +Source/Split controls. + +Document Text, Workspace Text, Headings, Documents, Topics, Recent and Local +History initially show **Enter text to search**. Empty queries do not start +matching, scan the workspace, enumerate topic/document inventories or read History +revision contents. Clearing cancels outstanding work and removes its results and +selection. **No results** appears only after a nonempty query completes without +matches; loading, errors and unavailable resources have distinct feedback. + +Welcome displays its ordinary chronological Recent list. Recent search waits for +input and filters that collection without changing its order. Actions initially +displays the command catalog grouped in the same categories as Keyboard Shortcuts +Settings. +Within each group, available commands come first, then localized names. Group +headings are not selectable. With text entered, Actions shows one flat list ranked +by exact name, name prefix/token matches, then weaker name or metadata matches. +Commands without shortcuts remain visible. Selection follows command identity +when metadata changes; unavailable actions cannot execute or redirect Enter. +Each category selector option shows its current effective shortcut, including +custom assignments. Unassigned categories show no shortcut. ## Active document -In Source or Split view, the search panel shows the match count and provides -previous and next navigation. Its replacement field supports: +In Source or Split view, the search panel shows the full document match count. +Previous and next navigate every occurrence, including folded and off-screen +content, and wrap only at the document boundaries. Previous without a selected +match selects the final occurrence. The sidebar automatically loads and reveals +the selected result; **Show more results** is not required for this navigation. +Its replacement field supports: - replacing the current match; - replacing the current match and moving to the next one; and @@ -49,14 +166,18 @@ 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 +Replace and find next waits for matches in the modified document before moving +the caret. Changing the category, query, document or selection cancels +that pending navigation. + +Editor and Reading views show sidebar 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 +The sidebar shows occurrences with their file and line. 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. @@ -69,7 +190,8 @@ the query when the result list is incomplete. ## Replace in the workspace -Workspace replacement is always reviewed: +Workspace replacement applies to supported local workspaces and is always reviewed. +It is unavailable for Notes workspaces and recovery content. 1. Run a workspace search and choose **Replace in Workspace**. 2. Enter the replacement text and choose **Review replacements**. @@ -77,10 +199,6 @@ Workspace replacement is always reviewed: 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 @@ -98,3 +216,41 @@ 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. + +## Keyboard shortcut settings + +Use **Settings → Keyboard Shortcuts** to browse the complete action catalog by +category. The page has no search or shortcut filter. Scroll to an action and +select it to record and assign a shortcut, +remove its assignment, or restore its default. **Restore all defaults** asks for +confirmation. Local text input and traversal operations remain local controls. +While typing in a search field, Select All, Cut, Copy, Paste and Undo keep editing +that field, including when those combinations are assigned to application actions. + +A custom assignment replaces the default and its aliases, including numeric-keypad +variants and Ctrl+Insert / Shift+Insert / Shift+Delete for Copy / Paste / Cut. +The Settings context line identifies default aliases. Removing +an assignment leaves the action available from its UI and Actions search. Changes +update editor dispatch, Actions badges, menus and header metadata together. +Writerside Source retains its existing Ctrl+B Go to Declaration precedence over +formatting. The declaration, usages and rename bindings are configurable too. +Select All uses the full source document, including folded content, in Source +and Split. Modified Enter combinations reach their assigned commands; ordinary +Enter and autocomplete retain their local editing behavior. + +Settings stores only overrides keyed by command identity. Logical keys respect +keyboard layout; display text is not used to identify a key. + +Conflicts name actions whose receiving contexts overlap, including commands that +are currently unavailable. Reassignment explicitly removes those conflicting +bindings in the same update. Resetting one action also checks for conflicts. +Changed search defaults yield to conflicting saved assignments; +Settings identifies the conflict and supports explicit reassignment or reset. +Separate Editor-only and Source-only operations can share a combination; each +keeps its own action and context in the list. +Capture ignores modifier-only presses and repeats. Escape cancels; held keys must +be released before normal command dispatch resumes. A desktop may reserve some +combinations that BusyMark cannot override. + +If saving fails, the current bindings remain active and Settings reports that +failure. Retry saving before relying on those assignments after restarting. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 21cc4151..dcea6973 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "التاريخ", "pdfTitlePageTitleRequired": "أدخل عنوانًا لصفحة العنوان.", "pdfTocSectionNote": "يبدأ في صفحة منفصلة وقد يمتد إلى عدة صفحات.", - "sourceSearchZeroLengthUnsupported": "لا تُدعم مطابقات التعبير النمطي ذات الطول الصفري (مثل ^ أو $ أو الاستباق الذي يطابق موضعًا فقط).", "workspaceSearchIncomplete": "تظهر بعض النتائج فقط. اعرض المزيد أو ضيّق نطاق البحث.", "workspaceSearchShowMore": "عرض المزيد من النتائج", "workspaceSearchSkippedFiles": "البحث غير مكتمل. تجاوز حجم هذه الملفات 1 MiB أو تعذّرت قراءتها:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "إظهار مرجع اختصارات لوحة المفاتيح", + "shortcutKeyboardShortcutsDescription": "تخصيص اختصارات لوحة مفاتيح التطبيق", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "مسح مساحات العمل الأخيرة", + "clearRecentWorkspaces": "مسح السجل الأخير", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "حساس لحالة الأحرف", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "الكلمة بالكامل", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "تعبير نمطي", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "تعبير نمطي غير صالح", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "ملف كبير: تم إيقاف التمييز والطي مؤقتًا", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "نص الرابط فارغ؛ أدخل اسمًا ميسّرًا يصف الغرض منه.", "diagnosticMarkdownLinkReviewText": "راجع ما إذا كان نص الرابط «⁨{text}⁩» يصف غرضه ضمن السياق.", "diagnosticMarkdownTableEmptyHeader": "يجب أن تعرّف رؤوس الجدول أعمدتها؛ أكمل كل رأس فارغ.", - "commandPalette": "لوحة الأوامر", - "commandPaletteHint": "اكتب أمرًا", - "commandPaletteEmpty": "لا توجد أوامر مطابقة", "tableAlignmentUnspecified": "المحاذاة: غير محددة", "tableAlignmentLeft": "المحاذاة: إلى اليسار", "tableAlignmentCenter": "المحاذاة: إلى الوسط", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "اختر ملف .css مقروءًا بترميز UTF-8 حتى 256 KiB، دون استيراد أو عناوين موارد أو HTML أو CSS قابل للتنفيذ أو يحتوي على محارف هروب.", "exportInvalidRange": "{field}: أدخل رقمًا بين {minimum} و{maximum}.", "clipboardHistory": "سجل النسخ واللصق", - "clipboardHistorySearchHint": "البحث في سجل الحافظة", "clipboardHistoryDescription": "افتح سجل النسخ واللصق الخاص بالجلسة.", "clipboardCurrent": "النسخة واللصق الحالية", "clipboardCurrentExternal": "النسخة واللصق من تطبيق آخر", "clipboardDestination": "الصق في {name}", "clipboardSessionOnly": "مخزن فقط لهذه جلسة BusyMark.", "clipboardNoItems": "سجل النسخ واللصق فارغ.", - "clipboardNoMatches": "لا توجد عناصر في سجل النسخ واللصق تطابق هذا البحث.", "clipboardDisabled": "مجموعة سجل النسخ واللصق معطلة في الإعدادات.", "clipboardUnavailable": "محتوى النسخة واللصق الحالي غير متاح أو غير مدعوم.", "clipboardTooLarge": "هذا العنصر في النسخة واللصق كبير جدًا بحيث لا يمكن الاحتفاظ به.", @@ -3349,8 +3327,6 @@ "findLocalHistoryDescription": "البحث في مستندات التاريخ المحلي ومحتوى الإصدارات.", "localHistoryNoDocuments": "لا توجد مستندات لديها تاريخ محلي.", "localHistoryNoRevisions": "هذا المستند ليس لديه إصدارات في التاريخ المحلي.", - "localHistoryNoMatches": "لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.", - "localHistorySearchHint": "البحث في السجل المحلي", "localHistorySelectedRevision": "الإصدار المحدد", "localHistoryCurrentEditor": "محتوى المحرر الحالي", "localHistoryCurrentDisk": "المحتوى الحالي على القرص", @@ -3610,5 +3586,61 @@ "wsInsertElements": "إدراج عناصر", "wsAdmonitionsAndQuotes": "تنبيهات واقتباسات", "wsSemanticText": "نص دلالي", - "wsPropertiesUnavailable": "العنصر الذي يتم فحصه غير متاح. افحص عنصرًا للمتابعة." + "wsPropertiesUnavailable": "العنصر الذي يتم فحصه غير متاح. افحص عنصرًا للمتابعة.", + "searchDocumentsCategory": "المستندات", + "searchTopicsCategory": "المواضيع", + "searchCurrentDocument": "المستند الحالي", + "searchCurrentWorkspace": "مساحة العمل الحالية", + "browseNotes": "تصفح الملاحظات", + "searchActionsCommand": "البحث عن إجراءات", + "searchDocumentsCommand": "البحث عن مستندات", + "searchTopicsCommand": "البحث عن مواضيع", + "searchRecentCommand": "البحث في الأخيرة", + "searchResourceUnavailable": "هذا المورد غير متاح.", + "notesSync": "مزامنة الملاحظات", + "searchInWorkspace": "البحث في مساحة العمل", + "searchCategory": "فئة البحث", + "clearHistorySearchScope": "البحث في سجل المستندات", + "searchInDocument": "البحث في المستند", + "searchDocumentHint": "ابحث في هذا المستند…", + "searchWorkspaceHint": "ابحث في مساحة العمل هذه…", + "searchOutlineHint": "ابحث في العناوين…", + "searchHistoryHint": "ابحث في السجل المحلي…", + "searchEnterText": "أدخل نصًا للبحث", + "searchAccountUnavailable": "الحساب غير متاح", + "searchDocumentsHint": "البحث عن مستندات…", + "searchTopicsHint": "البحث عن مواضيع…", + "searchRecentHint": "البحث في الأخيرة…", + "searchActionsHint": "البحث عن إجراءات…", + "searchRequiresNotesWorkspace": "يتطلب مساحة عمل للملاحظات", + "searchSidebarLabel": "بحث:", + "searchDocumentTextCategory": "نص المستند", + "searchWorkspaceTextCategory": "نص مساحة العمل", + "searchHeadingsCategory": "العناوين", + "shortcutUnassigned": "غير معيّن", + "shortcutPressKeys": "اضغط مجموعة مفاتيح…", + "shortcutRecord": "تسجيل اختصار", + "shortcutRestoreAll": "استعادة جميع الإعدادات الافتراضية", + "shortcutRestoreAllQuestion": "هل تريد استعادة الاختصارات الافتراضية لجميع الإجراءات؟ ستُزال التعيينات المخصصة.", + "shortcutSaveFailed": "الاختصارات مفعّلة ولكن تعذر حفظها. أعد المحاولة للاحتفاظ بها بعد إعادة التشغيل.", + "shortcutRetry": "إعادة محاولة الحفظ", + "shortcutChanged": "تغيرت التعيينات. راجع الاختصارات الحالية وحاول مجددًا.", + "shortcutInvalid": "لا يمكن تعيين هذه المجموعة في هذا السياق.", + "shortcutSystemWarning": "قد يستخدم سطح المكتب هذه المجموعة. لا يستطيع BusyMark تجاوز اختصارات النظام.", + "shortcutConflicts": "يتعارض هذا الاختصار مع الإجراءات التالية:", + "shortcutRemove": "إزالة التعيين", + "shortcutRestoreDefault": "استعادة الافتراضي", + "shortcutAssign": "تعيين", + "shortcutReassign": "إعادة التعيين", + "shortcutContextApplication": "التطبيق", + "shortcutContextDocument": "طرق عرض المستند", + "shortcutContextEditor": "المحرر والمصدر", + "shortcutContextWritersideSource": "مصدر Writerside", + "shortcutContextWorkspace": "التنقل في مساحة العمل", + "shortcutContextTree": "أشجار الشريط الجانبي", + "searchHeadingsCommand": "البحث عن العناوين", + "shortcutFixed": "يتولاه المحرر؛ غير قابل للتخصيص.", + "shortcutDefaultConflict": "يتعارض الاختصار الافتراضي مع تعيين مخصص.", + "shortcutCurrentBinding": "الاختصار الحالي", + "shortcutProposedBinding": "الاختصار المقترح" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 643e1188..bb3ace0b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Datum", "pdfTitlePageTitleRequired": "Geben Sie einen Titel für die Titelseite ein.", "pdfTocSectionNote": "Beginnt auf einer separaten Seite und kann mehrere Seiten umfassen.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Diese Tastaturkürzelübersicht anzeigen", + "shortcutKeyboardShortcutsDescription": "Tastenkürzel der Anwendung anpassen", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Zuletzt verwendete Arbeitsbereiche löschen", + "clearRecentWorkspaces": "Zuletzt verwendete Einträge löschen", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Groß-/Kleinschreibung beachten", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Nur ganze Wörter", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Regulärer Ausdruck", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Ungültiger regulärer Ausdruck", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Große Datei: Hervorhebung und Faltung sind pausiert", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "Der Linktext ist leer; geben Sie einen zugänglichen Namen an, der den Zweck beschreibt.", "diagnosticMarkdownLinkReviewText": "Prüfen Sie, ob der Linktext „{text}“ seinen Zweck im Kontext beschreibt.", "diagnosticMarkdownTableEmptyHeader": "Tabellenüberschriften müssen ihre Spalten bezeichnen; füllen Sie jede leere Überschrift aus.", - "commandPalette": "Befehlspalette", - "commandPaletteHint": "Befehl eingeben", - "commandPaletteEmpty": "Keine passenden Befehle", "tableAlignmentUnspecified": "Ausrichtung: Nicht festgelegt", "tableAlignmentLeft": "Ausrichtung: Links", "tableAlignmentCenter": "Ausrichtung: Mitte", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Wählen Sie eine lesbare UTF-8-CSS-Datei bis 256 KiB ohne Importe, Ressourcen-URLs, HTML sowie ausführbares oder maskiertes CSS.", "exportInvalidRange": "{field}: Geben Sie eine Zahl zwischen {minimum} und {maximum} ein.", "clipboardHistory": "Zwischenablage-Historie", - "clipboardHistorySearchHint": "Zwischenablageverlauf durchsuchen", "clipboardHistoryDescription": "Öffne die Sitzungs-spezifische Zwischenablage-Historie.", "clipboardCurrent": "Aktuelle Zwischenablage", "clipboardCurrentExternal": "Aktuelle Zwischenablage aus einer anderen Anwendung", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Elemente einfügen", "wsAdmonitionsAndQuotes": "Hinweise und Zitate", "wsSemanticText": "Semantischer Text", - "wsPropertiesUnavailable": "Das betrachtete Element ist nicht verfügbar. Wählen Sie ein Element zum Fortfahren." + "wsPropertiesUnavailable": "Das betrachtete Element ist nicht verfügbar. Wählen Sie ein Element zum Fortfahren.", + "searchDocumentsCategory": "Dokumente", + "searchTopicsCategory": "Themen", + "searchCurrentDocument": "Aktuelles Dokument", + "searchCurrentWorkspace": "Aktueller Arbeitsbereich", + "browseNotes": "Notizen durchsuchen", + "searchActionsCommand": "Aktionen suchen", + "searchDocumentsCommand": "Dokumente suchen", + "searchTopicsCommand": "Themen suchen", + "searchRecentCommand": "Zuletzt geöffnet suchen", + "searchResourceUnavailable": "Diese Ressource ist nicht verfügbar.", + "notesSync": "Notizen synchronisieren", + "searchInWorkspace": "Im Arbeitsbereich suchen", + "searchCategory": "Suchkategorie", + "clearHistorySearchScope": "Verlauf aller Dokumente durchsuchen", + "searchInDocument": "Im Dokument suchen", + "searchDocumentHint": "Dieses Dokument durchsuchen…", + "searchWorkspaceHint": "Diesen Arbeitsbereich durchsuchen…", + "searchOutlineHint": "Überschriften suchen…", + "searchHistoryHint": "Lokalen Verlauf durchsuchen…", + "searchEnterText": "Suchtext eingeben", + "searchAccountUnavailable": "Konto nicht verfügbar", + "searchDocumentsHint": "Dokumente suchen…", + "searchTopicsHint": "Themen suchen…", + "searchRecentHint": "Zuletzt geöffnet suchen…", + "searchActionsHint": "Aktionen suchen…", + "searchRequiresNotesWorkspace": "Erfordert einen Notizen-Arbeitsbereich", + "searchSidebarLabel": "Suchen:", + "searchDocumentTextCategory": "Dokumenttext", + "searchWorkspaceTextCategory": "Arbeitsbereichstext", + "searchHeadingsCategory": "Überschriften", + "shortcutUnassigned": "Nicht zugewiesen", + "shortcutPressKeys": "Tastenkombination drücken…", + "shortcutRecord": "Tastenkürzel aufnehmen", + "shortcutRestoreAll": "Alle Vorgaben wiederherstellen", + "shortcutRestoreAllQuestion": "Standardkürzel aller Aktionen wiederherstellen? Eigene Zuweisungen werden entfernt.", + "shortcutSaveFailed": "Tastenkürzel sind aktiv, konnten aber nicht gespeichert werden. Zum Beibehalten nach Neustart erneut speichern.", + "shortcutRetry": "Speichern wiederholen", + "shortcutChanged": "Zuweisungen geändert. Aktuelle Tastenkürzel prüfen und erneut versuchen.", + "shortcutInvalid": "Diese Kombination kann hier nicht zugewiesen werden.", + "shortcutSystemWarning": "Diese Kombination wird möglicherweise vom Desktop verwendet. BusyMark kann Systemkürzel nicht überschreiben.", + "shortcutConflicts": "Dieses Tastenkürzel überschneidet sich mit diesen Aktionen:", + "shortcutRemove": "Zuweisung entfernen", + "shortcutRestoreDefault": "Vorgabe wiederherstellen", + "shortcutAssign": "Zuweisen", + "shortcutReassign": "Neu zuweisen", + "shortcutContextApplication": "Anwendung", + "shortcutContextDocument": "Dokumentansichten", + "shortcutContextEditor": "Editor und Quelltext", + "shortcutContextWritersideSource": "Writerside-Quelltext", + "shortcutContextWorkspace": "Arbeitsbereichsnavigation", + "shortcutContextTree": "Seitenleistenbäume", + "searchHeadingsCommand": "Überschriften suchen", + "shortcutFixed": "Vom Editor verwaltet; nicht konfigurierbar.", + "shortcutDefaultConflict": "Das Standardkürzel steht mit einer eigenen Zuweisung in Konflikt.", + "shortcutCurrentBinding": "Aktuelles Tastenkürzel", + "shortcutProposedBinding": "Vorgeschlagenes Tastenkürzel" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fa1d3be4..1b391441 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -358,12 +358,6 @@ "insert": "Insert", "@insert": {"description": "Insert button label."}, "keyboardShortcuts": "Keyboard Shortcuts", - "commandPalette": "Command Palette", - "@commandPalette": {"description": "Title and command label for the searchable command palette."}, - "commandPaletteHint": "Type a command", - "@commandPaletteHint": {"description": "Search hint in the command palette."}, - "commandPaletteEmpty": "No matching commands", - "@commandPaletteEmpty": {"description": "Empty state in the command palette."}, "commandUnavailableInContext": "Unavailable in the current editor context", "@commandUnavailableInContext": {"description":"Reason shown for a disabled contextual command."}, "@keyboardShortcuts": {"description": "Keyboard shortcuts dialog title and menu item."}, @@ -507,7 +501,7 @@ "@shortcutSaveDescription": {"description": "Keyboard shortcut description for saving content."}, "shortcutSearchDescription": "Search the current workspace", "@shortcutSearchDescription": {"description": "Keyboard shortcut description for search."}, - "shortcutKeyboardShortcutsDescription": "Show this keyboard shortcut reference", + "shortcutKeyboardShortcutsDescription": "Customize application keyboard shortcuts", "@shortcutKeyboardShortcutsDescription": {"description": "Keyboard shortcut description for opening shortcuts."}, "shortcutSyntaxReferenceDescription": "Open the Syntax Reference", "@shortcutSyntaxReferenceDescription": {"description": "Keyboard shortcut description for opening the Syntax Reference."}, @@ -663,8 +657,8 @@ "@editorFontSize": {"description": "Editor font size setting label."}, "validateOnEdit": "Validate on edit", "@validateOnEdit": {"description": "Validation setting label."}, - "clearRecentWorkspaces": "Clear recent workspaces", - "@clearRecentWorkspaces": {"description": "Settings action label for clearing recent workspaces."}, + "clearRecentWorkspaces": "Clear recent history", + "@clearRecentWorkspaces": {"description": "Settings action for clearing Recent navigation metadata in both document and workspace sections."}, "editingButtonsPosition": "Editing buttons position", "@editingButtonsPosition": {"description": "Settings label for WYSIWYG editing button position."}, "editingButtonsPositionDescription": "Choose where the floating WYSIWYG editing buttons appear.", @@ -1162,12 +1156,6 @@ "@sourceSearchPreviousMatch": {"description": "Tooltip for moving to the previous source search match."}, "sourceSearchNextMatch": "Next match", "@sourceSearchNextMatch": {"description": "Tooltip for moving to the next source search match."}, - "sourceSearchCaseSensitive": "Case sensitive", - "@sourceSearchCaseSensitive": {"description": "Tooltip for toggling case-sensitive source search."}, - "sourceSearchWholeWord": "Whole word", - "@sourceSearchWholeWord": {"description": "Tooltip for toggling whole-word source search."}, - "sourceSearchRegex": "Regex", - "@sourceSearchRegex": {"description": "Tooltip for toggling regular-expression source search."}, "sourceSearchReplacement": "Replace with", "@sourceSearchReplacement": {"description": "Placeholder for the active-document replacement field."}, "sourceSearchReplaceCurrent": "Replace current match", @@ -1249,12 +1237,9 @@ "@keepMine": {"description": "Action that keeps the editor buffer after an external change."}, "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."}, "nothingToRead": "Nothing to read", @@ -2720,7 +2705,6 @@ "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", @@ -2728,7 +2712,6 @@ "@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.", @@ -2746,8 +2729,6 @@ "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", @@ -3157,5 +3138,87 @@ "wsInsertElements": "Insert elements", "wsAdmonitionsAndQuotes": "Admonitions and quotes", "wsSemanticText": "Semantic text", - "wsPropertiesUnavailable": "The inspected element is unavailable. Inspect an element to continue." + "wsPropertiesUnavailable": "The inspected element is unavailable. Inspect an element to continue.", + "searchDocumentsCategory": "Documents", + "searchTopicsCategory": "Topics", + "searchCurrentDocument": "Current document", + "searchCurrentWorkspace": "Current workspace", + "browseNotes": "Browse Notes", + "searchActionsCommand": "Search Actions", + "searchDocumentsCommand": "Search Documents", + "searchTopicsCommand": "Search Topics", + "searchRecentCommand": "Search Recent", + "searchResourceUnavailable": "This resource is unavailable.", + "notesSync": "Sync notes", + "@notesSync": {"description": "Command to synchronize the active Nextcloud Notes account."}, + "searchInWorkspace": "Search in Workspace", + "searchCategory": "Search category", + "clearHistorySearchScope": "Search history across documents", + "searchInDocument": "Search in Document", + "searchDocumentHint": "Search this document…", + "searchWorkspaceHint": "Search this workspace…", + "searchOutlineHint": "Search headings…", + "searchHistoryHint": "Search Local History…", + "searchEnterText": "Enter text to search", + "searchAccountUnavailable": "Account unavailable", + "searchDocumentsHint": "Search documents…", + "searchTopicsHint": "Search topics…", + "searchRecentHint": "Search recent resources…", + "searchActionsHint": "Search actions…", + "searchRequiresNotesWorkspace": "Requires a Notes workspace", + "searchSidebarLabel": "Search:", + "@searchSidebarLabel": {"description": "Search and keyboard shortcut settings: searchSidebarLabel"}, + "searchDocumentTextCategory": "Document Text", + "@searchDocumentTextCategory": {"description": "Search and keyboard shortcut settings: searchDocumentTextCategory"}, + "searchWorkspaceTextCategory": "Workspace Text", + "@searchWorkspaceTextCategory": {"description": "Search and keyboard shortcut settings: searchWorkspaceTextCategory"}, + "searchHeadingsCategory": "Headings", + "@searchHeadingsCategory": {"description": "Search and keyboard shortcut settings: searchHeadingsCategory"}, + "shortcutUnassigned": "Unassigned", + "@shortcutUnassigned": {"description": "Search and keyboard shortcut settings: shortcutUnassigned"}, + "shortcutPressKeys": "Press a key combination…", + "@shortcutPressKeys": {"description": "Search and keyboard shortcut settings: shortcutPressKeys"}, + "shortcutRecord": "Record shortcut", + "@shortcutRecord": {"description": "Search and keyboard shortcut settings: shortcutRecord"}, + "shortcutRestoreAll": "Restore all defaults", + "@shortcutRestoreAll": {"description": "Search and keyboard shortcut settings: shortcutRestoreAll"}, + "shortcutRestoreAllQuestion": "Restore every action’s default shortcut? Custom assignments will be removed.", + "@shortcutRestoreAllQuestion": {"description": "Search and keyboard shortcut settings: shortcutRestoreAllQuestion"}, + "shortcutSaveFailed": "Shortcuts are active but could not be saved. Retry to keep them after restart.", + "@shortcutSaveFailed": {"description": "Search and keyboard shortcut settings: shortcutSaveFailed"}, + "shortcutRetry": "Retry saving", + "@shortcutRetry": {"description": "Search and keyboard shortcut settings: shortcutRetry"}, + "shortcutChanged": "Assignments changed. Review the current shortcuts and try again.", + "@shortcutChanged": {"description": "Search and keyboard shortcut settings: shortcutChanged"}, + "shortcutInvalid": "This combination cannot be assigned in this context.", + "@shortcutInvalid": {"description": "Search and keyboard shortcut settings: shortcutInvalid"}, + "shortcutSystemWarning": "This combination may be used by your desktop. BusyMark cannot override system shortcuts.", + "@shortcutSystemWarning": {"description": "Search and keyboard shortcut settings: shortcutSystemWarning"}, + "shortcutConflicts": "This shortcut overlaps these actions:", + "@shortcutConflicts": {"description": "Search and keyboard shortcut settings: shortcutConflicts"}, + "shortcutRemove": "Remove assignment", + "@shortcutRemove": {"description": "Search and keyboard shortcut settings: shortcutRemove"}, + "shortcutRestoreDefault": "Restore default", + "@shortcutRestoreDefault": {"description": "Search and keyboard shortcut settings: shortcutRestoreDefault"}, + "shortcutAssign": "Assign", + "@shortcutAssign": {"description": "Search and keyboard shortcut settings: shortcutAssign"}, + "shortcutReassign": "Reassign", + "@shortcutReassign": {"description": "Search and keyboard shortcut settings: shortcutReassign"}, + "shortcutContextApplication": "Application", + "@shortcutContextApplication": {"description": "Search and keyboard shortcut settings: shortcutContextApplication"}, + "shortcutContextDocument": "Document views", + "@shortcutContextDocument": {"description": "Search and keyboard shortcut settings: shortcutContextDocument"}, + "shortcutContextEditor": "Editor and Source", + "@shortcutContextEditor": {"description": "Search and keyboard shortcut settings: shortcutContextEditor"}, + "shortcutContextWritersideSource": "Writerside Source", + "@shortcutContextWritersideSource": {"description": "Search and keyboard shortcut settings: shortcutContextWritersideSource"}, + "shortcutContextWorkspace": "Workspace navigation", + "@shortcutContextWorkspace": {"description": "Search and keyboard shortcut settings: shortcutContextWorkspace"}, + "shortcutContextTree": "Sidebar trees", + "@shortcutContextTree": {"description": "Search and keyboard shortcut settings: shortcutContextTree"}, + "searchHeadingsCommand": "Search Headings", + "shortcutFixed": "Handled by the editor; not configurable.", + "shortcutDefaultConflict": "Default shortcut conflicts with a custom assignment.", + "shortcutCurrentBinding": "Current shortcut", + "shortcutProposedBinding": "Proposed shortcut" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 38458714..9b516a28 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Fecha", "pdfTitlePageTitleRequired": "Introduzca un título para la portada.", "pdfTocSectionNote": "Comienza en una página independiente y puede ocupar varias páginas.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Mostrar esta referencia de atajos de teclado", + "shortcutKeyboardShortcutsDescription": "Personalizar los atajos de teclado de la aplicación", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Borrar espacios de trabajo recientes", + "clearRecentWorkspaces": "Borrar el historial reciente", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Distinguir mayúsculas y minúsculas", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Palabra completa", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Expresión regular", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Expresión regular no válida", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Archivo grande: el resaltado y el plegado están en pausa", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "El texto del enlace está vacío; proporcione un nombre accesible que describa su propósito.", "diagnosticMarkdownLinkReviewText": "Revise si el texto del enlace «{text}» describe su propósito en contexto.", "diagnosticMarkdownTableEmptyHeader": "Los encabezados de tabla deben identificar sus columnas; complete cada encabezado vacío.", - "commandPalette": "Paleta de comandos", - "commandPaletteHint": "Escribe un comando", - "commandPaletteEmpty": "No hay comandos coincidentes", "tableAlignmentUnspecified": "Alineación: Sin especificar", "tableAlignmentLeft": "Alineación: Izquierda", "tableAlignmentCenter": "Alineación: Centro", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Elija un archivo .css UTF-8 legible de hasta 256 KiB, sin importaciones, URL de recursos, HTML ni CSS ejecutable o escapado.", "exportInvalidRange": "{field}: introduzca un número entre {minimum} y {maximum}.", "clipboardHistory": "Historial de la Copia", - "clipboardHistorySearchHint": "Buscar en el historial del portapapeles", "clipboardHistoryDescription": "Abrir el historial de la copia local de la sesión.", "clipboardCurrent": "Copia actual", "clipboardCurrentExternal": "Copia actual de otra aplicación", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Insertar elementos", "wsAdmonitionsAndQuotes": "Avisos y citas", "wsSemanticText": "Texto semántico", - "wsPropertiesUnavailable": "El elemento inspeccionado no está disponible. Inspeccione otro elemento para continuar." + "wsPropertiesUnavailable": "El elemento inspeccionado no está disponible. Inspeccione otro elemento para continuar.", + "searchDocumentsCategory": "Documentos", + "searchTopicsCategory": "Temas", + "searchCurrentDocument": "Documento actual", + "searchCurrentWorkspace": "Espacio de trabajo actual", + "browseNotes": "Explorar notas", + "searchActionsCommand": "Buscar acciones", + "searchDocumentsCommand": "Buscar documentos", + "searchTopicsCommand": "Buscar temas", + "searchRecentCommand": "Buscar recientes", + "searchResourceUnavailable": "Este recurso no está disponible.", + "notesSync": "Sincronizar notas", + "searchInWorkspace": "Buscar en espacio de trabajo", + "searchCategory": "Categoría de búsqueda", + "clearHistorySearchScope": "Buscar en el historial de documentos", + "searchInDocument": "Buscar en el documento", + "searchDocumentHint": "Buscar en este documento…", + "searchWorkspaceHint": "Buscar en este espacio de trabajo…", + "searchOutlineHint": "Buscar encabezados…", + "searchHistoryHint": "Buscar en el historial local…", + "searchEnterText": "Introduce texto para buscar", + "searchAccountUnavailable": "Cuenta no disponible", + "searchDocumentsHint": "Buscar documentos…", + "searchTopicsHint": "Buscar temas…", + "searchRecentHint": "Buscar recientes…", + "searchActionsHint": "Buscar acciones…", + "searchRequiresNotesWorkspace": "Requiere un espacio de trabajo de Notas", + "searchSidebarLabel": "Buscar:", + "searchDocumentTextCategory": "Texto del documento", + "searchWorkspaceTextCategory": "Texto del espacio de trabajo", + "searchHeadingsCategory": "Encabezados", + "shortcutUnassigned": "Sin asignar", + "shortcutPressKeys": "Pulse una combinación de teclas…", + "shortcutRecord": "Capturar atajo", + "shortcutRestoreAll": "Restaurar todos los valores predeterminados", + "shortcutRestoreAllQuestion": "¿Restaurar los atajos predeterminados de todas las acciones? Se eliminarán las asignaciones personalizadas.", + "shortcutSaveFailed": "Los atajos están activos, pero no se pudieron guardar. Reintente para conservarlos tras reiniciar.", + "shortcutRetry": "Reintentar guardado", + "shortcutChanged": "Las asignaciones cambiaron. Revise los atajos actuales y reintente.", + "shortcutInvalid": "Esta combinación no se puede asignar en este contexto.", + "shortcutSystemWarning": "El escritorio puede usar esta combinación. BusyMark no puede sustituir atajos del sistema.", + "shortcutConflicts": "Este atajo entra en conflicto con estas acciones:", + "shortcutRemove": "Quitar asignación", + "shortcutRestoreDefault": "Restaurar valor predeterminado", + "shortcutAssign": "Asignar", + "shortcutReassign": "Reasignar", + "shortcutContextApplication": "Aplicación", + "shortcutContextDocument": "Vistas del documento", + "shortcutContextEditor": "Editor y código fuente", + "shortcutContextWritersideSource": "Código fuente de Writerside", + "shortcutContextWorkspace": "Navegación del espacio de trabajo", + "shortcutContextTree": "Árboles laterales", + "searchHeadingsCommand": "Buscar encabezados", + "shortcutFixed": "Lo gestiona el editor; no se puede configurar.", + "shortcutDefaultConflict": "El atajo predeterminado entra en conflicto con una asignación personalizada.", + "shortcutCurrentBinding": "Atajo actual", + "shortcutProposedBinding": "Atajo propuesto" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 0a9e9b77..bba52b19 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Kuupäev", "pdfTitlePageTitleRequired": "Sisesta tiitellehe pealkiri.", "pdfTocSectionNote": "Algab eraldi lehelt ja võib hõlmata mitut lehte.", - "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:", @@ -673,7 +672,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Kuva kiirklahvide loend", + "shortcutKeyboardShortcutsDescription": "Kohanda rakenduse kiirklahve", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -970,7 +969,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Tühjenda hiljutiste tööruumide loend", + "clearRecentWorkspaces": "Tühjenda hiljutiste kirjete loend", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1855,22 +1854,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Tõstutundlik", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Terve sõna", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Regulaaravaldis", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Vigane regulaaravaldis", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Suur fail: esiletõstmine ja voltimine on peatatud", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3668,9 +3651,6 @@ "diagnosticMarkdownLinkEmptyText": "Lingi tekst on tühi; lisa ligipääsetav nimi, mis kirjeldab selle otstarvet.", "diagnosticMarkdownLinkReviewText": "Kontrolli, kas lingi tekst „{text}” kirjeldab kontekstis selle otstarvet.", "diagnosticMarkdownTableEmptyHeader": "Tabelipäised peavad veerge kirjeldama; täida kõik tühjad päised.", - "commandPalette": "Käskude palett", - "commandPaletteHint": "Sisesta käsk", - "commandPaletteEmpty": "Sobivaid käske pole", "tableAlignmentUnspecified": "Joondus: määramata", "tableAlignmentLeft": "Joondus: vasakule", "tableAlignmentCenter": "Joondus: keskele", @@ -3785,14 +3765,12 @@ "exportInvalidCss": "Vali loetav UTF-8 .css-fail kuni 256 KiB, ilma importide, ressursside URL-ide, HTML-i ning käivitatava või paojadadega CSS-ita.", "exportInvalidRange": "{field}: sisesta arv vahemikus {minimum} kuni {maximum}.", "clipboardHistory": "Kleepimiskredi ajalugu", - "clipboardHistorySearchHint": "Otsi lõikepuhvri ajaloost", "clipboardHistoryDescription": "Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.", "clipboardCurrent": "Praegune kleepimiskredi", "clipboardCurrentExternal": "Praegune kleepimiskredi teistest rakendusest", "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.", @@ -3809,8 +3787,6 @@ "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", @@ -4070,5 +4046,61 @@ "wsInsertElements": "Lisa elemendid", "wsAdmonitionsAndQuotes": "Märkused ja tsitaadid", "wsSemanticText": "Semantiline tekst", - "wsPropertiesUnavailable": "Vaadeldav element pole saadaval. Jätkamiseks vali element." + "wsPropertiesUnavailable": "Vaadeldav element pole saadaval. Jätkamiseks vali element.", + "searchDocumentsCategory": "Dokumendid", + "searchTopicsCategory": "Teemad", + "searchCurrentDocument": "Praegune dokument", + "searchCurrentWorkspace": "Praegune tööruum", + "browseNotes": "Sirvi märkmeid", + "searchActionsCommand": "Otsi toiminguid", + "searchDocumentsCommand": "Otsi dokumente", + "searchTopicsCommand": "Otsi teemasid", + "searchRecentCommand": "Otsi hiljutisi", + "searchResourceUnavailable": "See ressurss pole saadaval.", + "notesSync": "Sünkrooni märkmed", + "searchInWorkspace": "Otsi tööalast", + "searchCategory": "Otsingu kategooria", + "clearHistorySearchScope": "Otsi dokumentide ajaloost", + "searchInDocument": "Otsi dokumendist", + "searchDocumentHint": "Otsi sellest dokumendist…", + "searchWorkspaceHint": "Otsi sellest tööruumist…", + "searchOutlineHint": "Otsi pealkirju…", + "searchHistoryHint": "Otsi kohalikust ajaloost…", + "searchEnterText": "Sisesta otsitav tekst", + "searchAccountUnavailable": "Konto pole saadaval", + "searchDocumentsHint": "Otsi dokumente…", + "searchTopicsHint": "Otsi teemasid…", + "searchRecentHint": "Otsi hiljutisi…", + "searchActionsHint": "Otsi toiminguid…", + "searchRequiresNotesWorkspace": "Vajab märkmete tööruumi", + "searchSidebarLabel": "Otsi:", + "searchDocumentTextCategory": "Dokumendi tekst", + "searchWorkspaceTextCategory": "Tööala tekst", + "searchHeadingsCategory": "Pealkirjad", + "shortcutUnassigned": "Määramata", + "shortcutPressKeys": "Vajuta klahvikombinatsiooni…", + "shortcutRecord": "Salvesta kiirklahv", + "shortcutRestoreAll": "Taasta kõik vaikeväärtused", + "shortcutRestoreAllQuestion": "Kas taastada kõigi toimingute vaikekiirklahvid? Kohandatud määrangud eemaldatakse.", + "shortcutSaveFailed": "Kiirklahvid on aktiivsed, kuid neid ei saanud salvestada. Proovi uuesti, et säilitada need pärast taaskäivitamist.", + "shortcutRetry": "Proovi salvestamist uuesti", + "shortcutChanged": "Määrangud on muutunud. Vaata praegused kiirklahvid üle ja proovi uuesti.", + "shortcutInvalid": "Seda kombinatsiooni ei saa selles kontekstis määrata.", + "shortcutSystemWarning": "Töölaud võib seda kombinatsiooni kasutada. BusyMark ei saa süsteemi kiirklahve alistada.", + "shortcutConflicts": "See kiirklahv kattub järgmiste toimingutega:", + "shortcutRemove": "Eemalda määrang", + "shortcutRestoreDefault": "Taasta vaikeväärtus", + "shortcutAssign": "Määra", + "shortcutReassign": "Määra ümber", + "shortcutContextApplication": "Rakendus", + "shortcutContextDocument": "Dokumendivaated", + "shortcutContextEditor": "Redaktor ja lähtekood", + "shortcutContextWritersideSource": "Writerside’i lähtekood", + "shortcutContextWorkspace": "Tööala navigeerimine", + "shortcutContextTree": "Külgriba puud", + "searchHeadingsCommand": "Otsi pealkirju", + "shortcutFixed": "Redaktori hallatav; pole seadistatav.", + "shortcutDefaultConflict": "Vaikimisi otsetee on vastuolus kohandatud määranguga.", + "shortcutCurrentBinding": "Praegune otsetee", + "shortcutProposedBinding": "Pakutud otsetee" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index a009276a..285a30d0 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "تاریخ", "pdfTitlePageTitleRequired": "عنوانی برای صفحهٔ عنوان وارد کنید.", "pdfTocSectionNote": "از صفحه‌ای جداگانه شروع می‌شود و ممکن است چند صفحه ادامه یابد.", - "sourceSearchZeroLengthUnsupported": "تطبیق‌های با طول صفر در عبارت منظم پشتیبانی نمی‌شوند (مانند ^، $ یا نگاه‌به‌جلو که فقط با یک موقعیت تطبیق دارد).", "workspaceSearchIncomplete": "فقط بخشی از نتایج نمایش داده می‌شود. نتایج بیشتری نمایش دهید یا جستجو را محدود کنید.", "workspaceSearchShowMore": "نمایش نتایج بیشتر", "workspaceSearchSkippedFiles": "جستجو کامل نیست. حجم این فایل‌ها بیش از 1 MiB است یا خواندن آن‌ها ممکن نبود:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "نمایش مرجع میانبرهای صفحه‌کلید", + "shortcutKeyboardShortcutsDescription": "سفارشی‌سازی میان‌برهای صفحه‌کلید برنامه", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "پاک کردن فضاهای کاری اخیر", + "clearRecentWorkspaces": "پاک کردن تاریخچهٔ اخیر", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1569,22 +1568,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "حساس به بزرگی و کوچکی حروف", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "تمام کلمه", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "عبارت منظم", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "عبارت منظم نامعتبر است", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "فایل بزرگ: برجسته‌سازی و جمع‌کردن موقتاً متوقف شده‌اند", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3250,9 +3233,6 @@ "diagnosticMarkdownLinkEmptyText": "متن پیوند خالی است؛ نام دسترس‌پذیری وارد کنید که هدف آن را توضیح دهد.", "diagnosticMarkdownLinkReviewText": "بررسی کنید که آیا متن پیوند «⁨{text}⁩» هدف آن را در زمینه توضیح می‌دهد.", "diagnosticMarkdownTableEmptyHeader": "سرستون‌های جدول باید ستون‌های خود را مشخص کنند؛ هر سرستون خالی را تکمیل کنید.", - "commandPalette": "پالت فرمان", - "commandPaletteHint": "یک فرمان وارد کنید", - "commandPaletteEmpty": "هیچ فرمان منطبقی وجود ندارد", "tableAlignmentUnspecified": "تراز: مشخص‌نشده", "tableAlignmentLeft": "تراز: چپ", "tableAlignmentCenter": "تراز: وسط", @@ -3367,14 +3347,12 @@ "exportInvalidCss": "یک فایل .css خوانا با UTF-8 تا ۲۵۶ KiB انتخاب کنید، بدون واردسازی، نشانی منابع، HTML یا CSS اجرایی یا دارای نویسهٔ گریز.", "exportInvalidRange": "{field}: عددی بین {minimum} و {maximum} وارد کنید.", "clipboardHistory": "تاریخچه کپی و پیست", - "clipboardHistorySearchHint": "جستجو در تاریخچه کلیپ‌بورد", "clipboardHistoryDescription": "باز کردن تاریخچه کپی و پیست مخصوص جلسه.", "clipboardCurrent": "کپی و پیست فعلی", "clipboardCurrentExternal": "کپی و پیست از برنامه دیگر", "clipboardDestination": "کپی و پیست در {name}", "clipboardSessionOnly": "فقط برای این جلسه BusyMark ذخیره می شود.", "clipboardNoItems": "تاریخچه کپی و پیست خالی است.", - "clipboardNoMatches": "هیچ آیتم کپی و پیستی با این جستجو مطابقت ندارد.", "clipboardDisabled": "مجموعه تاریخچه کپی و پیست در تنظیمات غیرفعال است.", "clipboardUnavailable": "محتوای کپی و پیست فعلی در دسترس نیست یا پشتیبانی نمی شود.", "clipboardTooLarge": "این آیتم کپی شده خیلی بزرگ است و نمی‌توان آن را نگه داشت.", @@ -3391,8 +3369,6 @@ "findLocalHistoryDescription": "اسناد و محتوای نسخه‌های تاریخ محلی را جستجو کنید.", "localHistoryNoDocuments": "هنوز هیچ سند دارای تاریخ محلی نیست.", "localHistoryNoRevisions": "این سند دارای هیچ نسخه‌ای از تاریخ محلی نیست.", - "localHistoryNoMatches": "هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.", - "localHistorySearchHint": "جستجو در تاریخچه محلی", "localHistorySelectedRevision": "نسخه انتخاب شده", "localHistoryCurrentEditor": "محتوای فعلی ویرایشگر", "localHistoryCurrentDisk": "محتوای فعلی روی دیسک", @@ -3652,5 +3628,61 @@ "wsInsertElements": "درج عناصر", "wsAdmonitionsAndQuotes": "یادآوری‌ها و نقل‌قول‌ها", "wsSemanticText": "متن معنایی", - "wsPropertiesUnavailable": "عنصر بررسی‌شده در دسترس نیست. برای ادامه عنصری را بررسی کنید." + "wsPropertiesUnavailable": "عنصر بررسی‌شده در دسترس نیست. برای ادامه عنصری را بررسی کنید.", + "searchDocumentsCategory": "سندها", + "searchTopicsCategory": "موضوع‌ها", + "searchCurrentDocument": "سند کنونی", + "searchCurrentWorkspace": "فضای کاری کنونی", + "browseNotes": "مرور یادداشت‌ها", + "searchActionsCommand": "جستجوی کنش‌ها", + "searchDocumentsCommand": "جستجوی سندها", + "searchTopicsCommand": "جستجوی موضوع‌ها", + "searchRecentCommand": "جستجوی موارد اخیر", + "searchResourceUnavailable": "این منبع در دسترس نیست.", + "notesSync": "همگام‌سازی یادداشت‌ها", + "searchInWorkspace": "جستجو در فضای کاری", + "searchCategory": "دستهٔ جستجو", + "clearHistorySearchScope": "جستجو در تاریخچهٔ سندها", + "searchInDocument": "جستجو در سند", + "searchDocumentHint": "جستجو در این سند…", + "searchWorkspaceHint": "جستجو در این فضای کاری…", + "searchOutlineHint": "جستجوی عنوان‌ها…", + "searchHistoryHint": "جستجو در تاریخچهٔ محلی…", + "searchEnterText": "متن جستجو را وارد کنید", + "searchAccountUnavailable": "حساب در دسترس نیست", + "searchDocumentsHint": "جستجوی سندها…", + "searchTopicsHint": "جستجوی موضوع‌ها…", + "searchRecentHint": "جستجوی موارد اخیر…", + "searchActionsHint": "جستجوی کنش‌ها…", + "searchRequiresNotesWorkspace": "به فضای کاری یادداشت‌ها نیاز دارد", + "searchSidebarLabel": "جستجو:", + "searchDocumentTextCategory": "متن سند", + "searchWorkspaceTextCategory": "متن فضای کاری", + "searchHeadingsCategory": "عنوان‌ها", + "shortcutUnassigned": "تخصیص داده نشده", + "shortcutPressKeys": "یک ترکیب کلید را فشار دهید…", + "shortcutRecord": "ثبت میان‌بر", + "shortcutRestoreAll": "بازیابی همهٔ پیش‌فرض‌ها", + "shortcutRestoreAllQuestion": "میان‌برهای پیش‌فرض همهٔ کنش‌ها بازیابی شوند؟ تخصیص‌های سفارشی حذف می‌شوند.", + "shortcutSaveFailed": "میان‌برها فعال‌اند اما ذخیره نشدند. برای حفظ آن‌ها پس از راه‌اندازی مجدد، دوباره ذخیره کنید.", + "shortcutRetry": "تلاش دوباره برای ذخیره", + "shortcutChanged": "تخصیص‌ها تغییر کرده‌اند. میان‌برهای فعلی را بررسی و دوباره تلاش کنید.", + "shortcutInvalid": "این ترکیب را نمی‌توان در این زمینه تخصیص داد.", + "shortcutSystemWarning": "ممکن است میزکار از این ترکیب استفاده کند. BusyMark نمی‌تواند میان‌برهای سیستم را بازنویسی کند.", + "shortcutConflicts": "این میان‌بر با کنش‌های زیر تداخل دارد:", + "shortcutRemove": "حذف تخصیص", + "shortcutRestoreDefault": "بازیابی پیش‌فرض", + "shortcutAssign": "تخصیص", + "shortcutReassign": "تخصیص مجدد", + "shortcutContextApplication": "برنامه", + "shortcutContextDocument": "نماهای سند", + "shortcutContextEditor": "ویرایشگر و منبع", + "shortcutContextWritersideSource": "منبع Writerside", + "shortcutContextWorkspace": "پیمایش فضای کاری", + "shortcutContextTree": "درخت‌های نوار کناری", + "searchHeadingsCommand": "جستجوی عنوان‌ها", + "shortcutFixed": "توسط ویرایشگر مدیریت می‌شود؛ قابل تنظیم نیست.", + "shortcutDefaultConflict": "میان‌بر پیش‌فرض با تخصیص سفارشی تداخل دارد.", + "shortcutCurrentBinding": "میان‌بر فعلی", + "shortcutProposedBinding": "میان‌بر پیشنهادی" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 230fd23b..506ac090 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Date", "pdfTitlePageTitleRequired": "Saisissez un titre pour la page de titre.", "pdfTocSectionNote": "Commence sur une page distincte et peut occuper plusieurs pages.", - "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 :", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Afficher cette liste de raccourcis clavier", + "shortcutKeyboardShortcutsDescription": "Personnaliser les raccourcis clavier de l’application", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Effacer les espaces de travail récents", + "clearRecentWorkspaces": "Effacer l’historique récent", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Respecter la casse", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Mot entier", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Expression régulière", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Expression régulière non valide", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Fichier volumineux : la coloration et le repliage sont suspendus", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "Le texte du lien est vide ; fournissez un nom accessible qui décrit son objectif.", "diagnosticMarkdownLinkReviewText": "Vérifiez si le texte du lien « {text} » décrit son objectif dans le contexte.", "diagnosticMarkdownTableEmptyHeader": "Les en-têtes de tableau doivent identifier leurs colonnes ; complétez chaque en-tête vide.", - "commandPalette": "Palette de commandes", - "commandPaletteHint": "Saisissez une commande", - "commandPaletteEmpty": "Aucune commande correspondante", "tableAlignmentUnspecified": "Alignement : non spécifié", "tableAlignmentLeft": "Alignement : gauche", "tableAlignmentCenter": "Alignement : centre", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Choisissez un fichier .css UTF-8 lisible de 256 KiB maximum, sans imports, URL de ressources, HTML ni CSS exécutable ou échappé.", "exportInvalidRange": "{field} : saisissez un nombre entre {minimum} et {maximum}.", "clipboardHistory": "Historique de la presse-papiers", - "clipboardHistorySearchHint": "Rechercher dans l’historique du presse-papiers", "clipboardHistoryDescription": "Ouvrir l'historique de la presse-papiers local à la session.", "clipboardCurrent": "Presse-papiers actuel", "clipboardCurrentExternal": "Presse-papiers actuel provenant d'une autre application", "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é.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Insérer des éléments", "wsAdmonitionsAndQuotes": "Avertissements et citations", "wsSemanticText": "Texte sémantique", - "wsPropertiesUnavailable": "L’élément inspecté est indisponible. Inspectez un élément pour continuer." + "wsPropertiesUnavailable": "L’élément inspecté est indisponible. Inspectez un élément pour continuer.", + "searchDocumentsCategory": "Documents", + "searchTopicsCategory": "Rubriques", + "searchCurrentDocument": "Document actuel", + "searchCurrentWorkspace": "Espace de travail actuel", + "browseNotes": "Parcourir les notes", + "searchActionsCommand": "Rechercher des actions", + "searchDocumentsCommand": "Rechercher des documents", + "searchTopicsCommand": "Rechercher des rubriques", + "searchRecentCommand": "Rechercher les éléments récents", + "searchResourceUnavailable": "Cette ressource est indisponible.", + "notesSync": "Synchroniser les notes", + "searchInWorkspace": "Rechercher dans l’espace de travail", + "searchCategory": "Catégorie de recherche", + "clearHistorySearchScope": "Rechercher dans l’historique des documents", + "searchInDocument": "Rechercher dans le document", + "searchDocumentHint": "Rechercher dans ce document…", + "searchWorkspaceHint": "Rechercher dans cet espace de travail…", + "searchOutlineHint": "Rechercher des titres…", + "searchHistoryHint": "Rechercher dans l’historique local…", + "searchEnterText": "Saisissez du texte à rechercher", + "searchAccountUnavailable": "Compte indisponible", + "searchDocumentsHint": "Rechercher des documents…", + "searchTopicsHint": "Rechercher des rubriques…", + "searchRecentHint": "Rechercher les éléments récents…", + "searchActionsHint": "Rechercher des actions…", + "searchRequiresNotesWorkspace": "Nécessite un espace de travail Notes", + "searchSidebarLabel": "Rechercher:", + "searchDocumentTextCategory": "Texte du document", + "searchWorkspaceTextCategory": "Texte de l’espace de travail", + "searchHeadingsCategory": "Titres", + "shortcutUnassigned": "Non attribué", + "shortcutPressKeys": "Appuyez sur une combinaison de touches…", + "shortcutRecord": "Enregistrer un raccourci", + "shortcutRestoreAll": "Rétablir tous les raccourcis", + "shortcutRestoreAllQuestion": "Rétablir les raccourcis par défaut de toutes les actions ? Les affectations personnalisées seront supprimées.", + "shortcutSaveFailed": "Les raccourcis sont actifs mais n’ont pas été sauvegardés. Réessayez pour les conserver après redémarrage.", + "shortcutRetry": "Réessayer la sauvegarde", + "shortcutChanged": "Les affectations ont changé. Vérifiez les raccourcis actuels et réessayez.", + "shortcutInvalid": "Cette combinaison ne peut pas être attribuée dans ce contexte.", + "shortcutSystemWarning": "Cette combinaison peut être utilisée par votre bureau. BusyMark ne peut pas remplacer les raccourcis système.", + "shortcutConflicts": "Ce raccourci entre en conflit avec ces actions :", + "shortcutRemove": "Supprimer l’affectation", + "shortcutRestoreDefault": "Rétablir le raccourci par défaut", + "shortcutAssign": "Attribuer", + "shortcutReassign": "Réattribuer", + "shortcutContextApplication": "Application entière", + "shortcutContextDocument": "Vues du document", + "shortcutContextEditor": "Éditeur et source", + "shortcutContextWritersideSource": "Source Writerside", + "shortcutContextWorkspace": "Navigation de l’espace de travail", + "shortcutContextTree": "Arborescences latérales", + "searchHeadingsCommand": "Rechercher des titres", + "shortcutFixed": "Géré par l’éditeur ; non configurable.", + "shortcutDefaultConflict": "Le raccourci par défaut entre en conflit avec une affectation personnalisée.", + "shortcutCurrentBinding": "Raccourci actuel", + "shortcutProposedBinding": "Raccourci proposé" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4c9b5781..9a08d875 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "तारीख", "pdfTitlePageTitleRequired": "शीर्षक पृष्ठ के लिए शीर्षक दर्ज करें।", "pdfTocSectionNote": "अलग पृष्ठ से शुरू होता है और कई पृष्ठों तक जा सकता है।", - "sourceSearchZeroLengthUnsupported": "शून्य लंबाई वाले रेगेक्स मिलान समर्थित नहीं हैं (जैसे ^, $ या केवल स्थिति से मेल खाने वाले लुकअहेड)।", "workspaceSearchIncomplete": "केवल कुछ परिणाम दिखाए गए हैं। अधिक परिणाम देखें या खोज को सीमित करें।", "workspaceSearchShowMore": "अधिक परिणाम दिखाएँ", "workspaceSearchSkippedFiles": "खोज अधूरी है। ये फ़ाइलें 1 MiB से बड़ी हैं या पढ़ी नहीं जा सकीं:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "यह कीबोर्ड शॉर्टकट संदर्भ दिखाएँ", + "shortcutKeyboardShortcutsDescription": "अनुप्रयोग के कीबोर्ड शॉर्टकट अनुकूलित करें", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "हालिया कार्यस्थान साफ़ करें", + "clearRecentWorkspaces": "हाल का इतिहास साफ़ करें", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "बड़े-छोटे अक्षरों के प्रति संवेदनशील", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "पूरा शब्द", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "रेगुलर एक्सप्रेशन", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "अमान्य रेगुलर एक्सप्रेशन", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "बड़ी फ़ाइल: हाइलाइटिंग और फ़ोल्डिंग अस्थायी रूप से रुकी हुई हैं", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "लिंक टेक्स्ट खाली है; उसके उद्देश्य का वर्णन करने वाला सुलभ नाम दें।", "diagnosticMarkdownLinkReviewText": "जाँचें कि लिंक टेक्स्ट “{text}” संदर्भ में उसके उद्देश्य का वर्णन करता है या नहीं।", "diagnosticMarkdownTableEmptyHeader": "तालिका शीर्षकों को अपने कॉलम पहचानने चाहिए; हर खाली शीर्षक पूरा करें।", - "commandPalette": "कमांड पैलेट", - "commandPaletteHint": "कमांड लिखें", - "commandPaletteEmpty": "कोई मेल खाता कमांड नहीं", "tableAlignmentUnspecified": "संरेखण: अनिर्दिष्ट", "tableAlignmentLeft": "संरेखण: बायाँ", "tableAlignmentCenter": "संरेखण: मध्य", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "256 KiB तक की पढ़ने योग्य UTF-8 .css फ़ाइल चुनें, जिसमें इंपोर्ट, संसाधन URL, HTML और निष्पादन योग्य या एस्केप वाला CSS न हो।", "exportInvalidRange": "{field}: {minimum} से {maximum} के बीच संख्या दर्ज करें।", "clipboardHistory": "क्लिपबोर्ड इतिहास", - "clipboardHistorySearchHint": "क्लिपबोर्ड इतिहास खोजें", "clipboardHistoryDescription": "सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।", "clipboardCurrent": "वर्तमान क्लिपबोर्ड", "clipboardCurrentExternal": "किसी अन्य एप्लिकेशन से वर्तमान क्लिपबोर्ड", "clipboardDestination": "{name} में पेस्ट करें", "clipboardSessionOnly": "केवल इस BusyMark सत्र के लिए संग्रहीत।", "clipboardNoItems": "क्लिपबोर्ड इतिहास खाली है।", - "clipboardNoMatches": "कोई क्लिपबोर्ड आइटम इस खोज से मेल नहीं खाता।", "clipboardDisabled": "क्लिपबोर्ड इतिहास संग्रह सेटिंग्स में अक्षम है।", "clipboardUnavailable": "वर्तमान क्लिपबोर्ड सामग्री अनुपलब्ध या समर्थित नहीं है।", "clipboardTooLarge": "यह क्लिपबोर्ड आइटम बहुत बड़ा है और इसे बनाए रखना संभव नहीं है।", @@ -3349,8 +3327,6 @@ "findLocalHistoryDescription": "स्थानीय इतिहास में दस्तावेज़ और संशोधन सामग्री खोजें।", "localHistoryNoDocuments": "अभी तक किसी भी दस्तावेज़ में स्थानीय इतिहास नहीं है।", "localHistoryNoRevisions": "इस दस्तावेज़ में स्थानीय इतिहास में कोई संशोधन नहीं है।", - "localHistoryNoMatches": "यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।", - "localHistorySearchHint": "स्थानीय इतिहास खोजें", "localHistorySelectedRevision": "चयनित संशोधन", "localHistoryCurrentEditor": "वर्तमान संपादक सामग्री", "localHistoryCurrentDisk": "डिस्क पर वर्तमान सामग्री", @@ -3610,5 +3586,61 @@ "wsInsertElements": "तत्व जोड़ें", "wsAdmonitionsAndQuotes": "सूचनाएँ और उद्धरण", "wsSemanticText": "अर्थपूर्ण पाठ", - "wsPropertiesUnavailable": "जाँचा गया तत्व उपलब्ध नहीं है। जारी रखने के लिए कोई तत्व जाँचें।" + "wsPropertiesUnavailable": "जाँचा गया तत्व उपलब्ध नहीं है। जारी रखने के लिए कोई तत्व जाँचें।", + "searchDocumentsCategory": "दस्तावेज़", + "searchTopicsCategory": "विषय", + "searchCurrentDocument": "वर्तमान दस्तावेज़", + "searchCurrentWorkspace": "वर्तमान कार्यक्षेत्र", + "browseNotes": "नोट ब्राउज़ करें", + "searchActionsCommand": "क्रियाएँ खोजें", + "searchDocumentsCommand": "दस्तावेज़ खोजें", + "searchTopicsCommand": "विषय खोजें", + "searchRecentCommand": "हाल के आइटम खोजें", + "searchResourceUnavailable": "यह संसाधन उपलब्ध नहीं है।", + "notesSync": "नोट सिंक करें", + "searchInWorkspace": "कार्यस्थान में खोजें", + "searchCategory": "खोज श्रेणी", + "clearHistorySearchScope": "दस्तावेज़ों के इतिहास में खोजें", + "searchInDocument": "दस्तावेज़ में खोजें", + "searchDocumentHint": "इस दस्तावेज़ में खोजें…", + "searchWorkspaceHint": "इस कार्यक्षेत्र में खोजें…", + "searchOutlineHint": "शीर्षक खोजें…", + "searchHistoryHint": "स्थानीय इतिहास में खोजें…", + "searchEnterText": "खोजने के लिए पाठ दर्ज करें", + "searchAccountUnavailable": "खाता उपलब्ध नहीं है", + "searchDocumentsHint": "दस्तावेज़ खोजें…", + "searchTopicsHint": "विषय खोजें…", + "searchRecentHint": "हाल के आइटम खोजें…", + "searchActionsHint": "क्रियाएँ खोजें…", + "searchRequiresNotesWorkspace": "नोट्स कार्यक्षेत्र आवश्यक है", + "searchSidebarLabel": "खोजें:", + "searchDocumentTextCategory": "दस्तावेज़ पाठ", + "searchWorkspaceTextCategory": "कार्यस्थान पाठ", + "searchHeadingsCategory": "शीर्षक", + "shortcutUnassigned": "असाइन नहीं किया गया", + "shortcutPressKeys": "कुंजी संयोजन दबाएँ…", + "shortcutRecord": "शॉर्टकट रिकॉर्ड करें", + "shortcutRestoreAll": "सभी डिफ़ॉल्ट पुनर्स्थापित करें", + "shortcutRestoreAllQuestion": "सभी क्रियाओं के डिफ़ॉल्ट शॉर्टकट पुनर्स्थापित करें? कस्टम असाइनमेंट हटा दिए जाएँगे।", + "shortcutSaveFailed": "शॉर्टकट सक्रिय हैं लेकिन सहेजे नहीं जा सके। पुनः प्रारंभ के बाद रखने के लिए दोबारा सहेजें।", + "shortcutRetry": "सहेजने का पुनः प्रयास करें", + "shortcutChanged": "असाइनमेंट बदल गए हैं। वर्तमान शॉर्टकट जाँचें और पुनः प्रयास करें।", + "shortcutInvalid": "इस संदर्भ में यह संयोजन असाइन नहीं किया जा सकता।", + "shortcutSystemWarning": "आपका डेस्कटॉप इस संयोजन का उपयोग कर सकता है। BusyMark सिस्टम शॉर्टकट नहीं बदल सकता।", + "shortcutConflicts": "यह शॉर्टकट इन क्रियाओं से टकराता है:", + "shortcutRemove": "असाइनमेंट हटाएँ", + "shortcutRestoreDefault": "डिफ़ॉल्ट पुनर्स्थापित करें", + "shortcutAssign": "असाइन करें", + "shortcutReassign": "फिर से असाइन करें", + "shortcutContextApplication": "अनुप्रयोग", + "shortcutContextDocument": "दस्तावेज़ दृश्य", + "shortcutContextEditor": "संपादक और स्रोत", + "shortcutContextWritersideSource": "Writerside स्रोत", + "shortcutContextWorkspace": "कार्यस्थान नेविगेशन", + "shortcutContextTree": "साइडबार ट्री", + "searchHeadingsCommand": "शीर्षक खोजें", + "shortcutFixed": "संपादक द्वारा प्रबंधित; कॉन्फ़िगर नहीं किया जा सकता।", + "shortcutDefaultConflict": "डिफ़ॉल्ट शॉर्टकट का कस्टम असाइनमेंट से टकराव है।", + "shortcutCurrentBinding": "वर्तमान शॉर्टकट", + "shortcutProposedBinding": "प्रस्तावित शॉर्टकट" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 3441e636..2649c65d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Tanggal", "pdfTitlePageTitleRequired": "Masukkan judul untuk halaman judul.", "pdfTocSectionNote": "Dimulai pada halaman terpisah dan dapat mencakup beberapa halaman.", - "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:", @@ -423,18 +422,6 @@ "@keyboardShortcuts": { "description": "Keyboard shortcuts dialog title and menu item." }, - "commandPalette": "Palet perintah", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "Ketikkan perintah", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "Tidak ada perintah yang cocok", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "Tidak tersedia dalam konteks editor saat ini", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Tampilkan referensi pintasan keyboard ini", + "shortcutKeyboardShortcutsDescription": "Sesuaikan pintasan keyboard aplikasi", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Hapus ruang kerja terbaru", + "clearRecentWorkspaces": "Hapus riwayat terbaru", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Peka huruf besar-kecil", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Seluruh kata", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Regex", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "Ganti dengan", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "Ekspresi reguler tidak valid", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "File besar: penyorotan dan pelipatan dijeda", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "Pilih berkas .css UTF-8 yang dapat dibaca hingga 256 KiB, tanpa impor, URL sumber daya, HTML, atau CSS yang dapat dieksekusi atau memakai escape.", "exportInvalidRange": "{field}: masukkan angka antara {minimum} dan {maximum}.", "clipboardHistory": "Sejarah Tempel", - "clipboardHistorySearchHint": "Cari riwayat papan klip", "clipboardHistoryDescription": "Buka riwayat tempel lokal sesi.", "clipboardCurrent": "Tempel saat ini", "clipboardCurrentExternal": "Tempel saat ini dari aplikasi lain", "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.", @@ -4888,8 +4857,6 @@ "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", @@ -5149,5 +5116,61 @@ "wsInsertElements": "Sisipkan elemen", "wsAdmonitionsAndQuotes": "Pemberitahuan dan kutipan", "wsSemanticText": "Teks semantik", - "wsPropertiesUnavailable": "Elemen yang diperiksa tidak tersedia. Periksa elemen untuk melanjutkan." + "wsPropertiesUnavailable": "Elemen yang diperiksa tidak tersedia. Periksa elemen untuk melanjutkan.", + "searchDocumentsCategory": "Dokumen", + "searchTopicsCategory": "Topik", + "searchCurrentDocument": "Dokumen saat ini", + "searchCurrentWorkspace": "Ruang kerja saat ini", + "browseNotes": "Jelajahi catatan", + "searchActionsCommand": "Cari tindakan", + "searchDocumentsCommand": "Cari dokumen", + "searchTopicsCommand": "Cari topik", + "searchRecentCommand": "Cari terbaru", + "searchResourceUnavailable": "Sumber daya ini tidak tersedia.", + "notesSync": "Sinkronkan catatan", + "searchInWorkspace": "Cari dalam Ruang Kerja", + "searchCategory": "Kategori pencarian", + "clearHistorySearchScope": "Cari riwayat seluruh dokumen", + "searchInDocument": "Cari dalam dokumen", + "searchDocumentHint": "Cari dalam dokumen ini…", + "searchWorkspaceHint": "Cari dalam ruang kerja ini…", + "searchOutlineHint": "Cari judul bagian…", + "searchHistoryHint": "Cari dalam riwayat lokal…", + "searchEnterText": "Masukkan teks untuk mencari", + "searchAccountUnavailable": "Akun tidak tersedia", + "searchDocumentsHint": "Cari dokumen…", + "searchTopicsHint": "Cari topik…", + "searchRecentHint": "Cari terbaru…", + "searchActionsHint": "Cari tindakan…", + "searchRequiresNotesWorkspace": "Memerlukan ruang kerja Catatan", + "searchSidebarLabel": "Cari:", + "searchDocumentTextCategory": "Teks dokumen", + "searchWorkspaceTextCategory": "Teks ruang kerja", + "searchHeadingsCategory": "Judul", + "shortcutUnassigned": "Belum ditetapkan", + "shortcutPressKeys": "Tekan kombinasi tombol…", + "shortcutRecord": "Rekam pintasan", + "shortcutRestoreAll": "Pulihkan semua bawaan", + "shortcutRestoreAllQuestion": "Pulihkan pintasan bawaan semua tindakan? Penetapan khusus akan dihapus.", + "shortcutSaveFailed": "Pintasan aktif tetapi tidak dapat disimpan. Coba lagi agar tetap tersedia setelah mulai ulang.", + "shortcutRetry": "Coba simpan lagi", + "shortcutChanged": "Penetapan berubah. Tinjau pintasan saat ini dan coba lagi.", + "shortcutInvalid": "Kombinasi ini tidak dapat ditetapkan dalam konteks ini.", + "shortcutSystemWarning": "Desktop Anda mungkin memakai kombinasi ini. BusyMark tidak dapat mengganti pintasan sistem.", + "shortcutConflicts": "Pintasan ini bertentangan dengan tindakan berikut:", + "shortcutRemove": "Hapus penetapan", + "shortcutRestoreDefault": "Pulihkan bawaan", + "shortcutAssign": "Tetapkan", + "shortcutReassign": "Tetapkan ulang", + "shortcutContextApplication": "Aplikasi", + "shortcutContextDocument": "Tampilan dokumen", + "shortcutContextEditor": "Editor dan sumber", + "shortcutContextWritersideSource": "Sumber Writerside", + "shortcutContextWorkspace": "Navigasi ruang kerja", + "shortcutContextTree": "Pohon bilah samping", + "searchHeadingsCommand": "Cari judul", + "shortcutFixed": "Dikelola oleh editor; tidak dapat dikonfigurasi.", + "shortcutDefaultConflict": "Pintasan bawaan bertentangan dengan penetapan khusus.", + "shortcutCurrentBinding": "Pintasan saat ini", + "shortcutProposedBinding": "Pintasan yang diusulkan" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d88067e9..ed6e899e 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Data", "pdfTitlePageTitleRequired": "Inserisci un titolo per il frontespizio.", "pdfTocSectionNote": "Inizia su una pagina separata e può occupare più pagine.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Mostra il riferimento alle scorciatoie da tastiera", + "shortcutKeyboardShortcutsDescription": "Personalizza le scorciatoie da tastiera dell’applicazione", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Cancella le aree di lavoro recenti", + "clearRecentWorkspaces": "Cancella la cronologia recente", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Maiuscole/minuscole", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Parola intera", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Espressione regolare", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Espressione regolare non valida", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "File di grandi dimensioni: evidenziazione e ripiegamento sono sospesi", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "Il testo del collegamento è vuoto; fornisci un nome accessibile che ne descriva lo scopo.", "diagnosticMarkdownLinkReviewText": "Verifica se il testo del collegamento “{text}” ne descrive lo scopo nel contesto.", "diagnosticMarkdownTableEmptyHeader": "Le intestazioni della tabella devono identificare le colonne; completa ogni intestazione vuota.", - "commandPalette": "Tavolozza dei comandi", - "commandPaletteHint": "Digita un comando", - "commandPaletteEmpty": "Nessun comando corrispondente", "tableAlignmentUnspecified": "Allineamento: non specificato", "tableAlignmentLeft": "Allineamento: sinistra", "tableAlignmentCenter": "Allineamento: centro", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Scegli un file .css UTF-8 leggibile fino a 256 KiB, senza importazioni, URL di risorse, HTML o CSS eseguibile o con escape.", "exportInvalidRange": "{field}: inserisci un numero tra {minimum} e {maximum}.", "clipboardHistory": "Cronologia della Copia", - "clipboardHistorySearchHint": "Cerca nella cronologia degli appunti", "clipboardHistoryDescription": "Apri la cronologia della copia locale della sessione.", "clipboardCurrent": "Copia corrente", "clipboardCurrentExternal": "Copia corrente da un'altra applicazione", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Inserisci elementi", "wsAdmonitionsAndQuotes": "Avvisi e citazioni", "wsSemanticText": "Testo semantico", - "wsPropertiesUnavailable": "L’elemento esaminato non è disponibile. Esamina un elemento per continuare." + "wsPropertiesUnavailable": "L’elemento esaminato non è disponibile. Esamina un elemento per continuare.", + "searchDocumentsCategory": "Documenti", + "searchTopicsCategory": "Argomenti", + "searchCurrentDocument": "Documento corrente", + "searchCurrentWorkspace": "Area di lavoro corrente", + "browseNotes": "Sfoglia note", + "searchActionsCommand": "Cerca azioni", + "searchDocumentsCommand": "Cerca documenti", + "searchTopicsCommand": "Cerca argomenti", + "searchRecentCommand": "Cerca recenti", + "searchResourceUnavailable": "Questa risorsa non è disponibile.", + "notesSync": "Sincronizza note", + "searchInWorkspace": "Cerca nello spazio di lavoro", + "searchCategory": "Categoria di ricerca", + "clearHistorySearchScope": "Cerca nella cronologia dei documenti", + "searchInDocument": "Cerca nel documento", + "searchDocumentHint": "Cerca in questo documento…", + "searchWorkspaceHint": "Cerca in questo spazio di lavoro…", + "searchOutlineHint": "Cerca titoli…", + "searchHistoryHint": "Cerca nella cronologia locale…", + "searchEnterText": "Inserisci il testo da cercare", + "searchAccountUnavailable": "Account non disponibile", + "searchDocumentsHint": "Cerca documenti…", + "searchTopicsHint": "Cerca argomenti…", + "searchRecentHint": "Cerca recenti…", + "searchActionsHint": "Cerca azioni…", + "searchRequiresNotesWorkspace": "Richiede uno spazio di lavoro Note", + "searchSidebarLabel": "Ricerca:", + "searchDocumentTextCategory": "Testo del documento", + "searchWorkspaceTextCategory": "Testo dell’area di lavoro", + "searchHeadingsCategory": "Intestazioni", + "shortcutUnassigned": "Non assegnata", + "shortcutPressKeys": "Premi una combinazione di tasti…", + "shortcutRecord": "Acquisisci scorciatoia", + "shortcutRestoreAll": "Ripristina tutte le impostazioni predefinite", + "shortcutRestoreAllQuestion": "Ripristinare le scorciatoie predefinite di tutte le azioni? Le assegnazioni personalizzate verranno rimosse.", + "shortcutSaveFailed": "Le scorciatoie sono attive ma non sono state salvate. Riprova per mantenerle dopo il riavvio.", + "shortcutRetry": "Riprova salvataggio", + "shortcutChanged": "Le assegnazioni sono cambiate. Controlla le scorciatoie attuali e riprova.", + "shortcutInvalid": "Questa combinazione non può essere assegnata in questo contesto.", + "shortcutSystemWarning": "Questa combinazione potrebbe essere usata dal desktop. BusyMark non può sostituire le scorciatoie di sistema.", + "shortcutConflicts": "Questa scorciatoia è in conflitto con queste azioni:", + "shortcutRemove": "Rimuovi assegnazione", + "shortcutRestoreDefault": "Ripristina predefinita", + "shortcutAssign": "Assegna", + "shortcutReassign": "Riassegna", + "shortcutContextApplication": "Applicazione", + "shortcutContextDocument": "Viste del documento", + "shortcutContextEditor": "Editor e sorgente", + "shortcutContextWritersideSource": "Sorgente Writerside", + "shortcutContextWorkspace": "Navigazione dell’area di lavoro", + "shortcutContextTree": "Alberi laterali", + "searchHeadingsCommand": "Cerca titoli", + "shortcutFixed": "Gestito dall’editor; non configurabile.", + "shortcutDefaultConflict": "La scorciatoia predefinita è in conflitto con un’assegnazione personalizzata.", + "shortcutCurrentBinding": "Scorciatoia attuale", + "shortcutProposedBinding": "Scorciatoia proposta" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1937b115..81f53078 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "日付", "pdfTitlePageTitleRequired": "タイトルページのタイトルを入力してください。", "pdfTocSectionNote": "別のページから始まり、複数ページにわたる場合があります。", - "sourceSearchZeroLengthUnsupported": "長さがゼロの正規表現の一致には対応していません(^、$、位置のみに一致する先読みなど)。", "workspaceSearchIncomplete": "一部の結果のみ表示しています。さらに表示するか、検索を絞り込んでください。", "workspaceSearchShowMore": "さらに結果を表示", "workspaceSearchSkippedFiles": "検索は未完了です。次のファイルは 1 MiB を超えているか、読み取れませんでした:", @@ -420,18 +419,6 @@ "description": "Insert button label." }, "keyboardShortcuts": "キーボードショートカット", - "commandPalette": "コマンドパレット", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "コマンドを入力", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "一致するコマンドはありません", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "現在のエディターのコンテキストでは利用できません", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "このキーボードショートカット一覧を表示", + "shortcutKeyboardShortcutsDescription": "アプリケーションのキーボードショートカットを設定", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "最近使ったワークスペースを消去", + "clearRecentWorkspaces": "最近の履歴を消去", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "大文字と小文字を区別", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "単語単位", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "正規表現", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "置換後", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "無効な正規表現", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "大きなファイル:ハイライトと折りたたみを一時停止しています", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "読み取り可能な UTF-8 .css ファイル(最大 256 KiB)を選択してください。インポート、リソース URL、HTML、実行可能な CSS、エスケープを含む CSS は使用できません。", "exportInvalidRange": "{field}: {minimum} から {maximum} の数値を入力してください。", "clipboardHistory": "コピーボード履歴", - "clipboardHistorySearchHint": "クリップボード履歴を検索", "clipboardHistoryDescription": "セッション固有のコピーボード履歴を開く。", "clipboardCurrent": "現在のコピーボード", "clipboardCurrentExternal": "別のアプリケーションからの現在のコピーボード", "clipboardDestination": "{name} に貼り付け", "clipboardSessionOnly": "このBusyMarkセッションのみに保存されます。", "clipboardNoItems": "コピーボード履歴は空です。", - "clipboardNoMatches": "この検索に一致するコピーボードの項目はありません。", "clipboardDisabled": "コピーボード履歴のコレクションは設定で無効になっています。", "clipboardUnavailable": "現在のコピーボードの内容は利用できませんまたはサポートされていません。", "clipboardTooLarge": "このクリップボードの項目は大きすぎて保持できません。", @@ -4888,8 +4857,6 @@ "findLocalHistoryDescription": "ローカル履歴のドキュメントとリビジョンの内容を検索します。", "localHistoryNoDocuments": "まだローカル履歴を持つドキュメントはありません。", "localHistoryNoRevisions": "このドキュメントにはローカル履歴の修正はありません。", - "localHistoryNoMatches": "この検索に一致するローカル履歴の修正はありません。", - "localHistorySearchHint": "ローカル履歴を検索", "localHistorySelectedRevision": "選択されたリビジョン", "localHistoryCurrentEditor": "現在の編集内容", "localHistoryCurrentDisk": "ディスク上の現在の内容", @@ -5149,5 +5116,61 @@ "wsInsertElements": "要素を挿入", "wsAdmonitionsAndQuotes": "注意書きと引用", "wsSemanticText": "セマンティックテキスト", - "wsPropertiesUnavailable": "確認中の要素は利用できません。続行するには要素を確認してください。" + "wsPropertiesUnavailable": "確認中の要素は利用できません。続行するには要素を確認してください。", + "searchDocumentsCategory": "ドキュメント", + "searchTopicsCategory": "トピック", + "searchCurrentDocument": "現在のドキュメント", + "searchCurrentWorkspace": "現在のワークスペース", + "browseNotes": "ノートを参照", + "searchActionsCommand": "アクションを検索", + "searchDocumentsCommand": "ドキュメントを検索", + "searchTopicsCommand": "トピックを検索", + "searchRecentCommand": "最近の項目を検索", + "searchResourceUnavailable": "このリソースは利用できません。", + "notesSync": "ノートを同期", + "searchInWorkspace": "ワークスペース内検索", + "searchCategory": "検索カテゴリ", + "clearHistorySearchScope": "すべてのドキュメントの履歴を検索", + "searchInDocument": "ドキュメント内を検索", + "searchDocumentHint": "このドキュメントを検索…", + "searchWorkspaceHint": "このワークスペースを検索…", + "searchOutlineHint": "見出しを検索…", + "searchHistoryHint": "ローカル履歴を検索…", + "searchEnterText": "検索するテキストを入力", + "searchAccountUnavailable": "アカウントを利用できません", + "searchDocumentsHint": "ドキュメントを検索…", + "searchTopicsHint": "トピックを検索…", + "searchRecentHint": "最近の項目を検索…", + "searchActionsHint": "アクションを検索…", + "searchRequiresNotesWorkspace": "ノートのワークスペースが必要です", + "searchSidebarLabel": "検索:", + "searchDocumentTextCategory": "文書のテキスト", + "searchWorkspaceTextCategory": "ワークスペースのテキスト", + "searchHeadingsCategory": "見出し", + "shortcutUnassigned": "未割り当て", + "shortcutPressKeys": "キーの組み合わせを押してください…", + "shortcutRecord": "ショートカットを記録", + "shortcutRestoreAll": "すべて既定に戻す", + "shortcutRestoreAllQuestion": "すべてのアクションのショートカットを既定に戻しますか?独自の割り当ては削除されます。", + "shortcutSaveFailed": "ショートカットは有効ですが保存できませんでした。再起動後も保持するには保存を再試行してください。", + "shortcutRetry": "保存を再試行", + "shortcutChanged": "割り当てが変更されました。現在のショートカットを確認して再試行してください。", + "shortcutInvalid": "この組み合わせはこのコンテキストでは割り当てられません。", + "shortcutSystemWarning": "この組み合わせはデスクトップで使われている可能性があります。BusyMarkはシステムのショートカットを上書きできません。", + "shortcutConflicts": "このショートカットは次のアクションと競合します:", + "shortcutRemove": "割り当てを削除", + "shortcutRestoreDefault": "既定に戻す", + "shortcutAssign": "割り当て", + "shortcutReassign": "再割り当て", + "shortcutContextApplication": "アプリケーション", + "shortcutContextDocument": "文書の表示", + "shortcutContextEditor": "エディターとソース", + "shortcutContextWritersideSource": "Writersideのソース", + "shortcutContextWorkspace": "ワークスペースの移動", + "shortcutContextTree": "サイドバーのツリー", + "searchHeadingsCommand": "見出しを検索", + "shortcutFixed": "エディターが管理するため、変更できません。", + "shortcutDefaultConflict": "既定のショートカットがカスタム割り当てと競合しています。", + "shortcutCurrentBinding": "現在のショートカット", + "shortcutProposedBinding": "新しいショートカット" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9920f6f7..c37940ae 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "날짜", "pdfTitlePageTitleRequired": "제목 페이지의 제목을 입력하세요.", "pdfTocSectionNote": "별도 페이지에서 시작하며 여러 페이지에 걸칠 수 있습니다.", - "sourceSearchZeroLengthUnsupported": "길이가 0인 정규식 일치는 지원하지 않습니다(예: ^, $, 위치에만 일치하는 전방 탐색).", "workspaceSearchIncomplete": "일부 결과만 표시됩니다. 더 표시하거나 검색 범위를 좁히세요.", "workspaceSearchShowMore": "결과 더 보기", "workspaceSearchSkippedFiles": "검색이 완료되지 않았습니다. 다음 파일은 1 MiB를 초과하거나 읽을 수 없습니다:", @@ -423,18 +422,6 @@ "@keyboardShortcuts": { "description": "Keyboard shortcuts dialog title and menu item." }, - "commandPalette": "명령 팔레트", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "명령을 입력하세요", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "일치하는 명령이 없습니다.", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "현재 편집기 컨텍스트에서는 사용할 수 없습니다.", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "이 키보드 단축키 참조 표시", + "shortcutKeyboardShortcutsDescription": "애플리케이션 키보드 단축키 사용자 지정", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "최근 작업공간 지우기", + "clearRecentWorkspaces": "최근 기록 지우기", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "대소문자 구분", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "전체 단어", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "정규식", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "다음으로 바꾸기", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "잘못된 정규식", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "대용량 파일: 강조 표시 및 접기가 일시 중지됩니다.", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "가져오기, 리소스 URL, HTML, 실행 가능하거나 이스케이프된 CSS가 없는 읽기 가능한 UTF-8 .css 파일(최대 256 KiB)을 선택하세요.", "exportInvalidRange": "{field}: {minimum}에서 {maximum} 사이의 숫자를 입력하세요.", "clipboardHistory": "클립보드 기록", - "clipboardHistorySearchHint": "클립보드 기록 검색", "clipboardHistoryDescription": "세션별 클립보드 기록을 엽니다.", "clipboardCurrent": "현재 클립보드", "clipboardCurrentExternal": "다른 애플리케이션의 현재 클립보드", "clipboardDestination": "{name}에 붙여넣기", "clipboardSessionOnly": "이 BusyMark 세션에만 저장됩니다.", "clipboardNoItems": "클립보드 기록이 비어 있습니다.", - "clipboardNoMatches": "이 검색과 일치하는 클립보드 항목이 없습니다.", "clipboardDisabled": "클립보드 기록 컬렉션이 설정에서 비활성화되었습니다.", "clipboardUnavailable": "현재 클립보드 내용은 사용할 수 없거나 지원되지 않습니다.", "clipboardTooLarge": "이 클립보드 항목은 저장할 수 없을 정도로 큽니다.", @@ -4888,8 +4857,6 @@ "findLocalHistoryDescription": "로컬 히스토리 문서 및 수정 내용 검색.", "localHistoryNoDocuments": "아직 로컬 히스토리가 있는 문서는 없습니다.", "localHistoryNoRevisions": "이 문서는 로컬 히스토리 수정 사항이 없습니다.", - "localHistoryNoMatches": "이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.", - "localHistorySearchHint": "로컬 기록 검색", "localHistorySelectedRevision": "선택된 수정", "localHistoryCurrentEditor": "현재 편집 내용", "localHistoryCurrentDisk": "디스크에 있는 현재 내용", @@ -5149,5 +5116,61 @@ "wsInsertElements": "요소 삽입", "wsAdmonitionsAndQuotes": "안내 및 인용", "wsSemanticText": "의미 텍스트", - "wsPropertiesUnavailable": "검사 중인 요소를 사용할 수 없습니다. 계속하려면 요소를 검사하세요." + "wsPropertiesUnavailable": "검사 중인 요소를 사용할 수 없습니다. 계속하려면 요소를 검사하세요.", + "searchDocumentsCategory": "문서", + "searchTopicsCategory": "주제", + "searchCurrentDocument": "현재 문서", + "searchCurrentWorkspace": "현재 작업 공간", + "browseNotes": "노트 찾아보기", + "searchActionsCommand": "작업 검색", + "searchDocumentsCommand": "문서 검색", + "searchTopicsCommand": "주제 검색", + "searchRecentCommand": "최근 항목 검색", + "searchResourceUnavailable": "이 리소스를 사용할 수 없습니다.", + "notesSync": "노트 동기화", + "searchInWorkspace": "작업 공간에서 검색", + "searchCategory": "검색 범주", + "clearHistorySearchScope": "문서 기록 전체 검색", + "searchInDocument": "문서에서 검색", + "searchDocumentHint": "이 문서에서 검색…", + "searchWorkspaceHint": "이 작업 공간에서 검색…", + "searchOutlineHint": "제목 검색…", + "searchHistoryHint": "로컬 기록 검색…", + "searchEnterText": "검색할 텍스트 입력", + "searchAccountUnavailable": "계정을 사용할 수 없음", + "searchDocumentsHint": "문서 검색…", + "searchTopicsHint": "주제 검색…", + "searchRecentHint": "최근 항목 검색…", + "searchActionsHint": "작업 검색…", + "searchRequiresNotesWorkspace": "노트 작업 공간 필요", + "searchSidebarLabel": "검색:", + "searchDocumentTextCategory": "문서 텍스트", + "searchWorkspaceTextCategory": "작업 공간 텍스트", + "searchHeadingsCategory": "제목", + "shortcutUnassigned": "할당되지 않음", + "shortcutPressKeys": "키 조합을 누르세요…", + "shortcutRecord": "단축키 기록", + "shortcutRestoreAll": "모든 기본값 복원", + "shortcutRestoreAllQuestion": "모든 동작의 기본 단축키를 복원할까요? 사용자 지정 할당이 제거됩니다.", + "shortcutSaveFailed": "단축키가 활성화되었지만 저장하지 못했습니다. 다시 시작 후에도 유지하려면 저장을 다시 시도하세요.", + "shortcutRetry": "저장 다시 시도", + "shortcutChanged": "할당이 변경되었습니다. 현재 단축키를 확인하고 다시 시도하세요.", + "shortcutInvalid": "이 조합은 이 문맥에서 할당할 수 없습니다.", + "shortcutSystemWarning": "데스크톱에서 이 조합을 사용할 수 있습니다. BusyMark는 시스템 단축키를 재정의할 수 없습니다.", + "shortcutConflicts": "이 단축키는 다음 동작과 충돌합니다:", + "shortcutRemove": "할당 제거", + "shortcutRestoreDefault": "기본값 복원", + "shortcutAssign": "할당", + "shortcutReassign": "다시 할당", + "shortcutContextApplication": "애플리케이션", + "shortcutContextDocument": "문서 보기", + "shortcutContextEditor": "편집기 및 소스", + "shortcutContextWritersideSource": "Writerside 소스", + "shortcutContextWorkspace": "작업 공간 탐색", + "shortcutContextTree": "사이드바 트리", + "searchHeadingsCommand": "제목 검색", + "shortcutFixed": "편집기에서 관리하므로 설정할 수 없습니다.", + "shortcutDefaultConflict": "기본 단축키가 사용자 지정 할당과 충돌합니다.", + "shortcutCurrentBinding": "현재 단축키", + "shortcutProposedBinding": "제안된 단축키" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index d3fa2c48..d53d1dce 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Dato", "pdfTitlePageTitleRequired": "Skriv inn en tittel for tittelsiden.", "pdfTocSectionNote": "Starter på en egen side og kan gå over flere sider.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Vis denne oversikten over tastatursnarveier", + "shortcutKeyboardShortcutsDescription": "Tilpass programmets hurtigtaster", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Tøm nylige arbeidsområder", + "clearRecentWorkspaces": "Tøm nylig historikk", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Skill mellom store og små bokstaver", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Hele ord", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Regulært uttrykk", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Ugyldig regulært uttrykk", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Stor fil: utheving og folding er satt på pause", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "Lenketeksten er tom. Oppgi et tilgjengelig navn som beskriver formålet.", "diagnosticMarkdownLinkReviewText": "Kontroller om lenketeksten «{text}» beskriver formålet i konteksten.", "diagnosticMarkdownTableEmptyHeader": "Tabelloverskrifter må identifisere kolonnene. Fyll ut alle tomme overskrifter.", - "commandPalette": "Kommandopalett", - "commandPaletteHint": "Skriv inn en kommando", - "commandPaletteEmpty": "Ingen samsvarende kommandoer", "tableAlignmentUnspecified": "Justering: ikke angitt", "tableAlignmentLeft": "Justering: venstre", "tableAlignmentCenter": "Justering: midtstilt", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Velg en lesbar UTF-8 .css-fil opptil 256 KiB uten importer, ressurs-URL-er, HTML eller kjørbar eller escaped CSS.", "exportInvalidRange": "{field}: skriv inn et tall mellom {minimum} og {maximum}.", "clipboardHistory": "Utklippshistorie", - "clipboardHistorySearchHint": "Søk i utklippstavlehistorikken", "clipboardHistoryDescription": "Åpne historien for utklippet som er lokal for sesjonen.", "clipboardCurrent": "Nåværende utklipp", "clipboardCurrentExternal": "Nåværende utklipp fra en annen applikasjon", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Sett inn elementer", "wsAdmonitionsAndQuotes": "Merknader og sitater", "wsSemanticText": "Semantisk tekst", - "wsPropertiesUnavailable": "Det inspiserte elementet er utilgjengelig. Inspiser et element for å fortsette." + "wsPropertiesUnavailable": "Det inspiserte elementet er utilgjengelig. Inspiser et element for å fortsette.", + "searchDocumentsCategory": "Dokumenter", + "searchTopicsCategory": "Emner", + "searchCurrentDocument": "Gjeldende dokument", + "searchCurrentWorkspace": "Gjeldende arbeidsområde", + "browseNotes": "Bla gjennom notater", + "searchActionsCommand": "Søk etter handlinger", + "searchDocumentsCommand": "Søk etter dokumenter", + "searchTopicsCommand": "Søk etter emner", + "searchRecentCommand": "Søk i nylige", + "searchResourceUnavailable": "Denne ressursen er utilgjengelig.", + "notesSync": "Synkroniser notater", + "searchInWorkspace": "Søk i arbeidsområde", + "searchCategory": "Søkekategori", + "clearHistorySearchScope": "Søk i dokumenthistorikken", + "searchInDocument": "Søk i dokument", + "searchDocumentHint": "Søk i dette dokumentet…", + "searchWorkspaceHint": "Søk i dette arbeidsområdet…", + "searchOutlineHint": "Søk i overskrifter…", + "searchHistoryHint": "Søk i lokal historikk…", + "searchEnterText": "Skriv inn tekst for å søke", + "searchAccountUnavailable": "Konto utilgjengelig", + "searchDocumentsHint": "Søk etter dokumenter…", + "searchTopicsHint": "Søk etter emner…", + "searchRecentHint": "Søk i nylige…", + "searchActionsHint": "Søk etter handlinger…", + "searchRequiresNotesWorkspace": "Krever et notatarbeidsområde", + "searchSidebarLabel": "Søk:", + "searchDocumentTextCategory": "Dokumenttekst", + "searchWorkspaceTextCategory": "Arbeidsområdetekst", + "searchHeadingsCategory": "Overskrifter", + "shortcutUnassigned": "Ikke tilordnet", + "shortcutPressKeys": "Trykk en tastekombinasjon…", + "shortcutRecord": "Registrer hurtigtast", + "shortcutRestoreAll": "Gjenopprett alle standarder", + "shortcutRestoreAllQuestion": "Gjenopprette standardhurtigtaster for alle handlinger? Egne tilordninger fjernes.", + "shortcutSaveFailed": "Hurtigtastene er aktive, men kunne ikke lagres. Prøv igjen for å beholde dem etter omstart.", + "shortcutRetry": "Prøv lagring igjen", + "shortcutChanged": "Tilordningene er endret. Se gjennom gjeldende hurtigtaster og prøv igjen.", + "shortcutInvalid": "Denne kombinasjonen kan ikke tilordnes i denne sammenhengen.", + "shortcutSystemWarning": "Skrivebordet kan bruke denne kombinasjonen. BusyMark kan ikke overstyre systemhurtigtaster.", + "shortcutConflicts": "Denne hurtigtasten overlapper disse handlingene:", + "shortcutRemove": "Fjern tilordning", + "shortcutRestoreDefault": "Gjenopprett standard", + "shortcutAssign": "Tilordne", + "shortcutReassign": "Tilordne på nytt", + "shortcutContextApplication": "Program", + "shortcutContextDocument": "Dokumentvisninger", + "shortcutContextEditor": "Redigering og kilde", + "shortcutContextWritersideSource": "Writerside-kilde", + "shortcutContextWorkspace": "Arbeidsområdenavigasjon", + "shortcutContextTree": "Sidefelttrær", + "searchHeadingsCommand": "Søk i overskrifter", + "shortcutFixed": "Håndteres av redigereren; kan ikke tilpasses.", + "shortcutDefaultConflict": "Standardhurtigtasten er i konflikt med en egendefinert tilordning.", + "shortcutCurrentBinding": "Gjeldende hurtigtast", + "shortcutProposedBinding": "Foreslått hurtigtast" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 5729425b..1ff57a90 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Datum", "pdfTitlePageTitleRequired": "Voer een titel voor de titelpagina in.", "pdfTocSectionNote": "Begint op een aparte pagina en kan meerdere pagina’s beslaan.", - "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:", @@ -423,18 +422,6 @@ "@keyboardShortcuts": { "description": "Keyboard shortcuts dialog title and menu item." }, - "commandPalette": "Commandopalet", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "Typ een opdracht", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "Geen overeenkomende opdrachten", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "Niet beschikbaar in de huidige editorcontext", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Toon deze sneltoetsreferentie", + "shortcutKeyboardShortcutsDescription": "Sneltoetsen van de toepassing aanpassen", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Wis recente werkruimten", + "clearRecentWorkspaces": "Recente geschiedenis wissen", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Hoofdlettergevoelig", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Heel woord", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Regex", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "Vervangen door", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "Ongeldige reguliere expressie", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Groot bestand: markeren en vouwen worden gepauzeerd", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "Kies een leesbaar UTF-8 .css-bestand tot 256 KiB zonder imports, bron-URL’s, HTML of uitvoerbare of ontsnapte CSS.", "exportInvalidRange": "{field}: voer een getal tussen {minimum} en {maximum} in.", "clipboardHistory": "Historie van de klembord", - "clipboardHistorySearchHint": "Klembordgeschiedenis doorzoeken", "clipboardHistoryDescription": "Open de klembordgeschiedenis per sessie.", "clipboardCurrent": "Huidig klembord", "clipboardCurrentExternal": "Huidig klembord van een ander programma", "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.", @@ -4888,8 +4857,6 @@ "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", @@ -5149,5 +5116,61 @@ "wsInsertElements": "Elementen invoegen", "wsAdmonitionsAndQuotes": "Opmerkingen en citaten", "wsSemanticText": "Semantische tekst", - "wsPropertiesUnavailable": "Het geïnspecteerde element is niet beschikbaar. Inspecteer een element om door te gaan." + "wsPropertiesUnavailable": "Het geïnspecteerde element is niet beschikbaar. Inspecteer een element om door te gaan.", + "searchDocumentsCategory": "Documenten", + "searchTopicsCategory": "Onderwerpen", + "searchCurrentDocument": "Huidig document", + "searchCurrentWorkspace": "Huidige werkruimte", + "browseNotes": "Notities bekijken", + "searchActionsCommand": "Acties zoeken", + "searchDocumentsCommand": "Documenten zoeken", + "searchTopicsCommand": "Onderwerpen zoeken", + "searchRecentCommand": "Recente items zoeken", + "searchResourceUnavailable": "Deze bron is niet beschikbaar.", + "notesSync": "Notities synchroniseren", + "searchInWorkspace": "Zoeken in werkruimte", + "searchCategory": "Zoekcategorie", + "clearHistorySearchScope": "Zoeken in documentgeschiedenis", + "searchInDocument": "Zoeken in document", + "searchDocumentHint": "Dit document doorzoeken…", + "searchWorkspaceHint": "Deze werkruimte doorzoeken…", + "searchOutlineHint": "Koppen zoeken…", + "searchHistoryHint": "Lokale geschiedenis doorzoeken…", + "searchEnterText": "Voer tekst in om te zoeken", + "searchAccountUnavailable": "Account niet beschikbaar", + "searchDocumentsHint": "Documenten zoeken…", + "searchTopicsHint": "Onderwerpen zoeken…", + "searchRecentHint": "Recente items zoeken…", + "searchActionsHint": "Acties zoeken…", + "searchRequiresNotesWorkspace": "Vereist een notitiewerkruimte", + "searchSidebarLabel": "Zoeken:", + "searchDocumentTextCategory": "Documenttekst", + "searchWorkspaceTextCategory": "Werkruimtetekst", + "searchHeadingsCategory": "Koppen", + "shortcutUnassigned": "Niet toegewezen", + "shortcutPressKeys": "Druk op een toetscombinatie…", + "shortcutRecord": "Sneltoets opnemen", + "shortcutRestoreAll": "Alle standaardwaarden herstellen", + "shortcutRestoreAllQuestion": "Standaardsneltoetsen van alle acties herstellen? Aangepaste toewijzingen worden verwijderd.", + "shortcutSaveFailed": "Sneltoetsen zijn actief, maar konden niet worden opgeslagen. Probeer opnieuw om ze na herstart te behouden.", + "shortcutRetry": "Opslaan opnieuw proberen", + "shortcutChanged": "Toewijzingen zijn gewijzigd. Controleer de huidige sneltoetsen en probeer opnieuw.", + "shortcutInvalid": "Deze combinatie kan in deze context niet worden toegewezen.", + "shortcutSystemWarning": "Deze combinatie wordt mogelijk door uw bureaublad gebruikt. BusyMark kan systeemsneltoetsen niet overschrijven.", + "shortcutConflicts": "Deze sneltoets overlapt met deze acties:", + "shortcutRemove": "Toewijzing verwijderen", + "shortcutRestoreDefault": "Standaard herstellen", + "shortcutAssign": "Toewijzen", + "shortcutReassign": "Opnieuw toewijzen", + "shortcutContextApplication": "Toepassing", + "shortcutContextDocument": "Documentweergaven", + "shortcutContextEditor": "Editor en bron", + "shortcutContextWritersideSource": "Writerside-bron", + "shortcutContextWorkspace": "Werkruimtenavigatie", + "shortcutContextTree": "Zijbalkbomen", + "searchHeadingsCommand": "Koppen zoeken", + "shortcutFixed": "Beheerd door de editor; niet instelbaar.", + "shortcutDefaultConflict": "De standaardsneltoets conflicteert met een aangepaste toewijzing.", + "shortcutCurrentBinding": "Huidige sneltoets", + "shortcutProposedBinding": "Voorgestelde sneltoets" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d481fa81..a6745499 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Data", "pdfTitlePageTitleRequired": "Wpisz tytuł strony tytułowej.", "pdfTocSectionNote": "Zaczyna się na osobnej stronie i może zajmować wiele stron.", - "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ć:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Pokaż ten wykaz skrótów klawiaturowych", + "shortcutKeyboardShortcutsDescription": "Dostosuj skróty klawiaturowe aplikacji", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Wyczyść listę ostatnich obszarów roboczych", + "clearRecentWorkspaces": "Wyczyść ostatnią historię", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Uwzględniaj wielkość liter", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Całe słowo", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Wyrażenie regularne", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Nieprawidłowe wyrażenie regularne", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Duży plik: podświetlanie i zwijanie są wstrzymane", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3234,9 +3217,6 @@ "diagnosticMarkdownLinkEmptyText": "Tekst odnośnika jest pusty; podaj dostępną nazwę opisującą jego cel.", "diagnosticMarkdownLinkReviewText": "Sprawdź, czy tekst odnośnika „{text}” opisuje jego cel w kontekście.", "diagnosticMarkdownTableEmptyHeader": "Nagłówki tabeli muszą identyfikować kolumny; uzupełnij każdy pusty nagłówek.", - "commandPalette": "Paleta poleceń", - "commandPaletteHint": "Wpisz polecenie", - "commandPaletteEmpty": "Brak pasujących poleceń", "tableAlignmentUnspecified": "Wyrównanie: nieokreślone", "tableAlignmentLeft": "Wyrównanie: do lewej", "tableAlignmentCenter": "Wyrównanie: do środka", @@ -3351,14 +3331,12 @@ "exportInvalidCss": "Wybierz czytelny plik .css UTF-8 do 256 KiB, bez importów, adresów zasobów, HTML oraz wykonywalnego CSS i sekwencji ucieczki.", "exportInvalidRange": "{field}: wpisz liczbę między {minimum} a {maximum}.", "clipboardHistory": "Historia w Koszyku", - "clipboardHistorySearchHint": "Przeszukaj historię schowka", "clipboardHistoryDescription": "Otwórz historię w koszyku lokalną dla sesji.", "clipboardCurrent": "Aktualny koszyk", "clipboardCurrentExternal": "Aktualny koszyk z innego programu", "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ć.", @@ -3375,8 +3353,6 @@ "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", @@ -3636,5 +3612,61 @@ "wsInsertElements": "Wstaw elementy", "wsAdmonitionsAndQuotes": "Uwagi i cytaty", "wsSemanticText": "Tekst semantyczny", - "wsPropertiesUnavailable": "Sprawdzany element jest niedostępny. Sprawdź element, aby kontynuować." + "wsPropertiesUnavailable": "Sprawdzany element jest niedostępny. Sprawdź element, aby kontynuować.", + "searchDocumentsCategory": "Dokumenty", + "searchTopicsCategory": "Tematy", + "searchCurrentDocument": "Bieżący dokument", + "searchCurrentWorkspace": "Bieżący obszar roboczy", + "browseNotes": "Przeglądaj notatki", + "searchActionsCommand": "Szukaj działań", + "searchDocumentsCommand": "Szukaj dokumentów", + "searchTopicsCommand": "Szukaj tematów", + "searchRecentCommand": "Szukaj ostatnich", + "searchResourceUnavailable": "Ten zasób jest niedostępny.", + "notesSync": "Synchronizuj notatki", + "searchInWorkspace": "Szukaj w obszarze roboczym", + "searchCategory": "Kategoria wyszukiwania", + "clearHistorySearchScope": "Szukaj w historii dokumentów", + "searchInDocument": "Szukaj w dokumencie", + "searchDocumentHint": "Przeszukaj ten dokument…", + "searchWorkspaceHint": "Przeszukaj ten obszar roboczy…", + "searchOutlineHint": "Szukaj nagłówków…", + "searchHistoryHint": "Przeszukaj historię lokalną…", + "searchEnterText": "Wpisz tekst do wyszukania", + "searchAccountUnavailable": "Konto niedostępne", + "searchDocumentsHint": "Szukaj dokumentów…", + "searchTopicsHint": "Szukaj tematów…", + "searchRecentHint": "Szukaj ostatnich…", + "searchActionsHint": "Szukaj działań…", + "searchRequiresNotesWorkspace": "Wymaga obszaru roboczego Notatek", + "searchSidebarLabel": "Szukaj:", + "searchDocumentTextCategory": "Tekst dokumentu", + "searchWorkspaceTextCategory": "Tekst obszaru roboczego", + "searchHeadingsCategory": "Nagłówki", + "shortcutUnassigned": "Nieprzypisany", + "shortcutPressKeys": "Naciśnij kombinację klawiszy…", + "shortcutRecord": "Zarejestruj skrót", + "shortcutRestoreAll": "Przywróć wszystkie domyślne", + "shortcutRestoreAllQuestion": "Przywrócić domyślne skróty wszystkich działań? Własne przypisania zostaną usunięte.", + "shortcutSaveFailed": "Skróty są aktywne, lecz nie udało się ich zapisać. Spróbuj ponownie, aby zachować je po restarcie.", + "shortcutRetry": "Ponów zapis", + "shortcutChanged": "Przypisania uległy zmianie. Sprawdź bieżące skróty i spróbuj ponownie.", + "shortcutInvalid": "Nie można przypisać tej kombinacji w tym kontekście.", + "shortcutSystemWarning": "Pulpit może używać tej kombinacji. BusyMark nie może zastępować skrótów systemowych.", + "shortcutConflicts": "Ten skrót koliduje z tymi działaniami:", + "shortcutRemove": "Usuń przypisanie", + "shortcutRestoreDefault": "Przywróć domyślny", + "shortcutAssign": "Przypisz", + "shortcutReassign": "Przypisz ponownie", + "shortcutContextApplication": "Aplikacja", + "shortcutContextDocument": "Widoki dokumentu", + "shortcutContextEditor": "Edytor i źródło", + "shortcutContextWritersideSource": "Źródło Writerside", + "shortcutContextWorkspace": "Nawigacja obszaru roboczego", + "shortcutContextTree": "Drzewa paska bocznego", + "searchHeadingsCommand": "Szukaj nagłówków", + "shortcutFixed": "Zarządzane przez edytor; nie można skonfigurować.", + "shortcutDefaultConflict": "Domyślny skrót koliduje z niestandardowym przypisaniem.", + "shortcutCurrentBinding": "Bieżący skrót", + "shortcutProposedBinding": "Proponowany skrót" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 8e5c7ee5..db505749 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Data", "pdfTitlePageTitleRequired": "Introduza um título para a página de título.", "pdfTocSectionNote": "Começa numa página separada e pode ocupar várias páginas.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos de teclado", + "shortcutKeyboardShortcutsDescription": "Personalizar os atalhos de teclado da aplicação", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Limpar espaços de trabalho recentes", + "clearRecentWorkspaces": "Limpar histórico recente", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Diferenciar maiúsculas de minúsculas", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Palavra inteira", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Expressão regular", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Expressão regular inválida", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Ficheiro grande: o realce e o recolhimento estão pausados", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "O texto da ligação está vazio; forneça um nome acessível que descreva a respetiva finalidade.", "diagnosticMarkdownLinkReviewText": "Verifique se o texto da ligação “{text}” descreve a respetiva finalidade no contexto.", "diagnosticMarkdownTableEmptyHeader": "Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.", - "commandPalette": "Paleta de comandos", - "commandPaletteHint": "Introduza um comando", - "commandPaletteEmpty": "Nenhum comando correspondente", "tableAlignmentUnspecified": "Alinhamento: não especificado", "tableAlignmentLeft": "Alinhamento: esquerda", "tableAlignmentCenter": "Alinhamento: centro", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Escolha um ficheiro .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", "exportInvalidRange": "{field}: introduza um número entre {minimum} e {maximum}.", "clipboardHistory": "Histórico da Área de Transferência", - "clipboardHistorySearchHint": "Pesquisar no histórico da área de transferência", "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", "clipboardCurrent": "Área de transferência atual", "clipboardCurrentExternal": "Área de transferência de outro aplicativo", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Inserir elementos", "wsAdmonitionsAndQuotes": "Avisos e citações", "wsSemanticText": "Texto semântico", - "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar." + "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar.", + "searchDocumentsCategory": "Documentos", + "searchTopicsCategory": "Tópicos", + "searchCurrentDocument": "Documento atual", + "searchCurrentWorkspace": "Área de trabalho atual", + "browseNotes": "Explorar notas", + "searchActionsCommand": "Pesquisar ações", + "searchDocumentsCommand": "Pesquisar documentos", + "searchTopicsCommand": "Pesquisar tópicos", + "searchRecentCommand": "Pesquisar recentes", + "searchResourceUnavailable": "Este recurso não está disponível.", + "notesSync": "Sincronizar notas", + "searchInWorkspace": "Pesquisar no espaço de trabalho", + "searchCategory": "Categoria de pesquisa", + "clearHistorySearchScope": "Pesquisar no histórico de documentos", + "searchInDocument": "Pesquisar no documento", + "searchDocumentHint": "Pesquisar neste documento…", + "searchWorkspaceHint": "Pesquisar neste espaço de trabalho…", + "searchOutlineHint": "Pesquisar títulos…", + "searchHistoryHint": "Pesquisar no histórico local…", + "searchEnterText": "Introduza texto para pesquisar", + "searchAccountUnavailable": "Conta indisponível", + "searchDocumentsHint": "Pesquisar documentos…", + "searchTopicsHint": "Pesquisar tópicos…", + "searchRecentHint": "Pesquisar recentes…", + "searchActionsHint": "Pesquisar ações…", + "searchRequiresNotesWorkspace": "Requer um espaço de trabalho de Notas", + "searchSidebarLabel": "Pesquisar:", + "searchDocumentTextCategory": "Texto do documento", + "searchWorkspaceTextCategory": "Texto do espaço de trabalho", + "searchHeadingsCategory": "Cabeçalhos", + "shortcutUnassigned": "Não atribuído", + "shortcutPressKeys": "Prima uma combinação de teclas…", + "shortcutRecord": "Capturar atalho", + "shortcutRestoreAll": "Repor todas as predefinições", + "shortcutRestoreAllQuestion": "Repor os atalhos predefinidos de todas as ações? As atribuições personalizadas serão removidas.", + "shortcutSaveFailed": "Os atalhos estão ativos, mas não foram guardados. Tente novamente para os manter após reiniciar.", + "shortcutRetry": "Tentar guardar novamente", + "shortcutChanged": "As atribuições mudaram. Reveja os atalhos atuais e tente novamente.", + "shortcutInvalid": "Esta combinação não pode ser atribuída neste contexto.", + "shortcutSystemWarning": "O ambiente de trabalho pode usar esta combinação. O BusyMark não pode substituir atalhos do sistema.", + "shortcutConflicts": "Este atalho entra em conflito com estas ações:", + "shortcutRemove": "Remover atribuição", + "shortcutRestoreDefault": "Repor predefinição", + "shortcutAssign": "Atribuir", + "shortcutReassign": "Reatribuir", + "shortcutContextApplication": "Aplicação", + "shortcutContextDocument": "Vistas do documento", + "shortcutContextEditor": "Editor e código-fonte", + "shortcutContextWritersideSource": "Código-fonte do Writerside", + "shortcutContextWorkspace": "Navegação do espaço de trabalho", + "shortcutContextTree": "Árvores laterais", + "searchHeadingsCommand": "Pesquisar títulos", + "shortcutFixed": "Gerido pelo editor; não é configurável.", + "shortcutDefaultConflict": "O atalho predefinido está em conflito com uma atribuição personalizada.", + "shortcutCurrentBinding": "Atalho atual", + "shortcutProposedBinding": "Atalho proposto" } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index c95e7ec6..54831952 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Data", "pdfTitlePageTitleRequired": "Insira um título para a página de título.", "pdfTocSectionNote": "Começa em uma página separada e pode ocupar várias páginas.", - "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:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Mostrar esta referência de atalhos de teclado", + "shortcutKeyboardShortcutsDescription": "Personalizar os atalhos de teclado do aplicativo", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Limpar espaços de trabalho recentes", + "clearRecentWorkspaces": "Limpar histórico recente", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Diferenciar maiúsculas de minúsculas", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Palavra inteira", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Expressão regular", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Expressão regular inválida", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Arquivo grande: o realce e o recolhimento estão pausados", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3208,9 +3191,6 @@ "diagnosticMarkdownLinkEmptyText": "O texto do link está vazio; forneça um nome acessível que descreva sua finalidade.", "diagnosticMarkdownLinkReviewText": "Verifique se o texto do link “{text}” descreve sua finalidade no contexto.", "diagnosticMarkdownTableEmptyHeader": "Os cabeçalhos da tabela devem identificar suas colunas; preencha cada cabeçalho vazio.", - "commandPalette": "Paleta de comandos", - "commandPaletteHint": "Digite um comando", - "commandPaletteEmpty": "Nenhum comando correspondente", "tableAlignmentUnspecified": "Alinhamento: não especificado", "tableAlignmentLeft": "Alinhamento: esquerda", "tableAlignmentCenter": "Alinhamento: centro", @@ -3325,14 +3305,12 @@ "exportInvalidCss": "Escolha um arquivo .css UTF-8 legível até 256 KiB, sem importações, URL de recursos, HTML ou CSS executável ou com escapes.", "exportInvalidRange": "{field}: insira um número entre {minimum} e {maximum}.", "clipboardHistory": "Histórico da Área de Transferência", - "clipboardHistorySearchHint": "Pesquisar no histórico da área de transferência", "clipboardHistoryDescription": "Abrir o histórico da área de transferência local da sessão.", "clipboardCurrent": "Área de transferência atual", "clipboardCurrentExternal": "Área de transferência de outro aplicativo", "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.", @@ -3349,8 +3327,6 @@ "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", @@ -3610,5 +3586,61 @@ "wsInsertElements": "Inserir elementos", "wsAdmonitionsAndQuotes": "Avisos e citações", "wsSemanticText": "Texto semântico", - "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar." + "wsPropertiesUnavailable": "O elemento inspecionado está indisponível. Inspecione um elemento para continuar.", + "searchDocumentsCategory": "Documentos", + "searchTopicsCategory": "Tópicos", + "searchCurrentDocument": "Documento atual", + "searchCurrentWorkspace": "Área de trabalho atual", + "browseNotes": "Explorar notas", + "searchActionsCommand": "Pesquisar ações", + "searchDocumentsCommand": "Pesquisar documentos", + "searchTopicsCommand": "Pesquisar tópicos", + "searchRecentCommand": "Pesquisar recentes", + "searchResourceUnavailable": "Este recurso não está disponível.", + "notesSync": "Sincronizar notas", + "searchInWorkspace": "Pesquisar no espaço de trabalho", + "searchCategory": "Categoria de pesquisa", + "clearHistorySearchScope": "Pesquisar no histórico de documentos", + "searchInDocument": "Pesquisar no documento", + "searchDocumentHint": "Pesquisar neste documento…", + "searchWorkspaceHint": "Pesquisar neste espaço de trabalho…", + "searchOutlineHint": "Pesquisar títulos…", + "searchHistoryHint": "Pesquisar no histórico local…", + "searchEnterText": "Digite texto para pesquisar", + "searchAccountUnavailable": "Conta indisponível", + "searchDocumentsHint": "Pesquisar documentos…", + "searchTopicsHint": "Pesquisar tópicos…", + "searchRecentHint": "Pesquisar recentes…", + "searchActionsHint": "Pesquisar ações…", + "searchRequiresNotesWorkspace": "Requer um espaço de trabalho de Notas", + "searchSidebarLabel": "Pesquisar:", + "searchDocumentTextCategory": "Texto do documento", + "searchWorkspaceTextCategory": "Texto do espaço de trabalho", + "searchHeadingsCategory": "Títulos", + "shortcutUnassigned": "Não atribuído", + "shortcutPressKeys": "Pressione uma combinação de teclas…", + "shortcutRecord": "Capturar atalho", + "shortcutRestoreAll": "Restaurar todos os padrões", + "shortcutRestoreAllQuestion": "Restaurar os atalhos padrão de todas as ações? As atribuições personalizadas serão removidas.", + "shortcutSaveFailed": "Os atalhos estão ativos, mas não foram salvos. Tente novamente para mantê-los após reiniciar.", + "shortcutRetry": "Tentar salvar novamente", + "shortcutChanged": "As atribuições mudaram. Confira os atalhos atuais e tente novamente.", + "shortcutInvalid": "Esta combinação não pode ser atribuída neste contexto.", + "shortcutSystemWarning": "O ambiente de trabalho pode usar esta combinação. O BusyMark não pode substituir atalhos do sistema.", + "shortcutConflicts": "Este atalho entra em conflito com estas ações:", + "shortcutRemove": "Remover atribuição", + "shortcutRestoreDefault": "Restaurar padrão", + "shortcutAssign": "Atribuir", + "shortcutReassign": "Reatribuir", + "shortcutContextApplication": "Aplicativo", + "shortcutContextDocument": "Visualizações do documento", + "shortcutContextEditor": "Editor e código-fonte", + "shortcutContextWritersideSource": "Código-fonte do Writerside", + "shortcutContextWorkspace": "Navegação do espaço de trabalho", + "shortcutContextTree": "Árvores laterais", + "searchHeadingsCommand": "Pesquisar títulos", + "shortcutFixed": "Gerenciado pelo editor; não é configurável.", + "shortcutDefaultConflict": "O atalho padrão está em conflito com uma atribuição personalizada.", + "shortcutCurrentBinding": "Atalho atual", + "shortcutProposedBinding": "Atalho proposto" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6de1a317..fdd307a1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Дата", "pdfTitlePageTitleRequired": "Введите заголовок титульной страницы.", "pdfTocSectionNote": "Начинается с отдельной страницы и может занимать несколько страниц.", - "sourceSearchZeroLengthUnsupported": "Совпадения регулярных выражений нулевой длины не поддерживаются (например, ^, $ или проверки вперёд, совпадающие только с позицией).", "workspaceSearchIncomplete": "Показана только часть результатов. Покажите больше или уточните поиск.", "workspaceSearchShowMore": "Показать больше результатов", "workspaceSearchSkippedFiles": "Поиск неполный. Эти файлы превышают 1 MiB или не могут быть прочитаны:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Показать справку по сочетаниям клавиш", + "shortcutKeyboardShortcutsDescription": "Настроить сочетания клавиш приложения", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Очистить список недавних рабочих областей", + "clearRecentWorkspaces": "Очистить недавнюю историю", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "С учётом регистра", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Слово целиком", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Регулярное выражение", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Некорректное регулярное выражение", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Большой файл: подсветка и сворачивание приостановлены", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3234,9 +3217,6 @@ "diagnosticMarkdownLinkEmptyText": "Текст ссылки пуст; укажите доступное имя, описывающее её назначение.", "diagnosticMarkdownLinkReviewText": "Проверьте, описывает ли текст ссылки «{text}» её назначение в контексте.", "diagnosticMarkdownTableEmptyHeader": "Заголовки таблицы должны обозначать столбцы; заполните каждый пустой заголовок.", - "commandPalette": "Палитра команд", - "commandPaletteHint": "Введите команду", - "commandPaletteEmpty": "Нет подходящих команд", "tableAlignmentUnspecified": "Выравнивание: не задано", "tableAlignmentLeft": "Выравнивание: по левому краю", "tableAlignmentCenter": "Выравнивание: по центру", @@ -3351,14 +3331,12 @@ "exportInvalidCss": "Выберите читаемый файл .css в UTF-8 до 256 КиБ без импортов, URL ресурсов, HTML, исполняемого CSS и escape-последовательностей.", "exportInvalidRange": "{field}: введите число от {minimum} до {maximum}.", "clipboardHistory": "История буфера обмена", - "clipboardHistorySearchHint": "Поиск в истории буфера обмена", "clipboardHistoryDescription": "Открыть историю буфера обмена, специфичную для сеанса.", "clipboardCurrent": "Текущий буфер обмена", "clipboardCurrentExternal": "Текущий буфер обмена из другого приложения", "clipboardDestination": "Вставить в {name}", "clipboardSessionOnly": "Сохраняется только для текущего сеанса BusyMark.", "clipboardNoItems": "История буфера обмена пуста.", - "clipboardNoMatches": "Нет элементов буфера обмена, соответствующих этому поиску.", "clipboardDisabled": "Коллекция \"История буфера обмена\" отключена в настройках.", "clipboardUnavailable": "Текущее содержимое буфера обмена недоступно или не поддерживается.", "clipboardTooLarge": "Этот элемент буфера обмена слишком велик для хранения.", @@ -3375,8 +3353,6 @@ "findLocalHistoryDescription": "Поиск документов и содержимого версий в локальной истории.", "localHistoryNoDocuments": "Ни один документ не имеет локальной истории.", "localHistoryNoRevisions": "Этот документ не имеет версий в локальной истории.", - "localHistoryNoMatches": "Ни одна версия не соответствует этому поиску.", - "localHistorySearchHint": "Поиск в локальной истории", "localHistorySelectedRevision": "Выбранная версия", "localHistoryCurrentEditor": "Текущее содержимое редактора", "localHistoryCurrentDisk": "Текущее содержимое на диске", @@ -3636,5 +3612,61 @@ "wsInsertElements": "Вставить элементы", "wsAdmonitionsAndQuotes": "Примечания и цитаты", "wsSemanticText": "Семантический текст", - "wsPropertiesUnavailable": "Проверяемый элемент недоступен. Выберите элемент для продолжения." + "wsPropertiesUnavailable": "Проверяемый элемент недоступен. Выберите элемент для продолжения.", + "searchDocumentsCategory": "Документы", + "searchTopicsCategory": "Темы", + "searchCurrentDocument": "Текущий документ", + "searchCurrentWorkspace": "Текущая рабочая область", + "browseNotes": "Обзор заметок", + "searchActionsCommand": "Поиск действий", + "searchDocumentsCommand": "Поиск документов", + "searchTopicsCommand": "Поиск тем", + "searchRecentCommand": "Поиск недавних", + "searchResourceUnavailable": "Этот ресурс недоступен.", + "notesSync": "Синхронизировать заметки", + "searchInWorkspace": "Поиск в рабочей области", + "searchCategory": "Категория поиска", + "clearHistorySearchScope": "Поиск в истории документов", + "searchInDocument": "Поиск в документе", + "searchDocumentHint": "Поиск в этом документе…", + "searchWorkspaceHint": "Поиск в этой рабочей области…", + "searchOutlineHint": "Поиск заголовков…", + "searchHistoryHint": "Поиск в локальной истории…", + "searchEnterText": "Введите текст для поиска", + "searchAccountUnavailable": "Аккаунт недоступен", + "searchDocumentsHint": "Поиск документов…", + "searchTopicsHint": "Поиск тем…", + "searchRecentHint": "Поиск недавних…", + "searchActionsHint": "Поиск действий…", + "searchRequiresNotesWorkspace": "Требуется рабочая область заметок", + "searchSidebarLabel": "Поиск:", + "searchDocumentTextCategory": "Текст документа", + "searchWorkspaceTextCategory": "Текст рабочей области", + "searchHeadingsCategory": "Заголовки", + "shortcutUnassigned": "Не назначено", + "shortcutPressKeys": "Нажмите сочетание клавиш…", + "shortcutRecord": "Записать сочетание клавиш", + "shortcutRestoreAll": "Восстановить все значения по умолчанию", + "shortcutRestoreAllQuestion": "Восстановить стандартные сочетания всех действий? Пользовательские назначения будут удалены.", + "shortcutSaveFailed": "Сочетания действуют, но не сохранены. Повторите сохранение, чтобы они сохранились после перезапуска.", + "shortcutRetry": "Повторить сохранение", + "shortcutChanged": "Назначения изменились. Проверьте текущие сочетания и повторите попытку.", + "shortcutInvalid": "Это сочетание нельзя назначить в данном контексте.", + "shortcutSystemWarning": "Это сочетание может использоваться рабочим столом. BusyMark не может переопределять системные сочетания.", + "shortcutConflicts": "Это сочетание конфликтует со следующими действиями:", + "shortcutRemove": "Удалить назначение", + "shortcutRestoreDefault": "Восстановить стандартное", + "shortcutAssign": "Назначить", + "shortcutReassign": "Переназначить", + "shortcutContextApplication": "Приложение", + "shortcutContextDocument": "Представления документа", + "shortcutContextEditor": "Редактор и исходный код", + "shortcutContextWritersideSource": "Исходный код Writerside", + "shortcutContextWorkspace": "Навигация рабочей области", + "shortcutContextTree": "Деревья боковой панели", + "searchHeadingsCommand": "Поиск заголовков", + "shortcutFixed": "Управляется редактором; настройка недоступна.", + "shortcutDefaultConflict": "Сочетание по умолчанию конфликтует с пользовательским назначением.", + "shortcutCurrentBinding": "Текущее сочетание", + "shortcutProposedBinding": "Предлагаемое сочетание" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 6d2251ae..43dfbc93 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Tarih", "pdfTitlePageTitleRequired": "Başlık sayfası için bir başlık girin.", "pdfTocSectionNote": "Ayrı bir sayfada başlar ve birden fazla sayfa sürebilir.", - "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ı:", @@ -423,18 +422,6 @@ "@keyboardShortcuts": { "description": "Keyboard shortcuts dialog title and menu item." }, - "commandPalette": "Komut Paleti", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "Bir komut yazın", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "Eşleşen komut yok", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "Mevcut düzenleyici bağlamında kullanılamıyor", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Bu klavye kısayolu referansını göster", + "shortcutKeyboardShortcutsDescription": "Uygulama klavye kısayollarını özelleştir", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Son çalışma alanlarını temizle", + "clearRecentWorkspaces": "Son geçmişi temizle", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Büyük-küçük harf duyarlı", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Tam sözcük", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Düzenli ifade", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "Şununla değiştir:", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "Geçersiz düzenli ifade", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Büyük dosya: vurgulama ve katlama duraklatıldı", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "İçe aktarım, kaynak URL’si, HTML, çalıştırılabilir veya kaçışlı CSS içermeyen, okunabilir UTF-8 .css dosyası seçin (en fazla 256 KiB).", "exportInvalidRange": "{field}: {minimum} ile {maximum} arasında bir sayı girin.", "clipboardHistory": "Kopyalama Geçmişi", - "clipboardHistorySearchHint": "Pano geçmişinde ara", "clipboardHistoryDescription": "Oturuma özgü kopyalama geçmişini aç.", "clipboardCurrent": "Mevcut kopyalama", "clipboardCurrentExternal": "Başka bir uygulamadan mevcut kopyalama", "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.", @@ -4888,8 +4857,6 @@ "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", @@ -5149,5 +5116,61 @@ "wsInsertElements": "Öğe ekle", "wsAdmonitionsAndQuotes": "Uyarılar ve alıntılar", "wsSemanticText": "Anlamsal metin", - "wsPropertiesUnavailable": "İncelenen öğe kullanılamıyor. Devam etmek için bir öğeyi inceleyin." + "wsPropertiesUnavailable": "İncelenen öğe kullanılamıyor. Devam etmek için bir öğeyi inceleyin.", + "searchDocumentsCategory": "Belgeler", + "searchTopicsCategory": "Konular", + "searchCurrentDocument": "Geçerli belge", + "searchCurrentWorkspace": "Geçerli çalışma alanı", + "browseNotes": "Notlara göz at", + "searchActionsCommand": "Eylemleri ara", + "searchDocumentsCommand": "Belgeleri ara", + "searchTopicsCommand": "Konuları ara", + "searchRecentCommand": "Son kullanılanları ara", + "searchResourceUnavailable": "Bu kaynak kullanılamıyor.", + "notesSync": "Notları eşitle", + "searchInWorkspace": "Çalışma Alanında Ara", + "searchCategory": "Arama kategorisi", + "clearHistorySearchScope": "Belgelerin geçmişinde ara", + "searchInDocument": "Belgede ara", + "searchDocumentHint": "Bu belgede ara…", + "searchWorkspaceHint": "Bu çalışma alanında ara…", + "searchOutlineHint": "Başlıklarda ara…", + "searchHistoryHint": "Yerel geçmişte ara…", + "searchEnterText": "Aranacak metni girin", + "searchAccountUnavailable": "Hesap kullanılamıyor", + "searchDocumentsHint": "Belgeleri ara…", + "searchTopicsHint": "Konuları ara…", + "searchRecentHint": "Son kullanılanları ara…", + "searchActionsHint": "Eylemleri ara…", + "searchRequiresNotesWorkspace": "Notlar çalışma alanı gerektirir", + "searchSidebarLabel": "Ara:", + "searchDocumentTextCategory": "Belge metni", + "searchWorkspaceTextCategory": "Çalışma alanı metni", + "searchHeadingsCategory": "Başlıklar", + "shortcutUnassigned": "Atanmamış", + "shortcutPressKeys": "Bir tuş birleşimine basın…", + "shortcutRecord": "Kısayol kaydet", + "shortcutRestoreAll": "Tüm varsayılanları geri yükle", + "shortcutRestoreAllQuestion": "Tüm eylemlerin varsayılan kısayolları geri yüklensin mi? Özel atamalar kaldırılacak.", + "shortcutSaveFailed": "Kısayollar etkin ancak kaydedilemedi. Yeniden başlatmadan sonra korumak için tekrar kaydedin.", + "shortcutRetry": "Kaydetmeyi yeniden dene", + "shortcutChanged": "Atamalar değişti. Geçerli kısayolları gözden geçirip yeniden deneyin.", + "shortcutInvalid": "Bu birleşim bu bağlamda atanamaz.", + "shortcutSystemWarning": "Masaüstünüz bu birleşimi kullanıyor olabilir. BusyMark sistem kısayollarını geçersiz kılamaz.", + "shortcutConflicts": "Bu kısayol şu eylemlerle çakışıyor:", + "shortcutRemove": "Atamayı kaldır", + "shortcutRestoreDefault": "Varsayılanı geri yükle", + "shortcutAssign": "Ata", + "shortcutReassign": "Yeniden ata", + "shortcutContextApplication": "Uygulama", + "shortcutContextDocument": "Belge görünümleri", + "shortcutContextEditor": "Düzenleyici ve kaynak", + "shortcutContextWritersideSource": "Writerside kaynağı", + "shortcutContextWorkspace": "Çalışma alanı gezintisi", + "shortcutContextTree": "Kenar çubuğu ağaçları", + "searchHeadingsCommand": "Başlıklarda Ara", + "shortcutFixed": "Düzenleyici tarafından yönetilir; yapılandırılamaz.", + "shortcutDefaultConflict": "Varsayılan kısayol özel bir atamayla çakışıyor.", + "shortcutCurrentBinding": "Geçerli kısayol", + "shortcutProposedBinding": "Önerilen kısayol" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 3331cd25..217f43b4 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Дата", "pdfTitlePageTitleRequired": "Введіть заголовок титульної сторінки.", "pdfTocSectionNote": "Починається з окремої сторінки й може займати кілька сторінок.", - "sourceSearchZeroLengthUnsupported": "Збіги регулярних виразів нульової довжини не підтримуються (наприклад, ^, $ або перевірки вперед, що збігаються лише з позицією).", "workspaceSearchIncomplete": "Показано лише частину результатів. Покажіть більше або звузьте пошук.", "workspaceSearchShowMore": "Показати більше результатів", "workspaceSearchSkippedFiles": "Пошук неповний. Ці файли перевищують 1 MiB або їх не вдалося прочитати:", @@ -585,7 +584,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Показати довідку з комбінацій клавіш", + "shortcutKeyboardShortcutsDescription": "Налаштувати сполучення клавіш застосунку", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -882,7 +881,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Очистити список останніх робочих областей", + "clearRecentWorkspaces": "Очистити недавню історію", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1562,22 +1561,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "З урахуванням регістру", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Ціле слово", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Регулярний вираз", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, - "sourceSearchInvalidRegex": "Некоректний регулярний вираз", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Великий файл: підсвічування та згортання призупинено", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -3234,9 +3217,6 @@ "diagnosticMarkdownLinkEmptyText": "Текст посилання порожній; укажіть доступну назву, що описує його призначення.", "diagnosticMarkdownLinkReviewText": "Перевірте, чи описує текст посилання «{text}» його призначення в контексті.", "diagnosticMarkdownTableEmptyHeader": "Заголовки таблиці мають позначати стовпці; заповніть кожен порожній заголовок.", - "commandPalette": "Палітра команд", - "commandPaletteHint": "Введіть команду", - "commandPaletteEmpty": "Немає відповідних команд", "tableAlignmentUnspecified": "Вирівнювання: не вказано", "tableAlignmentLeft": "Вирівнювання: ліворуч", "tableAlignmentCenter": "Вирівнювання: по центру", @@ -3351,14 +3331,12 @@ "exportInvalidCss": "Виберіть читабельний файл .css у UTF-8 до 256 КіБ без імпортів, URL ресурсів, HTML, виконуваного CSS і escape-послідовностей.", "exportInvalidRange": "{field}: введіть число від {minimum} до {maximum}.", "clipboardHistory": "Історія Вирізання", - "clipboardHistorySearchHint": "Пошук в історії буфера обміну", "clipboardHistoryDescription": "Відкрити історію вирізання, прив'язану до сесії.", "clipboardCurrent": "Поточне вирізання", "clipboardCurrentExternal": "Поточне вирізання з іншого додатку", "clipboardDestination": "Вставити в {name}", "clipboardSessionOnly": "Зберігається лише для цієї сесії BusyMark.", "clipboardNoItems": "Історія вирізання порожня.", - "clipboardNoMatches": "Жоден елемент вирізання не відповідає цьому пошуку.", "clipboardDisabled": "Збірка \"Історія Вирізання\" вимкнена в Налаштуваннях.", "clipboardUnavailable": "Поточний вміст вирізання недоступний або не підтримується.", "clipboardTooLarge": "Цей елемент буфера обміну занадто великий для зберігання.", @@ -3375,8 +3353,6 @@ "findLocalHistoryDescription": "Пошук документів та вмісту версій у локальній історії.", "localHistoryNoDocuments": "Жоден документ поки не має локальної історії.", "localHistoryNoRevisions": "Цей документ не має версій локальної історії.", - "localHistoryNoMatches": "Жодна версія локальної історії не відповідає цьому пошуку.", - "localHistorySearchHint": "Пошук у локальній історії", "localHistorySelectedRevision": "Обрана версія", "localHistoryCurrentEditor": "Поточний вміст редактора", "localHistoryCurrentDisk": "Поточний вміст на диску", @@ -3636,5 +3612,61 @@ "wsInsertElements": "Вставити елементи", "wsAdmonitionsAndQuotes": "Примітки та цитати", "wsSemanticText": "Семантичний текст", - "wsPropertiesUnavailable": "Елемент, що перевіряється, недоступний. Виберіть елемент для продовження." + "wsPropertiesUnavailable": "Елемент, що перевіряється, недоступний. Виберіть елемент для продовження.", + "searchDocumentsCategory": "Документи", + "searchTopicsCategory": "Теми", + "searchCurrentDocument": "Поточний документ", + "searchCurrentWorkspace": "Поточна робоча область", + "browseNotes": "Переглянути нотатки", + "searchActionsCommand": "Пошук дій", + "searchDocumentsCommand": "Пошук документів", + "searchTopicsCommand": "Пошук тем", + "searchRecentCommand": "Пошук нещодавніх", + "searchResourceUnavailable": "Цей ресурс недоступний.", + "notesSync": "Синхронізувати нотатки", + "searchInWorkspace": "Пошук у робочій області", + "searchCategory": "Категорія пошуку", + "clearHistorySearchScope": "Пошук в історії документів", + "searchInDocument": "Пошук у документі", + "searchDocumentHint": "Пошук у цьому документі…", + "searchWorkspaceHint": "Пошук у цьому робочому просторі…", + "searchOutlineHint": "Пошук заголовків…", + "searchHistoryHint": "Пошук у локальній історії…", + "searchEnterText": "Введіть текст для пошуку", + "searchAccountUnavailable": "Обліковий запис недоступний", + "searchDocumentsHint": "Пошук документів…", + "searchTopicsHint": "Пошук тем…", + "searchRecentHint": "Пошук нещодавніх…", + "searchActionsHint": "Пошук дій…", + "searchRequiresNotesWorkspace": "Потрібен робочий простір нотаток", + "searchSidebarLabel": "Пошук:", + "searchDocumentTextCategory": "Текст документа", + "searchWorkspaceTextCategory": "Текст робочої області", + "searchHeadingsCategory": "Заголовки", + "shortcutUnassigned": "Не призначено", + "shortcutPressKeys": "Натисніть сполучення клавіш…", + "shortcutRecord": "Записати сполучення клавіш", + "shortcutRestoreAll": "Відновити всі типові значення", + "shortcutRestoreAllQuestion": "Відновити типові сполучення всіх дій? Власні призначення буде видалено.", + "shortcutSaveFailed": "Сполучення діють, але їх не збережено. Повторіть збереження, щоб залишити їх після перезапуску.", + "shortcutRetry": "Повторити збереження", + "shortcutChanged": "Призначення змінилися. Перевірте поточні сполучення та повторіть спробу.", + "shortcutInvalid": "Це сполучення не можна призначити в цьому контексті.", + "shortcutSystemWarning": "Це сполучення може використовуватися стільницею. BusyMark не може перевизначати системні сполучення.", + "shortcutConflicts": "Це сполучення конфліктує з такими діями:", + "shortcutRemove": "Вилучити призначення", + "shortcutRestoreDefault": "Відновити типове", + "shortcutAssign": "Призначити", + "shortcutReassign": "Перепризначити", + "shortcutContextApplication": "Застосунок", + "shortcutContextDocument": "Подання документа", + "shortcutContextEditor": "Редактор і джерело", + "shortcutContextWritersideSource": "Джерело Writerside", + "shortcutContextWorkspace": "Навігація робочою областю", + "shortcutContextTree": "Дерева бічної панелі", + "searchHeadingsCommand": "Пошук заголовків", + "shortcutFixed": "Керується редактором; налаштування недоступне.", + "shortcutDefaultConflict": "Типове сполучення конфліктує з користувацьким призначенням.", + "shortcutCurrentBinding": "Поточне сполучення", + "shortcutProposedBinding": "Запропоноване сполучення" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 81f61b10..e0a23f42 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "Ngày", "pdfTitlePageTitleRequired": "Nhập tiêu đề cho trang tiêu đề.", "pdfTocSectionNote": "Bắt đầu trên trang riêng và có thể kéo dài nhiều trang.", - "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:", @@ -420,18 +419,6 @@ "description": "Insert button label." }, "keyboardShortcuts": "Phím tắt", - "commandPalette": "Bảng lệnh", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "Nhập lệnh", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "Không có lệnh phù hợp", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "Lệnh không khả dụng trong ngữ cảnh trình biên tập hiện tại", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "Hiển thị tham chiếu phím tắt này", + "shortcutKeyboardShortcutsDescription": "Tùy chỉnh phím tắt của ứng dụng", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "Xóa không gian làm việc gần đây", + "clearRecentWorkspaces": "Xóa lịch sử gần đây", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "Phân biệt chữ hoa, chữ thường", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "Toàn bộ từ", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "Biểu thức chính quy", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "Thay thế bằng", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "Biểu thức chính quy không hợp lệ", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "Tệp lớn: đã tạm dừng tô sáng và thu gọn", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "Chọn tệp .css UTF-8 đọc được tối đa 256 KiB, không có nhập tài nguyên, URL tài nguyên, HTML, CSS thực thi hoặc ký tự thoát.", "exportInvalidRange": "{field}: nhập số từ {minimum} đến {maximum}.", "clipboardHistory": "Lịch sử sao chép", - "clipboardHistorySearchHint": "Tìm kiếm lịch sử bảng nhớ tạm", "clipboardHistoryDescription": "Mở lịch sử sao chép cục bộ phiên.", "clipboardCurrent": "Lịch sử sao chép hiện tại", "clipboardCurrentExternal": "Lịch sử sao chép từ một ứng dụng khác", "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.", @@ -4888,8 +4857,6 @@ "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", @@ -5149,5 +5116,61 @@ "wsInsertElements": "Chèn phần tử", "wsAdmonitionsAndQuotes": "Lưu ý và trích dẫn", "wsSemanticText": "Văn bản ngữ nghĩa", - "wsPropertiesUnavailable": "Phần tử đang kiểm tra không khả dụng. Kiểm tra một phần tử để tiếp tục." + "wsPropertiesUnavailable": "Phần tử đang kiểm tra không khả dụng. Kiểm tra một phần tử để tiếp tục.", + "searchDocumentsCategory": "Tài liệu", + "searchTopicsCategory": "Chủ đề", + "searchCurrentDocument": "Tài liệu hiện tại", + "searchCurrentWorkspace": "Không gian làm việc hiện tại", + "browseNotes": "Duyệt ghi chú", + "searchActionsCommand": "Tìm thao tác", + "searchDocumentsCommand": "Tìm tài liệu", + "searchTopicsCommand": "Tìm chủ đề", + "searchRecentCommand": "Tìm mục gần đây", + "searchResourceUnavailable": "Tài nguyên này không khả dụng.", + "notesSync": "Đồng bộ ghi chú", + "searchInWorkspace": "Tìm trong không gian làm việc", + "searchCategory": "Danh mục tìm kiếm", + "clearHistorySearchScope": "Tìm trong lịch sử tài liệu", + "searchInDocument": "Tìm trong tài liệu", + "searchDocumentHint": "Tìm trong tài liệu này…", + "searchWorkspaceHint": "Tìm trong không gian làm việc này…", + "searchOutlineHint": "Tìm tiêu đề…", + "searchHistoryHint": "Tìm trong lịch sử cục bộ…", + "searchEnterText": "Nhập văn bản để tìm kiếm", + "searchAccountUnavailable": "Tài khoản không khả dụng", + "searchDocumentsHint": "Tìm tài liệu…", + "searchTopicsHint": "Tìm chủ đề…", + "searchRecentHint": "Tìm mục gần đây…", + "searchActionsHint": "Tìm thao tác…", + "searchRequiresNotesWorkspace": "Yêu cầu không gian làm việc Ghi chú", + "searchSidebarLabel": "Tìm kiếm:", + "searchDocumentTextCategory": "Văn bản tài liệu", + "searchWorkspaceTextCategory": "Văn bản không gian làm việc", + "searchHeadingsCategory": "Tiêu đề", + "shortcutUnassigned": "Chưa gán", + "shortcutPressKeys": "Nhấn tổ hợp phím…", + "shortcutRecord": "Ghi phím tắt", + "shortcutRestoreAll": "Khôi phục tất cả mặc định", + "shortcutRestoreAllQuestion": "Khôi phục phím tắt mặc định của mọi thao tác? Các gán tùy chỉnh sẽ bị xóa.", + "shortcutSaveFailed": "Phím tắt đang có hiệu lực nhưng chưa lưu được. Thử lưu lại để giữ chúng sau khi khởi động lại.", + "shortcutRetry": "Thử lưu lại", + "shortcutChanged": "Các gán đã thay đổi. Xem lại phím tắt hiện tại và thử lại.", + "shortcutInvalid": "Không thể gán tổ hợp này trong ngữ cảnh này.", + "shortcutSystemWarning": "Môi trường màn hình có thể dùng tổ hợp này. BusyMark không thể ghi đè phím tắt hệ thống.", + "shortcutConflicts": "Phím tắt này xung đột với các thao tác sau:", + "shortcutRemove": "Xóa gán", + "shortcutRestoreDefault": "Khôi phục mặc định", + "shortcutAssign": "Gán", + "shortcutReassign": "Gán lại", + "shortcutContextApplication": "Ứng dụng", + "shortcutContextDocument": "Chế độ xem tài liệu", + "shortcutContextEditor": "Trình sửa và mã nguồn", + "shortcutContextWritersideSource": "Mã nguồn Writerside", + "shortcutContextWorkspace": "Điều hướng không gian làm việc", + "shortcutContextTree": "Cây thanh bên", + "searchHeadingsCommand": "Tìm tiêu đề", + "shortcutFixed": "Do trình soạn thảo quản lý; không thể cấu hình.", + "shortcutDefaultConflict": "Phím tắt mặc định xung đột với gán tùy chỉnh.", + "shortcutCurrentBinding": "Phím tắt hiện tại", + "shortcutProposedBinding": "Phím tắt đề xuất" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 40ecc186..df6e1b09 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -110,7 +110,6 @@ "pdfTitlePageDate": "日期", "pdfTitlePageTitleRequired": "请输入标题页的标题。", "pdfTocSectionNote": "从单独一页开始,可以跨多页。", - "sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。", "workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。", "workspaceSearchShowMore": "显示更多结果", "workspaceSearchSkippedFiles": "搜索不完整。以下文件超过 1 MiB 或无法读取:", @@ -422,18 +421,6 @@ "description": "Insert button label." }, "keyboardShortcuts": "键盘快捷键", - "commandPalette": "命令面板", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "输入命令", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "没有匹配的命令", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "当前编辑器上下文中无法使用此命令", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -717,7 +704,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "显示此键盘快捷键参考", + "shortcutKeyboardShortcutsDescription": "自定义应用程序键盘快捷键", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1014,7 +1001,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "清除最近使用的工作区", + "clearRecentWorkspaces": "清除最近记录", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1945,18 +1932,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "区分大小写", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "全字匹配", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "正则表达式", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "替换为", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2140,10 +2115,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "无效的正则表达式", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "大文件:已暂停语法突出显示和折叠", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4872,14 +4843,12 @@ "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", "clipboardHistory": "剪贴板历史", - "clipboardHistorySearchHint": "搜索剪贴板历史记录", "clipboardHistoryDescription": "打开会话本地剪贴板历史。", "clipboardCurrent": "当前剪贴板", "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", "clipboardDestination": "粘贴到 {name}", "clipboardSessionOnly": "仅存储在本次 BusyMark 会话中。", "clipboardNoItems": "剪贴板历史为空。", - "clipboardNoMatches": "没有匹配此搜索的剪贴板项目。", "clipboardDisabled": "剪贴板历史集合在设置中已禁用。", "clipboardUnavailable": "当前剪贴板内容不可用或不受支持。", "clipboardTooLarge": "此剪贴板内容过大,无法保留。", @@ -4896,8 +4865,6 @@ "findLocalHistoryDescription": "搜索本地历史文档和修订内容。", "localHistoryNoDocuments": "尚无文档具有本地历史。", "localHistoryNoRevisions": "此文档没有本地历史修订。", - "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", - "localHistorySearchHint": "搜索本地历史记录", "localHistorySelectedRevision": "已选择的修订", "localHistoryCurrentEditor": "当前编辑器内容", "localHistoryCurrentDisk": "磁盘上的当前内容", @@ -5161,5 +5128,61 @@ "wsInsertElements": "插入元素", "wsAdmonitionsAndQuotes": "提示和引用", "wsSemanticText": "语义文本", - "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。" + "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。", + "searchDocumentsCategory": "文档", + "searchTopicsCategory": "主题", + "searchCurrentDocument": "当前文档", + "searchCurrentWorkspace": "当前工作区", + "browseNotes": "浏览笔记", + "searchActionsCommand": "搜索操作", + "searchDocumentsCommand": "搜索文档", + "searchTopicsCommand": "搜索主题", + "searchRecentCommand": "搜索最近项目", + "searchResourceUnavailable": "此资源不可用。", + "notesSync": "同步笔记", + "searchInWorkspace": "在工作区中搜索", + "searchCategory": "搜索类别", + "clearHistorySearchScope": "搜索文档历史记录", + "searchInDocument": "在文档中搜索", + "searchDocumentHint": "搜索此文档…", + "searchWorkspaceHint": "搜索此工作区…", + "searchOutlineHint": "搜索标题…", + "searchHistoryHint": "搜索本地历史记录…", + "searchEnterText": "输入要搜索的文本", + "searchAccountUnavailable": "账户不可用", + "searchDocumentsHint": "搜索文档…", + "searchTopicsHint": "搜索主题…", + "searchRecentHint": "搜索最近项目…", + "searchActionsHint": "搜索操作…", + "searchRequiresNotesWorkspace": "需要笔记工作区", + "searchSidebarLabel": "搜索:", + "searchDocumentTextCategory": "文档文本", + "searchWorkspaceTextCategory": "工作区文本", + "searchHeadingsCategory": "标题", + "shortcutUnassigned": "未分配", + "shortcutPressKeys": "请按下组合键…", + "shortcutRecord": "录制快捷键", + "shortcutRestoreAll": "恢复所有默认设置", + "shortcutRestoreAllQuestion": "恢复所有操作的默认快捷键?自定义分配将被移除。", + "shortcutSaveFailed": "快捷键已生效,但无法保存。请重试保存以便重启后保留。", + "shortcutRetry": "重试保存", + "shortcutChanged": "分配已更改。请检查当前快捷键并重试。", + "shortcutInvalid": "无法在此上下文中分配此组合键。", + "shortcutSystemWarning": "桌面可能正在使用此组合键。BusyMark 无法覆盖系统快捷键。", + "shortcutConflicts": "此快捷键与以下操作冲突:", + "shortcutRemove": "移除分配", + "shortcutRestoreDefault": "恢复默认设置", + "shortcutAssign": "分配", + "shortcutReassign": "重新分配", + "shortcutContextApplication": "应用程序", + "shortcutContextDocument": "文档视图", + "shortcutContextEditor": "编辑器和源码", + "shortcutContextWritersideSource": "Writerside 源码", + "shortcutContextWorkspace": "工作区导航", + "shortcutContextTree": "侧栏树", + "searchHeadingsCommand": "搜索标题", + "shortcutFixed": "由编辑器管理,无法配置。", + "shortcutDefaultConflict": "默认快捷键与自定义分配冲突。", + "shortcutCurrentBinding": "当前快捷键", + "shortcutProposedBinding": "拟用快捷键" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 8cb2aaa8..6f2e5d0f 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -108,7 +108,6 @@ "pdfTitlePageDate": "日期", "pdfTitlePageTitleRequired": "请输入标题页的标题。", "pdfTocSectionNote": "从单独一页开始,可以跨多页。", - "sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。", "workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。", "workspaceSearchShowMore": "显示更多结果", "workspaceSearchSkippedFiles": "搜索不完整。以下文件超过 1 MiB 或无法读取:", @@ -420,18 +419,6 @@ "description": "Insert button label." }, "keyboardShortcuts": "键盘快捷键", - "commandPalette": "命令面板", - "@commandPalette": { - "description": "Title and command label for the searchable command palette." - }, - "commandPaletteHint": "输入命令", - "@commandPaletteHint": { - "description": "Search hint in the command palette." - }, - "commandPaletteEmpty": "没有匹配的命令", - "@commandPaletteEmpty": { - "description": "Empty state in the command palette." - }, "commandUnavailableInContext": "当前编辑器上下文中无法使用此命令", "@commandUnavailableInContext": { "description": "Reason shown for a disabled contextual command." @@ -715,7 +702,7 @@ "@shortcutSearchDescription": { "description": "Keyboard shortcut description for search." }, - "shortcutKeyboardShortcutsDescription": "显示此键盘快捷键参考", + "shortcutKeyboardShortcutsDescription": "自定义应用程序键盘快捷键", "@shortcutKeyboardShortcutsDescription": { "description": "Keyboard shortcut description for opening shortcuts." }, @@ -1012,7 +999,7 @@ "@validateOnEdit": { "description": "Validation setting label." }, - "clearRecentWorkspaces": "清除最近使用的工作区", + "clearRecentWorkspaces": "清除最近记录", "@clearRecentWorkspaces": { "description": "Settings action label for clearing recent workspaces." }, @@ -1943,18 +1930,6 @@ "@sourceSearchNextMatch": { "description": "Tooltip for moving to the next source search match." }, - "sourceSearchCaseSensitive": "区分大小写", - "@sourceSearchCaseSensitive": { - "description": "Tooltip for toggling case-sensitive source search." - }, - "sourceSearchWholeWord": "全字匹配", - "@sourceSearchWholeWord": { - "description": "Tooltip for toggling whole-word source search." - }, - "sourceSearchRegex": "正则表达式", - "@sourceSearchRegex": { - "description": "Tooltip for toggling regular-expression source search." - }, "sourceSearchReplacement": "替换为", "@sourceSearchReplacement": { "description": "Placeholder for the active-document replacement field." @@ -2138,10 +2113,6 @@ "@saveAs": { "description": "Action that saves the current document to another path." }, - "sourceSearchInvalidRegex": "无效的正则表达式", - "@sourceSearchInvalidRegex": { - "description": "Source search status shown when the regular expression is invalid." - }, "sourceLargeFileFeaturesPaused": "大文件:已暂停语法突出显示和折叠", "@sourceLargeFileFeaturesPaused": { "description": "Status banner shown when source highlighting and folding are disabled for a large file." @@ -4864,14 +4835,12 @@ "exportInvalidCss": "请选择可读取的 UTF-8 .css 文件(最大 256 KiB),不得包含导入、资源 URL、HTML、可执行或转义 CSS。", "exportInvalidRange": "{field}:请输入 {minimum} 到 {maximum} 之间的数字。", "clipboardHistory": "剪贴板历史", - "clipboardHistorySearchHint": "搜索剪贴板历史记录", "clipboardHistoryDescription": "打开会话本地剪贴板历史。", "clipboardCurrent": "当前剪贴板", "clipboardCurrentExternal": "来自其他应用程序的当前剪贴板", "clipboardDestination": "粘贴到 {name}", "clipboardSessionOnly": "仅存储在本次 BusyMark 会话中。", "clipboardNoItems": "剪贴板历史为空。", - "clipboardNoMatches": "没有匹配此搜索的剪贴板项目。", "clipboardDisabled": "剪贴板历史集合在设置中已禁用。", "clipboardUnavailable": "当前剪贴板内容不可用或不受支持。", "clipboardTooLarge": "此剪贴板内容过大,无法保留。", @@ -4888,8 +4857,6 @@ "findLocalHistoryDescription": "搜索本地历史文档和修订内容。", "localHistoryNoDocuments": "尚无文档具有本地历史。", "localHistoryNoRevisions": "此文档没有本地历史修订。", - "localHistoryNoMatches": "没有本地历史修订与此搜索匹配。", - "localHistorySearchHint": "搜索本地历史记录", "localHistorySelectedRevision": "已选择的修订", "localHistoryCurrentEditor": "当前编辑器内容", "localHistoryCurrentDisk": "磁盘上的当前内容", @@ -5149,5 +5116,61 @@ "wsInsertElements": "插入元素", "wsAdmonitionsAndQuotes": "提示和引用", "wsSemanticText": "语义文本", - "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。" + "wsPropertiesUnavailable": "所检查的元素不可用。请检查一个元素以继续。", + "searchDocumentsCategory": "文档", + "searchTopicsCategory": "主题", + "searchCurrentDocument": "当前文档", + "searchCurrentWorkspace": "当前工作区", + "browseNotes": "浏览笔记", + "searchActionsCommand": "搜索操作", + "searchDocumentsCommand": "搜索文档", + "searchTopicsCommand": "搜索主题", + "searchRecentCommand": "搜索最近项目", + "searchResourceUnavailable": "此资源不可用。", + "notesSync": "同步笔记", + "searchInWorkspace": "在工作区中搜索", + "searchCategory": "搜索类别", + "clearHistorySearchScope": "搜索文档历史记录", + "searchInDocument": "在文档中搜索", + "searchDocumentHint": "搜索此文档…", + "searchWorkspaceHint": "搜索此工作区…", + "searchOutlineHint": "搜索标题…", + "searchHistoryHint": "搜索本地历史记录…", + "searchEnterText": "输入要搜索的文本", + "searchAccountUnavailable": "账户不可用", + "searchDocumentsHint": "搜索文档…", + "searchTopicsHint": "搜索主题…", + "searchRecentHint": "搜索最近项目…", + "searchActionsHint": "搜索操作…", + "searchRequiresNotesWorkspace": "需要笔记工作区", + "searchSidebarLabel": "搜索:", + "searchDocumentTextCategory": "文档文本", + "searchWorkspaceTextCategory": "工作区文本", + "searchHeadingsCategory": "标题", + "shortcutUnassigned": "未分配", + "shortcutPressKeys": "请按下组合键…", + "shortcutRecord": "录制快捷键", + "shortcutRestoreAll": "恢复所有默认设置", + "shortcutRestoreAllQuestion": "恢复所有操作的默认快捷键?自定义分配将被移除。", + "shortcutSaveFailed": "快捷键已生效,但无法保存。请重试保存以便重启后保留。", + "shortcutRetry": "重试保存", + "shortcutChanged": "分配已更改。请检查当前快捷键并重试。", + "shortcutInvalid": "无法在此上下文中分配此组合键。", + "shortcutSystemWarning": "桌面可能正在使用此组合键。BusyMark 无法覆盖系统快捷键。", + "shortcutConflicts": "此快捷键与以下操作冲突:", + "shortcutRemove": "移除分配", + "shortcutRestoreDefault": "恢复默认设置", + "shortcutAssign": "分配", + "shortcutReassign": "重新分配", + "shortcutContextApplication": "应用程序", + "shortcutContextDocument": "文档视图", + "shortcutContextEditor": "编辑器和源码", + "shortcutContextWritersideSource": "Writerside 源码", + "shortcutContextWorkspace": "工作区导航", + "shortcutContextTree": "侧栏树", + "searchHeadingsCommand": "搜索标题", + "shortcutFixed": "由编辑器管理,无法配置。", + "shortcutDefaultConflict": "默认快捷键与自定义分配冲突。", + "shortcutCurrentBinding": "当前快捷键", + "shortcutProposedBinding": "拟用快捷键" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 3150b175..ae26f8f2 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1189,24 +1189,6 @@ abstract class AppLocalizations { /// **'Keyboard Shortcuts'** String get keyboardShortcuts; - /// Title and command label for the searchable command palette. - /// - /// In en, this message translates to: - /// **'Command Palette'** - String get commandPalette; - - /// Search hint in the command palette. - /// - /// In en, this message translates to: - /// **'Type a command'** - String get commandPaletteHint; - - /// Empty state in the command palette. - /// - /// In en, this message translates to: - /// **'No matching commands'** - String get commandPaletteEmpty; - /// Reason shown for a disabled contextual command. /// /// In en, this message translates to: @@ -1630,7 +1612,7 @@ abstract class AppLocalizations { /// Keyboard shortcut description for opening shortcuts. /// /// In en, this message translates to: - /// **'Show this keyboard shortcut reference'** + /// **'Customize application keyboard shortcuts'** String get shortcutKeyboardShortcutsDescription; /// Keyboard shortcut description for opening the Syntax Reference. @@ -2077,10 +2059,10 @@ abstract class AppLocalizations { /// **'Validate on edit'** String get validateOnEdit; - /// Settings action label for clearing recent workspaces. + /// Settings action for clearing Recent navigation metadata in both document and workspace sections. /// /// In en, this message translates to: - /// **'Clear recent workspaces'** + /// **'Clear recent history'** String get clearRecentWorkspaces; /// Settings label for WYSIWYG editing button position. @@ -3391,24 +3373,6 @@ abstract class AppLocalizations { /// **'Next match'** String get sourceSearchNextMatch; - /// Tooltip for toggling case-sensitive source search. - /// - /// In en, this message translates to: - /// **'Case sensitive'** - String get sourceSearchCaseSensitive; - - /// Tooltip for toggling whole-word source search. - /// - /// In en, this message translates to: - /// **'Whole word'** - String get sourceSearchWholeWord; - - /// Tooltip for toggling regular-expression source search. - /// - /// In en, this message translates to: - /// **'Regex'** - String get sourceSearchRegex; - /// Placeholder for the active-document replacement field. /// /// In en, this message translates to: @@ -3613,18 +3577,6 @@ abstract class AppLocalizations { /// **'Save As'** String get saveAs; - /// Source search status shown when the regular expression is invalid. - /// - /// In en, this message translates to: - /// **'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: @@ -7368,12 +7320,6 @@ abstract class AppLocalizations { /// **'Clipboard History'** String get clipboardHistory; - /// No description provided for @clipboardHistorySearchHint. - /// - /// In en, this message translates to: - /// **'Search clipboard history'** - String get clipboardHistorySearchHint; - /// No description provided for @clipboardHistoryDescription. /// /// In en, this message translates to: @@ -7410,12 +7356,6 @@ abstract class AppLocalizations { /// **'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: @@ -7512,18 +7452,6 @@ abstract class AppLocalizations { /// **'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: @@ -9251,6 +9179,342 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The inspected element is unavailable. Inspect an element to continue.'** String get wsPropertiesUnavailable; + + /// No description provided for @searchDocumentsCategory. + /// + /// In en, this message translates to: + /// **'Documents'** + String get searchDocumentsCategory; + + /// No description provided for @searchTopicsCategory. + /// + /// In en, this message translates to: + /// **'Topics'** + String get searchTopicsCategory; + + /// No description provided for @searchCurrentDocument. + /// + /// In en, this message translates to: + /// **'Current document'** + String get searchCurrentDocument; + + /// No description provided for @searchCurrentWorkspace. + /// + /// In en, this message translates to: + /// **'Current workspace'** + String get searchCurrentWorkspace; + + /// No description provided for @browseNotes. + /// + /// In en, this message translates to: + /// **'Browse Notes'** + String get browseNotes; + + /// No description provided for @searchActionsCommand. + /// + /// In en, this message translates to: + /// **'Search Actions'** + String get searchActionsCommand; + + /// No description provided for @searchDocumentsCommand. + /// + /// In en, this message translates to: + /// **'Search Documents'** + String get searchDocumentsCommand; + + /// No description provided for @searchTopicsCommand. + /// + /// In en, this message translates to: + /// **'Search Topics'** + String get searchTopicsCommand; + + /// No description provided for @searchRecentCommand. + /// + /// In en, this message translates to: + /// **'Search Recent'** + String get searchRecentCommand; + + /// No description provided for @searchResourceUnavailable. + /// + /// In en, this message translates to: + /// **'This resource is unavailable.'** + String get searchResourceUnavailable; + + /// Command to synchronize the active Nextcloud Notes account. + /// + /// In en, this message translates to: + /// **'Sync notes'** + String get notesSync; + + /// No description provided for @searchInWorkspace. + /// + /// In en, this message translates to: + /// **'Search in Workspace'** + String get searchInWorkspace; + + /// No description provided for @searchCategory. + /// + /// In en, this message translates to: + /// **'Search category'** + String get searchCategory; + + /// No description provided for @clearHistorySearchScope. + /// + /// In en, this message translates to: + /// **'Search history across documents'** + String get clearHistorySearchScope; + + /// No description provided for @searchInDocument. + /// + /// In en, this message translates to: + /// **'Search in Document'** + String get searchInDocument; + + /// No description provided for @searchDocumentHint. + /// + /// In en, this message translates to: + /// **'Search this document…'** + String get searchDocumentHint; + + /// No description provided for @searchWorkspaceHint. + /// + /// In en, this message translates to: + /// **'Search this workspace…'** + String get searchWorkspaceHint; + + /// No description provided for @searchOutlineHint. + /// + /// In en, this message translates to: + /// **'Search headings…'** + String get searchOutlineHint; + + /// No description provided for @searchHistoryHint. + /// + /// In en, this message translates to: + /// **'Search Local History…'** + String get searchHistoryHint; + + /// No description provided for @searchEnterText. + /// + /// In en, this message translates to: + /// **'Enter text to search'** + String get searchEnterText; + + /// No description provided for @searchAccountUnavailable. + /// + /// In en, this message translates to: + /// **'Account unavailable'** + String get searchAccountUnavailable; + + /// No description provided for @searchDocumentsHint. + /// + /// In en, this message translates to: + /// **'Search documents…'** + String get searchDocumentsHint; + + /// No description provided for @searchTopicsHint. + /// + /// In en, this message translates to: + /// **'Search topics…'** + String get searchTopicsHint; + + /// No description provided for @searchRecentHint. + /// + /// In en, this message translates to: + /// **'Search recent resources…'** + String get searchRecentHint; + + /// No description provided for @searchActionsHint. + /// + /// In en, this message translates to: + /// **'Search actions…'** + String get searchActionsHint; + + /// No description provided for @searchRequiresNotesWorkspace. + /// + /// In en, this message translates to: + /// **'Requires a Notes workspace'** + String get searchRequiresNotesWorkspace; + + /// Search and keyboard shortcut settings: searchSidebarLabel + /// + /// In en, this message translates to: + /// **'Search:'** + String get searchSidebarLabel; + + /// Search and keyboard shortcut settings: searchDocumentTextCategory + /// + /// In en, this message translates to: + /// **'Document Text'** + String get searchDocumentTextCategory; + + /// Search and keyboard shortcut settings: searchWorkspaceTextCategory + /// + /// In en, this message translates to: + /// **'Workspace Text'** + String get searchWorkspaceTextCategory; + + /// Search and keyboard shortcut settings: searchHeadingsCategory + /// + /// In en, this message translates to: + /// **'Headings'** + String get searchHeadingsCategory; + + /// Search and keyboard shortcut settings: shortcutUnassigned + /// + /// In en, this message translates to: + /// **'Unassigned'** + String get shortcutUnassigned; + + /// Search and keyboard shortcut settings: shortcutPressKeys + /// + /// In en, this message translates to: + /// **'Press a key combination…'** + String get shortcutPressKeys; + + /// Search and keyboard shortcut settings: shortcutRecord + /// + /// In en, this message translates to: + /// **'Record shortcut'** + String get shortcutRecord; + + /// Search and keyboard shortcut settings: shortcutRestoreAll + /// + /// In en, this message translates to: + /// **'Restore all defaults'** + String get shortcutRestoreAll; + + /// Search and keyboard shortcut settings: shortcutRestoreAllQuestion + /// + /// In en, this message translates to: + /// **'Restore every action’s default shortcut? Custom assignments will be removed.'** + String get shortcutRestoreAllQuestion; + + /// Search and keyboard shortcut settings: shortcutSaveFailed + /// + /// In en, this message translates to: + /// **'Shortcuts are active but could not be saved. Retry to keep them after restart.'** + String get shortcutSaveFailed; + + /// Search and keyboard shortcut settings: shortcutRetry + /// + /// In en, this message translates to: + /// **'Retry saving'** + String get shortcutRetry; + + /// Search and keyboard shortcut settings: shortcutChanged + /// + /// In en, this message translates to: + /// **'Assignments changed. Review the current shortcuts and try again.'** + String get shortcutChanged; + + /// Search and keyboard shortcut settings: shortcutInvalid + /// + /// In en, this message translates to: + /// **'This combination cannot be assigned in this context.'** + String get shortcutInvalid; + + /// Search and keyboard shortcut settings: shortcutSystemWarning + /// + /// In en, this message translates to: + /// **'This combination may be used by your desktop. BusyMark cannot override system shortcuts.'** + String get shortcutSystemWarning; + + /// Search and keyboard shortcut settings: shortcutConflicts + /// + /// In en, this message translates to: + /// **'This shortcut overlaps these actions:'** + String get shortcutConflicts; + + /// Search and keyboard shortcut settings: shortcutRemove + /// + /// In en, this message translates to: + /// **'Remove assignment'** + String get shortcutRemove; + + /// Search and keyboard shortcut settings: shortcutRestoreDefault + /// + /// In en, this message translates to: + /// **'Restore default'** + String get shortcutRestoreDefault; + + /// Search and keyboard shortcut settings: shortcutAssign + /// + /// In en, this message translates to: + /// **'Assign'** + String get shortcutAssign; + + /// Search and keyboard shortcut settings: shortcutReassign + /// + /// In en, this message translates to: + /// **'Reassign'** + String get shortcutReassign; + + /// Search and keyboard shortcut settings: shortcutContextApplication + /// + /// In en, this message translates to: + /// **'Application'** + String get shortcutContextApplication; + + /// Search and keyboard shortcut settings: shortcutContextDocument + /// + /// In en, this message translates to: + /// **'Document views'** + String get shortcutContextDocument; + + /// Search and keyboard shortcut settings: shortcutContextEditor + /// + /// In en, this message translates to: + /// **'Editor and Source'** + String get shortcutContextEditor; + + /// Search and keyboard shortcut settings: shortcutContextWritersideSource + /// + /// In en, this message translates to: + /// **'Writerside Source'** + String get shortcutContextWritersideSource; + + /// Search and keyboard shortcut settings: shortcutContextWorkspace + /// + /// In en, this message translates to: + /// **'Workspace navigation'** + String get shortcutContextWorkspace; + + /// Search and keyboard shortcut settings: shortcutContextTree + /// + /// In en, this message translates to: + /// **'Sidebar trees'** + String get shortcutContextTree; + + /// No description provided for @searchHeadingsCommand. + /// + /// In en, this message translates to: + /// **'Search Headings'** + String get searchHeadingsCommand; + + /// No description provided for @shortcutFixed. + /// + /// In en, this message translates to: + /// **'Handled by the editor; not configurable.'** + String get shortcutFixed; + + /// No description provided for @shortcutDefaultConflict. + /// + /// In en, this message translates to: + /// **'Default shortcut conflicts with a custom assignment.'** + String get shortcutDefaultConflict; + + /// No description provided for @shortcutCurrentBinding. + /// + /// In en, this message translates to: + /// **'Current shortcut'** + String get shortcutCurrentBinding; + + /// No description provided for @shortcutProposedBinding. + /// + /// In en, this message translates to: + /// **'Proposed shortcut'** + String get shortcutProposedBinding; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 97db9500..70d3ed99 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -577,15 +577,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get keyboardShortcuts => 'اختصارات لوحة المفاتيح'; - @override - String get commandPalette => 'لوحة الأوامر'; - - @override - String get commandPaletteHint => 'اكتب أمرًا'; - - @override - String get commandPaletteEmpty => 'لا توجد أوامر مطابقة'; - @override String get commandUnavailableInContext => 'هذا الأمر غير متاح في السياق الحالي.'; @@ -803,7 +794,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'إظهار مرجع اختصارات لوحة المفاتيح'; + 'تخصيص اختصارات لوحة مفاتيح التطبيق'; @override String get shortcutSyntaxReferenceDescription => 'فتح مرجع بناء الجملة'; @@ -1045,7 +1036,7 @@ class AppLocalizationsAr extends AppLocalizations { String get validateOnEdit => 'التحقق أثناء التحرير'; @override - String get clearRecentWorkspaces => 'مسح مساحات العمل الأخيرة'; + String get clearRecentWorkspaces => 'مسح السجل الأخير'; @override String get editingButtonsPosition => 'موضع أزرار التحرير'; @@ -1847,15 +1838,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get sourceSearchNextMatch => 'التطابق التالي'; - @override - String get sourceSearchCaseSensitive => 'حساس لحالة الأحرف'; - - @override - String get sourceSearchWholeWord => 'الكلمة بالكامل'; - - @override - String get sourceSearchRegex => 'تعبير نمطي'; - @override String get sourceSearchReplacement => 'استبدال بـ'; @@ -1981,13 +1963,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get saveAs => 'حفظ باسم'; - @override - String get sourceSearchInvalidRegex => 'تعبير نمطي غير صالح'; - - @override - String get sourceSearchZeroLengthUnsupported => - 'لا تُدعم مطابقات التعبير النمطي ذات الطول الصفري (مثل ^ أو \$ أو الاستباق الذي يطابق موضعًا فقط).'; - @override String get workspaceSearchIncomplete => 'تظهر بعض النتائج فقط. اعرض المزيد أو ضيّق نطاق البحث.'; @@ -4387,9 +4362,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get clipboardHistory => 'سجل النسخ واللصق'; - @override - String get clipboardHistorySearchHint => 'البحث في سجل الحافظة'; - @override String get clipboardHistoryDescription => 'افتح سجل النسخ واللصق الخاص بالجلسة.'; @@ -4411,10 +4383,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get clipboardNoItems => 'سجل النسخ واللصق فارغ.'; - @override - String get clipboardNoMatches => - 'لا توجد عناصر في سجل النسخ واللصق تطابق هذا البحث.'; - @override String get clipboardDisabled => 'مجموعة سجل النسخ واللصق معطلة في الإعدادات.'; @@ -4470,13 +4438,6 @@ class AppLocalizationsAr extends AppLocalizations { String get localHistoryNoRevisions => 'هذا المستند ليس لديه إصدارات في التاريخ المحلي.'; - @override - String get localHistoryNoMatches => - 'لا تتطابق أي إصدارات في التاريخ المحلي مع هذا البحث.'; - - @override - String get localHistorySearchHint => 'البحث في السجل المحلي'; - @override String get localHistorySelectedRevision => 'الإصدار المحدد'; @@ -5423,4 +5384,177 @@ class AppLocalizationsAr extends AppLocalizations { @override String get wsPropertiesUnavailable => 'العنصر الذي يتم فحصه غير متاح. افحص عنصرًا للمتابعة.'; + + @override + String get searchDocumentsCategory => 'المستندات'; + + @override + String get searchTopicsCategory => 'المواضيع'; + + @override + String get searchCurrentDocument => 'المستند الحالي'; + + @override + String get searchCurrentWorkspace => 'مساحة العمل الحالية'; + + @override + String get browseNotes => 'تصفح الملاحظات'; + + @override + String get searchActionsCommand => 'البحث عن إجراءات'; + + @override + String get searchDocumentsCommand => 'البحث عن مستندات'; + + @override + String get searchTopicsCommand => 'البحث عن مواضيع'; + + @override + String get searchRecentCommand => 'البحث في الأخيرة'; + + @override + String get searchResourceUnavailable => 'هذا المورد غير متاح.'; + + @override + String get notesSync => 'مزامنة الملاحظات'; + + @override + String get searchInWorkspace => 'البحث في مساحة العمل'; + + @override + String get searchCategory => 'فئة البحث'; + + @override + String get clearHistorySearchScope => 'البحث في سجل المستندات'; + + @override + String get searchInDocument => 'البحث في المستند'; + + @override + String get searchDocumentHint => 'ابحث في هذا المستند…'; + + @override + String get searchWorkspaceHint => 'ابحث في مساحة العمل هذه…'; + + @override + String get searchOutlineHint => 'ابحث في العناوين…'; + + @override + String get searchHistoryHint => 'ابحث في السجل المحلي…'; + + @override + String get searchEnterText => 'أدخل نصًا للبحث'; + + @override + String get searchAccountUnavailable => 'الحساب غير متاح'; + + @override + String get searchDocumentsHint => 'البحث عن مستندات…'; + + @override + String get searchTopicsHint => 'البحث عن مواضيع…'; + + @override + String get searchRecentHint => 'البحث في الأخيرة…'; + + @override + String get searchActionsHint => 'البحث عن إجراءات…'; + + @override + String get searchRequiresNotesWorkspace => 'يتطلب مساحة عمل للملاحظات'; + + @override + String get searchSidebarLabel => 'بحث:'; + + @override + String get searchDocumentTextCategory => 'نص المستند'; + + @override + String get searchWorkspaceTextCategory => 'نص مساحة العمل'; + + @override + String get searchHeadingsCategory => 'العناوين'; + + @override + String get shortcutUnassigned => 'غير معيّن'; + + @override + String get shortcutPressKeys => 'اضغط مجموعة مفاتيح…'; + + @override + String get shortcutRecord => 'تسجيل اختصار'; + + @override + String get shortcutRestoreAll => 'استعادة جميع الإعدادات الافتراضية'; + + @override + String get shortcutRestoreAllQuestion => + 'هل تريد استعادة الاختصارات الافتراضية لجميع الإجراءات؟ ستُزال التعيينات المخصصة.'; + + @override + String get shortcutSaveFailed => + 'الاختصارات مفعّلة ولكن تعذر حفظها. أعد المحاولة للاحتفاظ بها بعد إعادة التشغيل.'; + + @override + String get shortcutRetry => 'إعادة محاولة الحفظ'; + + @override + String get shortcutChanged => + 'تغيرت التعيينات. راجع الاختصارات الحالية وحاول مجددًا.'; + + @override + String get shortcutInvalid => 'لا يمكن تعيين هذه المجموعة في هذا السياق.'; + + @override + String get shortcutSystemWarning => + 'قد يستخدم سطح المكتب هذه المجموعة. لا يستطيع BusyMark تجاوز اختصارات النظام.'; + + @override + String get shortcutConflicts => 'يتعارض هذا الاختصار مع الإجراءات التالية:'; + + @override + String get shortcutRemove => 'إزالة التعيين'; + + @override + String get shortcutRestoreDefault => 'استعادة الافتراضي'; + + @override + String get shortcutAssign => 'تعيين'; + + @override + String get shortcutReassign => 'إعادة التعيين'; + + @override + String get shortcutContextApplication => 'التطبيق'; + + @override + String get shortcutContextDocument => 'طرق عرض المستند'; + + @override + String get shortcutContextEditor => 'المحرر والمصدر'; + + @override + String get shortcutContextWritersideSource => 'مصدر Writerside'; + + @override + String get shortcutContextWorkspace => 'التنقل في مساحة العمل'; + + @override + String get shortcutContextTree => 'أشجار الشريط الجانبي'; + + @override + String get searchHeadingsCommand => 'البحث عن العناوين'; + + @override + String get shortcutFixed => 'يتولاه المحرر؛ غير قابل للتخصيص.'; + + @override + String get shortcutDefaultConflict => + 'يتعارض الاختصار الافتراضي مع تعيين مخصص.'; + + @override + String get shortcutCurrentBinding => 'الاختصار الحالي'; + + @override + String get shortcutProposedBinding => 'الاختصار المقترح'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 721df893..c9f086f5 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -590,15 +590,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get keyboardShortcuts => 'Tastaturkürzel'; - @override - String get commandPalette => 'Befehlspalette'; - - @override - String get commandPaletteHint => 'Befehl eingeben'; - - @override - String get commandPaletteEmpty => 'Keine passenden Befehle'; - @override String get commandUnavailableInContext => 'Dieser Befehl ist im aktuellen Kontext nicht verfügbar.'; @@ -818,7 +809,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Diese Tastaturkürzelübersicht anzeigen'; + 'Tastenkürzel der Anwendung anpassen'; @override String get shortcutSyntaxReferenceDescription => 'Syntaxreferenz öffnen'; @@ -1075,8 +1066,7 @@ class AppLocalizationsDe extends AppLocalizations { String get validateOnEdit => 'Bei Bearbeitung validieren'; @override - String get clearRecentWorkspaces => - 'Zuletzt verwendete Arbeitsbereiche löschen'; + String get clearRecentWorkspaces => 'Zuletzt verwendete Einträge löschen'; @override String get editingButtonsPosition => 'Position der Bearbeitungsschaltflächen'; @@ -1866,15 +1856,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sourceSearchNextMatch => 'Nächster Treffer'; - @override - String get sourceSearchCaseSensitive => 'Groß-/Kleinschreibung beachten'; - - @override - String get sourceSearchWholeWord => 'Nur ganze Wörter'; - - @override - String get sourceSearchRegex => 'Regulärer Ausdruck'; - @override String get sourceSearchReplacement => 'Ersetzen durch'; @@ -2003,13 +1984,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get saveAs => 'Speichern unter'; - @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.'; @@ -4389,9 +4363,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get clipboardHistory => 'Zwischenablage-Historie'; - @override - String get clipboardHistorySearchHint => 'Zwischenablageverlauf durchsuchen'; - @override String get clipboardHistoryDescription => 'Öffne die Sitzungs-spezifische Zwischenablage-Historie.'; @@ -4415,10 +4386,6 @@ class AppLocalizationsDe extends AppLocalizations { @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.'; @@ -4476,13 +4443,6 @@ class AppLocalizationsDe extends AppLocalizations { 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'; @@ -5445,4 +5405,181 @@ class AppLocalizationsDe extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Das betrachtete Element ist nicht verfügbar. Wählen Sie ein Element zum Fortfahren.'; + + @override + String get searchDocumentsCategory => 'Dokumente'; + + @override + String get searchTopicsCategory => 'Themen'; + + @override + String get searchCurrentDocument => 'Aktuelles Dokument'; + + @override + String get searchCurrentWorkspace => 'Aktueller Arbeitsbereich'; + + @override + String get browseNotes => 'Notizen durchsuchen'; + + @override + String get searchActionsCommand => 'Aktionen suchen'; + + @override + String get searchDocumentsCommand => 'Dokumente suchen'; + + @override + String get searchTopicsCommand => 'Themen suchen'; + + @override + String get searchRecentCommand => 'Zuletzt geöffnet suchen'; + + @override + String get searchResourceUnavailable => + 'Diese Ressource ist nicht verfügbar.'; + + @override + String get notesSync => 'Notizen synchronisieren'; + + @override + String get searchInWorkspace => 'Im Arbeitsbereich suchen'; + + @override + String get searchCategory => 'Suchkategorie'; + + @override + String get clearHistorySearchScope => 'Verlauf aller Dokumente durchsuchen'; + + @override + String get searchInDocument => 'Im Dokument suchen'; + + @override + String get searchDocumentHint => 'Dieses Dokument durchsuchen…'; + + @override + String get searchWorkspaceHint => 'Diesen Arbeitsbereich durchsuchen…'; + + @override + String get searchOutlineHint => 'Überschriften suchen…'; + + @override + String get searchHistoryHint => 'Lokalen Verlauf durchsuchen…'; + + @override + String get searchEnterText => 'Suchtext eingeben'; + + @override + String get searchAccountUnavailable => 'Konto nicht verfügbar'; + + @override + String get searchDocumentsHint => 'Dokumente suchen…'; + + @override + String get searchTopicsHint => 'Themen suchen…'; + + @override + String get searchRecentHint => 'Zuletzt geöffnet suchen…'; + + @override + String get searchActionsHint => 'Aktionen suchen…'; + + @override + String get searchRequiresNotesWorkspace => + 'Erfordert einen Notizen-Arbeitsbereich'; + + @override + String get searchSidebarLabel => 'Suchen:'; + + @override + String get searchDocumentTextCategory => 'Dokumenttext'; + + @override + String get searchWorkspaceTextCategory => 'Arbeitsbereichstext'; + + @override + String get searchHeadingsCategory => 'Überschriften'; + + @override + String get shortcutUnassigned => 'Nicht zugewiesen'; + + @override + String get shortcutPressKeys => 'Tastenkombination drücken…'; + + @override + String get shortcutRecord => 'Tastenkürzel aufnehmen'; + + @override + String get shortcutRestoreAll => 'Alle Vorgaben wiederherstellen'; + + @override + String get shortcutRestoreAllQuestion => + 'Standardkürzel aller Aktionen wiederherstellen? Eigene Zuweisungen werden entfernt.'; + + @override + String get shortcutSaveFailed => + 'Tastenkürzel sind aktiv, konnten aber nicht gespeichert werden. Zum Beibehalten nach Neustart erneut speichern.'; + + @override + String get shortcutRetry => 'Speichern wiederholen'; + + @override + String get shortcutChanged => + 'Zuweisungen geändert. Aktuelle Tastenkürzel prüfen und erneut versuchen.'; + + @override + String get shortcutInvalid => + 'Diese Kombination kann hier nicht zugewiesen werden.'; + + @override + String get shortcutSystemWarning => + 'Diese Kombination wird möglicherweise vom Desktop verwendet. BusyMark kann Systemkürzel nicht überschreiben.'; + + @override + String get shortcutConflicts => + 'Dieses Tastenkürzel überschneidet sich mit diesen Aktionen:'; + + @override + String get shortcutRemove => 'Zuweisung entfernen'; + + @override + String get shortcutRestoreDefault => 'Vorgabe wiederherstellen'; + + @override + String get shortcutAssign => 'Zuweisen'; + + @override + String get shortcutReassign => 'Neu zuweisen'; + + @override + String get shortcutContextApplication => 'Anwendung'; + + @override + String get shortcutContextDocument => 'Dokumentansichten'; + + @override + String get shortcutContextEditor => 'Editor und Quelltext'; + + @override + String get shortcutContextWritersideSource => 'Writerside-Quelltext'; + + @override + String get shortcutContextWorkspace => 'Arbeitsbereichsnavigation'; + + @override + String get shortcutContextTree => 'Seitenleistenbäume'; + + @override + String get searchHeadingsCommand => 'Überschriften suchen'; + + @override + String get shortcutFixed => 'Vom Editor verwaltet; nicht konfigurierbar.'; + + @override + String get shortcutDefaultConflict => + 'Das Standardkürzel steht mit einer eigenen Zuweisung in Konflikt.'; + + @override + String get shortcutCurrentBinding => 'Aktuelles Tastenkürzel'; + + @override + String get shortcutProposedBinding => 'Vorgeschlagenes Tastenkürzel'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f07b1c4f..2362581f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -583,15 +583,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keyboardShortcuts => 'Keyboard Shortcuts'; - @override - String get commandPalette => 'Command Palette'; - - @override - String get commandPaletteHint => 'Type a command'; - - @override - String get commandPaletteEmpty => 'No matching commands'; - @override String get commandUnavailableInContext => 'Unavailable in the current editor context'; @@ -810,7 +801,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Show this keyboard shortcut reference'; + 'Customize application keyboard shortcuts'; @override String get shortcutSyntaxReferenceDescription => 'Open the Syntax Reference'; @@ -1058,7 +1049,7 @@ class AppLocalizationsEn extends AppLocalizations { String get validateOnEdit => 'Validate on edit'; @override - String get clearRecentWorkspaces => 'Clear recent workspaces'; + String get clearRecentWorkspaces => 'Clear recent history'; @override String get editingButtonsPosition => 'Editing buttons position'; @@ -1838,15 +1829,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sourceSearchNextMatch => 'Next match'; - @override - String get sourceSearchCaseSensitive => 'Case sensitive'; - - @override - String get sourceSearchWholeWord => 'Whole word'; - - @override - String get sourceSearchRegex => 'Regex'; - @override String get sourceSearchReplacement => 'Replace with'; @@ -1979,13 +1961,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get saveAs => 'Save As'; - @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.'; @@ -4374,9 +4349,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clipboardHistory => 'Clipboard History'; - @override - String get clipboardHistorySearchHint => 'Search clipboard history'; - @override String get clipboardHistoryDescription => 'Open the session-local clipboard history.'; @@ -4399,9 +4371,6 @@ class AppLocalizationsEn extends AppLocalizations { @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.'; @@ -4457,13 +4426,6 @@ class AppLocalizationsEn extends AppLocalizations { 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'; @@ -5413,4 +5375,178 @@ class AppLocalizationsEn extends AppLocalizations { @override String get wsPropertiesUnavailable => 'The inspected element is unavailable. Inspect an element to continue.'; + + @override + String get searchDocumentsCategory => 'Documents'; + + @override + String get searchTopicsCategory => 'Topics'; + + @override + String get searchCurrentDocument => 'Current document'; + + @override + String get searchCurrentWorkspace => 'Current workspace'; + + @override + String get browseNotes => 'Browse Notes'; + + @override + String get searchActionsCommand => 'Search Actions'; + + @override + String get searchDocumentsCommand => 'Search Documents'; + + @override + String get searchTopicsCommand => 'Search Topics'; + + @override + String get searchRecentCommand => 'Search Recent'; + + @override + String get searchResourceUnavailable => 'This resource is unavailable.'; + + @override + String get notesSync => 'Sync notes'; + + @override + String get searchInWorkspace => 'Search in Workspace'; + + @override + String get searchCategory => 'Search category'; + + @override + String get clearHistorySearchScope => 'Search history across documents'; + + @override + String get searchInDocument => 'Search in Document'; + + @override + String get searchDocumentHint => 'Search this document…'; + + @override + String get searchWorkspaceHint => 'Search this workspace…'; + + @override + String get searchOutlineHint => 'Search headings…'; + + @override + String get searchHistoryHint => 'Search Local History…'; + + @override + String get searchEnterText => 'Enter text to search'; + + @override + String get searchAccountUnavailable => 'Account unavailable'; + + @override + String get searchDocumentsHint => 'Search documents…'; + + @override + String get searchTopicsHint => 'Search topics…'; + + @override + String get searchRecentHint => 'Search recent resources…'; + + @override + String get searchActionsHint => 'Search actions…'; + + @override + String get searchRequiresNotesWorkspace => 'Requires a Notes workspace'; + + @override + String get searchSidebarLabel => 'Search:'; + + @override + String get searchDocumentTextCategory => 'Document Text'; + + @override + String get searchWorkspaceTextCategory => 'Workspace Text'; + + @override + String get searchHeadingsCategory => 'Headings'; + + @override + String get shortcutUnassigned => 'Unassigned'; + + @override + String get shortcutPressKeys => 'Press a key combination…'; + + @override + String get shortcutRecord => 'Record shortcut'; + + @override + String get shortcutRestoreAll => 'Restore all defaults'; + + @override + String get shortcutRestoreAllQuestion => + 'Restore every action’s default shortcut? Custom assignments will be removed.'; + + @override + String get shortcutSaveFailed => + 'Shortcuts are active but could not be saved. Retry to keep them after restart.'; + + @override + String get shortcutRetry => 'Retry saving'; + + @override + String get shortcutChanged => + 'Assignments changed. Review the current shortcuts and try again.'; + + @override + String get shortcutInvalid => + 'This combination cannot be assigned in this context.'; + + @override + String get shortcutSystemWarning => + 'This combination may be used by your desktop. BusyMark cannot override system shortcuts.'; + + @override + String get shortcutConflicts => 'This shortcut overlaps these actions:'; + + @override + String get shortcutRemove => 'Remove assignment'; + + @override + String get shortcutRestoreDefault => 'Restore default'; + + @override + String get shortcutAssign => 'Assign'; + + @override + String get shortcutReassign => 'Reassign'; + + @override + String get shortcutContextApplication => 'Application'; + + @override + String get shortcutContextDocument => 'Document views'; + + @override + String get shortcutContextEditor => 'Editor and Source'; + + @override + String get shortcutContextWritersideSource => 'Writerside Source'; + + @override + String get shortcutContextWorkspace => 'Workspace navigation'; + + @override + String get shortcutContextTree => 'Sidebar trees'; + + @override + String get searchHeadingsCommand => 'Search Headings'; + + @override + String get shortcutFixed => 'Handled by the editor; not configurable.'; + + @override + String get shortcutDefaultConflict => + 'Default shortcut conflicts with a custom assignment.'; + + @override + String get shortcutCurrentBinding => 'Current shortcut'; + + @override + String get shortcutProposedBinding => 'Proposed shortcut'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 243eae28..8fa585ae 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -586,15 +586,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get keyboardShortcuts => 'Atajos de teclado'; - @override - String get commandPalette => 'Paleta de comandos'; - - @override - String get commandPaletteHint => 'Escribe un comando'; - - @override - String get commandPaletteEmpty => 'No hay comandos coincidentes'; - @override String get commandUnavailableInContext => 'Este comando no está disponible en el contexto actual.'; @@ -814,7 +805,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Mostrar esta referencia de atajos de teclado'; + 'Personalizar los atajos de teclado de la aplicación'; @override String get shortcutSyntaxReferenceDescription => @@ -1073,7 +1064,7 @@ class AppLocalizationsEs extends AppLocalizations { String get validateOnEdit => 'Validar al editar'; @override - String get clearRecentWorkspaces => 'Borrar espacios de trabajo recientes'; + String get clearRecentWorkspaces => 'Borrar el historial reciente'; @override String get editingButtonsPosition => 'Posición de los botones de edición'; @@ -1861,15 +1852,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sourceSearchNextMatch => 'Coincidencia siguiente'; - @override - String get sourceSearchCaseSensitive => 'Distinguir mayúsculas y minúsculas'; - - @override - String get sourceSearchWholeWord => 'Palabra completa'; - - @override - String get sourceSearchRegex => 'Expresión regular'; - @override String get sourceSearchReplacement => 'Reemplazar por'; @@ -1997,13 +1979,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get saveAs => 'Guardar como'; - @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.'; @@ -4389,10 +4364,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get clipboardHistory => 'Historial de la Copia'; - @override - String get clipboardHistorySearchHint => - 'Buscar en el historial del portapapeles'; - @override String get clipboardHistoryDescription => 'Abrir el historial de la copia local de la sesión.'; @@ -4415,10 +4386,6 @@ class AppLocalizationsEs extends AppLocalizations { @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.'; @@ -4476,13 +4443,6 @@ class AppLocalizationsEs extends AppLocalizations { 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'; @@ -5448,4 +5408,181 @@ class AppLocalizationsEs extends AppLocalizations { @override String get wsPropertiesUnavailable => 'El elemento inspeccionado no está disponible. Inspeccione otro elemento para continuar.'; + + @override + String get searchDocumentsCategory => 'Documentos'; + + @override + String get searchTopicsCategory => 'Temas'; + + @override + String get searchCurrentDocument => 'Documento actual'; + + @override + String get searchCurrentWorkspace => 'Espacio de trabajo actual'; + + @override + String get browseNotes => 'Explorar notas'; + + @override + String get searchActionsCommand => 'Buscar acciones'; + + @override + String get searchDocumentsCommand => 'Buscar documentos'; + + @override + String get searchTopicsCommand => 'Buscar temas'; + + @override + String get searchRecentCommand => 'Buscar recientes'; + + @override + String get searchResourceUnavailable => 'Este recurso no está disponible.'; + + @override + String get notesSync => 'Sincronizar notas'; + + @override + String get searchInWorkspace => 'Buscar en espacio de trabajo'; + + @override + String get searchCategory => 'Categoría de búsqueda'; + + @override + String get clearHistorySearchScope => 'Buscar en el historial de documentos'; + + @override + String get searchInDocument => 'Buscar en el documento'; + + @override + String get searchDocumentHint => 'Buscar en este documento…'; + + @override + String get searchWorkspaceHint => 'Buscar en este espacio de trabajo…'; + + @override + String get searchOutlineHint => 'Buscar encabezados…'; + + @override + String get searchHistoryHint => 'Buscar en el historial local…'; + + @override + String get searchEnterText => 'Introduce texto para buscar'; + + @override + String get searchAccountUnavailable => 'Cuenta no disponible'; + + @override + String get searchDocumentsHint => 'Buscar documentos…'; + + @override + String get searchTopicsHint => 'Buscar temas…'; + + @override + String get searchRecentHint => 'Buscar recientes…'; + + @override + String get searchActionsHint => 'Buscar acciones…'; + + @override + String get searchRequiresNotesWorkspace => + 'Requiere un espacio de trabajo de Notas'; + + @override + String get searchSidebarLabel => 'Buscar:'; + + @override + String get searchDocumentTextCategory => 'Texto del documento'; + + @override + String get searchWorkspaceTextCategory => 'Texto del espacio de trabajo'; + + @override + String get searchHeadingsCategory => 'Encabezados'; + + @override + String get shortcutUnassigned => 'Sin asignar'; + + @override + String get shortcutPressKeys => 'Pulse una combinación de teclas…'; + + @override + String get shortcutRecord => 'Capturar atajo'; + + @override + String get shortcutRestoreAll => + 'Restaurar todos los valores predeterminados'; + + @override + String get shortcutRestoreAllQuestion => + '¿Restaurar los atajos predeterminados de todas las acciones? Se eliminarán las asignaciones personalizadas.'; + + @override + String get shortcutSaveFailed => + 'Los atajos están activos, pero no se pudieron guardar. Reintente para conservarlos tras reiniciar.'; + + @override + String get shortcutRetry => 'Reintentar guardado'; + + @override + String get shortcutChanged => + 'Las asignaciones cambiaron. Revise los atajos actuales y reintente.'; + + @override + String get shortcutInvalid => + 'Esta combinación no se puede asignar en este contexto.'; + + @override + String get shortcutSystemWarning => + 'El escritorio puede usar esta combinación. BusyMark no puede sustituir atajos del sistema.'; + + @override + String get shortcutConflicts => + 'Este atajo entra en conflicto con estas acciones:'; + + @override + String get shortcutRemove => 'Quitar asignación'; + + @override + String get shortcutRestoreDefault => 'Restaurar valor predeterminado'; + + @override + String get shortcutAssign => 'Asignar'; + + @override + String get shortcutReassign => 'Reasignar'; + + @override + String get shortcutContextApplication => 'Aplicación'; + + @override + String get shortcutContextDocument => 'Vistas del documento'; + + @override + String get shortcutContextEditor => 'Editor y código fuente'; + + @override + String get shortcutContextWritersideSource => 'Código fuente de Writerside'; + + @override + String get shortcutContextWorkspace => 'Navegación del espacio de trabajo'; + + @override + String get shortcutContextTree => 'Árboles laterales'; + + @override + String get searchHeadingsCommand => 'Buscar encabezados'; + + @override + String get shortcutFixed => 'Lo gestiona el editor; no se puede configurar.'; + + @override + String get shortcutDefaultConflict => + 'El atajo predeterminado entra en conflicto con una asignación personalizada.'; + + @override + String get shortcutCurrentBinding => 'Atajo actual'; + + @override + String get shortcutProposedBinding => 'Atajo propuesto'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index c8f55cf4..56abfe6d 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -578,15 +578,6 @@ class AppLocalizationsEt extends AppLocalizations { @override String get keyboardShortcuts => 'Kiirklahvid'; - @override - String get commandPalette => 'Käskude palett'; - - @override - String get commandPaletteHint => 'Sisesta käsk'; - - @override - String get commandPaletteEmpty => 'Sobivaid käske pole'; - @override String get commandUnavailableInContext => 'See käsk pole praeguses kontekstis saadaval.'; @@ -804,7 +795,8 @@ class AppLocalizationsEt extends AppLocalizations { String get shortcutSearchDescription => 'Otsi praegusest tööruumist'; @override - String get shortcutKeyboardShortcutsDescription => 'Kuva kiirklahvide loend'; + String get shortcutKeyboardShortcutsDescription => + 'Kohanda rakenduse kiirklahve'; @override String get shortcutSyntaxReferenceDescription => 'Ava süntaksiviide'; @@ -1056,7 +1048,7 @@ class AppLocalizationsEt extends AppLocalizations { String get validateOnEdit => 'Valideeri muutmisel'; @override - String get clearRecentWorkspaces => 'Tühjenda hiljutiste tööruumide loend'; + String get clearRecentWorkspaces => 'Tühjenda hiljutiste kirjete loend'; @override String get editingButtonsPosition => 'Redigeerimisnuppude asukoht'; @@ -1839,15 +1831,6 @@ class AppLocalizationsEt extends AppLocalizations { @override String get sourceSearchNextMatch => 'Järgmine vaste'; - @override - String get sourceSearchCaseSensitive => 'Tõstutundlik'; - - @override - String get sourceSearchWholeWord => 'Terve sõna'; - - @override - String get sourceSearchRegex => 'Regulaaravaldis'; - @override String get sourceSearchReplacement => 'Asenda väärtusega'; @@ -1973,13 +1956,6 @@ class AppLocalizationsEt extends AppLocalizations { @override String get saveAs => 'Salvesta nimega'; - @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.'; @@ -4348,9 +4324,6 @@ class AppLocalizationsEt extends AppLocalizations { @override String get clipboardHistory => 'Kleepimiskredi ajalugu'; - @override - String get clipboardHistorySearchHint => 'Otsi lõikepuhvri ajaloost'; - @override String get clipboardHistoryDescription => 'Ava sessiooni-spetsiifiline kleepimiskredi ajalugu.'; @@ -4374,10 +4347,6 @@ class AppLocalizationsEt extends AppLocalizations { @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.'; @@ -4434,13 +4403,6 @@ class AppLocalizationsEt extends AppLocalizations { 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'; @@ -5394,4 +5356,179 @@ class AppLocalizationsEt extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Vaadeldav element pole saadaval. Jätkamiseks vali element.'; + + @override + String get searchDocumentsCategory => 'Dokumendid'; + + @override + String get searchTopicsCategory => 'Teemad'; + + @override + String get searchCurrentDocument => 'Praegune dokument'; + + @override + String get searchCurrentWorkspace => 'Praegune tööruum'; + + @override + String get browseNotes => 'Sirvi märkmeid'; + + @override + String get searchActionsCommand => 'Otsi toiminguid'; + + @override + String get searchDocumentsCommand => 'Otsi dokumente'; + + @override + String get searchTopicsCommand => 'Otsi teemasid'; + + @override + String get searchRecentCommand => 'Otsi hiljutisi'; + + @override + String get searchResourceUnavailable => 'See ressurss pole saadaval.'; + + @override + String get notesSync => 'Sünkrooni märkmed'; + + @override + String get searchInWorkspace => 'Otsi tööalast'; + + @override + String get searchCategory => 'Otsingu kategooria'; + + @override + String get clearHistorySearchScope => 'Otsi dokumentide ajaloost'; + + @override + String get searchInDocument => 'Otsi dokumendist'; + + @override + String get searchDocumentHint => 'Otsi sellest dokumendist…'; + + @override + String get searchWorkspaceHint => 'Otsi sellest tööruumist…'; + + @override + String get searchOutlineHint => 'Otsi pealkirju…'; + + @override + String get searchHistoryHint => 'Otsi kohalikust ajaloost…'; + + @override + String get searchEnterText => 'Sisesta otsitav tekst'; + + @override + String get searchAccountUnavailable => 'Konto pole saadaval'; + + @override + String get searchDocumentsHint => 'Otsi dokumente…'; + + @override + String get searchTopicsHint => 'Otsi teemasid…'; + + @override + String get searchRecentHint => 'Otsi hiljutisi…'; + + @override + String get searchActionsHint => 'Otsi toiminguid…'; + + @override + String get searchRequiresNotesWorkspace => 'Vajab märkmete tööruumi'; + + @override + String get searchSidebarLabel => 'Otsi:'; + + @override + String get searchDocumentTextCategory => 'Dokumendi tekst'; + + @override + String get searchWorkspaceTextCategory => 'Tööala tekst'; + + @override + String get searchHeadingsCategory => 'Pealkirjad'; + + @override + String get shortcutUnassigned => 'Määramata'; + + @override + String get shortcutPressKeys => 'Vajuta klahvikombinatsiooni…'; + + @override + String get shortcutRecord => 'Salvesta kiirklahv'; + + @override + String get shortcutRestoreAll => 'Taasta kõik vaikeväärtused'; + + @override + String get shortcutRestoreAllQuestion => + 'Kas taastada kõigi toimingute vaikekiirklahvid? Kohandatud määrangud eemaldatakse.'; + + @override + String get shortcutSaveFailed => + 'Kiirklahvid on aktiivsed, kuid neid ei saanud salvestada. Proovi uuesti, et säilitada need pärast taaskäivitamist.'; + + @override + String get shortcutRetry => 'Proovi salvestamist uuesti'; + + @override + String get shortcutChanged => + 'Määrangud on muutunud. Vaata praegused kiirklahvid üle ja proovi uuesti.'; + + @override + String get shortcutInvalid => + 'Seda kombinatsiooni ei saa selles kontekstis määrata.'; + + @override + String get shortcutSystemWarning => + 'Töölaud võib seda kombinatsiooni kasutada. BusyMark ei saa süsteemi kiirklahve alistada.'; + + @override + String get shortcutConflicts => + 'See kiirklahv kattub järgmiste toimingutega:'; + + @override + String get shortcutRemove => 'Eemalda määrang'; + + @override + String get shortcutRestoreDefault => 'Taasta vaikeväärtus'; + + @override + String get shortcutAssign => 'Määra'; + + @override + String get shortcutReassign => 'Määra ümber'; + + @override + String get shortcutContextApplication => 'Rakendus'; + + @override + String get shortcutContextDocument => 'Dokumendivaated'; + + @override + String get shortcutContextEditor => 'Redaktor ja lähtekood'; + + @override + String get shortcutContextWritersideSource => 'Writerside’i lähtekood'; + + @override + String get shortcutContextWorkspace => 'Tööala navigeerimine'; + + @override + String get shortcutContextTree => 'Külgriba puud'; + + @override + String get searchHeadingsCommand => 'Otsi pealkirju'; + + @override + String get shortcutFixed => 'Redaktori hallatav; pole seadistatav.'; + + @override + String get shortcutDefaultConflict => + 'Vaikimisi otsetee on vastuolus kohandatud määranguga.'; + + @override + String get shortcutCurrentBinding => 'Praegune otsetee'; + + @override + String get shortcutProposedBinding => 'Pakutud otsetee'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 2fa72377..bd7be340 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -581,15 +581,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get keyboardShortcuts => 'میانبرهای صفحه‌کلید'; - @override - String get commandPalette => 'پالت فرمان'; - - @override - String get commandPaletteHint => 'یک فرمان وارد کنید'; - - @override - String get commandPaletteEmpty => 'هیچ فرمان منطبقی وجود ندارد'; - @override String get commandUnavailableInContext => 'این فرمان در زمینهٔ فعلی در دسترس نیست.'; @@ -808,7 +799,7 @@ class AppLocalizationsFa extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'نمایش مرجع میانبرهای صفحه‌کلید'; + 'سفارشی‌سازی میان‌برهای صفحه‌کلید برنامه'; @override String get shortcutSyntaxReferenceDescription => 'باز کردن راهنمای نحو'; @@ -1053,7 +1044,7 @@ class AppLocalizationsFa extends AppLocalizations { String get validateOnEdit => 'اعتبارسنجی هنگام ویرایش'; @override - String get clearRecentWorkspaces => 'پاک کردن فضاهای کاری اخیر'; + String get clearRecentWorkspaces => 'پاک کردن تاریخچهٔ اخیر'; @override String get editingButtonsPosition => 'موقعیت دکمه‌های ویرایش'; @@ -1878,15 +1869,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get sourceSearchNextMatch => 'تطابق بعدی'; - @override - String get sourceSearchCaseSensitive => 'حساس به بزرگی و کوچکی حروف'; - - @override - String get sourceSearchWholeWord => 'تمام کلمه'; - - @override - String get sourceSearchRegex => 'عبارت منظم'; - @override String get sourceSearchReplacement => 'جایگزینی با'; @@ -2013,13 +1995,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get saveAs => 'ذخیره با نام'; - @override - String get sourceSearchInvalidRegex => 'عبارت منظم نامعتبر است'; - - @override - String get sourceSearchZeroLengthUnsupported => - 'تطبیق‌های با طول صفر در عبارت منظم پشتیبانی نمی‌شوند (مانند ^، \$ یا نگاه‌به‌جلو که فقط با یک موقعیت تطبیق دارد).'; - @override String get workspaceSearchIncomplete => 'فقط بخشی از نتایج نمایش داده می‌شود. نتایج بیشتری نمایش دهید یا جستجو را محدود کنید.'; @@ -4396,9 +4371,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get clipboardHistory => 'تاریخچه کپی و پیست'; - @override - String get clipboardHistorySearchHint => 'جستجو در تاریخچه کلیپ‌بورد'; - @override String get clipboardHistoryDescription => 'باز کردن تاریخچه کپی و پیست مخصوص جلسه.'; @@ -4420,10 +4392,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get clipboardNoItems => 'تاریخچه کپی و پیست خالی است.'; - @override - String get clipboardNoMatches => - 'هیچ آیتم کپی و پیستی با این جستجو مطابقت ندارد.'; - @override String get clipboardDisabled => 'مجموعه تاریخچه کپی و پیست در تنظیمات غیرفعال است.'; @@ -4480,13 +4448,6 @@ class AppLocalizationsFa extends AppLocalizations { String get localHistoryNoRevisions => 'این سند دارای هیچ نسخه‌ای از تاریخ محلی نیست.'; - @override - String get localHistoryNoMatches => - 'هیچ نسخه‌ای از تاریخ محلی با این جستجو مطابقت ندارد.'; - - @override - String get localHistorySearchHint => 'جستجو در تاریخچه محلی'; - @override String get localHistorySelectedRevision => 'نسخه انتخاب شده'; @@ -5434,4 +5395,178 @@ class AppLocalizationsFa extends AppLocalizations { @override String get wsPropertiesUnavailable => 'عنصر بررسی‌شده در دسترس نیست. برای ادامه عنصری را بررسی کنید.'; + + @override + String get searchDocumentsCategory => 'سندها'; + + @override + String get searchTopicsCategory => 'موضوع‌ها'; + + @override + String get searchCurrentDocument => 'سند کنونی'; + + @override + String get searchCurrentWorkspace => 'فضای کاری کنونی'; + + @override + String get browseNotes => 'مرور یادداشت‌ها'; + + @override + String get searchActionsCommand => 'جستجوی کنش‌ها'; + + @override + String get searchDocumentsCommand => 'جستجوی سندها'; + + @override + String get searchTopicsCommand => 'جستجوی موضوع‌ها'; + + @override + String get searchRecentCommand => 'جستجوی موارد اخیر'; + + @override + String get searchResourceUnavailable => 'این منبع در دسترس نیست.'; + + @override + String get notesSync => 'همگام‌سازی یادداشت‌ها'; + + @override + String get searchInWorkspace => 'جستجو در فضای کاری'; + + @override + String get searchCategory => 'دستهٔ جستجو'; + + @override + String get clearHistorySearchScope => 'جستجو در تاریخچهٔ سندها'; + + @override + String get searchInDocument => 'جستجو در سند'; + + @override + String get searchDocumentHint => 'جستجو در این سند…'; + + @override + String get searchWorkspaceHint => 'جستجو در این فضای کاری…'; + + @override + String get searchOutlineHint => 'جستجوی عنوان‌ها…'; + + @override + String get searchHistoryHint => 'جستجو در تاریخچهٔ محلی…'; + + @override + String get searchEnterText => 'متن جستجو را وارد کنید'; + + @override + String get searchAccountUnavailable => 'حساب در دسترس نیست'; + + @override + String get searchDocumentsHint => 'جستجوی سندها…'; + + @override + String get searchTopicsHint => 'جستجوی موضوع‌ها…'; + + @override + String get searchRecentHint => 'جستجوی موارد اخیر…'; + + @override + String get searchActionsHint => 'جستجوی کنش‌ها…'; + + @override + String get searchRequiresNotesWorkspace => + 'به فضای کاری یادداشت‌ها نیاز دارد'; + + @override + String get searchSidebarLabel => 'جستجو:'; + + @override + String get searchDocumentTextCategory => 'متن سند'; + + @override + String get searchWorkspaceTextCategory => 'متن فضای کاری'; + + @override + String get searchHeadingsCategory => 'عنوان‌ها'; + + @override + String get shortcutUnassigned => 'تخصیص داده نشده'; + + @override + String get shortcutPressKeys => 'یک ترکیب کلید را فشار دهید…'; + + @override + String get shortcutRecord => 'ثبت میان‌بر'; + + @override + String get shortcutRestoreAll => 'بازیابی همهٔ پیش‌فرض‌ها'; + + @override + String get shortcutRestoreAllQuestion => + 'میان‌برهای پیش‌فرض همهٔ کنش‌ها بازیابی شوند؟ تخصیص‌های سفارشی حذف می‌شوند.'; + + @override + String get shortcutSaveFailed => + 'میان‌برها فعال‌اند اما ذخیره نشدند. برای حفظ آن‌ها پس از راه‌اندازی مجدد، دوباره ذخیره کنید.'; + + @override + String get shortcutRetry => 'تلاش دوباره برای ذخیره'; + + @override + String get shortcutChanged => + 'تخصیص‌ها تغییر کرده‌اند. میان‌برهای فعلی را بررسی و دوباره تلاش کنید.'; + + @override + String get shortcutInvalid => 'این ترکیب را نمی‌توان در این زمینه تخصیص داد.'; + + @override + String get shortcutSystemWarning => + 'ممکن است میزکار از این ترکیب استفاده کند. BusyMark نمی‌تواند میان‌برهای سیستم را بازنویسی کند.'; + + @override + String get shortcutConflicts => 'این میان‌بر با کنش‌های زیر تداخل دارد:'; + + @override + String get shortcutRemove => 'حذف تخصیص'; + + @override + String get shortcutRestoreDefault => 'بازیابی پیش‌فرض'; + + @override + String get shortcutAssign => 'تخصیص'; + + @override + String get shortcutReassign => 'تخصیص مجدد'; + + @override + String get shortcutContextApplication => 'برنامه'; + + @override + String get shortcutContextDocument => 'نماهای سند'; + + @override + String get shortcutContextEditor => 'ویرایشگر و منبع'; + + @override + String get shortcutContextWritersideSource => 'منبع Writerside'; + + @override + String get shortcutContextWorkspace => 'پیمایش فضای کاری'; + + @override + String get shortcutContextTree => 'درخت‌های نوار کناری'; + + @override + String get searchHeadingsCommand => 'جستجوی عنوان‌ها'; + + @override + String get shortcutFixed => 'توسط ویرایشگر مدیریت می‌شود؛ قابل تنظیم نیست.'; + + @override + String get shortcutDefaultConflict => + 'میان‌بر پیش‌فرض با تخصیص سفارشی تداخل دارد.'; + + @override + String get shortcutCurrentBinding => 'میان‌بر فعلی'; + + @override + String get shortcutProposedBinding => 'میان‌بر پیشنهادی'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 65489800..36b3cf25 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -591,15 +591,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get keyboardShortcuts => 'Raccourcis clavier'; - @override - String get commandPalette => 'Palette de commandes'; - - @override - String get commandPaletteHint => 'Saisissez une commande'; - - @override - String get commandPaletteEmpty => 'Aucune commande correspondante'; - @override String get commandUnavailableInContext => 'Cette commande n’est pas disponible dans le contexte actuel.'; @@ -819,7 +810,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Afficher cette liste de raccourcis clavier'; + 'Personnaliser les raccourcis clavier de l’application'; @override String get shortcutSyntaxReferenceDescription => @@ -1077,7 +1068,7 @@ class AppLocalizationsFr extends AppLocalizations { String get validateOnEdit => 'Valider pendant la modification'; @override - String get clearRecentWorkspaces => 'Effacer les espaces de travail récents'; + String get clearRecentWorkspaces => 'Effacer l’historique récent'; @override String get editingButtonsPosition => 'Position des boutons d’édition'; @@ -1866,15 +1857,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sourceSearchNextMatch => 'Occurrence suivante'; - @override - String get sourceSearchCaseSensitive => 'Respecter la casse'; - - @override - String get sourceSearchWholeWord => 'Mot entier'; - - @override - String get sourceSearchRegex => 'Expression régulière'; - @override String get sourceSearchReplacement => 'Remplacer par'; @@ -2005,13 +1987,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get saveAs => 'Enregistrer sous'; - @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.'; @@ -4391,10 +4366,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get clipboardHistory => 'Historique de la presse-papiers'; - @override - String get clipboardHistorySearchHint => - 'Rechercher dans l’historique du presse-papiers'; - @override String get clipboardHistoryDescription => 'Ouvrir l\'historique de la presse-papiers local à la session.'; @@ -4418,10 +4389,6 @@ class AppLocalizationsFr extends AppLocalizations { @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.'; @@ -4478,13 +4445,6 @@ class AppLocalizationsFr extends AppLocalizations { 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'; @@ -5451,4 +5411,181 @@ class AppLocalizationsFr extends AppLocalizations { @override String get wsPropertiesUnavailable => 'L’élément inspecté est indisponible. Inspectez un élément pour continuer.'; + + @override + String get searchDocumentsCategory => 'Documents'; + + @override + String get searchTopicsCategory => 'Rubriques'; + + @override + String get searchCurrentDocument => 'Document actuel'; + + @override + String get searchCurrentWorkspace => 'Espace de travail actuel'; + + @override + String get browseNotes => 'Parcourir les notes'; + + @override + String get searchActionsCommand => 'Rechercher des actions'; + + @override + String get searchDocumentsCommand => 'Rechercher des documents'; + + @override + String get searchTopicsCommand => 'Rechercher des rubriques'; + + @override + String get searchRecentCommand => 'Rechercher les éléments récents'; + + @override + String get searchResourceUnavailable => 'Cette ressource est indisponible.'; + + @override + String get notesSync => 'Synchroniser les notes'; + + @override + String get searchInWorkspace => 'Rechercher dans l’espace de travail'; + + @override + String get searchCategory => 'Catégorie de recherche'; + + @override + String get clearHistorySearchScope => + 'Rechercher dans l’historique des documents'; + + @override + String get searchInDocument => 'Rechercher dans le document'; + + @override + String get searchDocumentHint => 'Rechercher dans ce document…'; + + @override + String get searchWorkspaceHint => 'Rechercher dans cet espace de travail…'; + + @override + String get searchOutlineHint => 'Rechercher des titres…'; + + @override + String get searchHistoryHint => 'Rechercher dans l’historique local…'; + + @override + String get searchEnterText => 'Saisissez du texte à rechercher'; + + @override + String get searchAccountUnavailable => 'Compte indisponible'; + + @override + String get searchDocumentsHint => 'Rechercher des documents…'; + + @override + String get searchTopicsHint => 'Rechercher des rubriques…'; + + @override + String get searchRecentHint => 'Rechercher les éléments récents…'; + + @override + String get searchActionsHint => 'Rechercher des actions…'; + + @override + String get searchRequiresNotesWorkspace => + 'Nécessite un espace de travail Notes'; + + @override + String get searchSidebarLabel => 'Rechercher:'; + + @override + String get searchDocumentTextCategory => 'Texte du document'; + + @override + String get searchWorkspaceTextCategory => 'Texte de l’espace de travail'; + + @override + String get searchHeadingsCategory => 'Titres'; + + @override + String get shortcutUnassigned => 'Non attribué'; + + @override + String get shortcutPressKeys => 'Appuyez sur une combinaison de touches…'; + + @override + String get shortcutRecord => 'Enregistrer un raccourci'; + + @override + String get shortcutRestoreAll => 'Rétablir tous les raccourcis'; + + @override + String get shortcutRestoreAllQuestion => + 'Rétablir les raccourcis par défaut de toutes les actions ? Les affectations personnalisées seront supprimées.'; + + @override + String get shortcutSaveFailed => + 'Les raccourcis sont actifs mais n’ont pas été sauvegardés. Réessayez pour les conserver après redémarrage.'; + + @override + String get shortcutRetry => 'Réessayer la sauvegarde'; + + @override + String get shortcutChanged => + 'Les affectations ont changé. Vérifiez les raccourcis actuels et réessayez.'; + + @override + String get shortcutInvalid => + 'Cette combinaison ne peut pas être attribuée dans ce contexte.'; + + @override + String get shortcutSystemWarning => + 'Cette combinaison peut être utilisée par votre bureau. BusyMark ne peut pas remplacer les raccourcis système.'; + + @override + String get shortcutConflicts => + 'Ce raccourci entre en conflit avec ces actions :'; + + @override + String get shortcutRemove => 'Supprimer l’affectation'; + + @override + String get shortcutRestoreDefault => 'Rétablir le raccourci par défaut'; + + @override + String get shortcutAssign => 'Attribuer'; + + @override + String get shortcutReassign => 'Réattribuer'; + + @override + String get shortcutContextApplication => 'Application entière'; + + @override + String get shortcutContextDocument => 'Vues du document'; + + @override + String get shortcutContextEditor => 'Éditeur et source'; + + @override + String get shortcutContextWritersideSource => 'Source Writerside'; + + @override + String get shortcutContextWorkspace => 'Navigation de l’espace de travail'; + + @override + String get shortcutContextTree => 'Arborescences latérales'; + + @override + String get searchHeadingsCommand => 'Rechercher des titres'; + + @override + String get shortcutFixed => 'Géré par l’éditeur ; non configurable.'; + + @override + String get shortcutDefaultConflict => + 'Le raccourci par défaut entre en conflit avec une affectation personnalisée.'; + + @override + String get shortcutCurrentBinding => 'Raccourci actuel'; + + @override + String get shortcutProposedBinding => 'Raccourci proposé'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index a27cb5b2..88276b47 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -585,15 +585,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get keyboardShortcuts => 'कीबोर्ड शॉर्टकट'; - @override - String get commandPalette => 'कमांड पैलेट'; - - @override - String get commandPaletteHint => 'कमांड लिखें'; - - @override - String get commandPaletteEmpty => 'कोई मेल खाता कमांड नहीं'; - @override String get commandUnavailableInContext => 'यह आदेश वर्तमान संदर्भ में उपलब्ध नहीं है।'; @@ -812,7 +803,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'यह कीबोर्ड शॉर्टकट संदर्भ दिखाएँ'; + 'अनुप्रयोग के कीबोर्ड शॉर्टकट अनुकूलित करें'; @override String get shortcutSyntaxReferenceDescription => 'सिंटैक्स संदर्भ खोलें'; @@ -1055,7 +1046,7 @@ class AppLocalizationsHi extends AppLocalizations { String get validateOnEdit => 'संपादन के समय सत्यापित करें'; @override - String get clearRecentWorkspaces => 'हालिया कार्यस्थान साफ़ करें'; + String get clearRecentWorkspaces => 'हाल का इतिहास साफ़ करें'; @override String get editingButtonsPosition => 'संपादन बटनों की स्थिति'; @@ -1838,16 +1829,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get sourceSearchNextMatch => 'अगला मिलान'; - @override - String get sourceSearchCaseSensitive => - 'बड़े-छोटे अक्षरों के प्रति संवेदनशील'; - - @override - String get sourceSearchWholeWord => 'पूरा शब्द'; - - @override - String get sourceSearchRegex => 'रेगुलर एक्सप्रेशन'; - @override String get sourceSearchReplacement => 'इससे बदलें'; @@ -1974,13 +1955,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get saveAs => 'इस रूप में सहेजें'; - @override - String get sourceSearchInvalidRegex => 'अमान्य रेगुलर एक्सप्रेशन'; - - @override - String get sourceSearchZeroLengthUnsupported => - 'शून्य लंबाई वाले रेगेक्स मिलान समर्थित नहीं हैं (जैसे ^, \$ या केवल स्थिति से मेल खाने वाले लुकअहेड)।'; - @override String get workspaceSearchIncomplete => 'केवल कुछ परिणाम दिखाए गए हैं। अधिक परिणाम देखें या खोज को सीमित करें।'; @@ -4346,9 +4320,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get clipboardHistory => 'क्लिपबोर्ड इतिहास'; - @override - String get clipboardHistorySearchHint => 'क्लिपबोर्ड इतिहास खोजें'; - @override String get clipboardHistoryDescription => 'सत्र-विशिष्ट क्लिपबोर्ड इतिहास खोलें।'; @@ -4371,10 +4342,6 @@ class AppLocalizationsHi extends AppLocalizations { @override String get clipboardNoItems => 'क्लिपबोर्ड इतिहास खाली है।'; - @override - String get clipboardNoMatches => - 'कोई क्लिपबोर्ड आइटम इस खोज से मेल नहीं खाता।'; - @override String get clipboardDisabled => 'क्लिपबोर्ड इतिहास संग्रह सेटिंग्स में अक्षम है।'; @@ -4432,13 +4399,6 @@ class AppLocalizationsHi extends AppLocalizations { String get localHistoryNoRevisions => 'इस दस्तावेज़ में स्थानीय इतिहास में कोई संशोधन नहीं है।'; - @override - String get localHistoryNoMatches => - 'यह खोज स्थानीय इतिहास संशोधनों से मेल नहीं खाती।'; - - @override - String get localHistorySearchHint => 'स्थानीय इतिहास खोजें'; - @override String get localHistorySelectedRevision => 'चयनित संशोधन'; @@ -5388,4 +5348,179 @@ class AppLocalizationsHi extends AppLocalizations { @override String get wsPropertiesUnavailable => 'जाँचा गया तत्व उपलब्ध नहीं है। जारी रखने के लिए कोई तत्व जाँचें।'; + + @override + String get searchDocumentsCategory => 'दस्तावेज़'; + + @override + String get searchTopicsCategory => 'विषय'; + + @override + String get searchCurrentDocument => 'वर्तमान दस्तावेज़'; + + @override + String get searchCurrentWorkspace => 'वर्तमान कार्यक्षेत्र'; + + @override + String get browseNotes => 'नोट ब्राउज़ करें'; + + @override + String get searchActionsCommand => 'क्रियाएँ खोजें'; + + @override + String get searchDocumentsCommand => 'दस्तावेज़ खोजें'; + + @override + String get searchTopicsCommand => 'विषय खोजें'; + + @override + String get searchRecentCommand => 'हाल के आइटम खोजें'; + + @override + String get searchResourceUnavailable => 'यह संसाधन उपलब्ध नहीं है।'; + + @override + String get notesSync => 'नोट सिंक करें'; + + @override + String get searchInWorkspace => 'कार्यस्थान में खोजें'; + + @override + String get searchCategory => 'खोज श्रेणी'; + + @override + String get clearHistorySearchScope => 'दस्तावेज़ों के इतिहास में खोजें'; + + @override + String get searchInDocument => 'दस्तावेज़ में खोजें'; + + @override + String get searchDocumentHint => 'इस दस्तावेज़ में खोजें…'; + + @override + String get searchWorkspaceHint => 'इस कार्यक्षेत्र में खोजें…'; + + @override + String get searchOutlineHint => 'शीर्षक खोजें…'; + + @override + String get searchHistoryHint => 'स्थानीय इतिहास में खोजें…'; + + @override + String get searchEnterText => 'खोजने के लिए पाठ दर्ज करें'; + + @override + String get searchAccountUnavailable => 'खाता उपलब्ध नहीं है'; + + @override + String get searchDocumentsHint => 'दस्तावेज़ खोजें…'; + + @override + String get searchTopicsHint => 'विषय खोजें…'; + + @override + String get searchRecentHint => 'हाल के आइटम खोजें…'; + + @override + String get searchActionsHint => 'क्रियाएँ खोजें…'; + + @override + String get searchRequiresNotesWorkspace => 'नोट्स कार्यक्षेत्र आवश्यक है'; + + @override + String get searchSidebarLabel => 'खोजें:'; + + @override + String get searchDocumentTextCategory => 'दस्तावेज़ पाठ'; + + @override + String get searchWorkspaceTextCategory => 'कार्यस्थान पाठ'; + + @override + String get searchHeadingsCategory => 'शीर्षक'; + + @override + String get shortcutUnassigned => 'असाइन नहीं किया गया'; + + @override + String get shortcutPressKeys => 'कुंजी संयोजन दबाएँ…'; + + @override + String get shortcutRecord => 'शॉर्टकट रिकॉर्ड करें'; + + @override + String get shortcutRestoreAll => 'सभी डिफ़ॉल्ट पुनर्स्थापित करें'; + + @override + String get shortcutRestoreAllQuestion => + 'सभी क्रियाओं के डिफ़ॉल्ट शॉर्टकट पुनर्स्थापित करें? कस्टम असाइनमेंट हटा दिए जाएँगे।'; + + @override + String get shortcutSaveFailed => + 'शॉर्टकट सक्रिय हैं लेकिन सहेजे नहीं जा सके। पुनः प्रारंभ के बाद रखने के लिए दोबारा सहेजें।'; + + @override + String get shortcutRetry => 'सहेजने का पुनः प्रयास करें'; + + @override + String get shortcutChanged => + 'असाइनमेंट बदल गए हैं। वर्तमान शॉर्टकट जाँचें और पुनः प्रयास करें।'; + + @override + String get shortcutInvalid => + 'इस संदर्भ में यह संयोजन असाइन नहीं किया जा सकता।'; + + @override + String get shortcutSystemWarning => + 'आपका डेस्कटॉप इस संयोजन का उपयोग कर सकता है। BusyMark सिस्टम शॉर्टकट नहीं बदल सकता।'; + + @override + String get shortcutConflicts => 'यह शॉर्टकट इन क्रियाओं से टकराता है:'; + + @override + String get shortcutRemove => 'असाइनमेंट हटाएँ'; + + @override + String get shortcutRestoreDefault => 'डिफ़ॉल्ट पुनर्स्थापित करें'; + + @override + String get shortcutAssign => 'असाइन करें'; + + @override + String get shortcutReassign => 'फिर से असाइन करें'; + + @override + String get shortcutContextApplication => 'अनुप्रयोग'; + + @override + String get shortcutContextDocument => 'दस्तावेज़ दृश्य'; + + @override + String get shortcutContextEditor => 'संपादक और स्रोत'; + + @override + String get shortcutContextWritersideSource => 'Writerside स्रोत'; + + @override + String get shortcutContextWorkspace => 'कार्यस्थान नेविगेशन'; + + @override + String get shortcutContextTree => 'साइडबार ट्री'; + + @override + String get searchHeadingsCommand => 'शीर्षक खोजें'; + + @override + String get shortcutFixed => + 'संपादक द्वारा प्रबंधित; कॉन्फ़िगर नहीं किया जा सकता।'; + + @override + String get shortcutDefaultConflict => + 'डिफ़ॉल्ट शॉर्टकट का कस्टम असाइनमेंट से टकराव है।'; + + @override + String get shortcutCurrentBinding => 'वर्तमान शॉर्टकट'; + + @override + String get shortcutProposedBinding => 'प्रस्तावित शॉर्टकट'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index d04626c4..69616ae5 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -584,15 +584,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get keyboardShortcuts => 'Pintasan keyboard'; - @override - String get commandPalette => 'Palet perintah'; - - @override - String get commandPaletteHint => 'Ketikkan perintah'; - - @override - String get commandPaletteEmpty => 'Tidak ada perintah yang cocok'; - @override String get commandUnavailableInContext => 'Tidak tersedia dalam konteks editor saat ini'; @@ -811,7 +802,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Tampilkan referensi pintasan keyboard ini'; + 'Sesuaikan pintasan keyboard aplikasi'; @override String get shortcutSyntaxReferenceDescription => 'Buka referensi sintaks'; @@ -1062,7 +1053,7 @@ class AppLocalizationsId extends AppLocalizations { String get validateOnEdit => 'Validasi saat mengedit'; @override - String get clearRecentWorkspaces => 'Hapus ruang kerja terbaru'; + String get clearRecentWorkspaces => 'Hapus riwayat terbaru'; @override String get editingButtonsPosition => 'Posisi tombol pengeditan'; @@ -1843,15 +1834,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get sourceSearchNextMatch => 'Kecocokan berikutnya'; - @override - String get sourceSearchCaseSensitive => 'Peka huruf besar-kecil'; - - @override - String get sourceSearchWholeWord => 'Seluruh kata'; - - @override - String get sourceSearchRegex => 'Regex'; - @override String get sourceSearchReplacement => 'Ganti dengan'; @@ -1978,13 +1960,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get saveAs => 'Simpan sebagai'; - @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.'; @@ -4358,9 +4333,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get clipboardHistory => 'Sejarah Tempel'; - @override - String get clipboardHistorySearchHint => 'Cari riwayat papan klip'; - @override String get clipboardHistoryDescription => 'Buka riwayat tempel lokal sesi.'; @@ -4381,10 +4353,6 @@ class AppLocalizationsId extends AppLocalizations { @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.'; @@ -4442,13 +4410,6 @@ class AppLocalizationsId extends AppLocalizations { 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'; @@ -5396,4 +5357,180 @@ class AppLocalizationsId extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Elemen yang diperiksa tidak tersedia. Periksa elemen untuk melanjutkan.'; + + @override + String get searchDocumentsCategory => 'Dokumen'; + + @override + String get searchTopicsCategory => 'Topik'; + + @override + String get searchCurrentDocument => 'Dokumen saat ini'; + + @override + String get searchCurrentWorkspace => 'Ruang kerja saat ini'; + + @override + String get browseNotes => 'Jelajahi catatan'; + + @override + String get searchActionsCommand => 'Cari tindakan'; + + @override + String get searchDocumentsCommand => 'Cari dokumen'; + + @override + String get searchTopicsCommand => 'Cari topik'; + + @override + String get searchRecentCommand => 'Cari terbaru'; + + @override + String get searchResourceUnavailable => 'Sumber daya ini tidak tersedia.'; + + @override + String get notesSync => 'Sinkronkan catatan'; + + @override + String get searchInWorkspace => 'Cari dalam Ruang Kerja'; + + @override + String get searchCategory => 'Kategori pencarian'; + + @override + String get clearHistorySearchScope => 'Cari riwayat seluruh dokumen'; + + @override + String get searchInDocument => 'Cari dalam dokumen'; + + @override + String get searchDocumentHint => 'Cari dalam dokumen ini…'; + + @override + String get searchWorkspaceHint => 'Cari dalam ruang kerja ini…'; + + @override + String get searchOutlineHint => 'Cari judul bagian…'; + + @override + String get searchHistoryHint => 'Cari dalam riwayat lokal…'; + + @override + String get searchEnterText => 'Masukkan teks untuk mencari'; + + @override + String get searchAccountUnavailable => 'Akun tidak tersedia'; + + @override + String get searchDocumentsHint => 'Cari dokumen…'; + + @override + String get searchTopicsHint => 'Cari topik…'; + + @override + String get searchRecentHint => 'Cari terbaru…'; + + @override + String get searchActionsHint => 'Cari tindakan…'; + + @override + String get searchRequiresNotesWorkspace => 'Memerlukan ruang kerja Catatan'; + + @override + String get searchSidebarLabel => 'Cari:'; + + @override + String get searchDocumentTextCategory => 'Teks dokumen'; + + @override + String get searchWorkspaceTextCategory => 'Teks ruang kerja'; + + @override + String get searchHeadingsCategory => 'Judul'; + + @override + String get shortcutUnassigned => 'Belum ditetapkan'; + + @override + String get shortcutPressKeys => 'Tekan kombinasi tombol…'; + + @override + String get shortcutRecord => 'Rekam pintasan'; + + @override + String get shortcutRestoreAll => 'Pulihkan semua bawaan'; + + @override + String get shortcutRestoreAllQuestion => + 'Pulihkan pintasan bawaan semua tindakan? Penetapan khusus akan dihapus.'; + + @override + String get shortcutSaveFailed => + 'Pintasan aktif tetapi tidak dapat disimpan. Coba lagi agar tetap tersedia setelah mulai ulang.'; + + @override + String get shortcutRetry => 'Coba simpan lagi'; + + @override + String get shortcutChanged => + 'Penetapan berubah. Tinjau pintasan saat ini dan coba lagi.'; + + @override + String get shortcutInvalid => + 'Kombinasi ini tidak dapat ditetapkan dalam konteks ini.'; + + @override + String get shortcutSystemWarning => + 'Desktop Anda mungkin memakai kombinasi ini. BusyMark tidak dapat mengganti pintasan sistem.'; + + @override + String get shortcutConflicts => + 'Pintasan ini bertentangan dengan tindakan berikut:'; + + @override + String get shortcutRemove => 'Hapus penetapan'; + + @override + String get shortcutRestoreDefault => 'Pulihkan bawaan'; + + @override + String get shortcutAssign => 'Tetapkan'; + + @override + String get shortcutReassign => 'Tetapkan ulang'; + + @override + String get shortcutContextApplication => 'Aplikasi'; + + @override + String get shortcutContextDocument => 'Tampilan dokumen'; + + @override + String get shortcutContextEditor => 'Editor dan sumber'; + + @override + String get shortcutContextWritersideSource => 'Sumber Writerside'; + + @override + String get shortcutContextWorkspace => 'Navigasi ruang kerja'; + + @override + String get shortcutContextTree => 'Pohon bilah samping'; + + @override + String get searchHeadingsCommand => 'Cari judul'; + + @override + String get shortcutFixed => + 'Dikelola oleh editor; tidak dapat dikonfigurasi.'; + + @override + String get shortcutDefaultConflict => + 'Pintasan bawaan bertentangan dengan penetapan khusus.'; + + @override + String get shortcutCurrentBinding => 'Pintasan saat ini'; + + @override + String get shortcutProposedBinding => 'Pintasan yang diusulkan'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index eaf42622..19ec1b15 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -586,15 +586,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get keyboardShortcuts => 'Scorciatoie da tastiera'; - @override - String get commandPalette => 'Tavolozza dei comandi'; - - @override - String get commandPaletteHint => 'Digita un comando'; - - @override - String get commandPaletteEmpty => 'Nessun comando corrispondente'; - @override String get commandUnavailableInContext => 'Questo comando non è disponibile nel contesto corrente.'; @@ -813,7 +804,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Mostra il riferimento alle scorciatoie da tastiera'; + 'Personalizza le scorciatoie da tastiera dell’applicazione'; @override String get shortcutSyntaxReferenceDescription => @@ -1070,7 +1061,7 @@ class AppLocalizationsIt extends AppLocalizations { String get validateOnEdit => 'Convalida durante la modifica'; @override - String get clearRecentWorkspaces => 'Cancella le aree di lavoro recenti'; + String get clearRecentWorkspaces => 'Cancella la cronologia recente'; @override String get editingButtonsPosition => 'Posizione dei pulsanti di modifica'; @@ -1858,15 +1849,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get sourceSearchNextMatch => 'Corrispondenza successiva'; - @override - String get sourceSearchCaseSensitive => 'Maiuscole/minuscole'; - - @override - String get sourceSearchWholeWord => 'Parola intera'; - - @override - String get sourceSearchRegex => 'Espressione regolare'; - @override String get sourceSearchReplacement => 'Sostituisci con'; @@ -1995,13 +1977,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get saveAs => 'Salva con nome'; - @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.'; @@ -4384,10 +4359,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get clipboardHistory => 'Cronologia della Copia'; - @override - String get clipboardHistorySearchHint => - 'Cerca nella cronologia degli appunti'; - @override String get clipboardHistoryDescription => 'Apri la cronologia della copia locale della sessione.'; @@ -4411,10 +4382,6 @@ class AppLocalizationsIt extends AppLocalizations { @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.'; @@ -4472,13 +4439,6 @@ class AppLocalizationsIt extends AppLocalizations { 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'; @@ -5443,4 +5403,181 @@ class AppLocalizationsIt extends AppLocalizations { @override String get wsPropertiesUnavailable => 'L’elemento esaminato non è disponibile. Esamina un elemento per continuare.'; + + @override + String get searchDocumentsCategory => 'Documenti'; + + @override + String get searchTopicsCategory => 'Argomenti'; + + @override + String get searchCurrentDocument => 'Documento corrente'; + + @override + String get searchCurrentWorkspace => 'Area di lavoro corrente'; + + @override + String get browseNotes => 'Sfoglia note'; + + @override + String get searchActionsCommand => 'Cerca azioni'; + + @override + String get searchDocumentsCommand => 'Cerca documenti'; + + @override + String get searchTopicsCommand => 'Cerca argomenti'; + + @override + String get searchRecentCommand => 'Cerca recenti'; + + @override + String get searchResourceUnavailable => 'Questa risorsa non è disponibile.'; + + @override + String get notesSync => 'Sincronizza note'; + + @override + String get searchInWorkspace => 'Cerca nello spazio di lavoro'; + + @override + String get searchCategory => 'Categoria di ricerca'; + + @override + String get clearHistorySearchScope => 'Cerca nella cronologia dei documenti'; + + @override + String get searchInDocument => 'Cerca nel documento'; + + @override + String get searchDocumentHint => 'Cerca in questo documento…'; + + @override + String get searchWorkspaceHint => 'Cerca in questo spazio di lavoro…'; + + @override + String get searchOutlineHint => 'Cerca titoli…'; + + @override + String get searchHistoryHint => 'Cerca nella cronologia locale…'; + + @override + String get searchEnterText => 'Inserisci il testo da cercare'; + + @override + String get searchAccountUnavailable => 'Account non disponibile'; + + @override + String get searchDocumentsHint => 'Cerca documenti…'; + + @override + String get searchTopicsHint => 'Cerca argomenti…'; + + @override + String get searchRecentHint => 'Cerca recenti…'; + + @override + String get searchActionsHint => 'Cerca azioni…'; + + @override + String get searchRequiresNotesWorkspace => + 'Richiede uno spazio di lavoro Note'; + + @override + String get searchSidebarLabel => 'Ricerca:'; + + @override + String get searchDocumentTextCategory => 'Testo del documento'; + + @override + String get searchWorkspaceTextCategory => 'Testo dell’area di lavoro'; + + @override + String get searchHeadingsCategory => 'Intestazioni'; + + @override + String get shortcutUnassigned => 'Non assegnata'; + + @override + String get shortcutPressKeys => 'Premi una combinazione di tasti…'; + + @override + String get shortcutRecord => 'Acquisisci scorciatoia'; + + @override + String get shortcutRestoreAll => + 'Ripristina tutte le impostazioni predefinite'; + + @override + String get shortcutRestoreAllQuestion => + 'Ripristinare le scorciatoie predefinite di tutte le azioni? Le assegnazioni personalizzate verranno rimosse.'; + + @override + String get shortcutSaveFailed => + 'Le scorciatoie sono attive ma non sono state salvate. Riprova per mantenerle dopo il riavvio.'; + + @override + String get shortcutRetry => 'Riprova salvataggio'; + + @override + String get shortcutChanged => + 'Le assegnazioni sono cambiate. Controlla le scorciatoie attuali e riprova.'; + + @override + String get shortcutInvalid => + 'Questa combinazione non può essere assegnata in questo contesto.'; + + @override + String get shortcutSystemWarning => + 'Questa combinazione potrebbe essere usata dal desktop. BusyMark non può sostituire le scorciatoie di sistema.'; + + @override + String get shortcutConflicts => + 'Questa scorciatoia è in conflitto con queste azioni:'; + + @override + String get shortcutRemove => 'Rimuovi assegnazione'; + + @override + String get shortcutRestoreDefault => 'Ripristina predefinita'; + + @override + String get shortcutAssign => 'Assegna'; + + @override + String get shortcutReassign => 'Riassegna'; + + @override + String get shortcutContextApplication => 'Applicazione'; + + @override + String get shortcutContextDocument => 'Viste del documento'; + + @override + String get shortcutContextEditor => 'Editor e sorgente'; + + @override + String get shortcutContextWritersideSource => 'Sorgente Writerside'; + + @override + String get shortcutContextWorkspace => 'Navigazione dell’area di lavoro'; + + @override + String get shortcutContextTree => 'Alberi laterali'; + + @override + String get searchHeadingsCommand => 'Cerca titoli'; + + @override + String get shortcutFixed => 'Gestito dall’editor; non configurabile.'; + + @override + String get shortcutDefaultConflict => + 'La scorciatoia predefinita è in conflitto con un’assegnazione personalizzata.'; + + @override + String get shortcutCurrentBinding => 'Scorciatoia attuale'; + + @override + String get shortcutProposedBinding => 'Scorciatoia proposta'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 02686a7f..ca2606ef 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -562,15 +562,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get keyboardShortcuts => 'キーボードショートカット'; - @override - String get commandPalette => 'コマンドパレット'; - - @override - String get commandPaletteHint => 'コマンドを入力'; - - @override - String get commandPaletteEmpty => '一致するコマンドはありません'; - @override String get commandUnavailableInContext => '現在のエディターのコンテキストでは利用できません'; @@ -786,7 +777,7 @@ class AppLocalizationsJa extends AppLocalizations { String get shortcutSearchDescription => '現在のワークスペースを検索'; @override - String get shortcutKeyboardShortcutsDescription => 'このキーボードショートカット一覧を表示'; + String get shortcutKeyboardShortcutsDescription => 'アプリケーションのキーボードショートカットを設定'; @override String get shortcutSyntaxReferenceDescription => '構文リファレンスを開く'; @@ -1014,7 +1005,7 @@ class AppLocalizationsJa extends AppLocalizations { String get validateOnEdit => '編集時に検証'; @override - String get clearRecentWorkspaces => '最近使ったワークスペースを消去'; + String get clearRecentWorkspaces => '最近の履歴を消去'; @override String get editingButtonsPosition => '編集ボタンの位置'; @@ -1782,15 +1773,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get sourceSearchNextMatch => '次の一致'; - @override - String get sourceSearchCaseSensitive => '大文字と小文字を区別'; - - @override - String get sourceSearchWholeWord => '単語単位'; - - @override - String get sourceSearchRegex => '正規表現'; - @override String get sourceSearchReplacement => '置換後'; @@ -1911,13 +1893,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get saveAs => '名前を付けて保存'; - @override - String get sourceSearchInvalidRegex => '無効な正規表現'; - - @override - String get sourceSearchZeroLengthUnsupported => - '長さがゼロの正規表現の一致には対応していません(^、\$、位置のみに一致する先読みなど)。'; - @override String get workspaceSearchIncomplete => '一部の結果のみ表示しています。さらに表示するか、検索を絞り込んでください。'; @@ -4233,9 +4208,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get clipboardHistory => 'コピーボード履歴'; - @override - String get clipboardHistorySearchHint => 'クリップボード履歴を検索'; - @override String get clipboardHistoryDescription => 'セッション固有のコピーボード履歴を開く。'; @@ -4256,9 +4228,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get clipboardNoItems => 'コピーボード履歴は空です。'; - @override - String get clipboardNoMatches => 'この検索に一致するコピーボードの項目はありません。'; - @override String get clipboardDisabled => 'コピーボード履歴のコレクションは設定で無効になっています。'; @@ -4309,12 +4278,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get localHistoryNoRevisions => 'このドキュメントにはローカル履歴の修正はありません。'; - @override - String get localHistoryNoMatches => 'この検索に一致するローカル履歴の修正はありません。'; - - @override - String get localHistorySearchHint => 'ローカル履歴を検索'; - @override String get localHistorySelectedRevision => '選択されたリビジョン'; @@ -5246,4 +5209,175 @@ class AppLocalizationsJa extends AppLocalizations { @override String get wsPropertiesUnavailable => '確認中の要素は利用できません。続行するには要素を確認してください。'; + + @override + String get searchDocumentsCategory => 'ドキュメント'; + + @override + String get searchTopicsCategory => 'トピック'; + + @override + String get searchCurrentDocument => '現在のドキュメント'; + + @override + String get searchCurrentWorkspace => '現在のワークスペース'; + + @override + String get browseNotes => 'ノートを参照'; + + @override + String get searchActionsCommand => 'アクションを検索'; + + @override + String get searchDocumentsCommand => 'ドキュメントを検索'; + + @override + String get searchTopicsCommand => 'トピックを検索'; + + @override + String get searchRecentCommand => '最近の項目を検索'; + + @override + String get searchResourceUnavailable => 'このリソースは利用できません。'; + + @override + String get notesSync => 'ノートを同期'; + + @override + String get searchInWorkspace => 'ワークスペース内検索'; + + @override + String get searchCategory => '検索カテゴリ'; + + @override + String get clearHistorySearchScope => 'すべてのドキュメントの履歴を検索'; + + @override + String get searchInDocument => 'ドキュメント内を検索'; + + @override + String get searchDocumentHint => 'このドキュメントを検索…'; + + @override + String get searchWorkspaceHint => 'このワークスペースを検索…'; + + @override + String get searchOutlineHint => '見出しを検索…'; + + @override + String get searchHistoryHint => 'ローカル履歴を検索…'; + + @override + String get searchEnterText => '検索するテキストを入力'; + + @override + String get searchAccountUnavailable => 'アカウントを利用できません'; + + @override + String get searchDocumentsHint => 'ドキュメントを検索…'; + + @override + String get searchTopicsHint => 'トピックを検索…'; + + @override + String get searchRecentHint => '最近の項目を検索…'; + + @override + String get searchActionsHint => 'アクションを検索…'; + + @override + String get searchRequiresNotesWorkspace => 'ノートのワークスペースが必要です'; + + @override + String get searchSidebarLabel => '検索:'; + + @override + String get searchDocumentTextCategory => '文書のテキスト'; + + @override + String get searchWorkspaceTextCategory => 'ワークスペースのテキスト'; + + @override + String get searchHeadingsCategory => '見出し'; + + @override + String get shortcutUnassigned => '未割り当て'; + + @override + String get shortcutPressKeys => 'キーの組み合わせを押してください…'; + + @override + String get shortcutRecord => 'ショートカットを記録'; + + @override + String get shortcutRestoreAll => 'すべて既定に戻す'; + + @override + String get shortcutRestoreAllQuestion => + 'すべてのアクションのショートカットを既定に戻しますか?独自の割り当ては削除されます。'; + + @override + String get shortcutSaveFailed => + 'ショートカットは有効ですが保存できませんでした。再起動後も保持するには保存を再試行してください。'; + + @override + String get shortcutRetry => '保存を再試行'; + + @override + String get shortcutChanged => '割り当てが変更されました。現在のショートカットを確認して再試行してください。'; + + @override + String get shortcutInvalid => 'この組み合わせはこのコンテキストでは割り当てられません。'; + + @override + String get shortcutSystemWarning => + 'この組み合わせはデスクトップで使われている可能性があります。BusyMarkはシステムのショートカットを上書きできません。'; + + @override + String get shortcutConflicts => 'このショートカットは次のアクションと競合します:'; + + @override + String get shortcutRemove => '割り当てを削除'; + + @override + String get shortcutRestoreDefault => '既定に戻す'; + + @override + String get shortcutAssign => '割り当て'; + + @override + String get shortcutReassign => '再割り当て'; + + @override + String get shortcutContextApplication => 'アプリケーション'; + + @override + String get shortcutContextDocument => '文書の表示'; + + @override + String get shortcutContextEditor => 'エディターとソース'; + + @override + String get shortcutContextWritersideSource => 'Writersideのソース'; + + @override + String get shortcutContextWorkspace => 'ワークスペースの移動'; + + @override + String get shortcutContextTree => 'サイドバーのツリー'; + + @override + String get searchHeadingsCommand => '見出しを検索'; + + @override + String get shortcutFixed => 'エディターが管理するため、変更できません。'; + + @override + String get shortcutDefaultConflict => '既定のショートカットがカスタム割り当てと競合しています。'; + + @override + String get shortcutCurrentBinding => '現在のショートカット'; + + @override + String get shortcutProposedBinding => '新しいショートカット'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 8d060c70..27104bc9 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -561,15 +561,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get keyboardShortcuts => '키보드 단축키'; - @override - String get commandPalette => '명령 팔레트'; - - @override - String get commandPaletteHint => '명령을 입력하세요'; - - @override - String get commandPaletteEmpty => '일치하는 명령이 없습니다.'; - @override String get commandUnavailableInContext => '현재 편집기 컨텍스트에서는 사용할 수 없습니다.'; @@ -784,7 +775,7 @@ class AppLocalizationsKo extends AppLocalizations { String get shortcutSearchDescription => '현재 작업공간 검색'; @override - String get shortcutKeyboardShortcutsDescription => '이 키보드 단축키 참조 표시'; + String get shortcutKeyboardShortcutsDescription => '애플리케이션 키보드 단축키 사용자 지정'; @override String get shortcutSyntaxReferenceDescription => '구문 참조 열기'; @@ -1010,7 +1001,7 @@ class AppLocalizationsKo extends AppLocalizations { String get validateOnEdit => '편집할 때 검증'; @override - String get clearRecentWorkspaces => '최근 작업공간 지우기'; + String get clearRecentWorkspaces => '최근 기록 지우기'; @override String get editingButtonsPosition => '편집 버튼 위치'; @@ -1779,15 +1770,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get sourceSearchNextMatch => '다음 일치 항목'; - @override - String get sourceSearchCaseSensitive => '대소문자 구분'; - - @override - String get sourceSearchWholeWord => '전체 단어'; - - @override - String get sourceSearchRegex => '정규식'; - @override String get sourceSearchReplacement => '다음으로 바꾸기'; @@ -1909,13 +1891,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get saveAs => '다른 이름으로 저장'; - @override - String get sourceSearchInvalidRegex => '잘못된 정규식'; - - @override - String get sourceSearchZeroLengthUnsupported => - '길이가 0인 정규식 일치는 지원하지 않습니다(예: ^, \$, 위치에만 일치하는 전방 탐색).'; - @override String get workspaceSearchIncomplete => '일부 결과만 표시됩니다. 더 표시하거나 검색 범위를 좁히세요.'; @@ -4228,9 +4203,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get clipboardHistory => '클립보드 기록'; - @override - String get clipboardHistorySearchHint => '클립보드 기록 검색'; - @override String get clipboardHistoryDescription => '세션별 클립보드 기록을 엽니다.'; @@ -4251,9 +4223,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get clipboardNoItems => '클립보드 기록이 비어 있습니다.'; - @override - String get clipboardNoMatches => '이 검색과 일치하는 클립보드 항목이 없습니다.'; - @override String get clipboardDisabled => '클립보드 기록 컬렉션이 설정에서 비활성화되었습니다.'; @@ -4304,12 +4273,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get localHistoryNoRevisions => '이 문서는 로컬 히스토리 수정 사항이 없습니다.'; - @override - String get localHistoryNoMatches => '이 검색과 일치하는 로컬 히스토리 수정 사항이 없습니다.'; - - @override - String get localHistorySearchHint => '로컬 기록 검색'; - @override String get localHistorySelectedRevision => '선택된 수정'; @@ -5244,4 +5207,175 @@ class AppLocalizationsKo extends AppLocalizations { @override String get wsPropertiesUnavailable => '검사 중인 요소를 사용할 수 없습니다. 계속하려면 요소를 검사하세요.'; + + @override + String get searchDocumentsCategory => '문서'; + + @override + String get searchTopicsCategory => '주제'; + + @override + String get searchCurrentDocument => '현재 문서'; + + @override + String get searchCurrentWorkspace => '현재 작업 공간'; + + @override + String get browseNotes => '노트 찾아보기'; + + @override + String get searchActionsCommand => '작업 검색'; + + @override + String get searchDocumentsCommand => '문서 검색'; + + @override + String get searchTopicsCommand => '주제 검색'; + + @override + String get searchRecentCommand => '최근 항목 검색'; + + @override + String get searchResourceUnavailable => '이 리소스를 사용할 수 없습니다.'; + + @override + String get notesSync => '노트 동기화'; + + @override + String get searchInWorkspace => '작업 공간에서 검색'; + + @override + String get searchCategory => '검색 범주'; + + @override + String get clearHistorySearchScope => '문서 기록 전체 검색'; + + @override + String get searchInDocument => '문서에서 검색'; + + @override + String get searchDocumentHint => '이 문서에서 검색…'; + + @override + String get searchWorkspaceHint => '이 작업 공간에서 검색…'; + + @override + String get searchOutlineHint => '제목 검색…'; + + @override + String get searchHistoryHint => '로컬 기록 검색…'; + + @override + String get searchEnterText => '검색할 텍스트 입력'; + + @override + String get searchAccountUnavailable => '계정을 사용할 수 없음'; + + @override + String get searchDocumentsHint => '문서 검색…'; + + @override + String get searchTopicsHint => '주제 검색…'; + + @override + String get searchRecentHint => '최근 항목 검색…'; + + @override + String get searchActionsHint => '작업 검색…'; + + @override + String get searchRequiresNotesWorkspace => '노트 작업 공간 필요'; + + @override + String get searchSidebarLabel => '검색:'; + + @override + String get searchDocumentTextCategory => '문서 텍스트'; + + @override + String get searchWorkspaceTextCategory => '작업 공간 텍스트'; + + @override + String get searchHeadingsCategory => '제목'; + + @override + String get shortcutUnassigned => '할당되지 않음'; + + @override + String get shortcutPressKeys => '키 조합을 누르세요…'; + + @override + String get shortcutRecord => '단축키 기록'; + + @override + String get shortcutRestoreAll => '모든 기본값 복원'; + + @override + String get shortcutRestoreAllQuestion => + '모든 동작의 기본 단축키를 복원할까요? 사용자 지정 할당이 제거됩니다.'; + + @override + String get shortcutSaveFailed => + '단축키가 활성화되었지만 저장하지 못했습니다. 다시 시작 후에도 유지하려면 저장을 다시 시도하세요.'; + + @override + String get shortcutRetry => '저장 다시 시도'; + + @override + String get shortcutChanged => '할당이 변경되었습니다. 현재 단축키를 확인하고 다시 시도하세요.'; + + @override + String get shortcutInvalid => '이 조합은 이 문맥에서 할당할 수 없습니다.'; + + @override + String get shortcutSystemWarning => + '데스크톱에서 이 조합을 사용할 수 있습니다. BusyMark는 시스템 단축키를 재정의할 수 없습니다.'; + + @override + String get shortcutConflicts => '이 단축키는 다음 동작과 충돌합니다:'; + + @override + String get shortcutRemove => '할당 제거'; + + @override + String get shortcutRestoreDefault => '기본값 복원'; + + @override + String get shortcutAssign => '할당'; + + @override + String get shortcutReassign => '다시 할당'; + + @override + String get shortcutContextApplication => '애플리케이션'; + + @override + String get shortcutContextDocument => '문서 보기'; + + @override + String get shortcutContextEditor => '편집기 및 소스'; + + @override + String get shortcutContextWritersideSource => 'Writerside 소스'; + + @override + String get shortcutContextWorkspace => '작업 공간 탐색'; + + @override + String get shortcutContextTree => '사이드바 트리'; + + @override + String get searchHeadingsCommand => '제목 검색'; + + @override + String get shortcutFixed => '편집기에서 관리하므로 설정할 수 없습니다.'; + + @override + String get shortcutDefaultConflict => '기본 단축키가 사용자 지정 할당과 충돌합니다.'; + + @override + String get shortcutCurrentBinding => '현재 단축키'; + + @override + String get shortcutProposedBinding => '제안된 단축키'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 020f3e77..5678951d 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -588,15 +588,6 @@ class AppLocalizationsNb extends AppLocalizations { @override String get keyboardShortcuts => 'Tastatursnarveier'; - @override - String get commandPalette => 'Kommandopalett'; - - @override - String get commandPaletteHint => 'Skriv inn en kommando'; - - @override - String get commandPaletteEmpty => 'Ingen samsvarende kommandoer'; - @override String get commandUnavailableInContext => 'Denne kommandoen er ikke tilgjengelig i gjeldende kontekst.'; @@ -815,7 +806,7 @@ class AppLocalizationsNb extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Vis denne oversikten over tastatursnarveier'; + 'Tilpass programmets hurtigtaster'; @override String get shortcutSyntaxReferenceDescription => 'Åpne syntaksreferansen'; @@ -1063,7 +1054,7 @@ class AppLocalizationsNb extends AppLocalizations { String get validateOnEdit => 'Valider under redigering'; @override - String get clearRecentWorkspaces => 'Tøm nylige arbeidsområder'; + String get clearRecentWorkspaces => 'Tøm nylig historikk'; @override String get editingButtonsPosition => 'Plassering av redigeringsknapper'; @@ -1847,15 +1838,6 @@ class AppLocalizationsNb extends AppLocalizations { @override String get sourceSearchNextMatch => 'Neste treff'; - @override - String get sourceSearchCaseSensitive => 'Skill mellom store og små bokstaver'; - - @override - String get sourceSearchWholeWord => 'Hele ord'; - - @override - String get sourceSearchRegex => 'Regulært uttrykk'; - @override String get sourceSearchReplacement => 'Erstatt med'; @@ -1983,13 +1965,6 @@ class AppLocalizationsNb extends AppLocalizations { @override String get saveAs => 'Lagre som'; - @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.'; @@ -4354,9 +4329,6 @@ class AppLocalizationsNb extends AppLocalizations { @override String get clipboardHistory => 'Utklippshistorie'; - @override - String get clipboardHistorySearchHint => 'Søk i utklippstavlehistorikken'; - @override String get clipboardHistoryDescription => 'Åpne historien for utklippet som er lokal for sesjonen.'; @@ -4379,10 +4351,6 @@ class AppLocalizationsNb extends AppLocalizations { @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.'; @@ -4440,12 +4408,6 @@ class AppLocalizationsNb extends AppLocalizations { 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'; @@ -5402,4 +5364,179 @@ class AppLocalizationsNb extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Det inspiserte elementet er utilgjengelig. Inspiser et element for å fortsette.'; + + @override + String get searchDocumentsCategory => 'Dokumenter'; + + @override + String get searchTopicsCategory => 'Emner'; + + @override + String get searchCurrentDocument => 'Gjeldende dokument'; + + @override + String get searchCurrentWorkspace => 'Gjeldende arbeidsområde'; + + @override + String get browseNotes => 'Bla gjennom notater'; + + @override + String get searchActionsCommand => 'Søk etter handlinger'; + + @override + String get searchDocumentsCommand => 'Søk etter dokumenter'; + + @override + String get searchTopicsCommand => 'Søk etter emner'; + + @override + String get searchRecentCommand => 'Søk i nylige'; + + @override + String get searchResourceUnavailable => 'Denne ressursen er utilgjengelig.'; + + @override + String get notesSync => 'Synkroniser notater'; + + @override + String get searchInWorkspace => 'Søk i arbeidsområde'; + + @override + String get searchCategory => 'Søkekategori'; + + @override + String get clearHistorySearchScope => 'Søk i dokumenthistorikken'; + + @override + String get searchInDocument => 'Søk i dokument'; + + @override + String get searchDocumentHint => 'Søk i dette dokumentet…'; + + @override + String get searchWorkspaceHint => 'Søk i dette arbeidsområdet…'; + + @override + String get searchOutlineHint => 'Søk i overskrifter…'; + + @override + String get searchHistoryHint => 'Søk i lokal historikk…'; + + @override + String get searchEnterText => 'Skriv inn tekst for å søke'; + + @override + String get searchAccountUnavailable => 'Konto utilgjengelig'; + + @override + String get searchDocumentsHint => 'Søk etter dokumenter…'; + + @override + String get searchTopicsHint => 'Søk etter emner…'; + + @override + String get searchRecentHint => 'Søk i nylige…'; + + @override + String get searchActionsHint => 'Søk etter handlinger…'; + + @override + String get searchRequiresNotesWorkspace => 'Krever et notatarbeidsområde'; + + @override + String get searchSidebarLabel => 'Søk:'; + + @override + String get searchDocumentTextCategory => 'Dokumenttekst'; + + @override + String get searchWorkspaceTextCategory => 'Arbeidsområdetekst'; + + @override + String get searchHeadingsCategory => 'Overskrifter'; + + @override + String get shortcutUnassigned => 'Ikke tilordnet'; + + @override + String get shortcutPressKeys => 'Trykk en tastekombinasjon…'; + + @override + String get shortcutRecord => 'Registrer hurtigtast'; + + @override + String get shortcutRestoreAll => 'Gjenopprett alle standarder'; + + @override + String get shortcutRestoreAllQuestion => + 'Gjenopprette standardhurtigtaster for alle handlinger? Egne tilordninger fjernes.'; + + @override + String get shortcutSaveFailed => + 'Hurtigtastene er aktive, men kunne ikke lagres. Prøv igjen for å beholde dem etter omstart.'; + + @override + String get shortcutRetry => 'Prøv lagring igjen'; + + @override + String get shortcutChanged => + 'Tilordningene er endret. Se gjennom gjeldende hurtigtaster og prøv igjen.'; + + @override + String get shortcutInvalid => + 'Denne kombinasjonen kan ikke tilordnes i denne sammenhengen.'; + + @override + String get shortcutSystemWarning => + 'Skrivebordet kan bruke denne kombinasjonen. BusyMark kan ikke overstyre systemhurtigtaster.'; + + @override + String get shortcutConflicts => + 'Denne hurtigtasten overlapper disse handlingene:'; + + @override + String get shortcutRemove => 'Fjern tilordning'; + + @override + String get shortcutRestoreDefault => 'Gjenopprett standard'; + + @override + String get shortcutAssign => 'Tilordne'; + + @override + String get shortcutReassign => 'Tilordne på nytt'; + + @override + String get shortcutContextApplication => 'Program'; + + @override + String get shortcutContextDocument => 'Dokumentvisninger'; + + @override + String get shortcutContextEditor => 'Redigering og kilde'; + + @override + String get shortcutContextWritersideSource => 'Writerside-kilde'; + + @override + String get shortcutContextWorkspace => 'Arbeidsområdenavigasjon'; + + @override + String get shortcutContextTree => 'Sidefelttrær'; + + @override + String get searchHeadingsCommand => 'Søk i overskrifter'; + + @override + String get shortcutFixed => 'Håndteres av redigereren; kan ikke tilpasses.'; + + @override + String get shortcutDefaultConflict => + 'Standardhurtigtasten er i konflikt med en egendefinert tilordning.'; + + @override + String get shortcutCurrentBinding => 'Gjeldende hurtigtast'; + + @override + String get shortcutProposedBinding => 'Foreslått hurtigtast'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 5f8ee0e5..f6dd8897 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -589,15 +589,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get keyboardShortcuts => 'Sneltoetsen op het toetsenbord'; - @override - String get commandPalette => 'Commandopalet'; - - @override - String get commandPaletteHint => 'Typ een opdracht'; - - @override - String get commandPaletteEmpty => 'Geen overeenkomende opdrachten'; - @override String get commandUnavailableInContext => 'Niet beschikbaar in de huidige editorcontext'; @@ -816,7 +807,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Toon deze sneltoetsreferentie'; + 'Sneltoetsen van de toepassing aanpassen'; @override String get shortcutSyntaxReferenceDescription => 'Syntaxisreferentie openen'; @@ -1064,7 +1055,7 @@ class AppLocalizationsNl extends AppLocalizations { String get validateOnEdit => 'Valideren bij bewerken'; @override - String get clearRecentWorkspaces => 'Wis recente werkruimten'; + String get clearRecentWorkspaces => 'Recente geschiedenis wissen'; @override String get editingButtonsPosition => 'Positie van knoppen bewerken'; @@ -1850,15 +1841,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get sourceSearchNextMatch => 'Volgende overeenkomst'; - @override - String get sourceSearchCaseSensitive => 'Hoofdlettergevoelig'; - - @override - String get sourceSearchWholeWord => 'Heel woord'; - - @override - String get sourceSearchRegex => 'Regex'; - @override String get sourceSearchReplacement => 'Vervangen door'; @@ -1992,13 +1974,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get saveAs => 'Opslaan als'; - @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.'; @@ -4393,9 +4368,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get clipboardHistory => 'Historie van de klembord'; - @override - String get clipboardHistorySearchHint => 'Klembordgeschiedenis doorzoeken'; - @override String get clipboardHistoryDescription => 'Open de klembordgeschiedenis per sessie.'; @@ -4419,10 +4391,6 @@ class AppLocalizationsNl extends AppLocalizations { @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.'; @@ -4480,13 +4448,6 @@ class AppLocalizationsNl extends AppLocalizations { 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'; @@ -5448,4 +5409,178 @@ class AppLocalizationsNl extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Het geïnspecteerde element is niet beschikbaar. Inspecteer een element om door te gaan.'; + + @override + String get searchDocumentsCategory => 'Documenten'; + + @override + String get searchTopicsCategory => 'Onderwerpen'; + + @override + String get searchCurrentDocument => 'Huidig document'; + + @override + String get searchCurrentWorkspace => 'Huidige werkruimte'; + + @override + String get browseNotes => 'Notities bekijken'; + + @override + String get searchActionsCommand => 'Acties zoeken'; + + @override + String get searchDocumentsCommand => 'Documenten zoeken'; + + @override + String get searchTopicsCommand => 'Onderwerpen zoeken'; + + @override + String get searchRecentCommand => 'Recente items zoeken'; + + @override + String get searchResourceUnavailable => 'Deze bron is niet beschikbaar.'; + + @override + String get notesSync => 'Notities synchroniseren'; + + @override + String get searchInWorkspace => 'Zoeken in werkruimte'; + + @override + String get searchCategory => 'Zoekcategorie'; + + @override + String get clearHistorySearchScope => 'Zoeken in documentgeschiedenis'; + + @override + String get searchInDocument => 'Zoeken in document'; + + @override + String get searchDocumentHint => 'Dit document doorzoeken…'; + + @override + String get searchWorkspaceHint => 'Deze werkruimte doorzoeken…'; + + @override + String get searchOutlineHint => 'Koppen zoeken…'; + + @override + String get searchHistoryHint => 'Lokale geschiedenis doorzoeken…'; + + @override + String get searchEnterText => 'Voer tekst in om te zoeken'; + + @override + String get searchAccountUnavailable => 'Account niet beschikbaar'; + + @override + String get searchDocumentsHint => 'Documenten zoeken…'; + + @override + String get searchTopicsHint => 'Onderwerpen zoeken…'; + + @override + String get searchRecentHint => 'Recente items zoeken…'; + + @override + String get searchActionsHint => 'Acties zoeken…'; + + @override + String get searchRequiresNotesWorkspace => 'Vereist een notitiewerkruimte'; + + @override + String get searchSidebarLabel => 'Zoeken:'; + + @override + String get searchDocumentTextCategory => 'Documenttekst'; + + @override + String get searchWorkspaceTextCategory => 'Werkruimtetekst'; + + @override + String get searchHeadingsCategory => 'Koppen'; + + @override + String get shortcutUnassigned => 'Niet toegewezen'; + + @override + String get shortcutPressKeys => 'Druk op een toetscombinatie…'; + + @override + String get shortcutRecord => 'Sneltoets opnemen'; + + @override + String get shortcutRestoreAll => 'Alle standaardwaarden herstellen'; + + @override + String get shortcutRestoreAllQuestion => + 'Standaardsneltoetsen van alle acties herstellen? Aangepaste toewijzingen worden verwijderd.'; + + @override + String get shortcutSaveFailed => + 'Sneltoetsen zijn actief, maar konden niet worden opgeslagen. Probeer opnieuw om ze na herstart te behouden.'; + + @override + String get shortcutRetry => 'Opslaan opnieuw proberen'; + + @override + String get shortcutChanged => + 'Toewijzingen zijn gewijzigd. Controleer de huidige sneltoetsen en probeer opnieuw.'; + + @override + String get shortcutInvalid => + 'Deze combinatie kan in deze context niet worden toegewezen.'; + + @override + String get shortcutSystemWarning => + 'Deze combinatie wordt mogelijk door uw bureaublad gebruikt. BusyMark kan systeemsneltoetsen niet overschrijven.'; + + @override + String get shortcutConflicts => 'Deze sneltoets overlapt met deze acties:'; + + @override + String get shortcutRemove => 'Toewijzing verwijderen'; + + @override + String get shortcutRestoreDefault => 'Standaard herstellen'; + + @override + String get shortcutAssign => 'Toewijzen'; + + @override + String get shortcutReassign => 'Opnieuw toewijzen'; + + @override + String get shortcutContextApplication => 'Toepassing'; + + @override + String get shortcutContextDocument => 'Documentweergaven'; + + @override + String get shortcutContextEditor => 'Editor en bron'; + + @override + String get shortcutContextWritersideSource => 'Writerside-bron'; + + @override + String get shortcutContextWorkspace => 'Werkruimtenavigatie'; + + @override + String get shortcutContextTree => 'Zijbalkbomen'; + + @override + String get searchHeadingsCommand => 'Koppen zoeken'; + + @override + String get shortcutFixed => 'Beheerd door de editor; niet instelbaar.'; + + @override + String get shortcutDefaultConflict => + 'De standaardsneltoets conflicteert met een aangepaste toewijzing.'; + + @override + String get shortcutCurrentBinding => 'Huidige sneltoets'; + + @override + String get shortcutProposedBinding => 'Voorgestelde sneltoets'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 08351df2..a7103665 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -583,15 +583,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get keyboardShortcuts => 'Skróty klawiaturowe'; - @override - String get commandPalette => 'Paleta poleceń'; - - @override - String get commandPaletteHint => 'Wpisz polecenie'; - - @override - String get commandPaletteEmpty => 'Brak pasujących poleceń'; - @override String get commandUnavailableInContext => 'To polecenie nie jest dostępne w bieżącym kontekście.'; @@ -810,7 +801,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Pokaż ten wykaz skrótów klawiaturowych'; + 'Dostosuj skróty klawiaturowe aplikacji'; @override String get shortcutSyntaxReferenceDescription => @@ -1067,8 +1058,7 @@ class AppLocalizationsPl extends AppLocalizations { String get validateOnEdit => 'Sprawdzaj poprawność podczas edycji'; @override - String get clearRecentWorkspaces => - 'Wyczyść listę ostatnich obszarów roboczych'; + String get clearRecentWorkspaces => 'Wyczyść ostatnią historię'; @override String get editingButtonsPosition => 'Położenie przycisków edycji'; @@ -1868,15 +1858,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get sourceSearchNextMatch => 'Następne dopasowanie'; - @override - String get sourceSearchCaseSensitive => 'Uwzględniaj wielkość liter'; - - @override - String get sourceSearchWholeWord => 'Całe słowo'; - - @override - String get sourceSearchRegex => 'Wyrażenie regularne'; - @override String get sourceSearchReplacement => 'Zamień na'; @@ -2003,13 +1984,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get saveAs => 'Zapisz jako'; - @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.'; @@ -4403,9 +4377,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get clipboardHistory => 'Historia w Koszyku'; - @override - String get clipboardHistorySearchHint => 'Przeszukaj historię schowka'; - @override String get clipboardHistoryDescription => 'Otwórz historię w koszyku lokalną dla sesji.'; @@ -4428,10 +4399,6 @@ class AppLocalizationsPl extends AppLocalizations { @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.'; @@ -4489,13 +4456,6 @@ class AppLocalizationsPl extends AppLocalizations { 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'; @@ -5453,4 +5413,179 @@ class AppLocalizationsPl extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Sprawdzany element jest niedostępny. Sprawdź element, aby kontynuować.'; + + @override + String get searchDocumentsCategory => 'Dokumenty'; + + @override + String get searchTopicsCategory => 'Tematy'; + + @override + String get searchCurrentDocument => 'Bieżący dokument'; + + @override + String get searchCurrentWorkspace => 'Bieżący obszar roboczy'; + + @override + String get browseNotes => 'Przeglądaj notatki'; + + @override + String get searchActionsCommand => 'Szukaj działań'; + + @override + String get searchDocumentsCommand => 'Szukaj dokumentów'; + + @override + String get searchTopicsCommand => 'Szukaj tematów'; + + @override + String get searchRecentCommand => 'Szukaj ostatnich'; + + @override + String get searchResourceUnavailable => 'Ten zasób jest niedostępny.'; + + @override + String get notesSync => 'Synchronizuj notatki'; + + @override + String get searchInWorkspace => 'Szukaj w obszarze roboczym'; + + @override + String get searchCategory => 'Kategoria wyszukiwania'; + + @override + String get clearHistorySearchScope => 'Szukaj w historii dokumentów'; + + @override + String get searchInDocument => 'Szukaj w dokumencie'; + + @override + String get searchDocumentHint => 'Przeszukaj ten dokument…'; + + @override + String get searchWorkspaceHint => 'Przeszukaj ten obszar roboczy…'; + + @override + String get searchOutlineHint => 'Szukaj nagłówków…'; + + @override + String get searchHistoryHint => 'Przeszukaj historię lokalną…'; + + @override + String get searchEnterText => 'Wpisz tekst do wyszukania'; + + @override + String get searchAccountUnavailable => 'Konto niedostępne'; + + @override + String get searchDocumentsHint => 'Szukaj dokumentów…'; + + @override + String get searchTopicsHint => 'Szukaj tematów…'; + + @override + String get searchRecentHint => 'Szukaj ostatnich…'; + + @override + String get searchActionsHint => 'Szukaj działań…'; + + @override + String get searchRequiresNotesWorkspace => 'Wymaga obszaru roboczego Notatek'; + + @override + String get searchSidebarLabel => 'Szukaj:'; + + @override + String get searchDocumentTextCategory => 'Tekst dokumentu'; + + @override + String get searchWorkspaceTextCategory => 'Tekst obszaru roboczego'; + + @override + String get searchHeadingsCategory => 'Nagłówki'; + + @override + String get shortcutUnassigned => 'Nieprzypisany'; + + @override + String get shortcutPressKeys => 'Naciśnij kombinację klawiszy…'; + + @override + String get shortcutRecord => 'Zarejestruj skrót'; + + @override + String get shortcutRestoreAll => 'Przywróć wszystkie domyślne'; + + @override + String get shortcutRestoreAllQuestion => + 'Przywrócić domyślne skróty wszystkich działań? Własne przypisania zostaną usunięte.'; + + @override + String get shortcutSaveFailed => + 'Skróty są aktywne, lecz nie udało się ich zapisać. Spróbuj ponownie, aby zachować je po restarcie.'; + + @override + String get shortcutRetry => 'Ponów zapis'; + + @override + String get shortcutChanged => + 'Przypisania uległy zmianie. Sprawdź bieżące skróty i spróbuj ponownie.'; + + @override + String get shortcutInvalid => + 'Nie można przypisać tej kombinacji w tym kontekście.'; + + @override + String get shortcutSystemWarning => + 'Pulpit może używać tej kombinacji. BusyMark nie może zastępować skrótów systemowych.'; + + @override + String get shortcutConflicts => 'Ten skrót koliduje z tymi działaniami:'; + + @override + String get shortcutRemove => 'Usuń przypisanie'; + + @override + String get shortcutRestoreDefault => 'Przywróć domyślny'; + + @override + String get shortcutAssign => 'Przypisz'; + + @override + String get shortcutReassign => 'Przypisz ponownie'; + + @override + String get shortcutContextApplication => 'Aplikacja'; + + @override + String get shortcutContextDocument => 'Widoki dokumentu'; + + @override + String get shortcutContextEditor => 'Edytor i źródło'; + + @override + String get shortcutContextWritersideSource => 'Źródło Writerside'; + + @override + String get shortcutContextWorkspace => 'Nawigacja obszaru roboczego'; + + @override + String get shortcutContextTree => 'Drzewa paska bocznego'; + + @override + String get searchHeadingsCommand => 'Szukaj nagłówków'; + + @override + String get shortcutFixed => + 'Zarządzane przez edytor; nie można skonfigurować.'; + + @override + String get shortcutDefaultConflict => + 'Domyślny skrót koliduje z niestandardowym przypisaniem.'; + + @override + String get shortcutCurrentBinding => 'Bieżący skrót'; + + @override + String get shortcutProposedBinding => 'Proponowany skrót'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index fee2c505..b96412b6 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -586,15 +586,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get keyboardShortcuts => 'Atalhos de teclado'; - @override - String get commandPalette => 'Paleta de comandos'; - - @override - String get commandPaletteHint => 'Introduza um comando'; - - @override - String get commandPaletteEmpty => 'Nenhum comando correspondente'; - @override String get commandUnavailableInContext => 'Este comando não está disponível no contexto atual.'; @@ -814,7 +805,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Mostrar esta referência de atalhos de teclado'; + 'Personalizar os atalhos de teclado da aplicação'; @override String get shortcutSyntaxReferenceDescription => @@ -1068,7 +1059,7 @@ class AppLocalizationsPt extends AppLocalizations { String get validateOnEdit => 'Validar ao editar'; @override - String get clearRecentWorkspaces => 'Limpar espaços de trabalho recentes'; + String get clearRecentWorkspaces => 'Limpar histórico recente'; @override String get editingButtonsPosition => 'Posição dos botões de edição'; @@ -1854,16 +1845,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get sourceSearchNextMatch => 'Próxima correspondência'; - @override - String get sourceSearchCaseSensitive => - 'Diferenciar maiúsculas de minúsculas'; - - @override - String get sourceSearchWholeWord => 'Palavra inteira'; - - @override - String get sourceSearchRegex => 'Expressão regular'; - @override String get sourceSearchReplacement => 'Substituir por'; @@ -1991,13 +1972,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get saveAs => 'Guardar como'; - @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.'; @@ -4403,10 +4377,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get clipboardHistory => 'Histórico da Área de Transferência'; - @override - String get clipboardHistorySearchHint => - 'Pesquisar no histórico da área de transferência'; - @override String get clipboardHistoryDescription => 'Abrir o histórico da área de transferência local da sessão.'; @@ -4431,10 +4401,6 @@ class AppLocalizationsPt extends AppLocalizations { 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.'; @@ -4491,12 +4457,6 @@ class AppLocalizationsPt extends AppLocalizations { 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'; @@ -5461,6 +5421,182 @@ class AppLocalizationsPt extends AppLocalizations { @override String get wsPropertiesUnavailable => 'O elemento inspecionado está indisponível. Inspecione um elemento para continuar.'; + + @override + String get searchDocumentsCategory => 'Documentos'; + + @override + String get searchTopicsCategory => 'Tópicos'; + + @override + String get searchCurrentDocument => 'Documento atual'; + + @override + String get searchCurrentWorkspace => 'Área de trabalho atual'; + + @override + String get browseNotes => 'Explorar notas'; + + @override + String get searchActionsCommand => 'Pesquisar ações'; + + @override + String get searchDocumentsCommand => 'Pesquisar documentos'; + + @override + String get searchTopicsCommand => 'Pesquisar tópicos'; + + @override + String get searchRecentCommand => 'Pesquisar recentes'; + + @override + String get searchResourceUnavailable => 'Este recurso não está disponível.'; + + @override + String get notesSync => 'Sincronizar notas'; + + @override + String get searchInWorkspace => 'Pesquisar no espaço de trabalho'; + + @override + String get searchCategory => 'Categoria de pesquisa'; + + @override + String get clearHistorySearchScope => 'Pesquisar no histórico de documentos'; + + @override + String get searchInDocument => 'Pesquisar no documento'; + + @override + String get searchDocumentHint => 'Pesquisar neste documento…'; + + @override + String get searchWorkspaceHint => 'Pesquisar neste espaço de trabalho…'; + + @override + String get searchOutlineHint => 'Pesquisar títulos…'; + + @override + String get searchHistoryHint => 'Pesquisar no histórico local…'; + + @override + String get searchEnterText => 'Introduza texto para pesquisar'; + + @override + String get searchAccountUnavailable => 'Conta indisponível'; + + @override + String get searchDocumentsHint => 'Pesquisar documentos…'; + + @override + String get searchTopicsHint => 'Pesquisar tópicos…'; + + @override + String get searchRecentHint => 'Pesquisar recentes…'; + + @override + String get searchActionsHint => 'Pesquisar ações…'; + + @override + String get searchRequiresNotesWorkspace => + 'Requer um espaço de trabalho de Notas'; + + @override + String get searchSidebarLabel => 'Pesquisar:'; + + @override + String get searchDocumentTextCategory => 'Texto do documento'; + + @override + String get searchWorkspaceTextCategory => 'Texto do espaço de trabalho'; + + @override + String get searchHeadingsCategory => 'Cabeçalhos'; + + @override + String get shortcutUnassigned => 'Não atribuído'; + + @override + String get shortcutPressKeys => 'Prima uma combinação de teclas…'; + + @override + String get shortcutRecord => 'Capturar atalho'; + + @override + String get shortcutRestoreAll => 'Repor todas as predefinições'; + + @override + String get shortcutRestoreAllQuestion => + 'Repor os atalhos predefinidos de todas as ações? As atribuições personalizadas serão removidas.'; + + @override + String get shortcutSaveFailed => + 'Os atalhos estão ativos, mas não foram guardados. Tente novamente para os manter após reiniciar.'; + + @override + String get shortcutRetry => 'Tentar guardar novamente'; + + @override + String get shortcutChanged => + 'As atribuições mudaram. Reveja os atalhos atuais e tente novamente.'; + + @override + String get shortcutInvalid => + 'Esta combinação não pode ser atribuída neste contexto.'; + + @override + String get shortcutSystemWarning => + 'O ambiente de trabalho pode usar esta combinação. O BusyMark não pode substituir atalhos do sistema.'; + + @override + String get shortcutConflicts => + 'Este atalho entra em conflito com estas ações:'; + + @override + String get shortcutRemove => 'Remover atribuição'; + + @override + String get shortcutRestoreDefault => 'Repor predefinição'; + + @override + String get shortcutAssign => 'Atribuir'; + + @override + String get shortcutReassign => 'Reatribuir'; + + @override + String get shortcutContextApplication => 'Aplicação'; + + @override + String get shortcutContextDocument => 'Vistas do documento'; + + @override + String get shortcutContextEditor => 'Editor e código-fonte'; + + @override + String get shortcutContextWritersideSource => 'Código-fonte do Writerside'; + + @override + String get shortcutContextWorkspace => 'Navegação do espaço de trabalho'; + + @override + String get shortcutContextTree => 'Árvores laterais'; + + @override + String get searchHeadingsCommand => 'Pesquisar títulos'; + + @override + String get shortcutFixed => 'Gerido pelo editor; não é configurável.'; + + @override + String get shortcutDefaultConflict => + 'O atalho predefinido está em conflito com uma atribuição personalizada.'; + + @override + String get shortcutCurrentBinding => 'Atalho atual'; + + @override + String get shortcutProposedBinding => 'Atalho proposto'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -6043,15 +6179,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get keyboardShortcuts => 'Atalhos de teclado'; - @override - String get commandPalette => 'Paleta de comandos'; - - @override - String get commandPaletteHint => 'Digite um comando'; - - @override - String get commandPaletteEmpty => 'Nenhum comando correspondente'; - @override String get commandUnavailableInContext => 'Este comando não está disponível no contexto atual.'; @@ -6271,7 +6398,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get shortcutKeyboardShortcutsDescription => - 'Mostrar esta referência de atalhos de teclado'; + 'Personalizar os atalhos de teclado do aplicativo'; @override String get shortcutSyntaxReferenceDescription => @@ -6526,7 +6653,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get validateOnEdit => 'Validar ao editar'; @override - String get clearRecentWorkspaces => 'Limpar espaços de trabalho recentes'; + String get clearRecentWorkspaces => 'Limpar histórico recente'; @override String get editingButtonsPosition => 'Posição dos botões de edição'; @@ -7312,16 +7439,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get sourceSearchNextMatch => 'Próxima correspondência'; - @override - String get sourceSearchCaseSensitive => - 'Diferenciar maiúsculas de minúsculas'; - - @override - String get sourceSearchWholeWord => 'Palavra inteira'; - - @override - String get sourceSearchRegex => 'Expressão regular'; - @override String get sourceSearchReplacement => 'Substituir por'; @@ -7449,13 +7566,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get saveAs => 'Salvar como'; - @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.'; @@ -9858,10 +9968,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get clipboardHistory => 'Histórico da Área de Transferência'; - @override - String get clipboardHistorySearchHint => - 'Pesquisar no histórico da área de transferência'; - @override String get clipboardHistoryDescription => 'Abrir o histórico da área de transferência local da sessão.'; @@ -9886,10 +9992,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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.'; @@ -9947,13 +10049,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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'; @@ -10916,4 +11011,180 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get wsPropertiesUnavailable => 'O elemento inspecionado está indisponível. Inspecione um elemento para continuar.'; + + @override + String get searchDocumentsCategory => 'Documentos'; + + @override + String get searchTopicsCategory => 'Tópicos'; + + @override + String get searchCurrentDocument => 'Documento atual'; + + @override + String get searchCurrentWorkspace => 'Área de trabalho atual'; + + @override + String get browseNotes => 'Explorar notas'; + + @override + String get searchActionsCommand => 'Pesquisar ações'; + + @override + String get searchDocumentsCommand => 'Pesquisar documentos'; + + @override + String get searchTopicsCommand => 'Pesquisar tópicos'; + + @override + String get searchRecentCommand => 'Pesquisar recentes'; + + @override + String get searchResourceUnavailable => 'Este recurso não está disponível.'; + + @override + String get notesSync => 'Sincronizar notas'; + + @override + String get searchInWorkspace => 'Pesquisar no espaço de trabalho'; + + @override + String get searchCategory => 'Categoria de pesquisa'; + + @override + String get clearHistorySearchScope => 'Pesquisar no histórico de documentos'; + + @override + String get searchInDocument => 'Pesquisar no documento'; + + @override + String get searchDocumentHint => 'Pesquisar neste documento…'; + + @override + String get searchWorkspaceHint => 'Pesquisar neste espaço de trabalho…'; + + @override + String get searchOutlineHint => 'Pesquisar títulos…'; + + @override + String get searchHistoryHint => 'Pesquisar no histórico local…'; + + @override + String get searchEnterText => 'Digite texto para pesquisar'; + + @override + String get searchAccountUnavailable => 'Conta indisponível'; + + @override + String get searchDocumentsHint => 'Pesquisar documentos…'; + + @override + String get searchTopicsHint => 'Pesquisar tópicos…'; + + @override + String get searchRecentHint => 'Pesquisar recentes…'; + + @override + String get searchActionsHint => 'Pesquisar ações…'; + + @override + String get searchRequiresNotesWorkspace => + 'Requer um espaço de trabalho de Notas'; + + @override + String get searchSidebarLabel => 'Pesquisar:'; + + @override + String get searchDocumentTextCategory => 'Texto do documento'; + + @override + String get searchWorkspaceTextCategory => 'Texto do espaço de trabalho'; + + @override + String get searchHeadingsCategory => 'Títulos'; + + @override + String get shortcutUnassigned => 'Não atribuído'; + + @override + String get shortcutPressKeys => 'Pressione uma combinação de teclas…'; + + @override + String get shortcutRecord => 'Capturar atalho'; + + @override + String get shortcutRestoreAll => 'Restaurar todos os padrões'; + + @override + String get shortcutRestoreAllQuestion => + 'Restaurar os atalhos padrão de todas as ações? As atribuições personalizadas serão removidas.'; + + @override + String get shortcutSaveFailed => + 'Os atalhos estão ativos, mas não foram salvos. Tente novamente para mantê-los após reiniciar.'; + + @override + String get shortcutRetry => 'Tentar salvar novamente'; + + @override + String get shortcutChanged => + 'As atribuições mudaram. Confira os atalhos atuais e tente novamente.'; + + @override + String get shortcutInvalid => + 'Esta combinação não pode ser atribuída neste contexto.'; + + @override + String get shortcutSystemWarning => + 'O ambiente de trabalho pode usar esta combinação. O BusyMark não pode substituir atalhos do sistema.'; + + @override + String get shortcutConflicts => + 'Este atalho entra em conflito com estas ações:'; + + @override + String get shortcutRemove => 'Remover atribuição'; + + @override + String get shortcutRestoreDefault => 'Restaurar padrão'; + + @override + String get shortcutAssign => 'Atribuir'; + + @override + String get shortcutReassign => 'Reatribuir'; + + @override + String get shortcutContextApplication => 'Aplicativo'; + + @override + String get shortcutContextDocument => 'Visualizações do documento'; + + @override + String get shortcutContextEditor => 'Editor e código-fonte'; + + @override + String get shortcutContextWritersideSource => 'Código-fonte do Writerside'; + + @override + String get shortcutContextWorkspace => 'Navegação do espaço de trabalho'; + + @override + String get shortcutContextTree => 'Árvores laterais'; + + @override + String get searchHeadingsCommand => 'Pesquisar títulos'; + + @override + String get shortcutFixed => 'Gerenciado pelo editor; não é configurável.'; + + @override + String get shortcutDefaultConflict => + 'O atalho padrão está em conflito com uma atribuição personalizada.'; + + @override + String get shortcutCurrentBinding => 'Atalho atual'; + + @override + String get shortcutProposedBinding => 'Atalho proposto'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 1f63a5fd..c65ea905 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -586,15 +586,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get keyboardShortcuts => 'Сочетания клавиш'; - @override - String get commandPalette => 'Палитра команд'; - - @override - String get commandPaletteHint => 'Введите команду'; - - @override - String get commandPaletteEmpty => 'Нет подходящих команд'; - @override String get commandUnavailableInContext => 'Эта команда недоступна в текущем контексте.'; @@ -814,7 +805,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Показать справку по сочетаниям клавиш'; + 'Настроить сочетания клавиш приложения'; @override String get shortcutSyntaxReferenceDescription => @@ -1063,8 +1054,7 @@ class AppLocalizationsRu extends AppLocalizations { String get validateOnEdit => 'Проверять при редактировании'; @override - String get clearRecentWorkspaces => - 'Очистить список недавних рабочих областей'; + String get clearRecentWorkspaces => 'Очистить недавнюю историю'; @override String get editingButtonsPosition => 'Положение кнопок редактирования'; @@ -1865,15 +1855,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sourceSearchNextMatch => 'Следующее совпадение'; - @override - String get sourceSearchCaseSensitive => 'С учётом регистра'; - - @override - String get sourceSearchWholeWord => 'Слово целиком'; - - @override - String get sourceSearchRegex => 'Регулярное выражение'; - @override String get sourceSearchReplacement => 'Заменить на'; @@ -2000,13 +1981,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get saveAs => 'Сохранить как'; - @override - String get sourceSearchInvalidRegex => 'Некорректное регулярное выражение'; - - @override - String get sourceSearchZeroLengthUnsupported => - 'Совпадения регулярных выражений нулевой длины не поддерживаются (например, ^, \$ или проверки вперёд, совпадающие только с позицией).'; - @override String get workspaceSearchIncomplete => 'Показана только часть результатов. Покажите больше или уточните поиск.'; @@ -4406,9 +4380,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get clipboardHistory => 'История буфера обмена'; - @override - String get clipboardHistorySearchHint => 'Поиск в истории буфера обмена'; - @override String get clipboardHistoryDescription => 'Открыть историю буфера обмена, специфичную для сеанса.'; @@ -4432,10 +4403,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get clipboardNoItems => 'История буфера обмена пуста.'; - @override - String get clipboardNoMatches => - 'Нет элементов буфера обмена, соответствующих этому поиску.'; - @override String get clipboardDisabled => 'Коллекция \"История буфера обмена\" отключена в настройках.'; @@ -4493,13 +4460,6 @@ class AppLocalizationsRu extends AppLocalizations { String get localHistoryNoRevisions => 'Этот документ не имеет версий в локальной истории.'; - @override - String get localHistoryNoMatches => - 'Ни одна версия не соответствует этому поиску.'; - - @override - String get localHistorySearchHint => 'Поиск в локальной истории'; - @override String get localHistorySelectedRevision => 'Выбранная версия'; @@ -5456,4 +5416,180 @@ class AppLocalizationsRu extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Проверяемый элемент недоступен. Выберите элемент для продолжения.'; + + @override + String get searchDocumentsCategory => 'Документы'; + + @override + String get searchTopicsCategory => 'Темы'; + + @override + String get searchCurrentDocument => 'Текущий документ'; + + @override + String get searchCurrentWorkspace => 'Текущая рабочая область'; + + @override + String get browseNotes => 'Обзор заметок'; + + @override + String get searchActionsCommand => 'Поиск действий'; + + @override + String get searchDocumentsCommand => 'Поиск документов'; + + @override + String get searchTopicsCommand => 'Поиск тем'; + + @override + String get searchRecentCommand => 'Поиск недавних'; + + @override + String get searchResourceUnavailable => 'Этот ресурс недоступен.'; + + @override + String get notesSync => 'Синхронизировать заметки'; + + @override + String get searchInWorkspace => 'Поиск в рабочей области'; + + @override + String get searchCategory => 'Категория поиска'; + + @override + String get clearHistorySearchScope => 'Поиск в истории документов'; + + @override + String get searchInDocument => 'Поиск в документе'; + + @override + String get searchDocumentHint => 'Поиск в этом документе…'; + + @override + String get searchWorkspaceHint => 'Поиск в этой рабочей области…'; + + @override + String get searchOutlineHint => 'Поиск заголовков…'; + + @override + String get searchHistoryHint => 'Поиск в локальной истории…'; + + @override + String get searchEnterText => 'Введите текст для поиска'; + + @override + String get searchAccountUnavailable => 'Аккаунт недоступен'; + + @override + String get searchDocumentsHint => 'Поиск документов…'; + + @override + String get searchTopicsHint => 'Поиск тем…'; + + @override + String get searchRecentHint => 'Поиск недавних…'; + + @override + String get searchActionsHint => 'Поиск действий…'; + + @override + String get searchRequiresNotesWorkspace => + 'Требуется рабочая область заметок'; + + @override + String get searchSidebarLabel => 'Поиск:'; + + @override + String get searchDocumentTextCategory => 'Текст документа'; + + @override + String get searchWorkspaceTextCategory => 'Текст рабочей области'; + + @override + String get searchHeadingsCategory => 'Заголовки'; + + @override + String get shortcutUnassigned => 'Не назначено'; + + @override + String get shortcutPressKeys => 'Нажмите сочетание клавиш…'; + + @override + String get shortcutRecord => 'Записать сочетание клавиш'; + + @override + String get shortcutRestoreAll => 'Восстановить все значения по умолчанию'; + + @override + String get shortcutRestoreAllQuestion => + 'Восстановить стандартные сочетания всех действий? Пользовательские назначения будут удалены.'; + + @override + String get shortcutSaveFailed => + 'Сочетания действуют, но не сохранены. Повторите сохранение, чтобы они сохранились после перезапуска.'; + + @override + String get shortcutRetry => 'Повторить сохранение'; + + @override + String get shortcutChanged => + 'Назначения изменились. Проверьте текущие сочетания и повторите попытку.'; + + @override + String get shortcutInvalid => + 'Это сочетание нельзя назначить в данном контексте.'; + + @override + String get shortcutSystemWarning => + 'Это сочетание может использоваться рабочим столом. BusyMark не может переопределять системные сочетания.'; + + @override + String get shortcutConflicts => + 'Это сочетание конфликтует со следующими действиями:'; + + @override + String get shortcutRemove => 'Удалить назначение'; + + @override + String get shortcutRestoreDefault => 'Восстановить стандартное'; + + @override + String get shortcutAssign => 'Назначить'; + + @override + String get shortcutReassign => 'Переназначить'; + + @override + String get shortcutContextApplication => 'Приложение'; + + @override + String get shortcutContextDocument => 'Представления документа'; + + @override + String get shortcutContextEditor => 'Редактор и исходный код'; + + @override + String get shortcutContextWritersideSource => 'Исходный код Writerside'; + + @override + String get shortcutContextWorkspace => 'Навигация рабочей области'; + + @override + String get shortcutContextTree => 'Деревья боковой панели'; + + @override + String get searchHeadingsCommand => 'Поиск заголовков'; + + @override + String get shortcutFixed => 'Управляется редактором; настройка недоступна.'; + + @override + String get shortcutDefaultConflict => + 'Сочетание по умолчанию конфликтует с пользовательским назначением.'; + + @override + String get shortcutCurrentBinding => 'Текущее сочетание'; + + @override + String get shortcutProposedBinding => 'Предлагаемое сочетание'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index e79598c2..8aa8a53b 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -582,15 +582,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get keyboardShortcuts => 'Klavye Kısayolları'; - @override - String get commandPalette => 'Komut Paleti'; - - @override - String get commandPaletteHint => 'Bir komut yazın'; - - @override - String get commandPaletteEmpty => 'Eşleşen komut yok'; - @override String get commandUnavailableInContext => 'Mevcut düzenleyici bağlamında kullanılamıyor'; @@ -809,7 +800,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Bu klavye kısayolu referansını göster'; + 'Uygulama klavye kısayollarını özelleştir'; @override String get shortcutSyntaxReferenceDescription => 'Sözdizimi başvurusunu aç'; @@ -1060,7 +1051,7 @@ class AppLocalizationsTr extends AppLocalizations { String get validateOnEdit => 'Düzenlemede doğrula'; @override - String get clearRecentWorkspaces => 'Son çalışma alanlarını temizle'; + String get clearRecentWorkspaces => 'Son geçmişi temizle'; @override String get editingButtonsPosition => 'Düzenleme düğmelerinin konumu'; @@ -1840,15 +1831,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get sourceSearchNextMatch => 'Sonraki eşleşme'; - @override - String get sourceSearchCaseSensitive => 'Büyük-küçük harf duyarlı'; - - @override - String get sourceSearchWholeWord => 'Tam sözcük'; - - @override - String get sourceSearchRegex => 'Düzenli ifade'; - @override String get sourceSearchReplacement => 'Şununla değiştir:'; @@ -1974,13 +1956,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get saveAs => 'Farklı Kaydet'; - @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.'; @@ -4350,9 +4325,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get clipboardHistory => 'Kopyalama Geçmişi'; - @override - String get clipboardHistorySearchHint => 'Pano geçmişinde ara'; - @override String get clipboardHistoryDescription => 'Oturuma özgü kopyalama geçmişini aç.'; @@ -4376,10 +4348,6 @@ class AppLocalizationsTr extends AppLocalizations { @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.'; @@ -4437,13 +4405,6 @@ class AppLocalizationsTr extends AppLocalizations { 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'; @@ -5396,4 +5357,178 @@ class AppLocalizationsTr extends AppLocalizations { @override String get wsPropertiesUnavailable => 'İncelenen öğe kullanılamıyor. Devam etmek için bir öğeyi inceleyin.'; + + @override + String get searchDocumentsCategory => 'Belgeler'; + + @override + String get searchTopicsCategory => 'Konular'; + + @override + String get searchCurrentDocument => 'Geçerli belge'; + + @override + String get searchCurrentWorkspace => 'Geçerli çalışma alanı'; + + @override + String get browseNotes => 'Notlara göz at'; + + @override + String get searchActionsCommand => 'Eylemleri ara'; + + @override + String get searchDocumentsCommand => 'Belgeleri ara'; + + @override + String get searchTopicsCommand => 'Konuları ara'; + + @override + String get searchRecentCommand => 'Son kullanılanları ara'; + + @override + String get searchResourceUnavailable => 'Bu kaynak kullanılamıyor.'; + + @override + String get notesSync => 'Notları eşitle'; + + @override + String get searchInWorkspace => 'Çalışma Alanında Ara'; + + @override + String get searchCategory => 'Arama kategorisi'; + + @override + String get clearHistorySearchScope => 'Belgelerin geçmişinde ara'; + + @override + String get searchInDocument => 'Belgede ara'; + + @override + String get searchDocumentHint => 'Bu belgede ara…'; + + @override + String get searchWorkspaceHint => 'Bu çalışma alanında ara…'; + + @override + String get searchOutlineHint => 'Başlıklarda ara…'; + + @override + String get searchHistoryHint => 'Yerel geçmişte ara…'; + + @override + String get searchEnterText => 'Aranacak metni girin'; + + @override + String get searchAccountUnavailable => 'Hesap kullanılamıyor'; + + @override + String get searchDocumentsHint => 'Belgeleri ara…'; + + @override + String get searchTopicsHint => 'Konuları ara…'; + + @override + String get searchRecentHint => 'Son kullanılanları ara…'; + + @override + String get searchActionsHint => 'Eylemleri ara…'; + + @override + String get searchRequiresNotesWorkspace => 'Notlar çalışma alanı gerektirir'; + + @override + String get searchSidebarLabel => 'Ara:'; + + @override + String get searchDocumentTextCategory => 'Belge metni'; + + @override + String get searchWorkspaceTextCategory => 'Çalışma alanı metni'; + + @override + String get searchHeadingsCategory => 'Başlıklar'; + + @override + String get shortcutUnassigned => 'Atanmamış'; + + @override + String get shortcutPressKeys => 'Bir tuş birleşimine basın…'; + + @override + String get shortcutRecord => 'Kısayol kaydet'; + + @override + String get shortcutRestoreAll => 'Tüm varsayılanları geri yükle'; + + @override + String get shortcutRestoreAllQuestion => + 'Tüm eylemlerin varsayılan kısayolları geri yüklensin mi? Özel atamalar kaldırılacak.'; + + @override + String get shortcutSaveFailed => + 'Kısayollar etkin ancak kaydedilemedi. Yeniden başlatmadan sonra korumak için tekrar kaydedin.'; + + @override + String get shortcutRetry => 'Kaydetmeyi yeniden dene'; + + @override + String get shortcutChanged => + 'Atamalar değişti. Geçerli kısayolları gözden geçirip yeniden deneyin.'; + + @override + String get shortcutInvalid => 'Bu birleşim bu bağlamda atanamaz.'; + + @override + String get shortcutSystemWarning => + 'Masaüstünüz bu birleşimi kullanıyor olabilir. BusyMark sistem kısayollarını geçersiz kılamaz.'; + + @override + String get shortcutConflicts => 'Bu kısayol şu eylemlerle çakışıyor:'; + + @override + String get shortcutRemove => 'Atamayı kaldır'; + + @override + String get shortcutRestoreDefault => 'Varsayılanı geri yükle'; + + @override + String get shortcutAssign => 'Ata'; + + @override + String get shortcutReassign => 'Yeniden ata'; + + @override + String get shortcutContextApplication => 'Uygulama'; + + @override + String get shortcutContextDocument => 'Belge görünümleri'; + + @override + String get shortcutContextEditor => 'Düzenleyici ve kaynak'; + + @override + String get shortcutContextWritersideSource => 'Writerside kaynağı'; + + @override + String get shortcutContextWorkspace => 'Çalışma alanı gezintisi'; + + @override + String get shortcutContextTree => 'Kenar çubuğu ağaçları'; + + @override + String get searchHeadingsCommand => 'Başlıklarda Ara'; + + @override + String get shortcutFixed => + 'Düzenleyici tarafından yönetilir; yapılandırılamaz.'; + + @override + String get shortcutDefaultConflict => + 'Varsayılan kısayol özel bir atamayla çakışıyor.'; + + @override + String get shortcutCurrentBinding => 'Geçerli kısayol'; + + @override + String get shortcutProposedBinding => 'Önerilen kısayol'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 81c4de71..a2de1b9c 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -585,15 +585,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get keyboardShortcuts => 'Комбінації клавіш'; - @override - String get commandPalette => 'Палітра команд'; - - @override - String get commandPaletteHint => 'Введіть команду'; - - @override - String get commandPaletteEmpty => 'Немає відповідних команд'; - @override String get commandUnavailableInContext => 'Ця команда недоступна в поточному контексті.'; @@ -813,7 +804,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Показати довідку з комбінацій клавіш'; + 'Налаштувати сполучення клавіш застосунку'; @override String get shortcutSyntaxReferenceDescription => @@ -1068,8 +1059,7 @@ class AppLocalizationsUk extends AppLocalizations { String get validateOnEdit => 'Перевіряти під час редагування'; @override - String get clearRecentWorkspaces => - 'Очистити список останніх робочих областей'; + String get clearRecentWorkspaces => 'Очистити недавню історію'; @override String get editingButtonsPosition => 'Розташування кнопок редагування'; @@ -1873,15 +1863,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get sourceSearchNextMatch => 'Наступний збіг'; - @override - String get sourceSearchCaseSensitive => 'З урахуванням регістру'; - - @override - String get sourceSearchWholeWord => 'Ціле слово'; - - @override - String get sourceSearchRegex => 'Регулярний вираз'; - @override String get sourceSearchReplacement => 'Замінити на'; @@ -2008,13 +1989,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get saveAs => 'Зберегти як'; - @override - String get sourceSearchInvalidRegex => 'Некоректний регулярний вираз'; - - @override - String get sourceSearchZeroLengthUnsupported => - 'Збіги регулярних виразів нульової довжини не підтримуються (наприклад, ^, \$ або перевірки вперед, що збігаються лише з позицією).'; - @override String get workspaceSearchIncomplete => 'Показано лише частину результатів. Покажіть більше або звузьте пошук.'; @@ -4414,9 +4388,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get clipboardHistory => 'Історія Вирізання'; - @override - String get clipboardHistorySearchHint => 'Пошук в історії буфера обміну'; - @override String get clipboardHistoryDescription => 'Відкрити історію вирізання, прив\'язану до сесії.'; @@ -4439,10 +4410,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get clipboardNoItems => 'Історія вирізання порожня.'; - @override - String get clipboardNoMatches => - 'Жоден елемент вирізання не відповідає цьому пошуку.'; - @override String get clipboardDisabled => 'Збірка \"Історія Вирізання\" вимкнена в Налаштуваннях.'; @@ -4500,13 +4467,6 @@ class AppLocalizationsUk extends AppLocalizations { String get localHistoryNoRevisions => 'Цей документ не має версій локальної історії.'; - @override - String get localHistoryNoMatches => - 'Жодна версія локальної історії не відповідає цьому пошуку.'; - - @override - String get localHistorySearchHint => 'Пошук у локальній історії'; - @override String get localHistorySelectedRevision => 'Обрана версія'; @@ -5466,4 +5426,178 @@ class AppLocalizationsUk extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Елемент, що перевіряється, недоступний. Виберіть елемент для продовження.'; + + @override + String get searchDocumentsCategory => 'Документи'; + + @override + String get searchTopicsCategory => 'Теми'; + + @override + String get searchCurrentDocument => 'Поточний документ'; + + @override + String get searchCurrentWorkspace => 'Поточна робоча область'; + + @override + String get browseNotes => 'Переглянути нотатки'; + + @override + String get searchActionsCommand => 'Пошук дій'; + + @override + String get searchDocumentsCommand => 'Пошук документів'; + + @override + String get searchTopicsCommand => 'Пошук тем'; + + @override + String get searchRecentCommand => 'Пошук нещодавніх'; + + @override + String get searchResourceUnavailable => 'Цей ресурс недоступний.'; + + @override + String get notesSync => 'Синхронізувати нотатки'; + + @override + String get searchInWorkspace => 'Пошук у робочій області'; + + @override + String get searchCategory => 'Категорія пошуку'; + + @override + String get clearHistorySearchScope => 'Пошук в історії документів'; + + @override + String get searchInDocument => 'Пошук у документі'; + + @override + String get searchDocumentHint => 'Пошук у цьому документі…'; + + @override + String get searchWorkspaceHint => 'Пошук у цьому робочому просторі…'; + + @override + String get searchOutlineHint => 'Пошук заголовків…'; + + @override + String get searchHistoryHint => 'Пошук у локальній історії…'; + + @override + String get searchEnterText => 'Введіть текст для пошуку'; + + @override + String get searchAccountUnavailable => 'Обліковий запис недоступний'; + + @override + String get searchDocumentsHint => 'Пошук документів…'; + + @override + String get searchTopicsHint => 'Пошук тем…'; + + @override + String get searchRecentHint => 'Пошук нещодавніх…'; + + @override + String get searchActionsHint => 'Пошук дій…'; + + @override + String get searchRequiresNotesWorkspace => 'Потрібен робочий простір нотаток'; + + @override + String get searchSidebarLabel => 'Пошук:'; + + @override + String get searchDocumentTextCategory => 'Текст документа'; + + @override + String get searchWorkspaceTextCategory => 'Текст робочої області'; + + @override + String get searchHeadingsCategory => 'Заголовки'; + + @override + String get shortcutUnassigned => 'Не призначено'; + + @override + String get shortcutPressKeys => 'Натисніть сполучення клавіш…'; + + @override + String get shortcutRecord => 'Записати сполучення клавіш'; + + @override + String get shortcutRestoreAll => 'Відновити всі типові значення'; + + @override + String get shortcutRestoreAllQuestion => + 'Відновити типові сполучення всіх дій? Власні призначення буде видалено.'; + + @override + String get shortcutSaveFailed => + 'Сполучення діють, але їх не збережено. Повторіть збереження, щоб залишити їх після перезапуску.'; + + @override + String get shortcutRetry => 'Повторити збереження'; + + @override + String get shortcutChanged => + 'Призначення змінилися. Перевірте поточні сполучення та повторіть спробу.'; + + @override + String get shortcutInvalid => + 'Це сполучення не можна призначити в цьому контексті.'; + + @override + String get shortcutSystemWarning => + 'Це сполучення може використовуватися стільницею. BusyMark не може перевизначати системні сполучення.'; + + @override + String get shortcutConflicts => 'Це сполучення конфліктує з такими діями:'; + + @override + String get shortcutRemove => 'Вилучити призначення'; + + @override + String get shortcutRestoreDefault => 'Відновити типове'; + + @override + String get shortcutAssign => 'Призначити'; + + @override + String get shortcutReassign => 'Перепризначити'; + + @override + String get shortcutContextApplication => 'Застосунок'; + + @override + String get shortcutContextDocument => 'Подання документа'; + + @override + String get shortcutContextEditor => 'Редактор і джерело'; + + @override + String get shortcutContextWritersideSource => 'Джерело Writerside'; + + @override + String get shortcutContextWorkspace => 'Навігація робочою областю'; + + @override + String get shortcutContextTree => 'Дерева бічної панелі'; + + @override + String get searchHeadingsCommand => 'Пошук заголовків'; + + @override + String get shortcutFixed => 'Керується редактором; налаштування недоступне.'; + + @override + String get shortcutDefaultConflict => + 'Типове сполучення конфліктує з користувацьким призначенням.'; + + @override + String get shortcutCurrentBinding => 'Поточне сполучення'; + + @override + String get shortcutProposedBinding => 'Запропоноване сполучення'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index c1d9082e..a6733207 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -582,15 +582,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get keyboardShortcuts => 'Phím tắt'; - @override - String get commandPalette => 'Bảng lệnh'; - - @override - String get commandPaletteHint => 'Nhập lệnh'; - - @override - String get commandPaletteEmpty => 'Không có lệnh phù hợp'; - @override String get commandUnavailableInContext => 'Lệnh không khả dụng trong ngữ cảnh trình biên tập hiện tại'; @@ -810,7 +801,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get shortcutKeyboardShortcutsDescription => - 'Hiển thị tham chiếu phím tắt này'; + 'Tùy chỉnh phím tắt của ứng dụng'; @override String get shortcutSyntaxReferenceDescription => 'Mở tham chiếu cú pháp'; @@ -1055,7 +1046,7 @@ class AppLocalizationsVi extends AppLocalizations { String get validateOnEdit => 'Kiểm tra khi chỉnh sửa'; @override - String get clearRecentWorkspaces => 'Xóa không gian làm việc gần đây'; + String get clearRecentWorkspaces => 'Xóa lịch sử gần đây'; @override String get editingButtonsPosition => 'Vị trí các nút chỉnh sửa'; @@ -1836,15 +1827,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get sourceSearchNextMatch => 'Kết quả khớp tiếp theo'; - @override - String get sourceSearchCaseSensitive => 'Phân biệt chữ hoa, chữ thường'; - - @override - String get sourceSearchWholeWord => 'Toàn bộ từ'; - - @override - String get sourceSearchRegex => 'Biểu thức chính quy'; - @override String get sourceSearchReplacement => 'Thay thế bằng'; @@ -1971,13 +1953,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get saveAs => 'Lưu thành'; - @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.'; @@ -4347,9 +4322,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get clipboardHistory => 'Lịch sử sao chép'; - @override - String get clipboardHistorySearchHint => 'Tìm kiếm lịch sử bảng nhớ tạm'; - @override String get clipboardHistoryDescription => 'Mở lịch sử sao chép cục bộ phiên.'; @@ -4371,10 +4343,6 @@ class AppLocalizationsVi extends AppLocalizations { @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.'; @@ -4431,13 +4399,6 @@ class AppLocalizationsVi extends AppLocalizations { 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'; @@ -5387,4 +5348,178 @@ class AppLocalizationsVi extends AppLocalizations { @override String get wsPropertiesUnavailable => 'Phần tử đang kiểm tra không khả dụng. Kiểm tra một phần tử để tiếp tục.'; + + @override + String get searchDocumentsCategory => 'Tài liệu'; + + @override + String get searchTopicsCategory => 'Chủ đề'; + + @override + String get searchCurrentDocument => 'Tài liệu hiện tại'; + + @override + String get searchCurrentWorkspace => 'Không gian làm việc hiện tại'; + + @override + String get browseNotes => 'Duyệt ghi chú'; + + @override + String get searchActionsCommand => 'Tìm thao tác'; + + @override + String get searchDocumentsCommand => 'Tìm tài liệu'; + + @override + String get searchTopicsCommand => 'Tìm chủ đề'; + + @override + String get searchRecentCommand => 'Tìm mục gần đây'; + + @override + String get searchResourceUnavailable => 'Tài nguyên này không khả dụng.'; + + @override + String get notesSync => 'Đồng bộ ghi chú'; + + @override + String get searchInWorkspace => 'Tìm trong không gian làm việc'; + + @override + String get searchCategory => 'Danh mục tìm kiếm'; + + @override + String get clearHistorySearchScope => 'Tìm trong lịch sử tài liệu'; + + @override + String get searchInDocument => 'Tìm trong tài liệu'; + + @override + String get searchDocumentHint => 'Tìm trong tài liệu này…'; + + @override + String get searchWorkspaceHint => 'Tìm trong không gian làm việc này…'; + + @override + String get searchOutlineHint => 'Tìm tiêu đề…'; + + @override + String get searchHistoryHint => 'Tìm trong lịch sử cục bộ…'; + + @override + String get searchEnterText => 'Nhập văn bản để tìm kiếm'; + + @override + String get searchAccountUnavailable => 'Tài khoản không khả dụng'; + + @override + String get searchDocumentsHint => 'Tìm tài liệu…'; + + @override + String get searchTopicsHint => 'Tìm chủ đề…'; + + @override + String get searchRecentHint => 'Tìm mục gần đây…'; + + @override + String get searchActionsHint => 'Tìm thao tác…'; + + @override + String get searchRequiresNotesWorkspace => + 'Yêu cầu không gian làm việc Ghi chú'; + + @override + String get searchSidebarLabel => 'Tìm kiếm:'; + + @override + String get searchDocumentTextCategory => 'Văn bản tài liệu'; + + @override + String get searchWorkspaceTextCategory => 'Văn bản không gian làm việc'; + + @override + String get searchHeadingsCategory => 'Tiêu đề'; + + @override + String get shortcutUnassigned => 'Chưa gán'; + + @override + String get shortcutPressKeys => 'Nhấn tổ hợp phím…'; + + @override + String get shortcutRecord => 'Ghi phím tắt'; + + @override + String get shortcutRestoreAll => 'Khôi phục tất cả mặc định'; + + @override + String get shortcutRestoreAllQuestion => + 'Khôi phục phím tắt mặc định của mọi thao tác? Các gán tùy chỉnh sẽ bị xóa.'; + + @override + String get shortcutSaveFailed => + 'Phím tắt đang có hiệu lực nhưng chưa lưu được. Thử lưu lại để giữ chúng sau khi khởi động lại.'; + + @override + String get shortcutRetry => 'Thử lưu lại'; + + @override + String get shortcutChanged => + 'Các gán đã thay đổi. Xem lại phím tắt hiện tại và thử lại.'; + + @override + String get shortcutInvalid => 'Không thể gán tổ hợp này trong ngữ cảnh này.'; + + @override + String get shortcutSystemWarning => + 'Môi trường màn hình có thể dùng tổ hợp này. BusyMark không thể ghi đè phím tắt hệ thống.'; + + @override + String get shortcutConflicts => 'Phím tắt này xung đột với các thao tác sau:'; + + @override + String get shortcutRemove => 'Xóa gán'; + + @override + String get shortcutRestoreDefault => 'Khôi phục mặc định'; + + @override + String get shortcutAssign => 'Gán'; + + @override + String get shortcutReassign => 'Gán lại'; + + @override + String get shortcutContextApplication => 'Ứng dụng'; + + @override + String get shortcutContextDocument => 'Chế độ xem tài liệu'; + + @override + String get shortcutContextEditor => 'Trình sửa và mã nguồn'; + + @override + String get shortcutContextWritersideSource => 'Mã nguồn Writerside'; + + @override + String get shortcutContextWorkspace => 'Điều hướng không gian làm việc'; + + @override + String get shortcutContextTree => 'Cây thanh bên'; + + @override + String get searchHeadingsCommand => 'Tìm tiêu đề'; + + @override + String get shortcutFixed => 'Do trình soạn thảo quản lý; không thể cấu hình.'; + + @override + String get shortcutDefaultConflict => + 'Phím tắt mặc định xung đột với gán tùy chỉnh.'; + + @override + String get shortcutCurrentBinding => 'Phím tắt hiện tại'; + + @override + String get shortcutProposedBinding => 'Phím tắt đề xuất'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 501b563c..411f2047 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -555,15 +555,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get keyboardShortcuts => '键盘快捷键'; - @override - String get commandPalette => '命令面板'; - - @override - String get commandPaletteHint => '输入命令'; - - @override - String get commandPaletteEmpty => '没有匹配的命令'; - @override String get commandUnavailableInContext => '当前编辑器上下文中无法使用此命令'; @@ -776,7 +767,7 @@ class AppLocalizationsZh extends AppLocalizations { String get shortcutSearchDescription => '搜索当前工作区'; @override - String get shortcutKeyboardShortcutsDescription => '显示此键盘快捷键参考'; + String get shortcutKeyboardShortcutsDescription => '自定义应用程序键盘快捷键'; @override String get shortcutSyntaxReferenceDescription => '打开语法参考'; @@ -1001,7 +992,7 @@ class AppLocalizationsZh extends AppLocalizations { String get validateOnEdit => '编辑时验证'; @override - String get clearRecentWorkspaces => '清除最近使用的工作区'; + String get clearRecentWorkspaces => '清除最近记录'; @override String get editingButtonsPosition => '编辑按钮位置'; @@ -1758,15 +1749,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sourceSearchNextMatch => '下一个匹配项'; - @override - String get sourceSearchCaseSensitive => '区分大小写'; - - @override - String get sourceSearchWholeWord => '全字匹配'; - - @override - String get sourceSearchRegex => '正则表达式'; - @override String get sourceSearchReplacement => '替换为'; @@ -1885,13 +1867,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get saveAs => '另存为'; - @override - String get sourceSearchInvalidRegex => '无效的正则表达式'; - - @override - String get sourceSearchZeroLengthUnsupported => - '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; - @override String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; @@ -4160,9 +4135,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clipboardHistory => '剪贴板历史'; - @override - String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; - @override String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; @@ -4183,9 +4155,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clipboardNoItems => '剪贴板历史为空。'; - @override - String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; - @override String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; @@ -4236,12 +4205,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get localHistoryNoRevisions => '此文档没有本地历史修订。'; - @override - String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; - - @override - String get localHistorySearchHint => '搜索本地历史记录'; - @override String get localHistorySelectedRevision => '已选择的修订'; @@ -5165,6 +5128,174 @@ class AppLocalizationsZh extends AppLocalizations { @override String get wsPropertiesUnavailable => '所检查的元素不可用。请检查一个元素以继续。'; + + @override + String get searchDocumentsCategory => '文档'; + + @override + String get searchTopicsCategory => '主题'; + + @override + String get searchCurrentDocument => '当前文档'; + + @override + String get searchCurrentWorkspace => '当前工作区'; + + @override + String get browseNotes => '浏览笔记'; + + @override + String get searchActionsCommand => '搜索操作'; + + @override + String get searchDocumentsCommand => '搜索文档'; + + @override + String get searchTopicsCommand => '搜索主题'; + + @override + String get searchRecentCommand => '搜索最近项目'; + + @override + String get searchResourceUnavailable => '此资源不可用。'; + + @override + String get notesSync => '同步笔记'; + + @override + String get searchInWorkspace => '在工作区中搜索'; + + @override + String get searchCategory => '搜索类别'; + + @override + String get clearHistorySearchScope => '搜索文档历史记录'; + + @override + String get searchInDocument => '在文档中搜索'; + + @override + String get searchDocumentHint => '搜索此文档…'; + + @override + String get searchWorkspaceHint => '搜索此工作区…'; + + @override + String get searchOutlineHint => '搜索标题…'; + + @override + String get searchHistoryHint => '搜索本地历史记录…'; + + @override + String get searchEnterText => '输入要搜索的文本'; + + @override + String get searchAccountUnavailable => '账户不可用'; + + @override + String get searchDocumentsHint => '搜索文档…'; + + @override + String get searchTopicsHint => '搜索主题…'; + + @override + String get searchRecentHint => '搜索最近项目…'; + + @override + String get searchActionsHint => '搜索操作…'; + + @override + String get searchRequiresNotesWorkspace => '需要笔记工作区'; + + @override + String get searchSidebarLabel => '搜索:'; + + @override + String get searchDocumentTextCategory => '文档文本'; + + @override + String get searchWorkspaceTextCategory => '工作区文本'; + + @override + String get searchHeadingsCategory => '标题'; + + @override + String get shortcutUnassigned => '未分配'; + + @override + String get shortcutPressKeys => '请按下组合键…'; + + @override + String get shortcutRecord => '录制快捷键'; + + @override + String get shortcutRestoreAll => '恢复所有默认设置'; + + @override + String get shortcutRestoreAllQuestion => '恢复所有操作的默认快捷键?自定义分配将被移除。'; + + @override + String get shortcutSaveFailed => '快捷键已生效,但无法保存。请重试保存以便重启后保留。'; + + @override + String get shortcutRetry => '重试保存'; + + @override + String get shortcutChanged => '分配已更改。请检查当前快捷键并重试。'; + + @override + String get shortcutInvalid => '无法在此上下文中分配此组合键。'; + + @override + String get shortcutSystemWarning => '桌面可能正在使用此组合键。BusyMark 无法覆盖系统快捷键。'; + + @override + String get shortcutConflicts => '此快捷键与以下操作冲突:'; + + @override + String get shortcutRemove => '移除分配'; + + @override + String get shortcutRestoreDefault => '恢复默认设置'; + + @override + String get shortcutAssign => '分配'; + + @override + String get shortcutReassign => '重新分配'; + + @override + String get shortcutContextApplication => '应用程序'; + + @override + String get shortcutContextDocument => '文档视图'; + + @override + String get shortcutContextEditor => '编辑器和源码'; + + @override + String get shortcutContextWritersideSource => 'Writerside 源码'; + + @override + String get shortcutContextWorkspace => '工作区导航'; + + @override + String get shortcutContextTree => '侧栏树'; + + @override + String get searchHeadingsCommand => '搜索标题'; + + @override + String get shortcutFixed => '由编辑器管理,无法配置。'; + + @override + String get shortcutDefaultConflict => '默认快捷键与自定义分配冲突。'; + + @override + String get shortcutCurrentBinding => '当前快捷键'; + + @override + String get shortcutProposedBinding => '拟用快捷键'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -5716,15 +5847,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get keyboardShortcuts => '键盘快捷键'; - @override - String get commandPalette => '命令面板'; - - @override - String get commandPaletteHint => '输入命令'; - - @override - String get commandPaletteEmpty => '没有匹配的命令'; - @override String get commandUnavailableInContext => '当前编辑器上下文中无法使用此命令'; @@ -5937,7 +6059,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get shortcutSearchDescription => '搜索当前工作区'; @override - String get shortcutKeyboardShortcutsDescription => '显示此键盘快捷键参考'; + String get shortcutKeyboardShortcutsDescription => '自定义应用程序键盘快捷键'; @override String get shortcutSyntaxReferenceDescription => '打开语法参考'; @@ -6162,7 +6284,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get validateOnEdit => '编辑时验证'; @override - String get clearRecentWorkspaces => '清除最近使用的工作区'; + String get clearRecentWorkspaces => '清除最近记录'; @override String get editingButtonsPosition => '编辑按钮位置'; @@ -6919,15 +7041,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get sourceSearchNextMatch => '下一个匹配项'; - @override - String get sourceSearchCaseSensitive => '区分大小写'; - - @override - String get sourceSearchWholeWord => '全字匹配'; - - @override - String get sourceSearchRegex => '正则表达式'; - @override String get sourceSearchReplacement => '替换为'; @@ -7046,13 +7159,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get saveAs => '另存为'; - @override - String get sourceSearchInvalidRegex => '无效的正则表达式'; - - @override - String get sourceSearchZeroLengthUnsupported => - '不支持零长度的正则表达式匹配(例如 ^、\$ 或仅匹配位置的前瞻断言)。'; - @override String get workspaceSearchIncomplete => '仅显示了部分结果。请显示更多结果或缩小搜索范围。'; @@ -9321,9 +9427,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get clipboardHistory => '剪贴板历史'; - @override - String get clipboardHistorySearchHint => '搜索剪贴板历史记录'; - @override String get clipboardHistoryDescription => '打开会话本地剪贴板历史。'; @@ -9344,9 +9447,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get clipboardNoItems => '剪贴板历史为空。'; - @override - String get clipboardNoMatches => '没有匹配此搜索的剪贴板项目。'; - @override String get clipboardDisabled => '剪贴板历史集合在设置中已禁用。'; @@ -9397,12 +9497,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get localHistoryNoRevisions => '此文档没有本地历史修订。'; - @override - String get localHistoryNoMatches => '没有本地历史修订与此搜索匹配。'; - - @override - String get localHistorySearchHint => '搜索本地历史记录'; - @override String get localHistorySelectedRevision => '已选择的修订'; @@ -10326,4 +10420,172 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get wsPropertiesUnavailable => '所检查的元素不可用。请检查一个元素以继续。'; + + @override + String get searchDocumentsCategory => '文档'; + + @override + String get searchTopicsCategory => '主题'; + + @override + String get searchCurrentDocument => '当前文档'; + + @override + String get searchCurrentWorkspace => '当前工作区'; + + @override + String get browseNotes => '浏览笔记'; + + @override + String get searchActionsCommand => '搜索操作'; + + @override + String get searchDocumentsCommand => '搜索文档'; + + @override + String get searchTopicsCommand => '搜索主题'; + + @override + String get searchRecentCommand => '搜索最近项目'; + + @override + String get searchResourceUnavailable => '此资源不可用。'; + + @override + String get notesSync => '同步笔记'; + + @override + String get searchInWorkspace => '在工作区中搜索'; + + @override + String get searchCategory => '搜索类别'; + + @override + String get clearHistorySearchScope => '搜索文档历史记录'; + + @override + String get searchInDocument => '在文档中搜索'; + + @override + String get searchDocumentHint => '搜索此文档…'; + + @override + String get searchWorkspaceHint => '搜索此工作区…'; + + @override + String get searchOutlineHint => '搜索标题…'; + + @override + String get searchHistoryHint => '搜索本地历史记录…'; + + @override + String get searchEnterText => '输入要搜索的文本'; + + @override + String get searchAccountUnavailable => '账户不可用'; + + @override + String get searchDocumentsHint => '搜索文档…'; + + @override + String get searchTopicsHint => '搜索主题…'; + + @override + String get searchRecentHint => '搜索最近项目…'; + + @override + String get searchActionsHint => '搜索操作…'; + + @override + String get searchRequiresNotesWorkspace => '需要笔记工作区'; + + @override + String get searchSidebarLabel => '搜索:'; + + @override + String get searchDocumentTextCategory => '文档文本'; + + @override + String get searchWorkspaceTextCategory => '工作区文本'; + + @override + String get searchHeadingsCategory => '标题'; + + @override + String get shortcutUnassigned => '未分配'; + + @override + String get shortcutPressKeys => '请按下组合键…'; + + @override + String get shortcutRecord => '录制快捷键'; + + @override + String get shortcutRestoreAll => '恢复所有默认设置'; + + @override + String get shortcutRestoreAllQuestion => '恢复所有操作的默认快捷键?自定义分配将被移除。'; + + @override + String get shortcutSaveFailed => '快捷键已生效,但无法保存。请重试保存以便重启后保留。'; + + @override + String get shortcutRetry => '重试保存'; + + @override + String get shortcutChanged => '分配已更改。请检查当前快捷键并重试。'; + + @override + String get shortcutInvalid => '无法在此上下文中分配此组合键。'; + + @override + String get shortcutSystemWarning => '桌面可能正在使用此组合键。BusyMark 无法覆盖系统快捷键。'; + + @override + String get shortcutConflicts => '此快捷键与以下操作冲突:'; + + @override + String get shortcutRemove => '移除分配'; + + @override + String get shortcutRestoreDefault => '恢复默认设置'; + + @override + String get shortcutAssign => '分配'; + + @override + String get shortcutReassign => '重新分配'; + + @override + String get shortcutContextApplication => '应用程序'; + + @override + String get shortcutContextDocument => '文档视图'; + + @override + String get shortcutContextEditor => '编辑器和源码'; + + @override + String get shortcutContextWritersideSource => 'Writerside 源码'; + + @override + String get shortcutContextWorkspace => '工作区导航'; + + @override + String get shortcutContextTree => '侧栏树'; + + @override + String get searchHeadingsCommand => '搜索标题'; + + @override + String get shortcutFixed => '由编辑器管理,无法配置。'; + + @override + String get shortcutDefaultConflict => '默认快捷键与自定义分配冲突。'; + + @override + String get shortcutCurrentBinding => '当前快捷键'; + + @override + String get shortcutProposedBinding => '拟用快捷键'; } diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index 67f7b336..afbe9859 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../workspace/presentation/settings_screen.dart'; import '../workspace/presentation/welcome_screen.dart'; import '../workspace/presentation/workspace_screen.dart'; +import '../workspace/workspace_controller.dart'; const settingsRoutePath = '/settings'; const _settingsReturnTargetParameter = 'returnTo'; @@ -45,6 +46,33 @@ String settingsLocationForUri(Uri currentUri) { return settingsLocation(settingsReturnTargetForUri(currentUri)); } +void openKeyboardShortcutsSettings(BuildContext context) { + final router = GoRouter.of(context); + final uri = router.routeInformationProvider.value.uri; + if (uri.path == '/workspace') { + final container = ProviderScope.containerOf(context, listen: false); + if (!container + .read(workspaceControllerProvider.notifier) + .propertyEdits + .flush()) { + return; + } + } + final location = Uri.parse(settingsLocationForUri(uri)) + .replace( + queryParameters: { + ...Uri.parse(settingsLocationForUri(uri)).queryParameters, + 'page': 'keyboardShortcuts', + }, + ) + .toString(); + if (uri.path == settingsRoutePath) { + router.replace(location); + } else { + router.go(location); + } +} + final rootNavigatorKey = GlobalKey( debugLabel: 'BusyMark root navigator', ); diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 14f3c282..b1c5ef33 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show mapEquals; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -10,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import '../export/export_options.dart'; import '../spellcheck/spelling_language.dart'; import 'app_locale.dart'; +import 'shortcut_binding.dart'; enum BusyMarkThemeModePreference { system, light, dark } @@ -46,16 +48,28 @@ extension BusyMarkThemeModePreferenceX on BusyMarkThemeModePreference { } } -class RecentWorkspace { - const RecentWorkspace({ +class RecentResource { + const RecentResource({ required this.path, required this.kind, required this.lastOpenedAt, + this.accountId, + this.accountLabel, + this.localId, + this.ownerRoot, + this.title, + this.document = false, }); - factory RecentWorkspace.fromJson(Map json) { - return RecentWorkspace( + factory RecentResource.fromJson(Map json) { + return RecentResource( path: json['path']?.toString() ?? '', + accountId: json['accountId'] as String?, + accountLabel: json['accountLabel'] as String?, + localId: json['localId'] as String?, + ownerRoot: json['ownerRoot'] as String?, + title: json['title'] as String?, + document: json['document'] as bool? ?? json['kind'] == 'singleMarkdown', kind: json['kind']?.toString() ?? 'unknown', lastOpenedAt: DateTime.tryParse(json['lastOpenedAt']?.toString() ?? '') ?? @@ -66,14 +80,35 @@ class RecentWorkspace { final String path; final String kind; final DateTime lastOpenedAt; + final String? accountId, accountLabel, localId, ownerRoot, title; + final bool document; + bool get isDocument => document || kind == 'singleMarkdown'; + String get identity => accountId == null + ? p.normalize(p.absolute(path)) + : '$accountId:${localId ?? 'workspace'}'; Map toJson() => { 'path': path, 'kind': kind, + if (accountId != null) 'accountId': accountId, + if (accountLabel != null) 'accountLabel': accountLabel, + if (localId != null) 'localId': localId, + if (ownerRoot != null) 'ownerRoot': ownerRoot, + if (title != null) 'title': title, + 'document': isDocument, 'lastOpenedAt': lastOpenedAt.toIso8601String(), }; } +// Keep the existing JSON representation and key; resource type never affects +// retention. Stable identities are deduplicated after timestamp ordering. +List _normalizeRecent(Iterable resources) { + final ordered = resources.toList() + ..sort((a, b) => b.lastOpenedAt.compareTo(a.lastOpenedAt)); + final seen = {}; + return ordered.where((r) => seen.add(r.identity)).take(12).toList(); +} + class AppSettings { const AppSettings({ required this.themeModePreference, @@ -115,6 +150,7 @@ class AppSettings { required this.confirmCloseWithUnsavedChanges, required this.recentWorkspaces, this.lastOpenedPath, + this.shortcutOverrides = const {}, }); factory AppSettings.defaults() { @@ -279,14 +315,18 @@ class AppSettings { confirmCloseWithUnsavedChanges: json['confirmCloseWithUnsavedChanges'] as bool? ?? defaults.confirmCloseWithUnsavedChanges, + shortcutOverrides: readShortcutOverrides(json['shortcutOverrides']), lastOpenedPath: json['lastOpenedPath']?.toString(), recentWorkspaces: recent is List - ? recent - .whereType() - .map((item) => item.cast()) - .map(RecentWorkspace.fromJson) - .where((item) => item.path.isNotEmpty) - .toList() + ? _normalizeRecent( + recent + .whereType() + .map((item) => item.cast()) + .map(RecentResource.fromJson) + .where( + (item) => item.path.isNotEmpty || item.accountId != null, + ), + ) : defaults.recentWorkspaces, ); } @@ -329,13 +369,17 @@ class AppSettings { final bool reopenPreviousWorkspaceOnStartup; final bool confirmCloseWithUnsavedChanges; final String? lastOpenedPath; - final List recentWorkspaces; + final Map shortcutOverrides; + final List recentWorkspaces; ThemeMode get themeMode => themeModePreference.themeMode; Locale? get locale => busyMarkLocaleFromTag(localeTag); Map toJson() => { + 'shortcutOverrides': { + for (final e in shortcutOverrides.entries) e.key: e.value?.toJson(), + }, 'themeModePreference': themeModePreference.name, 'localeTag': localeTag, 'sidebarVisible': sidebarVisible, @@ -420,6 +464,7 @@ class AppSettings { } AppSettings copyWith({ + Map? shortcutOverrides, BusyMarkThemeModePreference? themeModePreference, Object? localeTag = _unset, bool? sidebarVisible, @@ -458,9 +503,10 @@ class AppSettings { bool? reopenPreviousWorkspaceOnStartup, bool? confirmCloseWithUnsavedChanges, String? lastOpenedPath, - List? recentWorkspaces, + List? recentWorkspaces, }) { return AppSettings( + shortcutOverrides: shortcutOverrides ?? this.shortcutOverrides, themeModePreference: themeModePreference ?? this.themeModePreference, localeTag: identical(localeTag, _unset) ? this.localeTag @@ -523,7 +569,9 @@ class AppSettings { confirmCloseWithUnsavedChanges: confirmCloseWithUnsavedChanges ?? this.confirmCloseWithUnsavedChanges, lastOpenedPath: lastOpenedPath ?? this.lastOpenedPath, - recentWorkspaces: recentWorkspaces ?? this.recentWorkspaces, + recentWorkspaces: recentWorkspaces == null + ? this.recentWorkspaces + : _normalizeRecent(recentWorkspaces), ); } } @@ -626,6 +674,20 @@ class AppSettingsController extends Notifier { Future waitUntilLoaded() => _loadFuture; + /// Invoked after validation against the current in-memory assignments. All + /// preferences still use the same ordered writer and settings file. + bool _trackShortcutSaves = false; + Future saveShortcutOverrides( + Map overrides, + ) { + _trackShortcutSaves = true; + return _save( + state.copyWith(shortcutOverrides: Map.unmodifiable(overrides)), + ); + } + + Future retryShortcutSave() => _save(state); + Future setPdfExportOptions(PdfExportOptions options) { options.validateOrThrow(); return _mutate((settings) => settings.copyWith(pdfExportOptions: options)); @@ -941,32 +1003,127 @@ class AppSettingsController extends Notifier { Future recordOpenedWorkspace({ required String path, required String kind, + String? accountId, + String? title, }) { - final openedAt = DateTime.now(); - return _mutate((settings) { - final recent = [ - RecentWorkspace(path: path, kind: kind, lastOpenedAt: openedAt), - ...settings.recentWorkspaces.where((item) => item.path != path), - ].take(12).toList(); - return settings.copyWith(lastOpenedPath: path, recentWorkspaces: recent); - }); + return recordRecent( + RecentResource( + path: accountId == null ? p.normalize(p.absolute(path)) : '', + kind: kind, + accountId: accountId, + title: title, + document: kind == 'singleMarkdown', + lastOpenedAt: DateTime.now(), + ), + updateLastOpenedPath: accountId == null, + ); } - Future clearRecentWorkspaces() { - return _mutate((settings) => settings.copyWith(recentWorkspaces: [])); - } + Future recordRecent( + RecentResource resource, { + bool updateLastOpenedPath = false, + }) => _mutate((settings) { + final ordered = [ + resource, + ...settings.recentWorkspaces.where( + (r) => r.identity != resource.identity, + ), + ]; + final retained = _normalizeRecent(ordered); + return settings.copyWith( + recentWorkspaces: retained, + lastOpenedPath: updateLastOpenedPath + ? resource.path + : settings.lastOpenedPath, + ); + }); - Future removeRecentWorkspace(String path) { + Future clearRecentWorkspaces() => + _mutate((settings) => settings.copyWith(recentWorkspaces: [])); + + Future removeRecentWorkspace(String path) => _mutate( + (settings) => settings.copyWith( + recentWorkspaces: settings.recentWorkspaces + .where( + (r) => + r.identity != path && + !(r.accountId == null && p.equals(r.path, path)), + ) + .toList(), + ), + ); + + Future updateRecentNoteTitles(Map<(String, String), String> titles) { + if (!state.recentWorkspaces.any( + (r) => + r.accountId != null && + r.localId != null && + titles[(r.accountId!, r.localId!)] != null && + titles[(r.accountId!, r.localId!)] != r.title, + )) { + return Future.value(); + } return _mutate( (settings) => settings.copyWith( recentWorkspaces: [ - for (final item in settings.recentWorkspaces) - if (!p.equals(item.path, path)) item, + for (final r in settings.recentWorkspaces) + if (r.accountId != null && + r.localId != null && + titles[(r.accountId!, r.localId!)] != null) + RecentResource( + path: r.path, + kind: r.kind, + lastOpenedAt: r.lastOpenedAt, + document: r.isDocument, + accountId: r.accountId, + accountLabel: r.accountLabel, + localId: r.localId, + title: titles[(r.accountId!, r.localId!)], + ownerRoot: r.ownerRoot, + ) + else + r, ], ), ); } + Future removeRecentAccount(String accountId) => _mutate( + (settings) => settings.copyWith( + recentWorkspaces: settings.recentWorkspaces + .where((r) => r.accountId != accountId) + .toList(), + ), + ); + + Future moveRecentPath(String from, String to) => _mutate( + (settings) => settings.copyWith( + recentWorkspaces: [ + for (final r in settings.recentWorkspaces) + if (r.accountId == null && + (p.equals(r.path, from) || p.isWithin(from, r.path))) + RecentResource( + path: p.equals(r.path, from) + ? to + : p.join(to, p.relative(r.path, from: from)), + kind: r.kind, + lastOpenedAt: r.lastOpenedAt, + document: r.isDocument, + ownerRoot: r.ownerRoot == null + ? null + : p.equals(r.ownerRoot!, from) + ? to + : p.isWithin(from, r.ownerRoot!) + ? p.join(to, p.relative(r.ownerRoot!, from: from)) + : r.ownerRoot, + title: r.title, + ) + else + r, + ], + ), + ); + Future _load() async { AppSettings loaded; try { @@ -974,6 +1131,7 @@ class AppSettingsController extends Notifier { } on Object { loaded = AppSettings.defaults(); } + if (!ref.mounted) return; // Visibility belongs to this run; retain the preferred inspector width. // Explicit requests made during hydration are applied below. loaded = loaded.copyWith(elementPropertiesVisible: false); @@ -998,7 +1156,7 @@ class AppSettingsController extends Notifier { return _save(mutation(state)); } - Future _save(AppSettings next) async { + Future _save(AppSettings next) async { state = next; final json = next.toJson(); final previousSave = _lastSave; @@ -1006,15 +1164,34 @@ class AppSettingsController extends Notifier { await previousSave; try { await _store.save(json); + return true; } on Object { - // Keep the in-memory preference even when local persistence is unavailable. + // Runtime preferences stay visible; keymap callers observe this outcome. + return false; } }(); _lastSave = save; - await save; + final succeeded = await save; + if (ref.mounted && + _trackShortcutSaves && + mapEquals(state.shortcutOverrides, next.shortcutOverrides)) { + ref.read(shortcutPersistenceOutcomeProvider.notifier).complete(succeeded); + } + return succeeded; } } +class ShortcutPersistenceOutcome extends Notifier { + @override + bool? build() => null; + void complete(bool saved) => state = saved; +} + +final shortcutPersistenceOutcomeProvider = + NotifierProvider( + ShortcutPersistenceOutcome.new, + ); + final localSettingsStoreProvider = Provider( (ref) => const JsonFileLocalSettingsStore(), ); diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index d4e8c5b0..1d85ae95 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -1,3 +1,8 @@ +import '../workspace/recent_presentation.dart'; +import '../search/welcome_notes_action.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/presentation/notes_workspace_ui.dart'; import '../references/reference_controller.dart'; import 'dart:async'; @@ -27,12 +32,18 @@ import '../workspace/workspace_tab_actions.dart'; import 'app_router.dart'; import 'app_locale.dart'; import 'app_settings.dart'; -import 'command_palette.dart'; -import 'quick_open.dart'; +import '../search/search_session.dart'; +import '../search/search_activation.dart'; +import '../workspace/recent_navigation.dart'; import 'command_registry.dart'; +import 'command_shortcuts.dart'; +import 'keymap_controller.dart'; +import 'path_label.dart'; +import 'shortcut_capture.dart'; import 'app_theme.dart'; import 'busymark_dialogs.dart'; import 'busymark_design.dart'; +import 'busymark_search_field.dart'; import 'busymark_glyphs.dart'; import 'busymark_toast.dart'; import 'localization.dart'; @@ -40,75 +51,145 @@ import 'system_accent.dart'; import 'window_control_service.dart'; import 'linux/linux_window_host.dart'; +final _busyMarkDefaultCommandRegistryProvider = + Provider((ref) { + final commandIntents = { + for (final id in BusyMarkCommandCatalog.notesWorkspaceCommandIds) + id: _NotesWorkspaceActionIntent(id), + BusyMarkCommandIds.newNote: const _NotesActionIntent( + WelcomeNotesAction.create, + ), + BusyMarkCommandIds.browseNotes: const _NotesActionIntent( + WelcomeNotesAction.browse, + ), + BusyMarkCommandIds.newDocument: const _NewWorkspaceIntent(), + BusyMarkCommandIds.open: const _OpenWorkspaceIntent(), + BusyMarkCommandIds.save: const _SaveActiveIntent(), + BusyMarkCommandIds.export: const _ExportIntent(), + BusyMarkCommandIds.fullScreen: const _ToggleFullScreenIntent(), + BusyMarkCommandIds.back: const _BackIntent(), + BusyMarkCommandIds.keyboardShortcuts: const _KeyboardShortcutsIntent(), + BusyMarkCommandIds.syntaxReference: const _SyntaxReferenceIntent(), + BusyMarkCommandIds.settings: const _SettingsIntent(), + BusyMarkCommandIds.nextTab: const _NextTabIntent(), + BusyMarkCommandIds.previousTab: const _PreviousTabIntent(), + BusyMarkCommandIds.closeTab: const _CloseTabIntent(), + BusyMarkCommandIds.closeAllTabs: const _CloseAllTabsIntent(), + BusyMarkCommandIds.toggleSidebar: const _ToggleSidebarIntent(), + BusyMarkCommandIds.clipboardHistory: + const _OpenClipboardHistoryIntent(), + BusyMarkCommandIds.localHistory: const _OpenLocalHistoryIntent(), + BusyMarkCommandIds.viewEditor: const _DocumentViewModeIntent( + DocumentViewModePreference.editor, + ), + BusyMarkCommandIds.viewSource: const _DocumentViewModeIntent( + DocumentViewModePreference.source, + ), + BusyMarkCommandIds.viewReading: const _DocumentViewModeIntent( + DocumentViewModePreference.preview, + ), + BusyMarkCommandIds.viewSplit: const _DocumentViewModeIntent( + DocumentViewModePreference.split, + ), + }; + final defaults = BusyMarkCommandCatalog.create( + executions: { + for (final id in [ + BusyMarkCommandIds.sidebarFiles, + BusyMarkCommandIds.sidebarToc, + BusyMarkCommandIds.sidebarOutline, + BusyMarkCommandIds.sidebarGit, + ]) + id: () => ref + .read(workspaceNavigationCommandProvider.notifier) + .request(id), + for (final entry in searchCommandDestinations.entries) + entry.key: () { + final search = ref.read(searchSessionProvider.notifier); + search.openCategory(entry.value); + }, + BusyMarkCommandIds.elementProperties: () => ref + .read(workspaceControllerProvider.notifier) + .toggleElementProperties + ?.call(), + BusyMarkCommandIds.references: () => + ref.read(referencePanelRequestProvider.notifier).request(), + for (final entry in commandIntents.entries) + entry.key: () { + final target = rootNavigatorKey.currentContext; + if (target != null) { + Actions.maybeInvoke(target, entry.value); + } + }, + }, + enabled: { + for (final id in BusyMarkCommandCatalog.notesWorkspaceCommandIds) + id: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace?.isRemote == + true, + BusyMarkCommandIds.sidebarFiles: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.sidebarToc: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref + .read(workspaceControllerProvider) + .workspace + ?.writersideProject != + null, + BusyMarkCommandIds.sidebarOutline: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).activeBuffer != null, + BusyMarkCommandIds.sidebarGit: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(gitControllerProvider).repositoryInfo != null, + for (final id in [ + BusyMarkCommandIds.elementProperties, + BusyMarkCommandIds.references, + BusyMarkCommandIds.viewEditor, + BusyMarkCommandIds.viewSource, + BusyMarkCommandIds.viewReading, + BusyMarkCommandIds.viewSplit, + BusyMarkCommandIds.nextTab, + BusyMarkCommandIds.previousTab, + BusyMarkCommandIds.closeTab, + BusyMarkCommandIds.closeAllTabs, + ]) + id: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + for (final entry in searchCommandDestinations.entries) + entry.key: () => ref + .read(searchSessionProvider.notifier) + .categories + .contains(entry.value), + BusyMarkCommandIds.save: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.export: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + canExportWorkspace(ref.read(workspaceControllerProvider)), + BusyMarkCommandIds.clipboardHistory: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.localHistory: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + BusyMarkCommandIds.findLocalHistory: () => + ref.read(searchSessionProvider.notifier).workspaceVisible && + ref.read(workspaceControllerProvider).workspace != null, + }, + ); + return defaults; + }); + final busyMarkCommandRegistryProvider = Provider(( ref, ) { - final commandIntents = { - BusyMarkCommandIds.newDocument: const _NewWorkspaceIntent(), - BusyMarkCommandIds.open: const _OpenWorkspaceIntent(), - BusyMarkCommandIds.save: const _SaveActiveIntent(), - BusyMarkCommandIds.export: const _ExportIntent(), - BusyMarkCommandIds.fullScreen: const _ToggleFullScreenIntent(), - BusyMarkCommandIds.back: const _BackIntent(), - BusyMarkCommandIds.search: const _OpenSearchIntent(), - BusyMarkCommandIds.keyboardShortcuts: const _KeyboardShortcutsIntent(), - BusyMarkCommandIds.commandPalette: const _CommandPaletteIntent(), - BusyMarkCommandIds.quickOpen: const _QuickOpenIntent(), - BusyMarkCommandIds.syntaxReference: const _SyntaxReferenceIntent(), - BusyMarkCommandIds.settings: const _SettingsIntent(), - BusyMarkCommandIds.nextTab: const _NextTabIntent(), - BusyMarkCommandIds.previousTab: const _PreviousTabIntent(), - 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, - ), - BusyMarkCommandIds.viewSource: const _DocumentViewModeIntent( - DocumentViewModePreference.source, - ), - BusyMarkCommandIds.viewReading: const _DocumentViewModeIntent( - DocumentViewModePreference.preview, - ), - BusyMarkCommandIds.viewSplit: const _DocumentViewModeIntent( - DocumentViewModePreference.split, - ), - }; - return BusyMarkCommandCatalog.create( - executions: { - BusyMarkCommandIds.elementProperties: () => ref - .read(workspaceControllerProvider.notifier) - .toggleElementProperties - ?.call(), - BusyMarkCommandIds.references: () => - ref.read(referencePanelRequestProvider.notifier).request(), - for (final entry in commandIntents.entries) - entry.key: () { - final target = rootNavigatorKey.currentContext; - if (target != null) { - Actions.maybeInvoke(target, entry.value); - } - }, - }, - enabled: { - BusyMarkCommandIds.quickOpen: () => - ref.read(workspaceControllerProvider).workspace != null, - BusyMarkCommandIds.save: () => - ref.read(workspaceControllerProvider).workspace != null, - BusyMarkCommandIds.export: () => - 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, - }, + return effectiveCommandRegistry( + ref.watch(_busyMarkDefaultCommandRegistryProvider), + ref.watch(appSettingsControllerProvider.select((s) => s.shortcutOverrides)), ); }); @@ -168,7 +249,7 @@ class BusyMarkApp extends ConsumerWidget { ], supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) { - final commandRegistry = ref.read(busyMarkCommandRegistryProvider); + final commandRegistry = ref.watch(busyMarkCommandRegistryProvider); final headerBarDefaults = _nativeHeaderBarDefaults( context, settings, @@ -178,8 +259,9 @@ class BusyMarkApp extends ConsumerWidget { final appContent = HeaderBarConfigurationDefaults( configuration: headerBarDefaults, child: _BusyMarkWindowLifecycle( - child: Shortcuts( + child: BusyMarkCommandShortcuts( shortcuts: commandRegistry.shortcutIntents( + excludedIds: searchCommandDestinations.keys.toSet(), scopes: const { BusyMarkCommandScope.application, BusyMarkCommandScope.documentView, @@ -188,12 +270,74 @@ class BusyMarkApp extends ConsumerWidget { ), child: Actions( actions: { + _NotesWorkspaceActionIntent: + CallbackAction<_NotesWorkspaceActionIntent>( + onInvoke: (intent) { + final target = rootNavigatorKey.currentContext; + final workspace = ref + .read(workspaceControllerProvider) + .workspace; + if (target == null || + !ref + .read(searchSessionProvider.notifier) + .workspaceVisible || + workspace?.isRemote != true) { + return null; + } + switch (intent.commandId) { + case BusyMarkCommandIds.syncNotes: + unawaited( + ref + .read(workspaceControllerProvider.notifier) + .refreshNextcloudNotes(), + ); + case BusyMarkCommandIds.exportNotes: + final repository = ref + .read(nextcloudNotesRepositoryProvider) + .value; + if (repository == null) return null; + unawaited( + showNotesExport(target, ref, [ + for (final note in repository.notes) + if (note.accountId == + workspace!.nextcloudAccountId && + !isNotesRecovery(note)) + note.localId, + ]), + ); + case BusyMarkCommandIds.importNotesFolder: + unawaited(showNotesImport(target, ref)); + case BusyMarkCommandIds.importNotesFile: + unawaited( + showNotesImport(target, ref, folder: false), + ); + } + return null; + }, + ), BusyMarkCommandIntent: CallbackAction( onInvoke: (intent) { + if (searchCommandDestinations.containsKey( + intent.commandId, + ) && + (rootNavigatorKey.currentState?.canPop() ?? false)) { + return null; + } unawaited(commandRegistry.execute(intent.commandId)); return null; }, ), + _NotesActionIntent: CallbackAction<_NotesActionIntent>( + onInvoke: (intent) { + final target = rootNavigatorKey.currentContext; + if (target != null) { + unawaited( + performWelcomeNotesAction(target, ref, intent.action), + ); + } + return null; + }, + ), _NewWorkspaceIntent: CallbackAction<_NewWorkspaceIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -261,27 +405,11 @@ class BusyMarkApp extends ConsumerWidget { final navigatorContext = rootNavigatorKey.currentContext; if (navigatorContext != null) { - showBusyMarkKeyboardShortcutsDialog( - navigatorContext, - ); + openKeyboardShortcutsSettings(navigatorContext); } return null; }, ), - _CommandPaletteIntent: CallbackAction<_CommandPaletteIntent>( - onInvoke: (intent) { - final navigatorContext = rootNavigatorKey.currentContext; - if (navigatorContext != null) { - unawaited( - showBusyMarkCommandPalette( - navigatorContext, - commandRegistry, - ), - ); - } - return null; - }, - ), _SettingsIntent: CallbackAction<_SettingsIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -326,15 +454,6 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), - _FindLocalHistoryIntent: - CallbackAction<_FindLocalHistoryIntent>( - onInvoke: (intent) { - ref - .read(localHistoryFindRequestProvider.notifier) - .request(); - return null; - }, - ), _NextTabIntent: CallbackAction<_NextTabIntent>( onInvoke: (intent) { final navigatorContext = rootNavigatorKey.currentContext; @@ -385,25 +504,6 @@ class BusyMarkApp extends ConsumerWidget { return null; }, ), - _QuickOpenIntent: CallbackAction<_QuickOpenIntent>( - onInvoke: (_) { - final target = rootNavigatorKey.currentContext; - if (target != null) unawaited(showQuickOpen(target, ref)); - return null; - }, - ), - _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( - onInvoke: (intent) { - if (ref.read(workspaceControllerProvider).workspace != - null) { - final notifier = ref.read( - workspaceSearchOpenRequestProvider.notifier, - ); - notifier.request(); - } - return null; - }, - ), _ToggleSidebarIntent: CallbackAction<_ToggleSidebarIntent>( onInvoke: (intent) { _toggleSidebar( @@ -488,6 +588,20 @@ class BusyMarkApp extends ConsumerWidget { final recentWorkspaces = ref .read(appSettingsControllerProvider) .recentWorkspaces; + final container = ProviderScope.containerOf(context, listen: false); + final cachedAccounts = container.exists(nextcloudNotesRepositoryProvider) + ? ref.read(nextcloudNotesRepositoryProvider).value?.accounts + : null; + final connectedAccount = container.exists(nextcloudConnectionProvider) + ? ref.read(nextcloudConnectionProvider).account + : null; + final accountLabels = { + for (final account in cachedAccounts ?? []) + account.id: '${account.loginName} · ${account.server.host}', + if (connectedAccount != null) + connectedAccount.id: + '${connectedAccount.loginName} · ${connectedAccount.server.host}', + }; final choice = await showBusyMarkModalDialog<_OpenChooserChoice>( context, headerBarService: headerBar.isAvailable ? headerBar : null, @@ -529,9 +643,27 @@ class BusyMarkApp extends ConsumerWidget { BusyMarkActionRow( title: busyMarkLtrIsolateFor( dialogContext, - _displayPath(recent.path), + recentResourceTitle(dialogContext, recent), ), - subtitle: busyMarkLtrIsolateFor(dialogContext, recent.path), + tooltip: recent.accountId == null + ? recent.path + : accountLabels[recent.accountId], + subtitleWidget: recent.accountId == null + ? BusyMarkPathLabel( + path: recentResourceLocation( + dialogContext, + recent, + recentResources: recentWorkspaces, + ), + style: Theme.of(dialogContext).textTheme.bodySmall, + ) + : Text( + recentResourceLocation( + dialogContext, + recent, + accountLabel: accountLabels[recent.accountId], + ), + ), leading: const Icon(BusyMarkGlyphs.history), trailing: Icon( BusyMarkGlyphs.forwardFor( @@ -540,7 +672,7 @@ class BusyMarkApp extends ConsumerWidget { ), onTap: () => Navigator.pop( dialogContext, - _OpenRecentWorkspace(recent.path), + _OpenRecentResource(recent), ), ), ], @@ -551,10 +683,14 @@ class BusyMarkApp extends ConsumerWidget { if (!context.mounted || choice == null) { return; } + if (choice case _OpenRecentResource(:final resource)) { + await openRecentResource(context, ref, resource); + return; + } final path = switch (choice) { _OpenMarkdownFile() => await _chooseMarkdownFile(ref), _OpenWorkspaceFolder() => await _chooseWorkspaceFolder(ref), - _OpenRecentWorkspace(:final path) => path, + _OpenRecentResource() => null, }; if (path == null || path.isEmpty || !context.mounted) { return; @@ -802,11 +938,6 @@ class BusyMarkApp extends ConsumerWidget { return p.extension(lastPath).isEmpty ? lastPath : p.dirname(lastPath); } - String _displayPath(String path) { - final name = p.basename(path); - return name.isEmpty ? path : name; - } - void _toggleSidebar(WidgetRef ref, {required bool allowWithoutWorkspace}) { final workspace = ref.read(workspaceControllerProvider).workspace; if (workspace == null && !allowWithoutWorkspace) { @@ -846,8 +977,8 @@ class BusyMarkApp extends ConsumerWidget { final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr; BusyMarkCommand command(String id) => commandRegistry[id]!; String label(String id) => command(id).label(context); - String shortcut(String id) => command(id).shortcut!.label; - String accelerator(String id) => command(id).shortcut!.gtkAccelerator!; + String shortcut(String id) => command(id).shortcut?.label ?? ''; + String accelerator(String id) => command(id).shortcut?.gtkAccelerator ?? ''; final labels = HeaderBarLabels( editor: label(BusyMarkCommandIds.viewEditor), source: label(BusyMarkCommandIds.viewSource), @@ -863,7 +994,7 @@ class BusyMarkApp extends ConsumerWidget { splitShortcut: shortcut(BusyMarkCommandIds.viewSplit), splitGtkAccelerator: accelerator(BusyMarkCommandIds.viewSplit), search: material.searchFieldLabel, - searchShortcut: shortcut(BusyMarkCommandIds.search), + searchShortcut: '', refresh: l10n.validate, menu: l10n.mainMenu, sidebar: settings.sidebarVisible ? l10n.hideSidebar : l10n.showSidebar, @@ -880,11 +1011,6 @@ class BusyMarkApp extends ConsumerWidget { settings: label(BusyMarkCommandIds.settings), settingsShortcut: shortcut(BusyMarkCommandIds.settings), settingsGtkAccelerator: accelerator(BusyMarkCommandIds.settings), - keyboardShortcuts: label(BusyMarkCommandIds.keyboardShortcuts), - keyboardShortcutsShortcut: shortcut(BusyMarkCommandIds.keyboardShortcuts), - keyboardShortcutsGtkAccelerator: accelerator( - BusyMarkCommandIds.keyboardShortcuts, - ), syntaxReference: label(BusyMarkCommandIds.syntaxReference), syntaxReferenceShortcut: shortcut(BusyMarkCommandIds.syntaxReference), syntaxReferenceGtkAccelerator: accelerator( @@ -1106,41 +1232,85 @@ class _BusyMarkSearchShortcutHandlerState } bool _handleKeyEvent(KeyEvent event) { + // Capture owns the event in FocusManager's early handler, including the + // release that ends suppression. Yield here so native key delivery can + // reach that handler instead of treating it as an application command. + if (BusyMarkShortcutCapture.suppressing) return false; if (event is! KeyDownEvent) { return false; } - final workspaceOpen = - ref.read(workspaceControllerProvider).workspace != null; - if (!workspaceOpen) { - return false; - } - final keyboard = HardwareKeyboard.instance; - final commands = - BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata; - if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { - if (rootNavigatorKey.currentState?.canPop() ?? false) { + if (rootNavigatorKey.currentState?.canPop() ?? false) return false; + final focused = FocusManager.instance.primaryFocus?.context; + if (focused != null) { + final editable = focused.findAncestorStateOfType(); + if (editable?.widget.controller.value.composing.isValid == true) { return false; } - ref.read(workspaceSearchOpenRequestProvider.notifier).request(); + } + if (event.logicalKey == LogicalKeyboardKey.escape && + ref.read(searchSessionProvider).active) { + ref.read(searchSessionProvider.notifier).close(); return true; } + final keyboard = HardwareKeyboard.instance; + // Search delivery precedes Flutter's focus hierarchy. Let the query's + // DefaultTextEditingShortcuts consume local editing before any reassigned + // application search command, just as it does for other application actions. + if (FocusManager.instance.primaryFocus is BusyMarkSearchFocusNode && + BusyMarkLocalTextEditingShortcuts.bindings.keys.any( + (a) => a.accepts(event, keyboard), + )) { + return false; + } + if (ref.read(searchSessionProvider.notifier).categories.isEmpty) { + return false; + } + final commands = ref.read(busyMarkCommandRegistryProvider); if (commands.shortcutAccepts( - BusyMarkCommandIds.textEscape, + BusyMarkCommandIds.sidebarOutline, event, keyboard, )) { - if (rootNavigatorKey.currentState?.canPop() ?? false) { - return false; + unawaited(commands.execute(BusyMarkCommandIds.sidebarOutline)); + return true; + } + for (final entry in searchCommandDestinations.entries) { + if (commands.shortcutAccepts(entry.key, event, keyboard)) { + unawaited(commands.execute(entry.key)); + return true; } - ref.read(workspaceSearchCloseRequestProvider.notifier).request(); - return false; } return false; } @override - Widget build(BuildContext context) => widget.child; + Widget build(BuildContext context) { + ref.listen( + headerBarSearchEventsProvider, + (_, next) => next.whenData((event) { + final controller = ref.read(searchSessionProvider.notifier); + if (!ref.read(searchSessionProvider).active && + (event is HeaderBarSearchQueryChanged || + event is HeaderBarSearchSubmitted)) { + controller.open(); + } + switch (event) { + case HeaderBarSearchQueryChanged(:final query): + controller.query(query); + case HeaderBarSearchSubmitted(:final query): + controller.query(query); + unawaited(activateSelectedSearchResult(context, ref)); + case HeaderBarSearchCleared(): + controller.query(''); + case HeaderBarSearchEscapePressed(): + controller.close(); + case HeaderBarSearchFocusChanged(): + break; + } + }), + ); + return widget.child; + } } class _NewWorkspaceIntent extends Intent { @@ -1163,10 +1333,10 @@ final class _OpenWorkspaceFolder extends _OpenChooserChoice { const _OpenWorkspaceFolder(); } -final class _OpenRecentWorkspace extends _OpenChooserChoice { - const _OpenRecentWorkspace(this.path); +final class _OpenRecentResource extends _OpenChooserChoice { + const _OpenRecentResource(this.resource); - final String path; + final RecentResource resource; } sealed class _NewChooserChoice { @@ -1201,10 +1371,6 @@ class _KeyboardShortcutsIntent extends Intent { const _KeyboardShortcutsIntent(); } -class _CommandPaletteIntent extends Intent { - const _CommandPaletteIntent(); -} - class _SettingsIntent extends Intent { const _SettingsIntent(); } @@ -1229,10 +1395,6 @@ class _CloseAllTabsIntent extends Intent { const _CloseAllTabsIntent(); } -class _OpenSearchIntent extends Intent { - const _OpenSearchIntent(); -} - class _OpenClipboardHistoryIntent extends Intent { const _OpenClipboardHistoryIntent(); } @@ -1241,10 +1403,6 @@ class _OpenLocalHistoryIntent extends Intent { const _OpenLocalHistoryIntent(); } -class _FindLocalHistoryIntent extends Intent { - const _FindLocalHistoryIntent(); -} - class _ToggleSidebarIntent extends Intent { const _ToggleSidebarIntent(); } @@ -1255,6 +1413,12 @@ class _DocumentViewModeIntent extends Intent { final DocumentViewModePreference mode; } -class _QuickOpenIntent extends Intent { - const _QuickOpenIntent(); +class _NotesActionIntent extends Intent { + const _NotesActionIntent(this.action); + final WelcomeNotesAction action; +} + +class _NotesWorkspaceActionIntent extends Intent { + const _NotesWorkspaceActionIntent(this.commandId); + final String commandId; } diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 4cc14af5..fcce5d4e 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -10,6 +10,7 @@ import 'package:yaru/yaru.dart'; import '../platform/native_menu_service.dart'; import '../platform/gtk_header_icon_service.dart'; import 'busymark_glyphs.dart'; +import 'path_label.dart'; /// Resolve an explicit six-digit RGB color chosen in a document/export control. /// This does not inherit the application's theme accent. @@ -1507,6 +1508,7 @@ List? _busyMarkNativeMenuEntries( iconName: BusyMarkGlyphs.nativeMenuIconName(item.icon), iconColorArgb: item.iconColor?.toARGB32(), shortcut: item.shortcut, + gtkAccelerator: item.gtkAccelerator, enabled: item.enabled, checkable: item.trailingCheck, selected: item.trailingCheck && item.checked, @@ -1835,6 +1837,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { IconData? icon, Color? iconColor, String? shortcut, + this.gtkAccelerator, super.enabled = true, bool checked = false, bool trailingCheck = false, @@ -1869,6 +1872,7 @@ class BusyMarkPopupMenuItem extends PopupMenuItem { final IconData? icon; final Color? iconColor; final String? shortcut; + final String? gtkAccelerator; final bool checked; final bool trailingCheck; final bool mutuallyExclusive; @@ -2132,12 +2136,16 @@ class BusyMarkPopupSelectorOption { required this.label, this.icon, this.iconColor, + this.shortcut, + this.gtkAccelerator, }); final T value; final String label; final IconData? icon; final Color? iconColor; + final String? shortcut; + final String? gtkAccelerator; } /// The shared desktop selector used by Settings-style control rows. @@ -2155,7 +2163,7 @@ class BusyMarkPopupSelector extends StatelessWidget { this.buttonMaxWidth = BusyMarkSizes.languageButtonMaxWidth, this.leading, this.fullWidth = false, - this.contentPadding, + this.maxLabelLines = 1, }); final T? value; @@ -2169,10 +2177,12 @@ class BusyMarkPopupSelector extends StatelessWidget { final double buttonMaxWidth; final Widget? leading; final bool fullWidth; - final EdgeInsetsGeometry? contentPadding; - + final int maxLabelLines; @override Widget build(BuildContext context) { + // Inherit borderless button geometry and interaction states while keeping + // the neutral control foreground rather than an action-link accent. + final neutralButtonStyle = Theme.of(context).outlinedButtonTheme.style; final selectorEnabled = enabled && options.isNotEmpty; final fallbackMenuWidth = (fullWidth ? popupMaxWidth : buttonMaxWidth) .clamp(popupMinWidth, popupMaxWidth); @@ -2194,6 +2204,8 @@ class BusyMarkPopupSelector extends StatelessWidget { label: option.label, icon: option.icon, iconColor: option.iconColor, + shortcut: option.shortcut, + gtkAccelerator: option.gtkAccelerator, checked: option.value == value, trailingCheck: true, ), @@ -2204,16 +2216,13 @@ class BusyMarkPopupSelector extends StatelessWidget { message: tooltip, child: Semantics( expanded: trigger.isOpen, - child: BusyMarkPushButton.standard( + child: TextButton( + style: ButtonStyle( + foregroundColor: neutralButtonStyle?.foregroundColor, + iconColor: neutralButtonStyle?.iconColor, + ), onPressed: trigger.onPressed, focusNode: trigger.focusNode, - style: Theme.of(context).outlinedButtonTheme.style - ?.copyWith( - side: const WidgetStatePropertyAll(BorderSide.none), - padding: contentPadding == null - ? null - : WidgetStatePropertyAll(contentPadding), - ), child: Row( mainAxisSize: fullWidth ? MainAxisSize.max @@ -2227,9 +2236,9 @@ class BusyMarkPopupSelector extends StatelessWidget { Expanded( child: Text( label, - maxLines: 1, + maxLines: maxLabelLines, overflow: TextOverflow.ellipsis, - softWrap: false, + softWrap: maxLabelLines > 1, ), ) else @@ -2245,9 +2254,9 @@ class BusyMarkPopupSelector extends StatelessWidget { ), child: Text( label, - maxLines: 1, + maxLines: maxLabelLines, overflow: TextOverflow.ellipsis, - softWrap: false, + softWrap: maxLabelLines > 1, ), ), ), @@ -2767,8 +2776,14 @@ class BusyMarkSidebarRecordRow extends StatefulWidget { required this.onTap, this.title, this.subtitle, + this.pathSubtitle = false, this.content, this.selected = false, + this.compact = false, + this.focusNode, + this.geometryKey, + this.onFocusChange, + this.trailing, this.onDoubleTap, this.semanticsLabel, this.tooltip, @@ -2792,9 +2807,16 @@ class BusyMarkSidebarRecordRow extends StatefulWidget { final IconData icon; final String? title; final String? subtitle; + final bool pathSubtitle; final Widget? content; - final bool selected; - final VoidCallback onTap; + final bool selected, compact; + final FocusNode? focusNode; + + /// Optional measured anchor; the public row key remains its resource identity. + final GlobalKey? geometryKey; + final ValueChanged? onFocusChange; + final Widget? trailing; + final VoidCallback? onTap; final VoidCallback? onDoubleTap; final String? semanticsLabel; final String? tooltip; @@ -2841,7 +2863,7 @@ class _BusyMarkSidebarRecordRowState void _handleTap() { final onDoubleTap = widget.onDoubleTap; if (onDoubleTap == null) { - widget.onTap(); + widget.onTap?.call(); return; } final now = DateTime.now(); @@ -2849,7 +2871,7 @@ class _BusyMarkSidebarRecordRowState final doubleTap = previous != null && now.difference(previous) <= kDoubleTapTimeout; _lastTapAt = doubleTap ? null : now; - widget.onTap(); + widget.onTap?.call(); if (doubleTap) onDoubleTap(); } @@ -2888,7 +2910,7 @@ class _BusyMarkSidebarRecordRowState final customContent = widget.content; final menuItems = widget.menuItemsBuilder?.call(context); final trailing = menuItems == null || menuItems.isEmpty - ? null + ? widget.trailing : BusyMarkMenuButton( tooltip: widget.menuTooltip!, items: menuItems, @@ -2907,60 +2929,81 @@ class _BusyMarkSidebarRecordRowState ), ), ); - 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), - ), - ], - ], + final customRow = customContent == null + ? null + : ConstrainedBox( + constraints: BoxConstraints( + minHeight: widget.compact + ? BusyMarkSizes.sidebarTreeRowHeight + : 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: EdgeInsets.symmetric( + vertical: widget.compact + ? BusyMarkSpacing.xs + : BusyMarkSpacing.sm, + ), + child: customContent, ), + ), + if (trailing != null) ...[ + const SizedBox(width: BusyMarkSpacing.xs), + trailing, + ], + const SizedBox(width: BusyMarkSpacing.sm), + ], ), - ), - if (trailing != null) ...[ - const SizedBox(width: BusyMarkSpacing.xs), - trailing, - ], - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), - ); + ); + // Standard identities use the same native row layout as other BusyMark + // lists. Yaru owns their typography, content spacing and minimum extent. + final tile = customRow == null + ? YaruListTile.square( + leading: Icon( + widget.icon, + size: BusyMarkSizes.iconSm, + color: colors.mutedForeground, + ), + title: Text( + widget.title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle == null || subtitle.isEmpty + ? null + : _busyMarkGroupedRowSubtitle( + context, + widget.pathSubtitle + ? BusyMarkPathLabel(path: subtitle) + : Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + trailing: trailing, + focusNode: widget.focusNode ?? _rowFocusNode, + onFocusChange: widget.onFocusChange, + hoverColor: busyMarkRowHoverColor(context), + onTap: widget.onTap == null ? null : _handleTap, + ) + : InkWell( + hoverColor: busyMarkRowHoverColor(context), + focusNode: widget.focusNode ?? _rowFocusNode, + onFocusChange: widget.onFocusChange, + canRequestFocus: true, + onTap: widget.onTap == null ? null : _handleTap, + child: customRow, + ); final row = KeyedSubtree( key: _rowKey, child: Focus( @@ -2975,17 +3018,14 @@ class _BusyMarkSidebarRecordRowState : BusyMarkLinuxPalette.transparent, borderRadius: BorderRadius.circular(BusyMarkRadius.md), clipBehavior: Clip.antiAlias, - child: InkWell( - hoverColor: busyMarkRowHoverColor(context), - focusNode: _rowFocusNode, - onTap: _handleTap, + child: GestureDetector( onSecondaryTapUp: !_hasMenu ? null : (details) { - _rowFocusNode.requestFocus(); + (widget.focusNode ?? _rowFocusNode).requestFocus(); unawaited(_showContextMenu(details.globalPosition)); }, - child: content, + child: tile, ), ), ), @@ -2996,17 +3036,21 @@ class _BusyMarkSidebarRecordRowState selected: widget.selected, button: true, label: widget.semanticsLabel, - onTap: _handleTap, + enabled: widget.onTap != null, + onTap: widget.onTap == null ? null : _handleTap, child: row, ); final tooltip = widget.tooltip; - return tooltip == null || tooltip.isEmpty + final presented = tooltip == null || tooltip.isEmpty ? semanticRow : Tooltip( message: tooltip, excludeFromSemantics: true, child: semanticRow, ); + return widget.geometryKey == null + ? presented + : KeyedSubtree(key: widget.geometryKey, child: presented); } } @@ -3138,7 +3182,10 @@ class BusyMarkGroupedList extends StatelessWidget { if (title != null) ...[ Padding( padding: padding, - child: Text(title!, style: busyMarkSectionHeaderStyle(context)), + child: Semantics( + header: true, + child: Text(title!, style: busyMarkSectionHeaderStyle(context)), + ), ), if (description != null && description!.isNotEmpty) ...[ const SizedBox(height: BusyMarkSpacing.xs), diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 5a22af42..854b7e16 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -1,6 +1,8 @@ +import 'command_registry.dart'; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -9,8 +11,6 @@ import '../../l10n/generated/app_localizations.dart'; import '../platform/linux_header_bar_service.dart'; import 'app_metadata.dart'; import 'busymark_dialog_identity.dart'; -import 'busymark_shortcuts.dart'; -import 'command_registry.dart'; import 'busymark_design.dart'; import 'busymark_glyphs.dart'; import 'localization.dart'; @@ -77,19 +77,6 @@ class _DismissBusyMarkModalIntent extends Intent { const _DismissBusyMarkModalIntent(); } -final _busyMarkModalShortcuts = { - for (final command in BusyMarkCommandCatalog.metadata.commands) - if (command.shortcut != null && - command.scope != BusyMarkCommandScope.tree && - command.scope != BusyMarkCommandScope.textEditing) - command.shortcut!.activator: const DoNothingAndStopPropagationIntent(), - BusyMarkCommandCatalog - .metadata[BusyMarkCommandIds.textEscape]! - .shortcut! - .activator: - const _DismissBusyMarkModalIntent(), -}; - /// Prevents application and workspace shortcuts from escaping a modal surface. /// /// Use this around modal UI that is not presented by @@ -146,7 +133,23 @@ class _BusyMarkModalShortcutBoundaryState @override Widget build(BuildContext context) { return Shortcuts( - shortcuts: _busyMarkModalShortcuts, + shortcuts: { + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .shortcutIntents( + scopes: const { + BusyMarkCommandScope.application, + BusyMarkCommandScope.documentView, + BusyMarkCommandScope.editor, + BusyMarkCommandScope.wysiwyg, + BusyMarkCommandScope.sidebar, + BusyMarkCommandScope.sourceSymbol, + }, + intentFor: (_) => const DoNothingAndStopPropagationIntent(), + ), + const SingleActivator(LogicalKeyboardKey.escape): + const _DismissBusyMarkModalIntent(), + }, child: Actions( actions: >{ _DismissBusyMarkModalIntent: @@ -161,7 +164,7 @@ class _BusyMarkModalShortcutBoundaryState focusNode: _fallbackFocusNode, skipTraversal: true, includeSemantics: false, - child: widget.child, + child: DefaultTextEditingShortcuts(child: widget.child), ), ), ); @@ -457,591 +460,6 @@ Future _openApacheLicense() async { await launchUrl(_apacheLicenseUri, mode: LaunchMode.externalApplication); } -void showBusyMarkKeyboardShortcutsDialog(BuildContext context) { - final registry = - BusyMarkCommandRegistryScope.maybeOf(context) ?? - BusyMarkCommandCatalog.metadata; - final headerBar = LinuxHeaderBarService.instance; - unawaited( - showBusyMarkModalDialog( - context, - headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (context) { - final grouped = >{}; - final shortcutLabels = {}; - for (final command in registry.commands) { - final shortcut = command.shortcut; - if (shortcut == null || !shortcutLabels.add(shortcut.label)) { - continue; - } - grouped.putIfAbsent(command.category(context), () => []).add(command); - } - return _BusyMarkInfoDialog( - title: context.l10n.keyboardShortcuts, - icon: BusyMarkGlyphs.keyboard, - maxWidth: 460, - children: [ - for (final entry in grouped.entries) - BusyMarkGroupedList( - title: entry.key, - filled: true, - children: [ - for (final command in entry.value) - BusyMarkActionRow( - title: command.label(context), - subtitle: command.description?.call(context), - leading: const Icon(BusyMarkGlyphs.keyboard), - trailing: _KeyboardShortcutBadge(command.shortcut!.label), - ), - ], - ), - ], - ); - }, - ), - ); -} - -// Retained temporarily as a layout reference while every shortcut consumer is -// migrated to the registry-backed presentation above. -void showLegacyBusyMarkKeyboardShortcutsDialog(BuildContext context) { - final headerBar = LinuxHeaderBarService.instance; - unawaited( - showBusyMarkModalDialog( - context, - headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (context) => _BusyMarkInfoDialog( - title: context.l10n.keyboardShortcuts, - icon: BusyMarkGlyphs.keyboard, - maxWidth: 460, - children: [ - BusyMarkGroupedList( - title: context.l10n.shortcutGroupGeneral, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.shortcutNewDocument, - subtitle: context.l10n.shortcutNewDocumentDescription, - leading: const Icon(BusyMarkGlyphs.newDocument), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.newDocument, - ), - ), - BusyMarkActionRow( - title: context.l10n.open, - subtitle: context.l10n.shortcutOpenDescription, - leading: const Icon(BusyMarkGlyphs.folderOpen), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.open, - ), - ), - BusyMarkActionRow( - title: context.l10n.save, - subtitle: context.l10n.shortcutSaveDescription, - leading: const Icon(BusyMarkGlyphs.save), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.save, - ), - ), - BusyMarkActionRow( - title: context.l10n.export, - leading: const Icon(BusyMarkGlyphs.exportPdf), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.export, - ), - ), - BusyMarkActionRow( - title: context.l10n.fullScreen, - leading: const Icon(BusyMarkGlyphs.fullScreen), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.fullScreen, - ), - ), - BusyMarkActionRow( - title: context.l10n.back, - leading: Icon( - BusyMarkGlyphs.backFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.back, - ), - ), - BusyMarkActionRow( - title: context.l10n.search, - subtitle: context.l10n.shortcutSearchDescription, - leading: const Icon(BusyMarkGlyphs.search), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.search, - ), - ), - BusyMarkActionRow( - title: context.l10n.keyboardShortcuts, - subtitle: context.l10n.shortcutKeyboardShortcutsDescription, - leading: const Icon(BusyMarkGlyphs.keyboard), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.keyboardShortcuts, - ), - ), - BusyMarkActionRow( - title: context.l10n.syntaxReference, - subtitle: context.l10n.shortcutSyntaxReferenceDescription, - leading: const Icon(BusyMarkGlyphs.markdownFile), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.syntaxReference, - ), - ), - BusyMarkActionRow( - title: context.l10n.settings, - subtitle: context.l10n.shortcutSettingsDescription, - leading: const Icon(BusyMarkGlyphs.settings), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.settings, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.tabs, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.shortcutNextTab, - subtitle: context.l10n.shortcutNextTabDescription, - leading: const Icon(BusyMarkGlyphs.tab), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.nextTab, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutPreviousTab, - subtitle: context.l10n.shortcutPreviousTabDescription, - leading: const Icon(BusyMarkGlyphs.tab), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.previousTab, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutCloseTab, - subtitle: context.l10n.shortcutCloseTabDescription, - leading: const Icon(BusyMarkGlyphs.clear), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.closeTab, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutCloseAllTabs, - subtitle: context.l10n.shortcutCloseAllTabsDescription, - leading: const Icon(BusyMarkGlyphs.clearAll), - trailing: const _KeyboardShortcutBadge( - BusyMarkAppShortcutLabels.closeAllTabs, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.viewMode, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.editor, - leading: const Icon(BusyMarkGlyphs.editorView), - trailing: const _KeyboardShortcutBadge( - BusyMarkDocumentViewShortcutLabels.editor, - ), - ), - BusyMarkActionRow( - title: context.l10n.source, - leading: const Icon(BusyMarkGlyphs.sourceView), - trailing: const _KeyboardShortcutBadge( - BusyMarkDocumentViewShortcutLabels.source, - ), - ), - BusyMarkActionRow( - title: context.l10n.reading, - leading: const Icon(BusyMarkGlyphs.previewView), - trailing: const _KeyboardShortcutBadge( - BusyMarkDocumentViewShortcutLabels.reading, - ), - ), - BusyMarkActionRow( - title: context.l10n.split, - leading: const Icon(BusyMarkGlyphs.splitView), - trailing: const _KeyboardShortcutBadge( - BusyMarkDocumentViewShortcutLabels.split, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.shortcutGroupTextEditing, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.selectAll, - subtitle: context.l10n.shortcutSelectAllDescription, - leading: const Icon(BusyMarkGlyphs.selectAll), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.selectAll, - ), - ), - BusyMarkActionRow( - title: context.l10n.cut, - subtitle: context.l10n.shortcutCutDescription, - leading: const Icon(BusyMarkGlyphs.cut), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.cut, - ), - ), - BusyMarkActionRow( - title: context.l10n.copy, - subtitle: context.l10n.shortcutCopyDescription, - leading: const Icon(BusyMarkGlyphs.copy), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.copy, - ), - ), - BusyMarkActionRow( - title: context.l10n.paste, - subtitle: context.l10n.shortcutPasteDescription, - leading: const Icon(BusyMarkGlyphs.paste), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.paste, - ), - ), - BusyMarkActionRow( - title: context.l10n.clipboardPastePlainText, - subtitle: context.l10n.shortcutPastePlainTextDescription, - leading: const Icon(BusyMarkGlyphs.paste), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.pastePlainText, - ), - ), - BusyMarkActionRow( - title: context.l10n.undo, - subtitle: context.l10n.shortcutUndoDescription, - leading: Icon( - BusyMarkGlyphs.undoFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.undo, - ), - ), - BusyMarkActionRow( - title: context.l10n.redo, - subtitle: context.l10n.shortcutRedoDescription, - leading: Icon( - BusyMarkGlyphs.redoFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.redo, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutInsertIndentation, - subtitle: context.l10n.shortcutInsertIndentationDescription, - leading: Icon( - BusyMarkGlyphs.indentFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.insertIndentation, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutOutdentSource, - subtitle: context.l10n.shortcutOutdentSourceDescription, - leading: Icon( - BusyMarkGlyphs.outdentFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.outdentSource, - ), - ), - BusyMarkActionRow( - title: context.l10n.shortcutEscape, - subtitle: context.l10n.shortcutEscapeDescription, - leading: const Icon(BusyMarkGlyphs.clear), - trailing: const _KeyboardShortcutBadge( - BusyMarkTextEditingShortcutLabels.escape, - ), - ), - BusyMarkActionRow( - title: context.l10n.aiRefineWithAi, - leading: const Icon(BusyMarkGlyphs.ai), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.refineWithAi, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.shortcutGroupFormatting, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.bold, - subtitle: context.l10n.shortcutBoldDescription, - leading: const Icon(BusyMarkGlyphs.bold), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.bold, - ), - ), - BusyMarkActionRow( - title: context.l10n.italic, - subtitle: context.l10n.shortcutItalicDescription, - leading: const Icon(BusyMarkGlyphs.italic), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.italic, - ), - ), - BusyMarkActionRow( - title: context.l10n.underline, - subtitle: context.l10n.shortcutUnderlineDescription, - leading: const Icon(BusyMarkGlyphs.underline), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.underline, - ), - ), - BusyMarkActionRow( - title: context.l10n.link, - subtitle: context.l10n.shortcutLinkDescription, - leading: const Icon(BusyMarkGlyphs.link), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.link, - ), - ), - BusyMarkActionRow( - title: context.l10n.inlineCode, - subtitle: context.l10n.shortcutInlineCodeDescription, - leading: const Icon(BusyMarkGlyphs.code), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.inlineCode, - ), - ), - BusyMarkActionRow( - title: context.l10n.strikethrough, - subtitle: context.l10n.shortcutStrikethroughDescription, - leading: const Icon(BusyMarkGlyphs.strikethrough), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.strikethrough, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.shortcutGroupBlocks, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.paragraph, - subtitle: context.l10n.shortcutParagraphDescription, - leading: const Icon(BusyMarkGlyphs.paragraph), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.paragraph, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading1, - subtitle: context.l10n.shortcutHeading1Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading1, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading2, - subtitle: context.l10n.shortcutHeading2Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading2, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading3, - subtitle: context.l10n.shortcutHeading3Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading3, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading4, - subtitle: context.l10n.shortcutHeading4Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading4, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading5, - subtitle: context.l10n.shortcutHeading5Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading5, - ), - ), - BusyMarkActionRow( - title: context.l10n.heading6, - subtitle: context.l10n.shortcutHeading6Description, - leading: const Icon(BusyMarkGlyphs.heading), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.heading6, - ), - ), - BusyMarkActionRow( - title: context.l10n.blockquote, - leading: const Icon(BusyMarkGlyphs.blockquote), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.blockquote, - ), - ), - BusyMarkActionRow( - title: context.l10n.codeBlock, - leading: const Icon(BusyMarkGlyphs.code), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.codeBlock, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.shortcutGroupLists, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.numberedList, - subtitle: context.l10n.shortcutNumberedListDescription, - leading: const Icon(BusyMarkGlyphs.orderedList), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.orderedList, - ), - ), - BusyMarkActionRow( - title: context.l10n.bulletedList, - subtitle: context.l10n.shortcutBulletedListDescription, - leading: const Icon(BusyMarkGlyphs.unorderedList), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.unorderedList, - ), - ), - BusyMarkActionRow( - title: context.l10n.checklist, - subtitle: context.l10n.shortcutChecklistDescription, - leading: const Icon(BusyMarkGlyphs.checklist), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.taskList, - ), - ), - BusyMarkActionRow( - title: context.l10n.indentListItem, - leading: Icon( - BusyMarkGlyphs.indentFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.indent, - ), - ), - BusyMarkActionRow( - title: context.l10n.outdentListItem, - leading: Icon( - BusyMarkGlyphs.outdentFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.outdent, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.insert, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.image, - leading: const Icon(BusyMarkGlyphs.image), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.image, - ), - ), - BusyMarkActionRow( - title: context.l10n.hardLineBreak, - leading: const Icon(BusyMarkGlyphs.hardBreak), - trailing: const _KeyboardShortcutBadge( - BusyMarkEditorShortcutLabels.hardLineBreak, - ), - ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.shortcutGroupSidebar, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.toggleSidebar, - leading: const Icon(BusyMarkGlyphs.sidebar), - trailing: const _KeyboardShortcutBadge( - BusyMarkSidebarShortcutLabels.toggleSidebar, - ), - ), - BusyMarkActionRow( - title: context.l10n.files, - leading: const Icon(BusyMarkGlyphs.documentOpen), - trailing: const _KeyboardShortcutBadge( - BusyMarkSidebarShortcutLabels.files, - ), - ), - BusyMarkActionRow( - title: context.l10n.toc, - leading: const Icon(BusyMarkGlyphs.orderedList), - trailing: const _KeyboardShortcutBadge( - BusyMarkSidebarShortcutLabels.toc, - ), - ), - BusyMarkActionRow( - title: context.l10n.outline, - leading: Icon( - BusyMarkGlyphs.indentFor(Directionality.of(context)), - ), - trailing: const _KeyboardShortcutBadge( - BusyMarkSidebarShortcutLabels.outline, - ), - ), - BusyMarkActionRow( - title: context.l10n.git, - leading: const Icon(BusyMarkGlyphs.branch), - trailing: const _KeyboardShortcutBadge( - BusyMarkSidebarShortcutLabels.git, - ), - ), - BusyMarkActionRow( - title: context.l10n.localHistoryEllipsis, - leading: const Icon(BusyMarkGlyphs.sidebarLocalHistory), - 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, - leading: const Icon(BusyMarkGlyphs.delete), - trailing: const _KeyboardShortcutBadge( - BusyMarkTreeShortcutLabels.deleteSelection, - ), - ), - ], - ), - ], - ), - ), - ); -} - void showBusyMarkSyntaxReferenceDialog(BuildContext context) { final headerBar = LinuxHeaderBarService.instance; unawaited( @@ -2595,46 +2013,6 @@ class _SyntaxReferenceValue extends StatelessWidget { } } -class _BusyMarkInfoDialog extends StatelessWidget { - const _BusyMarkInfoDialog({ - required this.title, - required this.icon, - required this.children, - this.maxWidth = 460, - }); - - final String title; - final IconData icon; - final List children; - final double maxWidth; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return BusyMarkInformationalDialog( - closeLabel: context.l10n.close, - maxWidth: maxWidth, - maxHeight: 560, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMarkDialogIdentity( - visual: Icon( - icon, - size: BusyMarkDialogIdentity.visualExtent, - color: colorScheme.primary, - ), - title: title, - ), - const SizedBox(height: BusyMarkSpacing.lg), - ...children, - ], - ), - ); - } -} - class _BusyMarkAboutDialog extends StatelessWidget { const _BusyMarkAboutDialog(); @@ -2756,38 +2134,3 @@ class _AboutVersionTag extends StatelessWidget { ); } } - -class _KeyboardShortcutBadge extends StatelessWidget { - const _KeyboardShortcutBadge(this.label); - - final String label; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.control, - borderRadius: BorderRadius.circular(BusyMarkRadius.sm), - border: Border.all(color: colors.subtleBorder), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - vertical: BusyMarkSpacing.xxs, - ), - child: Directionality( - textDirection: TextDirection.ltr, - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontFamily: BusyMarkTypography.monoFontFamily, - fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, - color: colors.foreground, - ), - ), - ), - ), - ); - } -} diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart index 00b13ac8..904d4677 100644 --- a/lib/src/app/busymark_main_menu.dart +++ b/lib/src/app/busymark_main_menu.dart @@ -13,9 +13,6 @@ enum BusyMarkMainMenuAction { export, fullScreen, settings, - keyboardShortcuts, - commandPalette, - quickOpen, syntaxReference, reportIssue, aboutBusyMark, @@ -34,9 +31,8 @@ class BusyMarkMainMenuButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; - final commands = - BusyMarkCommandRegistryScope.maybeOf(context) ?? - BusyMarkCommandCatalog.metadata; + final boundCommands = BusyMarkCommandRegistryScope.maybeOf(context); + final commands = boundCommands ?? BusyMarkCommandCatalog.metadata; BusyMarkCommand command(String id) => commands[id]!; final fullScreen = ref.watch( windowControlServiceProvider.select((service) => service.isFullScreen), @@ -50,6 +46,9 @@ class BusyMarkMainMenuButton extends ConsumerWidget { label: command(BusyMarkCommandIds.export).label(context), icon: BusyMarkGlyphs.exportPdf, shortcut: command(BusyMarkCommandIds.export).shortcut?.label, + gtkAccelerator: command( + BusyMarkCommandIds.export, + ).shortcut?.gtkAccelerator, enabled: canExport, ), BusyMarkPopupMenuItem( @@ -57,6 +56,9 @@ class BusyMarkMainMenuButton extends ConsumerWidget { label: command(BusyMarkCommandIds.fullScreen).label(context), icon: BusyMarkGlyphs.fullScreen, shortcut: command(BusyMarkCommandIds.fullScreen).shortcut?.label, + gtkAccelerator: command( + BusyMarkCommandIds.fullScreen, + ).shortcut?.gtkAccelerator, checked: fullScreen, trailingCheck: true, mutuallyExclusive: false, @@ -66,32 +68,18 @@ class BusyMarkMainMenuButton extends ConsumerWidget { label: command(BusyMarkCommandIds.settings).label(context), icon: BusyMarkGlyphs.settings, shortcut: command(BusyMarkCommandIds.settings).shortcut?.label, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.keyboardShortcuts, - label: command(BusyMarkCommandIds.keyboardShortcuts).label(context), - icon: BusyMarkGlyphs.keyboard, - shortcut: command( - BusyMarkCommandIds.keyboardShortcuts, - ).shortcut?.label, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.quickOpen, - label: command(BusyMarkCommandIds.quickOpen).label(context), - icon: BusyMarkGlyphs.search, - shortcut: command(BusyMarkCommandIds.quickOpen).shortcut?.label, - ), - BusyMarkPopupMenuItem( - value: BusyMarkMainMenuAction.commandPalette, - label: command(BusyMarkCommandIds.commandPalette).label(context), - icon: BusyMarkGlyphs.search, - shortcut: command(BusyMarkCommandIds.commandPalette).shortcut?.label, + gtkAccelerator: command( + BusyMarkCommandIds.settings, + ).shortcut?.gtkAccelerator, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.syntaxReference, label: command(BusyMarkCommandIds.syntaxReference).label(context), icon: BusyMarkGlyphs.markdownFile, shortcut: command(BusyMarkCommandIds.syntaxReference).shortcut?.label, + gtkAccelerator: command( + BusyMarkCommandIds.syntaxReference, + ).shortcut?.gtkAccelerator, ), BusyMarkPopupMenuItem( value: BusyMarkMainMenuAction.reportIssue, @@ -109,11 +97,6 @@ class BusyMarkMainMenuButton extends ConsumerWidget { BusyMarkMainMenuAction.export => BusyMarkCommandIds.export, BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen, BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings, - BusyMarkMainMenuAction.keyboardShortcuts => - BusyMarkCommandIds.keyboardShortcuts, - BusyMarkMainMenuAction.quickOpen => BusyMarkCommandIds.quickOpen, - BusyMarkMainMenuAction.commandPalette => - BusyMarkCommandIds.commandPalette, BusyMarkMainMenuAction.syntaxReference => BusyMarkCommandIds.syntaxReference, BusyMarkMainMenuAction.reportIssue || diff --git a/lib/src/app/busymark_search_field.dart b/lib/src/app/busymark_search_field.dart index 6909182a..119e5008 100644 --- a/lib/src/app/busymark_search_field.dart +++ b/lib/src/app/busymark_search_field.dart @@ -2,6 +2,48 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; +/// Identifies local query input to the application's early keyboard route. +/// No ancestor lookup is needed while focus detaches during navigation. +class BusyMarkSearchFocusNode extends FocusNode { + BusyMarkSearchFocusNode() + : super(debugLabel: 'BusyMarkSearchField text entry'); +} + +/// Keep input editing local without shadowing application navigation (for +/// example Alt+Left) with a second complete platform shortcut map. +class BusyMarkLocalTextEditingShortcuts extends StatelessWidget { + const BusyMarkLocalTextEditingShortcuts({super.key, required this.child}); + + final Widget child; + static const bindings = { + SingleActivator(LogicalKeyboardKey.keyA, control: true): + SelectAllTextIntent(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyC, control: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.insert, control: true): + CopySelectionTextIntent.copy, + SingleActivator(LogicalKeyboardKey.keyX, control: true): + CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.delete, shift: true): + CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + SingleActivator(LogicalKeyboardKey.keyV, control: true): PasteTextIntent( + SelectionChangedCause.keyboard, + ), + SingleActivator(LogicalKeyboardKey.insert, shift: true): PasteTextIntent( + SelectionChangedCause.keyboard, + ), + SingleActivator(LogicalKeyboardKey.keyZ, control: true): UndoTextIntent( + SelectionChangedCause.keyboard, + ), + SingleActivator(LogicalKeyboardKey.keyZ, control: true, shift: true): + RedoTextIntent(SelectionChangedCause.keyboard), + }; + + @override + Widget build(BuildContext context) => + Shortcuts(shortcuts: bindings, child: child); +} + /// Flutter counterpart to BusyMark's native Linux `GtkSearchEntry`. /// /// The native entry cannot be embedded in Flutter-owned layouts. This uses the @@ -17,6 +59,7 @@ class BusyMarkSearchField extends StatefulWidget { this.focusRequest = 0, this.onChanged, this.onSubmitted, + this.onMoveSelection, this.onClear, this.onEscape, this.clearButtonSemanticLabel, @@ -32,6 +75,7 @@ class BusyMarkSearchField extends StatefulWidget { final ValueChanged? onChanged; final ValueChanged? onSubmitted; + final ValueChanged? onMoveSelection; final VoidCallback? onClear; final VoidCallback? onEscape; final String? clearButtonSemanticLabel; @@ -44,7 +88,7 @@ class BusyMarkSearchField extends StatefulWidget { class _BusyMarkSearchFieldState extends State { late TextEditingController _controller; late bool _textIsEmpty; - final _focusNode = FocusNode(debugLabel: 'BusyMarkSearchField text entry'); + final _focusNode = BusyMarkSearchFocusNode(); @override void initState() { @@ -114,6 +158,27 @@ class _BusyMarkSearchFieldState extends State { @override Widget build(BuildContext context) { + _focusNode.onKeyEvent = (_, event) { + if ((event is! KeyDownEvent && event is! KeyRepeatEvent) || + _controller.value.composing.isValid) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + widget.onSubmitted != null) { + widget.onSubmitted!(_controller.text); + return KeyEventResult.handled; + } + if ((event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) && + widget.onMoveSelection != null) { + widget.onMoveSelection!( + event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1, + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }; final theme = Theme.of(context); final clearLabel = widget.clearButtonSemanticLabel ?? @@ -121,7 +186,8 @@ class _BusyMarkSearchFieldState extends State { return Focus( onKeyEvent: (_, event) { if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { + event.logicalKey == LogicalKeyboardKey.escape && + !_controller.value.composing.isValid) { if (widget.clearOnEscape) _clear(); widget.onEscape?.call(); return KeyEventResult.handled; @@ -130,32 +196,36 @@ class _BusyMarkSearchFieldState extends State { }, 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, - 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, + // Query editing stays local even if an application command acquires + // one of the platform's ordinary text-editing combinations. + child: BusyMarkLocalTextEditingShortcuts( + child: TextField( + controller: _controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, + cursorWidth: 1, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + 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 6409c3be..3c4a95b3 100644 --- a/lib/src/app/busymark_shortcuts.dart +++ b/lib/src/app/busymark_shortcuts.dart @@ -1,3 +1,4 @@ +import 'shortcut_binding.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -5,8 +6,8 @@ class BusyMarkShortcutDefinition { const BusyMarkShortcutDefinition({ required this.label, required this.activator, - this.gtkAccelerator, - }); + String? gtkAccelerator, + }) : _gtkAccelerator = gtkAccelerator; final String label; final ShortcutActivator activator; @@ -15,7 +16,15 @@ class BusyMarkShortcutDefinition { /// /// This is deliberately separate from [label]: GTK parses strings such as /// `comma`, then formats the visible label for the current desktop. - final String? gtkAccelerator; + final String? _gtkAccelerator; + String? get gtkAccelerator => + _gtkAccelerator ?? + switch (activator) { + final SingleActivator value => BusyMarkKeyBinding.fromActivator( + value, + ).gtkAccelerator, + _ => null, + }; } enum BusyMarkAppShortcutAction { @@ -48,12 +57,15 @@ abstract final class BusyMarkAppShortcuts { static const fullScreenLabel = 'F11'; static const backLabel = 'Alt+Left'; static const searchLabel = 'Ctrl+F'; - static const keyboardShortcutsLabel = 'Ctrl+Alt+K'; - static const quickOpenLabel = 'Ctrl+P'; - static const quickOpenGtkAccelerator = 'p'; + static const quickOpenLabel = 'Ctrl+Shift+D'; + static const quickOpenGtkAccelerator = 'd'; static const quickOpen = BusyMarkShortcutDefinition( label: quickOpenLabel, - activator: SingleActivator(LogicalKeyboardKey.keyP, control: true), + activator: SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + shift: true, + ), gtkAccelerator: quickOpenGtkAccelerator, ); @@ -73,7 +85,6 @@ abstract final class BusyMarkAppShortcuts { static const fullScreenGtkAccelerator = 'F11'; static const backGtkAccelerator = 'Left'; static const searchGtkAccelerator = 'f'; - static const keyboardShortcutsGtkAccelerator = 'k'; static const commandPaletteGtkAccelerator = 'p'; static const syntaxReferenceGtkAccelerator = 'm'; static const settingsGtkAccelerator = 's'; @@ -126,15 +137,6 @@ abstract final class BusyMarkAppShortcuts { activator: SingleActivator(LogicalKeyboardKey.keyF, control: true), gtkAccelerator: searchGtkAccelerator, ); - static const keyboardShortcuts = BusyMarkShortcutDefinition( - label: keyboardShortcutsLabel, - activator: SingleActivator( - LogicalKeyboardKey.keyK, - control: true, - alt: true, - ), - gtkAccelerator: keyboardShortcutsGtkAccelerator, - ); static const commandPalette = BusyMarkShortcutDefinition( label: commandPaletteLabel, activator: SingleActivator( @@ -205,7 +207,6 @@ abstract final class BusyMarkAppShortcuts { BusyMarkAppShortcutAction.fullScreen: fullScreen, BusyMarkAppShortcutAction.back: back, BusyMarkAppShortcutAction.search: search, - BusyMarkAppShortcutAction.keyboardShortcuts: keyboardShortcuts, BusyMarkAppShortcutAction.commandPalette: commandPalette, BusyMarkAppShortcutAction.quickOpen: quickOpen, BusyMarkAppShortcutAction.syntaxReference: syntaxReference, @@ -218,72 +219,6 @@ abstract final class BusyMarkAppShortcuts { }; } -abstract final class BusyMarkAppShortcutLabels { - const BusyMarkAppShortcutLabels._(); - - static const newDocument = BusyMarkAppShortcuts.newDocumentLabel; - static const open = BusyMarkAppShortcuts.openLabel; - static const save = BusyMarkAppShortcuts.saveLabel; - static const export = BusyMarkAppShortcuts.exportLabel; - static const fullScreen = BusyMarkAppShortcuts.fullScreenLabel; - static const back = BusyMarkAppShortcuts.backLabel; - static const search = BusyMarkAppShortcuts.searchLabel; - static const keyboardShortcuts = BusyMarkAppShortcuts.keyboardShortcutsLabel; - static const commandPalette = BusyMarkAppShortcuts.commandPaletteLabel; - static const syntaxReference = BusyMarkAppShortcuts.syntaxReferenceLabel; - static const settings = BusyMarkAppShortcuts.settingsLabel; - static const nextTab = BusyMarkAppShortcuts.nextTabLabel; - static const previousTab = BusyMarkAppShortcuts.previousTabLabel; - static const closeTab = BusyMarkAppShortcuts.closeTabLabel; - static const closeAllTabs = BusyMarkAppShortcuts.closeAllTabsLabel; -} - -abstract final class BusyMarkAppShortcutActivators { - const BusyMarkAppShortcutActivators._(); - - static ShortcutActivator get newDocument => - BusyMarkAppShortcuts.newDocument.activator; - static ShortcutActivator get open => BusyMarkAppShortcuts.open.activator; - static ShortcutActivator get save => BusyMarkAppShortcuts.save.activator; - static ShortcutActivator get export => BusyMarkAppShortcuts.export.activator; - static ShortcutActivator get fullScreen => - BusyMarkAppShortcuts.fullScreen.activator; - static ShortcutActivator get back => BusyMarkAppShortcuts.back.activator; - static ShortcutActivator get search => BusyMarkAppShortcuts.search.activator; - static ShortcutActivator get keyboardShortcuts => - BusyMarkAppShortcuts.keyboardShortcuts.activator; - static ShortcutActivator get commandPalette => - BusyMarkAppShortcuts.commandPalette.activator; - static ShortcutActivator get syntaxReference => - BusyMarkAppShortcuts.syntaxReference.activator; - static ShortcutActivator get settings => - BusyMarkAppShortcuts.settings.activator; - static ShortcutActivator get nextTab => - BusyMarkAppShortcuts.nextTab.activator; - static ShortcutActivator get previousTab => - BusyMarkAppShortcuts.previousTab.activator; - static ShortcutActivator get closeTab => - BusyMarkAppShortcuts.closeTab.activator; - static ShortcutActivator get closeAllTabs => - BusyMarkAppShortcuts.closeAllTabs.activator; - static ShortcutActivator get toggleSidebar => - BusyMarkAppShortcuts.toggleSidebar.activator; -} - -abstract final class BusyMarkAppShortcutGtkAccelerators { - const BusyMarkAppShortcutGtkAccelerators._(); - - static const search = BusyMarkAppShortcuts.searchGtkAccelerator; - static const export = BusyMarkAppShortcuts.exportGtkAccelerator; - static const fullScreen = BusyMarkAppShortcuts.fullScreenGtkAccelerator; - static const back = BusyMarkAppShortcuts.backGtkAccelerator; - static const keyboardShortcuts = - BusyMarkAppShortcuts.keyboardShortcutsGtkAccelerator; - static const syntaxReference = - BusyMarkAppShortcuts.syntaxReferenceGtkAccelerator; - static const settings = BusyMarkAppShortcuts.settingsGtkAccelerator; -} - enum BusyMarkDocumentViewShortcutAction { editor, source, reading, split } abstract final class BusyMarkDocumentViewShortcuts { @@ -345,37 +280,6 @@ abstract final class BusyMarkDocumentViewShortcuts { }; } -abstract final class BusyMarkDocumentViewShortcutLabels { - const BusyMarkDocumentViewShortcutLabels._(); - - static const editor = BusyMarkDocumentViewShortcuts.editorLabel; - static const source = BusyMarkDocumentViewShortcuts.sourceLabel; - static const reading = BusyMarkDocumentViewShortcuts.readingLabel; - static const split = BusyMarkDocumentViewShortcuts.splitLabel; -} - -abstract final class BusyMarkDocumentViewShortcutActivators { - const BusyMarkDocumentViewShortcutActivators._(); - - static ShortcutActivator get editor => - BusyMarkDocumentViewShortcuts.editor.activator; - static ShortcutActivator get source => - BusyMarkDocumentViewShortcuts.source.activator; - static ShortcutActivator get reading => - BusyMarkDocumentViewShortcuts.reading.activator; - static ShortcutActivator get split => - BusyMarkDocumentViewShortcuts.split.activator; -} - -abstract final class BusyMarkDocumentViewShortcutGtkAccelerators { - const BusyMarkDocumentViewShortcutGtkAccelerators._(); - - static const editor = BusyMarkDocumentViewShortcuts.editorGtkAccelerator; - static const source = BusyMarkDocumentViewShortcuts.sourceGtkAccelerator; - static const reading = BusyMarkDocumentViewShortcuts.readingGtkAccelerator; - static const split = BusyMarkDocumentViewShortcuts.splitGtkAccelerator; -} - enum BusyMarkTextEditingShortcutAction { selectAll, cut, @@ -467,48 +371,6 @@ abstract final class BusyMarkTextEditingShortcuts { }; } -abstract final class BusyMarkTextEditingShortcutLabels { - const BusyMarkTextEditingShortcutLabels._(); - - static const selectAll = BusyMarkTextEditingShortcuts.selectAllLabel; - static const cut = BusyMarkTextEditingShortcuts.cutLabel; - static const copy = BusyMarkTextEditingShortcuts.copyLabel; - static const paste = BusyMarkTextEditingShortcuts.pasteLabel; - static const pastePlainText = - BusyMarkTextEditingShortcuts.pastePlainTextLabel; - static const undo = BusyMarkTextEditingShortcuts.undoLabel; - static const redo = BusyMarkTextEditingShortcuts.redoLabel; - static const insertIndentation = - BusyMarkTextEditingShortcuts.insertIndentationLabel; - static const outdentSource = BusyMarkTextEditingShortcuts.outdentSourceLabel; - static const escape = BusyMarkTextEditingShortcuts.escapeLabel; -} - -abstract final class BusyMarkTextEditingShortcutActivators { - const BusyMarkTextEditingShortcutActivators._(); - - static ShortcutActivator get selectAll => - BusyMarkTextEditingShortcuts.selectAll.activator; - static ShortcutActivator get cut => - BusyMarkTextEditingShortcuts.cut.activator; - static ShortcutActivator get copy => - BusyMarkTextEditingShortcuts.copy.activator; - static ShortcutActivator get paste => - BusyMarkTextEditingShortcuts.paste.activator; - static ShortcutActivator get pastePlainText => - BusyMarkTextEditingShortcuts.pastePlainText.activator; - static ShortcutActivator get undo => - BusyMarkTextEditingShortcuts.undo.activator; - static ShortcutActivator get redo => - BusyMarkTextEditingShortcuts.redo.activator; - static ShortcutActivator get insertIndentation => - BusyMarkTextEditingShortcuts.insertIndentation.activator; - static ShortcutActivator get outdentSource => - BusyMarkTextEditingShortcuts.outdentSource.activator; - static ShortcutActivator get escape => - BusyMarkTextEditingShortcuts.escape.activator; -} - enum BusyMarkEditorShortcutAction { refineWithAi, copyPlainText, @@ -770,111 +632,6 @@ abstract final class BusyMarkEditorShortcuts { }; } -abstract final class BusyMarkEditorShortcutLabels { - const BusyMarkEditorShortcutLabels._(); - - static const refineWithAi = BusyMarkEditorShortcuts.refineWithAiLabel; - static const copyPlainText = BusyMarkEditorShortcuts.copyPlainTextLabel; - static const textStyle = BusyMarkEditorShortcuts.textStyleLabel; - static const bold = BusyMarkEditorShortcuts.boldLabel; - static const italic = BusyMarkEditorShortcuts.italicLabel; - static const underline = BusyMarkEditorShortcuts.underlineLabel; - static const strikethrough = BusyMarkEditorShortcuts.strikethroughLabel; - static const inlineCode = BusyMarkEditorShortcuts.inlineCodeLabel; - static const link = BusyMarkEditorShortcuts.linkLabel; - static const paragraph = BusyMarkEditorShortcuts.paragraphLabel; - static const heading1 = BusyMarkEditorShortcuts.heading1Label; - static const heading2 = BusyMarkEditorShortcuts.heading2Label; - static const heading3 = BusyMarkEditorShortcuts.heading3Label; - static const heading4 = BusyMarkEditorShortcuts.heading4Label; - static const heading5 = BusyMarkEditorShortcuts.heading5Label; - static const heading6 = BusyMarkEditorShortcuts.heading6Label; - static const orderedList = BusyMarkEditorShortcuts.orderedListLabel; - static const unorderedList = BusyMarkEditorShortcuts.unorderedListLabel; - static const taskList = BusyMarkEditorShortcuts.taskListLabel; - static const indent = BusyMarkEditorShortcuts.indentLabel; - static const outdent = BusyMarkEditorShortcuts.outdentLabel; - 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; -} - -abstract final class BusyMarkEditorShortcutActivators { - const BusyMarkEditorShortcutActivators._(); - - static Map intentMap( - Intent Function(BusyMarkEditorShortcutAction action) intentFor, - ) { - return { - for (final entry in BusyMarkEditorShortcuts.definitions.entries) - entry.value.activator: intentFor(entry.key), - }; - } - - static BusyMarkEditorShortcutAction? actionForKeyEvent( - KeyEvent event, - HardwareKeyboard keyboard, - ) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return null; - } - for (final entry in BusyMarkEditorShortcuts.definitions.entries) { - if (entry.value.activator.accepts(event, keyboard)) { - return entry.key; - } - } - return null; - } - - static ShortcutActivator get refineWithAi => - BusyMarkEditorShortcuts.refineWithAi.activator; - static ShortcutActivator get bold => BusyMarkEditorShortcuts.bold.activator; - static ShortcutActivator get italic => - BusyMarkEditorShortcuts.italic.activator; - static ShortcutActivator get underline => - BusyMarkEditorShortcuts.underline.activator; - static ShortcutActivator get link => BusyMarkEditorShortcuts.link.activator; - static ShortcutActivator get inlineCode => - BusyMarkEditorShortcuts.inlineCode.activator; - static ShortcutActivator get strikethrough => - BusyMarkEditorShortcuts.strikethrough.activator; - static ShortcutActivator get paragraph => - BusyMarkEditorShortcuts.paragraph.activator; - static ShortcutActivator get heading1 => - BusyMarkEditorShortcuts.heading1.activator; - static ShortcutActivator get heading2 => - BusyMarkEditorShortcuts.heading2.activator; - static ShortcutActivator get heading3 => - BusyMarkEditorShortcuts.heading3.activator; - static ShortcutActivator get heading4 => - BusyMarkEditorShortcuts.heading4.activator; - static ShortcutActivator get heading5 => - BusyMarkEditorShortcuts.heading5.activator; - static ShortcutActivator get heading6 => - BusyMarkEditorShortcuts.heading6.activator; - static ShortcutActivator get orderedList => - BusyMarkEditorShortcuts.orderedList.activator; - static ShortcutActivator get unorderedList => - BusyMarkEditorShortcuts.unorderedList.activator; - static ShortcutActivator get taskList => - BusyMarkEditorShortcuts.taskList.activator; - static ShortcutActivator get indent => - BusyMarkEditorShortcuts.indent.activator; - static ShortcutActivator get outdent => - BusyMarkEditorShortcuts.outdent.activator; - static ShortcutActivator get blockquote => - BusyMarkEditorShortcuts.blockquote.activator; - 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, @@ -931,66 +688,6 @@ abstract final class BusyMarkSidebarShortcuts { }; } -abstract final class BusyMarkSidebarShortcutLabels { - const BusyMarkSidebarShortcutLabels._(); - - static const toggleSidebar = BusyMarkAppShortcuts.toggleSidebarLabel; - static const files = BusyMarkSidebarShortcuts.filesLabel; - 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 { - const BusyMarkSidebarShortcutActivators._(); - - static ShortcutActivator get toggleSidebar => - BusyMarkSidebarShortcuts.toggleSidebar.activator; - static ShortcutActivator get files => - BusyMarkSidebarShortcuts.files.activator; - static ShortcutActivator get toc => BusyMarkSidebarShortcuts.toc.activator; - 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, - HardwareKeyboard keyboard, - ) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return null; - } - if (!keyboard.isControlPressed || - keyboard.isShiftPressed || - keyboard.isAltPressed || - keyboard.isMetaPressed) { - return null; - } - return switch (event.logicalKey) { - LogicalKeyboardKey.digit1 || - LogicalKeyboardKey.numpad1 => BusyMarkSidebarShortcutAction.files, - LogicalKeyboardKey.digit2 || - LogicalKeyboardKey.numpad2 => BusyMarkSidebarShortcutAction.toc, - LogicalKeyboardKey.digit3 || - LogicalKeyboardKey.numpad3 => BusyMarkSidebarShortcutAction.outline, - LogicalKeyboardKey.digit4 || - LogicalKeyboardKey.numpad4 => BusyMarkSidebarShortcutAction.git, - LogicalKeyboardKey.digit5 || - LogicalKeyboardKey.numpad5 => BusyMarkSidebarShortcutAction.localHistory, - LogicalKeyboardKey.digit6 || LogicalKeyboardKey.numpad6 => - BusyMarkSidebarShortcutAction.clipboardHistory, - _ => null, - }; - } -} - enum BusyMarkTreeShortcutAction { deleteSelection } abstract final class BusyMarkTreeShortcuts { @@ -1008,16 +705,3 @@ abstract final class BusyMarkTreeShortcuts { BusyMarkTreeShortcutAction.deleteSelection: deleteSelection, }; } - -abstract final class BusyMarkTreeShortcutLabels { - const BusyMarkTreeShortcutLabels._(); - - static const deleteSelection = BusyMarkTreeShortcuts.deleteSelectionLabel; -} - -abstract final class BusyMarkTreeShortcutActivators { - const BusyMarkTreeShortcutActivators._(); - - static ShortcutActivator get deleteSelection => - BusyMarkTreeShortcuts.deleteSelection.activator; -} diff --git a/lib/src/app/command_palette.dart b/lib/src/app/command_palette.dart deleted file mode 100644 index 131985f2..00000000 --- a/lib/src/app/command_palette.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import '../platform/linux_header_bar_service.dart'; -import 'busymark_design.dart'; -import 'busymark_dialogs.dart'; -import 'busymark_glyphs.dart'; -import 'command_registry.dart'; -import 'localization.dart'; - -Future showBusyMarkCommandPalette( - BuildContext context, - BusyMarkCommandRegistry registry, -) async { - final headerBar = LinuxHeaderBarService.instance; - final commandTarget = FocusManager.instance.primaryFocus?.context; - await showBusyMarkModalDialog( - context, - headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (context) => _BusyMarkCommandPalette( - registry: registry, - commandTarget: commandTarget, - ), - ); -} - -class _BusyMarkCommandPalette extends StatefulWidget { - const _BusyMarkCommandPalette({ - required this.registry, - required this.commandTarget, - }); - - final BusyMarkCommandRegistry registry; - final BuildContext? commandTarget; - - @override - State<_BusyMarkCommandPalette> createState() => - _BusyMarkCommandPaletteState(); -} - -class _BusyMarkCommandPaletteState extends State<_BusyMarkCommandPalette> { - var _query = ''; - - List get _commands { - final query = _query.trim().toLowerCase(); - return widget.registry.visibleCommands().where((command) { - if (query.isEmpty) { - return true; - } - return command.label(context).toLowerCase().contains(query) || - command.category(context).toLowerCase().contains(query) || - command.id.toLowerCase().contains(query); - }).toList(); - } - - @override - Widget build(BuildContext context) { - final commands = _commands; - return BusyMarkDialogShell( - title: context.l10n.commandPalette, - maxWidth: BusyMarkSizes.dialogCompact, - children: [ - BusyMarkGroupedList( - filled: true, - children: [ - BusyMarkGroupedTextEntry( - label: context.l10n.commandPaletteHint, - autofocus: true, - onChanged: (value) => setState(() => _query = value), - onSubmitted: (_) { - if (commands.isNotEmpty && _enabled(commands.first)) { - _execute(commands.first); - } - }, - ), - ], - ), - const SizedBox(height: BusyMarkSpacing.md), - if (commands.isEmpty) - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: Text( - context.l10n.commandPaletteEmpty, - textAlign: TextAlign.center, - ), - ) - else - BusyMarkGroupedList( - filled: true, - children: [ - for (final command in commands) - BusyMarkActionRow( - title: command.label(context), - subtitle: _enabled(command) - ? command.category(context) - : command.disabledReason?.call(context) ?? - command.category(context), - leading: const Icon(BusyMarkGlyphs.search), - trailing: command.shortcut == null - ? null - : Text(command.shortcut!.label), - enabled: _enabled(command), - onTap: _enabled(command) ? () => _execute(command) : null, - ), - ], - ), - ], - ); - } - - void _execute(BusyMarkCommand command) { - Navigator.pop(context); - unawaited( - Future.microtask( - () => - widget.registry.executeInContext(command.id, widget.commandTarget), - ), - ); - } - - bool _enabled(BusyMarkCommand command) { - return widget.registry.canExecuteInContext( - command.id, - widget.commandTarget, - ); - } -} diff --git a/lib/src/app/command_presentation.dart b/lib/src/app/command_presentation.dart new file mode 100644 index 00000000..674bb42b --- /dev/null +++ b/lib/src/app/command_presentation.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +import 'command_registry.dart'; + +/// Preserve the reference's category order, then include its previously omitted +/// unbound categories. Overrides never change the grouping order. +List commandCategoryOrder(BuildContext context) { + final commands = BusyMarkCommandCatalog.metadata.commands; + return { + for (final c in commands) + if (c.shortcut != null && c.scope != BusyMarkCommandScope.sourceSymbol) + c.category(context), + for (final c in commands) c.category(context), + }.toList(growable: false); +} + +int? commandMatchRank({ + required String name, + required String category, + String? description, + required String id, + required String query, +}) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return 0; + final title = name.toLowerCase(); + if (title == q) return 0; + if (title.startsWith(q)) return 1; + final tokens = q.split(RegExp(r'\s+')); + final words = title.split(RegExp(r'[\s\p{P}]+', unicode: true)); + if (tokens.every((t) => words.any((w) => w.startsWith(t)))) return 2; + if (title.contains(q)) return 3; + final text = + '$title ${category.toLowerCase()} ${description?.toLowerCase() ?? ''}'; + if (tokens.every(text.contains)) return 4; + return id.toLowerCase().contains(q) ? 5 : null; +} diff --git a/lib/src/app/command_registry.dart b/lib/src/app/command_registry.dart index 2fa9cc70..8309a05f 100644 --- a/lib/src/app/command_registry.dart +++ b/lib/src/app/command_registry.dart @@ -1,9 +1,13 @@ +import '../editor/wysiwyg/wysiwyg_commands.dart'; +import '../markdown/busymark_document.dart'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'busymark_shortcuts.dart'; +import 'shortcut_binding.dart'; +import 'shortcut_capture.dart'; import 'localization.dart'; typedef BusyMarkCommandCallback = FutureOr Function(); @@ -28,10 +32,12 @@ class BusyMarkContextCommandAction BusyMarkContextCommandAction({ required this.isCommandEnabled, required this.onCommand, + this.captureTarget, }); final bool Function(String commandId) isCommandEnabled; final void Function(String commandId) onCommand; + final BusyMarkContextCommandSnapshot Function()? captureTarget; @override bool isEnabled(BusyMarkContextCommandIntent intent) { @@ -45,6 +51,14 @@ class BusyMarkContextCommandAction } } +/// Editors capture their actual selection before the query receives focus. +/// Validation reads that editor again; it never restores a different selection. +class BusyMarkContextCommandSnapshot { + const BusyMarkContextCommandSnapshot(this._validate); + final bool Function() _validate; + bool get isCurrent => _validate(); +} + enum BusyMarkCommandScope { application, documentView, @@ -52,8 +66,12 @@ enum BusyMarkCommandScope { editor, sidebar, tree, + sourceSymbol, + wysiwyg, } +enum BusyMarkCommandResourceContext { any, localWorkspace, notesWorkspace } + @immutable class BusyMarkCommand { const BusyMarkCommand({ @@ -62,6 +80,8 @@ class BusyMarkCommand { required this.category, required this.scope, this.shortcut, + this.defaultYieldsToOverrides = false, + this.resourceContext = BusyMarkCommandResourceContext.any, this.description, this.disabledReason, this.execute, @@ -74,6 +94,24 @@ class BusyMarkCommand { final BusyMarkCommandLabel category; final BusyMarkCommandScope scope; final BusyMarkShortcutDefinition? shortcut; + final bool defaultYieldsToOverrides; + final BusyMarkCommandResourceContext resourceContext; + + BusyMarkCommand withShortcut(BusyMarkShortcutDefinition? value) => + BusyMarkCommand( + id: id, + label: label, + category: category, + scope: scope, + shortcut: value, + defaultYieldsToOverrides: defaultYieldsToOverrides, + resourceContext: resourceContext, + description: description, + disabledReason: disabledReason, + execute: execute, + enabled: enabled, + visible: visible, + ); final BusyMarkCommandDescription? description; final BusyMarkCommandDescription? disabledReason; final BusyMarkCommandCallback? execute; @@ -127,9 +165,155 @@ class BusyMarkCommandRegistry { : commands = List.unmodifiable(commands) { _validate(this.commands); _byId = {for (final command in this.commands) command.id: command}; + for (final c in this.commands) { + _aliases[c.id] = _defaultAliases(c); + } + _compileKeyCandidates(); } final List commands; + final Map> _aliases = {}; + final Map<(BusyMarkCommandScope, LogicalKeyboardKey), List> + _keyCandidates = {}; + void _compileKeyCandidates() { + _keyCandidates.clear(); + for (final c in commands) { + for (final activator in activators(c.id)) { + for (final trigger in activator.triggers ?? []) { + (_keyCandidates[(c.scope, trigger)] ??= []).add(c.id); + } + } + } + } + + final Map _replacedDefaults = {}; + final Map> _retiredBindings = {}; + final Map> _blockedBindings = {}; + List _blockedFor(Set scopes) { + final key = scopes.fold(0, (int bits, scope) => bits | 1 << scope.index); + return _blockedBindings.putIfAbsent(key, () { + final receivers = { + ...scopes, + BusyMarkCommandScope.application, + BusyMarkCommandScope.documentView, + BusyMarkCommandScope.sidebar, + }; + bool replaced(BusyMarkCommand old, ShortcutActivator retired) => + commands.any( + (c) => + receivers.contains(c.scope) && + contextsOverlap(old, c) && + activators(c.id).any( + (a) => a is SingleActivator && retired is SingleActivator + ? BusyMarkKeyBinding.fromActivator(a) == + BusyMarkKeyBinding.fromActivator(retired) + : a == retired, + ), + ); + return [ + for (final c in _replacedDefaults.values) + if (scopes.contains(c.scope)) + for (final a in _retiredBindings[c.id] ?? []) + if (!replaced(c, a)) a, + ]; + }); + } + + Map blockedDefaultIntents( + Set scopes, + ) => { + for (final a in _blockedFor(scopes)) + a: const DoNothingAndStopPropagationIntent(), + }; + bool blocksReplacedDefault( + KeyEvent event, + HardwareKeyboard keyboard, + Set scopes, + ) => _blockedFor(scopes).any((a) => a.accepts(event, keyboard)); + List activators(String id) => [ + if (_byId[id]?.shortcut case final value?) value.activator, + ...?_aliases[id], + ]; + + BusyMarkCommandRegistry withOverrides( + Map overrides, + ) { + final result = BusyMarkCommandRegistry([ + for (final command in commands) + if (!overrides.containsKey(command.id)) + command + else + command.withShortcut(switch (overrides[command.id]) { + final binding? => BusyMarkShortcutDefinition( + label: binding.label, + activator: binding.activator, + gtkAccelerator: binding.gtkAccelerator, + ), + _ => null, + }), + ]); + result._aliases.clear(); + for (final command in result.commands) { + if (overrides.containsKey(command.id)) { + result._replacedDefaults[command.id] = this[command.id]!; + result._retiredBindings[command.id] = activators(command.id); + } else { + result._aliases[command.id] = _defaultAliases(command); + } + } + result._compileKeyCandidates(); + return result; + } + + Map bindingIntents(String id, Intent intent) => { + for (final activator in activators(id)) activator: intent, + }; + + /// Actual receiving contexts overlap across scopes. Tree browsing is the one + /// disjoint document context; line movement edits the Source .tree buffer. + static bool contextsOverlap(BusyMarkCommand a, BusyMarkCommand b) { + if (a.resourceContext != BusyMarkCommandResourceContext.any && + b.resourceContext != BusyMarkCommandResourceContext.any && + a.resourceContext != b.resourceContext) { + return false; + } + bool global(BusyMarkCommand c) => { + BusyMarkCommandScope.application, + BusyMarkCommandScope.documentView, + BusyMarkCommandScope.sidebar, + }.contains(c.scope); + bool treeOnly(BusyMarkCommand c) => + c.id == BusyMarkCommandIds.treeDeleteSelection || + c.id == BusyMarkCommandIds.treeRenameSelection; + if (global(a) || global(b)) return true; + bool sourceOnly(BusyMarkCommand c) => + c.scope == BusyMarkCommandScope.sourceSymbol || + c.id == BusyMarkCommandIds.treeMoveLineUp || + c.id == BusyMarkCommandIds.treeMoveLineDown; + if (a.scope == BusyMarkCommandScope.wysiwyg && sourceOnly(b) || + b.scope == BusyMarkCommandScope.wysiwyg && sourceOnly(a)) { + return false; + } + if (treeOnly(a) || treeOnly(b)) { + return treeOnly(a) && treeOnly(b); + } + return true; + } + + List conflicts(String id, BusyMarkKeyBinding binding) { + final command = this[id]; + if (command == null) return const []; + return [ + for (final other in commands) + if (other.id != id && + contextsOverlap(command, other) && + activators(other.id).whereType().any( + (a) => BusyMarkKeyBinding.fromActivator(a) == binding, + )) + other, + ]; + } + late final Map _byId; BusyMarkCommand? operator [](String id) => _byId[id]; @@ -153,7 +337,7 @@ class BusyMarkCommandRegistry { if (command == null || command.execute == null) { return false; } - if (!_isContextual(command) || contextTarget == null) { + if (!isContextual(command) || contextTarget == null) { return command.enabled(); } final action = Actions.maybeFind( @@ -167,7 +351,7 @@ class BusyMarkCommandRegistry { if (command == null || !canExecuteInContext(id, contextTarget)) { return false; } - if (_isContextual(command) && contextTarget != null) { + if (isContextual(command) && contextTarget != null) { Actions.maybeInvoke(contextTarget, BusyMarkContextCommandIntent(id)); return true; } @@ -175,15 +359,46 @@ class BusyMarkCommandRegistry { return true; } - bool _isContextual(BusyMarkCommand command) { + bool isContextual(BusyMarkCommand command) { return command.scope == BusyMarkCommandScope.editor || + command.scope == BusyMarkCommandScope.wysiwyg || command.scope == BusyMarkCommandScope.textEditing || + command.scope == BusyMarkCommandScope.sourceSymbol || + command.scope == BusyMarkCommandScope.tree || command.id == BusyMarkCommandIds.treeMoveLineUp || command.id == BusyMarkCommandIds.treeMoveLineDown; } + bool requiresDocumentTarget(BusyMarkCommand command) => + isContextual(command) || + command.scope == BusyMarkCommandScope.documentView || + const { + BusyMarkCommandIds.search, + BusyMarkCommandIds.save, + BusyMarkCommandIds.export, + BusyMarkCommandIds.closeTab, + BusyMarkCommandIds.elementProperties, + BusyMarkCommandIds.references, + BusyMarkCommandIds.sidebarOutline, + }.contains(command.id); + + bool requiresWorkspaceTarget(BusyMarkCommand command) => + command.scope == BusyMarkCommandScope.sidebar || + const { + BusyMarkCommandIds.localHistory, + BusyMarkCommandIds.findLocalHistory, + BusyMarkCommandIds.syncNotes, + BusyMarkCommandIds.exportNotes, + BusyMarkCommandIds.importNotesFolder, + BusyMarkCommandIds.importNotesFile, + BusyMarkCommandIds.nextTab, + BusyMarkCommandIds.previousTab, + BusyMarkCommandIds.closeAllTabs, + }.contains(command.id); + bool shortcutAccepts(String id, KeyEvent event, HardwareKeyboard keyboard) { - return _byId[id]?.shortcut?.activator.accepts(event, keyboard) ?? false; + return !BusyMarkShortcutCapture.suppressing && + activators(id).any((a) => a.accepts(event, keyboard)); } String? matchingCommandId( @@ -191,11 +406,9 @@ class BusyMarkCommandRegistry { HardwareKeyboard keyboard, { required BusyMarkCommandScope scope, }) { - for (final command in commands) { - if (command.scope == scope && - command.shortcut?.activator.accepts(event, keyboard) == true) { - return command.id; - } + if (BusyMarkShortcutCapture.suppressing) return null; + for (final id in _keyCandidates[(scope, event.logicalKey)] ?? []) { + if (shortcutAccepts(id, event, keyboard)) return id; } return null; } @@ -203,17 +416,21 @@ class BusyMarkCommandRegistry { Map shortcutIntents({ required Set scopes, required Intent Function(String commandId) intentFor, + Set excludedIds = const {}, }) { return { for (final command in commands) - if (scopes.contains(command.scope) && command.shortcut != null) - command.shortcut!.activator: intentFor(command.id), + if (scopes.contains(command.scope) && + command.shortcut != null && + !excludedIds.contains(command.id)) + for (final activator in activators(command.id)) + activator: intentFor(command.id), }; } static void _validate(List commands) { final ids = {}; - final shortcuts = <(BusyMarkCommandScope, ShortcutActivator), String>{}; + final shortcuts = <(BusyMarkCommandScope, Object), List>{}; for (final command in commands) { if (!RegExp( r'^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$', @@ -231,13 +448,22 @@ class BusyMarkCommandRegistry { if (shortcut == null) { continue; } - final key = (command.scope, shortcut.activator); - if (shortcuts[key] case final existing?) { - throw BusyMarkCommandRegistryValidationException( - 'Shortcut conflict in ${command.scope.name}: $existing and ${command.id}', - ); + final activator = shortcut.activator; + final key = ( + command.scope, + activator is SingleActivator + ? BusyMarkKeyBinding.fromActivator(activator) + : activator, + ); + final existing = shortcuts[key] ??= []; + for (final other in existing) { + if (contextsOverlap(command, other)) { + throw BusyMarkCommandRegistryValidationException( + 'Shortcut conflict in ${command.scope.name}: ${other.id} and ${command.id}', + ); + } } - shortcuts[key] = command.id; + existing.add(command); } } } @@ -250,6 +476,16 @@ abstract final class BusyMarkCommandIds { static const fullScreen = 'view.fullScreen'; static const back = 'navigation.back'; static const search = 'search.find'; + static const searchWorkspace = 'search.workspace'; + static const searchHeadings = 'search.headings'; + static const searchTopics = 'search.topics'; + static const searchRecent = 'search.recent'; + static const newNote = 'notes.create'; + static const browseNotes = 'notes.browse'; + static const syncNotes = 'notes.sync'; + static const exportNotes = 'notes.export'; + static const importNotesFolder = 'notes.importFolder'; + static const importNotesFile = 'notes.importFile'; static const keyboardShortcuts = 'help.keyboardShortcuts'; static const quickOpen = 'workspace.quickOpen'; static const commandPalette = 'view.commandPalette'; @@ -286,12 +522,22 @@ abstract final class BusyMarkCommandIds { static const clipboardHistory = 'sidebar.clipboardHistory'; static const localHistory = 'history.local'; static const findLocalHistory = 'history.findLocal'; + static const sourceDeclaration = 'source.declaration'; + static const sourceUsages = 'source.usages'; + static const sourceRename = 'source.rename'; + static const treeRenameSelection = 'tree.renameSelection'; static const treeDeleteSelection = 'tree.deleteSelection'; static const treeMoveLineUp = 'tree.moveLineUp'; static const treeMoveLineDown = 'tree.moveLineDown'; } abstract final class BusyMarkCommandCatalog { + static const notesWorkspaceCommandIds = { + BusyMarkCommandIds.syncNotes, + BusyMarkCommandIds.exportNotes, + BusyMarkCommandIds.importNotesFolder, + BusyMarkCommandIds.importNotesFile, + }; static final BusyMarkCommandRegistry metadata = create(); static BusyMarkCommandRegistry create({ @@ -305,11 +551,15 @@ abstract final class BusyMarkCommandCatalog { required BusyMarkCommandLabel category, required BusyMarkCommandScope scope, BusyMarkShortcutDefinition? shortcut, + bool defaultYieldsToOverrides = false, BusyMarkCommandDescription? description, }) { final contextual = scope == BusyMarkCommandScope.editor || + scope == BusyMarkCommandScope.wysiwyg || scope == BusyMarkCommandScope.textEditing || + scope == BusyMarkCommandScope.sourceSymbol || + scope == BusyMarkCommandScope.tree || id == BusyMarkCommandIds.treeMoveLineUp || id == BusyMarkCommandIds.treeMoveLineDown; return BusyMarkCommand( @@ -318,6 +568,18 @@ abstract final class BusyMarkCommandCatalog { category: category, scope: scope, shortcut: shortcut, + defaultYieldsToOverrides: defaultYieldsToOverrides, + resourceContext: notesWorkspaceCommandIds.contains(id) + ? BusyMarkCommandResourceContext.notesWorkspace + : scope == BusyMarkCommandScope.sourceSymbol || + const { + BusyMarkCommandIds.treeDeleteSelection, + BusyMarkCommandIds.treeRenameSelection, + BusyMarkCommandIds.treeMoveLineUp, + BusyMarkCommandIds.treeMoveLineDown, + }.contains(id) + ? BusyMarkCommandResourceContext.localWorkspace + : BusyMarkCommandResourceContext.any, description: description, disabledReason: (context) => context.l10n.commandUnavailableInContext, execute: @@ -333,6 +595,141 @@ abstract final class BusyMarkCommandCatalog { } final commands = [ + command( + id: BusyMarkCommandIds.sourceDeclaration, + label: (c) => c.l10n.goToDeclaration, + category: (c) => 'Writerside', + scope: BusyMarkCommandScope.sourceSymbol, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+B', + activator: SingleActivator(LogicalKeyboardKey.keyB, control: true), + ), + ), + command( + id: BusyMarkCommandIds.sourceUsages, + label: (c) => c.l10n.findUsages, + category: (c) => 'Writerside', + scope: BusyMarkCommandScope.sourceSymbol, + shortcut: const BusyMarkShortcutDefinition( + label: 'Alt+F7', + activator: SingleActivator(LogicalKeyboardKey.f7, alt: true), + ), + ), + command( + id: BusyMarkCommandIds.sourceRename, + label: (c) => c.l10n.rename, + category: (c) => 'Writerside', + scope: BusyMarkCommandScope.sourceSymbol, + shortcut: const BusyMarkShortcutDefinition( + label: 'Shift+F6', + activator: SingleActivator(LogicalKeyboardKey.f6, shift: true), + ), + ), + command( + id: BusyMarkCommandIds.newNote, + label: (c) => c.l10n.nextcloudNewNote, + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + command( + id: BusyMarkCommandIds.browseNotes, + label: (c) => c.l10n.browseNotes, + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + command( + id: BusyMarkCommandIds.syncNotes, + label: (c) => c.l10n.notesSync, + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + command( + id: BusyMarkCommandIds.exportNotes, + label: (c) => c.l10n.notesExport, + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + command( + id: BusyMarkCommandIds.importNotesFolder, + label: (c) => '${c.l10n.notesImport} · ${c.l10n.folder}', + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + command( + id: BusyMarkCommandIds.importNotesFile, + label: (c) => '${c.l10n.notesImport} · ${c.l10n.fileTypeMarkdown}', + category: (c) => c.l10n.nextcloudNotes, + scope: BusyMarkCommandScope.application, + ), + for (final entry in writersideCommands.entries) + command( + id: entry.key, + label: (context) => writersideCommandLabel(context, entry.value), + category: (context) => 'Writerside', + scope: BusyMarkCommandScope.wysiwyg, + ), + command( + id: BusyMarkCommandIds.searchWorkspace, + label: (context) => context.l10n.searchInWorkspace, + category: (c) => c.l10n.search, + scope: BusyMarkCommandScope.application, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+F', + activator: SingleActivator( + LogicalKeyboardKey.keyF, + control: true, + shift: true, + ), + gtkAccelerator: 'f', + ), + ), + command( + id: BusyMarkCommandIds.searchHeadings, + label: (c) => c.l10n.searchHeadingsCommand, + category: (c) => c.l10n.search, + scope: BusyMarkCommandScope.application, + defaultYieldsToOverrides: true, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+H', + activator: SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ), + gtkAccelerator: 'h', + ), + ), + command( + id: BusyMarkCommandIds.searchTopics, + label: (context) => context.l10n.searchTopicsCommand, + category: (c) => c.l10n.search, + scope: BusyMarkCommandScope.application, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+T', + activator: SingleActivator( + LogicalKeyboardKey.keyT, + control: true, + shift: true, + ), + gtkAccelerator: 't', + ), + ), + command( + id: BusyMarkCommandIds.searchRecent, + label: (context) => context.l10n.searchRecentCommand, + category: (c) => c.l10n.search, + scope: BusyMarkCommandScope.application, + defaultYieldsToOverrides: true, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+R', + activator: SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + shift: true, + ), + gtkAccelerator: 'r', + ), + ), command( id: BusyMarkCommandIds.elementProperties, label: (context) => context.l10n.wsElementProperties, @@ -352,6 +749,8 @@ abstract final class BusyMarkCommandCatalog { category: (context) => _appCategory(context, action), scope: BusyMarkCommandScope.application, shortcut: BusyMarkAppShortcuts.definitions[action], + defaultYieldsToOverrides: + action == BusyMarkAppShortcutAction.quickOpen, description: (context) => _appDescription(context, action), ), for (final action in BusyMarkDocumentViewShortcutAction.values) @@ -445,6 +844,16 @@ abstract final class BusyMarkCommandCatalog { shortcut: BusyMarkSidebarShortcuts.definitions[action], description: (context) => _sidebarDescription(context, action), ), + command( + id: BusyMarkCommandIds.treeRenameSelection, + label: (c) => c.l10n.rename, + category: (c) => c.l10n.shortcutGroupSidebar, + scope: BusyMarkCommandScope.tree, + shortcut: const BusyMarkShortcutDefinition( + label: 'Shift+F6', + activator: SingleActivator(LogicalKeyboardKey.f6, shift: true), + ), + ), for (final action in BusyMarkTreeShortcutAction.values) command( id: 'tree.${action.name}', @@ -457,8 +866,18 @@ abstract final class BusyMarkCommandCatalog { ), command( id: BusyMarkCommandIds.findLocalHistory, + defaultYieldsToOverrides: true, + shortcut: const BusyMarkShortcutDefinition( + label: 'Ctrl+Shift+L', + activator: SingleActivator( + LogicalKeyboardKey.keyL, + control: true, + shift: true, + ), + gtkAccelerator: 'l', + ), label: (context) => context.l10n.findLocalHistoryEllipsis, - category: (context) => context.l10n.shortcutGroupSidebar, + category: (context) => context.l10n.search, scope: BusyMarkCommandScope.application, description: (context) => context.l10n.findLocalHistoryDescription, ), @@ -499,11 +918,12 @@ abstract final class BusyMarkCommandCatalog { BusyMarkAppShortcutAction.export => context.l10n.export, BusyMarkAppShortcutAction.fullScreen => context.l10n.fullScreen, BusyMarkAppShortcutAction.back => context.l10n.back, - BusyMarkAppShortcutAction.search => context.l10n.search, - BusyMarkAppShortcutAction.quickOpen => context.l10n.quickOpen, + BusyMarkAppShortcutAction.search => context.l10n.searchInDocument, + BusyMarkAppShortcutAction.quickOpen => context.l10n.searchDocumentsCommand, BusyMarkAppShortcutAction.keyboardShortcuts => context.l10n.keyboardShortcuts, - BusyMarkAppShortcutAction.commandPalette => context.l10n.commandPalette, + BusyMarkAppShortcutAction.commandPalette => + context.l10n.searchActionsCommand, BusyMarkAppShortcutAction.syntaxReference => context.l10n.syntaxReference, BusyMarkAppShortcutAction.settings => context.l10n.settings, BusyMarkAppShortcutAction.nextTab => context.l10n.shortcutNextTab, @@ -517,6 +937,9 @@ abstract final class BusyMarkCommandCatalog { BuildContext context, BusyMarkAppShortcutAction action, ) => switch (action) { + BusyMarkAppShortcutAction.search || + BusyMarkAppShortcutAction.quickOpen || + BusyMarkAppShortcutAction.commandPalette => context.l10n.search, BusyMarkAppShortcutAction.nextTab || BusyMarkAppShortcutAction.previousTab || BusyMarkAppShortcutAction.closeTab || @@ -764,3 +1187,105 @@ abstract final class BusyMarkCommandCatalog { } } } + +/// The same typed commands used by the consolidated Writerside toolbar. +final writersideCommands = { + for (final command in BusyWritersideInsertCommand.values) + writersideToolbarCommandId(BusyWritersideToolbarInsert(command)): + BusyWritersideToolbarInsert(command), + for (final style in BusyAdmonitionStyle.values) + writersideToolbarCommandId(BusyWritersideToolbarAdmonition(style)): + BusyWritersideToolbarAdmonition(style), + for (final command in [ + BusyWysiwygInlineCommand.uiControl, + BusyWysiwygInlineCommand.filePath, + BusyWysiwygInlineCommand.uiPath, + BusyWysiwygInlineCommand.shortcut, + ]) + writersideToolbarCommandId(BusyWritersideToolbarSemantic(command)): + BusyWritersideToolbarSemantic(command), +}; +String writersideToolbarCommandId( + BusyWritersideToolbarCommand value, +) => switch (value) { + BusyWritersideToolbarInsert(:final command) => + 'writerside.insert${command.name[0].toUpperCase()}${command.name.substring(1)}', + BusyWritersideToolbarAdmonition(:final style) => 'writerside.${style.name}', + BusyWritersideToolbarSemantic(:final command) => 'writerside.${command.name}', +}; +String writersideCommandLabel( + BuildContext context, + BusyWritersideToolbarCommand command, +) => switch (command) { + BusyWritersideToolbarInsert(:final command) => switch (command) { + BusyWritersideInsertCommand.procedure => context.l10n.procedure, + BusyWritersideInsertCommand.tabs => context.l10n.tabs, + BusyWritersideInsertCommand.definitionList => context.l10n.wsDefinitionList, + BusyWritersideInsertCommand.include => context.l10n.wsIncludeContent, + BusyWritersideInsertCommand.variable => context.l10n.wsVariableReference, + BusyWritersideInsertCommand.video => context.l10n.video, + BusyWritersideInsertCommand.tldr => context.l10n.wsTldr, + BusyWritersideInsertCommand.convertListToProcedure => + context.l10n.wsConvertList, + }, + BusyWritersideToolbarAdmonition(:final style) => switch (style) { + BusyAdmonitionStyle.tip => context.l10n.tip, + BusyAdmonitionStyle.note => context.l10n.note, + BusyAdmonitionStyle.warning => context.l10n.warning, + BusyAdmonitionStyle.quote => context.l10n.quote, + }, + BusyWritersideToolbarSemantic(:final command) => switch (command) { + BusyWysiwygInlineCommand.uiControl => context.l10n.wsUiControl, + BusyWysiwygInlineCommand.filePath => context.l10n.wsFilePath, + BusyWysiwygInlineCommand.uiPath => context.l10n.wsUiPath, + _ => context.l10n.keyboardShortcuts, + }, +}; + +final _keypadAliases = { + LogicalKeyboardKey.digit0: LogicalKeyboardKey.numpad0, + LogicalKeyboardKey.digit1: LogicalKeyboardKey.numpad1, + LogicalKeyboardKey.digit2: LogicalKeyboardKey.numpad2, + LogicalKeyboardKey.digit3: LogicalKeyboardKey.numpad3, + LogicalKeyboardKey.digit4: LogicalKeyboardKey.numpad4, + LogicalKeyboardKey.digit5: LogicalKeyboardKey.numpad5, + LogicalKeyboardKey.digit6: LogicalKeyboardKey.numpad6, +}; + +List _defaultAliases(BusyMarkCommand command) { + final shortcut = command.shortcut; + if (shortcut == null) return const []; + final aliases = [ + ...?const >{ + BusyMarkCommandIds.textCopy: [ + SingleActivator(LogicalKeyboardKey.insert, control: true), + ], + BusyMarkCommandIds.textCut: [ + SingleActivator(LogicalKeyboardKey.delete, shift: true), + ], + BusyMarkCommandIds.textPaste: [ + SingleActivator(LogicalKeyboardKey.insert, shift: true), + ], + }[command.id], + ]; + final a = shortcut.activator; + if (a is SingleActivator && + (command.scope == BusyMarkCommandScope.sidebar || + command.id == BusyMarkCommandIds.localHistory || + command.id == BusyMarkCommandIds.clipboardHistory)) { + final key = _keypadAliases[a.trigger]; + if (key != null) { + aliases.add( + SingleActivator( + key, + control: a.control, + shift: a.shift, + alt: a.alt, + meta: a.meta, + includeRepeats: false, + ), + ); + } + } + return aliases; +} diff --git a/lib/src/app/command_shortcuts.dart b/lib/src/app/command_shortcuts.dart new file mode 100644 index 00000000..de723243 --- /dev/null +++ b/lib/src/app/command_shortcuts.dart @@ -0,0 +1,86 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'command_registry.dart'; + +/// A disabled command still owns its combination in this context. +/// It must not fall through to a different platform text-editing operation. +class BusyMarkCommandShortcuts extends StatefulWidget { + const BusyMarkCommandShortcuts({ + super.key, + required this.shortcuts, + required this.child, + this.isComposing, + }); + final Map shortcuts; + final Widget child; + final bool Function()? isComposing; + @override + State createState() => + _BusyMarkCommandShortcutsState(); +} + +class _BusyMarkCommandShortcutsState extends State { + late final _CommandShortcutManager _manager = _CommandShortcutManager( + widget.shortcuts, + widget.isComposing, + ); + @override + void didUpdateWidget(BusyMarkCommandShortcuts oldWidget) { + super.didUpdateWidget(oldWidget); + _manager.update(widget.shortcuts, widget.isComposing); + } + + @override + void dispose() { + _manager.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + Shortcuts.manager(manager: _manager, child: widget.child); +} + +class _CommandShortcutManager extends ShortcutManager { + _CommandShortcutManager( + Map bindings, + bool Function()? isComposing, + ) { + update(bindings, isComposing); + } + bool Function()? _isComposing; + final _commandKeys = >{}; + void update( + Map bindings, + bool Function()? isComposing, + ) { + _isComposing = isComposing; + shortcuts = bindings; + _commandKeys.clear(); + for (final entry in bindings.entries) { + if (entry.value is! BusyMarkContextCommandIntent && + entry.value is! BusyMarkCommandIntent) { + continue; + } + for (final key in entry.key.triggers ?? []) { + (_commandKeys[key] ??= []).add(entry.key); + } + } + } + + @override + KeyEventResult handleKeypress(BuildContext context, KeyEvent event) { + if (_isComposing?.call() ?? false) { + return KeyEventResult.skipRemainingHandlers; + } + final result = super.handleKeypress(context, event); + if (result == KeyEventResult.ignored && + (_commandKeys[event.logicalKey] ?? const []).any( + (a) => a.accepts(event, HardwareKeyboard.instance), + )) { + return KeyEventResult.handled; + } + return result; + } +} diff --git a/lib/src/app/keyboard_shortcut_badge.dart b/lib/src/app/keyboard_shortcut_badge.dart new file mode 100644 index 00000000..0a360d93 --- /dev/null +++ b/lib/src/app/keyboard_shortcut_badge.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'busymark_design.dart'; + +class BusyMarkKeyboardShortcutBadge extends StatelessWidget { + const BusyMarkKeyboardShortcutBadge(this.label, {super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: colors.control, + borderRadius: BorderRadius.circular(BusyMarkRadius.sm), + border: Border.all(color: colors.subtleBorder), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + vertical: BusyMarkSpacing.xxs, + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Text( + // Let a narrow/scaled badge wrap between modifiers, rather than + // splitting a name such as Shift. Identity never comes from text. + label.replaceAll('+', '+\u200B'), + semanticsLabel: label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontFamily: BusyMarkTypography.monoFontFamily, + fontFamilyFallback: BusyMarkTypography.monoFontFamilyFallback, + color: colors.foreground, + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/app/keymap_controller.dart b/lib/src/app/keymap_controller.dart new file mode 100644 index 00000000..5f139518 --- /dev/null +++ b/lib/src/app/keymap_controller.dart @@ -0,0 +1,269 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app_settings.dart'; +import 'command_registry.dart'; +import 'shortcut_binding.dart'; + +enum KeymapUpdateStatus { saved, failed, changed, conflict, invalid } + +class KeymapUpdate { + const KeymapUpdate(this.status, [this.conflicts = const []]); + final KeymapUpdateStatus status; + final List conflicts; +} + +/// Validation and publication are synchronous after hydration. A stale editor +/// can never publish its old copy of the entire map over a newer assignment. +class KeymapController extends Notifier { + @override + KeymapUpdateStatus build() { + ref.listen(shortcutPersistenceOutcomeProvider, (_, saved) { + if (saved != null) { + state = saved ? KeymapUpdateStatus.saved : KeymapUpdateStatus.failed; + } + }); + return KeymapUpdateStatus.saved; + } + + int _generation = 0; + + Future update( + String commandId, + BusyMarkKeyBinding? binding, { + required Map expected, + bool reset = false, + Set reassign = const {}, + }) async { + final settings = ref.read(appSettingsControllerProvider.notifier); + await settings.waitUntilLoaded(); + if (!ref.mounted) return const KeymapUpdate(KeymapUpdateStatus.changed); + final current = ref.read(appSettingsControllerProvider).shortcutOverrides; + if (!mapEquals(current, expected)) { + return const KeymapUpdate(KeymapUpdateStatus.changed); + } + final defaults = BusyMarkCommandCatalog.metadata; + final command = defaults[commandId]; + if (command == null || !shortcutConfigurable(command)) { + return const KeymapUpdate(KeymapUpdateStatus.invalid); + } + final candidate = reset ? defaultBinding(command) : binding; + if (!reset && + candidate != null && + !shortcutAssignmentAllowed(command, candidate)) { + return const KeymapUpdate(KeymapUpdateStatus.invalid); + } + final effective = effectiveCommandRegistry(defaults, current); + final candidates = reset + ? defaults + .activators(commandId) + .whereType() + .map(BusyMarkKeyBinding.fromActivator) + : [if (candidate != null) candidate]; + final conflicts = { + for (final value in candidates) + for (final other in effective.conflicts(commandId, value)) + other.id: other, + }.values.toList(growable: false); + if (!setEquals(conflicts.map((c) => c.id).toSet(), reassign) && + conflicts.isNotEmpty) { + return KeymapUpdate(KeymapUpdateStatus.conflict, conflicts); + } + final next = validatedShortcutOverrides(defaults, current); + for (final c in conflicts) { + next[c.id] = null; + } + if (reset) { + next.remove(commandId); + } else { + next[commandId] = binding; + } + final generation = ++_generation; + final saved = await settings.saveShortcutOverrides(next); + if (ref.mounted && generation == _generation) { + state = saved ? KeymapUpdateStatus.saved : KeymapUpdateStatus.failed; + } + return KeymapUpdate( + saved ? KeymapUpdateStatus.saved : KeymapUpdateStatus.failed, + ); + } + + Future resetAll() async { + final settings = ref.read(appSettingsControllerProvider.notifier); + await settings.waitUntilLoaded(); + final generation = ++_generation; + final saved = await settings.saveShortcutOverrides(const {}); + if (ref.mounted && generation == _generation) { + state = saved ? KeymapUpdateStatus.saved : KeymapUpdateStatus.failed; + } + return saved; + } + + Future retry() async { + final generation = ++_generation; + final saved = await ref + .read(appSettingsControllerProvider.notifier) + .retryShortcutSave(); + if (ref.mounted && generation == _generation) { + state = saved ? KeymapUpdateStatus.saved : KeymapUpdateStatus.failed; + } + } +} + +final keymapControllerProvider = + NotifierProvider( + KeymapController.new, + ); + +BusyMarkKeyBinding? defaultBinding(BusyMarkCommand command) => + switch (command.shortcut?.activator) { + final SingleActivator a => BusyMarkKeyBinding.fromActivator(a), + _ => null, + }; + +/// These are local input/traversal operations, not global assignments. Their +/// existing context-specific behavior remains local to each editor/widget. +bool shortcutConfigurable(BusyMarkCommand command) => !const { + BusyMarkCommandIds.textInsertIndentation, + BusyMarkCommandIds.textOutdentSource, + BusyMarkCommandIds.textEscape, +}.contains(command.id); + +/// Invalid, unknown or conflicting persisted records are ignored individually. +/// Defaults are never mutated. No unvalidated entry becomes executable. +BusyMarkCommandRegistry effectiveCommandRegistry( + BusyMarkCommandRegistry defaults, + Map overrides, +) { + final valid = validatedShortcutOverrides(defaults, overrides); + final deferred = deferredDefaultConflicts(defaults, valid); + return valid.isEmpty && deferred.isEmpty + ? defaults + : defaults.withOverrides({ + ...valid, + for (final id in deferred.keys) id: null, + }); +} + +/// Newly supplied defaults must not take a previously saved user binding. +/// This is derived runtime state, never an extra persisted override. Settings +/// exposes these conflicts and resolves them through the usual transaction. +Map> deferredDefaultConflicts( + BusyMarkCommandRegistry defaults, + Map overrides, +) { + final result = >{}; + for (final command in defaults.commands) { + final binding = defaultBinding(command); + if (!command.defaultYieldsToOverrides || + overrides.containsKey(command.id) || + binding == null) { + continue; + } + final conflicts = []; + for (final entry in overrides.entries) { + final other = defaults[entry.key]; + if (other != null && + entry.value == binding && + shortcutAssignmentAllowed(other, binding) && + BusyMarkCommandRegistry.contextsOverlap(command, other)) { + conflicts.add(other); + } + } + if (conflicts.isNotEmpty) result[command.id] = conflicts; + } + return result; +} + +/// Resolve all assignments together: a later override may free an earlier +/// command's default. Conflicting loaded custom records are rejected together, +/// then checked again against any defaults restored by that rejection. +Map validatedShortcutOverrides( + BusyMarkCommandRegistry defaults, + Map overrides, +) { + final valid = {}; + for (final e in overrides.entries) { + final command = defaults[e.key]; + if (command != null && + shortcutConfigurable(command) && + (e.value == null || shortcutAssignmentAllowed(command, e.value!))) { + valid[e.key] = e.value; + } + } + while (true) { + final deferred = deferredDefaultConflicts(defaults, valid); + final bindings = >{}; + for (final command in defaults.commands) { + final effective = valid.containsKey(command.id) + ? [if (valid[command.id] case final value?) value] + : [ + if (!deferred.containsKey(command.id)) + for (final a + in defaults + .activators(command.id) + .whereType()) + BusyMarkKeyBinding.fromActivator(a), + ]; + for (final binding in effective) { + (bindings[binding] ??= []).add(command); + } + } + final rejected = {}; + for (final commands in bindings.values) { + for (var i = 0; i < commands.length; i++) { + for (var j = i + 1; j < commands.length; j++) { + final a = commands[i], b = commands[j]; + if (a.id == b.id || !BusyMarkCommandRegistry.contextsOverlap(a, b)) { + continue; + } + if (valid[a.id] != null) rejected.add(a.id); + if (valid[b.id] != null) rejected.add(b.id); + } + } + } + if (rejected.isEmpty) return valid; + for (final id in rejected) { + valid.remove(id); + } + } +} + +bool shortcutAssignmentAllowed( + BusyMarkCommand command, + BusyMarkKeyBinding binding, +) { + if (BusyMarkKeyBinding.fromJson(binding.toJson()) != binding) return false; + if (BusyMarkKeyBinding.modifierKeys.contains(binding.key)) return false; + if (binding == defaultBinding(command)) return true; + final key = binding.key; + if (key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.contextMenu || + key == LogicalKeyboardKey.f10 && + binding.shift && + !binding.control && + !binding.alt && + !binding.meta || + binding.control && + !binding.alt && + !binding.meta && + { + LogicalKeyboardKey.space, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + }.contains(key)) { + return false; + } + if (binding.control || binding.alt || binding.meta) return true; + // Preserve local tree deletion; ordinary typing, traversal and dismissal + // cannot become application-wide assignments. + return (command.scope == BusyMarkCommandScope.tree && + binding.key == LogicalKeyboardKey.delete) || + binding.key.keyId >= LogicalKeyboardKey.f1.keyId && + binding.key.keyId <= LogicalKeyboardKey.f24.keyId; +} diff --git a/lib/src/app/linux/linux_page_frame.dart b/lib/src/app/linux/linux_page_frame.dart index 7cd74deb..8900120b 100644 --- a/lib/src/app/linux/linux_page_frame.dart +++ b/lib/src/app/linux/linux_page_frame.dart @@ -18,6 +18,7 @@ class LinuxPageFrame extends StatefulWidget { this.sidebarBody, this.sidebarAvailable = false, this.sidebarExpanded = false, + this.sidebarOverlay = false, this.sidebarTransitionGeneration = 0, }); @@ -27,6 +28,7 @@ class LinuxPageFrame extends StatefulWidget { final Widget? sidebarBody; final bool sidebarAvailable; final bool sidebarExpanded; + final bool sidebarOverlay; final int sidebarTransitionGeneration; @override @@ -40,6 +42,9 @@ class _LinuxPageFrameState extends State animationBehavior: AnimationBehavior.preserve, value: _targetVisible ? 1 : 0, ); + final _bodyKey = GlobalKey(); + final _headerKey = GlobalKey(); + final _sidebarBodyKey = GlobalKey(); late int _generation; bool _disableAnimations = false; @@ -120,6 +125,10 @@ class _LinuxPageFrameState extends State final oppositeControlInset = sidebarAtLeft ? metrics.rightControlInset : metrics.leftControlInset; + final retainedSidebar = KeyedSubtree( + key: _sidebarBodyKey, + child: widget.sidebarBody ?? const SizedBox.shrink(), + ); final sidebar = _hasSidebar ? SizedBox( width: BusyMarkSizes.sidebarWidth, @@ -136,15 +145,18 @@ class _LinuxPageFrameState extends State child: widget.sidebarHeader!, ), ), - Expanded(child: widget.sidebarBody!), + Expanded(child: retainedSidebar), ], ), ), ) : null; final main = _LinuxFrameMain( - header: ColoredBox(color: colors.headerbarFlat, child: widget.header), - body: widget.body, + header: KeyedSubtree( + key: _headerKey, + child: ColoredBox(color: colors.headerbarFlat, child: widget.header), + ), + body: KeyedSubtree(key: _bodyKey, child: widget.body), ); return AnimatedBuilder( animation: _controller, @@ -165,6 +177,48 @@ class _LinuxPageFrameState extends State ? oppositeControlInset : sidebarSideOverlap; final sidebarInteractive = _targetVisible && _controller.value == 1; + final sidebarViewport = sidebar == null + ? null + : DecoratedBox( + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide(color: colors.sidebarBorder), + ), + ), + child: sidebar, + ); + if (widget.sidebarOverlay) { + return Stack( + children: [ + Positioned.fill( + child: Column( + children: [ + SizedBox( + height: BusyMarkLinuxWindowMetrics.headerHeight, + child: LinuxPageHeaderInsetsScope( + leftObstruction: metrics.leftControlInset, + rightObstruction: metrics.rightControlInset, + child: (mainChild! as _LinuxFrameMain).header, + ), + ), + Expanded(child: (mainChild as _LinuxFrameMain).body), + ], + ), + ), + if (_targetVisible && sidebarViewport != null) + PositionedDirectional( + start: 0, + top: BusyMarkLinuxWindowMetrics.headerHeight, + bottom: 0, + width: BusyMarkSizes.sidebarWidth, + child: ColoredBox( + color: colors.sidebar, + child: retainedSidebar, + ), + ), + ], + ); + } return Row( children: [ if (sidebar != null) diff --git a/lib/src/app/path_label.dart b/lib/src/app/path_label.dart new file mode 100644 index 00000000..db6bb9f0 --- /dev/null +++ b/lib/src/app/path_label.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as p; + +/// One measured elision of a location, retaining its trailing path components. +/// Full navigation identities and accessible descriptions belong to the row. +class BusyMarkPathLabel extends StatelessWidget { + const BusyMarkPathLabel({required this.path, this.style, super.key}); + final String path; + final TextStyle? style; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final effectiveStyle = DefaultTextStyle.of(context).style.merge(style); + final scaler = MediaQuery.textScalerOf(context); + bool fits(String text) { + final painter = TextPainter( + text: TextSpan(text: text, style: effectiveStyle), + textDirection: TextDirection.ltr, + textScaler: scaler, + maxLines: 1, + )..layout(); + final result = painter.width <= constraints.maxWidth; + painter.dispose(); + return result; + } + + var label = path; + if (!fits(label)) { + final prefix = p.isAbsolute(path) + ? '${p.rootPrefix(path)}…${p.separator}' + : path.startsWith('~${p.separator}') + ? '~${p.separator}…${p.separator}' + : '…${p.separator}'; + final parts = p + .split(path) + .where((part) => part != p.rootPrefix(path) && part != '~') + .toList(); + for (var i = 1; i < parts.length; i++) { + label = '$prefix${p.joinAll(parts.skip(i))}'; + if (fits(label)) break; + } + if (!fits(label)) { + final end = (parts.lastOrNull ?? path).characters.toList(); + var low = 0, high = end.length; + while (low < high) { + final mid = (low + high) ~/ 2; + if (fits('$prefix${end.skip(mid).join()}')) { + high = mid; + } else { + low = mid + 1; + } + } + label = '$prefix${end.skip(low).join()}'; + } + } + return Text( + label, + style: style, + maxLines: 1, + softWrap: false, + textDirection: TextDirection.ltr, + overflow: TextOverflow.clip, + ); + }, + ); +} diff --git a/lib/src/app/quick_open.dart b/lib/src/app/quick_open.dart index 345e365d..1f9aa37c 100644 --- a/lib/src/app/quick_open.dart +++ b/lib/src/app/quick_open.dart @@ -1,18 +1,6 @@ import 'dart:isolate'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path/path.dart' as p; - -import '../nextcloud_notes/application/nextcloud_connection.dart'; -import '../nextcloud_notes/application/notes_navigation.dart'; import '../nextcloud_notes/domain/notes_search.dart'; -import '../search/workspace_search_scope.dart'; -import '../workspace/workspace_controller.dart'; -import 'busymark_design.dart'; -import 'busymark_dialogs.dart'; -import 'localization.dart'; class QuickOpenDocument { const QuickOpenDocument({ @@ -59,187 +47,10 @@ List rankQuickOpen( return ranked.take(limit).map((r) => r.document).toList(); } -Future showQuickOpen(BuildContext context, WidgetRef ref) async { - final workspace = ref.read(workspaceControllerProvider).workspace; - if (workspace == null) return; - final previous = FocusManager.instance.primaryFocus; - final documents = []; - if (workspace.isRemote) { - final repository = await ref.read(nextcloudNotesRepositoryProvider.future); - documents.addAll( - repository.notes - .where( - (n) => - n.accountId == workspace.nextcloudAccountId && - !isNotesRecovery(n), - ) - .map( - (n) => QuickOpenDocument( - title: n.title, - path: n.category, - localId: n.localId, - ), - ), - ); - } else { - documents.addAll( - workspace.files - .where(isSearchableWorkspaceDocument) - .map( - (f) => QuickOpenDocument( - title: p.basenameWithoutExtension(f.relativePath), - path: f.relativePath, - filePath: f.absolutePath, - ), - ), - ); - } - if (!context.mounted) return; - final result = await showBusyMarkModalDialog( - context, - builder: (_) => QuickOpenDialog(documents: documents), - ); - if (!ref.context.mounted || - ref.read(workspaceControllerProvider).workspace?.id != workspace.id) { - return; - } - if (result == null) { - if (previous?.context?.mounted == true) previous!.requestFocus(); - return; - } - final controller = ref.read(workspaceControllerProvider.notifier); - if (result.localId != null) { - await controller.openNextcloudNote(result.localId!); - } else { - await controller.openActiveFile(result.filePath!); - } -} - -class QuickOpenDialog extends StatefulWidget { - const QuickOpenDialog({super.key, required this.documents}); - final List documents; - @override - State createState() => _QuickOpenDialogState(); -} - -class _QuickOpenDialogState extends State { - late final FocusNode _focus = FocusNode(onKeyEvent: _key); - final _scroll = ScrollController(); - List _results = []; - int _selected = 0, _generation = 0; - bool _loading = false; - @override - void initState() { - super.initState(); - _search(''); - } - - Future _search(String query) async { - final generation = ++_generation; - setState(() => _loading = true); - final documents = widget.documents; - final results = await _rankQuickOpenAsync(documents, query); - if (!mounted || generation != _generation) return; - setState(() { - _results = results; - _selected = 0; - _loading = false; - }); - if (_scroll.hasClients) _scroll.jumpTo(0); - } - - KeyEventResult _key(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(context); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.enter) { - _open(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowDown || - event.logicalKey == LogicalKeyboardKey.arrowUp) { - if (_results.isNotEmpty) { - setState( - () => _selected = - (_selected + - (event.logicalKey == LogicalKeyboardKey.arrowDown - ? 1 - : -1)) - .clamp(0, _results.length - 1), - ); - if (_scroll.hasClients) { - _scroll.animateTo( - (_selected * 64.0).clamp(0, _scroll.position.maxScrollExtent), - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - void _open() { - if (!_loading && _results.isNotEmpty) { - Navigator.pop(context, _results[_selected]); - } - } - - @override - void dispose() { - _generation++; - _focus.dispose(); - _scroll.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => BusyMarkDialogShell( - title: context.l10n.quickOpen, - maxWidth: 640, - children: [ - TextField( - autofocus: true, - focusNode: _focus, - decoration: InputDecoration(labelText: context.l10n.quickOpen), - onChanged: _search, - ), - if (_loading) const LinearProgressIndicator(), - SizedBox( - height: 340, - child: _results.isEmpty - ? Center(child: Text(context.l10n.noResults)) - : ListView.builder( - controller: _scroll, - itemCount: _results.length, - itemExtent: 64, - itemBuilder: (context, index) { - final document = _results[index]; - return ListTile( - selected: index == _selected, - title: Text( - document.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - document.path, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => Navigator.pop(context, document), - ); - }, - ), - ), - ], - ); -} - -Future> _rankQuickOpenAsync( +/// A top-level isolate boundary prevents a search controller's subscriptions +/// and futures from being captured by a ranking closure. +Future> rankQuickOpenAsync( List documents, - String query, -) => Isolate.run(() => rankQuickOpen(documents, query)); + String query, { + int limit = 100, +}) => Isolate.run(() => rankQuickOpen(documents, query, limit: limit)); diff --git a/lib/src/app/shortcut_binding.dart b/lib/src/app/shortcut_binding.dart new file mode 100644 index 00000000..5e7f0522 --- /dev/null +++ b/lib/src/app/shortcut_binding.dart @@ -0,0 +1,200 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// A layout-aware logical key, independent of localized presentation text. +@immutable +class BusyMarkKeyBinding { + const BusyMarkKeyBinding( + this.key, { + this.control = false, + this.shift = false, + this.alt = false, + this.meta = false, + }); + final LogicalKeyboardKey key; + final bool control, shift, alt, meta; + + static final modifierKeys = { + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + LogicalKeyboardKey.control, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.altGraph, + LogicalKeyboardKey.compose, + }; + + static BusyMarkKeyBinding? fromJson(Object? value) { + if (value is! Map) return null; + final id = value['key']; + if (id is! int || id < 0 || id > 0x2ffffffff) return null; + for (final name in ['control', 'shift', 'alt', 'meta']) { + if (value[name] != null && value[name] is! bool) return null; + } + final known = LogicalKeyboardKey.findKeyByKeyId(id); + if (known == null && + !(id >= 0x20 && + id <= 0x10ffff && + !(id >= 0x7f && id <= 0x9f) && + !(id >= 0xd800 && id <= 0xdfff))) { + return null; + } + final key = known ?? LogicalKeyboardKey(id); + if (modifierKeys.contains(key) || key == LogicalKeyboardKey.unidentified) { + return null; + } + return BusyMarkKeyBinding( + key, + control: value['control'] == true, + shift: value['shift'] == true, + alt: value['alt'] == true, + meta: value['meta'] == true, + ); + } + + factory BusyMarkKeyBinding.fromActivator(SingleActivator value) => + BusyMarkKeyBinding( + value.trigger, + control: value.control, + shift: value.shift, + alt: value.alt, + meta: value.meta, + ); + + static BusyMarkKeyBinding? fromEvent(KeyEvent event) { + if (event is! KeyDownEvent || + modifierKeys.contains(event.logicalKey) || + event.logicalKey == LogicalKeyboardKey.unidentified) { + return null; + } + final keyboard = HardwareKeyboard.instance; + final binding = BusyMarkKeyBinding( + event.logicalKey, + control: keyboard.isControlPressed, + shift: keyboard.isShiftPressed, + alt: keyboard.isAltPressed, + meta: keyboard.isMetaPressed, + ); + return fromJson(binding.toJson()); + } + + SingleActivator get activator => SingleActivator( + key, + control: control, + shift: shift, + alt: alt, + meta: meta, + includeRepeats: false, + ); + Map toJson() => { + 'key': key.keyId, + 'control': control, + 'shift': shift, + 'alt': alt, + 'meta': meta, + }; + String get label => [ + if (control) 'Ctrl', + if (alt) 'Alt', + if (shift) 'Shift', + if (meta) 'Super', + key == LogicalKeyboardKey.escape ? 'Esc' : key.keyLabel, + ].join('+'); + + /// Native metadata uses GDK names for functional keys. Unicode scalar + /// notation is resolved through GDK by the existing menu bridge. + String get gtkAccelerator { + final name = + _gtkNames[key] ?? + (key.keyId < 0x100000000 + ? (!RegExp(r'^[a-zA-Z0-9]$').hasMatch(key.keyLabel) + ? 'U+${key.keyId.toRadixString(16).toUpperCase().padLeft(4, '0')}' + : key.keyLabel.toLowerCase()) + : key.keyLabel.replaceAll(' ', '_')); + return '${control ? '' : ''}${alt ? '' : ''}' + '${shift ? '' : ''}${meta ? '' : ''}$name'; + } + + bool get hasKnownSystemConflict => + meta || + (alt && {LogicalKeyboardKey.tab, LogicalKeyboardKey.f4}.contains(key)) || + (control && + alt && + (key == LogicalKeyboardKey.delete || + key.keyId >= LogicalKeyboardKey.f1.keyId && + key.keyId <= LogicalKeyboardKey.f12.keyId)); + + @override + bool operator ==(Object other) => + other is BusyMarkKeyBinding && + key == other.key && + control == other.control && + shift == other.shift && + alt == other.alt && + meta == other.meta; + @override + int get hashCode => Object.hash(key, control, shift, alt, meta); +} + +final _gtkNames = { + LogicalKeyboardKey.escape: 'Escape', + LogicalKeyboardKey.enter: 'Return', + LogicalKeyboardKey.arrowLeft: 'Left', + LogicalKeyboardKey.arrowRight: 'Right', + LogicalKeyboardKey.arrowUp: 'Up', + LogicalKeyboardKey.arrowDown: 'Down', + LogicalKeyboardKey.pageUp: 'Page_Up', + LogicalKeyboardKey.pageDown: 'Page_Down', + LogicalKeyboardKey.backspace: 'BackSpace', + LogicalKeyboardKey.space: 'space', + LogicalKeyboardKey.comma: 'comma', + LogicalKeyboardKey.period: 'period', + LogicalKeyboardKey.slash: 'slash', + LogicalKeyboardKey.backslash: 'backslash', + LogicalKeyboardKey.semicolon: 'semicolon', + LogicalKeyboardKey.quote: 'apostrophe', + LogicalKeyboardKey.bracketLeft: 'bracketleft', + LogicalKeyboardKey.bracketRight: 'bracketright', + LogicalKeyboardKey.minus: 'minus', + LogicalKeyboardKey.equal: 'equal', + LogicalKeyboardKey.backquote: 'grave', + LogicalKeyboardKey.numpad0: 'KP_0', + LogicalKeyboardKey.numpad1: 'KP_1', + LogicalKeyboardKey.numpad2: 'KP_2', + LogicalKeyboardKey.numpad3: 'KP_3', + LogicalKeyboardKey.numpad4: 'KP_4', + LogicalKeyboardKey.numpad5: 'KP_5', + LogicalKeyboardKey.numpad6: 'KP_6', + LogicalKeyboardKey.numpad7: 'KP_7', + LogicalKeyboardKey.numpad8: 'KP_8', + LogicalKeyboardKey.numpad9: 'KP_9', + LogicalKeyboardKey.numpadEnter: 'KP_Enter', + LogicalKeyboardKey.numpadAdd: 'KP_Add', + LogicalKeyboardKey.numpadSubtract: 'KP_Subtract', + LogicalKeyboardKey.numpadMultiply: 'KP_Multiply', + LogicalKeyboardKey.numpadDivide: 'KP_Divide', + LogicalKeyboardKey.numpadDecimal: 'KP_Decimal', +}; + +/// Missing entries use defaults; null entries explicitly unassign a command. +Map readShortcutOverrides(Object? value) { + if (value is! Map) return const {}; + return Map.unmodifiable({ + for (final entry in value.entries) + if (entry.key is String && + RegExp( + r'^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$', + ).hasMatch(entry.key as String)) + if (entry.value == null) + entry.key as String: null + else if (BusyMarkKeyBinding.fromJson(entry.value) case final binding?) + entry.key as String: binding, + }); +} diff --git a/lib/src/app/shortcut_capture.dart b/lib/src/app/shortcut_capture.dart new file mode 100644 index 00000000..eb31dacc --- /dev/null +++ b/lib/src/app/shortcut_capture.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'shortcut_binding.dart'; + +/// Intercepts before the focus tree and also gates hardware-level dispatch. +/// After capture closes, held keys must be released before normal dispatch. +abstract final class BusyMarkShortcutCapture { + static Object? _owner; + static void Function(BusyMarkKeyBinding)? _onCandidate; + static VoidCallback? _onCancel; + static bool _installed = false; + static bool get suppressing => _installed; + + static void begin( + Object owner, { + required void Function(BusyMarkKeyBinding) onCandidate, + required VoidCallback onCancel, + }) { + if (_owner != null && !identical(_owner, owner)) _onCancel?.call(); + _owner = owner; + _onCandidate = onCandidate; + _onCancel = onCancel; + if (!_installed) { + _installed = true; + FocusManager.instance.addEarlyKeyEventHandler(_handle); + HardwareKeyboard.instance.addHandler(_observeRelease); + } + } + + static void end(Object owner) { + if (!identical(_owner, owner)) return; + _owner = null; + _onCandidate = null; + _onCancel = null; + _releaseIfIdle(); + } + + static void _releaseIfIdle() { + if (_owner == null && + HardwareKeyboard.instance.logicalKeysPressed.isEmpty && + _installed) { + FocusManager.instance.removeEarlyKeyEventHandler(_handle); + HardwareKeyboard.instance.removeHandler(_observeRelease); + _installed = false; + } + } + + // Native delivery can omit the focus phase of an already handled release. + // Observe releases independently of that phase so capture cannot keep command + // dispatch suppressed after its owner and all held keys have gone away. + static bool _observeRelease(KeyEvent event) { + if (event is KeyUpEvent && _owner == null) { + scheduleMicrotask(_releaseIfIdle); + } + return false; + } + + static KeyEventResult _handle(KeyEvent event) { + final focused = FocusManager.instance.primaryFocus?.context; + final composing = + focused + ?.findAncestorStateOfType() + ?.widget + .controller + .value + .composing + .isValid == + true; + if (_owner != null && event is KeyDownEvent && !composing) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + _onCancel?.call(); + } else if (BusyMarkKeyBinding.fromEvent(event) case final binding?) { + _onCandidate?.call(binding); + } + } + // The event that releases the last captured key is still consumed. + _releaseIfIdle(); + return KeyEventResult.handled; + } +} diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart index 69a10b1c..3aeeeb21 100644 --- a/lib/src/clipboard/clipboard_history_panel.dart +++ b/lib/src/clipboard/clipboard_history_panel.dart @@ -6,7 +6,6 @@ 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'; @@ -24,7 +23,6 @@ class ClipboardHistoryPanel extends ConsumerStatefulWidget { } class _ClipboardHistoryPanelState extends ConsumerState { - final _searchController = TextEditingController(); final _listScrollController = ScrollController(); final _listFocusNode = FocusNode(debugLabel: 'Clipboard History list'); final _entryKeys = {}; @@ -46,7 +44,6 @@ class _ClipboardHistoryPanelState extends ConsumerState { @override void dispose() { - _searchController.dispose(); _listScrollController.dispose(); _listFocusNode.dispose(); super.dispose(); @@ -57,14 +54,11 @@ class _ClipboardHistoryPanelState extends ConsumerState { 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, + if (current != null) current, for (final entry in state.entries) - if ((current == null || !current.equivalentTo(entry)) && - _matches(entry, query)) - entry, + if (current == null || !current.equivalentTo(entry)) entry, ]; if (_selectedId != null && !visible.any((entry) => entry.id == _selectedId)) { @@ -86,17 +80,13 @@ class _ClipboardHistoryPanelState extends ConsumerState { BusyMarkSpacing.sm, ), child: Row( - textDirection: TextDirection.ltr, children: [ Expanded( - child: BusyMarkSearchField( - controller: _searchController, - hintText: context.l10n.clipboardHistorySearchHint, - onChanged: (_) => setState(() {}), - onEscape: widget.onEscape, + child: Text( + context.l10n.clipboardHistory, + style: Theme.of(context).textTheme.bodyMedium, ), ), - const SizedBox(width: BusyMarkSpacing.sm), BusyMarkHeaderPopupMenuButton<_ClipboardHistoryAction>( key: const ValueKey('clipboard-history-actions-menu'), tooltip: context.l10n.actions, @@ -147,9 +137,7 @@ class _ClipboardHistoryPanelState extends ConsumerState { Expanded( child: visible.isEmpty ? _PanelEmpty( - text: query.isNotEmpty - ? context.l10n.clipboardNoMatches - : state.clipboardAvailable + text: state.clipboardAvailable ? context.l10n.clipboardNoItems : context.l10n.clipboardUnavailable, ) @@ -201,17 +189,6 @@ class _ClipboardHistoryPanelState extends ConsumerState { ); } - 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, @@ -222,9 +199,8 @@ class _ClipboardHistoryPanelState extends ConsumerState { widget.onEscape?.call(); return KeyEventResult.handled; } - // Key events bubble through this Focus even while the search field owns - // primary focus. List navigation, activation, and especially deletion must - // never consume text-editing keys from that descendant. + // Descendant controls and popups own their keys. List navigation, + // activation and deletion require primary focus on the list itself. if (!_listFocusNode.hasPrimaryFocus) return KeyEventResult.ignored; if (visible.isEmpty) return KeyEventResult.ignored; final index = visible.indexWhere((entry) => entry.id == _selectedId); @@ -377,7 +353,6 @@ class _ClipboardEntryTile extends StatelessWidget { busyMarkLtrIsolateFor(context, originPath), maxLines: 1, overflow: TextOverflow.ellipsis, - textDirection: TextDirection.ltr, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colors.mutedForeground), diff --git a/lib/src/editor/editor_text_context_menu.dart b/lib/src/editor/editor_text_context_menu.dart index 2123f6b7..fa4b150f 100644 --- a/lib/src/editor/editor_text_context_menu.dart +++ b/lib/src/editor/editor_text_context_menu.dart @@ -248,6 +248,7 @@ class _BusyMarkEditorTextContextMenuState value: spellingFallback, label: command?.label(context) ?? '', shortcut: command?.shortcut?.label, + gtkAccelerator: command?.shortcut?.gtkAccelerator, ), ); items.add(const PopupMenuDivider(height: BusyMarkSpacing.sm)); @@ -265,6 +266,7 @@ class _BusyMarkEditorTextContextMenuState label: command.label(context), icon: BusyMarkGlyphs.paste, shortcut: command.shortcut?.label, + gtkAccelerator: command.shortcut?.gtkAccelerator, ), ); } @@ -277,6 +279,7 @@ class _BusyMarkEditorTextContextMenuState label: command.label(context), icon: BusyMarkGlyphs.paste, shortcut: command.shortcut?.label, + gtkAccelerator: command.shortcut?.gtkAccelerator, ), ); } @@ -304,6 +307,7 @@ class _BusyMarkEditorTextContextMenuState label: commands[commandId]!.label(context), icon: _iconFor(item.type), shortcut: commands[commandId]!.shortcut?.label, + gtkAccelerator: commands[commandId]!.shortcut?.gtkAccelerator, enabled: callback != null, ), ); @@ -328,6 +332,7 @@ class _BusyMarkEditorTextContextMenuState label: command.label(context), icon: BusyMarkGlyphs.copy, shortcut: command.shortcut?.label, + gtkAccelerator: command.shortcut?.gtkAccelerator, ), ); } @@ -344,6 +349,9 @@ class _BusyMarkEditorTextContextMenuState icon: BusyMarkGlyphs.ai, shortcut: commands[BusyMarkCommandIds.editorRefineWithAi]?.shortcut?.label, + gtkAccelerator: commands[BusyMarkCommandIds.editorRefineWithAi] + ?.shortcut + ?.gtkAccelerator, ), ); } @@ -360,6 +368,7 @@ class _BusyMarkEditorTextContextMenuState label: command.label(context), icon: entry.$2, shortcut: command.shortcut?.label, + gtkAccelerator: command.shortcut?.gtkAccelerator, enabled: command.enabled(), ), ); diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 5f1141af..0ad226d7 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,3 +1,5 @@ +import '../../app/command_shortcuts.dart'; +import '../../app/busymark_search_field.dart'; import '../link_destination.dart'; import '../wysiwyg/writerside_editing_adapter.dart'; import '../../assets/document_media_context.dart'; @@ -83,15 +85,18 @@ class BusyMarkSourceEditor extends StatefulWidget { required this.editorFontSize, required this.wordWrap, required this.searchActive, + this.searchControlsVisible = true, required this.searchOptions, - required this.onSearchOptionsChanged, + this.searchSnapshot, + this.onNavigateSearch, + this.onCancelSearchNavigation, this.searchReplacement = '', this.onSearchReplacementChanged, required this.onChanged, this.onTransactionalChanged, this.onUndo, this.onRedo, - required this.onOpenSearch, + this.onOpenSearch, required this.onCloseSearch, this.onVisibleLineChanged, this.onAiEdit, @@ -128,15 +133,18 @@ class BusyMarkSourceEditor extends StatefulWidget { final double editorFontSize; final bool wordWrap; final bool searchActive; + final bool searchControlsVisible; final SourceSearchOptions searchOptions; - final ValueChanged onSearchOptionsChanged; + final SourceSearchSnapshot? searchSnapshot; + final int? Function(SourceSearchNavigationRequest)? onNavigateSearch; + final ValueChanged? onCancelSearchNavigation; final String searchReplacement; final ValueChanged? onSearchReplacementChanged; final BusyMarkSourceChanged onChanged; final BusyMarkSourceTransactionalChanged? onTransactionalChanged; final TextEditingValue? Function()? onUndo; final TextEditingValue? Function()? onRedo; - final VoidCallback onOpenSearch; + final VoidCallback? onOpenSearch; final VoidCallback onCloseSearch; final ValueChanged? onVisibleLineChanged; final BusyMarkAiEditCallback? onAiEdit; @@ -175,6 +183,7 @@ class BusyMarkSourceEditorState extends State { late final ScrollController _horizontalScrollController; late UndoHistoryController _undoController; final _sourceEditorKey = GlobalKey(); + final _searchPanelKey = GlobalKey(); final _foldedRegionKeys = {}; final _searchController = SourceSearchController(); final _searchWorker = SourceSearchWorker(); @@ -182,6 +191,7 @@ class BusyMarkSourceEditorState extends State { bool _applyingSearchReplacement = false; ({int start, int end})? _requestedSearchRange; bool _searchNavigationFailed = false; + _SharedSearchNavigation? _pendingSharedSearchNavigation; final _intrinsicWidthCache = SourceIntrinsicWidthCache(); final _lineLayoutCache = SourceLineLayoutCache(); final _autocompleteProvider = const SourceAutocompleteProvider(); @@ -357,6 +367,7 @@ class BusyMarkSourceEditorState extends State { widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); } if (widget.text != oldWidget.text || + widget.searchActive != oldWidget.searchActive || widget.searchOptions != oldWidget.searchOptions || widget.searchReplacement != oldWidget.searchReplacement) { _replacementWorker.cancel(); @@ -364,6 +375,14 @@ class BusyMarkSourceEditorState extends State { final path = widget.documentId ?? widget.filePath ?? ''; final pathChanged = path != _lastPath; final languageChanged = widget.language != oldWidget.language; + if (pathChanged || + languageChanged || + widget.searchActive != oldWidget.searchActive || + widget.searchOptions != oldWidget.searchOptions || + (_pendingSharedSearchNavigation != null && + widget.text != _pendingSharedSearchNavigation!.request.source)) { + _cancelSharedSearchNavigation(); + } var authoritativeDocumentChanged = false; if (pathChanged) { authoritativeDocumentChanged = true; @@ -392,6 +411,18 @@ class BusyMarkSourceEditorState extends State { _requestedSearchRange = null; _searchNavigationFailed = false; _syncSearchOptions(); + } else if (widget.searchSnapshot?.result != + oldWidget.searchSnapshot?.result || + widget.searchSnapshot?.source != oldWidget.searchSnapshot?.source) { + _syncSearchOptions(); + } + if (widget.searchSnapshot?.navigationRequest != + oldWidget.searchSnapshot?.navigationRequest || + widget.searchSnapshot?.appliedNavigation != + oldWidget.searchSnapshot?.appliedNavigation || + widget.searchSnapshot?.searching != + oldWidget.searchSnapshot?.searching) { + _syncSearchOptions(); } if (widget.wordWrap && !oldWidget.wordWrap) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -419,6 +450,12 @@ class BusyMarkSourceEditorState extends State { } void _handleControllerActivity() { + final pending = _pendingSharedSearchNavigation; + if (pending != null && + (pending.request.source != _controller.fullText || + pending.selection != _controller.fullSelection)) { + _cancelSharedSearchNavigation(); + } _scheduleSessionPublication(); if (widget.wordWrap || _horizontalCaretScheduled) { return; @@ -499,6 +536,7 @@ class BusyMarkSourceEditorState extends State { required int startOffset, required int endOffset, }) { + _cancelSharedSearchNavigation(); _unfoldSourceRange(startOffset, endOffset); final start = startOffset.clamp(0, _controller.fullText.length).toInt(); final end = endOffset.clamp(start, _controller.fullText.length).toInt(); @@ -520,6 +558,12 @@ class BusyMarkSourceEditorState extends State { result.firstMatchIndex + localIndex, ); _controller.setSearchResult(_searchController.result); + if (widget.searchSnapshot != null && widget.onNavigateSearch != null) { + _requestSharedSearchNavigation( + matchIndex: result.firstMatchIndex + localIndex, + reveal: false, + ); + } setState(() {}); } else if (start < end) { _scheduleSearch( @@ -531,14 +575,35 @@ class BusyMarkSourceEditorState extends State { _searchController.setCurrentMatchIndex(null); _controller.setSearchResult(_searchController.result); } + final source = _controller.fullText; + final documentId = widget.documentId ?? widget.filePath; + final options = widget.searchOptions; + final sourceRevision = widget.searchSnapshot?.sourceRevision; + final sessionRevision = widget.searchSnapshot?.sessionRevision; + bool current() => + mounted && + _controller.fullText == source && + (widget.documentId ?? widget.filePath) == documentId && + widget.searchOptions == options && + widget.searchSnapshot?.sourceRevision == sourceRevision && + widget.searchSnapshot?.sessionRevision == sessionRevision && + _controller.fullSelection == + TextSelection(baseOffset: start, extentOffset: end); WidgetsBinding.instance.addPostFrameCallback((_) { - _animateScrollToLine(line); + if (!current()) return; + _animateSearchToLine(line, current); WidgetsBinding.instance.addPostFrameCallback((_) { - _jumpScrollToLine(line); + if (current()) { + _jumpScrollToLine(line); + _ensureSearchCaretVisible(); + } }); unawaited( Future.delayed(BusyMarkMotion.previewSearchDelay, () { - _jumpScrollToLine(line); + if (current()) { + _jumpScrollToLine(line); + _ensureSearchCaretVisible(); + } }), ); }); @@ -577,6 +642,11 @@ class BusyMarkSourceEditorState extends State { } final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; + final unmodified = + !keyboard.isControlPressed && + !keyboard.isAltPressed && + !keyboard.isMetaPressed && + !keyboard.isShiftPressed; if (_autocompleteSuggestions.isNotEmpty) { if (key == LogicalKeyboardKey.arrowDown) { _moveAutocompleteSelection(1); @@ -586,7 +656,8 @@ class BusyMarkSourceEditorState extends State { _moveAutocompleteSelection(-1); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.tab) { + if (unmodified && + (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.tab)) { _applyAutocomplete(_autocompleteSuggestions[_autocompleteSelection]); return KeyEventResult.handled; } @@ -601,22 +672,20 @@ class BusyMarkSourceEditorState extends State { _showAutocomplete(); return KeyEventResult.handled; } - if (widget.onSymbolAction != null) { - final action = key == LogicalKeyboardKey.keyB && keyboard.isControlPressed - ? SourceSymbolAction.declaration - : key == LogicalKeyboardKey.f7 && keyboard.isAltPressed - ? SourceSymbolAction.usages - : key == LogicalKeyboardKey.f6 && keyboard.isShiftPressed - ? SourceSymbolAction.rename - : null; - if (action != null) { - widget.onSymbolAction!(action, _controller.fullSelection.extentOffset); - return KeyEventResult.handled; - } - } final commands = BusyMarkCommandRegistryScope.read(context) ?? BusyMarkCommandCatalog.metadata; + if (widget.onSymbolAction != null) { + for (final entry in sourceSymbolCommands.entries) { + if (commands.shortcutAccepts(entry.value, event, keyboard)) { + widget.onSymbolAction!( + entry.key, + _controller.fullSelection.extentOffset, + ); + return KeyEventResult.handled; + } + } + } if (widget.filePath?.toLowerCase().endsWith('.tree') == true) { final up = commands.shortcutAccepts( BusyMarkCommandIds.treeMoveLineUp, @@ -633,8 +702,9 @@ class BusyMarkSourceEditorState extends State { return KeyEventResult.handled; } } - if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { - widget.onOpenSearch(); + if (widget.onOpenSearch != null && + commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { + widget.onOpenSearch!(); return KeyEventResult.handled; } if (commands.shortcutAccepts( @@ -703,7 +773,7 @@ class BusyMarkSourceEditorState extends State { _outdentSelection(); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.enter && !keyboard.isShiftPressed) { + if (key == LogicalKeyboardKey.enter && unmodified) { _applyFullEditingValue(SourceCommands.smartEnter(_fullEditingValue())); return KeyEventResult.handled; } @@ -775,21 +845,72 @@ class BusyMarkSourceEditorState extends State { key: _sourceEditorKey, child: KeyedSubtree( key: ValueKey(widget.documentId ?? widget.filePath), - child: Shortcuts( - shortcuts: - (BusyMarkCommandRegistryScope.maybeOf(context) ?? - BusyMarkCommandCatalog.metadata) - .shortcutIntents( - scopes: const {BusyMarkCommandScope.editor}, - intentFor: BusyMarkContextCommandIntent.new, - ), + child: BusyMarkCommandShortcuts( + isComposing: () => _controller.value.composing.isValid, + shortcuts: { + // Other text-editing bindings are dispatched by the + // source input handler. Select All uses the existing + // full-source action, including folded content. + for (final activator + in (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .activators(BusyMarkCommandIds.textSelectAll)) + activator: const BusyMarkContextCommandIntent( + BusyMarkCommandIds.textSelectAll, + ), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .blockedDefaultIntents({ + BusyMarkCommandScope.textEditing, + BusyMarkCommandScope.editor, + if (widget.onSymbolAction != null) + BusyMarkCommandScope.sourceSymbol, + if (widget.filePath?.toLowerCase().endsWith( + '.tree', + ) == + true) + BusyMarkCommandScope.tree, + }), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .shortcutIntents( + scopes: { + BusyMarkCommandScope.editor, + if (widget.onSymbolAction != null) + BusyMarkCommandScope.sourceSymbol, + }, + intentFor: BusyMarkContextCommandIntent.new, + ), + }, child: Actions( actions: { BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + captureTarget: () { + final source = _controller.fullText; + final selection = _controller.fullSelection; + return BusyMarkContextCommandSnapshot( + () => + mounted && + !_hasActiveComposition && + _controller.fullText == source && + _controller.fullSelection == selection, + ); + }, isCommandEnabled: (commandId) => !_hasActiveComposition && - (commandId == + (!DocumentReadOnlyScope.of(context) || + { + BusyMarkCommandIds.textCopy, + BusyMarkCommandIds.textSelectAll, + BusyMarkCommandIds.editorCopyPlainText, + BusyMarkCommandIds.checkSpelling, + }.contains(commandId)) && + ((widget.onSymbolAction != null && + sourceSymbolCommands.containsValue( + commandId, + )) || + commandId == BusyMarkCommandIds.checkSpelling || commandId.startsWith('editor.') || (widget.filePath?.toLowerCase().endsWith( @@ -801,12 +922,53 @@ class BusyMarkSourceEditorState extends State { BusyMarkCommandIds.treeMoveLineDown, }.contains(commandId)) || { + BusyMarkCommandIds.textSelectAll, + BusyMarkCommandIds.textUndo, + BusyMarkCommandIds.textRedo, BusyMarkCommandIds.textCopy, BusyMarkCommandIds.textCut, BusyMarkCommandIds.textPaste, BusyMarkCommandIds.textPastePlainText, }.contains(commandId)), onCommand: (commandId) { + final symbol = sourceSymbolCommands.entries + .where((e) => e.value == commandId) + .firstOrNull; + if (symbol != null && + widget.onSymbolAction != null) { + widget.onSymbolAction!( + symbol.key, + _controller.fullSelection.extentOffset, + ); + return; + } + if (commandId == + BusyMarkCommandIds.textSelectAll) { + _controller.fullSelection = TextSelection( + baseOffset: 0, + extentOffset: _controller.fullText.length, + ); + return; + } + if (commandId == BusyMarkCommandIds.textUndo || + commandId == BusyMarkCommandIds.textRedo) { + final undo = + commandId == BusyMarkCommandIds.textUndo; + final callback = undo + ? widget.onUndo + : widget.onRedo; + if (callback != null) { + final value = callback(); + if (value != null) { + _applyOwnedUndoValue(value); + } + } else if (undo) { + _undoController.undo(); + } else { + _undoController.redo(); + } + return; + } if (commandId == BusyMarkCommandIds.checkSpelling) { widget.onCheckSpelling?.call(); @@ -940,19 +1102,28 @@ class BusyMarkSourceEditorState extends State { ), label: switch (action) { SourceSymbolAction.declaration => - 'Go to Declaration', + context.l10n.goToDeclaration, SourceSymbolAction.usages => - 'Find Usages', + context.l10n.findUsages, SourceSymbolAction.rename => context.l10n.rename, }, - shortcut: switch (action) { - SourceSymbolAction.declaration => - 'Ctrl+B', - SourceSymbolAction.usages => 'Alt+F7', - SourceSymbolAction.rename => - 'Shift+F6', - }, + shortcut: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.label, + gtkAccelerator: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.gtkAccelerator, ), ], onRefineWithAi: widget.onAiEdit == null @@ -990,32 +1161,22 @@ class BusyMarkSourceEditorState extends State { left: BusyMarkSpacing.sm, child: _SourceLargeFileBanner(), ), - if (widget.searchActive) + if (widget.searchActive && widget.searchControlsVisible) Positioned( top: BusyMarkSpacing.sm, right: BusyMarkSpacing.sm, child: _SourceSearchPanel( + key: _searchPanelKey, result: _searchController.result, + truncated: widget.searchSnapshot?.truncated ?? false, onPrevious: _previousSearchMatch, onNext: _nextSearchMatch, - onToggleCaseSensitive: () => _updateSearchOptions( - widget.searchOptions.copyWith( - caseSensitive: !widget.searchOptions.caseSensitive, - ), - ), - onToggleWholeWord: () => _updateSearchOptions( - widget.searchOptions.copyWith( - wholeWord: !widget.searchOptions.wholeWord, - ), - ), - onToggleRegex: () => _updateSearchOptions( - widget.searchOptions.copyWith( - regex: !widget.searchOptions.regex, - ), - ), replacement: widget.searchReplacement, canReplaceCurrent: - _requestedSearchRange == null && !_searchNavigationFailed, + !DocumentReadOnlyScope.of(context) && + _requestedSearchRange == null && + !_searchNavigationFailed, + canReplaceAll: !DocumentReadOnlyScope.of(context), onReplacementChanged: widget.onSearchReplacementChanged ?? (_) {}, onReplaceCurrent: () => unawaited(_replaceCurrentSearchMatch()), @@ -1135,15 +1296,71 @@ class BusyMarkSourceEditorState extends State { } final options = widget.searchOptions; final previousResult = _searchController.result; + final shared = widget.searchSnapshot; + if (shared != null) { + if (revealCurrentAfterRefresh && + widget.onNavigateSearch != null && + (minimumFullOffset != null || currentIndex != null)) { + _requestSharedSearchNavigation( + matchIndex: currentIndex, + minimumFullOffset: minimumFullOffset, + ); + _searchController.stageOptions(options); + _controller.setSearchResult(_searchController.result); + return; + } + _searchController.stageOptions(options); + if (shared.source != _controller.fullText || + shared.result.options != options) { + _controller.setSearchResult(_searchController.result); + return; + } + final document = _controller.document; + // Only project the authoritative window through the local folding map. + final projected = SourceSearchResult( + options: options, + totalMatchCount: shared.result.totalMatchCount, + firstMatchIndex: shared.result.firstMatchIndex, + currentMatchIndex: shared.result.currentMatchIndex, + matches: [ + for (final match in shared.result.matches) + if (match.fullEnd <= document.fullText.length) + _projectSearchMatch(document, match), + ], + ); + _searchController.acceptResult(projected); + final requested = _requestedSearchRange; + if (requested != null && !shared.searching) { + final index = projected.matches.indexWhere( + (match) => + match.fullStart == requested.start && + match.fullEnd == requested.end, + ); + _searchController.setCurrentMatchIndex( + index < 0 ? null : projected.firstMatchIndex + index, + ); + _searchNavigationFailed = index < 0; + _requestedSearchRange = null; + } else if (widget.onNavigateSearch == null) { + _searchController.setCurrentMatchIndex( + currentIndex ?? + (previousResult.options == options + ? previousResult.currentMatchIndex + : null), + ); + } + _controller.setSearchResult(_searchController.result); + _applySharedSearchNavigation(shared); + return; + } final requestedFirstMatchIndex = firstMatchIndex ?? (previousResult.options == options ? previousResult.firstMatchIndex : 0); - final invalidRegex = sourceSearchOptionsHaveInvalidRegex(options); - _searchController.stageOptions(options, invalidRegex: invalidRegex); + _searchController.stageOptions(options); _controller.setSearchResult(_searchController.result); - if (options.query.isEmpty || invalidRegex) { + if (options.query.isEmpty) { return; } _searchDebounce = Timer(const Duration(milliseconds: 120), () { @@ -1214,6 +1431,96 @@ class BusyMarkSourceEditorState extends State { }); } + SourceSearchMatch _projectSearchMatch( + SourceDocument document, + SourceSearchMatch match, + ) { + final visible = document.fullRangeToVisibleRange( + match.fullStart, + match.fullEnd, + ); + return SourceSearchMatch( + fullStart: match.fullStart, + fullEnd: match.fullEnd, + visibleStart: visible.range.start, + visibleEnd: visible.range.end, + hidden: + visible.clippedByHiddenRange || + visible.range.start == visible.range.end, + ); + } + + void _requestSharedSearchNavigation({ + int? matchIndex, + int? minimumFullOffset, + bool reveal = true, + }) { + final request = SourceSearchNavigationRequest( + documentId: widget.documentId ?? widget.filePath, + source: _controller.fullText, + options: widget.searchOptions, + matchIndex: matchIndex, + minimumFullOffset: minimumFullOffset, + reveal: reveal, + ); + final id = widget.onNavigateSearch?.call(request); + if (id != null && reveal) { + _pendingSharedSearchNavigation = _SharedSearchNavigation( + id, + request, + _controller.fullSelection, + ); + } + } + + void _cancelSharedSearchNavigation() { + final pending = _pendingSharedSearchNavigation; + _pendingSharedSearchNavigation = null; + if (pending != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onCancelSearchNavigation?.call(pending.id); + }); + } + } + + void _applySharedSearchNavigation(SourceSearchSnapshot snapshot) { + final pending = _pendingSharedSearchNavigation; + if (pending == null) return; + if (snapshot.navigationRequest != pending.id) { + _cancelSharedSearchNavigation(); + return; + } + if (snapshot.searching || + snapshot.appliedNavigation != pending.id || + pending.scheduled) { + return; + } + pending.scheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + pending.scheduled = false; + if (!mounted || + !identical(_pendingSharedSearchNavigation, pending) || + !widget.searchActive || + widget.searchSnapshot?.navigationRequest != pending.id || + widget.searchSnapshot?.appliedNavigation != pending.id || + pending.request.documentId != + (widget.documentId ?? widget.filePath) || + pending.request.options != widget.searchOptions || + pending.request.source != _controller.fullText || + pending.selection != _controller.fullSelection) { + return; + } + _pendingSharedSearchNavigation = null; + if (!_searchNavigationFailed) { + _revealSearchMatch(_searchController.result.currentMatch); + final index = _searchController.result.currentMatchIndex; + if (index != null) { + _requestSharedSearchNavigation(matchIndex: index, reveal: false); + } + } + }); + } + void _refreshSearch({ int? currentIndex, int? firstMatchIndex, @@ -1232,10 +1539,6 @@ class BusyMarkSourceEditorState extends State { ); } - void _updateSearchOptions(SourceSearchOptions options) { - widget.onSearchOptionsChanged(options); - } - void _nextSearchMatch() { _requestedSearchRange = null; _searchNavigationFailed = false; @@ -1266,6 +1569,10 @@ class BusyMarkSourceEditorState extends State { } void _selectSearchMatchIndex(int index, {required bool loadPreviousWindow}) { + if (widget.searchSnapshot != null && widget.onNavigateSearch != null) { + _requestSharedSearchNavigation(matchIndex: index); + return; + } final result = _searchController.result; final storedEnd = result.firstMatchIndex + result.matches.length; if (index >= result.firstMatchIndex && index < storedEnd) { @@ -1284,7 +1591,7 @@ class BusyMarkSourceEditorState extends State { } Future _replaceCurrentSearchMatch({bool findNext = false}) async { - if (_searchController.result.invalidRegex || + if (DocumentReadOnlyScope.of(context) || _requestedSearchRange != null || _searchNavigationFailed) { return; @@ -1300,6 +1607,7 @@ class BusyMarkSourceEditorState extends State { final document = _controller.document; final options = widget.searchOptions; final replacement = widget.searchReplacement; + final selection = _controller.fullSelection; final preview = await _replacementWorker.previewText( source: document.fullText, options: options, @@ -1308,12 +1616,13 @@ class BusyMarkSourceEditorState extends State { targetEnd: currentMatch.fullEnd, ); if (!mounted || + DocumentReadOnlyScope.of(context) || preview == null || !identical(document, _controller.document) || options != widget.searchOptions || replacement != widget.searchReplacement || + selection != _controller.fullSelection || _searchController.result.currentMatchIndex != currentIndex || - preview.invalidRegex || preview.matches.isEmpty) { return; } @@ -1345,6 +1654,7 @@ class BusyMarkSourceEditorState extends State { } Future _replaceAllSearchMatches() async { + if (DocumentReadOnlyScope.of(context)) return; final document = _controller.document; final options = widget.searchOptions; final replacement = widget.searchReplacement; @@ -1354,11 +1664,11 @@ class BusyMarkSourceEditorState extends State { replacement: replacement, ); if (!mounted || + DocumentReadOnlyScope.of(context) || preview == null || !identical(document, _controller.document) || options != widget.searchOptions || replacement != widget.searchReplacement || - preview.invalidRegex || preview.matches.isEmpty) { return; } @@ -1404,11 +1714,87 @@ class BusyMarkSourceEditorState extends State { _focusNode.requestFocus(); _controller.setSearchResult(_searchController.result); setState(() {}); + final source = _controller.fullText; + final documentId = widget.documentId ?? widget.filePath; + final options = widget.searchOptions; + final selection = _controller.fullSelection; + final sourceRevision = widget.searchSnapshot?.sourceRevision; + final sessionRevision = widget.searchSnapshot?.sessionRevision; WidgetsBinding.instance.addPostFrameCallback((_) { - _animateScrollToLine(line); + if (!mounted || + source != _controller.fullText || + documentId != (widget.documentId ?? widget.filePath) || + options != widget.searchOptions || + sourceRevision != widget.searchSnapshot?.sourceRevision || + sessionRevision != widget.searchSnapshot?.sessionRevision || + !widget.searchActive || + selection != _controller.fullSelection) { + return; + } + _animateSearchToLine( + line, + () => + mounted && + source == _controller.fullText && + documentId == (widget.documentId ?? widget.filePath) && + options == widget.searchOptions && + sourceRevision == widget.searchSnapshot?.sourceRevision && + sessionRevision == widget.searchSnapshot?.sessionRevision && + selection == _controller.fullSelection, + ); }); } + void _animateSearchToLine(int line, bool Function() current) { + if (!_scrollController.hasClients || !current()) return; + unawaited( + _scrollController + .animateTo( + _scrollOffsetForLine(line), + duration: BusyMarkMotion.scroll, + curve: Curves.easeOutCubic, + ) + .then((_) { + if (current()) _ensureSearchCaretVisible(); + }), + ); + } + + void _ensureSearchCaretVisible() { + if (!mounted || !_scrollController.hasClients) return; + final root = _sourceEditorKey.currentContext?.findRenderObject(); + final editable = root == null ? null : _findSourceRenderEditable(root); + if (editable == null || !_controller.selection.isValid) return; + final caret = editable.getLocalRectForCaret( + TextPosition(offset: _controller.selection.extentOffset), + ); + final position = _scrollController.position; + const margin = BusyMarkSpacing.sm; + final height = math.min(editable.size.height, position.viewportDimension); + var topMargin = margin; + final panel = _searchPanelKey.currentContext?.findRenderObject(); + if (panel is RenderBox && panel.hasSize) { + final panelBounds = + editable.globalToLocal(panel.localToGlobal(Offset.zero)) & panel.size; + if (caret.right > panelBounds.left && caret.left < panelBounds.right) { + topMargin = math.max( + margin, + math.min(panelBounds.bottom + margin, height - caret.height - margin), + ); + } + } + final delta = caret.top < topMargin + ? caret.top - topMargin + : caret.bottom > height - margin + ? caret.bottom - height + margin + : 0.0; + if (delta != 0) { + position.jumpTo( + (position.pixels + delta).clamp(0.0, position.maxScrollExtent), + ); + } + } + void _recomputeFoldRegions({bool resetCollapsed = false}) { final next = _controller.sourceFeaturesDegraded ? const [] @@ -3430,16 +3816,24 @@ class _CollapsedSpellingPainter extends CustomPainter { oldDelegate.textScaler != textScaler; } +class _SharedSearchNavigation { + _SharedSearchNavigation(this.id, this.request, this.selection); + final int id; + final SourceSearchNavigationRequest request; + final TextSelection selection; + bool scheduled = false; +} + class _SourceSearchPanel extends StatefulWidget { const _SourceSearchPanel({ + super.key, required this.result, + this.truncated = false, required this.onPrevious, required this.onNext, - required this.onToggleCaseSensitive, - required this.onToggleWholeWord, - required this.onToggleRegex, required this.replacement, required this.canReplaceCurrent, + this.canReplaceAll = true, required this.onReplacementChanged, required this.onReplaceCurrent, required this.onReplaceAndFindNext, @@ -3448,13 +3842,12 @@ class _SourceSearchPanel extends StatefulWidget { }); final SourceSearchResult result; + final bool truncated; final VoidCallback onPrevious; final VoidCallback onNext; - final VoidCallback onToggleCaseSensitive; - final VoidCallback onToggleWholeWord; - final VoidCallback onToggleRegex; final String replacement; final bool canReplaceCurrent; + final bool canReplaceAll; final ValueChanged onReplacementChanged; final VoidCallback onReplaceCurrent; final VoidCallback onReplaceAndFindNext; @@ -3467,6 +3860,7 @@ class _SourceSearchPanel extends StatefulWidget { class _SourceSearchPanelState extends State<_SourceSearchPanel> { late final TextEditingController _replacementController; + final _replacementFocus = BusyMarkSearchFocusNode(); @override void initState() { @@ -3486,6 +3880,7 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { @override void dispose() { _replacementController.dispose(); + _replacementFocus.dispose(); super.dispose(); } @@ -3493,9 +3888,7 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final result = widget.result; - final status = result.invalidRegex - ? context.l10n.sourceSearchInvalidRegex - : result.totalMatchCount == 0 + final status = result.totalMatchCount == 0 ? '0 / 0' : result.currentMatchIndex == null ? '– / ${result.totalMatchCount}' @@ -3510,23 +3903,14 @@ 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: [ Text( - status, - textDirection: result.invalidRegex - ? Directionality.of(context) - : TextDirection.ltr, + widget.truncated ? '$status+' : status, + textDirection: TextDirection.ltr, style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: result.invalidRegex - ? Theme.of(context).colorScheme.error - : colors.mutedForeground, + color: colors.mutedForeground, fontFeatures: const [FontFeature.tabularFigures()], ), ), @@ -3543,24 +3927,6 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { icon: YaruIcons.pan_down, onPressed: result.totalMatchCount == 0 ? null : widget.onNext, ), - _SearchOptionButton( - label: 'Aa', - tooltip: context.l10n.sourceSearchCaseSensitive, - selected: result.options.caseSensitive, - onPressed: widget.onToggleCaseSensitive, - ), - _SearchOptionButton( - label: 'W', - tooltip: context.l10n.sourceSearchWholeWord, - selected: result.options.wholeWord, - onPressed: widget.onToggleWholeWord, - ), - _SearchOptionButton( - label: '.*', - tooltip: context.l10n.sourceSearchRegex, - selected: result.options.regex, - onPressed: widget.onToggleRegex, - ), _SearchPanelIconButton( tooltip: context.l10n.close, icon: YaruIcons.window_close, @@ -3575,16 +3941,19 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { child: Row( children: [ Expanded( - child: TextField( - key: const ValueKey('source-search-replacement'), - controller: _replacementController, - onChanged: widget.onReplacementChanged, - decoration: InputDecoration( - isDense: true, - hintText: context.l10n.sourceSearchReplacement, - contentPadding: const EdgeInsets.symmetric( - horizontal: BusyMarkSpacing.sm, - vertical: BusyMarkSpacing.xs, + child: BusyMarkLocalTextEditingShortcuts( + child: TextField( + key: const ValueKey('source-search-replacement'), + controller: _replacementController, + focusNode: _replacementFocus, + onChanged: widget.onReplacementChanged, + decoration: InputDecoration( + isDense: true, + hintText: context.l10n.sourceSearchReplacement, + contentPadding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + vertical: BusyMarkSpacing.xs, + ), ), ), ), @@ -3609,7 +3978,7 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { tooltip: context.l10n.sourceSearchReplaceAll, icon: BusyMarkGlyphs.searchUnavailable, onPressed: - result.options.query.isEmpty || result.invalidRegex + !widget.canReplaceAll || result.options.query.isEmpty ? null : widget.onReplaceAll, ), @@ -3645,46 +4014,6 @@ class _SearchPanelIconButton extends StatelessWidget { } } -class _SearchOptionButton extends StatelessWidget { - const _SearchOptionButton({ - required this.label, - required this.tooltip, - required this.selected, - required this.onPressed, - }); - - final String label; - final String tooltip; - final bool selected; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - Widget optionLabel() => Builder( - builder: (context) => Text( - label, - textDirection: TextDirection.ltr, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: IconTheme.of(context).color, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: YaruIconButton( - tooltip: tooltip, - iconSize: 28, - isSelected: selected, - onPressed: onPressed, - icon: optionLabel(), - selectedIcon: optionLabel(), - ), - ); - } -} - class _SourceAutocompletePopup extends StatelessWidget { const _SourceAutocompletePopup({ required this.suggestions, @@ -3964,3 +4293,9 @@ String _collapsedLineText(String text) { } return '$trimmed ...'; } + +const sourceSymbolCommands = { + SourceSymbolAction.declaration: BusyMarkCommandIds.sourceDeclaration, + SourceSymbolAction.usages: BusyMarkCommandIds.sourceUsages, + SourceSymbolAction.rename: BusyMarkCommandIds.sourceRename, +}; diff --git a/lib/src/editor/source/source_search.dart b/lib/src/editor/source/source_search.dart index 82d37974..68ec1d16 100644 --- a/lib/src/editor/source/source_search.dart +++ b/lib/src/editor/source/source_search.dart @@ -10,44 +10,18 @@ import 'source_hidden_ranges.dart'; /// navigation. const int sourceInteractiveSearchMatchLimit = 2048; +/// The literal query also identifies asynchronous matching/navigation requests. class SourceSearchOptions { - const SourceSearchOptions({ - this.query = '', - this.caseSensitive = false, - this.wholeWord = false, - this.regex = false, - }); + const SourceSearchOptions({this.query = ''}); final String query; - final bool caseSensitive; - final bool wholeWord; - final bool regex; - - SourceSearchOptions copyWith({ - String? query, - bool? caseSensitive, - bool? wholeWord, - bool? regex, - }) { - return SourceSearchOptions( - query: query ?? this.query, - caseSensitive: caseSensitive ?? this.caseSensitive, - wholeWord: wholeWord ?? this.wholeWord, - regex: regex ?? this.regex, - ); - } @override - bool operator ==(Object other) { - return other is SourceSearchOptions && - other.query == query && - other.caseSensitive == caseSensitive && - other.wholeWord == wholeWord && - other.regex == regex; - } + bool operator ==(Object other) => + other is SourceSearchOptions && other.query == query; @override - int get hashCode => Object.hash(query, caseSensitive, wholeWord, regex); + int get hashCode => query.hashCode; } class SourceSearchMatch { @@ -75,8 +49,6 @@ class SourceSearchResult { this.currentMatchIndex, int? totalMatchCount, this.firstMatchIndex = 0, - this.invalidRegex = false, - this.hasZeroLengthMatches = false, }) : totalMatchCount = totalMatchCount ?? matches.length; static final empty = SourceSearchResult( @@ -93,8 +65,6 @@ 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; @@ -115,8 +85,6 @@ class SourceSearchResult { currentMatchIndex: currentMatchIndex, totalMatchCount: totalMatchCount, firstMatchIndex: firstMatchIndex, - invalidRegex: invalidRegex, - hasZeroLengthMatches: hasZeroLengthMatches, ); } @@ -127,8 +95,6 @@ class SourceSearchResult { other.currentMatchIndex == currentMatchIndex && other.totalMatchCount == totalMatchCount && other.firstMatchIndex == firstMatchIndex && - other.invalidRegex == invalidRegex && - other.hasZeroLengthMatches == hasZeroLengthMatches && _matchesEqual(other.matches, matches); } @@ -138,12 +104,50 @@ class SourceSearchResult { currentMatchIndex, totalMatchCount, firstMatchIndex, - invalidRegex, - hasZeroLengthMatches, Object.hashAll(matches.map(_matchHash)), ); } +/// A source editor projects the window's matches through its folding map. +/// It does not start another search while this snapshot is supplied. +class SourceSearchSnapshot { + const SourceSearchSnapshot({ + required this.source, + required this.result, + this.truncated = false, + this.searching = false, + this.navigationRequest = 0, + this.appliedNavigation = 0, + this.sourceRevision, + this.sessionRevision = 0, + }); + final String source; + final SourceSearchResult result; + final bool truncated; + final bool searching; + final int navigationRequest, appliedNavigation; + final int? sourceRevision; + final int sessionRevision; +} + +/// A navigation request for the window's existing search session. The source +/// and identity must still match the active buffer before resolving an offset. +class SourceSearchNavigationRequest { + const SourceSearchNavigationRequest({ + required this.documentId, + required this.source, + required this.options, + this.matchIndex, + this.minimumFullOffset, + this.reveal = true, + }); + final String? documentId; + final String source; + final SourceSearchOptions options; + final int? matchIndex, minimumFullOffset; + final bool reveal; +} + class SourceSearchController { SourceSearchController({ SourceSearchOptions options = const SourceSearchOptions(), @@ -159,19 +163,10 @@ class SourceSearchController { int get version => _version; - void setOptions(SourceSearchOptions options, SourceDocument document) { - _options = options; - refresh(document); - } - - void stageOptions(SourceSearchOptions options, {bool invalidRegex = false}) { + void stageOptions(SourceSearchOptions options) { _options = options; _version++; - _result = SourceSearchResult( - options: options, - matches: const [], - invalidRegex: invalidRegex, - ); + _result = SourceSearchResult(options: options, matches: const []); } void acceptResult(SourceSearchResult result) { @@ -253,10 +248,11 @@ class SourceSearchController { } } -/// Runs potentially expensive regular expressions away from Flutter's UI +/// Runs literal matching and full-document counting away from Flutter's UI /// isolate. Starting a newer request kills the preceding worker immediately. class SourceSearchWorker { Isolate? _isolate; + SendPort? _sessionPort; ReceivePort? _receivePort; Completer? _completer; var _generation = 0; @@ -269,49 +265,65 @@ class SourceSearchWorker { int? minimumFullOffset, int maximumMatches = sourceInteractiveSearchMatchLimit, bool stopAfterMaximumMatches = false, + bool reuseWorker = false, }) { - cancel(); + final reuse = reuseWorker && _sessionPort != null && _completer == null; + if (!reuse) cancel(); final generation = ++_generation; final receivePort = ReceivePort(); final completer = Completer(); _receivePort = receivePort; _completer = completer; + late final Map request; receivePort.listen((message) { if (generation != _generation || completer.isCompleted) { return; } + if (message is SendPort && reuseWorker) { + _sessionPort = message; + message.send([receivePort.sendPort, request]); + return; + } if (message is Map) { completer.complete(_decodeSearchResult(message, options)); } else { completer.complete(null); } - _releaseWorker(kill: true); + if (reuseWorker) { + _receivePort?.close(); + _receivePort = null; + _completer = null; + } else { + _releaseWorker(kill: true); + } }); - final request = { + request = { 'text': document.fullText, 'hidden': [ for (final range in document.hiddenRanges.ranges) [range.start, range.end, range.key], ], 'query': options.query, - 'caseSensitive': options.caseSensitive, - 'wholeWord': options.wholeWord, - 'regex': options.regex, 'currentMatchIndex': currentMatchIndex, 'firstMatchIndex': firstMatchIndex, 'minimumFullOffset': minimumFullOffset, 'maximumMatches': maximumMatches, 'stopAfterMaximumMatches': stopAfterMaximumMatches, }; + if (reuse) { + _sessionPort!.send([receivePort.sendPort, request]); + return completer.future; + } Isolate.spawn>( - _sourceSearchWorkerMain, + reuseWorker ? _sourceSearchSessionMain : _sourceSearchWorkerMain, [receivePort.sendPort, request], debugName: 'BusyMark source search', onError: receivePort.sendPort, onExit: receivePort.sendPort, ) .then((isolate) { - if (generation != _generation || completer.isCompleted) { + if (generation != _generation || + completer.isCompleted && !reuseWorker) { isolate.kill(priority: Isolate.immediate); } else { _isolate = isolate; @@ -342,23 +354,34 @@ class SourceSearchWorker { _isolate?.kill(priority: Isolate.immediate); } _isolate = null; + _sessionPort = null; _receivePort?.close(); _receivePort = null; _completer = null; } } +// One explicitly scoped workspace query can reuse its worker for sequential +// files. Cancelling or changing the query still kills the isolate immediately. +void _sourceSearchSessionMain(List payload) { + final requests = ReceivePort(); + (payload[0] as SendPort).send(requests.sendPort); + requests.listen((request) { + final message = (request as List); + try { + _sourceSearchWorkerMain(message); + } on Object { + (message[0] as SendPort).send(null); + } + }); +} + void _sourceSearchWorkerMain(List payload) { final sendPort = payload[0] as SendPort; final request = payload[1] as Map; final text = request['text']! as String; final hidden = request['hidden']! as List; - final options = SourceSearchOptions( - query: request['query']! as String, - caseSensitive: request['caseSensitive']! as bool, - wholeWord: request['wholeWord']! as bool, - regex: request['regex']! as bool, - ); + final options = SourceSearchOptions(query: request['query']! as String); final document = SourceDocument( fullText: text, hiddenRanges: SourceHiddenRanges( @@ -383,8 +406,6 @@ void _sourceSearchWorkerMain(List payload) { stopAfterMaximumMatches: request['stopAfterMaximumMatches']! as bool, ); sendPort.send({ - 'invalidRegex': result.invalidRegex, - 'hasZeroLengthMatches': result.hasZeroLengthMatches, 'currentMatchIndex': result.currentMatchIndex, 'totalMatchCount': result.totalMatchCount, 'firstMatchIndex': result.firstMatchIndex, @@ -421,28 +442,9 @@ SourceSearchResult _decodeSearchResult( currentMatchIndex: payload['currentMatchIndex'] as int?, totalMatchCount: payload['totalMatchCount']! as int, firstMatchIndex: payload['firstMatchIndex']! as int, - invalidRegex: payload['invalidRegex']! as bool, - hasZeroLengthMatches: payload['hasZeroLengthMatches']! as bool, ); } -bool sourceSearchOptionsHaveInvalidRegex(SourceSearchOptions options) { - if (!options.regex || options.query.isEmpty) { - return false; - } - try { - RegExp( - options.query, - caseSensitive: options.caseSensitive, - multiLine: true, - unicode: true, - ); - return false; - } on FormatException { - return true; - } -} - class SourceWorkspaceSearchFileResult { const SourceWorkspaceSearchFileResult({ required this.filePath, @@ -493,28 +495,7 @@ SourceSearchResult searchSourceDocument( return SourceSearchResult(options: options, matches: const []); } - late final Iterable<({int start, int end})> rawMatches; - if (options.regex) { - try { - rawMatches = - RegExp( - query, - caseSensitive: options.caseSensitive, - multiLine: true, - unicode: true, - ) - .allMatches(document.fullText) - .map((match) => (start: match.start, end: match.end)); - } on FormatException { - return SourceSearchResult( - options: options, - matches: const [], - invalidRegex: true, - ); - } - } else { - rawMatches = _plainMatches(document.fullText, query, options.caseSensitive); - } + final rawMatches = _plainMatches(document.fullText, query); final requestedFirstMatchIndex = firstMatchIndex < 0 ? 0 : firstMatchIndex; final matchLimit = maximumMatches?.clamp(0, 0x7fffffff).toInt(); @@ -522,12 +503,7 @@ 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, @@ -535,10 +511,6 @@ SourceSearchResult searchSourceDocument( )) { continue; } - if (options.wholeWord && - !_isWholeWord(document.fullText, match.start, match.end)) { - continue; - } final matchIndex = totalMatchCount++; if (!foundOffsetWindow) { if (match.start < minimumFullOffset!) { @@ -585,7 +557,6 @@ SourceSearchResult searchSourceDocument( : null, totalMatchCount: totalMatchCount, firstMatchIndex: storedFirstMatchIndex, - hasZeroLengthMatches: hasZeroLengthMatches, ); } @@ -605,11 +576,10 @@ bool sourceSearchRangeHasSafeBoundaries(String source, int start, int end) { Iterable<({int start, int end})> _plainMatches( String source, String query, - bool caseSensitive, ) sync* { final expression = RegExp( RegExp.escape(query), - caseSensitive: caseSensitive, + caseSensitive: false, unicode: true, ); for (final match in expression.allMatches(source)) { @@ -617,47 +587,6 @@ Iterable<({int start, int end})> _plainMatches( } } -bool _isWholeWord(String source, int start, int end) { - return !_isUnicodeWordCharacter(_characterBefore(source, start)) && - !_isUnicodeWordCharacter(_characterAt(source, end)); -} - -final _unicodeWordCharacter = RegExp(r'^[\p{L}\p{N}\p{M}_]$', unicode: true); - -bool _isUnicodeWordCharacter(String? character) { - if (character == null) { - return false; - } - return _unicodeWordCharacter.hasMatch(character); -} - -String? _characterBefore(String source, int offset) { - if (offset <= 0 || source.isEmpty) { - return null; - } - final end = offset.clamp(0, source.length).toInt(); - var start = end - 1; - if (_isLowSurrogate(source.codeUnitAt(start)) && - start > 0 && - _isHighSurrogate(source.codeUnitAt(start - 1))) { - start--; - } - return source.substring(start, end); -} - -String? _characterAt(String source, int offset) { - if (offset < 0 || offset >= source.length) { - return null; - } - var end = offset + 1; - if (_isHighSurrogate(source.codeUnitAt(offset)) && - end < source.length && - _isLowSurrogate(source.codeUnitAt(end))) { - end++; - } - return source.substring(offset, end); -} - bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF; bool _isLowSurrogate(int unit) => unit >= 0xDC00 && unit <= 0xDFFF; diff --git a/lib/src/editor/wysiwyg/writerside_properties.dart b/lib/src/editor/wysiwyg/writerside_properties.dart index 460d66ec..bfb38b2e 100644 --- a/lib/src/editor/wysiwyg/writerside_properties.dart +++ b/lib/src/editor/wysiwyg/writerside_properties.dart @@ -1083,12 +1083,19 @@ class BusyMarkWritersideProperties extends StatelessWidget { BusyMarkCommandCatalog.metadata; return Shortcuts( shortcuts: { + ...commands.blockedDefaultIntents(const { + BusyMarkCommandScope.textEditing, + }), if (!readOnly && onUndo != null) - commands['text.undo']!.shortcut!.activator: - const _PropertyHistoryIntent(false), + ...commands.bindingIntents( + 'text.undo', + const _PropertyHistoryIntent(false), + ), if (!readOnly && onRedo != null) - commands['text.redo']!.shortcut!.activator: - const _PropertyHistoryIntent(true), + ...commands.bindingIntents( + 'text.redo', + const _PropertyHistoryIntent(true), + ), }, child: Actions( actions: { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index e18c682b..2bad3006 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -1,3 +1,5 @@ +import '../../app/command_shortcuts.dart'; +import 'package:flutter/foundation.dart' show listEquals; import '../../writerside/writerside_effective_properties.dart'; import '../../writerside/writerside_model.dart'; import '../link_destination.dart'; @@ -829,31 +831,51 @@ class BusyMarkWysiwygEditorState extends State { } } final selectedBlockIds = _fullySelectedBlockIds(blocks); - return Shortcuts( + return BusyMarkCommandShortcuts( + isComposing: () => _hasActiveClipboardComposition, shortcuts: { + ...commandRegistry.blockedDefaultIntents(const { + BusyMarkCommandScope.textEditing, + BusyMarkCommandScope.editor, + BusyMarkCommandScope.wysiwyg, + }), ...commandRegistry.shortcutIntents( - scopes: const {BusyMarkCommandScope.editor}, + scopes: const { + BusyMarkCommandScope.editor, + BusyMarkCommandScope.wysiwyg, + }, intentFor: BusyMarkContextCommandIntent.new, ), - commandRegistry[BusyMarkCommandIds.textPaste]!.shortcut!.activator: - const _PasteTextIntent(BusyMarkPasteMode.normal), - commandRegistry[BusyMarkCommandIds.textPastePlainText]! - .shortcut! - .activator: const _PasteTextIntent( - BusyMarkPasteMode.plainText, + ...commandRegistry.bindingIntents( + BusyMarkCommandIds.textPaste, + const _PasteTextIntent(BusyMarkPasteMode.normal), + ), + ...commandRegistry.bindingIntents( + BusyMarkCommandIds.textPastePlainText, + const _PasteTextIntent(BusyMarkPasteMode.plainText), + ), + ...commandRegistry.bindingIntents( + BusyMarkCommandIds.textSelectAll, + const _SelectAllTextIntent(), + ), + ...commandRegistry.bindingIntents( + 'text.undo', + const _UndoEditorIntent(), + ), + ...commandRegistry.bindingIntents( + 'text.redo', + const _RedoEditorIntent(), ), - commandRegistry[BusyMarkCommandIds.textSelectAll]!.shortcut!.activator: - const _SelectAllTextIntent(), - commandRegistry['text.undo']!.shortcut!.activator: - const _UndoEditorIntent(), - commandRegistry['text.redo']!.shortcut!.activator: - const _RedoEditorIntent(), if (blockSelectionActive) - commandRegistry[BusyMarkCommandIds.textCopy]!.shortcut!.activator: - const _CopyBlockSelectionIntent(), + ...commandRegistry.bindingIntents( + BusyMarkCommandIds.textCopy, + const _CopyBlockSelectionIntent(), + ), if (blockSelectionActive) - commandRegistry[BusyMarkCommandIds.textCut]!.shortcut!.activator: - const _CutBlockSelectionIntent(), + ...commandRegistry.bindingIntents( + BusyMarkCommandIds.textCut, + const _CutBlockSelectionIntent(), + ), if (blockSelectionActive) const SingleActivator(LogicalKeyboardKey.backspace): const _DeleteBlockSelectionIntent(), @@ -861,12 +883,35 @@ class BusyMarkWysiwygEditorState extends State { const SingleActivator(LogicalKeyboardKey.delete): const _DeleteBlockSelectionIntent(), if (blockSelectionActive) - commandRegistry['text.escape']!.shortcut!.activator: - const _ClearBlockSelectionIntent(), + ...commandRegistry.bindingIntents( + 'text.escape', + const _ClearBlockSelectionIntent(), + ), }, child: Actions( actions: { BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + captureTarget: () { + final guard = _captureDialogTarget(); + final blockId = _activeBlockId, cellId = _activeCellId; + final target = _activeTextTarget(); + final selection = target?.controller.selection; + final ranges = [ + for (final r in _selectedTextRanges()) + (r.block.id, r.start, r.end), + ]; + return BusyMarkContextCommandSnapshot( + () => + _isDialogTargetCurrent(guard) && + blockId == _activeBlockId && + cellId == _activeCellId && + selection == _activeTextTarget()?.controller.selection && + listEquals(ranges, [ + for (final r in _selectedTextRanges()) + (r.block.id, r.start, r.end), + ]), + ); + }, isCommandEnabled: _canApplyContextCommand, onCommand: _applyContextCommand, ), @@ -4171,7 +4216,8 @@ class BusyMarkWysiwygEditorState extends State { } return KeyEventResult.handled; } - if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { + if (widget.onOpenSearch != null && + commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { widget.onOpenSearch?.call(); return KeyEventResult.handled; } @@ -4201,6 +4247,13 @@ class BusyMarkWysiwygEditorState extends State { _applyEditorShortcutAction(shortcutAction); return KeyEventResult.handled; } + if (commands.blocksReplacedDefault(event, keyboard, const { + BusyMarkCommandScope.editor, + BusyMarkCommandScope.wysiwyg, + BusyMarkCommandScope.textEditing, + })) { + return KeyEventResult.handled; + } if (event is KeyDownEvent && key == LogicalKeyboardKey.enter && keyboard.isControlPressed && @@ -4442,7 +4495,8 @@ class BusyMarkWysiwygEditorState extends State { } return KeyEventResult.handled; } - if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { + if (widget.onOpenSearch != null && + commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { widget.onOpenSearch?.call(); return KeyEventResult.handled; } @@ -5649,6 +5703,33 @@ class BusyMarkWysiwygEditorState extends State { } bool _canApplyContextCommand(String commandId) { + if (DocumentReadOnlyScope.of(context) && + !{ + BusyMarkCommandIds.textSelectAll, + BusyMarkCommandIds.textCopy, + BusyMarkCommandIds.editorCopyPlainText, + BusyMarkCommandIds.checkSpelling, + }.contains(commandId)) { + return false; + } + final writerside = writersideCommands[commandId]; + if (writerside != null) { + if (DocumentReadOnlyScope.of(context) || + !_documentController.document.isWriterside) { + return false; + } + return switch (writerside) { + BusyWritersideToolbarInsert(:final command) => + _documentController.canInsertWriterside(command, _activeBlockId) && + (!{ + BusyWritersideInsertCommand.include, + BusyWritersideInsertCommand.variable, + }.contains(command) || + widget.writersideProjectIndex != null), + BusyWritersideToolbarAdmonition() => _canApplyAdmonitionCommand(), + BusyWritersideToolbarSemantic() => _hasSemanticCommandTarget, + }; + } if ((commandId == BusyMarkCommandIds.textPaste || commandId == BusyMarkCommandIds.textPastePlainText) && _hasActiveClipboardComposition) { @@ -5656,9 +5737,49 @@ class BusyMarkWysiwygEditorState extends State { } if (commandId.startsWith('editor.')) { final name = commandId.substring('editor.'.length); - return BusyMarkEditorShortcutAction.values.any( - (action) => action.name == name, - ); + final action = BusyMarkEditorShortcutAction.values + .where((a) => a.name == name) + .firstOrNull; + if (action == null) return false; + return switch (action) { + BusyMarkEditorShortcutAction.bold || + BusyMarkEditorShortcutAction.italic || + BusyMarkEditorShortcutAction.underline || + BusyMarkEditorShortcutAction.strikethrough || + BusyMarkEditorShortcutAction.inlineCode || + BusyMarkEditorShortcutAction.link => _hasInlineCommandTarget, + BusyMarkEditorShortcutAction.paragraph => _canApplyBlockCommand( + BusyWysiwygBlockCommand.paragraph, + ), + BusyMarkEditorShortcutAction.heading1 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading1, + ), + BusyMarkEditorShortcutAction.heading2 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading2, + ), + BusyMarkEditorShortcutAction.heading3 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading3, + ), + BusyMarkEditorShortcutAction.heading4 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading4, + ), + BusyMarkEditorShortcutAction.heading5 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading5, + ), + BusyMarkEditorShortcutAction.heading6 => _canApplyBlockCommand( + BusyWysiwygBlockCommand.heading6, + ), + BusyMarkEditorShortcutAction.orderedList => _canApplyBlockCommand( + BusyWysiwygBlockCommand.orderedList, + ), + BusyMarkEditorShortcutAction.unorderedList => _canApplyBlockCommand( + BusyWysiwygBlockCommand.unorderedList, + ), + BusyMarkEditorShortcutAction.blockquote => _canApplyBlockCommand( + BusyWysiwygBlockCommand.blockquote, + ), + _ => true, + }; } if (commandId == BusyMarkCommandIds.checkSpelling) { return widget.onCheckSpelling != null; @@ -5676,6 +5797,30 @@ class BusyMarkWysiwygEditorState extends State { } void _applyContextCommand(String commandId) { + final writerside = writersideCommands[commandId]; + if (writerside != null) { + if (!_canApplyContextCommand(commandId)) return; + switch (writerside) { + case BusyWritersideToolbarInsert(:final command): + unawaited( + _applyWritersideInsert( + command, + _captureDialogTarget(), + _activeBlockId, + ), + ); + case BusyWritersideToolbarAdmonition(:final style): + _applyToolbarCommand(() => _applyAdmonitionCommand(style)); + case BusyWritersideToolbarSemantic(:final command): + if (_selectedTextRanges().isEmpty && + _activeTextTarget()?.controller.selection.isCollapsed == true) { + unawaited(_insertSemanticText(command)); + } else { + _applyToolbarCommand(() => _applyInlineCommand(command)); + } + } + return; + } if (commandId == BusyMarkCommandIds.checkSpelling) { widget.onCheckSpelling?.call(); return; @@ -8057,6 +8202,7 @@ class BusyMarkWysiwygEditorState extends State { label: command.label(context), icon: icon, shortcut: command.shortcut?.label, + gtkAccelerator: command.shortcut?.gtkAccelerator, ); } diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 4ee4dfcd..7bee489a 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -1,8 +1,9 @@ +import '../../app/busymark_shortcuts.dart' show BusyMarkShortcutDefinition; import 'package:flutter/material.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; -import '../../app/busymark_shortcuts.dart'; +import '../../app/command_registry.dart'; import '../../app/localization.dart'; import '../../markdown/busymark_document.dart'; import 'wysiwyg_commands.dart'; @@ -119,7 +120,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.bold, icon: BusyMarkGlyphs.bold, - shortcut: BusyMarkEditorShortcutLabels.bold, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.bold'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? () => onInlineCommand(BusyWysiwygInlineCommand.bold) : null, @@ -128,7 +133,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.italic, icon: BusyMarkGlyphs.italic, - shortcut: BusyMarkEditorShortcutLabels.italic, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.italic'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? () => onInlineCommand(BusyWysiwygInlineCommand.italic) : null, @@ -137,7 +146,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.underline, icon: BusyMarkGlyphs.underline, - shortcut: BusyMarkEditorShortcutLabels.underline, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.underline'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? () => onInlineCommand(BusyWysiwygInlineCommand.underline) : null, @@ -146,7 +159,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.strikethrough, icon: BusyMarkGlyphs.strikethrough, - shortcut: BusyMarkEditorShortcutLabels.strikethrough, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog + .metadata)['editor.strikethrough'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? () => onInlineCommand(BusyWysiwygInlineCommand.strikethrough) @@ -156,7 +174,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.inlineCode, icon: BusyMarkGlyphs.code, - shortcut: BusyMarkEditorShortcutLabels.inlineCode, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.inlineCode'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? () => onInlineCommand(BusyWysiwygInlineCommand.code) : null, @@ -165,7 +187,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.link, icon: BusyMarkGlyphs.link, - shortcut: BusyMarkEditorShortcutLabels.link, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.link'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled ? onLinkCommand : null, ), _button( @@ -178,7 +204,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.hardLineBreak, icon: BusyMarkGlyphs.hardBreak, - shortcut: BusyMarkEditorShortcutLabels.hardLineBreak, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog + .metadata)['editor.hardLineBreak'] + ?.shortcut + ?.label, onPressed: inlineCommandsEnabled && lineBreakCommandsEnabled ? onHardBreakCommand : null, @@ -198,7 +229,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.blockquote, icon: BusyMarkGlyphs.blockquote, - shortcut: BusyMarkEditorShortcutLabels.blockquote, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.blockquote'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.blockquote) ? () => onBlockCommand(BusyWysiwygBlockCommand.blockquote) @@ -208,7 +243,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.codeBlock, icon: BusyMarkGlyphs.codeBlock, - shortcut: BusyMarkEditorShortcutLabels.codeBlock, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.codeBlock'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.codeBlock) ? () => onBlockCommand(BusyWysiwygBlockCommand.codeBlock) : null, @@ -229,7 +268,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.thematicBreak, icon: BusyMarkGlyphs.thematicBreak, - shortcut: BusyMarkEditorShortcutLabels.thematicBreak, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog + .metadata)['editor.thematicBreak'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.thematicBreak) ? () => onBlockCommand(BusyWysiwygBlockCommand.thematicBreak) @@ -241,7 +285,12 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.unorderedList, icon: BusyMarkGlyphs.unorderedList, - shortcut: BusyMarkEditorShortcutLabels.unorderedList, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog + .metadata)['editor.unorderedList'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.unorderedList) ? () => onBlockCommand(BusyWysiwygBlockCommand.unorderedList) @@ -251,7 +300,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.orderedList, icon: BusyMarkGlyphs.orderedList, - shortcut: BusyMarkEditorShortcutLabels.orderedList, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.orderedList'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.orderedList) ? () => onBlockCommand(BusyWysiwygBlockCommand.orderedList) @@ -261,7 +314,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.taskList, icon: BusyMarkGlyphs.checkedBox, - shortcut: BusyMarkEditorShortcutLabels.taskList, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.taskList'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.taskList) ? () => onBlockCommand(BusyWysiwygBlockCommand.taskList) : null, @@ -269,6 +326,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { _button( context, tooltip: context.l10n.toggleTaskChecked, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.toggleTask'] + ?.shortcut + ?.label, icon: BusyMarkGlyphs.checkedBox, onPressed: onToggleTaskCommand, ), @@ -276,14 +338,22 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.indentListItem, icon: BusyMarkGlyphs.indentFor(direction), - shortcut: BusyMarkEditorShortcutLabels.indent, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.indent'] + ?.shortcut + ?.label, onPressed: onIndentCommand, ), _button( context, tooltip: context.l10n.outdentListItem, icon: BusyMarkGlyphs.outdentFor(direction), - shortcut: BusyMarkEditorShortcutLabels.outdent, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.outdent'] + ?.shortcut + ?.label, onPressed: onOutdentCommand, ), ], @@ -292,7 +362,11 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.image, icon: BusyMarkGlyphs.image, - shortcut: BusyMarkEditorShortcutLabels.image, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.image'] + ?.shortcut + ?.label, onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.image) ? onImageCommand : null, @@ -300,12 +374,22 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { _button( context, tooltip: context.l10n.inlineImage, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.inlineImage'] + ?.shortcut + ?.label, icon: BusyMarkGlyphs.inlineImage, onPressed: inlineCommandsEnabled ? onInlineImageCommand : null, ), _button( context, tooltip: context.l10n.table, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.table'] + ?.shortcut + ?.label, icon: BusyMarkGlyphs.table, onPressed: onTableCommand, ), @@ -337,6 +421,14 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { for (final command in BusyWritersideInsertCommand.values) BusyMarkPopupMenuItem( value: BusyWritersideToolbarInsert(command), + shortcut: _writersideShortcut( + context, + BusyWritersideToolbarInsert(command), + )?.label, + gtkAccelerator: _writersideShortcut( + context, + BusyWritersideToolbarInsert(command), + )?.gtkAccelerator, label: busyMarkWritersideInsertLabel(context, command), enabled: isWritersideInsertEnabled?.call(command) ?? true, ), @@ -349,6 +441,14 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { for (final style in BusyAdmonitionStyle.values) BusyMarkPopupMenuItem( value: BusyWritersideToolbarAdmonition(style), + shortcut: _writersideShortcut( + context, + BusyWritersideToolbarAdmonition(style), + )?.label, + gtkAccelerator: _writersideShortcut( + context, + BusyWritersideToolbarAdmonition(style), + )?.gtkAccelerator, label: switch (style) { BusyAdmonitionStyle.tip => context.l10n.tip, BusyAdmonitionStyle.note => context.l10n.note, @@ -370,6 +470,14 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { ]) BusyMarkPopupMenuItem( value: BusyWritersideToolbarSemantic(command), + shortcut: _writersideShortcut( + context, + BusyWritersideToolbarSemantic(command), + )?.label, + gtkAccelerator: _writersideShortcut( + context, + BusyWritersideToolbarSemantic(command), + )?.gtkAccelerator, enabled: inlineCommandsEnabled && semanticCommandsEnabled, label: busyMarkSemanticInlineLabel(context, command), ), @@ -411,7 +519,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { onCanceled: onMenuCanceled, tooltip: context.l10n.textStyle, icon: BusyMarkGlyphs.font, - shortcut: BusyMarkEditorShortcutLabels.textStyle, + shortcut: null, transparent: false, elevated: true, foregroundColor: BusyMarkLinuxPalette.white, @@ -421,49 +529,77 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { value: BusyWysiwygBlockCommand.paragraph, label: context.l10n.paragraph, icon: BusyMarkGlyphs.paragraph, - shortcut: BusyMarkEditorShortcutLabels.paragraph, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.paragraph'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.paragraph), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading1, label: context.l10n.heading1, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading1, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading1'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading1), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading2, label: context.l10n.heading2, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading2, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading2'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading2), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading3, label: context.l10n.heading3, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading3, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading3'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading3), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading4, label: context.l10n.heading4, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading4, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading4'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading4), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading5, label: context.l10n.heading5, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading5, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading5'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading5), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading6, label: context.l10n.heading6, icon: BusyMarkGlyphs.heading, - shortcut: BusyMarkEditorShortcutLabels.heading6, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)['editor.heading6'] + ?.shortcut + ?.label, enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading6), ), ], @@ -506,3 +642,13 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { }); } } + +BusyMarkShortcutDefinition? _writersideShortcut( + BuildContext context, + BusyWritersideToolbarCommand command, +) => + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[writersideToolbarCommandId( + command, + )] + ?.shortcut; diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index 2e4fc7e8..c6c36fc0 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -53,11 +53,6 @@ final localHistoryOpenRequestProvider = LocalHistoryOpenRequestController.new, ); -final localHistoryFindRequestProvider = - NotifierProvider( - LocalHistoryOpenRequestController.new, - ); - class LocalHistoryOpenRequestController extends Notifier { @override int build() => 0; @@ -68,29 +63,23 @@ class LocalHistoryOpenRequestController extends Notifier { class LocalHistoryState { const LocalHistoryState({ this.snapshot = const LocalHistorySnapshot(), + this.searchRevision = 0, this.loading = false, this.selectedDocumentId, this.selectedRevisionId, this.selectedRevision, - this.searchQuery = '', - this.searching = false, - this.searchMatches = const {}, - this.documentSearchMatches = const {}, - this.findingDocuments = false, + this.browsingDocuments = false, this.inspectingRetainedDocument = false, this.warning, }); final LocalHistorySnapshot snapshot; + final int searchRevision; 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 browsingDocuments; final bool inspectingRetainedDocument; final LocalHistoryWarning? warning; @@ -103,24 +92,19 @@ class LocalHistoryState { ? const [] : snapshot.revisionsFor(selectedDocumentId!); - bool revisionVisible(LocalHistoryRevisionSummary revision) => - searchQuery.isEmpty || searchMatches.contains(revision.id); - LocalHistoryState copyWith({ LocalHistorySnapshot? snapshot, + int? searchRevision, bool? loading, Object? selectedDocumentId = _unset, Object? selectedRevisionId = _unset, Object? selectedRevision = _unset, - String? searchQuery, - bool? searching, - Set? searchMatches, - Set? documentSearchMatches, - bool? findingDocuments, + bool? browsingDocuments, bool? inspectingRetainedDocument, Object? warning = _unset, }) => LocalHistoryState( snapshot: snapshot ?? this.snapshot, + searchRevision: searchRevision ?? this.searchRevision, loading: loading ?? this.loading, selectedDocumentId: identical(selectedDocumentId, _unset) ? this.selectedDocumentId @@ -131,11 +115,7 @@ class LocalHistoryState { 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, + browsingDocuments: browsingDocuments ?? this.browsingDocuments, inspectingRetainedDocument: inspectingRetainedDocument ?? this.inspectingRetainedDocument, warning: identical(warning, _unset) @@ -418,7 +398,6 @@ class LocalHistoryController extends Notifier { var _historyGeneration = 0; var _snapshotGeneration = 0; var _loadGeneration = 0; - var _searchGeneration = 0; var _scopeGeneration = 0; var _pathOperationSerial = 0; Future Function()? _persistDurableState; @@ -3840,17 +3819,12 @@ class LocalHistoryController extends Notifier { 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, + browsingDocuments: false, inspectingRetainedDocument: false, ); _showCaptureFailure(); @@ -3874,7 +3848,7 @@ class LocalHistoryController extends Notifier { } else { state = state.copyWith( loading: false, - findingDocuments: false, + browsingDocuments: false, inspectingRetainedDocument: false, ); } @@ -3897,17 +3871,12 @@ class LocalHistoryController extends Notifier { _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, + browsingDocuments: false, inspectingRetainedDocument: false, ); _showCaptureFailure(); @@ -3928,39 +3897,29 @@ class LocalHistoryController extends Notifier { return; } _loadGeneration++; - _searchGeneration++; state = state.copyWith( loading: false, selectedDocumentId: documentId, selectedRevisionId: null, selectedRevision: null, - searchQuery: '', - searching: false, - searchMatches: const {}, - documentSearchMatches: const {}, - findingDocuments: false, + browsingDocuments: false, inspectingRetainedDocument: inspectingRetainedDocument, ); _showCaptureFailure(); } - /// Opens the store-wide discovery surface used by Find in Local History. + /// Browses retained documents without filtering the revision timeline. /// No current editor is implied: closed, renamed, untitled, and deleted /// documents remain first-class results. - void beginDocumentSearch() { + void browseDocuments() { _scopeGeneration++; _loadGeneration++; - _searchGeneration++; state = state.copyWith( loading: false, selectedDocumentId: null, selectedRevisionId: null, selectedRevision: null, - searchQuery: '', - searching: false, - searchMatches: const {}, - documentSearchMatches: const {}, - findingDocuments: true, + browsingDocuments: true, inspectingRetainedDocument: false, ); _showCaptureFailure(); @@ -3990,7 +3949,11 @@ class LocalHistoryController extends Notifier { if (document != null) selectDocument(document.id); } - Future selectRevision(String revisionId) async { + Future selectRevision( + String revisionId, { + bool Function()? acceptSelection, + }) async { + if (acceptSelection?.call() == false) return; final documentId = state.selectedDocumentId; final summary = state.snapshot.revisions .where((revision) => revision.id == revisionId) @@ -4011,6 +3974,14 @@ class LocalHistoryController extends Notifier { (revision != null && revision.summary.documentId != documentId)) { return; } + if (acceptSelection?.call() == false) { + state = state.copyWith( + loading: false, + selectedRevisionId: null, + selectedRevision: null, + ); + return; + } state = state.copyWith( selectedRevision: revision, loading: false, @@ -4025,6 +3996,14 @@ class LocalHistoryController extends Notifier { state.selectedRevisionId != revisionId) { return; } + if (acceptSelection?.call() == false) { + state = state.copyWith( + loading: false, + selectedRevisionId: null, + selectedRevision: null, + ); + return; + } state = state.copyWith( loading: false, warning: LocalHistoryWarning( @@ -4113,11 +4092,8 @@ class LocalHistoryController extends Notifier { return false; } - Future search(String query) async { - await _runSearch(query, clearMatches: true); - } - Future clearDocument(String documentId) async { + _invalidateFindRequests(); final durableBeforeClear = _durableStateFingerprint(); final document = state.snapshot.documents .where((candidate) => candidate.id == documentId) @@ -4266,7 +4242,6 @@ class LocalHistoryController extends Notifier { } _checkpointTimers.remove(_pathRemapOwner)?.cancel(); _loadGeneration++; - _searchGeneration++; _documentIdsByBuffer.removeWhere((_, value) => value == documentId); final retiredPromotions = _pendingUntitledPromotions.values .where((promotion) => promotion.documentId == documentId) @@ -4387,6 +4362,7 @@ class LocalHistoryController extends Notifier { } Future clearAll() async { + _invalidateFindRequests(); final durableBeforeClear = _durableStateFingerprint(); final affectedBuffers = { ..._documentIdsByBuffer.keys, @@ -4394,7 +4370,6 @@ class LocalHistoryController extends Notifier { }; _historyGeneration++; _loadGeneration++; - _searchGeneration++; _cancelCheckpoints(clearTransitions: false); _captureFailures.clear(); _documentIdsByBuffer.clear(); @@ -5966,7 +5941,6 @@ class LocalHistoryController extends Notifier { _clearEpochLoaded = true; _storeWarning = warning; _snapshotGeneration++; - _searchGeneration++; if (capturedBufferId != null && _normalBrowsingScope?.bufferId == capturedBufferId && capturedSnapshot != null && @@ -5975,7 +5949,7 @@ class LocalHistoryController extends Notifier { } var selectedDocumentId = state.selectedDocumentId; - if (!state.findingDocuments && !state.inspectingRetainedDocument) { + if (!state.browsingDocuments && !state.inspectingRetainedDocument) { final scope = _normalBrowsingScope; if (scope != null) { selectedDocumentId = _documentIdsByBuffer[scope.bufferId]; @@ -6040,106 +6014,88 @@ class LocalHistoryController extends Notifier { selectedRevision = null; } - final query = state.searchQuery; state = state.copyWith( snapshot: snapshot, + searchRevision: state.searchRevision + 1, 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; - } + void _invalidateFindRequests() { + _snapshotGeneration++; + state = state.copyWith(searchRevision: state.searchRevision + 1); + } + + /// Searches a retained snapshot without binding an editor, capturing a + /// revision, selecting a timeline or replacing an existing comparison. + Future findRetained({ + required String query, + String? documentId, + int maximumResults = 80, + bool Function()? isCancelled, + void Function(LocalHistorySearchPage page)? onProgress, + }) async { final snapshot = state.snapshot; - final summaries = findingDocuments - ? snapshot.revisions - : documentId == null - ? const [] - : snapshot.revisionsFor(documentId); - final matches = {}; - final documentMatches = {}; - final needle = query.toLowerCase(); + final version = _snapshotGeneration; 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); - } + version == _snapshotGeneration && + !(isCancelled?.call() ?? false); + final documents = { + for (final document in snapshot.documents) + if (documentId == null || document.id == documentId) + document.id: document, + }; + final matches = []; + final needle = query.toLowerCase(); + LocalHistorySearchPage page({bool truncated = false}) => + LocalHistorySearchPage( + List.unmodifiable(matches.take(maximumResults)), + truncated: truncated, + ); + final orderedDocuments = documents.values.toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + if (documentId == null) { + for (final document in orderedDocuments) { + if (!current()) return null; + if (document.displayName.toLowerCase().contains(needle) || + (document.currentPath?.toLowerCase().contains(needle) ?? false) || + document.historicalPaths.any( + (path) => path.toLowerCase().contains(needle), + )) { + matches.add(LocalHistoryDocumentMatch(document)); + if (matches.length > maximumResults) return page(truncated: true); } } - 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 (matches.isNotEmpty) onProgress?.call(page()); + if (needle.isEmpty) return current() ? page() : null; + } + final summaries = + snapshot.revisions + .where((r) => documents.containsKey(r.documentId)) + .toList() + ..sort((a, b) => b.capturedAt.compareTo(a.capturedAt)); + for (final summary in summaries) { + if (!current()) return null; + // Empty scoped queries can use summaries without reading any bodies. + final revision = needle.isEmpty + ? null + : await _store.readRevision(summary.id); + if (!current()) return null; + if (needle.isEmpty || + revision?.source.toLowerCase().contains(needle) == true) { + matches.add( + LocalHistoryRevisionMatch(documents[summary.documentId]!, summary), + ); + if (matches.length > maximumResults) return page(truncated: true); + onProgress?.call(page()); } - 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(), - ), - ); } + return current() ? page() : null; } void _setWarning(LocalHistoryWarningKind kind, [String? detail]) { diff --git a/lib/src/local_history/local_history_models.dart b/lib/src/local_history/local_history_models.dart index 096f379b..a44c9d4f 100644 --- a/lib/src/local_history/local_history_models.dart +++ b/lib/src/local_history/local_history_models.dart @@ -201,6 +201,32 @@ class LocalHistoryRevision { bool get isIntact => sourceChecksum(source) == summary.checksum; } +/// Search returns navigation metadata only. Revision bodies remain in the store. +sealed class LocalHistorySearchMatch { + const LocalHistorySearchMatch(this.document); + final LocalHistoryDocument document; + String get identity; +} + +final class LocalHistoryDocumentMatch extends LocalHistorySearchMatch { + const LocalHistoryDocumentMatch(super.document); + @override + String get identity => 'history-document:${document.id}'; +} + +final class LocalHistoryRevisionMatch extends LocalHistorySearchMatch { + const LocalHistoryRevisionMatch(super.document, this.revision); + final LocalHistoryRevisionSummary revision; + @override + String get identity => 'history-revision:${document.id}:${revision.id}'; +} + +class LocalHistorySearchPage { + const LocalHistorySearchPage(this.matches, {this.truncated = false}); + final List matches; + final bool truncated; +} + @immutable class LocalHistorySnapshot { const LocalHistorySnapshot({ diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index 50040ba8..05ee9f4f 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -7,7 +7,6 @@ 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'; @@ -15,33 +14,23 @@ import 'local_history_controller.dart'; import 'local_history_models.dart'; class LocalHistoryPanel extends ConsumerStatefulWidget { - const LocalHistoryPanel({this.focusSearchRequest = 0, super.key}); - - final int focusSearchRequest; + const LocalHistoryPanel({super.key}); @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)) { + (history.browsingDocuments || history.inspectingRetainedDocument)) { return; } final buffer = ref.read(workspaceControllerProvider).activeBuffer; @@ -58,12 +47,6 @@ class _LocalHistoryPanelState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(localHistoryControllerProvider); - if (_searchController.text != state.searchQuery) { - _searchController.value = TextEditingValue( - text: state.searchQuery, - selection: TextSelection.collapsed(offset: state.searchQuery.length), - ); - } final controller = ref.read(localHistoryControllerProvider.notifier); final workspaceState = ref.watch(workspaceControllerProvider); final activeBuffer = workspaceState.activeBuffer; @@ -80,7 +63,7 @@ class _LocalHistoryPanelState extends ConsumerState { if (previous == next) return; if (next.bufferId == null) { final history = ref.read(localHistoryControllerProvider); - if (!history.findingDocuments && + if (!history.browsingDocuments && !history.inspectingRetainedDocument) { controller.clearDocumentScope(); } @@ -92,26 +75,8 @@ class _LocalHistoryPanelState extends ConsumerState { } }, ); - 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] + final visibleDocuments = [...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 @@ -128,91 +93,61 @@ class _LocalHistoryPanelState extends ConsumerState { 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)), - ), + _LocalHistoryHeader( + selectedDocument: selected, + browsingDocuments: state.browsingDocuments, + inspectingRetainedDocument: state.inspectingRetainedDocument, + onBack: () => unawaited(_selectActive(preserveLookup: false)), + actions: 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, ), - 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)); - } - }, + 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.clearDocument: + final documentId = selected?.id; + if (documentId != null) { + unawaited(_confirmClear(action, documentId)); + } + case _HistoryAction.clearAll: + unawaited(_confirmClear(action, null)); + } + }, ), ), if (state.warning != null) @@ -233,17 +168,11 @@ class _LocalHistoryPanelState extends ConsumerState { ), ), Expanded( - child: state.findingDocuments + child: state.browsingDocuments ? visibleDocuments.isEmpty - ? _HistoryEmpty( - text: documents.isEmpty - ? context.l10n.localHistoryNoDocuments - : context.l10n.localHistoryNoMatches, - ) + ? _HistoryEmpty(text: context.l10n.localHistoryNoDocuments) : ListView.builder( - key: const ValueKey( - 'local-history-document-search-results', - ), + key: const ValueKey('local-history-documents'), padding: BusyMarkInsets.sidebarList, itemCount: visibleDocuments.length, itemBuilder: (context, index) { @@ -274,11 +203,7 @@ class _LocalHistoryPanelState extends ConsumerState { : activeBuffer == null && !state.inspectingRetainedDocument ? _HistoryEmpty(text: context.l10n.localHistoryNoDocuments) : visibleRevisions.isEmpty - ? _HistoryEmpty( - text: state.searchQuery.isEmpty - ? context.l10n.localHistoryNoRevisions - : context.l10n.localHistoryNoMatches, - ) + ? _HistoryEmpty(text: context.l10n.localHistoryNoRevisions) : ListView.builder( key: ValueKey( 'local-history-revisions-${selected?.id ?? 'none'}', @@ -392,74 +317,79 @@ typedef _LocalHistoryEditorScope = ({ String? displayName, }); -enum _HistoryAction { refresh, find, clearDocument, clearAll } +enum _HistoryAction { refresh, clearDocument, clearAll } class _LocalHistoryHeader extends StatelessWidget { const _LocalHistoryHeader({ required this.selectedDocument, - required this.findingDocuments, + required this.browsingDocuments, required this.inspectingRetainedDocument, required this.onBack, + required this.actions, }); final LocalHistoryDocument? selectedDocument; - final bool findingDocuments; + final bool browsingDocuments; final bool inspectingRetainedDocument; final VoidCallback onBack; + final Widget actions; @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; + final contextTitle = browsingDocuments + ? context.l10n.searchDocumentsCategory + : selectedDocument?.displayName; + final retained = browsingDocuments || inspectingRetainedDocument; return Padding( - key: const ValueKey('local-history-context-header'), - padding: const EdgeInsets.fromLTRB( + key: const ValueKey('local-history-panel-header'), + padding: const EdgeInsetsDirectional.fromSTEB( BusyMarkSpacing.md, BusyMarkSpacing.sm, + BusyMarkSpacing.md, 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), + if (retained) ...[ + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.localHistory, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (contextTitle != null) + Tooltip( + message: selectedPath ?? contextTitle, child: Text( - busyMarkLtrIsolateFor(context, title), + contextTitle, + key: const ValueKey('local-history-document-context'), 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, ), - ], - ], - ), + ), + ], ), ), + if (inspectingRetainedDocument && selectedDocument?.deleted == true) + Text( + context.l10n.localHistoryDeleted, + style: Theme.of(context).textTheme.bodySmall, + ), + actions, ], ), ); diff --git a/lib/src/markdown/document_outline.dart b/lib/src/markdown/document_outline.dart index 9e26daa4..c4152574 100644 --- a/lib/src/markdown/document_outline.dart +++ b/lib/src/markdown/document_outline.dart @@ -83,3 +83,34 @@ extension BusyDocumentOutline on BusyDocument { return List.unmodifiable(headings); } } + +/// A projection of the existing model; browsing keeps the complete tree. +class DocumentOutlineMatch { + const DocumentOutlineMatch(this.heading, this.ancestors); + final DocumentOutlineHeading heading; + final List ancestors; +} + +/// Match headings in document order, retaining ancestor context and the exact +/// source/editor identities. This never parses or changes the outline. +List matchDocumentOutline( + List headings, + String query, { + int maximumResults = 80, +}) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty || maximumResults <= 0) return const []; + final matches = []; + final ancestors = []; + for (final heading in headings) { + while (ancestors.isNotEmpty && ancestors.last.level >= heading.level) { + ancestors.removeLast(); + } + if (heading.text.toLowerCase().contains(normalized)) { + matches.add(DocumentOutlineMatch(heading, List.unmodifiable(ancestors))); + if (matches.length >= maximumResults) break; + } + ancestors.add(heading); + } + return List.unmodifiable(matches); +} diff --git a/lib/src/nextcloud_notes/application/nextcloud_connection.dart b/lib/src/nextcloud_notes/application/nextcloud_connection.dart index 88e6148d..9b56b733 100644 --- a/lib/src/nextcloud_notes/application/nextcloud_connection.dart +++ b/lib/src/nextcloud_notes/application/nextcloud_connection.dart @@ -1,3 +1,4 @@ +import '../../app/app_settings.dart'; import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -366,6 +367,9 @@ class NextcloudConnectionController extends Notifier { account.id, beforeRemove: () => secrets.delete(account.id), ); + await ref + .read(appSettingsControllerProvider.notifier) + .removeRecentAccount(account.id); if (!_disposed) { state = NextcloudConnectionState( error: revoked diff --git a/lib/src/nextcloud_notes/application/notes_search_controller.dart b/lib/src/nextcloud_notes/application/notes_search_controller.dart index 890bdbca..49be522e 100644 --- a/lib/src/nextcloud_notes/application/notes_search_controller.dart +++ b/lib/src/nextcloud_notes/application/notes_search_controller.dart @@ -53,7 +53,6 @@ class NotesSearchController { Future search( String text, { - bool wholeWord = false, int limit = 80, List overlays = const [], }) async { @@ -61,7 +60,11 @@ class NotesSearchController { bool current() => !_disposed && generation == _generation; _publish(const NotesSearchState(loading: true, indexing: true)); try { - final query = NotesSearchQuery(text, wholeWord: wholeWord); + final query = NotesSearchQuery(text); + if (query.terms.isEmpty) { + if (current()) _publish(const NotesSearchState()); + return; + } while (await store.indexStep() != 0) { if (!current()) return; } diff --git a/lib/src/nextcloud_notes/data/notes_store.dart b/lib/src/nextcloud_notes/data/notes_store.dart index 0161959f..e2b66c52 100644 --- a/lib/src/nextcloud_notes/data/notes_store.dart +++ b/lib/src/nextcloud_notes/data/notes_store.dart @@ -237,7 +237,6 @@ class NotesStore { await _call('searchChunk', { 'account': accountId, 'query': query.text, - 'wholeWord': query.wholeWord, 'after': after, 'continuation': continuation, 'recovery': recovery, @@ -462,10 +461,7 @@ Object? _execute(Database db, String action, dynamic argument) { return null; case 'searchChunk': final a = argument as Map; - final query = NotesSearchQuery( - a['query'] as String, - wholeWord: a['wholeWord'] as bool, - ); + final query = NotesSearchQuery(a['query'] as String); final expression = query.ftsCandidates; final recovery = a['recovery'] == true; final rows = db.select( diff --git a/lib/src/nextcloud_notes/domain/notes_search.dart b/lib/src/nextcloud_notes/domain/notes_search.dart index e5b3c40b..531a8eeb 100644 --- a/lib/src/nextcloud_notes/domain/notes_search.dart +++ b/lib/src/nextcloud_notes/domain/notes_search.dart @@ -16,7 +16,7 @@ class NotesSearchTerm { /// Only these two field prefixes and quotes are grammar. Everything else, /// including SQL/FTS operators, punctuation and wildcards, is literal text. class NotesSearchQuery { - NotesSearchQuery(this.text, {this.wholeWord = false}) { + NotesSearchQuery(this.text) { if (text.length > 512) throw const FormatException('Search is too long.'); if ('"'.allMatches(text).length.isOdd) { throw const FormatException('Close the quoted phrase.'); @@ -37,7 +37,6 @@ class NotesSearchQuery { } } final String text; - final bool wholeWord; late final List terms; String? get ftsCandidates { final indexable = terms.where((t) => t.literal.runes.length >= 3); @@ -105,48 +104,17 @@ class NotesSearchHit { String notesSearchDigest(String source) => sha256.convert(utf8.encode(source)).toString(); -final _word = RegExp(r'[\p{L}\p{M}\p{N}_]', unicode: true); - -Iterable<({int start, int end})> _ranges( - String normalized, - String term, - bool wholeWord, -) sync* { +Iterable<({int start, int end})> _ranges(String normalized, String term) sync* { var position = 0; while (position <= normalized.length) { final start = normalized.indexOf(term, position); if (start < 0) break; final end = start + term.length; - // Unicode identifier boundaries; punctuation in the query remains literal. - if (!wholeWord || - ((start == 0 || - !_word.hasMatch( - String.fromCharCode(normalized.runesBefore(start)), - )) && - (end == normalized.length || - !_word.hasMatch( - String.fromCharCode(normalized.runesAt(end)), - )))) { - yield (start: start, end: end); - } + yield (start: start, end: end); position = end; } } -extension on String { - int runesBefore(int offset) { - var start = offset - 1; - if (start > 0 && - codeUnitAt(start) >= 0xdc00 && - codeUnitAt(start) <= 0xdfff) { - start--; - } - return substring(start, offset).runes.first; - } - - int runesAt(int offset) => substring(offset).runes.first; -} - /// Shared verifier for indexed durable documents, dirty overlays and stale /// navigation. Offsets map back through grapheme clusters to authored UTF-16. List matchNotesDocument({ @@ -169,15 +137,15 @@ List matchNotesDocument({ for (final term in query.terms) { final contentMatches = (term.field == null - ? _ranges(body, term.literal, query.wholeWord) + ? _ranges(body, term.literal) : const <({int start, int end})>[]) .iterator; final hasContent = contentMatches.moveNext(); final titleMatches = term.field != 'category' - ? _ranges(normalizedTitle, term.literal, query.wholeWord) + ? _ranges(normalizedTitle, term.literal) : <({int start, int end})>[]; final categoryMatches = term.field != 'title' - ? _ranges(normalizedCategory, term.literal, query.wholeWord) + ? _ranges(normalizedCategory, term.literal) : <({int start, int end})>[]; if (!hasContent && titleMatches.isEmpty && categoryMatches.isEmpty) { return []; diff --git a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart index a6c7822e..a2eabc24 100644 --- a/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart +++ b/lib/src/nextcloud_notes/presentation/nextcloud_settings.dart @@ -1,3 +1,4 @@ +import '../../search/welcome_notes_action.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -23,14 +24,30 @@ class NextcloudNotesSettings extends ConsumerStatefulWidget { class _NextcloudNotesSettingsState extends ConsumerState { final _server = TextEditingController(); + late final WelcomeNotesActionController _pendingWelcome; + bool _returningToWelcome = false; + @override + void initState() { + super.initState(); + _pendingWelcome = ref.read(welcomeNotesActionProvider.notifier); + } + @override void dispose() { + if (!_returningToWelcome) Future.microtask(_pendingWelcome.clear); _server.dispose(); super.dispose(); } Future _connect() async { - await ref.read(nextcloudConnectionProvider.notifier).connect(_server.text); + final connected = await ref + .read(nextcloudConnectionProvider.notifier) + .connect(_server.text); + if (mounted && connected && ref.read(welcomeNotesActionProvider) != null) { + _returningToWelcome = true; + context.go('/'); + return; + } if (!mounted) return; final account = ref.read(nextcloudConnectionProvider).account; if (account != null) { @@ -119,7 +136,10 @@ class _NextcloudNotesSettingsState if (connection.phase == NextcloudConnectionPhase.awaitingBrowser) BusyMarkActionRow( title: context.l10n.cancel, - onTap: controller.cancel, + onTap: () { + ref.read(welcomeNotesActionProvider.notifier).clear(); + controller.cancel(); + }, ), ], ), diff --git a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart index 7cb6a4b4..9dda003d 100644 --- a/lib/src/nextcloud_notes/presentation/notes_sidebar.dart +++ b/lib/src/nextcloud_notes/presentation/notes_sidebar.dart @@ -1,3 +1,4 @@ +import '../../search/search_session.dart'; import 'dart:async'; import 'package:file_selector/file_selector.dart'; @@ -365,20 +366,16 @@ class _NextcloudNotesSidebarState extends ConsumerState { ), Padding( padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.sm), - child: TextField( - readOnly: true, - decoration: InputDecoration( - hintText: context.l10n.search, - prefixIcon: const Icon(BusyMarkGlyphs.search), - ), - onTap: () => unawaited( - showNotesSearch( - context, - ref, - account: widget.accountId, - recovery: _navigation.destination == NotesDestination.recovery, - ), - ), + child: BusyMarkPushButton.standard( + key: const ValueKey('notes-search-entry'), + child: Text(context.l10n.search), + onPressed: () => ref + .read(searchSessionProvider.notifier) + .open( + category: SearchCategory.workspace, + recovery: + _navigation.destination == NotesDestination.recovery, + ), ), ), SizedBox( diff --git a/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart index 83053aea..2de1e7a0 100644 --- a/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart +++ b/lib/src/nextcloud_notes/presentation/notes_workspace_ui.dart @@ -2,13 +2,10 @@ import 'dart:async'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../app/app_settings.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_dialogs.dart'; -import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; import '../../local_history/local_history_controller.dart'; import '../../local_history/local_history_models.dart'; @@ -17,10 +14,8 @@ import '../application/nextcloud_connection.dart'; import '../application/notes_navigation.dart'; import '../application/notes_repository.dart'; import '../application/notes_offline_controller.dart'; -import '../application/notes_search_controller.dart'; import '../application/notes_transfer_service.dart'; import '../domain/notes_models.dart'; -import '../domain/notes_search.dart'; final notesOfflineProvider = FutureProvider.autoDispose .family((ref, accountId) async { @@ -49,9 +44,11 @@ class NotesMatchNavigation { this.start, this.end, this.digest, - this.request, - ); + this.request, { + this.accountId, + }); final String localId, digest; + final String? accountId; final int start, end, request; } @@ -63,392 +60,20 @@ final notesMatchNavigationProvider = class NotesMatchNavigationController extends Notifier { @override NotesMatchNavigation? build() => null; - void navigate(String id, int start, int end, String digest) { + void navigate( + String id, + int start, + int end, + String digest, { + String? accountId, + }) { state = NotesMatchNavigation( id, start, end, digest, (state?.request ?? 0) + 1, - ); - } -} - -Future showNotesSearch( - BuildContext context, - WidgetRef ref, { - String query = '', - String? account, - bool recovery = false, -}) async { - final accountId = - account ?? - ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId; - if (accountId == null) return; - final focus = FocusManager.instance.primaryFocus; - final choice = - await showBusyMarkModalDialog< - ({NotesSearchHit hit, String query, bool wholeWord, bool source}) - >( - context, - builder: (_) => NotesSearchDialog( - accountId: accountId, - initialQuery: query, - recovery: recovery, - ), - ); - if (!context.mounted) return; - if (choice == null) { - if (focus?.context?.mounted == true) focus!.requestFocus(); - return; - } - if (ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != - accountId) { - return; - } - if (recovery) { - final repository = await ref.read(nextcloudNotesRepositoryProvider.future); - final note = repository.noteById(choice.hit.localId); - if (context.mounted && - note != null && - note.accountId == accountId && - isNotesRecovery(note)) { - await showNotesRecovery(context, ref, note); - } - return; - } - final controller = ref.read(workspaceControllerProvider.notifier); - if (!await controller.openNextcloudNote(choice.hit.localId) || - !context.mounted) { - return; - } - final buffer = ref.read(workspaceControllerProvider).activeBuffer; - if (buffer?.remoteNote?.localId != choice.hit.localId) return; - final repository = await ref.read(nextcloudNotesRepositoryProvider.future); - final note = repository.noteById(choice.hit.localId); - if (note == null) return; - var hit = choice.hit; - // Even a revision collision cannot apply offsets to a different document. - if (buffer!.revision != hit.revision || - notesSearchDigest(buffer.text) != hit.digest) { - final refreshed = await relocateNotesSearchHit( - NotesSearchQuery(choice.query, wholeWord: choice.wholeWord), - NotesSearchOverlay( - note.localId, - buffer.revision, - note.title, - note.category, - buffer.text, - ), - hit.start ?? 0, - ); - if (refreshed == null || !context.mounted) return; - final current = ref.read(workspaceControllerProvider).activeBuffer; - if (current?.remoteNote?.localId != note.localId || - current!.revision != refreshed.revision || - notesSearchDigest(current.text) != refreshed.digest) { - return; - } - hit = refreshed; - } - if (hit.start == null || hit.end == null) return; - if (choice.source) { - await ref - .read(appSettingsControllerProvider.notifier) - .setDocumentViewMode(DocumentViewModePreference.source); - } - ref - .read(notesMatchNavigationProvider.notifier) - .navigate(hit.localId, hit.start!, hit.end!, hit.digest); -} - -class NotesSearchDialog extends ConsumerStatefulWidget { - const NotesSearchDialog({ - super.key, - required this.accountId, - this.initialQuery = '', - this.recovery = false, - }); - final String accountId, initialQuery; - final bool recovery; - @override - ConsumerState createState() => _NotesSearchDialogState(); -} - -class _NotesSearchDialogState extends ConsumerState { - NotesSearchController? _controller; - StreamSubscription? _results; - StreamSubscription? _repositoryChanges; - Timer? _debounce; - late final _text = TextEditingController(text: widget.initialQuery); - late final _focus = FocusNode(onKeyEvent: _key); - final _scroll = ScrollController(); - NotesSearchState _state = const NotesSearchState(loading: true); - bool _wholeWord = false; - int _selected = 0, _limit = 80; - @override - void initState() { - super.initState(); - unawaited(_initialize()); - } - - Future _initialize() async { - final repository = await ref.read(nextcloudNotesRepositoryProvider.future); - if (!mounted) return; - _controller = NotesSearchController( - repository.store, - widget.accountId, - recovery: widget.recovery, - ); - _results = _controller!.changes.listen((state) { - if (mounted) { - setState(() { - _state = state; - _selected = 0; - }); - } - }); - _repositoryChanges = repository.changes.listen((_) => _schedule()); - _search(); - } - - void _schedule() { - _controller?.cancel(); - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 150), _search); - } - - void _search() { - final repository = ref.read(nextcloudNotesRepositoryProvider).value; - if (repository == null || _controller == null) return; - final overlays = [ - for (final buffer - in ref.read(workspaceControllerProvider).documentBuffers) - if (!widget.recovery && - buffer.isDirty && - buffer.remoteNote?.accountId == widget.accountId && - repository.noteById(buffer.remoteNote!.localId) != null) - NotesSearchOverlay( - buffer.remoteNote!.localId, - buffer.revision, - repository.noteById(buffer.remoteNote!.localId)!.title, - repository.noteById(buffer.remoteNote!.localId)!.category, - buffer.text, - ), - ]; - unawaited( - _controller!.search( - _text.text, - wholeWord: _wholeWord, - limit: _limit, - overlays: overlays, - ), - ); - } - - KeyEventResult _key(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(context); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.enter) { - _open(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowUp || - event.logicalKey == LogicalKeyboardKey.arrowDown) { - if (_state.hits.isNotEmpty) { - setState( - () => _selected = - (_selected + - (event.logicalKey == LogicalKeyboardKey.arrowDown - ? 1 - : -1)) - .clamp(0, _state.hits.length - 1), - ); - if (_scroll.hasClients) { - _scroll.animateTo( - (_selected * 90.0).clamp(0, _scroll.position.maxScrollExtent), - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - void _open({bool source = false, int? index}) { - if (_state.loading || _state.hits.isEmpty) return; - Navigator.pop(context, ( - hit: _state.hits[index ?? _selected], - query: _text.text, - wholeWord: _wholeWord, - source: source, - )); - } - - @override - void dispose() { - _debounce?.cancel(); - unawaited(_results?.cancel()); - unawaited(_repositoryChanges?.cancel()); - unawaited(_controller?.dispose()); - _text.dispose(); - _focus.dispose(); - _scroll.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - ref.listen( - workspaceControllerProvider.select((s) => s.documentBuffers), - (a, b) => _schedule(), - ); - return BusyMarkDialogShell( - title: widget.recovery - ? '${context.l10n.notesRecovery} · ${context.l10n.search}' - : context.l10n.search, - maxWidth: 760, - children: [ - TextField( - autofocus: true, - focusNode: _focus, - controller: _text, - decoration: InputDecoration(labelText: context.l10n.search), - onChanged: (_) { - _limit = 80; - _schedule(); - }, - ), - if (widget.recovery) Text(context.l10n.notesRecoveryHelp), - Text( - context.l10n.notesSearchHelp, - style: Theme.of(context).textTheme.bodySmall, - ), - CheckboxListTile( - dense: true, - title: Text(context.l10n.sourceSearchWholeWord), - value: _wholeWord, - onChanged: (v) => setState(() { - _wholeWord = v ?? false; - _schedule(); - }), - ), - if (_state.loading) const LinearProgressIndicator(), - if (_state.indexing) Text(context.l10n.notesIndexing), - if (_state.error != null) - Text('${context.l10n.warning}: ${_state.error}'), - SizedBox( - height: 360, - child: _state.hits.isEmpty - ? Center( - child: Text( - _state.loading - ? context.l10n.notesIndexing - : context.l10n.noResults, - ), - ) - : ListView.builder( - key: const ValueKey('notes-search-results'), - controller: _scroll, - itemCount: _state.hits.length, - itemBuilder: (context, index) { - final hit = _state.hits[index]; - final from = - hit.snippetMatchStart ?? - (hit.start == null - ? 0 - : (hit.start! - hit.snippetStart).clamp( - 0, - hit.snippet.length, - )); - final to = - hit.snippetMatchEnd ?? - (hit.end == null - ? 0 - : (hit.end! - hit.snippetStart).clamp( - from, - hit.snippet.length, - )); - return ListTile( - key: ValueKey(( - 'notes-search-hit', - hit.localId, - hit.start, - hit.end, - )), - selected: index == _selected, - title: Text( - hit.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hit.category.isNotEmpty) - Text( - hit.category, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: hit.snippet - .substring(0, from) - .replaceAll(RegExp(r'[\r\n\t]'), ' '), - ), - TextSpan( - text: hit.snippet - .substring(from, to) - .replaceAll(RegExp(r'[\r\n\t]'), ' '), - style: TextStyle( - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: hit.snippet - .substring(to) - .replaceAll(RegExp(r'[\r\n\t]'), ' '), - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - trailing: widget.recovery || hit.start == null - ? null - : IconButton( - tooltip: context.l10n.notesSourceLocation, - icon: const Icon(BusyMarkGlyphs.code), - onPressed: () => - _open(source: true, index: index), - ), - onTap: () => _open(index: index), - ); - }, - ), - ), - if (_state.truncated) - TextButton( - onPressed: () { - _limit += 80; - _search(); - }, - child: Text(context.l10n.workspaceSearchShowMore), - ), - ], + accountId: accountId, ); } } diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index 0ad1759a..a347b09d 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -41,9 +41,6 @@ class HeaderBarLabels { required this.settings, required this.settingsShortcut, required this.settingsGtkAccelerator, - required this.keyboardShortcuts, - required this.keyboardShortcutsShortcut, - required this.keyboardShortcutsGtkAccelerator, required this.syntaxReference, required this.syntaxReferenceShortcut, required this.syntaxReferenceGtkAccelerator, @@ -82,9 +79,6 @@ class HeaderBarLabels { final String settings; final String settingsShortcut; final String settingsGtkAccelerator; - final String keyboardShortcuts; - final String keyboardShortcutsShortcut; - final String keyboardShortcutsGtkAccelerator; final String syntaxReference; final String syntaxReferenceShortcut; final String syntaxReferenceGtkAccelerator; @@ -123,9 +117,6 @@ class HeaderBarLabels { 'settings': settings, 'settingsShortcut': settingsShortcut, 'settingsGtkAccelerator': settingsGtkAccelerator, - 'keyboardShortcuts': keyboardShortcuts, - 'keyboardShortcutsShortcut': keyboardShortcutsShortcut, - 'keyboardShortcutsGtkAccelerator': keyboardShortcutsGtkAccelerator, 'syntaxReference': syntaxReference, 'syntaxReferenceShortcut': syntaxReferenceShortcut, 'syntaxReferenceGtkAccelerator': syntaxReferenceGtkAccelerator, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 98a4d12d..d9459a42 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -20,7 +20,6 @@ enum HeaderBarAction { fullScreen, menu, settings, - keyboardShortcuts, syntaxReference, reportIssue, aboutBusyMark, @@ -397,7 +396,6 @@ class LinuxHeaderBarService extends ChangeNotifier { 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, 'settings' => HeaderBarAction.settings, - 'keyboardShortcuts' => HeaderBarAction.keyboardShortcuts, 'syntaxReference' => HeaderBarAction.syntaxReference, 'reportIssue' => HeaderBarAction.reportIssue, 'aboutBusyMark' => HeaderBarAction.aboutBusyMark, diff --git a/lib/src/platform/native_menu_service.dart b/lib/src/platform/native_menu_service.dart index fae6e370..5b999d36 100644 --- a/lib/src/platform/native_menu_service.dart +++ b/lib/src/platform/native_menu_service.dart @@ -25,6 +25,7 @@ final class NativeMenuEntry { this.iconName, this.iconColorArgb, this.shortcut, + this.gtkAccelerator, this.enabled = true, this.checkable = false, this.selected = false, @@ -39,6 +40,7 @@ final class NativeMenuEntry { }) : iconName = null, iconColorArgb = null, shortcut = null, + gtkAccelerator = null, checkable = false, selected = false, mutuallyExclusive = false, @@ -49,6 +51,7 @@ final class NativeMenuEntry { iconName = null, iconColorArgb = null, shortcut = null, + gtkAccelerator = null, enabled = false, checkable = false, selected = false, @@ -60,6 +63,7 @@ final class NativeMenuEntry { final String? iconName; final int? iconColorArgb; final String? shortcut; + final String? gtkAccelerator; final bool enabled; final bool checkable; final bool selected; @@ -73,6 +77,8 @@ final class NativeMenuEntry { if (iconName != null && iconName!.isNotEmpty) 'icon': iconName!, if (iconColorArgb != null) 'iconColor': iconColorArgb!, if (shortcut != null && shortcut!.isNotEmpty) 'shortcut': shortcut!, + if (gtkAccelerator != null && gtkAccelerator!.isNotEmpty) + 'gtkAccelerator': gtkAccelerator!, 'enabled': enabled, 'checkable': checkable, 'selected': selected, diff --git a/lib/src/platform/user_directories.dart b/lib/src/platform/user_directories.dart new file mode 100644 index 00000000..1ec41a53 --- /dev/null +++ b/lib/src/platform/user_directories.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// The desktop's actual home, including the original home in a Snap sandbox. +String? get userHomeDirectory { + for (final key in ['SNAP_REAL_HOME', 'HOME', 'USERPROFILE']) { + final value = Platform.environment[key]; + if (value != null && value.isNotEmpty && p.isAbsolute(value)) { + return p.normalize(value); + } + } + return null; +} diff --git a/lib/src/search/search_action_availability.dart b/lib/src/search/search_action_availability.dart new file mode 100644 index 00000000..41d5928a --- /dev/null +++ b/lib/src/search/search_action_availability.dart @@ -0,0 +1,42 @@ +import '../app/command_registry.dart'; +import '../workspace/workspace_model.dart'; +import 'search_session.dart'; + +bool searchActionEnabled( + BusyMarkCommandRegistry registry, + BusyMarkCommand command, + SearchSessionController controller, + WorkspaceState workspace, +) { + final target = controller.commandTarget; + if (registry.requiresDocumentTarget(command) && + (!controller.workspaceVisible || + target?.bufferId == null || + target?.isCurrent(workspace) != true)) { + return false; + } + final activeNotesAction = + controller.workspaceVisible && + (workspace.workspace?.isRemote == true || + controller.actionAccountId != null) && + { + BusyMarkCommandIds.newNote, + BusyMarkCommandIds.browseNotes, + }.contains(command.id); + if ((registry.requiresWorkspaceTarget(command) || activeNotesAction) && + (!controller.workspaceVisible || + target?.workspaceId == null || + target?.workspaceId != workspace.workspace?.id)) { + return false; + } + if (registry.isContextual(command)) { + return controller.workspaceVisible && + target?.isCurrent(workspace) == true && + registry.canExecuteInContext(command.id, target!.context); + } + if (!controller.workspaceVisible && + command.scope != BusyMarkCommandScope.application) { + return false; + } + return command.canExecute; +} diff --git a/lib/src/search/search_activation.dart b/lib/src/search/search_activation.dart new file mode 100644 index 00000000..4b2ff3a8 --- /dev/null +++ b/lib/src/search/search_activation.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_settings.dart'; +import '../app/busymark_toast.dart'; +import '../app/command_registry.dart'; +import '../app/localization.dart'; +import '../local_history/local_history_controller.dart'; +import '../local_history/local_history_models.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/domain/notes_search.dart'; +import '../nextcloud_notes/presentation/notes_workspace_ui.dart'; +import '../workspace/document_navigation.dart'; +import '../workspace/outline_navigation.dart'; +import '../workspace/recent_navigation.dart'; +import '../workspace/workspace_controller.dart'; +import 'search_session.dart'; +import 'search_action_availability.dart'; + +bool actionEnabled(WidgetRef ref, ActionSearchResult result) { + final registry = BusyMarkCommandRegistryScope.read(ref.context); + return registry != null && + searchActionEnabled( + registry, + result.command, + ref.read(searchSessionProvider.notifier), + ref.read(workspaceControllerProvider), + ); +} + +Future activateSelectedSearchResult( + BuildContext context, + WidgetRef ref, +) async { + final controller = ref.read(searchSessionProvider.notifier); + if (!await controller.submit() || !context.mounted) return; + final state = ref.read(searchSessionProvider); + if (state.results.isEmpty) return; + await activateSearchResult(context, ref, state.results[state.selected]); +} + +final _searchActivationProvider = Provider((ref) => _SearchActivation()); + +class _SearchActivation { + bool busy = false; +} + +Future activateSearchResult( + BuildContext context, + WidgetRef ref, + SidebarSearchResult result, { + bool source = false, +}) async { + final guard = ref.read(_searchActivationProvider); + if (guard.busy) return; + guard.busy = true; + final session = ref.read(searchSessionProvider.notifier); + final snapshot = ref.read(searchSessionProvider); + final workspace = ref.read(workspaceControllerProvider).workspace; + final controller = ref.read(workspaceControllerProvider.notifier); + void unavailable() { + if (context.mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.commandUnavailableInContext, + ); + } + } + + try { + if (!snapshot.active || + !snapshot.results.any((r) => identical(r, result))) { + unavailable(); + return; + } + session.select(snapshot.results.indexWhere((r) => identical(r, result))); + bool currentRequest() => + ref.context.mounted && + session.workspaceVisible && + ref.read(workspaceControllerProvider).workspace?.id == workspace?.id && + ref.read(searchSessionProvider).active && + ref.read(searchSessionProvider).requestRevision == + snapshot.requestRevision && + ref.read(searchSessionProvider).focusRequest == snapshot.focusRequest && + ref.read(searchSessionProvider).query == snapshot.query && + ref.read(searchSessionProvider).category == snapshot.category && + ref.read(searchSessionProvider).recovery == snapshot.recovery && + ref.read(searchSessionProvider).historyDocumentId == + snapshot.historyDocumentId; + switch (result) { + case OutlineSearchResult(): + final live = ref.read(workspaceControllerProvider); + final buffer = live.activeBuffer; + if (!currentRequest() || + buffer?.id != result.bufferId || + buffer?.revision != result.revision || + notesSearchDigest(buffer!.text) != result.digest || + !navigateOutlineHeading( + ref, + workspaceId: result.workspaceId, + bufferId: result.bufferId, + source: buffer.text, + heading: result.heading, + )) { + unavailable(); + } + + case ActionSearchResult(:final command): + if (!actionEnabled(ref, result)) { + unavailable(); + return; + } + final registry = BusyMarkCommandRegistryScope.read(context)!; + final success = await registry.executeInContext( + command.id, + session.commandTarget?.context, + ); + if (success && + ref.context.mounted && + ref.read(searchSessionProvider).focusRequest == + snapshot.focusRequest && + ref.read(searchSessionProvider).category == snapshot.category) { + session.close(); + } + case RecentSearchResult(:final resource): + await openRecentResource(context, ref, resource); + case DocumentSearchResult( + :final workspaceId, + :final accountId, + :final document, + ): + if (!session.workspaceVisible || workspace?.id != workspaceId) { + unavailable(); + return; + } + if (accountId != null) { + final repo = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repo.noteById(document.localId!); + if (!context.mounted || + !currentRequest() || + ref.read(workspaceControllerProvider).workspace?.id != + workspaceId || + note?.accountId != accountId || + isNotesRecovery(note!) || + repo.isCreationCandidateBinding(note.localId)) { + unavailable(); + return; + } + if (await controller.openNextcloudNote( + document.localId!, + acceptNavigation: currentRequest, + )) { + if (!currentRequest()) return; + revealOpenedDocument(ref); + session.close(restoreFocus: false); + } + } else { + if (!workspace!.files.any( + (f) => f.absolutePath == document.filePath, + )) { + unavailable(); + return; + } + if (await controller.openActiveFile( + document.filePath!, + acceptNavigation: currentRequest, + )) { + if (!currentRequest()) return; + revealOpenedDocument(ref); + session.close(restoreFocus: false); + } + } + case TopicSearchResult(:final workspaceId, :final path): + if (!session.workspaceVisible || + workspace?.id != workspaceId || + ![ + for (final module + in workspace!.writersideProject?.modules ?? + [ + if (workspace.writersideModule != null) + workspace.writersideModule!, + ]) + ...module.topics, + ].any((t) => p.equals(t.filePath, path))) { + unavailable(); + return; + } + if (await controller.openActiveFile( + path, + acceptNavigation: currentRequest, + )) { + if (!currentRequest()) return; + revealOpenedDocument(ref); + session.close(restoreFocus: false); + } + case HistorySearchResult(:final match): + if (!currentRequest()) return; + final history = ref.read(localHistoryControllerProvider.notifier); + final retained = ref.read(localHistoryControllerProvider).snapshot; + final document = retained.documents + .where((d) => d.id == match.document.id) + .firstOrNull; + if (document == null) { + unavailable(); + return; + } + if (match is LocalHistoryRevisionMatch && + !retained.revisions.any( + (r) => + r.id == match.revision.id && + r.documentId == document.id && + r.checksum == match.revision.checksum, + )) { + unavailable(); + return; + } + history.inspectRetainedDocument(document.id); + if (match is LocalHistoryRevisionMatch) { + await history.selectRevision( + match.revision.id, + acceptSelection: currentRequest, + ); + } else { + // Metadata matches show the unfiltered retained timeline. They never + // open a live path, bind a buffer or create a replacement document. + session.close(restoreFocus: false); + ref.read(localHistoryOpenRequestProvider.notifier).request(); + } + case TextSearchResult(:final workspaceId, :final match): + if (!session.workspaceVisible || workspace?.id != workspaceId) { + unavailable(); + return; + } + // An open-buffer ID is only a hint: clean tabs may close without + // invalidating a workspace result. Resolve its durable file identity + // again, including a tab reopened since this result was produced. + final live = ref.read(workspaceControllerProvider); + final destination = match.filePath != null + ? live.bufferForPath(match.filePath!) + : live.documentBuffers + .where((buffer) => buffer.id == match.bufferId) + .firstOrNull; + bool validSource(String text) => + match.startOffset >= 0 && + match.endOffset > match.startOffset && + match.endOffset <= text.length && + notesSearchDigest(text) == match.digest; + if (destination != null) { + if (!validSource(destination.text)) { + unavailable(); + return; + } + if (live.activeBufferId != destination.id && + !await controller.activateDocumentBuffer( + destination.id, + acceptNavigation: currentRequest, + )) { + return; + } + } else if (match.filePath != null) { + if (!await controller.openActiveFile( + match.filePath!, + acceptNavigation: currentRequest, + )) { + if (currentRequest()) unavailable(); + return; + } + } else { + unavailable(); + return; + } + if (!context.mounted || + !currentRequest() || + ref.read(workspaceControllerProvider).workspace?.id != + workspaceId) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer == null || + (match.filePath != null && buffer.filePath != match.filePath) || + !validSource(buffer.text)) { + unavailable(); + return; + } + revealOpenedDocument(ref); + ref + .read(notesMatchNavigationProvider.notifier) + .navigate( + buffer.remoteNote?.localId ?? buffer.filePath ?? buffer.id, + match.startOffset, + match.endOffset, + match.digest, + accountId: buffer.remoteNote?.accountId, + ); + case NoteSearchResult(:final accountId, :final hit, :final recovery): + final repo = await ref.read(nextcloudNotesRepositoryProvider.future); + final note = repo.noteById(hit.localId); + if (!context.mounted || + !currentRequest() || + ref + .read(workspaceControllerProvider) + .workspace + ?.nextcloudAccountId != + accountId || + note?.accountId != accountId || + isNotesRecovery(note!) != recovery) { + unavailable(); + return; + } + if (recovery) { + session.close(restoreFocus: false); + await showNotesRecovery(context, ref, note); + return; + } + if (!await controller.openNextcloudNote( + hit.localId, + acceptNavigation: currentRequest, + ) || + !context.mounted || + !currentRequest()) { + return; + } + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer?.remoteNote?.accountId != accountId || + buffer?.remoteNote?.localId != hit.localId || + notesSearchDigest(buffer!.text) != hit.digest) { + unavailable(); + return; + } + if (source) { + controller.updateActiveEditorMode(DocumentViewModePreference.source); + await ref + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + if (!context.mounted || !currentRequest()) return; + } + revealOpenedDocument(ref); + if (hit.start != null && hit.end != null) { + ref + .read(notesMatchNavigationProvider.notifier) + .navigate( + hit.localId, + hit.start!, + hit.end!, + hit.digest, + accountId: accountId, + ); + } + } + } finally { + guard.busy = false; + } +} diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart index 1c1b0cf7..dd5a2184 100644 --- a/lib/src/search/search_replace_service.dart +++ b/lib/src/search/search_replace_service.dart @@ -33,18 +33,14 @@ class TextReplacementPreview { required this.options, required this.replacement, required this.matches, - this.invalidRegex = false, this.truncated = false, - this.hasZeroLengthMatches = false, }); final String source; final SourceSearchOptions options; final String replacement; final List matches; - final bool invalidRegex; final bool truncated; - final bool hasZeroLengthMatches; String apply({Set? selectedMatchIds}) { if (matches.isEmpty) { @@ -79,8 +75,6 @@ enum WorkspaceReplacementSourceKind { disk, dirtyBuffer } enum WorkspaceReplacementIssueKind { remoteWorkspaceUnsupported, cancelled, - invalidRegex, - zeroLengthMatches, oversized, unreadable, invalidUtf8, @@ -147,8 +141,7 @@ class WorkspaceReplacementPreview { bool get isComplete => !issues.any( (issue) => issue.kind == WorkspaceReplacementIssueKind.truncated || - issue.kind == WorkspaceReplacementIssueKind.cancelled || - issue.kind == WorkspaceReplacementIssueKind.invalidRegex, + issue.kind == WorkspaceReplacementIssueKind.cancelled, ); } @@ -186,44 +179,16 @@ class SearchReplacementService { maximumMatches: matchLimit + 1, stopAfterMaximumMatches: true, ); - if (search.invalidRegex) { - return TextReplacementPreview( - source: source, - options: options, - replacement: replacement, - matches: const [], - invalidRegex: true, - ); - } - RegExp? expression; - if (options.regex) { - expression = RegExp( - options.query, - caseSensitive: options.caseSensitive, - multiLine: true, - unicode: true, - ); - } final matches = []; for (final (index, match) in search.matches.take(matchLimit).indexed) { final original = source.substring(match.fullStart, match.fullEnd); - var renderedReplacement = replacement; - if (expression != null) { - final regexMatch = expression.matchAsPrefix(source, match.fullStart); - if (regexMatch is RegExpMatch && regexMatch.end == match.fullEnd) { - renderedReplacement = _expandRegexReplacement( - replacement, - regexMatch, - ); - } - } matches.add( TextReplacementMatch( id: '$idPrefix:$index:${match.fullStart}:${match.fullEnd}', start: match.fullStart, end: match.fullEnd, original: original, - replacement: renderedReplacement, + replacement: replacement, ), ); } @@ -233,7 +198,6 @@ class SearchReplacementService { replacement: replacement, matches: List.unmodifiable(matches), truncated: search.totalMatchCount > matchLimit, - hasZeroLengthMatches: search.hasZeroLengthMatches, ); } @@ -254,48 +218,18 @@ class SearchReplacementService { matches: const [], ); } - var renderedReplacement = replacement; - if (options.regex) { - try { - final expression = RegExp( - options.query, - caseSensitive: options.caseSensitive, - multiLine: true, - unicode: true, - ); - final match = expression.matchAsPrefix(source, start); - if (match is! RegExpMatch || match.end != end) { - return TextReplacementPreview( - source: source, - options: options, - replacement: replacement, - matches: const [], - ); - } - renderedReplacement = _expandRegexReplacement(replacement, match); - } on FormatException { - return TextReplacementPreview( - source: source, - options: options, - replacement: replacement, - matches: const [], - 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 [], - ); - } + final match = RegExp( + RegExp.escape(options.query), + caseSensitive: false, + 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, @@ -307,7 +241,7 @@ class SearchReplacementService { start: start, end: end, original: source.substring(start, end), - replacement: renderedReplacement, + replacement: replacement, ), ], ); @@ -431,23 +365,6 @@ class SearchReplacementService { ); 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; } @@ -700,62 +617,6 @@ class SearchReplacementService { issues: List.unmodifiable(issues), ); } - - String _expandRegexReplacement(String replacement, RegExpMatch match) { - final output = StringBuffer(); - for (var index = 0; index < replacement.length; index++) { - final character = replacement[index]; - if (character != r'$' || index + 1 >= replacement.length) { - output.write(character); - continue; - } - final next = replacement[index + 1]; - if (next == r'$') { - output.write(r'$'); - index++; - continue; - } - if (next == '&') { - output.write(match.group(0) ?? ''); - index++; - continue; - } - if (next == '{') { - final close = replacement.indexOf('}', index + 2); - if (close > index + 2) { - final name = replacement.substring(index + 2, close); - try { - output.write(match.namedGroup(name) ?? ''); - index = close; - continue; - } on ArgumentError { - // Preserve an unknown capture reference literally. - } - } - } - if (_isDigit(next)) { - var end = index + 1; - while (end < replacement.length && - end < index + 3 && - _isDigit(replacement[end])) { - end++; - } - final group = int.parse(replacement.substring(index + 1, end)); - if (group <= match.groupCount) { - output.write(match.group(group) ?? ''); - index = end - 1; - continue; - } - } - output.write(r'$'); - } - return output.toString(); - } - - bool _isDigit(String value) { - final unit = value.codeUnitAt(0); - return unit >= 48 && unit <= 57; - } } class WorkspaceReplacementCancellation { @@ -813,9 +674,6 @@ class SearchReplacementWorker { final request = { 'source': source, 'query': options.query, - 'caseSensitive': options.caseSensitive, - 'wholeWord': options.wholeWord, - 'regex': options.regex, 'replacement': replacement, 'maximumMatches': maximumMatches, 'targetStart': targetStart, @@ -869,12 +727,7 @@ class SearchReplacementWorker { void _replacementWorkerMain(List payload) { final sendPort = payload[0] as SendPort; final request = payload[1] as Map; - final options = SourceSearchOptions( - query: request['query']! as String, - caseSensitive: request['caseSensitive']! as bool, - wholeWord: request['wholeWord']! as bool, - regex: request['regex']! as bool, - ); + final options = SourceSearchOptions(query: request['query']! as String); final service = SearchReplacementService( maximumMatches: request['maximumMatches']! as int, ); @@ -896,9 +749,7 @@ void _replacementWorkerMain(List payload) { replacement: replacement, ); sendPort.send({ - 'invalidRegex': preview.invalidRegex, 'truncated': preview.truncated, - 'hasZeroLengthMatches': preview.hasZeroLengthMatches, 'matches': [ for (final match in preview.matches) [ @@ -933,9 +784,7 @@ TextReplacementPreview _decodeReplacementPreview( replacement: item[4]! as String, ), ]), - invalidRegex: payload['invalidRegex']! as bool, truncated: payload['truncated']! as bool, - hasZeroLengthMatches: payload['hasZeroLengthMatches']! as bool, ); } diff --git a/lib/src/search/search_result_presentation.dart b/lib/src/search/search_result_presentation.dart new file mode 100644 index 00000000..0defdd72 --- /dev/null +++ b/lib/src/search/search_result_presentation.dart @@ -0,0 +1,147 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:path/path.dart' as p; + +import '../app/localization.dart'; +import '../app/app_settings.dart'; +import '../local_history/local_history_models.dart'; +import '../workspace/recent_presentation.dart'; +import 'search_result_preview.dart'; +import 'search_session.dart'; + +/// Display projections never change the metadata used for activation or matching. +class SearchResultPresentation { + const SearchResultPresentation( + this.title, + this.subtitle, { + this.shortcut, + this.details, + }); + final String title, subtitle; + final String? shortcut, details; +} + +SearchResultPresentation presentSearchResult( + BuildContext context, + SidebarSearchResult result, { + String? Function(String accountId)? accountLabel, + Iterable recentResources = const [], + String? workspaceRoot, +}) { + String account(String id) => + accountLabel?.call(id) ?? context.l10n.searchAccountUnavailable; + String location(String path, String title) { + if (p.basename(path) == title || + p.basenameWithoutExtension(path) == title) { + final parent = p.dirname(path); + return parent == '.' ? '' : parent; + } + return path; + } + + return switch (result) { + TextSearchResult(:final match) => SearchResultPresentation( + searchResultTitle(context, match.lineText ?? ''), + context.l10n.searchResultLine(match.relativePath, match.line), + ), + NoteSearchResult(:final hit, :final accountId) => SearchResultPresentation( + hit.snippet.isEmpty ? hit.title : hit.snippet, + [ + hit.title, + if (hit.category.isNotEmpty) hit.category, + account(accountId), + ].join(' · '), + ), + DocumentSearchResult(:final document, :final accountId) => + SearchResultPresentation( + accountId == null ? p.basename(document.path) : document.title, + accountId == null + ? location(document.path, p.basename(document.path)) + : [ + if (document.path.isNotEmpty) document.path, + account(accountId), + ].join(' · '), + details: accountId == null ? document.filePath ?? document.path : null, + ), + TopicSearchResult( + :final title, + :final module, + :final topicId, + :final relativePath, + ) => + SearchResultPresentation( + title, + [ + module, + if (topicId != p.basenameWithoutExtension(relativePath)) topicId, + location(relativePath, title), + ].where((s) => s.isNotEmpty).toSet().join(' · '), + details: relativePath, + ), + RecentSearchResult(:final resource) => SearchResultPresentation( + recentResourceTitle(context, resource), + recentResourceLocation( + context, + resource, + recentResources: recentResources, + workspaceRoot: workspaceRoot, + accountLabel: resource.accountId == null + ? null + : accountLabel?.call(resource.accountId!), + ), + details: resource.accountId == null + ? resource.path + : recentResourceLocation( + context, + resource, + accountLabel: accountLabel?.call(resource.accountId!), + ), + ), + OutlineSearchResult( + :final heading, + :final ancestors, + :final documentName, + ) => + SearchResultPresentation( + heading.text, + [ + context.l10n.headingLevelAbbreviation(heading.level), + if (ancestors.isNotEmpty) ancestors.join(' › ') else documentName, + ].join(' · '), + ), + HistorySearchResult(:final match) => SearchResultPresentation( + match.document.displayName, + [ + if (match is LocalHistoryRevisionMatch) + DateFormat.yMd( + Localizations.localeOf(context).toLanguageTag(), + ).add_Hms().format(match.revision.capturedAt.toLocal()), + if (match.document.remoteNote != null) + account(match.document.remoteNote!.accountId) + else + location( + match is LocalHistoryRevisionMatch + ? match.revision.historicalPath ?? '' + : match.document.currentPath ?? + match.document.historicalPaths.lastOrNull ?? + '', + match.document.displayName, + ), + ].where((s) => s.isNotEmpty).join(' · '), + ), + ActionSearchResult( + :final title, + :final category, + :final command, + :final enabled, + :final disabledReason, + ) => + SearchResultPresentation( + title, + enabled + ? category + : '$category · ${disabledReason ?? context.l10n.commandUnavailableInContext}', + shortcut: command.shortcut?.label, + ), + }; +} diff --git a/lib/src/search/search_result_preview.dart b/lib/src/search/search_result_preview.dart new file mode 100644 index 00000000..6ec609f1 --- /dev/null +++ b/lib/src/search/search_result_preview.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import '../app/localization.dart'; +import '../editor/source/source_search.dart'; +import 'workspace_text_search.dart'; + +String stripMarkdownForSearchResult(String line, {BuildContext? context}) { + // 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); + if (fence != null) { + final language = fence.group(2)?.trim() ?? ''; + return language.isEmpty + ? context?.l10n.codeBlock ?? 'code block' + : language; + } + + value = value + .replaceFirst(RegExp(r'^\s{0,3}#{1,6}\s+'), '') + .replaceFirst(RegExp(r'^\s{0,3}>\s?'), '') + .replaceFirst(RegExp(r'^\s{0,3}[-+*]\s+\[[ xX]\]\s+'), '') + .replaceFirst(RegExp(r'^\s{0,3}[-+*]\s+'), '') + .replaceFirst(RegExp(r'^\s{0,3}\d+[.)]\s+'), '') + .trim(); + final replacements = { + RegExp(r'!\[([^\]]*)\]\([^)]+\)'): (match) => match.group(1) ?? '', + RegExp(r'\[([^\]]+)\]\([^)]+\)'): (match) => match.group(1) ?? '', + RegExp(r'\[([^\]]+)\]\[[^\]]*\]'): (match) => match.group(1) ?? '', + RegExp(r'`([^`]*)`'): (match) => match.group(1) ?? '', + RegExp(r'\*\*([^*]+)\*\*'): (match) => match.group(1) ?? '', + RegExp(r'__([^_]+)__'): (match) => match.group(1) ?? '', + RegExp(r'\*([^*]+)\*'): (match) => match.group(1) ?? '', + RegExp(r'_([^_]+)_'): (match) => match.group(1) ?? '', + RegExp(r'~~([^~]+)~~'): (match) => match.group(1) ?? '', + RegExp(r'<[^>]+>'): (_) => '', + }; + for (final entry in replacements.entries) { + value = value.replaceAllMapped(entry.key, entry.value); + } + value = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + return value.isEmpty + ? context?.l10n.untitledResult ?? 'untitled result' + : value; +} + +String searchResultTitle(BuildContext context, String line) { + final trimmed = stripMarkdownForSearchResult(line, context: context); + if (trimmed.length <= 120) { + return trimmed; + } + var end = 117; + if (!sourceSearchRangeHasSafeBoundaries(trimmed, end, end)) end--; + return '${trimmed.substring(0, end)}...'; +} diff --git a/lib/src/search/search_session.dart b/lib/src/search/search_session.dart new file mode 100644 index 00000000..6aa5e0fa --- /dev/null +++ b/lib/src/search/search_session.dart @@ -0,0 +1,1515 @@ +import 'package:flutter/foundation.dart'; +import '../app/command_presentation.dart'; +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_router.dart'; +import '../app/app_settings.dart'; +import '../app/busymark_design.dart'; +import '../app/command_registry.dart'; +import '../app/quick_open.dart'; +import '../editor/source/source_document.dart'; +import '../editor/source/source_search.dart'; +import '../local_history/local_history_controller.dart'; +import '../local_history/local_history_models.dart'; +import '../markdown/document_outline.dart'; +import '../workspace/outline_navigation.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../nextcloud_notes/application/notes_search_controller.dart'; +import '../nextcloud_notes/domain/notes_search.dart'; +import '../workspace/document_buffer.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_model.dart'; +import 'workspace_search_scope.dart'; +import 'workspace_text_search.dart'; + +enum SearchCategory { + file, + workspace, + outline, + documents, + topics, + recent, + localHistory, + actions, +} + +const searchCommandDestinations = { + BusyMarkCommandIds.search: SearchCategory.file, + BusyMarkCommandIds.searchWorkspace: SearchCategory.workspace, + BusyMarkCommandIds.searchHeadings: SearchCategory.outline, + BusyMarkCommandIds.quickOpen: SearchCategory.documents, + BusyMarkCommandIds.searchTopics: SearchCategory.topics, + BusyMarkCommandIds.searchRecent: SearchCategory.recent, + BusyMarkCommandIds.findLocalHistory: SearchCategory.localHistory, + BusyMarkCommandIds.commandPalette: SearchCategory.actions, +}; + +String searchCommandForCategory(SearchCategory category) => + searchCommandDestinations.entries + .singleWhere((e) => e.value == category) + .key; + +/// A result is qualified by its owning workspace/account, never a synthetic path. +sealed class SidebarSearchResult { + const SidebarSearchResult(); + String get identity; +} + +final class TextSearchResult extends SidebarSearchResult { + const TextSearchResult(this.workspaceId, this.match); + final String workspaceId; + final WorkspaceTextMatch match; + @override + String get identity => + '$workspaceId:${match.bufferId ?? match.filePath}:${match.startOffset}:${match.endOffset}'; +} + +final class NoteSearchResult extends SidebarSearchResult { + const NoteSearchResult(this.accountId, this.hit, this.recovery); + final String accountId; + final NotesSearchHit hit; + final bool recovery; + @override + String get identity => '$accountId:${hit.localId}:${hit.start}:${hit.end}'; +} + +final class OutlineSearchResult extends SidebarSearchResult { + const OutlineSearchResult( + this.workspaceId, + this.bufferId, + this.revision, + this.digest, + this.heading, + this.ancestors, + this.documentName, + ); + final String workspaceId, bufferId, digest, documentName; + final int revision; + final DocumentOutlineHeading heading; + final List ancestors; + @override + String get identity => + '$workspaceId:$bufferId:${heading.id}:${heading.editorBlockId}:${heading.sourceStartOffset}:${heading.sourceStartLine}'; +} + +final class DocumentSearchResult extends SidebarSearchResult { + const DocumentSearchResult(this.workspaceId, this.document, {this.accountId}); + final String workspaceId; + final String? accountId; + final QuickOpenDocument document; + @override + String get identity => '$workspaceId:${document.identity}'; +} + +final class TopicSearchResult extends SidebarSearchResult { + const TopicSearchResult( + this.workspaceId, + this.path, + this.title, + this.topicId, + this.module, + this.relativePath, + ); + final String workspaceId, path, title, topicId, module, relativePath; + @override + String get identity => '$workspaceId:$path'; +} + +final class RecentSearchResult extends SidebarSearchResult { + const RecentSearchResult(this.resource); + final RecentResource resource; + @override + String get identity => resource.identity; +} + +final class HistorySearchResult extends SidebarSearchResult { + const HistorySearchResult(this.match); + final LocalHistorySearchMatch match; + @override + String get identity => match.identity; +} + +/// Metadata is localized at the presentation boundary; commands remain in the registry. +final class ActionSearchResult extends SidebarSearchResult { + const ActionSearchResult( + this.command, + this.title, + this.category, + this.description, { + this.enabled = true, + this.disabledReason, + }); + final BusyMarkCommand command; + final String title, category; + final String? description, disabledReason; + final bool enabled; + @override + String get identity => command.id; +} + +/// Identities carried with every generation, independently of retained buffers. +class SearchSessionContext { + const SearchSessionContext( + this.route, + this.workspaceId, + this.bufferId, + this.revision, + this.accountId, + ); + final String route; + final String? workspaceId, bufferId, accountId; + final int? revision; +} + +class SearchSessionState { + const SearchSessionState({ + this.active = false, + this.context, + this.category = SearchCategory.file, + this.recovery = false, + this.historyDocumentId, + this.query = '', + this.results = const [], + this.selected = 0, + this.searching = false, + this.indexing = false, + this.truncated = false, + this.skippedFiles = const [], + this.error, + this.focusRequest = 0, + this.generation = 0, + this.requestRevision = 0, + this.documentMatchCount = 0, + this.documentFirstMatchIndex = 0, + this.documentCurrentMatchIndex, + this.sourceNavigationRequest = 0, + this.appliedSourceNavigation = 0, + }); + final bool active; + final SearchSessionContext? context; + final SearchCategory category; + final bool recovery; + final String? historyDocumentId; + final String query; + final List results; + final int selected, focusRequest, generation; + final int requestRevision; + final int documentMatchCount, documentFirstMatchIndex; + final int? documentCurrentMatchIndex; + final int sourceNavigationRequest, appliedSourceNavigation; + final bool searching, indexing, truncated; + final List skippedFiles; + final String? error; + + /// Text queries preserve literal whitespace; name queries use their existing + /// normalization. This is the single gate for every request entry point. + bool get queryIsEmpty => switch (category) { + SearchCategory.file => query.isEmpty, + SearchCategory.workspace => + context?.accountId == null ? query.isEmpty : query.trim().isEmpty, + _ => query.trim().isEmpty, + }; + bool get showingInitialList => + queryIsEmpty && category == SearchCategory.actions; + bool get waitingForInput => queryIsEmpty && !showingInitialList; + bool get fileActive => active && category == SearchCategory.file; + bool get workspaceActive => active && category == SearchCategory.workspace; + bool get textActive => fileActive || workspaceActive; + SourceSearchSnapshot sourceSnapshot(DocumentBuffer? buffer) { + final ranges = <(int, int)>[]; + if (textActive && + !waitingForInput && + results.isNotEmpty && + buffer != null && + (workspaceActive || + context?.bufferId == buffer.id && + context?.revision == buffer.revision)) { + final remote = buffer.remoteNote; + final digest = notesSearchDigest(buffer.text); + for (final result in results) { + if (result is TextSearchResult && + result.match.digest == digest && + result.match.startOffset >= 0 && + result.match.endOffset <= buffer.text.length && + (result.match.bufferId == buffer.id || + buffer.filePath != null && + result.match.filePath == buffer.filePath)) { + ranges.add((result.match.startOffset, result.match.endOffset)); + } else if (result is NoteSearchResult && + remote != null && + result.accountId == remote.accountId && + result.hit.localId == remote.localId && + result.hit.digest == digest && + result.hit.start != null && + result.hit.end != null) { + ranges.add((result.hit.start!, result.hit.end!)); + } + } + } + return SourceSearchSnapshot( + source: buffer?.text ?? '', + truncated: category != SearchCategory.file && truncated, + searching: searching, + navigationRequest: sourceNavigationRequest, + appliedNavigation: appliedSourceNavigation, + sourceRevision: buffer?.revision, + sessionRevision: requestRevision, + result: SourceSearchResult( + options: options, + totalMatchCount: category == SearchCategory.file + ? documentMatchCount + : null, + firstMatchIndex: category == SearchCategory.file + ? documentFirstMatchIndex + : 0, + currentMatchIndex: category == SearchCategory.file + ? documentCurrentMatchIndex + : null, + matches: [ + for (final (start, end) in ranges) + SourceSearchMatch( + fullStart: start, + fullEnd: end, + visibleStart: start, + visibleEnd: end, + hidden: false, + ), + ], + ), + ); + } + + SourceSearchOptions get options => + SourceSearchOptions(query: textActive ? query : ''); + SearchSessionState copyWith({ + bool? active, + SearchSessionContext? context, + SearchCategory? category, + bool? recovery, + String? historyDocumentId, + bool clearHistoryScope = false, + String? query, + List? results, + int? selected, + bool? searching, + bool? indexing, + bool? truncated, + List? skippedFiles, + String? error, + int? focusRequest, + int? generation, + int? requestRevision, + int? documentMatchCount, + int? documentFirstMatchIndex, + int? documentCurrentMatchIndex, + bool clearDocumentSelection = false, + int? sourceNavigationRequest, + int? appliedSourceNavigation, + }) => SearchSessionState( + active: active ?? this.active, + context: context ?? this.context, + category: category ?? this.category, + recovery: recovery ?? this.recovery, + historyDocumentId: clearHistoryScope + ? null + : historyDocumentId ?? this.historyDocumentId, + query: query ?? this.query, + results: results ?? this.results, + selected: selected ?? this.selected, + searching: searching ?? this.searching, + indexing: indexing ?? this.indexing, + truncated: truncated ?? this.truncated, + skippedFiles: skippedFiles ?? this.skippedFiles, + error: error, + focusRequest: focusRequest ?? this.focusRequest, + generation: generation ?? this.generation, + requestRevision: requestRevision ?? this.requestRevision, + documentMatchCount: documentMatchCount ?? this.documentMatchCount, + documentFirstMatchIndex: + documentFirstMatchIndex ?? this.documentFirstMatchIndex, + documentCurrentMatchIndex: clearDocumentSelection + ? null + : documentCurrentMatchIndex ?? this.documentCurrentMatchIndex, + sourceNavigationRequest: + sourceNavigationRequest ?? this.sourceNavigationRequest, + appliedSourceNavigation: + appliedSourceNavigation ?? this.appliedSourceNavigation, + ); +} + +final searchSessionProvider = + NotifierProvider( + SearchSessionController.new, + ); + +/// One window owns one bounded, ephemeral search session. No queries are persisted. +class SearchSessionController extends Notifier { + SearchSessionController({SourceSearchWorker? worker}) + : _worker = worker ?? SourceSearchWorker(); + int _originContextRevision = 0; + final SourceSearchWorker _worker; + Timer? _debounce; + NotesSearchController? _notes; + ProviderSubscription? _historyChanges; + StreamSubscription? _repositoryChanges; + StreamSubscription? _noteChanges; + final _queries = {}; + SourceSearchOptions _recoveryOptions = const SourceSearchOptions(); + final _selectedIdentities = {}; + final _limits = {}; + String? _historyScope; + List _commands = const []; + int _request = 0, _limit = maxWorkspaceSearchResults; + int _sourceWindowStart = 0, _sourceNavigationSerial = 0; + int? _sourceCurrentIndex; + _DocumentSearchNavigation? _sourceNavigation; + bool _disposed = false, _hasOpened = false; + FocusNode? _returnFocus; + WeakReference? _originFocus; + String? _originWorkspace, _originBuffer; + Future _running = Future.value(); + SearchCommandTarget? commandTarget; + String? actionAccountId; + + bool get workspaceVisible => + ref.read(appRouterProvider).routeInformationProvider.value.uri.path == + '/workspace'; + List get categories { + final route = ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path; + if (route != '/' && route != '/workspace') return const []; + final workspace = workspaceVisible + ? ref.read(workspaceControllerProvider).workspace + : null; + final buffer = workspace == null + ? null + : ref.read(workspaceControllerProvider).activeBuffer; + return [ + if (buffer != null) SearchCategory.file, + if (workspace != null) SearchCategory.workspace, + if (buffer != null) SearchCategory.outline, + if (workspace != null) SearchCategory.documents, + if (workspace?.writersideProject != null || + workspace?.writersideModule != null) + SearchCategory.topics, + SearchCategory.recent, + if (workspace != null) SearchCategory.localHistory, + SearchCategory.actions, + ]; + } + + @override + SearchSessionState build() { + final router = ref.read(appRouterProvider); + var route = router.routeInformationProvider.value.uri; + void routeChanged() { + final next = router.routeInformationProvider.value.uri; + if (next == route) return; + route = next; + _originContextRevision++; + close(restoreFocus: false); + commandTarget = null; + actionAccountId = null; + _originFocus = null; + _originWorkspace = null; + _originBuffer = null; + } + + router.routeInformationProvider.addListener(routeChanged); + void focusChanged() { + if (state.active || !workspaceVisible) return; + final focus = FocusManager.instance.primaryFocus; + final contextRevision = _originContextRevision; + // Focus notifications can refer to an element being deactivated by a + // rename, tab close or route change. Inspect ancestry after finalization. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!ref.mounted || + state.active || + !workspaceVisible || + contextRevision != _originContextRevision || + focus?.context?.mounted != true) { + return; + } + if (_isOrigin(focus)) { + _originFocus = WeakReference(focus!); + final workspace = ref.read(workspaceControllerProvider); + _originWorkspace = workspace.workspace?.id; + _originBuffer = + Actions.maybeFind(focus.context!) != + null + ? workspace.activeBufferId + : null; + } + }); + WidgetsBinding.instance.scheduleFrame(); + } + + FocusManager.instance.addListener(focusChanged); + ref.listen(workspaceControllerProvider, (before, after) { + if (before?.activeBuffer?.editorState.mode != + after.activeBuffer?.editorState.mode) { + cancelSourceNavigation(state.sourceNavigationRequest); + } + if (before?.workspace?.id != after.workspace?.id || + before?.activeBufferId != after.activeBufferId) { + _originContextRevision++; + } + if (before?.workspace?.id != after.workspace?.id) { + commandTarget = null; + _originFocus = null; + _returnFocus = null; + if (state.recovery && workspaceVisible && after.workspace != null) { + open( + category: after.activeBuffer == null + ? SearchCategory.workspace + : SearchCategory.file, + ); + return; + } + } + if (state.active && _searchInputsChanged(state.category, before, after)) { + if (categories.isEmpty) { + close(restoreFocus: false); + return; + } + if (!categories.contains(state.category)) { + open(category: categories.first); + } else { + _schedule(); + } + } + }); + ref.listen(appSettingsControllerProvider, (before, after) { + if (state.active && + state.category == SearchCategory.recent && + !identical(before?.recentWorkspaces, after.recentWorkspaces)) { + _schedule(); + } + }); + ref.onDispose(() { + _historyChanges?.close(); + _disposed = true; + _request++; + _debounce?.cancel(); + _worker.dispose(); + unawaited(_notes?.dispose()); + unawaited(_repositoryChanges?.cancel()); + unawaited(_noteChanges?.cancel()); + router.routeInformationProvider.removeListener(routeChanged); + FocusManager.instance.removeListener(focusChanged); + commandTarget = null; + _returnFocus = null; + actionAccountId = null; + }); + return const SearchSessionState(); + } + + List _commandCategories = const []; + + void configureCommands( + List commands, { + List categoryOrder = const [], + }) { + final categoriesChanged = !listEquals(_commandCategories, categoryOrder); + _commandCategories = categoryOrder; + final changed = + categoriesChanged || + commands.length != _commands.length || + commands.indexed.any((entry) { + final (i, value) = entry; + final old = _commands[i]; + return value.identity != old.identity || + value.enabled != old.enabled || + value.title != old.title || + value.category != old.category || + value.description != old.description || + value.disabledReason != old.disabledReason || + value.command.shortcut?.label != old.command.shortcut?.label; + }); + _commands = commands; + if (changed && state.active && state.category == SearchCategory.actions) { + // Metadata arrives from the localized registry boundary during rendering. + // Publish after the frame, without executing a handler or restarting work. + final request = _request; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_current(request) && state.category == SearchCategory.actions) { + _publishActions(); + } + }); + WidgetsBinding.instance.scheduleFrame(); + } + } + + void _publishActions() { + if (state.results.isNotEmpty && + state.selected >= 0 && + state.selected < state.results.length) { + _selectedIdentities[state.category] = + state.results[state.selected].identity; + } + final query = state.query.trim(); + int? rank(ActionSearchResult c) => commandMatchRank( + name: c.title, + category: c.category, + description: c.description, + id: c.identity, + query: query, + ); + final categories = _commandCategories.isEmpty + ? _commands.map((c) => c.category).toSet().toList() + : _commandCategories; + final results = _commands.where((c) => rank(c) != null).toList() + ..sort((a, b) { + if (query.isEmpty) { + final group = categories + .indexOf(a.category) + .compareTo(categories.indexOf(b.category)); + if (group != 0) return group; + } else { + final relevance = rank(a)!.compareTo(rank(b)!); + if (relevance != 0) return relevance; + } + if (a.enabled != b.enabled) return a.enabled ? -1 : 1; + final name = a.title.compareTo(b.title); + if (name != 0) return name; + final category = a.category.compareTo(b.category); + return category != 0 ? category : a.identity.compareTo(b.identity); + }); + state = state.copyWith( + results: List.unmodifiable(results), + selected: _selectionFor(results), + searching: false, + ); + } + + bool _isOrigin(FocusNode? focus) { + final context = focus?.context; + return context != null && + (Actions.maybeFind(context) != null || + context.findAncestorWidgetOfExactType() != + null || + context.findAncestorStateOfType() != null); + } + + void captureOrigin() { + if (state.active) return; + var focus = FocusManager.instance.primaryFocus; + final workspace = ref.read(workspaceControllerProvider); + if (workspaceVisible && + !_isOrigin(focus) && + _originWorkspace == workspace.workspace?.id && + (_originBuffer == null || _originBuffer == workspace.activeBufferId) && + _originFocus?.target?.context?.mounted == true) { + focus = _originFocus?.target; + } + _returnFocus = focus; + actionAccountId = workspaceVisible + ? workspace.workspace?.nextcloudAccountId + : null; + commandTarget = workspaceVisible + ? SearchCommandTarget.capture(workspace, focus) + : null; + } + + void open({SearchCategory? category, bool recovery = false}) { + final available = categories; + if (available.isEmpty) return; + final requested = + category ?? + (_hasOpened && available.contains(state.category) + ? state.category + : available.contains(SearchCategory.file) + ? SearchCategory.file + : SearchCategory.recent); + if (!available.contains(requested)) return; + + captureOrigin(); + _resetSourceNavigation(); + _remember(); + final isRecovery = requested == SearchCategory.workspace && recovery; + final options = isRecovery + ? _recoveryOptions + : _queries[requested] ?? const SourceSearchOptions(); + _limit = _limits[requested] ?? maxWorkspaceSearchResults; + _hasOpened = true; + state = state.copyWith( + active: true, + category: requested, + recovery: isRecovery, + historyDocumentId: requested == SearchCategory.localHistory + ? _historyScope + : null, + clearHistoryScope: + requested != SearchCategory.localHistory || _historyScope == null, + query: options.query, + results: const [], + selected: 0, + focusRequest: state.focusRequest + 1, + requestRevision: state.requestRevision + 1, + ); + _schedule(); + } + + void _observeHistory() { + _historyChanges ??= ref.listen(localHistoryControllerProvider, ( + before, + after, + ) { + if (state.active && + state.category == SearchCategory.localHistory && + before?.searchRevision != after.searchRevision) { + _schedule(); + } + }); + } + + void openCategory(SearchCategory category) { + if (category == SearchCategory.localHistory) { + openHistory(); + } else { + open(category: category); + } + } + + void openHistory({String? documentId}) { + _historyScope = documentId; + open(category: SearchCategory.localHistory); + } + + void clearHistoryScope() { + _historyScope = null; + set(state.copyWith(clearHistoryScope: true)); + } + + void toggle() => state.active ? close() : open(); + void close({bool restoreFocus = true}) { + if (!state.active) return; + _historyChanges?.close(); + _historyChanges = null; + _remember(); + _resetSourceNavigation(); + _cancel(); + state = state.copyWith( + active: false, + results: const [], + searching: false, + indexing: false, + generation: _request, + requestRevision: state.requestRevision + 1, + documentMatchCount: 0, + documentFirstMatchIndex: 0, + clearDocumentSelection: true, + sourceNavigationRequest: 0, + appliedSourceNavigation: 0, + ); + final target = _returnFocus; + _returnFocus = null; + commandTarget = null; + actionAccountId = null; + unawaited(_notes?.dispose()); + _notes = null; + unawaited(_repositoryChanges?.cancel()); + _repositoryChanges = null; + if (restoreFocus && !(rootNavigatorKey.currentState?.canPop() ?? false)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (target?.context?.mounted == true && target!.canRequestFocus) { + target.requestFocus(); + } + }); + } + } + + void _remember() { + final options = SourceSearchOptions(query: state.query); + if (state.recovery) { + _recoveryOptions = options; + } else { + _queries[state.category] = options; + } + _limits[state.category] = _limit; + if (state.selected >= 0 && state.selected < state.results.length) { + _selectedIdentities[state.category] = + state.results[state.selected].identity; + } + } + + void set(SearchSessionState next) { + if (next.query != state.query && next.category != SearchCategory.actions) { + _selectedIdentities.remove(next.category); + } + _resetSourceNavigation(); + state = next.copyWith( + results: const [], + selected: 0, + requestRevision: state.requestRevision + 1, + ); + _limit = maxWorkspaceSearchResults; + _remember(); + _schedule(); + } + + void query(String value) => set(state.copyWith(query: value)); + void select(int index) { + if (state.results.isEmpty) return; + state = state.copyWith(selected: index.clamp(0, state.results.length - 1)); + _selectedIdentities[state.category] = + state.results[state.selected].identity; + } + + void moveSelection(int delta) => select(state.selected + delta); + + void showMore() { + if (!state.active || state.waitingForInput) return; + _limit += maxWorkspaceSearchResults; + if (state.textActive && state.category == SearchCategory.file) { + _sourceWindowStart = + ((_sourceCurrentIndex ?? _sourceWindowStart) ~/ _limit) * _limit; + } + _schedule(); + } + + void _resetSourceNavigation() { + _sourceNavigation = null; + _sourceCurrentIndex = null; + _sourceWindowStart = 0; + } + + /// Selects by full-document ordinal or post-edit source offset. Displayed + /// rows remain a bounded window; scanning/counting stays in the same worker. + int? navigateSource(SourceSearchNavigationRequest request) { + final workspace = ref.read(workspaceControllerProvider); + final buffer = workspace.activeBuffer; + if (!state.textActive || + state.category != SearchCategory.file || + !workspaceVisible || + buffer == null || + (buffer.id != request.documentId && + buffer.filePath != request.documentId) || + buffer.text != request.source || + state.options != request.options || + request.matchIndex != null && request.matchIndex! < 0 || + request.minimumFullOffset != null && + (request.minimumFullOffset! < 0 || + request.minimumFullOffset! > buffer.text.length)) { + return null; + } + final id = ++_sourceNavigationSerial; + final index = request.matchIndex; + final navigation = _DocumentSearchNavigation( + id, + request, + workspace.workspace!.id, + buffer.id, + buffer.revision, + buffer.editorState.mode, + state.requestRevision, + ); + if (!state.searching && + index != null && + index >= state.documentFirstMatchIndex && + index < state.documentFirstMatchIndex + state.results.length) { + _sourceNavigation = request.reveal ? navigation : null; + _sourceCurrentIndex = index; + state = state.copyWith( + documentCurrentMatchIndex: index, + selected: index - state.documentFirstMatchIndex, + sourceNavigationRequest: request.reveal ? id : 0, + appliedSourceNavigation: request.reveal ? id : 0, + ); + return id; + } + _sourceNavigation = navigation; + if (index != null) _sourceWindowStart = (index ~/ _limit) * _limit; + state = state.copyWith(sourceNavigationRequest: id); + _schedule(); + return id; + } + + void cancelSourceNavigation(int id) { + if (id == 0 || state.sourceNavigationRequest != id) return; + final searchingForTarget = _sourceNavigation != null; + _sourceNavigation = null; + _sourceCurrentIndex = null; + state = state.copyWith( + sourceNavigationRequest: 0, + appliedSourceNavigation: 0, + clearDocumentSelection: true, + ); + if (searchingForTarget) _schedule(); + } + + bool _sourceNavigationIsCurrent(_DocumentSearchNavigation navigation) { + final workspace = ref.read(workspaceControllerProvider); + final buffer = workspace.activeBuffer; + return state.textActive && + state.category == SearchCategory.file && + workspaceVisible && + workspace.workspace?.id == navigation.workspaceId && + buffer?.id == navigation.bufferId && + buffer?.revision == navigation.revision && + buffer?.editorState.mode == navigation.mode && + buffer?.text == navigation.request.source && + state.options == navigation.request.options && + state.requestRevision == navigation.requestRevision; + } + + void refresh(WorkspaceState _) => _schedule(); + void _cancel() { + _request++; + _debounce?.cancel(); + _worker.cancel(); + _notes?.cancel(); + unawaited(_noteChanges?.cancel()); + _noteChanges = null; + } + + void _schedule() { + _cancel(); + final request = _request; + final visible = workspaceVisible; + final workspace = ref.read(workspaceControllerProvider); + final navigation = _sourceNavigation; + final validNavigation = + navigation != null && _sourceNavigationIsCurrent(navigation); + if (!validNavigation && + (navigation != null || + state.context?.bufferId != workspace.activeBufferId || + state.context?.revision != workspace.activeBuffer?.revision || + !state.textActive || + state.category != SearchCategory.file)) { + _resetSourceNavigation(); + } + state = state.copyWith( + context: SearchSessionContext( + ref.read(appRouterProvider).routeInformationProvider.value.uri.path, + visible ? workspace.workspace?.id : null, + visible ? workspace.activeBufferId : null, + visible ? workspace.activeBuffer?.revision : null, + visible ? workspace.workspace?.nextcloudAccountId : null, + ), + results: const [], + selected: 0, + searching: state.active, + indexing: false, + truncated: false, + skippedFiles: const [], + generation: request, + documentMatchCount: 0, + documentFirstMatchIndex: _sourceWindowStart, + clearDocumentSelection: true, + sourceNavigationRequest: validNavigation ? navigation.id : 0, + appliedSourceNavigation: 0, + ); + if (state.active && + state.category == SearchCategory.localHistory && + !state.waitingForInput) { + _observeHistory(); + } else { + _historyChanges?.close(); + _historyChanges = null; + } + if (!state.active || state.waitingForInput) { + _selectedIdentities.remove(state.category); + state = state.copyWith(searching: false); + return; + } + _debounce = Timer(const Duration(milliseconds: 120), () { + _debounce = null; + _running = _run(request); + unawaited(_running); + }); + } + + Future submit() async { + if (state.waitingForInput) return state.active && !state.searching; + final generation = _request; + if (_debounce?.isActive == true) { + _debounce!.cancel(); + _debounce = null; + _running = _run(generation); + } + await _running; + return _current(generation) && !state.searching; + } + + bool _current(int request) => + !_disposed && request == _request && state.active; + Future _run(int request) async { + if (!_current(request) || state.waitingForInput) return; + final snapshot = state; + final workspaceState = ref.read(workspaceControllerProvider); + final workspace = workspaceState.workspace; + void publish(List results, {bool truncated = false}) { + if (_current(request)) { + state = state.copyWith( + results: List.unmodifiable(results), + selected: _selectionFor(results), + searching: false, + truncated: truncated, + ); + } + } + + try { + switch (snapshot.category) { + case SearchCategory.file: + case SearchCategory.workspace: + if (workspace == null || !workspaceVisible) return; + if (snapshot.category != SearchCategory.file && workspace.isRemote) { + await _searchNotes(request, workspaceState, snapshot); + return; + } + final options = snapshot.options; + WorkspaceTextOutcome outcome; + void publishText( + WorkspaceTextOutcome outcome, { + required bool searching, + }) { + if (!_current(request)) return; + final results = [ + for (final match in outcome.matches) + TextSearchResult(workspace.id, match), + ]; + state = state.copyWith( + results: List.unmodifiable(results), + selected: _selectionFor(results), + searching: searching, + truncated: outcome.truncated, + skippedFiles: outcome.skippedFiles, + ); + } + if (snapshot.category == SearchCategory.file) { + await _searchDocument(request, workspaceState, snapshot); + return; + } else { + outcome = await loadWorkspaceTextMatches( + workspaceState, + options, + loadText: ref.read(workspaceServiceProvider).loadText, + isCancelled: () => !_current(request), + worker: _worker, + maximumResults: _limit, + onProgress: (outcome) => publishText(outcome, searching: true), + ); + } + if (!_current(request)) return; + publishText(outcome, searching: false); + case SearchCategory.outline: + final buffer = workspaceState.activeBuffer; + if (workspace == null || buffer == null || !workspaceVisible) return; + final digest = notesSearchDigest(buffer.text); + final matches = [ + for (final match in matchDocumentOutline( + activeDocumentOutline(workspaceState), + snapshot.query, + maximumResults: _limit + 1, + )) + OutlineSearchResult( + workspace.id, + buffer.id, + buffer.revision, + digest, + match.heading, + List.unmodifiable(match.ancestors.map((h) => h.text)), + buffer.displayName, + ), + ]; + publish( + matches.take(_limit).toList(), + truncated: matches.length > _limit, + ); + case SearchCategory.documents: + if (workspace == null || !workspaceVisible) return; + final documents = []; + if (workspace.isRemote) { + final repository = await ref.read( + nextcloudNotesRepositoryProvider.future, + ); + if (!_current(request)) return; + documents.addAll( + repository.notes + .where( + (n) => + n.accountId == workspace.nextcloudAccountId && + !isNotesRecovery(n) && + !repository.isCreationCandidateBinding(n.localId), + ) + .map( + (n) => QuickOpenDocument( + title: n.title, + path: n.category, + localId: n.localId, + ), + ), + ); + _observeRepository(repository.changes); + } else { + documents.addAll( + workspace.files + .where(isSearchableWorkspaceDocument) + .map( + (f) => QuickOpenDocument( + title: p.basenameWithoutExtension(f.relativePath), + path: f.relativePath, + filePath: f.absolutePath, + ), + ), + ); + } + final query = snapshot.query; + final limit = _limit; + final ranked = await rankQuickOpenAsync( + documents, + query, + limit: limit + 1, + ); + publish([ + for (final document in ranked.take(limit)) + DocumentSearchResult( + workspace.id, + document, + accountId: workspace.nextcloudAccountId, + ), + ], truncated: ranked.length > limit); + case SearchCategory.topics: + if (workspace == null || !workspaceVisible) return; + final query = normalizeNotesSearch(snapshot.query.trim()); + final seen = {}; + final topics = []; + final modules = + workspace.writersideProject?.modules ?? + [ + if (workspace.writersideModule != null) + workspace.writersideModule!, + ]; + for (final module in modules) { + for (final topic in module.topics) { + final path = p.normalize(topic.filePath); + if (!seen.add(path)) continue; + final title = topic.title ?? topic.id; + final relative = p.relative(path, from: workspace.rootPath); + if (!normalizeNotesSearch( + '$title ${topic.id} ${topic.fileName} $relative', + ).contains(query)) { + continue; + } + topics.add( + TopicSearchResult( + workspace.id, + path, + title, + topic.id, + module.config.moduleName ?? p.basename(module.rootPath), + relative, + ), + ); + } + } + topics.sort((a, b) { + final title = a.title.compareTo(b.title); + return title == 0 ? a.path.compareTo(b.path) : title; + }); + publish( + topics.take(_limit).toList(), + truncated: topics.length > _limit, + ); + case SearchCategory.recent: + final query = normalizeNotesSearch(snapshot.query.trim()); + final history = ref + .read(appSettingsControllerProvider) + .recentWorkspaces; + publish([ + for (final item in history) + if (normalizeNotesSearch( + '${item.title ?? ''} ${item.path} ${item.ownerRoot ?? ''} ${item.accountId ?? ''} ${item.accountLabel ?? ''}', + ).contains(query)) + RecentSearchResult(item), + ]); + case SearchCategory.localHistory: + final history = ref.read(localHistoryControllerProvider.notifier); + void publishHistory( + LocalHistorySearchPage page, { + bool complete = false, + }) { + if (!_current(request)) return; + final results = [ + for (final match in page.matches) HistorySearchResult(match), + ]; + state = state.copyWith( + results: results, + selected: _selectionFor(results), + searching: !complete, + truncated: page.truncated, + ); + } + final page = await history.findRetained( + query: snapshot.query, + documentId: snapshot.historyDocumentId, + maximumResults: _limit, + isCancelled: () => !_current(request), + onProgress: publishHistory, + ); + if (page != null) publishHistory(page, complete: true); + case SearchCategory.actions: + if (_current(request)) _publishActions(); + } + } on Object catch (error) { + if (_current(request)) { + state = state.copyWith( + results: const [], + searching: false, + error: error.toString(), + ); + } + } + } + + int _selectionFor(List results) { + final id = _selectedIdentities[state.category]; + final found = results.indexWhere((r) => r.identity == id); + return found < 0 ? 0 : found; + } + + void _observeRepository(Stream changes) { + _repositoryChanges ??= changes.listen((_) { + if (state.active && + (state.category == SearchCategory.documents || + state.textActive && state.category != SearchCategory.file)) { + _schedule(); + } + }); + } + + Future _searchDocument( + int request, + WorkspaceState workspace, + SearchSessionState snapshot, + ) async { + final buffer = workspace.activeBuffer; + if (buffer == null) { + if (_current(request)) state = state.copyWith(searching: false); + return; + } + final document = SourceDocument(fullText: buffer.text); + final navigation = _sourceNavigation; + var matches = await _worker.search( + document, + snapshot.options, + firstMatchIndex: _sourceWindowStart, + minimumFullOffset: navigation?.request.minimumFullOffset, + maximumMatches: _limit, + ); + if (!_current(request)) return; + if (matches == null) { + state = state.copyWith( + searching: false, + skippedFiles: [buffer.displayName], + ); + return; + } + // An offset past the actual final occurrence wraps to the document's first + // window, never to the first row in the previously displayed page. + if (matches.matches.isEmpty && matches.totalMatchCount > 0) { + matches = await _worker.search( + document, + snapshot.options, + maximumMatches: _limit, + ); + if (!_current(request) || matches == null) return; + } + if (navigation != null && !_sourceNavigationIsCurrent(navigation)) return; + var selected = navigation?.request.minimumFullOffset != null + ? matches.matches.isEmpty + ? null + : matches.firstMatchIndex + : navigation?.request.matchIndex ?? _sourceCurrentIndex; + if (selected != null && + (selected < matches.firstMatchIndex || + selected >= matches.firstMatchIndex + matches.matches.length)) { + selected = null; + } + _sourceCurrentIndex = selected; + _sourceWindowStart = matches.firstMatchIndex; + // Retain the request until the editor acknowledges applying it. A derived + // workspace refresh may otherwise erase a ready-but-not-yet-revealed match. + _sourceNavigation = selected != null && navigation?.request.reveal == true + ? navigation + : null; + final digest = notesSearchDigest(buffer.text); + state = state.copyWith( + documentMatchCount: matches.totalMatchCount, + documentFirstMatchIndex: matches.firstMatchIndex, + documentCurrentMatchIndex: selected, + clearDocumentSelection: selected == null, + selected: selected == null ? 0 : selected - matches.firstMatchIndex, + sourceNavigationRequest: navigation?.id ?? 0, + appliedSourceNavigation: navigation?.request.reveal == true + ? navigation!.id + : 0, + searching: false, + truncated: matches.totalMatchCount > matches.matches.length, + results: [ + for (final match in matches.matches) + TextSearchResult( + workspace.workspace!.id, + WorkspaceTextMatch( + filePath: buffer.filePath, + bufferId: buffer.id, + digest: digest, + relativePath: buffer.displayName, + fileKind: resolveWorkspaceDocumentContext( + workspace.workspace!, + buffer, + ).kind, + line: document.lineIndex.lineNumberAtOffset(match.fullStart), + startOffset: match.fullStart, + endOffset: match.fullEnd, + query: snapshot.query, + lineText: boundedSearchSnippet( + document.lineIndex + .lineAt( + document.lineIndex.lineNumberAtOffset(match.fullStart), + ) + .text, + matchStart: + match.fullStart - + document.lineIndex.offsetForLine( + document.lineIndex.lineNumberAtOffset(match.fullStart), + ), + ), + ), + ), + ], + ); + } + + Future _searchNotes( + int request, + WorkspaceState workspace, + SearchSessionState snapshot, + ) async { + final account = workspace.workspace!.nextcloudAccountId!; + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!_current(request)) return; + _observeRepository(repository.changes); + await _notes?.dispose(); + if (!_current(request)) return; + final controller = NotesSearchController( + repository.store, + account, + recovery: snapshot.recovery, + ); + _notes = controller; + _noteChanges = controller.changes.listen((value) { + if (!_current(request)) return; + state = state.copyWith( + results: [ + for (final hit in value.hits) + NoteSearchResult(account, hit, snapshot.recovery), + ], + searching: value.loading, + indexing: value.indexing, + truncated: value.truncated, + error: value.error?.toString(), + ); + }); + await controller.search( + snapshot.query, + limit: _limit, + overlays: [ + for (final buffer in workspace.documentBuffers) + if (buffer.isDirty && + buffer.remoteNote?.accountId == account && + repository.noteById(buffer.remoteNote!.localId) != null) + NotesSearchOverlay( + buffer.remoteNote!.localId, + buffer.revision, + repository.noteById(buffer.remoteNote!.localId)!.title, + repository.noteById(buffer.remoteNote!.localId)!.category, + buffer.text, + ), + ], + ); + } +} + +class _DocumentSearchNavigation { + const _DocumentSearchNavigation( + this.id, + this.request, + this.workspaceId, + this.bufferId, + this.revision, + this.mode, + this.requestRevision, + ); + final int id, revision, requestRevision; + final SourceSearchNavigationRequest request; + final String workspaceId, bufferId; + final DocumentViewModePreference mode; +} + +/// Context plus document identity, revision, view and selection are all required. +/// Losing the target never falls back to the current query field or another editor. +class SearchCommandTarget { + SearchCommandTarget( + this.workspaceId, + this.bufferId, + this.revision, + this.editorState, + this.focus, + this.selectionSnapshot, + ); + final String? workspaceId, bufferId; + final int? revision; + final DocumentEditorState? editorState; + final FocusNode? focus; + final BusyMarkContextCommandSnapshot? selectionSnapshot; + BuildContext? get context => + focus?.context?.mounted == true ? focus!.context : null; + static SearchCommandTarget capture(WorkspaceState state, FocusNode? focus) => + SearchCommandTarget( + state.workspace?.id, + state.activeBufferId, + state.activeBuffer?.revision, + state.activeBuffer?.editorState, + focus, + focus?.context == null + ? null + : (Actions.maybeFind(focus!.context!) + as BusyMarkContextCommandAction?) + ?.captureTarget + ?.call(), + ); + bool isCurrent(WorkspaceState state) => + context != null && + workspaceId == state.workspace?.id && + bufferId == state.activeBufferId && + revision == state.activeBuffer?.revision && + editorState?.mode == state.activeBuffer?.editorState.mode && + (selectionSnapshot?.isCurrent ?? + (editorState?.selection == + state.activeBuffer?.editorState.selection && + editorState?.wysiwygState.anchorBlockId == + state.activeBuffer?.editorState.wysiwygState.anchorBlockId && + editorState?.wysiwygState.anchorOffset == + state.activeBuffer?.editorState.wysiwygState.anchorOffset && + editorState?.wysiwygState.extentBlockId == + state.activeBuffer?.editorState.wysiwygState.extentBlockId && + editorState?.wysiwygState.extentOffset == + state.activeBuffer?.editorState.wysiwygState.extentOffset)); +} + +// Search dependencies differ from editor/navigation dependencies. In particular, +// a tab selection, inspector update, or derived outline refresh must not rescan +// every workspace file or retained historical revision. +bool _searchInputsChanged( + SearchCategory category, + WorkspaceState? before, + WorkspaceState after, +) { + if (before == null || before.workspace?.id != after.workspace?.id) { + return true; + } + switch (category) { + case SearchCategory.outline: + if (!identical(before.liveOutline, after.liveOutline) || + !identical(before.preview, after.preview)) { + return true; + } + continue document; + document: + case SearchCategory.file: + final a = before.activeBuffer, b = after.activeBuffer; + return a?.id != b?.id || + a?.revision != b?.revision || + a?.text != b?.text || + a?.filePath != b?.filePath || + a?.displayName != b?.displayName; + case SearchCategory.workspace: + if (after.workspace?.isRemote == true) { + final account = after.workspace?.nextcloudAccountId; + Map overlays(WorkspaceState value) => { + for (final buffer in value.documentBuffers) + if (buffer.dirty && buffer.remoteNote?.accountId == account) + buffer.id: (buffer.revision, buffer.text), + }; + return !mapEquals(overlays(before), overlays(after)); + } + Map files( + WorkspaceState value, + ) => { + for (final file in value.workspace?.files ?? const []) + if (isSearchableWorkspaceDocument(file)) + file.absolutePath: ( + file.relativePath, + file.kind, + file.size, + file.lastModified, + ), + }; + final a = files(before), b = files(after); + if (!mapEquals(a, b)) return true; + Map overlays(WorkspaceState value) => { + for (final buffer in value.documentBuffers) + if (buffer.dirty && b.containsKey(buffer.filePath)) + buffer.filePath!: (buffer.id, buffer.revision, buffer.text), + }; + return !mapEquals(overlays(before), overlays(after)); + case SearchCategory.documents: + final a = before.workspace?.files ?? const []; + final b = after.workspace?.files ?? const []; + return !mapEquals( + {for (final f in a) f.absolutePath: (f.relativePath, f.kind)}, + {for (final f in b) f.absolutePath: (f.relativePath, f.kind)}, + ); + case SearchCategory.topics: + return !identical( + before.workspace?.writersideProject, + after.workspace?.writersideProject, + ) || + !identical( + before.workspace?.writersideModule, + after.workspace?.writersideModule, + ); + case SearchCategory.recent: + case SearchCategory.localHistory: + case SearchCategory.actions: + return false; + } +} + +bool workspaceSearchInputsChanged( + WorkspaceState? before, + WorkspaceState after, +) { + if (before == null || + before.workspace?.id != after.workspace?.id || + before.activeBufferId != after.activeBufferId || + before.activeText != after.activeText || + !identical(before.workspace, after.workspace)) { + return true; + } + if (before.documentBuffers.length != after.documentBuffers.length) { + return true; + } + for (var i = 0; i < after.documentBuffers.length; i++) { + final a = before.documentBuffers[i], b = after.documentBuffers[i]; + if (a.id != b.id || + a.filePath != b.filePath || + a.revision != b.revision || + a.text != b.text) { + return true; + } + } + return false; +} diff --git a/lib/src/search/search_sidebar.dart b/lib/src/search/search_sidebar.dart new file mode 100644 index 00000000..8b1970ed --- /dev/null +++ b/lib/src/search/search_sidebar.dart @@ -0,0 +1,726 @@ +import '../app/command_presentation.dart'; +import '../app/keyboard_shortcut_badge.dart'; +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app/app_settings.dart'; +import '../app/busymark_design.dart'; +import '../app/busymark_glyphs.dart'; +import '../app/busymark_search_field.dart'; +import '../app/command_registry.dart'; +import '../app/localization.dart'; +import '../local_history/local_history_controller.dart'; +import '../local_history/local_history_models.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../workspace/recent_navigation.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_glyphs.dart'; +import 'search_activation.dart'; +import 'search_result_presentation.dart'; +import 'search_action_availability.dart'; +import 'search_session.dart'; + +String searchCategoryLabel(BuildContext context, SearchCategory category) => + switch (category) { + SearchCategory.file => context.l10n.searchDocumentTextCategory, + SearchCategory.workspace => context.l10n.searchWorkspaceTextCategory, + SearchCategory.outline => context.l10n.searchHeadingsCategory, + SearchCategory.documents => context.l10n.searchDocumentsCategory, + SearchCategory.topics => context.l10n.searchTopicsCategory, + SearchCategory.recent => context.l10n.recent, + SearchCategory.localHistory => context.l10n.localHistory, + SearchCategory.actions => context.l10n.actions, + }; + +bool _resultBodyHasFocus() { + var result = false; + FocusManager.instance.primaryFocus?.context?.visitAncestorElements((element) { + final widget = element.widget; + if (widget is IconButton || + widget is BusyMarkCompactIconButton || + widget is BusyMarkMenuButton) { + return false; + } + if (widget is SearchResultList) { + result = true; + return false; + } + return true; + }); + return result; +} + +/// The only query entry, mounted in the existing header on both routes. +class SearchHeaderField extends ConsumerStatefulWidget { + const SearchHeaderField({super.key}); + @override + ConsumerState createState() => _SearchHeaderFieldState(); +} + +class _SearchHeaderFieldState extends ConsumerState { + final _text = TextEditingController(); + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(searchSessionProvider); + if (_text.text != state.query) { + _text.value = TextEditingValue( + text: state.query, + selection: TextSelection.collapsed(offset: state.query.length), + ); + } + final controller = ref.read(searchSessionProvider.notifier); + return Focus( + onKeyEvent: (_, event) { + if (event is! KeyDownEvent || _text.value.composing.isValid) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.arrowUp) { + controller.moveSelection( + key == LogicalKeyboardKey.arrowDown ? 1 : -1, + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: BusyMarkSearchField( + key: const ValueKey('shared-search-query'), + controller: _text, + hintText: switch (state.category) { + SearchCategory.file => context.l10n.searchDocumentHint, + SearchCategory.workspace => context.l10n.searchWorkspaceHint, + SearchCategory.outline => context.l10n.searchOutlineHint, + SearchCategory.documents => context.l10n.searchDocumentsHint, + SearchCategory.topics => context.l10n.searchTopicsHint, + SearchCategory.recent => context.l10n.searchRecentHint, + SearchCategory.localHistory => context.l10n.searchHistoryHint, + SearchCategory.actions => context.l10n.searchActionsHint, + }, + autofocus: state.active, + focusRequest: state.focusRequest, + clearOnEscape: false, + onChanged: controller.query, + onMoveSelection: controller.moveSelection, + onClear: () => controller.query(''), + onEscape: controller.close, + onSubmitted: (_) => + unawaited(activateSelectedSearchResult(context, ref)), + ), + ); + } +} + +/// One lazy result surface, shared by all categories and both application routes. +class SearchSidebar extends ConsumerWidget { + const SearchSidebar({super.key, this.onReviewReplacement}); + final Future Function()? onReviewReplacement; + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(searchSessionProvider); + if (!state.active) return const SizedBox.shrink(); + final controller = ref.read(searchSessionProvider.notifier); + final commands = BusyMarkCommandRegistryScope.maybeOf(context); + if (commands != null) { + controller.configureCommands([ + for (final c in commands.visibleCommands()) + ActionSearchResult( + c, + c.label(context), + c.category(context), + c.description?.call(context), + enabled: searchActionEnabled( + commands, + c, + controller, + ref.read(workspaceControllerProvider), + ), + disabledReason: + BusyMarkCommandCatalog.notesWorkspaceCommandIds.contains( + c.id, + ) && + ref.read(workspaceControllerProvider).workspace?.isRemote != + true + ? context.l10n.searchRequiresNotesWorkspace + : c.disabledReason?.call(context), + ), + ], categoryOrder: commandCategoryOrder(context)); + } + final workspace = ref.watch(workspaceControllerProvider); + final remote = workspace.workspace?.isRemote == true; + final historyDocuments = + state.category == SearchCategory.localHistory && + state.historyDocumentId != null + ? ref.watch(localHistoryControllerProvider).snapshot.documents + : const []; + final container = ProviderScope.containerOf(context, listen: false); + final accountId = workspace.workspace?.nextcloudAccountId; + final activeAccount = + remote && container.exists(nextcloudConnectionProvider) + ? ref.watch(nextcloudConnectionProvider).account + : null; + final cachedAccount = + remote && container.exists(nextcloudNotesRepositoryProvider) + ? ref + .watch(nextcloudNotesRepositoryProvider) + .value + ?.accountById(accountId ?? '') + : null; + final account = activeAccount?.id == accountId + ? activeAccount + : cachedAccount; + final historyDocument = state.historyDocumentId == null + ? null + : historyDocuments + .where((d) => d.id == state.historyDocumentId) + .firstOrNull; + final contextLabel = + state.fileActive || state.category == SearchCategory.outline + ? workspace.activeBuffer?.displayName + : state.workspaceActive || + state.category == SearchCategory.documents || + state.category == SearchCategory.topics + ? (state.recovery + ? context.l10n.notesRecovery + : remote + ? (account?.id == workspace.workspace?.nextcloudAccountId + ? '${account!.loginName} · ${account.server.host}' + : context.l10n.searchAccountUnavailable) + : workspace.workspace?.filesystemRootPath) + : state.category == SearchCategory.localHistory + ? historyDocument?.displayName ?? + (state.historyDocumentId != null + ? context.l10n.searchResourceUnavailable + : null) + : null; + return Focus( + onKeyEvent: (_, event) { + if (!state.active || event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.escape) { + controller.close(); + return KeyEventResult.handled; + } + if (!_resultBodyHasFocus()) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + controller.moveSelection( + event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1, + ); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter) { + unawaited(activateSelectedSearchResult(context, ref)); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + vertical: BusyMarkSpacing.xs, + ), + child: Row( + children: [ + const Icon(BusyMarkGlyphs.search, size: BusyMarkSizes.iconSm), + const SizedBox(width: BusyMarkSpacing.xs), + Text(context.l10n.searchSidebarLabel), + const SizedBox(width: BusyMarkSpacing.xs), + Expanded( + child: Semantics( + label: context.l10n.searchCategory, + child: BusyMarkPopupSelector( + key: const ValueKey('search-category'), + value: state.category, + label: searchCategoryLabel(context, state.category), + tooltip: context.l10n.searchCategory, + fullWidth: true, + maxLabelLines: 2, + options: [ + for (final category in controller.categories) + BusyMarkPopupSelectorOption( + value: category, + label: searchCategoryLabel(context, category), + shortcut: + (commands ?? + BusyMarkCommandCatalog + .metadata)[searchCommandForCategory( + category, + )] + ?.shortcut + ?.label, + gtkAccelerator: + (commands ?? + BusyMarkCommandCatalog + .metadata)[searchCommandForCategory( + category, + )] + ?.shortcut + ?.gtkAccelerator, + ), + ], + onSelected: (category) => + controller.openCategory(category), + ), + ), + ), + ], + ), + ), + if (contextLabel != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Tooltip( + message: contextLabel, + child: Text( + contextLabel, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (state.category == SearchCategory.localHistory && + state.historyDocumentId != null) + BusyMarkCompactIconButton( + tooltip: context.l10n.clearHistorySearchScope, + icon: BusyMarkGlyphs.clear, + onPressed: controller.clearHistoryScope, + ), + ], + ), + ), + if (remote && state.workspaceActive) + _SearchNotice(context.l10n.notesSearchHelp), + if (!remote && + state.workspaceActive && + !state.recovery && + onReviewReplacement != null && + state.query.isNotEmpty && + !state.searching) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + ), + child: BusyMarkPushButton.standard( + onPressed: () => unawaited(onReviewReplacement!()), + child: Text(context.l10n.workspaceReplace), + ), + ), + if (state.searching) + const LinearProgressIndicator( + key: ValueKey('workspace-search-progress'), + ), + if (state.indexing) _SearchNotice(context.l10n.notesIndexing), + if (state.error != null) _SearchNotice(state.error!), + if (state.skippedFiles.isNotEmpty) + Tooltip( + message: state.skippedFiles.join('\n'), + child: _SearchNotice( + '${context.l10n.workspaceSearchSkippedFiles} · ${state.skippedFiles.length}\n' + '${state.skippedFiles.join('\n')}', + ), + ), + if (state.truncated) + _SearchNotice(context.l10n.workspaceSearchIncomplete), + if (state.truncated) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.sm, + ), + child: Tooltip( + message: context.l10n.workspaceSearchIncomplete, + child: TextButton( + onPressed: controller.showMore, + child: Text(context.l10n.workspaceSearchShowMore), + ), + ), + ), + Expanded( + child: (state.results.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Text( + state.searching || + state.error != null || + state.skippedFiles.isNotEmpty + ? '' + : state.waitingForInput + ? context.l10n.searchEnterText + : state.showingInitialList + ? context.l10n.commandUnavailableInContext + : context.l10n.noResults, + ), + ), + ) + : SearchResultList( + groupActions: + state.category == SearchCategory.actions && + state.query.trim().isEmpty, + results: state.results, + selected: state.selected, + onActivate: (result) => + activateSearchResult(context, ref, result), + )), + ), + ], + ), + ); + } +} + +class _SearchNotice extends StatelessWidget { + const _SearchNotice(this.text); + final String text; + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkSpacing.md, + vertical: BusyMarkSpacing.xs, + ), + child: Text( + text, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ); +} + +class SearchResultList extends ConsumerStatefulWidget { + const SearchResultList({ + super.key, + required this.results, + this.selected = -1, + required this.onActivate, + this.groupActions = false, + }); + final bool groupActions; + final List results; + final int selected; + final Future Function(SidebarSearchResult) onActivate; + @override + ConsumerState createState() => _SearchResultListState(); +} + +class _SearchResultListState extends ConsumerState { + final _scroll = ScrollController(); + final _keys = {}; + final _focusNodes = {}; + @override + void initState() { + super.initState(); + final selected = widget.selected; + final results = widget.results; + if (selected >= 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_reveal(selected, results, restoreFocus: false)); + }); + } + } + + @override + void didUpdateWidget(covariant SearchResultList oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.results, widget.results)) { + _keys.clear(); + final retained = {for (final result in widget.results) result.identity}; + final obsolete = [ + for (final id in _focusNodes.keys) + if (!retained.contains(id)) id, + ]; + final released = [for (final id in obsolete) _focusNodes.remove(id)!]; + // Old lazy rows detach this frame. Keep focus by resource identity when + // availability reorders Actions; release handles for discarded pages. + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final node in released) { + node.dispose(); + } + }); + } + if (widget.selected >= 0 && + (oldWidget.selected != widget.selected || + !identical(oldWidget.results, widget.results))) { + final index = widget.selected; + final results = widget.results; + final restoreFocus = _resultBodyHasFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_reveal(index, results, restoreFocus: restoreFocus)); + }); + } + } + + Future _reveal( + int index, + List results, { + required bool restoreFocus, + }) async { + // Rows wrap and follow text scaling. Advance by actual viewports until the + // lazy row mounts, then reveal its measured geometry rather than guessing + // its offset from an assumed fixed row height. + while (mounted && + _scroll.hasClients && + widget.selected == index && + identical(widget.results, results)) { + final target = _keys[index]?.currentContext; + if (target != null && target.mounted) { + await Scrollable.ensureVisible( + target, + alignment: .5, + duration: const Duration(milliseconds: 80), + ); + if (restoreFocus && + mounted && + widget.selected == index && + identical(widget.results, results)) { + _focusNodes[results[index].identity]?.requestFocus(); + } + return; + } + final mountedRows = _keys.entries.where( + (entry) => entry.value.currentContext != null, + ); + final before = mountedRows.isNotEmpty && index < mountedRows.first.key; + final position = _scroll.position; + final offset = + (position.pixels + position.viewportDimension * (before ? -.8 : .8)) + .clamp(0.0, position.maxScrollExtent); + if (offset == position.pixels) return; + _scroll.jumpTo(offset); + await WidgetsBinding.instance.endOfFrame; + } + } + + @override + void dispose() { + for (final node in _focusNodes.values) { + node.dispose(); + } + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final rows = widget.results; + final recentResources = ref.watch( + appSettingsControllerProvider.select((s) => s.recentWorkspaces), + ); + final workspaceRoot = ref.watch( + workspaceControllerProvider.select( + (s) => s.workspace?.filesystemRootPath, + ), + ); + final colors = BusyMarkSurfaceColors.of(context); + final container = ProviderScope.containerOf(context, listen: false); + final activeAccount = container.exists(nextcloudConnectionProvider) + ? ref.watch(nextcloudConnectionProvider).account + : null; + final cachedAccounts = container.exists(nextcloudNotesRepositoryProvider) + ? ref.watch(nextcloudNotesRepositoryProvider).value?.accounts + : null; + final accountLabels = { + for (final account in cachedAccounts ?? []) + account.id: '${account.loginName} · ${account.server.host}', + if (activeAccount != null) + activeAccount.id: + '${activeAccount.loginName} · ${activeAccount.server.host}', + }; + return Material( + color: BusyMarkLinuxPalette.transparent, + child: ListView.builder( + key: const ValueKey('sidebar-search-results'), + controller: _scroll, + padding: BusyMarkInsets.sidebarList, + itemCount: rows.length, + itemBuilder: (context, index) { + final result = rows[index]; + final presentation = presentSearchResult( + context, + result, + recentResources: recentResources, + workspaceRoot: workspaceRoot, + accountLabel: (id) => accountLabels[id], + ); + final title = presentation.title; + final subtitle = widget.groupActions && result is ActionSearchResult + ? (result.enabled ? '' : result.disabledReason ?? '') + : presentation.subtitle; + final shortcut = presentation.shortcut; + final enabled = result is! ActionSearchResult || result.enabled; + final selected = + widget.selected >= 0 && + widget.selected < widget.results.length && + widget.results[widget.selected].identity == result.identity; + final key = result is NoteSearchResult + ? ValueKey(( + 'notes-search-hit', + result.hit.localId, + result.hit.start, + result.hit.end, + )) + : ValueKey(result.identity); + final row = BusyMarkSidebarRecordRow( + key: key, + geometryKey: _keys.putIfAbsent(index, GlobalKey.new), + icon: result is RecentSearchResult + ? WorkspaceGlyphs.forRecent(result.resource) + : result is ActionSearchResult + ? BusyMarkGlyphs.keyboard + : result is OutlineSearchResult + ? BusyMarkGlyphs.tag + : result is HistorySearchResult + ? BusyMarkGlyphs.history + : BusyMarkGlyphs.document, + selected: selected, + compact: true, + focusNode: _focusNodes.putIfAbsent(result.identity, FocusNode.new), + onFocusChange: (focused) { + if (focused && + widget.selected >= 0 && + ref.read(searchSessionProvider).active) { + ref.read(searchSessionProvider.notifier).select(index); + } + }, + semanticsLabel: + '$title\n$subtitle${presentation.details == null ? '' : '\n${presentation.details}'}${shortcut == null ? '' : '\n$shortcut'}', + title: result is RecentSearchResult ? title : null, + subtitle: result is RecentSearchResult ? subtitle : null, + pathSubtitle: + result is RecentSearchResult && + result.resource.accountId == null, + tooltip: result is RecentSearchResult ? presentation.details : null, + content: result is RecentSearchResult + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: enabled + ? colors.foreground + : colors.mutedForeground, + ), + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.mutedForeground), + ), + ], + ), + trailing: + result is NoteSearchResult && + !result.recovery && + result.hit.start != null + ? BusyMarkCompactIconButton( + tooltip: context.l10n.notesSourceLocation, + icon: BusyMarkGlyphs.code, + onPressed: () => unawaited( + activateSearchResult(context, ref, result, source: true), + ), + ) + : shortcut == null + ? null + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: BusyMarkKeyboardShortcutBadge(shortcut), + ), + onTap: enabled ? () => unawaited(widget.onActivate(result)) : null, + menuTooltip: result is RecentSearchResult + ? context.l10n.actions + : null, + menuItemsBuilder: result is RecentSearchResult + ? (context) => _recentMenuItems(context, result.resource) + : null, + onMenuSelected: result is RecentSearchResult + ? (action) => unawaited( + runRecentNavigationAction( + context, + ref, + result.resource, + action, + ), + ) + : null, + ); + if (!widget.groupActions || + result is! ActionSearchResult || + index > 0 && + rows[index - 1] is ActionSearchResult && + (rows[index - 1] as ActionSearchResult).category == + result.category) { + return row; + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.sm, + BusyMarkSpacing.xs, + ), + child: Semantics( + header: true, + child: Text( + result.category, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ), + row, + ], + ); + }, + ), + ); + } +} + +List> _recentMenuItems( + BuildContext context, + RecentResource resource, +) => [ + if (resource.accountId == null) ...[ + BusyMarkPopupMenuItem( + value: RecentNavigationAction.openInFiles, + label: context.l10n.openInFiles, + icon: BusyMarkGlyphs.folderOpen, + ), + BusyMarkPopupMenuItem( + value: RecentNavigationAction.copyPath, + label: context.l10n.copyPath, + icon: BusyMarkGlyphs.copy, + ), + ], + BusyMarkPopupMenuItem( + value: RecentNavigationAction.removeFromRecent, + label: context.l10n.removeFromRecent, + icon: BusyMarkGlyphs.clear, + ), +]; diff --git a/lib/src/search/welcome_notes_action.dart b/lib/src/search/welcome_notes_action.dart new file mode 100644 index 00000000..413b941b --- /dev/null +++ b/lib/src/search/welcome_notes_action.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app/app_router.dart'; +import '../app/busymark_toast.dart'; +import '../app/localization.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../workspace/workspace_controller.dart'; +import '../workspace/workspace_safety.dart'; + +enum WelcomeNotesAction { create, browse } + +final welcomeNotesActionProvider = + NotifierProvider( + WelcomeNotesActionController.new, + ); + +class WelcomeNotesActionController extends Notifier { + bool busy = false; + @override + WelcomeNotesAction? build() { + final router = ref.read(appRouterProvider); + void routeChanged() { + final uri = router.routeInformationProvider.value.uri; + if (uri.path != '/' && + !(uri.path == settingsRoutePath && + uri.queryParameters['page'] == 'nextcloudNotes')) { + clear(); + } + } + + router.routeInformationProvider.addListener(routeChanged); + ref.onDispose( + () => router.routeInformationProvider.removeListener(routeChanged), + ); + return null; + } + + void request(WelcomeNotesAction action) => state = action; + void clear() { + if (ref.mounted && state != null) state = null; + } +} + +Future performWelcomeNotesAction( + BuildContext context, + WidgetRef ref, + WelcomeNotesAction action, +) async { + final pending = ref.read(welcomeNotesActionProvider.notifier); + if (pending.busy) return; + pending.busy = true; + try { + final origin = ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri; + if (origin.path != '/' && origin.path != '/workspace') return; + await ref.read(nextcloudConnectionProvider.notifier).reload(); + if (!context.mounted || + ref.read(appRouterProvider).routeInformationProvider.value.uri != + origin) { + pending.clear(); + return; + } + final activeAccount = origin.path == '/workspace' + ? ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId + : null; + final repository = ref.read(nextcloudNotesRepositoryProvider).value; + final account = activeAccount == null + ? ref.read(nextcloudConnectionProvider).account + : repository?.accountById(activeAccount); + if (activeAccount != null && account == null) { + pending.clear(); + BusyMarkToastOverlay.show( + context, + message: context.l10n.searchResourceUnavailable, + ); + return; + } + if (account == null) { + // Returning without a successful connection cancels the pending operation. + if (ref.read(welcomeNotesActionProvider) != null) { + pending.clear(); + return; + } + pending.request(action); + context.go( + '${settingsLocation(SettingsReturnTarget.welcome)}&page=nextcloudNotes', + ); + return; + } + pending + .clear(); // Claim once before any asynchronous work or pointer reactivation. + bool currentRequest() => + context.mounted && + ref.read(appRouterProvider).routeInformationProvider.value.uri == + origin && + (activeAccount == null + ? ref.read(nextcloudConnectionProvider).account?.id == account.id + : ref + .read(workspaceControllerProvider) + .workspace + ?.nextcloudAccountId == + account.id && + repository?.accountById(account.id) != null); + if (!await confirmSafeToContinue(context, ref) || !context.mounted) return; + if (ref.read(appRouterProvider).routeInformationProvider.value.uri != + origin) { + return; + } + final controller = ref.read(workspaceControllerProvider.notifier); + if (ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != + account.id && + !await controller.openNextcloudWorkspace( + account.id, + acceptNavigation: currentRequest, + )) { + return; + } + if (!context.mounted || + ref.read(appRouterProvider).routeInformationProvider.value.uri != + origin || + !currentRequest() || + ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != + account.id) { + return; + } + if (action == WelcomeNotesAction.create && + !await controller.createNextcloudNote( + acceptNavigation: currentRequest, + )) { + return; + } + if (context.mounted && currentRequest()) context.go('/workspace'); + } finally { + pending.busy = false; + } +} diff --git a/lib/src/search/workspace_text_search.dart b/lib/src/search/workspace_text_search.dart new file mode 100644 index 00000000..434d5301 --- /dev/null +++ b/lib/src/search/workspace_text_search.dart @@ -0,0 +1,173 @@ +import 'dart:math' as math; +import '../workspace/workspace_model.dart'; +import '../editor/source/source_search.dart'; +import '../editor/source/source_document.dart'; +import '../nextcloud_notes/domain/notes_search.dart'; +import 'workspace_search_scope.dart'; + +class WorkspaceTextMatch { + const WorkspaceTextMatch({ + required this.filePath, + required this.relativePath, + required this.fileKind, + required this.line, + required this.startOffset, + required this.endOffset, + required this.query, + this.lineText, + required this.digest, + this.bufferId, + }); + + final String? filePath; + final String relativePath; + final DocumentKind fileKind; + final int line; + final int startOffset; + final int endOffset; + final String query; + final String? lineText; + final String digest; + final String? bufferId; +} + +const int maxWorkspaceSearchResults = 80; +const int maxWorkspaceSearchFileBytes = 1024 * 1024; + +class WorkspaceTextOutcome { + const WorkspaceTextOutcome({ + this.matches = const [], + this.truncated = false, + this.skippedFiles = const [], + }); + + final List matches; + final bool truncated; + final List skippedFiles; +} + +Future loadWorkspaceTextMatches( + WorkspaceState state, + SourceSearchOptions options, { + required Future Function(String path) loadText, + required bool Function() isCancelled, + required SourceSearchWorker worker, + required int maximumResults, + void Function(WorkspaceTextOutcome outcome)? onProgress, +}) async { + final workspace = state.workspace; + if (workspace == null || options.query.isEmpty || isCancelled()) { + return const WorkspaceTextOutcome(); + } + final results = []; + final skippedFiles = []; + final publicationClock = Stopwatch()..start(); + var publishedMatches = 0; + var lastPublication = 0; + final sortedFiles = + workspace.files.where(isSearchableWorkspaceDocument).toList() + ..sort((a, b) => a.relativePath.compareTo(b.relativePath)); + Future read(DocumentFile file) async { + final buffer = state.bufferForPath(file.absolutePath); + if (buffer != null) return buffer.text; + if (file.absolutePath == state.activeBuffer?.filePath) { + return state.activeText; + } + if (file.size > maxWorkspaceSearchFileBytes || isCancelled()) return null; + try { + return await loadText(file.absolutePath); + } on Object { + return null; + } + } + + // Bound read-ahead to eight eligible files. Matching remains ordered and + // cancellable; a slow file does not serialize every subsequent disk read. + final pending = >{}; + var nextRead = 0; + for (var index = 0; index < sortedFiles.length; index++) { + if (isCancelled()) return const WorkspaceTextOutcome(); + while (nextRead < sortedFiles.length && nextRead < index + 8) { + pending[nextRead] = read(sortedFiles[nextRead]); + nextRead++; + } + final file = sortedFiles[index]; + final buffer = state.bufferForPath(file.absolutePath); + final text = await pending.remove(index)!; + if (isCancelled()) return const WorkspaceTextOutcome(); + if (text == null) skippedFiles.add(file.relativePath); + if (text != null) { + final document = SourceDocument(fullText: text); + final search = await worker.search( + document, + options, + maximumMatches: maximumResults + 1 - results.length, + stopAfterMaximumMatches: true, + reuseWorker: true, + ); + if (isCancelled()) return const WorkspaceTextOutcome(); + if (search == null) { + skippedFiles.add(file.relativePath); + } else { + final digest = notesSearchDigest(text); + for (final match in search.matches) { + final line = document.lineIndex.lineNumberAtOffset(match.fullStart); + final sourceLine = document.lineIndex.lineAt(line); + results.add( + WorkspaceTextMatch( + digest: digest, + bufferId: buffer?.id, + 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, + ), + ), + ); + } + } + } + // Only report truncation once an additional result was actually found. + if (results.length > maximumResults) { + return WorkspaceTextOutcome( + matches: List.unmodifiable(results.take(maximumResults)), + truncated: true, + skippedFiles: List.unmodifiable(skippedFiles), + ); + } + if (onProgress != null && + results.length > publishedMatches && + (publishedMatches == 0 || + publicationClock.elapsedMilliseconds - lastPublication >= 50)) { + publishedMatches = results.length; + lastPublication = publicationClock.elapsedMilliseconds; + onProgress( + WorkspaceTextOutcome( + matches: List.unmodifiable(results), + skippedFiles: List.unmodifiable(skippedFiles), + ), + ); + } + } + return WorkspaceTextOutcome( + matches: List.unmodifiable(results), + skippedFiles: List.unmodifiable(skippedFiles), + ); +} + +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 ? '…' : ''}'; +} diff --git a/lib/src/spellcheck/spelling_release_smoke.dart b/lib/src/spellcheck/spelling_release_smoke.dart index e1c4b7a2..3aefece2 100644 --- a/lib/src/spellcheck/spelling_release_smoke.dart +++ b/lib/src/spellcheck/spelling_release_smoke.dart @@ -282,7 +282,6 @@ Future runSpellingReleaseSmoke(String reportPath) async { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -397,7 +396,6 @@ helo wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onTransactionalChanged: (text, sourceFilePath, previousSelection, selection, undoGroup) => diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart index c4b977e0..c98b7fbf 100644 --- a/lib/src/workspace/document_buffer.dart +++ b/lib/src/workspace/document_buffer.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'package:flutter/services.dart'; import '../app/app_settings.dart'; -import '../editor/source/source_search.dart'; import '../editor/wysiwyg/wysiwyg_session_state.dart'; import '../spellcheck/spelling_language.dart'; import '../nextcloud_notes/domain/notes_models.dart'; @@ -115,7 +114,6 @@ class DocumentEditorState { this.selection = const TextSelection.collapsed(offset: 0), this.scrollOffset = 0, this.foldedRegionKeys = const {}, - this.searchOptions = const SourceSearchOptions(), this.searchReplacement = '', this.undoState = const DocumentUndoState(), this.wysiwygState = const WysiwygEditorSessionState(), @@ -126,7 +124,6 @@ class DocumentEditorState { final TextSelection selection; final double scrollOffset; final Set foldedRegionKeys; - final SourceSearchOptions searchOptions; final String searchReplacement; final DocumentUndoState undoState; final WysiwygEditorSessionState wysiwygState; @@ -137,7 +134,6 @@ class DocumentEditorState { TextSelection? selection, double? scrollOffset, Set? foldedRegionKeys, - SourceSearchOptions? searchOptions, String? searchReplacement, DocumentUndoState? undoState, WysiwygEditorSessionState? wysiwygState, @@ -150,7 +146,6 @@ class DocumentEditorState { foldedRegionKeys: Set.unmodifiable( foldedRegionKeys ?? this.foldedRegionKeys, ), - searchOptions: searchOptions ?? this.searchOptions, searchReplacement: searchReplacement ?? this.searchReplacement, undoState: undoState ?? this.undoState, wysiwygState: wysiwygState ?? this.wysiwygState, @@ -164,11 +159,6 @@ class DocumentEditorState { 'selectionExtent': selection.extentOffset, 'scrollOffset': scrollOffset, 'foldedRegionKeys': foldedRegionKeys.toList(), - 'searchQuery': searchOptions.query, - 'searchCaseSensitive': searchOptions.caseSensitive, - 'searchWholeWord': searchOptions.wholeWord, - 'searchRegex': searchOptions.regex, - 'searchReplacement': searchReplacement, 'wysiwygState': wysiwygState.toJson(), 'spellingLanguage': spellingLanguage.toJson(), }; @@ -189,12 +179,6 @@ class DocumentEditorState { ?.map((value) => value.toString()) .toSet() ?? const {}, - searchOptions: SourceSearchOptions( - query: json['searchQuery']?.toString() ?? '', - caseSensitive: json['searchCaseSensitive'] as bool? ?? false, - wholeWord: json['searchWholeWord'] as bool? ?? false, - regex: json['searchRegex'] as bool? ?? false, - ), searchReplacement: json['searchReplacement']?.toString() ?? '', wysiwygState: WysiwygEditorSessionState.fromJson( (json['wysiwygState'] as Map?)?.cast() ?? const {}, diff --git a/lib/src/workspace/document_navigation.dart b/lib/src/workspace/document_navigation.dart new file mode 100644 index 00000000..a05ac7d9 --- /dev/null +++ b/lib/src/workspace/document_navigation.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../git/application/git_controller.dart'; +import '../local_history/local_history_controller.dart'; +import 'workspace_controller.dart'; + +/// Reveal a successfully opened document using the same view transition as +/// selecting its editor tab. Resource validation and opening happen first. +void revealOpenedDocument(WidgetRef ref) { + final history = ref.read(localHistoryControllerProvider.notifier); + history.clearComparison(); + ref.read(gitControllerProvider.notifier).deactivateDiffFile(); + final buffer = ref.read(workspaceControllerProvider).activeBuffer; + if (buffer != null) unawaited(history.selectDocumentForBuffer(buffer)); +} diff --git a/lib/src/workspace/outline_navigation.dart b/lib/src/workspace/outline_navigation.dart new file mode 100644 index 00000000..c689b5ec --- /dev/null +++ b/lib/src/workspace/outline_navigation.dart @@ -0,0 +1,196 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +import '../editor/wysiwyg/writerside_editing_adapter.dart'; +import '../markdown/document_outline.dart'; +import '../markdown/preview_model.dart'; +import 'workspace_controller.dart'; +import 'workspace_model.dart'; + +final outlineNavigationTargetProvider = + NotifierProvider< + OutlineNavigationTargetController, + OutlineNavigationTarget? + >(OutlineNavigationTargetController.new); +final outlineViewportTargetProvider = + NotifierProvider( + OutlineViewportTargetController.new, + ); + +class OutlineNavigationTargetController + extends Notifier { + @override + OutlineNavigationTarget? build() => null; + + void set(OutlineNavigationTarget? target) { + state = target; + } +} + +class OutlineViewportTargetController extends Notifier { + @override + OutlineViewportTarget? build() => null; + + void set(OutlineViewportTarget target) { + if (state?.sameLocationAs(target) ?? false) { + return; + } + state = target; + } +} + +class OutlineNavigationTarget { + const OutlineNavigationTarget({ + required this.workspaceId, + required this.bufferId, + required this.filePath, + required this.headingId, + required this.line, + this.editorBlockId, + }); + + final String workspaceId; + final String bufferId; + final String? filePath; + final String headingId; + final int? line; + final String? editorBlockId; +} + +class OutlineViewportTarget { + const OutlineViewportTarget({ + required this.workspaceId, + required this.bufferId, + required this.filePath, + required this.headingId, + required this.sourceStartOffset, + required this.editorBlockId, + }); + + final String workspaceId; + final String bufferId; + final String? filePath; + final String? headingId; + final int? sourceStartOffset; + final String? editorBlockId; + + bool sameLocationAs(OutlineViewportTarget other) { + return workspaceId == other.workspaceId && + bufferId == other.bufferId && + filePath == other.filePath && + headingId == other.headingId && + sourceStartOffset == other.sourceStartOffset && + editorBlockId == other.editorBlockId; + } +} + +void setOutlineViewportTarget( + WidgetRef ref, { + required Workspace workspace, + required String? bufferId, + required DocumentOutlineHeading? heading, +}) { + ref + .read(outlineViewportTargetProvider.notifier) + .set( + OutlineViewportTarget( + workspaceId: workspace.id, + bufferId: bufferId ?? '', + filePath: workspace.activeFilePath, + headingId: heading?.id, + sourceStartOffset: heading?.sourceStartOffset, + editorBlockId: heading?.editorBlockId, + ), + ); +} + +List activeDocumentOutline(WorkspaceState state) { + final liveOutline = state.liveOutline; + final workspace = state.workspace; + if (liveOutline != null && + workspace != null && + liveOutline.matches(workspace, state.activeBuffer, state.activeText)) { + return liveOutline.headings; + } + final preview = state.preview; + if (preview != null) { + return preview.outline; + } + final buffer = state.activeBuffer; + if (workspace != null && + buffer != null && + resolveWorkspaceDocumentContext(workspace, buffer).kind == + DocumentKind.writersideXmlTopic) { + return const WritersideEditingAdapter() + .parseXml( + filePath: buffer.filePath ?? workspace.activeFilePath ?? '', + source: buffer.text, + ) + ?.outline ?? + const []; + } + final markdown = workspace?.markdown; + if (workspace == null || markdown == null) return const []; + if (buffer == null) { + if (state.documentBuffers.isNotEmpty) return const []; + return [ + for (final heading in markdown.headings) + DocumentOutlineHeading.fromMarkdown(heading), + ]; + } + final context = resolveWorkspaceDocumentContext(workspace, buffer); + if (!p.equals(markdown.filePath, context.parserPath) || + markdown.source != buffer.text || + markdown.mode != context.markdownMode) { + return const []; + } + return [ + for (final heading in markdown.headings) + DocumentOutlineHeading.fromMarkdown(heading), + ]; +} + +/// Navigate a heading only against the source/model that produced it. +bool navigateOutlineHeading( + WidgetRef ref, { + required String workspaceId, + required String? bufferId, + required String source, + required DocumentOutlineHeading heading, +}) { + final current = ref.read(workspaceControllerProvider); + final workspace = current.workspace; + if (workspace == null || + workspace.id != workspaceId || + current.activeBufferId != bufferId || + current.activeText != source || + !activeDocumentOutline(current).any( + (h) => + h.id == heading.id && + h.editorBlockId == heading.editorBlockId && + h.sourceStartOffset == heading.sourceStartOffset && + h.sourceStartLine == heading.sourceStartLine && + h.text == heading.text, + )) { + return false; + } + setOutlineViewportTarget( + ref, + workspace: workspace, + bufferId: bufferId, + heading: heading, + ); + ref + .read(outlineNavigationTargetProvider.notifier) + .set( + OutlineNavigationTarget( + workspaceId: workspaceId, + bufferId: bufferId ?? '', + filePath: workspace.activeFilePath, + headingId: heading.id, + line: heading.sourceStartLine, + editorBlockId: heading.editorBlockId, + ), + ); + return true; +} diff --git a/lib/src/workspace/presentation/keyboard_shortcuts_settings.dart b/lib/src/workspace/presentation/keyboard_shortcuts_settings.dart new file mode 100644 index 00000000..3cdadd68 --- /dev/null +++ b/lib/src/workspace/presentation/keyboard_shortcuts_settings.dart @@ -0,0 +1,437 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_settings.dart'; +import '../../app/busymark_design.dart'; +import '../../app/busymark_dialogs.dart'; +import '../../app/command_presentation.dart'; +import '../../app/command_registry.dart'; +import '../../app/keymap_controller.dart'; +import '../../app/keyboard_shortcut_badge.dart'; +import '../../app/busymark_glyphs.dart'; +import '../../app/localization.dart'; +import '../../app/shortcut_binding.dart'; +import '../../app/shortcut_capture.dart'; + +class KeyboardShortcutsSettings extends ConsumerStatefulWidget { + const KeyboardShortcutsSettings({super.key}); + @override + ConsumerState createState() => + _KeyboardShortcutsSettingsState(); +} + +class _KeyboardShortcutsSettingsState + extends ConsumerState { + bool _restoring = false; + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + settings.shortcutOverrides, + ); + final l10n = context.l10n; + final categories = commandCategoryOrder(context); + final commands = registry.commands.toList() + ..sort((a, b) { + final group = categories + .indexOf(a.category(context)) + .compareTo(categories.indexOf(b.category(context))); + if (group != 0) return group; + final name = a.label(context).compareTo(b.label(context)); + return name != 0 ? name : a.id.compareTo(b.id); + }); + final groups = >{ + for (final category in categories) + if (commands.any((c) => c.category(context) == category)) + category: commands + .where((c) => c.category(context) == category) + .toList(), + }; + final deferred = deferredDefaultConflicts( + BusyMarkCommandCatalog.metadata, + validatedShortcutOverrides( + BusyMarkCommandCatalog.metadata, + settings.shortcutOverrides, + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (ref.watch(keymapControllerProvider) == KeymapUpdateStatus.failed) + BusyMarkBanner( + title: l10n.shortcutSaveFailed, + actionLabel: l10n.shortcutRetry, + onAction: () => + unawaited(ref.read(keymapControllerProvider.notifier).retry()), + ), + Expanded( + child: ListView.builder( + key: const ValueKey('settings-shortcut-list'), + itemCount: groups.length, + itemBuilder: (context, i) { + final group = groups.entries.elementAt(i); + return BusyMarkGroupedList( + title: group.key, + filled: true, + children: [ + for (final command in group.value) + _commandRow( + context, + registry, + command, + deferred[command.id], + ), + ], + ); + }, + ), + ), + const SizedBox(height: BusyMarkSpacing.sm), + Align( + alignment: AlignmentDirectional.centerEnd, + child: BusyMarkPushButton.standard( + key: const ValueKey('reset-all-shortcuts'), + onPressed: _restoring ? null : _restoreAll, + child: Text(l10n.shortcutRestoreAll), + ), + ), + ], + ); + } + + Widget _commandRow( + BuildContext context, + BusyMarkCommandRegistry registry, + BusyMarkCommand command, + List? conflicts, + ) { + final configurable = shortcutConfigurable(command); + final description = [ + commandContextLabel(context, command), + if (!configurable) context.l10n.shortcutFixed, + if (conflicts != null) context.l10n.shortcutDefaultConflict, + for (final a + in registry + .activators(command.id) + .skip(1) + .whereType()) + '\u2066${BusyMarkKeyBinding.fromActivator(a).label}\u2069', + ].join(' · '); + return BusyMarkActionRow( + key: ValueKey('shortcut-edit-${command.id}'), + title: command.label(context), + subtitle: description, + tooltip: '${command.label(context)}\n$description', + enabled: configurable, + trailing: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: BusyMarkKeyboardShortcutBadge( + command.shortcut?.label ?? context.l10n.shortcutUnassigned, + ), + ), + onTap: configurable + ? () => showBusyMarkModalDialog( + context, + builder: (_) => _ShortcutEditor(command: command), + ) + : null, + ); + } + + Future _restoreAll() async { + if (_restoring) return; + setState(() => _restoring = true); + try { + final confirmed = await showBusyMarkModalDialog( + context, + builder: (c) => BusyMarkDialogShell( + title: context.l10n.shortcutRestoreAll, + actions: [ + BusyMarkPushButton.standard( + onPressed: () => Navigator.pop(c, false), + child: Text(context.l10n.cancel), + ), + BusyMarkPushButton.suggested( + onPressed: () => Navigator.pop(c, true), + child: Text(context.l10n.shortcutRestoreAll), + ), + ], + children: [Text(context.l10n.shortcutRestoreAllQuestion)], + ), + ); + if (confirmed == true && mounted) { + await ref.read(keymapControllerProvider.notifier).resetAll(); + } + } finally { + if (mounted) setState(() => _restoring = false); + } + } +} + +class _ShortcutEditor extends ConsumerStatefulWidget { + const _ShortcutEditor({required this.command}); + final BusyMarkCommand command; + @override + ConsumerState<_ShortcutEditor> createState() => _ShortcutEditorState(); +} + +class _ShortcutEditorState extends ConsumerState<_ShortcutEditor> { + late Map _expected; + BusyMarkKeyBinding? _candidate; + bool _recording = false, _saving = false; + bool _reset = false; + List _conflicts = const []; + String? _message; + final _assignFocus = FocusNode(); + final _captureFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _expected = ref.read(appSettingsControllerProvider).shortcutOverrides; + _conflicts = + deferredDefaultConflicts( + BusyMarkCommandCatalog.metadata, + validatedShortcutOverrides( + BusyMarkCommandCatalog.metadata, + _expected, + ), + )[widget.command.id] ?? + const []; + if (_conflicts.isNotEmpty) { + _candidate = defaultBinding( + BusyMarkCommandCatalog.metadata[widget.command.id]!, + ); + _reset = true; + } + + _captureFocus.addListener(() { + if (!_captureFocus.hasFocus && _recording) { + _cancelCapture(); + } + }); + } + + @override + void dispose() { + BusyMarkShortcutCapture.end(this); + _assignFocus.dispose(); + _captureFocus.dispose(); + super.dispose(); + } + + void _cancelCapture() { + BusyMarkShortcutCapture.end(this); + if (mounted) { + setState(() { + _recording = false; + }); + } + } + + void _record() { + _captureFocus.requestFocus(); + setState(() { + _recording = true; + _conflicts = const []; + _message = null; + }); + BusyMarkShortcutCapture.begin( + this, + onCandidate: (binding) { + BusyMarkShortcutCapture.end(this); + if (!mounted) return; + setState(() { + _candidate = binding; + _recording = false; + _reset = false; + _expected = ref.read(appSettingsControllerProvider).shortcutOverrides; + _conflicts = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + _expected, + ).conflicts(widget.command.id, binding); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _assignFocus.requestFocus(); + }); + }, + onCancel: _cancelCapture, + ); + } + + Future _save({ + bool reset = false, + bool remove = false, + bool reassign = false, + }) async { + if (_saving) return; + _cancelCapture(); + setState(() { + _saving = true; + _reset = reset; + }); + final result = await ref + .read(keymapControllerProvider.notifier) + .update( + widget.command.id, + remove ? null : _candidate, + expected: _expected, + reset: reset, + reassign: reassign ? _conflicts.map((c) => c.id).toSet() : const {}, + ); + if (!mounted) return; + if (result.status == KeymapUpdateStatus.saved) { + Navigator.pop(context); + return; + } + setState(() { + _saving = false; + _conflicts = result.conflicts; + _expected = ref.read(appSettingsControllerProvider).shortcutOverrides; + _message = switch (result.status) { + KeymapUpdateStatus.failed => context.l10n.shortcutSaveFailed, + KeymapUpdateStatus.changed => context.l10n.shortcutChanged, + KeymapUpdateStatus.invalid => context.l10n.shortcutInvalid, + _ => null, + }; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final current = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + ref.watch(appSettingsControllerProvider).shortcutOverrides, + )[widget.command.id]!; + return BusyMarkDialogShell( + title: widget.command.label(context), + actions: [ + BusyMarkPushButton.standard( + onPressed: _saving + ? null + : () { + _cancelCapture(); + Navigator.pop(context); + }, + child: Text(l10n.cancel), + ), + BusyMarkPushButton.suggested( + key: const ValueKey('assign-shortcut'), + focusNode: _assignFocus, + onPressed: + _saving || _recording || _candidate == null && _conflicts.isEmpty + ? null + : () => _save(reset: _reset, reassign: _conflicts.isNotEmpty), + child: Text( + _conflicts.isEmpty ? l10n.shortcutAssign : l10n.shortcutReassign, + ), + ), + ], + children: [ + if (_saving) + Semantics( + label: l10n.save, + liveRegion: true, + child: const LinearProgressIndicator( + key: ValueKey('shortcut-saving'), + ), + ), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + title: l10n.shortcutCurrentBinding, + subtitle: commandContextLabel(context, widget.command), + trailing: BusyMarkKeyboardShortcutBadge( + current.shortcut?.label ?? l10n.shortcutUnassigned, + ), + ), + BusyMarkActionRow( + key: const ValueKey('record-shortcut'), + title: _recording ? l10n.shortcutPressKeys : l10n.shortcutRecord, + enabled: !_saving, + leading: const Icon(BusyMarkGlyphs.keyboard), + onTap: _recording ? _cancelCapture : _record, + // Focus remains on the explicit recording control, never an editor. + trailing: Focus( + focusNode: _captureFocus, + skipTraversal: true, + includeSemantics: false, + child: const SizedBox.shrink(), + ), + ), + if (_candidate != null) + BusyMarkActionRow( + title: l10n.shortcutProposedBinding, + trailing: BusyMarkKeyboardShortcutBadge(_candidate!.label), + ), + ], + ), + if (_candidate?.hasKnownSystemConflict == true) + Text(l10n.shortcutSystemWarning), + if (_conflicts.isNotEmpty) + BusyMarkGroupedList( + title: l10n.shortcutConflicts, + filled: true, + children: [ + for (final c in _conflicts) + BusyMarkActionRow( + title: c.label(context), + subtitle: commandContextLabel(context, c), + trailing: BusyMarkKeyboardShortcutBadge( + effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + ref + .read(appSettingsControllerProvider) + .shortcutOverrides, + )[c.id]?.shortcut?.label ?? + l10n.shortcutUnassigned, + ), + ), + ], + ), + if (_message != null) + Semantics( + liveRegion: true, + child: Text(_message!, key: const ValueKey('shortcut-feedback')), + ), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + key: const ValueKey('remove-shortcut'), + title: l10n.shortcutRemove, + enabled: !_saving, + onTap: () => _save(remove: true), + ), + BusyMarkActionRow( + key: const ValueKey('restore-shortcut-default'), + title: l10n.shortcutRestoreDefault, + enabled: !_saving, + onTap: () => _save(reset: true), + ), + ], + ), + ], + ); + } +} + +String commandContextLabel(BuildContext context, BusyMarkCommand command) => + switch (command.scope) { + BusyMarkCommandScope.application => + context.l10n.shortcutContextApplication, + BusyMarkCommandScope.documentView => context.l10n.shortcutContextDocument, + BusyMarkCommandScope.editor || + BusyMarkCommandScope.textEditing => context.l10n.shortcutContextEditor, + BusyMarkCommandScope.wysiwyg => context.l10n.editor, + BusyMarkCommandScope.sourceSymbol => + context.l10n.shortcutContextWritersideSource, + BusyMarkCommandScope.sidebar => context.l10n.shortcutContextWorkspace, + BusyMarkCommandScope.tree => context.l10n.shortcutContextTree, + }; diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 4e107f1c..99c7343c 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -1,3 +1,4 @@ +import 'keyboard_shortcuts_settings.dart'; import '../../nextcloud_notes/presentation/nextcloud_settings.dart'; import 'dart:async'; import 'dart:convert'; @@ -19,11 +20,12 @@ import '../../app/app_locale.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_motion_widgets.dart'; +import '../../app/busymark_search_field.dart'; import '../../app/linux/linux_page_frame.dart'; import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; -import '../../app/busymark_shortcuts.dart'; +import '../../app/command_registry.dart'; import '../../app/busymark_toast.dart'; import '../../app/localization.dart'; import '../../app/window_control_service.dart'; @@ -104,6 +106,7 @@ class _SettingsScreenState extends ConsumerState { }); }); final pageBody = switch (_page) { + SettingsPage.keyboardShortcuts => const KeyboardShortcutsSettings(), SettingsPage.appearance => BusyMarkGroupedList( title: l10n.appearance, filled: true, @@ -491,6 +494,7 @@ class _SettingsScreenState extends ConsumerState { Expanded( child: BusyMarkClamp( maxWidth: BusyMarkSizes.settingsWidth, + scrollable: _page != SettingsPage.keyboardShortcuts, margin: EdgeInsets.zero, padding: BusyMarkInsets.settingsPage, child: BusyMarkKeyedCrossfade( @@ -528,7 +532,9 @@ class _SettingsScreenState extends ConsumerState { synchronizer: headerBar.configurationSynchronizer, configuration: headerConfiguration, enabled: headerBar.isAvailable, - child: Scaffold(backgroundColor: colors.view, body: body), + child: BusyMarkLocalTextEditingShortcuts( + child: Scaffold(backgroundColor: colors.view, body: body), + ), ), ); }, @@ -638,8 +644,6 @@ class _SettingsScreenState extends ConsumerState { _goBack(); case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); - case HeaderBarAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); case HeaderBarAction.syntaxReference: showBusyMarkSyntaxReferenceDialog(context); case HeaderBarAction.reportIssue: @@ -682,11 +686,6 @@ class _SettingsScreenState extends ConsumerState { unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: _selectPage(SettingsPage.appearance); - case BusyMarkMainMenuAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); - case BusyMarkMainMenuAction.quickOpen: - case BusyMarkMainMenuAction.commandPalette: - return; case BusyMarkMainMenuAction.syntaxReference: showBusyMarkSyntaxReferenceDialog(context); case BusyMarkMainMenuAction.reportIssue: @@ -1311,6 +1310,7 @@ enum SettingsPage { appearance, editor, spellingDictionaries, + keyboardShortcuts, validation, history, ai, @@ -1321,6 +1321,7 @@ enum SettingsPage { const _primarySettingsPages = [ SettingsPage.appearance, SettingsPage.editor, + SettingsPage.keyboardShortcuts, SettingsPage.validation, SettingsPage.history, SettingsPage.ai, @@ -1337,6 +1338,7 @@ SettingsPage _settingsNavigationSelection(SettingsPage page) { SettingsPage settingsPageFromRouteValue(String? value) { return switch (value) { + 'keyboardShortcuts' => SettingsPage.keyboardShortcuts, 'editor' => SettingsPage.editor, 'spellingDictionaries' => SettingsPage.spellingDictionaries, 'validation' => SettingsPage.validation, @@ -1357,6 +1359,7 @@ String settingsPageRouteValue(SettingsPage page) => page.name; String _settingsPageLabel(BuildContext context, SettingsPage page) { final l10n = context.l10n; return switch (page) { + SettingsPage.keyboardShortcuts => l10n.keyboardShortcuts, SettingsPage.appearance => l10n.appearance, SettingsPage.editor => l10n.editor, SettingsPage.spellingDictionaries => l10n.spellingDictionaries, @@ -1370,6 +1373,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { IconData _settingsPageIcon(SettingsPage page) { return switch (page) { + SettingsPage.keyboardShortcuts => BusyMarkGlyphs.keyboard, SettingsPage.appearance => BusyMarkGlyphs.appearance, SettingsPage.editor => BusyMarkGlyphs.editorView, SettingsPage.spellingDictionaries => BusyMarkGlyphs.symbols, @@ -1488,7 +1492,12 @@ class _SettingsFallbackHeader extends StatelessWidget { tooltip: context.l10n.back, icon: BusyMarkGlyphs.headerBackFor(Directionality.of(context)), nativeIcon: BusyMarkLinuxHeaderIcon.back, - shortcut: BusyMarkAppShortcutLabels.back, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .back] + ?.shortcut + ?.label, onPressed: onBack, ), ], diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 75ef3f7d..ca4a8de7 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -1,3 +1,8 @@ +import '../../app/command_registry.dart'; +import '../recent_navigation.dart'; +import '../../search/search_session.dart'; +import '../../search/search_sidebar.dart'; +import '../../search/welcome_notes_action.dart'; import '../../nextcloud_notes/application/nextcloud_connection.dart'; import 'dart:async'; import 'dart:io'; @@ -6,10 +11,8 @@ import 'package:file_selector/file_selector.dart'; import 'package:busymark/src/app/startup_path.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as p; -import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; import '../../app/app_settings.dart'; @@ -20,8 +23,6 @@ import '../../app/linux/linux_page_frame.dart'; import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; -import '../../app/busymark_shortcuts.dart'; -import '../../app/busymark_toast.dart'; import '../../app/localization.dart'; import '../../app/window_control_service.dart'; import '../../core/debug_log.dart'; @@ -31,7 +32,6 @@ import '../../platform/gtk_header_icon_service.dart'; import '../../platform/linux_header_bar_service.dart'; import '../../writerside/writerside_project_creator.dart'; import '../workspace_controller.dart'; -import '../workspace_glyphs.dart'; import '../workspace_message.dart'; import '../workspace_safety.dart'; @@ -54,7 +54,31 @@ class _WelcomeScreenState extends ConsumerState { final settings = ref.watch(appSettingsControllerProvider); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final colors = BusyMarkSurfaceColors.of(context); - final sidebarVisible = settings.sidebarVisible; + final search = ref.watch(searchSessionProvider); + final sidebarVisible = settings.sidebarVisible || search.active; + final connection = + ProviderScope.containerOf( + context, + listen: false, + ).exists(nextcloudConnectionProvider) + ? ref.watch(nextcloudConnectionProvider) + : const NextcloudConnectionState(); + final pending = ref.watch(welcomeNotesActionProvider); + if (pending != null && connection.account != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + ref.read(welcomeNotesActionProvider) == pending && + ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/') { + unawaited(performWelcomeNotesAction(context, ref, pending)); + } + }); + } ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { _handleHeaderBarAction(context, event.action); @@ -95,10 +119,28 @@ class _WelcomeScreenState extends ConsumerState { final welcomeMainColor = colors.view; final welcomeSidebar = SizedBox( width: BusyMarkSizes.sidebarWidth, - child: _WelcomeSidebar( - recentWorkspaces: settings.recentWorkspaces, - onOpenRecent: _openPath, - onRecentAction: _performRecentAction, + child: BusyMarkSidebarSurface( + showEndBorder: false, + child: IndexedStack( + index: search.active ? 1 : 0, + children: [ + ExcludeFocus( + excluding: search.active, + child: SearchResultList( + results: [ + for (final r in settings.recentWorkspaces) + RecentSearchResult(r), + ], + onActivate: (r) => openRecentResource( + context, + ref, + (r as RecentSearchResult).resource, + ), + ), + ), + const SearchSidebar(), + ], + ), ), ); final welcomeContent = ColoredBox( @@ -123,6 +165,21 @@ class _WelcomeScreenState extends ConsumerState { ), onTap: _createMarkdownFile, ), + BusyMarkActionRow( + key: const ValueKey('welcome-new-nextcloud-note'), + title: context.l10n.nextcloudNewNote, + subtitle: connection.account == null + ? context.l10n.nextcloudNotes + : 'Nextcloud · ${connection.account!.server}', + leading: const Icon(BusyMarkGlyphs.newDocument), + onTap: () => unawaited( + performWelcomeNotesAction( + context, + ref, + WelcomeNotesAction.create, + ), + ), + ), BusyMarkActionRow( title: context.l10n.createWritersideProject, subtitle: context.l10n.createWritersideProjectDescription, @@ -158,30 +215,21 @@ class _WelcomeScreenState extends ConsumerState { ), BusyMarkActionRow( key: const ValueKey('welcome-nextcloud-notes'), - title: context.l10n.nextcloudNotes, + title: context.l10n.browseNotes, + subtitle: connection.account == null + ? context.l10n.nextcloudNotes + : 'Nextcloud · ${connection.account!.server}', leading: const Icon(BusyMarkGlyphs.documentHistory), trailing: Icon( BusyMarkGlyphs.forwardFor(Directionality.of(context)), ), - onTap: () async { - final account = ref - .read(nextcloudConnectionProvider) - .account; - if (account == null) { - context.go( - '${settingsLocation(SettingsReturnTarget.welcome)}&page=nextcloudNotes', - ); - } else { - if (!await confirmSafeToContinue(context, ref) || - !context.mounted) { - return; - } - final opened = await ref - .read(workspaceControllerProvider.notifier) - .openNextcloudWorkspace(account.id); - if (opened && context.mounted) context.go('/workspace'); - } - }, + onTap: () => unawaited( + performWelcomeNotesAction( + context, + ref, + WelcomeNotesAction.browse, + ), + ), ), ], ), @@ -205,11 +253,11 @@ class _WelcomeScreenState extends ConsumerState { .copyWith( title: context.l10n.appTitle, viewMode: AppViewMode.editor, - searchQuery: '', + searchQuery: search.query, canRefresh: false, documentControlsVisible: false, - searchActive: false, - searchVisible: false, + searchActive: search.active, + searchVisible: true, sidebarVisible: sidebarVisible, sidebarToggleVisible: true, backVisible: false, @@ -226,8 +274,13 @@ class _WelcomeScreenState extends ConsumerState { sidebarHeader: const BusyMarkLinuxBrandHeader(), sidebarBody: welcomeSidebar, sidebarAvailable: + search.active || constraints.maxWidth >= - BusyMarkSizes.sidebarWidth + BusyMarkSizes.previewMinWidth, + BusyMarkSizes.sidebarWidth + BusyMarkSizes.previewMinWidth, + sidebarOverlay: + search.active && + constraints.maxWidth < + BusyMarkSizes.sidebarWidth + BusyMarkSizes.previewMinWidth, sidebarExpanded: sidebarVisible, sidebarTransitionGeneration: ref.watch( sidebarTransitionGenerationProvider, @@ -242,14 +295,31 @@ class _WelcomeScreenState extends ConsumerState { icon: BusyMarkGlyphs.sidebar, nativeIcon: BusyMarkLinuxHeaderIcon.sidebar, selected: sidebarVisible, - shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog + .metadata)[BusyMarkCommandIds.toggleSidebar] + ?.shortcut + ?.label, onPressed: _toggleSidebar, ), ], ), - title: BusyMarkLinuxHeaderTitle(context.l10n.appTitle), + centerAllocation: search.active + ? BusyMarkLinuxHeaderCenterAllocation.fillBetweenControls + : BusyMarkLinuxHeaderCenterAllocation.centeredBetweenControls, + title: search.active + ? const SearchHeaderField() + : BusyMarkLinuxHeaderTitle(context.l10n.appTitle), trailing: BusyMarkLinuxHeaderControlGroup( children: [ + BusyMarkHeaderIconButton( + tooltip: context.l10n.search, + icon: BusyMarkGlyphs.search, + selected: search.active, + onPressed: () => + ref.read(searchSessionProvider.notifier).toggle(), + ), BusyMarkMainMenuButton( onSelected: (action) => _handleMainMenuAction(context, headerBar, action), @@ -270,8 +340,6 @@ class _WelcomeScreenState extends ConsumerState { context.go(settingsLocation(SettingsReturnTarget.welcome)); case HeaderBarAction.aboutBusyMark: showBusyMarkAboutDialog(context); - case HeaderBarAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); case HeaderBarAction.syntaxReference: showBusyMarkSyntaxReferenceDialog(context); case HeaderBarAction.reportIssue: @@ -281,8 +349,9 @@ class _WelcomeScreenState extends ConsumerState { ); case HeaderBarAction.sidebarToggle: _toggleSidebar(); - case HeaderBarAction.back: case HeaderBarAction.search: + ref.read(searchSessionProvider.notifier).toggle(); + case HeaderBarAction.back: case HeaderBarAction.refresh: case HeaderBarAction.save: case HeaderBarAction.export: @@ -323,11 +392,6 @@ class _WelcomeScreenState extends ConsumerState { unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: context.go(settingsLocation(SettingsReturnTarget.welcome)); - case BusyMarkMainMenuAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); - case BusyMarkMainMenuAction.quickOpen: - case BusyMarkMainMenuAction.commandPalette: - return; case BusyMarkMainMenuAction.syntaxReference: showBusyMarkSyntaxReferenceDialog(context); case BusyMarkMainMenuAction.reportIssue: @@ -482,152 +546,6 @@ class _WelcomeScreenState extends ConsumerState { context.go('/workspace'); } } - - Future _performRecentAction( - RecentWorkspace recent, - _RecentWorkspaceAction action, - ) async { - switch (action) { - case _RecentWorkspaceAction.openInFiles: - final rawPath = recent.path.trim(); - if (rawPath.isEmpty) { - return; - } - final normalized = p.normalize(rawPath); - final target = - FileSystemEntity.typeSync(normalized) == - FileSystemEntityType.directory - ? normalized - : p.dirname(normalized); - final launched = await launchUrl( - Uri.file(target), - mode: LaunchMode.externalApplication, - ); - if (!launched && mounted) { - BusyMarkToastOverlay.show( - context, - message: context.l10n.couldNotOpenTarget(target), - priority: BusyMarkToastPriority.high, - ); - } - case _RecentWorkspaceAction.copyPath: - await Clipboard.setData(ClipboardData(text: recent.path)); - case _RecentWorkspaceAction.removeFromRecent: - await ref - .read(appSettingsControllerProvider.notifier) - .removeRecentWorkspace(recent.path); - } - } -} - -class _WelcomeSidebar extends StatelessWidget { - const _WelcomeSidebar({ - required this.recentWorkspaces, - required this.onOpenRecent, - required this.onRecentAction, - }); - - final List recentWorkspaces; - final Future Function(String path) onOpenRecent; - final Future Function( - RecentWorkspace recent, - _RecentWorkspaceAction action, - ) - onRecentAction; - - @override - Widget build(BuildContext context) { - return BusyMarkSidebarSurface( - showEndBorder: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ListView( - padding: BusyMarkInsets.sidebarList, - children: [ - if (recentWorkspaces.isNotEmpty) - _WelcomeSidebarSection( - title: context.l10n.recent, - children: [ - for (final recent in recentWorkspaces) - BusyMarkSidebarRecordRow<_RecentWorkspaceAction>( - icon: WorkspaceGlyphs.forRecent(recent), - title: busyMarkLtrIsolateFor( - context, - _displayPath(recent.path), - ), - subtitle: busyMarkLtrIsolateFor(context, recent.path), - onTap: () => unawaited(onOpenRecent(recent.path)), - menuTooltip: context.l10n.actions, - menuItemsBuilder: _recentWorkspaceMenuItems, - onMenuSelected: (action) => - unawaited(onRecentAction(recent, action)), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} - -class _WelcomeSidebarSection extends StatelessWidget { - const _WelcomeSidebarSection({required this.title, required this.children}); - - final String title; - final List children; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: BusyMarkSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.xs), - child: Text(title, style: busyMarkSectionHeaderStyle(context)), - ), - const SizedBox(height: BusyMarkSpacing.sm), - ...children, - ], - ), - ); - } -} - -enum _RecentWorkspaceAction { openInFiles, copyPath, removeFromRecent } - -List> _recentWorkspaceMenuItems( - BuildContext context, -) { - return [ - BusyMarkPopupMenuItem( - value: _RecentWorkspaceAction.openInFiles, - label: context.l10n.openInFiles, - icon: BusyMarkGlyphs.folderOpen, - ), - BusyMarkPopupMenuItem( - value: _RecentWorkspaceAction.copyPath, - label: context.l10n.copyPath, - icon: BusyMarkGlyphs.copy, - ), - const PopupMenuDivider(height: BusyMarkSpacing.sm), - BusyMarkPopupMenuItem( - value: _RecentWorkspaceAction.removeFromRecent, - label: context.l10n.removeFromRecent, - icon: BusyMarkGlyphs.clear, - ), - ]; -} - -String _displayPath(String path) { - final name = p.basename(path); - return name.isEmpty ? path : name; } class BusyMarkCreateWritersideProjectDialog extends StatefulWidget { diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 576d286d..def3fb32 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -1,3 +1,6 @@ +import '../../search/search_result_preview.dart'; +import '../../search/search_session.dart'; +import '../../search/search_sidebar.dart'; import '../../editor/wysiwyg/wysiwyg_commands.dart'; import '../../writerside/writerside_property_validation.dart'; import '../../writerside/writerside_effective_properties.dart'; @@ -51,7 +54,6 @@ import '../../app/linux/linux_page_frame.dart'; import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; -import '../../app/busymark_search_field.dart'; import '../../app/busymark_toast.dart'; import '../../app/command_registry.dart'; import '../../app/localization.dart'; @@ -80,7 +82,6 @@ import '../../editor/source/source_controller.dart'; import '../../editor/source/source_autocomplete.dart'; import '../../editor/source/source_document.dart'; import '../../editor/source/source_editor.dart'; -import '../../editor/source/source_search.dart'; import '../../editor/wysiwyg/wysiwyg_editor.dart'; import '../../editor/wysiwyg/writerside_editing_adapter.dart'; import '../../editor/wysiwyg/wysiwyg_session_state.dart'; @@ -94,6 +95,7 @@ import '../../git/presentation/git_file_status_colors.dart'; import '../../git/presentation/git_sidebar_tab.dart'; import '../../markdown/busymark_document.dart'; import '../../markdown/document_outline.dart'; +import '../outline_navigation.dart'; import '../../markdown/markdown_model.dart'; import '../../markdown/markdown_parser.dart'; import '../../markdown/markdown_section_editor.dart'; @@ -163,24 +165,11 @@ class _ProblemsPresentation extends Notifier { } } -final _outlineNavigationTargetProvider = - NotifierProvider< - _OutlineNavigationTargetController, - _OutlineNavigationTarget? - >(_OutlineNavigationTargetController.new); -final _outlineViewportTargetProvider = - NotifierProvider<_OutlineViewportTargetController, _OutlineViewportTarget?>( - _OutlineViewportTargetController.new, - ); final _sourceNavigationTargetProvider = NotifierProvider< _SourceNavigationTargetController, _SourceNavigationTarget? >(_SourceNavigationTargetController.new); -final _workspaceSearchProvider = - NotifierProvider<_WorkspaceSearchController, _WorkspaceSearchState>( - _WorkspaceSearchController.new, - ); final _searchNavigationTargetProvider = NotifierProvider< _SearchNavigationTargetController, @@ -191,29 +180,6 @@ final _sidebarShortcutRequestProvider = _SidebarShortcutRequestController.new, ); -class _OutlineNavigationTargetController - extends Notifier<_OutlineNavigationTarget?> { - @override - _OutlineNavigationTarget? build() => null; - - void set(_OutlineNavigationTarget? target) { - state = target; - } -} - -class _OutlineViewportTargetController - extends Notifier<_OutlineViewportTarget?> { - @override - _OutlineViewportTarget? build() => null; - - void set(_OutlineViewportTarget target) { - if (state?.sameLocationAs(target) ?? false) { - return; - } - state = target; - } -} - class _SourceNavigationTargetController extends Notifier<_SourceNavigationTarget?> { @override @@ -224,189 +190,6 @@ class _SourceNavigationTargetController } } -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; - - @override - _WorkspaceSearchState build() { - _loadText = ref.read(workspaceServiceProvider).loadText; - ref.listen(workspaceControllerProvider, (previous, next) { - final activeBufferChanged = - previous?.activeBufferId != next.activeBufferId; - var shouldRefresh = _workspaceSearchInputsChanged(previous, next); - if (activeBufferChanged && !state.active) { - final options = next.activeBuffer?.editorState.searchOptions; - if (options != null) { - state = state - .withOptions(options) - .copyWith(matches: const [], searching: false); - } - } else if (!activeBufferChanged) { - final previousOptions = - previous?.activeBuffer?.editorState.searchOptions; - final nextOptions = next.activeBuffer?.editorState.searchOptions; - if (nextOptions != null && - previousOptions != nextOptions && - !_sameSourceSearchOptions(state.options, nextOptions)) { - state = state - .withOptions(nextOptions) - .copyWith(matches: const [], searching: false); - shouldRefresh = true; - } - } - if (shouldRefresh) { - refresh(next); - } - }); - ref.onDispose(() { - _disposed = true; - _request += 1; - _debounce?.cancel(); - _worker.dispose(); - }); - return const _WorkspaceSearchState(); - } - - void set(_WorkspaceSearchState searchState) { - final inputChanged = - state.query != searchState.query || - state.caseSensitive != searchState.caseSensitive || - 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, - ); - final buffer = ref.read(workspaceControllerProvider).activeBuffer; - if (buffer != null && - !_sameSourceSearchOptions( - buffer.editorState.searchOptions, - state.options, - )) { - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorState( - buffer.editorState.copyWith(searchOptions: state.options), - ); - } - _schedule(ref.read(workspaceControllerProvider)); - } - - void refresh(WorkspaceState workspaceState) { - if (!state.active || state.options.query.isEmpty) { - return; - } - _schedule(workspaceState); - } - - Future submit() async { - _debounce?.cancel(); - _worker.cancel(); - final request = ++_request; - final workspaceState = ref.read(workspaceControllerProvider); - final options = state.options; - if (!state.active || - options.query.isEmpty || - workspaceState.workspace == null) { - return false; - } - state = state.copyWith(matches: const [], searching: true); - await _runSearch( - request: request, - workspaceState: workspaceState, - options: options, - ); - return !_disposed && request == _request; - } - - void _schedule(WorkspaceState workspaceState) { - _debounce?.cancel(); - _worker.cancel(); - final request = ++_request; - final workspace = workspaceState.workspace; - final options = state.options; - if (!state.active || options.query.isEmpty || workspace == null) { - final clearMatches = options.query.isEmpty || workspace == null; - if (state.searching || (clearMatches && state.matches.isNotEmpty)) { - state = state.copyWith( - matches: clearMatches ? const [] : state.matches, - searching: false, - ); - } - return; - } - state = state.copyWith( - matches: const [], - searching: true, - truncated: false, - skippedFiles: const [], - invalidRegex: false, - hasZeroLengthMatches: false, - ); - _debounce = Timer(_debounceDelay, () { - _debounce = null; - unawaited( - _runSearch( - request: request, - workspaceState: workspaceState, - options: options, - ), - ); - }); - } - - Future _runSearch({ - required int request, - required WorkspaceState workspaceState, - required SourceSearchOptions options, - }) async { - try { - 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: 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, - skippedFiles: [workspaceState.workspace?.filesystemRootPath ?? ''], - ); - } - } - - void showMore() { - _resultLimit += _maxWorkspaceSearchResults; - _schedule(ref.read(workspaceControllerProvider)); - } -} - class _SearchNavigationTargetController extends Notifier<_SearchNavigationTarget?> { @override @@ -430,51 +213,6 @@ class _SidebarShortcutRequestController extends Notifier<_SidebarTab?> { } } -class _OutlineNavigationTarget { - const _OutlineNavigationTarget({ - required this.workspaceId, - required this.bufferId, - required this.filePath, - required this.headingId, - required this.line, - this.editorBlockId, - }); - - final String workspaceId; - final String bufferId; - final String? filePath; - final String headingId; - final int? line; - final String? editorBlockId; -} - -class _OutlineViewportTarget { - const _OutlineViewportTarget({ - required this.workspaceId, - required this.bufferId, - required this.filePath, - required this.headingId, - required this.sourceStartOffset, - required this.editorBlockId, - }); - - final String workspaceId; - final String bufferId; - final String? filePath; - final String? headingId; - final int? sourceStartOffset; - final String? editorBlockId; - - bool sameLocationAs(_OutlineViewportTarget other) { - return workspaceId == other.workspaceId && - bufferId == other.bufferId && - filePath == other.filePath && - headingId == other.headingId && - sourceStartOffset == other.sourceStartOffset && - editorBlockId == other.editorBlockId; - } -} - class _SourceNavigationTarget { const _SourceNavigationTarget({ required this.filePath, @@ -493,149 +231,6 @@ class _SourceNavigationTarget { final bool xmlChildren; } -class _WorkspaceSearchState { - const _WorkspaceSearchState({ - this.active = false, - this.query = '', - this.caseSensitive = false, - this.wholeWord = false, - this.regex = false, - this.matches = const [], - this.searching = false, - this.truncated = false, - this.skippedFiles = const [], - this.invalidRegex = false, - this.hasZeroLengthMatches = false, - }); - - final bool active; - final String query; - final bool caseSensitive; - final bool wholeWord; - 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, - caseSensitive: caseSensitive, - wholeWord: wholeWord, - regex: regex, - ); - - _WorkspaceSearchState copyWith({ - bool? active, - String? query, - bool? caseSensitive, - bool? wholeWord, - bool? regex, - List<_WorkspaceSearchMatch>? matches, - bool? searching, - bool? truncated, - List? skippedFiles, - bool? invalidRegex, - bool? hasZeroLengthMatches, - }) { - return _WorkspaceSearchState( - active: active ?? this.active, - query: query ?? this.query, - caseSensitive: caseSensitive ?? this.caseSensitive, - wholeWord: wholeWord ?? this.wholeWord, - 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, - ); - } - - _WorkspaceSearchState withOptions(SourceSearchOptions options) { - return copyWith( - query: options.query, - caseSensitive: options.caseSensitive, - wholeWord: options.wholeWord, - regex: options.regex, - ); - } -} - -bool _sameSourceSearchOptions( - SourceSearchOptions first, - SourceSearchOptions second, -) { - return first.query == second.query && - first.caseSensitive == second.caseSensitive && - first.wholeWord == second.wholeWord && - first.regex == second.regex; -} - -bool _workspaceSearchInputsChanged( - WorkspaceState? previous, - WorkspaceState next, -) { - if (previous == null || - previous.activeBufferId != next.activeBufferId || - 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)) { - return false; - } - if (previousWorkspace == null || nextWorkspace == null) { - return previousWorkspace != nextWorkspace; - } - final previousActivePath = previous.activeBuffer?.filePath; - final nextActivePath = next.activeBuffer?.filePath; - return previousActivePath != nextActivePath || - !_sameWorkspaceSearchFiles(previousWorkspace.files, nextWorkspace.files); -} - -bool _sameWorkspaceSearchFiles( - List first, - List second, -) { - final firstSearchable = first.where(_isOpenableTextDocument).toList() - ..sort((left, right) => left.absolutePath.compareTo(right.absolutePath)); - final secondSearchable = second.where(_isOpenableTextDocument).toList() - ..sort((left, right) => left.absolutePath.compareTo(right.absolutePath)); - if (firstSearchable.length != secondSearchable.length) { - return false; - } - for (var index = 0; index < firstSearchable.length; index++) { - final left = firstSearchable[index]; - final right = secondSearchable[index]; - if (left.absolutePath != right.absolutePath || - left.relativePath != right.relativePath || - left.kind != right.kind || - left.size != right.size || - left.lastModified != right.lastModified) { - return false; - } - } - return true; -} - class _SearchNavigationTarget { const _SearchNavigationTarget({ required this.filePath, @@ -658,46 +253,12 @@ class _SearchNavigationTarget { final int request; } -class _ToggleSearchIntent extends Intent { - const _ToggleSearchIntent(); -} - -class _OpenSearchIntent extends Intent { - const _OpenSearchIntent(); -} - class _SelectSidebarTabIntent extends Intent { const _SelectSidebarTabIntent(this.tab); final _SidebarTab tab; } -final _headerSearchFocusRequestProvider = - NotifierProvider<_HeaderSearchFocusRequest, int>( - _HeaderSearchFocusRequest.new, - ); - -class _HeaderSearchFocusRequest extends Notifier { - @override - int build() => 0; - void request() => state++; -} - -final _searchReturnFocusProvider = Provider<_SearchReturnFocus>( - (ref) => _SearchReturnFocus(), -); - -class _SearchReturnFocus { - FocusNode? node; - void restore() { - final target = node; - node = null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (target?.context?.mounted == true) target!.requestFocus(); - }); - } -} - Future _publishWritersideTitleDialog( BuildContext context, WidgetRef ref, @@ -900,19 +461,19 @@ class WorkspaceScreen extends ConsumerWidget { if (workspace == null) { return const WelcomeScreen(); } - final searchState = ref.watch(_workspaceSearchProvider); + final searchState = ref.watch(searchSessionProvider); 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); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final settingsController = ref.read(appSettingsControllerProvider.notifier); final sidebarVisible = - settings.sidebarVisible && _hasWorkspaceSidebar(workspace); - final documentOutline = _activeDocumentOutline(state); + searchState.active || + (settings.sidebarVisible && _hasWorkspaceSidebar(workspace)); + final documentOutline = activeDocumentOutline(state); final canExportPdf = canExportWorkspacePdf(state); final canExportHtml = canExportWorkspaceHtml(state); final sidebar = SizedBox( @@ -922,8 +483,6 @@ class WorkspaceScreen extends ConsumerWidget { workspace: workspace, outline: documentOutline, searchState: searchState, - searchResults: searchResults, - onOpenSearchResult: (result) => _openSearchResult(context, ref, result), ), ); final workspaceContent = Column( @@ -981,44 +540,16 @@ class WorkspaceScreen extends ConsumerWidget { _handleHeaderBarAction(context, ref, event.action); }); }); - ref.listen(headerBarSearchEventsProvider, (previous, next) { - next.whenData((event) { - switch (event) { - case HeaderBarSearchQueryChanged(:final query): - final current = ref.read(_workspaceSearchProvider); - if (current.query == query && current.active) { - return; - } - _clearGitDetailSelection(ref); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.copyWith(active: true, query: query)); - unawaited(settingsController.setSidebarVisible(true)); - 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); - case HeaderBarSearchEscapePressed(): - _closeSearch(ref); - case HeaderBarSearchFocusChanged(): - break; - } - }); - }); - ref.listen(workspaceSearchOpenRequestProvider, (previous, next) { - if (next != previous) { - _openSearch(ref); - } - }); - ref.listen(workspaceSearchCloseRequestProvider, (previous, next) { - if (next != previous) { - _closeSearch(ref); - } + ref.listen(workspaceNavigationCommandProvider, (_, request) { + if (request == null) return; + final tab = switch (request.$1) { + BusyMarkCommandIds.sidebarFiles => _SidebarTab.files, + BusyMarkCommandIds.sidebarToc => _SidebarTab.toc, + BusyMarkCommandIds.sidebarOutline => _SidebarTab.outline, + BusyMarkCommandIds.sidebarGit => _SidebarTab.git, + _ => null, + }; + if (tab != null) _selectSidebarShortcut(ref, tab); }); ref.listen(clipboardHistoryOpenRequestProvider, (previous, next) { if (next == previous) return; @@ -1033,7 +564,10 @@ class WorkspaceScreen extends ConsumerWidget { if (next == previous) return; _selectSidebarShortcut(ref, _SidebarTab.localHistory); final buffer = ref.read(workspaceControllerProvider).activeBuffer; - if (buffer != null) { + if (buffer != null && + !ref + .read(localHistoryControllerProvider) + .inspectingRetainedDocument) { unawaited( ref .read(localHistoryControllerProvider.notifier) @@ -1041,11 +575,6 @@ class WorkspaceScreen extends ConsumerWidget { ); } }); - 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) { @@ -1087,53 +616,20 @@ class WorkspaceScreen extends ConsumerWidget { enabled: headerBar.isAvailable, child: Shortcuts( shortcuts: { - commandRegistry[BusyMarkCommandIds.search]!.shortcut!.activator: - const _OpenSearchIntent(), - commandRegistry[BusyMarkCommandIds.sidebarFiles]!.shortcut!.activator: - const _SelectSidebarTabIntent(_SidebarTab.files), - const SingleActivator(LogicalKeyboardKey.numpad1, control: true): - const _SelectSidebarTabIntent(_SidebarTab.files), - commandRegistry[BusyMarkCommandIds.sidebarToc]!.shortcut!.activator: - const _SelectSidebarTabIntent(_SidebarTab.toc), - const SingleActivator(LogicalKeyboardKey.numpad2, control: true): - const _SelectSidebarTabIntent(_SidebarTab.toc), - commandRegistry[BusyMarkCommandIds.sidebarOutline]! - .shortcut! - .activator: const _SelectSidebarTabIntent( - _SidebarTab.outline, - ), - const SingleActivator(LogicalKeyboardKey.numpad3, control: true): - const _SelectSidebarTabIntent(_SidebarTab.outline), - commandRegistry[BusyMarkCommandIds.sidebarGit]!.shortcut!.activator: - 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), + for (final entry in const { + BusyMarkCommandIds.sidebarFiles: _SidebarTab.files, + BusyMarkCommandIds.sidebarToc: _SidebarTab.toc, + BusyMarkCommandIds.sidebarGit: _SidebarTab.git, + BusyMarkCommandIds.localHistory: _SidebarTab.localHistory, + BusyMarkCommandIds.clipboardHistory: _SidebarTab.clipboard, + }.entries) + ...commandRegistry.bindingIntents( + entry.key, + _SelectSidebarTabIntent(entry.value), + ), }, child: Actions( actions: { - _OpenSearchIntent: CallbackAction<_OpenSearchIntent>( - onInvoke: (intent) { - _openSearch(ref); - return null; - }, - ), - _ToggleSearchIntent: CallbackAction<_ToggleSearchIntent>( - onInvoke: (intent) { - _toggleSearch(ref); - return null; - }, - ), _SelectSidebarTabIntent: CallbackAction<_SelectSidebarTabIntent>( onInvoke: (intent) { _selectSidebarShortcut(ref, intent.tab); @@ -1154,7 +650,8 @@ class WorkspaceScreen extends ConsumerWidget { key: const ValueKey('workspace-page-frame'), sidebarHeader: const BusyMarkLinuxBrandHeader(), sidebarBody: sidebar, - sidebarAvailable: sidebarAvailable, + sidebarAvailable: sidebarAvailable || searchState.active, + sidebarOverlay: searchState.active && !sidebarAvailable, sidebarExpanded: sidebarVisible, sidebarTransitionGeneration: ref.watch( sidebarTransitionGenerationProvider, @@ -1215,18 +712,7 @@ class WorkspaceScreen extends ConsumerWidget { duration: BusyMarkMotion.search, children: [ BusyMarkLinuxHeaderTitle(title), - _HeaderSearchField( - active: searchState.active, - focusRequest: ref.watch( - _headerSearchFocusRequestProvider, - ), - query: searchState.query, - onChanged: (query) => _setSearchQuery(ref, query), - onClear: () => _clearSearchQuery(ref), - onSubmitted: () => - unawaited(_submitSearch(context, ref)), - onEscape: () => _closeSearch(ref), - ), + const SearchHeaderField(), ], ), trailing: BusyMarkLinuxHeaderControlGroup( @@ -1276,9 +762,6 @@ class WorkspaceScreen extends ConsumerWidget { tooltip: context.l10n.search, icon: BusyMarkGlyphs.search, selected: searchState.active, - shortcut: commandRegistry[BusyMarkCommandIds.search] - ?.shortcut - ?.label, onPressed: () => _toggleSearch(ref), ), BusyMarkMainMenuButton( @@ -1330,54 +813,8 @@ class WorkspaceScreen extends ConsumerWidget { ); } - void _toggleSearch(WidgetRef ref) { - final search = ref.read(_workspaceSearchProvider); - if (search.active) { - _closeSearch(ref); - } else { - _openSearch(ref); - } - } - - void _openSearch(WidgetRef ref) { - if (ref.read(workspaceControllerProvider).workspace?.isRemote == true) { - final target = ref.context; - unawaited(showNotesSearch(target, ref)); - return; - } - final search = ref.read(_workspaceSearchProvider); - ref.read(_headerSearchFocusRequestProvider.notifier).request(); - if (!search.active) { - ref.read(_searchReturnFocusProvider).node = - FocusManager.instance.primaryFocus; - } - if (search.active) { - unawaited( - ref - .read(appSettingsControllerProvider.notifier) - .setSidebarVisible(true), - ); - return; - } - _clearGitDetailSelection(ref); - ref - .read(_workspaceSearchProvider.notifier) - .set(search.copyWith(active: true)); - unawaited( - ref.read(appSettingsControllerProvider.notifier).setSidebarVisible(true), - ); - } - - void _closeSearch(WidgetRef ref) { - final search = ref.read(_workspaceSearchProvider); - if (!search.active) { - return; - } - ref - .read(_workspaceSearchProvider.notifier) - .set(search.copyWith(active: false)); - ref.read(_searchReturnFocusProvider).restore(); - } + void _toggleSearch(WidgetRef ref) => + ref.read(searchSessionProvider.notifier).toggle(); void _selectSidebarShortcut(WidgetRef ref, _SidebarTab tab) { final state = ref.read(workspaceControllerProvider); @@ -1388,40 +825,13 @@ class WorkspaceScreen extends ConsumerWidget { if (workspace == null || !_sidebarTabsFor(workspace.kind).contains(tab)) { return; } - _closeSearch(ref); + ref.read(searchSessionProvider.notifier).close(restoreFocus: false); unawaited( ref.read(appSettingsControllerProvider.notifier).setSidebarVisible(true), ); ref.read(_sidebarShortcutRequestProvider.notifier).select(tab); } - void _setSearchQuery(WidgetRef ref, String query) { - final current = ref.read(_workspaceSearchProvider); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.copyWith(active: true, query: query)); - } - - void _clearSearchQuery(WidgetRef ref) { - final current = ref.read(_workspaceSearchProvider); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.copyWith(query: '')); - } - - Future _submitSearch(BuildContext context, WidgetRef ref) async { - final current = await ref.read(_workspaceSearchProvider.notifier).submit(); - if (!current || !context.mounted) { - return; - } - final matches = ref.read(_workspaceSearchProvider).matches; - if (matches.isEmpty) { - return; - } - final result = _workspaceSearchResults(context, matches).first; - await _openSearchResult(context, ref, result); - } - void _handleHeaderBarAction( BuildContext context, WidgetRef ref, @@ -1446,8 +856,6 @@ class WorkspaceScreen extends ConsumerWidget { execute(BusyMarkCommandIds.fullScreen); case HeaderBarAction.settings: execute(BusyMarkCommandIds.settings); - case HeaderBarAction.keyboardShortcuts: - execute(BusyMarkCommandIds.keyboardShortcuts); case HeaderBarAction.syntaxReference: execute(BusyMarkCommandIds.syntaxReference); case HeaderBarAction.reportIssue: @@ -1497,11 +905,6 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: context.go(settingsLocation(SettingsReturnTarget.workspace)); - case BusyMarkMainMenuAction.keyboardShortcuts: - showBusyMarkKeyboardShortcutsDialog(context); - case BusyMarkMainMenuAction.quickOpen: - case BusyMarkMainMenuAction.commandPalette: - return; case BusyMarkMainMenuAction.syntaxReference: showBusyMarkSyntaxReferenceDialog(context); case BusyMarkMainMenuAction.reportIssue: @@ -1570,46 +973,7 @@ class WorkspaceScreen extends ConsumerWidget { DocumentViewModePreference.preview => BusyMarkCommandIds.viewReading, DocumentViewModePreference.split => BusyMarkCommandIds.viewSplit, }; - return commands[id]!.shortcut!.label; - } - - Future _openSearchResult( - BuildContext context, - WidgetRef ref, - _WorkspaceSearchResult result, - ) async { - final state = ref.read(workspaceControllerProvider); - final workspace = state.workspace; - final searchOptions = ref.read(_workspaceSearchProvider).options; - if (workspace == null) { - return; - } - final activePath = state.activeBuffer?.filePath; - if (activePath != result.filePath) { - final opened = await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(result.filePath); - if (!opened) return; - } - 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) - .set( - _SearchNavigationTarget( - filePath: result.filePath, - line: result.line, - startOffset: result.startOffset, - endOffset: result.endOffset, - query: result.query, - request: (previous?.request ?? 0) + 1, - ), - ); + return commands[id]?.shortcut?.label ?? ''; } Future _showProblemsDialog(BuildContext context, WidgetRef ref) async { @@ -2268,97 +1632,24 @@ class _GitFileList extends StatelessWidget { } } -class _HeaderSearchField extends StatefulWidget { - const _HeaderSearchField({ - required this.query, - required this.onChanged, - required this.onClear, - required this.onSubmitted, - required this.onEscape, - this.active = true, - this.focusRequest = 0, - }); - - final bool active; - final int focusRequest; - final String query; - final ValueChanged onChanged; - final VoidCallback onClear; - final VoidCallback onSubmitted; - final VoidCallback onEscape; - - @override - State<_HeaderSearchField> createState() => _HeaderSearchFieldState(); -} - -class _HeaderSearchFieldState extends State<_HeaderSearchField> { - late final TextEditingController _controller; - var _focusRequest = 0; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.query); - } - - @override - void didUpdateWidget(covariant _HeaderSearchField oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.active && - (!oldWidget.active || widget.focusRequest != oldWidget.focusRequest)) { - _focusRequest++; - } - if (widget.query != _controller.text) { - _controller.value = TextEditingValue( - text: widget.query, - selection: TextSelection.collapsed(offset: widget.query.length), - ); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BusyMarkSearchField( - controller: _controller, - hintText: context.l10n.search, - autofocus: widget.active, - clearOnEscape: false, - focusRequest: _focusRequest, - onChanged: widget.onChanged, - onSubmitted: (_) => widget.onSubmitted(), - onClear: widget.onClear, - onEscape: widget.onEscape, - ); - } -} - class _Sidebar extends ConsumerStatefulWidget { const _Sidebar({ super.key, required this.workspace, required this.outline, required this.searchState, - required this.searchResults, - required this.onOpenSearchResult, }); final Workspace workspace; final List outline; - final _WorkspaceSearchState searchState; - final List<_WorkspaceSearchResult> searchResults; - final Future Function(_WorkspaceSearchResult result) onOpenSearchResult; + final SearchSessionState searchState; @override ConsumerState<_Sidebar> createState() => _SidebarState(); } class _SidebarState extends ConsumerState<_Sidebar> { + final _outlineKey = GlobalKey<_OutlineTabState>(); late final WorkspaceController _topicActionController; late int _tab; late String _workspaceId; @@ -2395,9 +1686,6 @@ class _SidebarState extends ConsumerState<_Sidebar> { @override void didUpdateWidget(covariant _Sidebar oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.searchState.active && !oldWidget.searchState.active) { - _clearGitDetailSelection(ref); - } if (widget.workspace.id != _workspaceId) { _workspaceId = widget.workspace.id; _activeFilePath = widget.workspace.activeFilePath; @@ -2415,12 +1703,14 @@ class _SidebarState extends ConsumerState<_Sidebar> { Widget build(BuildContext context) { final tabs = _sidebarTabsFor(widget.workspace.kind); ref.listen(workspaceControllerProvider, (previous, next) { - if (_workspaceSearchInputsChanged(previous, next)) { + if (workspaceSearchInputsChanged(previous, next)) { _replacementCancellation?.cancel(); } }); - ref.listen(_workspaceSearchProvider, (previous, next) { + ref.listen(searchSessionProvider, (previous, next) { if (previous?.options != next.options || + previous?.category != next.category || + previous?.recovery != next.recovery || previous?.active != next.active) { _replacementCancellation?.cancel(); } @@ -2448,110 +1738,130 @@ class _SidebarState extends ConsumerState<_Sidebar> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _SidebarHeader( - workspace: widget.workspace, - tabs: tabs, - selectedTab: widget.searchState.active ? null : selectedTab, - repositoryInfo: repositoryInfo, - showTabMenu: !widget.searchState.active && tabs.length > 1, - onSelectTab: (tab) => _selectTab(tab, tabs), - loadGitMenuItems: (menuContext, repository) => - _loadWorkspaceGitMenuItems(menuContext, ref, repository), - onGitAction: (menuContext, action) => - _performWorkspaceGitAction(menuContext, ref, action), - onRefineActiveDocument: () => - unawaited(_refineActiveDocumentWithAi(context)), - ), + if (!widget.searchState.active) + _SidebarHeader( + workspace: widget.workspace, + tabs: tabs, + selectedTab: selectedTab, + repositoryInfo: repositoryInfo, + showTabMenu: tabs.length > 1, + onSelectTab: (tab) => _selectTab(tab, tabs), + loadGitMenuItems: (menuContext, repository) => + _loadWorkspaceGitMenuItems(menuContext, ref, repository), + onGitAction: (menuContext, action) => + _performWorkspaceGitAction(menuContext, ref, action), + onRefineActiveDocument: () => + unawaited(_refineActiveDocumentWithAi(context)), + ), Expanded( - child: BusyMarkKeyedCrossfade( - transitionKey: widget.searchState.active - ? 'search' - : _topicRenameReview != null - ? 'rename' - : _topicUsageReview != null - ? 'usages' - : selectedTab ?? 'empty', - child: widget.searchState.active - ? _SearchSidebar( - 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, - ) - : _topicRenameReview != null - ? _WritersideTopicRenameSidebar( - plan: _topicRenameReview!, - onBack: () => setState(() => _topicRenameReview = null), - onDoRefactor: () => _applyReviewedTopicRename(context), - ) - : _topicUsageReview != null - ? _WritersideTopicUsagesSidebar( - review: _topicUsageReview!, - onBack: _closeTopicUsageReview, - onOpenUsage: (usage) => - _openWritersideTopicUsage(context, usage), - onDoRefactor: () => - _resumeWritersideTopicRemoval(context), - ) - : switch (selectedTab) { - _SidebarTab.notes => NextcloudNotesSidebar( - accountId: widget.workspace.nextcloudAccountId!, - ), - _SidebarTab.files => _FilesTab( - workspace: widget.workspace, - onShowFileHistory: _showFileHistory, - onRenameTopic: (path) => - _startWritersideTopicRename(context, path), - onRequestTopicRemoval: (target) => - _runWritersideTopicRemoval(context, target), - ), - _SidebarTab.toc => _TocTab( - workspace: widget.workspace, - onShowFileHistory: _showFileHistory, - onRenameTopic: (path) => - _startWritersideTopicRename(context, path), - onRequestTopicRemoval: (target) => - _runWritersideTopicRemoval(context, target), - ), - _SidebarTab.outline => _OutlineTab( + child: Stack( + fit: StackFit.expand, + children: [ + Offstage( + offstage: + widget.searchState.active || + selectedTab != _SidebarTab.outline, + child: ExcludeFocus( + excluding: + widget.searchState.active || + selectedTab != _SidebarTab.outline, + child: TickerMode( + enabled: + !widget.searchState.active && + selectedTab == _SidebarTab.outline, + child: _OutlineTab( + key: _outlineKey, workspace: widget.workspace, bufferId: ref .watch(workspaceControllerProvider) .activeBufferId, headings: widget.outline, ), - _SidebarTab.git => GitSidebarTab( - workspace: widget.workspace, - onOpenFile: (relativePath) => - _openGitDiffFile(context, ref, relativePath), - onConfirmDiscard: (files) => - _confirmDiscardGitFiles(context, ref, files), - onAfterWorkspaceFilesChanged: () => - _refreshWorkspaceAfterGitFileChanges(ref), - onConfirmSwitchBranch: (branchName) => - _confirmSwitchGitBranch(context, ref, branchName), - onConfirmPushSetUpstream: () => - _confirmGitPushSetUpstream(context, ref), - ), - _SidebarTab.clipboard => ClipboardHistoryPanel( - onEscape: () => ref - .read(clipboardInsertionRegistryProvider) - .target - ?.requestEditorFocus(), - ), - _SidebarTab.references => const ReferencesPanel(), - _SidebarTab.localHistory => LocalHistoryPanel( - focusSearchRequest: ref.watch( - localHistoryFindRequestProvider, - ), - ), - null => const SizedBox.shrink(), - }, + ), + ), + ), + if (widget.searchState.active || + selectedTab != _SidebarTab.outline) + BusyMarkKeyedCrossfade( + transitionKey: widget.searchState.active + ? 'search' + : _topicRenameReview != null + ? 'rename' + : _topicUsageReview != null + ? 'usages' + : selectedTab ?? 'empty', + child: widget.searchState.active + ? SearchSidebar( + onReviewReplacement: _reviewWorkspaceReplacement, + ) + : _topicRenameReview != null + ? _WritersideTopicRenameSidebar( + plan: _topicRenameReview!, + onBack: () => + setState(() => _topicRenameReview = null), + onDoRefactor: () => + _applyReviewedTopicRename(context), + ) + : _topicUsageReview != null + ? _WritersideTopicUsagesSidebar( + review: _topicUsageReview!, + onBack: _closeTopicUsageReview, + onOpenUsage: (usage) => + _openWritersideTopicUsage(context, usage), + onDoRefactor: () => + _resumeWritersideTopicRemoval(context), + ) + : switch (selectedTab) { + _SidebarTab.notes => NextcloudNotesSidebar( + accountId: widget.workspace.nextcloudAccountId!, + ), + _SidebarTab.files => _FilesTab( + workspace: widget.workspace, + onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), + onRequestTopicRemoval: (target) => + _runWritersideTopicRemoval(context, target), + ), + _SidebarTab.toc => _TocTab( + workspace: widget.workspace, + onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), + onRequestTopicRemoval: (target) => + _runWritersideTopicRemoval(context, target), + ), + _SidebarTab.outline => const SizedBox.shrink(), + _SidebarTab.git => GitSidebarTab( + workspace: widget.workspace, + onOpenFile: (relativePath) => + _openGitDiffFile(context, ref, relativePath), + onConfirmDiscard: (files) => + _confirmDiscardGitFiles(context, ref, files), + onAfterWorkspaceFilesChanged: () => + _refreshWorkspaceAfterGitFileChanges(ref), + onConfirmSwitchBranch: (branchName) => + _confirmSwitchGitBranch( + context, + ref, + branchName, + ), + onConfirmPushSetUpstream: () => + _confirmGitPushSetUpstream(context, ref), + ), + _SidebarTab.clipboard => ClipboardHistoryPanel( + onEscape: () => ref + .read(clipboardInsertionRegistryProvider) + .target + ?.requestEditorFocus(), + ), + _SidebarTab.references => const ReferencesPanel(), + _SidebarTab.localHistory => + const LocalHistoryPanel(), + null => const SizedBox.shrink(), + }, + ), + ], ), ), ], @@ -2564,6 +1874,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { List<_SidebarTab> tabs, { bool showGitChanges = true, }) { + ref.read(searchSessionProvider.notifier).close(restoreFocus: false); final index = tabs.indexOf(tab); if (index < 0) { return; @@ -2571,6 +1882,13 @@ class _SidebarState extends ConsumerState<_Sidebar> { setState(() { _tab = index; }); + if (tab == _SidebarTab.outline) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !ref.read(searchSessionProvider).active) { + _outlineKey.currentState?.requestNavigationFocus(); + } + }); + } if (tab != _SidebarTab.localHistory) { ref.read(localHistoryControllerProvider.notifier).clearComparison(); } @@ -2979,6 +2297,18 @@ class _SidebarState extends ConsumerState<_Sidebar> { } Future _reviewWorkspaceReplacement() async { + final request = ref.read(searchSessionProvider); + final workspaceId = ref.read(workspaceControllerProvider).workspace?.id; + bool currentRequest() { + if (!mounted) return false; + final current = ref.read(searchSessionProvider); + return current.workspaceActive && + !current.recovery && + current.generation == request.generation && + ref.read(workspaceControllerProvider).workspace?.id == workspaceId; + } + + if (!currentRequest() || widget.workspace.isRemote) return; var replacement = ''; final headerBar = ref.read(linuxHeaderBarServiceProvider); final requested = await showBusyMarkModalEditorDialog( @@ -3007,7 +2337,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { ], ), ); - if (requested == null || !mounted) { + if (requested == null || !mounted || !currentRequest()) { return; } final service = const SearchReplacementService(); @@ -3036,6 +2366,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { _replacementCancellation = null; } if (!mounted || + !currentRequest() || preview == null || preview.issues.any( (issue) => issue.kind == WorkspaceReplacementIssueKind.cancelled, @@ -3054,7 +2385,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { builder: (dialogContext) => _WorkspaceReplacementReviewDialog(preview: preview), ); - if (selected == null || selected.isEmpty || !mounted) { + if (selected == null || selected.isEmpty || !mounted || !currentRequest()) { return; } final normalizations = {}; @@ -3069,7 +2400,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { context, file.relativePath, ); - if (normalization == null || !mounted) { + if (normalization == null || !mounted || !currentRequest()) { return; } normalizations[file.filePath] = normalization; @@ -3087,7 +2418,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { await controller.validateActive(); await ref.read(gitControllerProvider.notifier).refresh(); ref - .read(_workspaceSearchProvider.notifier) + .read(searchSessionProvider.notifier) .refresh(ref.read(workspaceControllerProvider)); if (!mounted) { return; @@ -3166,7 +2497,9 @@ class _SidebarState extends ConsumerState<_Sidebar> { final requestedIndex = request == null ? -1 : tabs.indexOf(request); return requestedIndex >= 0 ? requestedIndex - : _preferredSidebarTabIndex(workspace); + : (tabs.contains(_SidebarTab.files) + ? tabs.indexOf(_SidebarTab.files) + : _preferredSidebarTabIndex(workspace)); } } @@ -4670,21 +4003,53 @@ class _FilesTabState extends ConsumerState<_FilesTab> { } return Shortcuts( shortcuts: { - (BusyMarkCommandRegistryScope.maybeOf(context) ?? - BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds - .treeDeleteSelection]! - .shortcut! - .activator: - const _DeleteSelectedFileTreeEntryIntent(), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .bindingIntents( + BusyMarkCommandIds.treeDeleteSelection, + const _DeleteSelectedFileTreeEntryIntent(), + ), const SingleActivator(LogicalKeyboardKey.contextMenu): const _ShowSelectedSidebarTreeMenuIntent(), const SingleActivator(LogicalKeyboardKey.f10, shift: true): const _ShowSelectedSidebarTreeMenuIntent(), - const SingleActivator(LogicalKeyboardKey.f6, shift: true): - const _RenameSelectedTopicFileIntent(), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .bindingIntents( + BusyMarkCommandIds.treeRenameSelection, + const _RenameSelectedTopicFileIntent(), + ), }, child: Actions( actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + captureTarget: () { + final selected = _selectedPath, workspaceId = widget.workspace.id; + return BusyMarkContextCommandSnapshot( + () => + mounted && + widget.workspace.id == workspaceId && + _selectedPath == selected, + ); + }, + isCommandEnabled: (id) => + _selectedPath != null && + const { + BusyMarkCommandIds.treeDeleteSelection, + BusyMarkCommandIds.treeRenameSelection, + }.contains(id), + onCommand: (id) { + final target = _treeFocusNode.context; + if (target != null) { + Actions.maybeInvoke( + target, + id == BusyMarkCommandIds.treeRenameSelection + ? const _RenameSelectedTopicFileIntent() + : const _DeleteSelectedFileTreeEntryIntent(), + ); + } + }, + ), _DeleteSelectedFileTreeEntryIntent: CallbackAction<_DeleteSelectedFileTreeEntryIntent>( onInvoke: (_) { @@ -5142,7 +4507,13 @@ Future<_FileTreeAction?> _showFileTreeMenu( ? context.l10n.renameTopicFile : context.l10n.rename, icon: BusyMarkGlyphs.edit, - shortcut: safeDeleteTopic ? 'Shift+F6' : null, + shortcut: safeDeleteTopic + ? (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeRenameSelection] + ?.shortcut + ?.label + : null, ), BusyMarkPopupMenuItem( value: _FileTreeAction.cut, @@ -6198,21 +5569,56 @@ class _TocTabState extends ConsumerState<_TocTab> { } return Shortcuts( shortcuts: { - (BusyMarkCommandRegistryScope.maybeOf(context) ?? - BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds - .treeDeleteSelection]! - .shortcut! - .activator: - const _RemoveSelectedTocEntryIntent(), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .bindingIntents( + BusyMarkCommandIds.treeDeleteSelection, + const _RemoveSelectedTocEntryIntent(), + ), const SingleActivator(LogicalKeyboardKey.contextMenu): const _ShowSelectedSidebarTreeMenuIntent(), const SingleActivator(LogicalKeyboardKey.f10, shift: true): const _ShowSelectedSidebarTreeMenuIntent(), - const SingleActivator(LogicalKeyboardKey.f6, shift: true): - const _RenameSelectedTopicFileIntent(), + ...(BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata) + .bindingIntents( + BusyMarkCommandIds.treeRenameSelection, + const _RenameSelectedTopicFileIntent(), + ), }, child: Actions( actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + captureTarget: () { + final selected = _selectedNodePathKey; + final selection = Set.of(_selectedNodePathKeys); + final workspaceId = widget.workspace.id; + return BusyMarkContextCommandSnapshot( + () => + mounted && + widget.workspace.id == workspaceId && + _selectedNodePathKey == selected && + setEquals(selection, _selectedNodePathKeys), + ); + }, + isCommandEnabled: (id) => + selectedEntry != null && + const { + BusyMarkCommandIds.treeDeleteSelection, + BusyMarkCommandIds.treeRenameSelection, + }.contains(id), + onCommand: (id) { + final target = _treeFocusNode.context; + if (target != null) { + Actions.maybeInvoke( + target, + id == BusyMarkCommandIds.treeRenameSelection + ? const _RenameSelectedTopicFileIntent() + : const _RemoveSelectedTocEntryIntent(), + ); + } + }, + ), _RemoveSelectedTocEntryIntent: CallbackAction<_RemoveSelectedTocEntryIntent>( onInvoke: (_) { @@ -8349,7 +7755,12 @@ Future<_TocTreeAction?> _showTocTreeMenu( value: _TocTreeAction.rename, label: context.l10n.renameTopicFile, icon: BusyMarkGlyphs.edit, - shortcut: 'Shift+F6', + shortcut: + (BusyMarkCommandRegistryScope.maybeOf(context) ?? + BusyMarkCommandCatalog.metadata)[BusyMarkCommandIds + .treeRenameSelection] + ?.shortcut + ?.label, enabled: hasTopicFile, ), BusyMarkPopupMenuItem( @@ -9705,6 +9116,7 @@ Future _refineActiveSourceRangesWithAi( class _OutlineTab extends ConsumerStatefulWidget { const _OutlineTab({ + super.key, required this.workspace, required this.bufferId, required this.headings, @@ -9728,6 +9140,54 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { String? _revealedActiveNodeKey; Set _selectedNodeKeys = {}; String? _selectionAnchorKey; + List<_OutlineTreeEntry> _navigationEntries = const []; + int _keyboardIndex = -1; + String? _sourceAtBuild; + + final _navigationFocus = FocusNode(debugLabel: 'Outline navigation'); + void requestNavigationFocus() => _navigationFocus.requestFocus(); + + void _moveOutlineSelection(int delta) { + if (_navigationEntries.isEmpty) return; + setState( + () => _keyboardIndex = (_keyboardIndex + delta).clamp( + 0, + _navigationEntries.length - 1, + ), + ); + if (_treeScrollController.hasClients) { + _treeScrollController.jumpTo( + (_keyboardIndex * _treeRowExtent).clamp( + 0, + _treeScrollController.position.maxScrollExtent, + ), + ); + } + } + + void _activateOutlineSelection() { + if (_navigationEntries.isEmpty) return; + final heading = + _navigationEntries[_keyboardIndex.clamp( + 0, + _navigationEntries.length - 1, + )] + .node + .heading; + _navigateHeading(heading); + } + + void _navigateHeading(DocumentOutlineHeading heading) { + final source = _sourceAtBuild; + if (source == null) return; + navigateOutlineHeading( + ref, + workspaceId: widget.workspace.id, + bufferId: widget.bufferId, + source: source, + heading: heading, + ); + } @override void initState() { @@ -9759,6 +9219,7 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { @override void dispose() { + _navigationFocus.dispose(); _treeScrollController.dispose(); super.dispose(); } @@ -9969,8 +9430,13 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { ); } final tree = _buildOutlineTree(headings); - final entries = _visibleOutlineTreeEntries(tree, _expandedNodeKeys); - final viewportTarget = ref.watch(_outlineViewportTargetProvider); + final entries = _visibleOutlineTreeEntries( + tree, + _expandedNodeKeys, + ).toList(); + _navigationEntries = entries; + _sourceAtBuild = ref.read(workspaceControllerProvider).activeText; + final viewportTarget = ref.watch(outlineViewportTargetProvider); final activeNodeKey = _activeVisibleOutlineNodeKey( workspace: widget.workspace, bufferId: widget.bufferId, @@ -9983,76 +9449,80 @@ class _OutlineTabState extends ConsumerState<_OutlineTab> { for (var index = 0; index < headings.length; index += 1) _outlineNodeKey(headings[index]): index, }; - return ListView.builder( - key: const ValueKey('workspace-sidebar-outline-tree'), - controller: _treeScrollController, - padding: BusyMarkInsets.sidebarList, - itemExtent: _treeRowExtent, - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; - final node = entry.node; - final heading = node.heading; - final key = _outlineNodeKey(heading); - final headingIndex = headingIndexes[key]!; - final expanded = _expandedNodeKeys.contains(key); - final hasChildren = node.children.isNotEmpty; - final selected = _selectedNodeKeys.isEmpty - ? key == activeNodeKey - : _selectedNodeKeys.contains(key); - void toggle() { - setState(() { - _revealedActiveNodeKey = null; - if (expanded) { - _expandedNodeKeys.remove(key); - } else { - _expandedNodeKeys.add(key); - } - }); + return Focus( + focusNode: _navigationFocus, + onKeyEvent: (_, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowDown || + event.logicalKey == LogicalKeyboardKey.arrowUp) { + _moveOutlineSelection( + event.logicalKey == LogicalKeyboardKey.arrowDown ? 1 : -1, + ); + return KeyEventResult.handled; } - - return KeyedSubtree( - key: ValueKey('workspace-sidebar-outline-row-$headingIndex'), - child: _SidebarTreeRow( - title: heading.text, - depth: entry.depth, - icon: BusyMarkGlyphs.tag, - leading: _HeadingBadge(level: heading.level), - hasChildren: hasChildren, - expanded: expanded, - selected: selected, - onToggle: hasChildren ? toggle : null, - onTap: () { - final modifiers = _treeSelectionModifiers(); - _updateOutlineSelection(entries, index, modifiers); - if (modifiers.control || modifiers.shift) { - return; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _activateOutlineSelection(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: ListView.builder( + key: const ValueKey('workspace-sidebar-outline-tree'), + controller: _treeScrollController, + padding: BusyMarkInsets.sidebarList, + itemExtent: _treeRowExtent, + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + final node = entry.node; + final heading = node.heading; + final key = _outlineNodeKey(heading); + final headingIndex = headingIndexes[key]!; + final expanded = _expandedNodeKeys.contains(key); + final hasChildren = node.children.isNotEmpty; + final selected = _keyboardIndex >= 0 + ? index == _keyboardIndex + : _selectedNodeKeys.isEmpty + ? key == activeNodeKey + : _selectedNodeKeys.contains(key); + void toggle() { + setState(() { + _revealedActiveNodeKey = null; + if (expanded) { + _expandedNodeKeys.remove(key); + } else { + _expandedNodeKeys.add(key); } - _setOutlineViewportTarget( - ref, - workspace: widget.workspace, - bufferId: widget.bufferId, - heading: heading, - ); - ref - .read(_outlineNavigationTargetProvider.notifier) - .set( - _OutlineNavigationTarget( - workspaceId: widget.workspace.id, - bufferId: widget.bufferId ?? '', - filePath: widget.workspace.activeFilePath, - headingId: heading.id, - line: heading.sourceStartLine, - editorBlockId: heading.editorBlockId, - ), - ); - }, - onMenuRequested: (anchorContext, position) => unawaited( - _showSectionMenu(entries, headingIndexes, index, position), + }); + } + + return KeyedSubtree( + key: ValueKey('workspace-sidebar-outline-row-$headingIndex'), + child: _SidebarTreeRow( + title: heading.text, + depth: entry.depth, + icon: BusyMarkGlyphs.tag, + leading: _HeadingBadge(level: heading.level), + hasChildren: hasChildren, + expanded: expanded, + selected: selected, + onToggle: hasChildren ? toggle : null, + onTap: () { + final modifiers = _treeSelectionModifiers(); + _updateOutlineSelection(entries, index, modifiers); + if (modifiers.control || modifiers.shift) { + return; + } + _keyboardIndex = index; + _navigateHeading(heading); + }, + onMenuRequested: (anchorContext, position) => unawaited( + _showSectionMenu(entries, headingIndexes, index, position), + ), ), - ), - ); - }, + ); + }, + ), ); } @@ -10260,32 +9730,12 @@ Future _confirmDeleteOutlineSection( return confirmed ?? false; } -void _setOutlineViewportTarget( - WidgetRef ref, { - required Workspace workspace, - required String? bufferId, - required DocumentOutlineHeading? heading, -}) { - ref - .read(_outlineViewportTargetProvider.notifier) - .set( - _OutlineViewportTarget( - workspaceId: workspace.id, - bufferId: bufferId ?? '', - filePath: workspace.activeFilePath, - headingId: heading?.id, - sourceStartOffset: heading?.sourceStartOffset, - editorBlockId: heading?.editorBlockId, - ), - ); -} - String? _activeVisibleOutlineNodeKey({ required Workspace workspace, required String? bufferId, required List headings, required List<_OutlineTreeEntry> entries, - required _OutlineViewportTarget? target, + required OutlineViewportTarget? target, }) { if (target == null || target.workspaceId != workspace.id || @@ -10311,7 +9761,7 @@ String? _activeVisibleOutlineNodeKey({ int _outlineViewportHeadingIndex( List headings, - _OutlineViewportTarget target, + OutlineViewportTarget target, ) { final editorBlockId = target.editorBlockId; if (editorBlockId != null) { @@ -10337,52 +9787,6 @@ int _outlineViewportHeadingIndex( : headings.indexWhere((heading) => heading.id == headingId); } -List _activeDocumentOutline(WorkspaceState state) { - final liveOutline = state.liveOutline; - final workspace = state.workspace; - if (liveOutline != null && - workspace != null && - liveOutline.matches(workspace, state.activeBuffer, state.activeText)) { - return liveOutline.headings; - } - final preview = state.preview; - if (preview != null) { - return preview.outline; - } - final buffer = state.activeBuffer; - if (workspace != null && - buffer != null && - resolveWorkspaceDocumentContext(workspace, buffer).kind == - DocumentKind.writersideXmlTopic) { - return const WritersideEditingAdapter() - .parseXml( - filePath: buffer.filePath ?? workspace.activeFilePath ?? '', - source: buffer.text, - ) - ?.outline ?? - const []; - } - final markdown = workspace?.markdown; - if (workspace == null || markdown == null) return const []; - if (buffer == null) { - if (state.documentBuffers.isNotEmpty) return const []; - return [ - for (final heading in markdown.headings) - DocumentOutlineHeading.fromMarkdown(heading), - ]; - } - final context = resolveWorkspaceDocumentContext(workspace, buffer); - if (!p.equals(markdown.filePath, context.parserPath) || - markdown.source != buffer.text || - markdown.mode != context.markdownMode) { - return const []; - } - return [ - for (final heading in markdown.headings) - DocumentOutlineHeading.fromMarkdown(heading), - ]; -} - class _OutlineTreeNode { const _OutlineTreeNode({required this.heading, required this.children}); @@ -12117,8 +11521,17 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); } + final _previewSearchTimers = []; + void _cancelPreviewSearchTimers() { + for (final timer in _previewSearchTimers) { + timer.cancel(); + } + _previewSearchTimers.clear(); + } + @override void dispose() { + _cancelPreviewSearchTimers(); _inspectorWorkspaceController.propertyEdits.removeListener( _handlePropertyDraftChange, ); @@ -12239,7 +11652,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (!mounted || workspace == null) { return; } - _setOutlineViewportTarget( + setOutlineViewportTarget( ref, workspace: workspace, bufferId: widget.state.activeBufferId, @@ -12249,7 +11662,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { @override Widget build(BuildContext context) { - ref.listen(_outlineNavigationTargetProvider, (previous, next) { + ref.listen(outlineNavigationTargetProvider, (previous, next) { if (next == null) { return; } @@ -12263,19 +11676,22 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _scrollToOutlineTarget(next); }); ref.listen(notesMatchNavigationProvider, (previous, next) { + final currentDocument = ref.read(workspaceControllerProvider); if (next == null || + next.accountId != null && + next.accountId != currentDocument.workspace?.nextcloudAccountId || next.request == previous?.request || - (widget.state.activeBuffer?.remoteNote?.localId ?? - widget.state.activeBuffer?.filePath) != + (currentDocument.activeBuffer?.remoteNote?.localId ?? + currentDocument.activeBuffer?.filePath ?? + currentDocument.activeBuffer?.id) != next.localId) { return; } - final source = widget.state.activeBuffer!.text; + final source = currentDocument.activeBuffer!.text; if (notesSearchDigest(source) != next.digest) return; final line = SourceDocument( fullText: source, ).lineIndex.lineNumberAtOffset(next.start); - final old = ref.read(_searchNavigationTargetProvider); ref .read(_searchNavigationTargetProvider.notifier) .set( @@ -12287,16 +11703,23 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { startOffset: next.start, endOffset: next.end, query: '', - request: (old?.request ?? 0) + 1, + request: next.request, ), ); }); + ref.listen(searchSessionProvider, (previous, next) { + if (previous?.textActive == true && + previous?.requestRevision != next.requestRevision) { + _cancelPreviewSearchTimers(); + _previewSearchScrollRequest = -1; + ref.read(_searchNavigationTargetProvider.notifier).set(null); + } + }); final searchTarget = ref.watch(_searchNavigationTargetProvider); if (searchTarget != null && (searchTarget.localId != null || - (ref.read(_workspaceSearchProvider).active && - ref.read(_workspaceSearchProvider).query == - searchTarget.query)) && + (ref.read(searchSessionProvider).textActive && + ref.read(searchSessionProvider).query == searchTarget.query)) && searchTarget.request != _lastSearchNavigationRequest) { _scrollToSearchTarget(searchTarget); } @@ -12390,7 +11813,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { direction: widget.editorToolbarDirection, ); final activeEditorPath = _activeEditorPath(); - final searchState = ref.watch(_workspaceSearchProvider); + final searchState = ref.watch(searchSessionProvider); final spellingBannerPresentation = activeBuffer != null && spellingSupported && @@ -12640,17 +12063,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { visualizationRevision: ref .read(workspaceControllerProvider.notifier) .editRevision, - onOpenSearch: () => ref - .read( - workspaceSearchOpenRequestProvider.notifier, - ) - .request(), onCloseSearch: () => ref - .read( - workspaceSearchCloseRequestProvider - .notifier, - ) - .request(), + .read(searchSessionProvider.notifier) + .close(), onUndo: () => ref .read(workspaceControllerProvider.notifier) .undoActiveBuffer(), @@ -12763,8 +12178,18 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ), editorFontSize: widget.editorFontSize, wordWrap: widget.wordWrap, - searchActive: searchState.active, + searchActive: searchState.textActive, + searchControlsVisible: searchState.fileActive, searchOptions: searchState.options, + searchSnapshot: searchState.sourceSnapshot( + activeBuffer, + ), + onNavigateSearch: ref + .read(searchSessionProvider.notifier) + .navigateSource, + onCancelSearchNavigation: ref + .read(searchSessionProvider.notifier) + .cancelSourceNavigation, searchReplacement: activeBuffer?.editorState.searchReplacement ?? '', @@ -12793,14 +12218,6 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ); } }, - onSearchOptionsChanged: (options) { - final current = ref.read( - _workspaceSearchProvider, - ); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.withOptions(options)); - }, initialSelection: activeBuffer?.editorState.selection, initialScrollOffset: @@ -12847,17 +12264,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { ), ); }, - onOpenSearch: () => ref - .read( - workspaceSearchOpenRequestProvider.notifier, - ) - .request(), onCloseSearch: () => ref - .read( - workspaceSearchCloseRequestProvider - .notifier, - ) - .request(), + .read(searchSessionProvider.notifier) + .close(), onVisibleLineChanged: _handleSourceVisibleLineChanged, onChanged: (value, sourceFilePath) => @@ -15133,7 +14542,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { return resolveWorkspaceDocumentContext(workspace, buffer).kind; } - void _scrollToOutlineTarget(_OutlineNavigationTarget target) { + void _scrollToOutlineTarget(OutlineNavigationTarget target) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; @@ -15161,7 +14570,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { (target.localId != null ? target.localId != (widget.state.activeBuffer?.remoteNote?.localId ?? - widget.state.activeBuffer?.filePath) || + widget.state.activeBuffer?.filePath ?? + widget.state.activeBuffer?.id) || notesSearchDigest(widget.state.activeBuffer?.text ?? '') != target.digest : target.filePath != widget.state.activeBuffer?.filePath)) { @@ -15379,6 +14789,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } void _schedulePreviewSearchScroll(_SearchNavigationTarget target) { + _cancelPreviewSearchTimers(); _previewSearchScrollRequest = target.request; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_isCurrentPreviewSearchScroll(target) || @@ -15390,15 +14801,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _scrollPreviewToSearchTarget(target); } }); - unawaited( - Future.delayed(BusyMarkMotion.previewSearchDelay, () { + _previewSearchTimers.add( + Timer(BusyMarkMotion.previewSearchDelay, () { if (_isCurrentPreviewSearchScroll(target)) { _scrollPreviewToSearchTarget(target); } }), ); - unawaited( - Future.delayed(BusyMarkMotion.scroll, () { + _previewSearchTimers.add( + Timer(BusyMarkMotion.scroll, () { if (_isCurrentPreviewSearchScroll(target)) { _scrollPreviewToSearchTarget(target); } @@ -15408,7 +14819,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } bool _isCurrentPreviewSearchScroll(_SearchNavigationTarget target) { - return mounted && target.request == _previewSearchScrollRequest; + return mounted && + target.request == _previewSearchScrollRequest && + ref.read(searchSessionProvider).textActive; } bool _canUseWysiwyg(Workspace? workspace) { @@ -16604,13 +16017,13 @@ class _PreviewInlineText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final baseStyle = style ?? busyMarkDocumentBodyTextStyle(context); - final searchState = ref.watch(_workspaceSearchProvider); + final searchState = ref.watch(searchSessionProvider); final workspace = ref.watch(workspaceControllerProvider).workspace; final settings = ref.watch(appSettingsControllerProvider); final allowRemoteImages = settings.allowsRemoteImagesForWorkspace( _remoteImageWorkspacePath(workspace), ); - final highlightQuery = searchState.active ? searchState.query : ''; + final highlightQuery = searchState.textActive ? searchState.query : ''; final inlines = block.inlines.isEmpty ? [PreviewInline(kind: PreviewInlineKind.text, text: block.text)] : block.inlines; @@ -16713,7 +16126,7 @@ int? _previewSearchBlockIndexForSourceLine( if (rawLine.isEmpty) { return null; } - final strippedLine = _stripMarkdownForSearchResult(rawLine).toLowerCase(); + final strippedLine = stripMarkdownForSearchResult(rawLine).toLowerCase(); final rawNeedle = rawLine.toLowerCase(); if (strippedLine.isEmpty && rawNeedle.isEmpty) { return null; @@ -16850,7 +16263,7 @@ double _previewBlockTargetFraction( if (sourceLine.isNotEmpty && block.text.contains('\n')) { final sourceNeedles = { sourceLine, - _stripMarkdownForSearchResult(sourceLine), + stripMarkdownForSearchResult(sourceLine), }.where((value) => value.isNotEmpty).map((value) => value.toLowerCase()); final blockLines = block.text.split('\n'); for (final (index, blockLine) in blockLines.indexed) { @@ -17847,9 +17260,9 @@ void _navigatePreviewAnchor( return; } ref - .read(_outlineNavigationTargetProvider.notifier) + .read(outlineNavigationTargetProvider.notifier) .set( - _OutlineNavigationTarget( + OutlineNavigationTarget( workspaceId: workspace.id, bufferId: state.activeBufferId ?? '', filePath: workspace.activeFilePath, @@ -17976,143 +17389,6 @@ class _ProblemsList extends StatelessWidget { } } -class _SearchSidebar extends StatelessWidget { - const _SearchSidebar({ - required this.query, - required this.results, - required this.searching, - required this.onOpenResult, - required this.onReviewReplacement, - required this.state, - required this.onShowMore, - }); - - final String query; - final List<_WorkspaceSearchResult> results; - 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; - if (normalizedQuery.isEmpty) { - return _SidebarEmptyState( - icon: BusyMarkGlyphs.search, - title: context.l10n.search, - ); - } - if (searching && results.isEmpty) { - return const Center( - child: CircularProgressIndicator( - key: ValueKey('workspace-search-progress'), - ), - ); - } - final colors = BusyMarkSurfaceColors.of(context); - 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, - ); - } - final groups = _workspaceSearchFileGroups(results); - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - 0, - ), - child: SizedBox( - width: double.infinity, - child: BusyMarkPushButton.standard( - onPressed: () => unawaited(onReviewReplacement()), - child: Text(context.l10n.workspaceReplace), - ), - ), - ), - 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( - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - BusyMarkSpacing.sm, - BusyMarkSpacing.xxs, - ), - child: Text( - busyMarkLtrIsolateFor(context, group.relativePath), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colors.mutedForeground, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ), - for (final result in group.results) ...[ - _SearchResultRow( - result: result, - onOpen: () => onOpenResult(result), - ), - Divider( - height: BusyMarkStroke.hairline, - color: colors.subtleBorder, - ), - ], - ], - ], - ), - ), - ], - ); - } -} - @visibleForTesting final class SpellingReviewCorrection { const SpellingReviewCorrection({ @@ -18923,10 +18199,6 @@ String _workspaceReplacementIssueLabel( WorkspaceReplacementIssueKind.remoteWorkspaceUnsupported => context.l10n.nextcloudWorkspaceReplaceUnavailable, WorkspaceReplacementIssueKind.cancelled => context.l10n.cancel, - WorkspaceReplacementIssueKind.invalidRegex => - context.l10n.sourceSearchInvalidRegex, - WorkspaceReplacementIssueKind.zeroLengthMatches => - context.l10n.sourceSearchZeroLengthUnsupported, WorkspaceReplacementIssueKind.oversized => context.l10n.workspaceReplaceIssueOversized, WorkspaceReplacementIssueKind.unreadable => @@ -18949,23 +18221,6 @@ String _workspaceReplacementIssueLabel( }; } -class _SearchResultRow extends StatelessWidget { - const _SearchResultRow({required this.result, required this.onOpen}); - - final _WorkspaceSearchResult result; - final Future Function() onOpen; - - @override - Widget build(BuildContext context) { - return _SidebarNavigationResultRow( - title: result.title, - subtitle: result.subtitle, - icon: result.icon, - onOpen: onOpen, - ); - } -} - class _SidebarNavigationResultRow extends StatelessWidget { const _SidebarNavigationResultRow({ required this.title, @@ -19029,325 +18284,9 @@ class _SidebarNavigationResultRow extends StatelessWidget { } } -class _WorkspaceSearchResult { - const _WorkspaceSearchResult({ - required this.filePath, - required this.relativePath, - required this.line, - required this.startOffset, - required this.endOffset, - required this.query, - required this.title, - required this.subtitle, - required this.icon, - }); - - final String filePath; - final String relativePath; - final int line; - final int startOffset; - final int endOffset; - final String query; - final String title; - final String subtitle; - final IconData icon; -} - -enum _WorkspaceSearchMatchKind { text, fileName } - -class _WorkspaceSearchMatch { - const _WorkspaceSearchMatch({ - required this.kind, - required this.filePath, - required this.relativePath, - required this.fileKind, - required this.line, - required this.startOffset, - required this.endOffset, - required this.query, - this.lineText, - }); - - final _WorkspaceSearchMatchKind kind; - final String filePath; - final String relativePath; - final DocumentKind fileKind; - final int line; - final int startOffset; - final int endOffset; - final String query; - final String? lineText; -} - -class _WorkspaceSearchFileGroup { - const _WorkspaceSearchFileGroup({ - required this.relativePath, - required this.results, - }); - - final String relativePath; - final List<_WorkspaceSearchResult> results; -} - -List<_WorkspaceSearchFileGroup> _workspaceSearchFileGroups( - List<_WorkspaceSearchResult> results, -) { - final groups = >{}; - for (final result in results) { - groups.putIfAbsent(result.relativePath, () => []).add(result); - } - return [ - for (final entry in groups.entries) - _WorkspaceSearchFileGroup( - relativePath: entry.key, - results: List.unmodifiable(entry.value), - ), - ]; -} - -List<_WorkspaceSearchResult> _workspaceSearchResults( - BuildContext context, - List<_WorkspaceSearchMatch> matches, -) { - return [ - for (final match in matches) - _WorkspaceSearchResult( - filePath: match.filePath, - relativePath: match.relativePath, - line: match.line, - startOffset: match.startOffset, - endOffset: match.endOffset, - query: match.query, - title: switch (match.kind) { - _WorkspaceSearchMatchKind.text => _searchResultTitle( - context, - match.lineText ?? '', - ), - _WorkspaceSearchMatchKind.fileName => busyMarkLtrIsolateFor( - context, - match.relativePath, - ), - }, - subtitle: switch (match.kind) { - _WorkspaceSearchMatchKind.text => context.l10n.searchResultLine( - match.relativePath, - match.line, - ), - _WorkspaceSearchMatchKind.fileName => _documentKindLabel( - context, - match.fileKind, - ), - }, - icon: switch (match.kind) { - _WorkspaceSearchMatchKind.text => BusyMarkGlyphs.paragraph, - _WorkspaceSearchMatchKind.fileName => _documentKindIcon( - match.fileKind, - ), - }, - ), - ]; -} - -const int _maxWorkspaceSearchResults = 80; -const int _maxWorkspaceSearchFileBytes = 1024 * 1024; - -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; - if (workspace == null || options.query.isEmpty || isCancelled()) { - return const _WorkspaceSearchOutcome(); - } - 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 _WorkspaceSearchOutcome(); - final buffer = state.bufferForPath(file.absolutePath); - String? text; - if (buffer != null) { - text = buffer.text; - } else if (file.absolutePath == state.activeBuffer?.filePath) { - text = state.activeText; - } else if (file.size > _maxWorkspaceSearchFileBytes) { - skippedFiles.add(file.relativePath); - } else { - try { - text = await loadText(file.absolutePath); - } on Object { - skippedFiles.add(file.relativePath); - } - } - if (isCancelled()) return const _WorkspaceSearchOutcome(); - if (text != null) { - final document = SourceDocument(fullText: text); - final search = await worker.search( - document, - options, - maximumMatches: maximumResults + 1 - results.length, - stopAfterMaximumMatches: true, - ); - 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 (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, - ), - ); - } - // 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; - } - return RegExp( - RegExp.escape(options.query), - caseSensitive: options.caseSensitive, - unicode: true, - ).hasMatch(relativePath); -} - -String _searchResultTitle(BuildContext context, String line) { - final trimmed = _stripMarkdownForSearchResult(line, context: context); - if (trimmed.length <= 120) { - return trimmed; - } - 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}) { - // 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); - if (fence != null) { - final language = fence.group(2)?.trim() ?? ''; - return language.isEmpty - ? context?.l10n.codeBlock ?? 'code block' - : language; - } - - value = value - .replaceFirst(RegExp(r'^\s{0,3}#{1,6}\s+'), '') - .replaceFirst(RegExp(r'^\s{0,3}>\s?'), '') - .replaceFirst(RegExp(r'^\s{0,3}[-+*]\s+\[[ xX]\]\s+'), '') - .replaceFirst(RegExp(r'^\s{0,3}[-+*]\s+'), '') - .replaceFirst(RegExp(r'^\s{0,3}\d+[.)]\s+'), '') - .trim(); - final replacements = { - RegExp(r'!\[([^\]]*)\]\([^)]+\)'): (match) => match.group(1) ?? '', - RegExp(r'\[([^\]]+)\]\([^)]+\)'): (match) => match.group(1) ?? '', - RegExp(r'\[([^\]]+)\]\[[^\]]*\]'): (match) => match.group(1) ?? '', - RegExp(r'`([^`]*)`'): (match) => match.group(1) ?? '', - RegExp(r'\*\*([^*]+)\*\*'): (match) => match.group(1) ?? '', - RegExp(r'__([^_]+)__'): (match) => match.group(1) ?? '', - RegExp(r'\*([^*]+)\*'): (match) => match.group(1) ?? '', - RegExp(r'_([^_]+)_'): (match) => match.group(1) ?? '', - RegExp(r'~~([^~]+)~~'): (match) => match.group(1) ?? '', - RegExp(r'<[^>]+>'): (_) => '', - }; - for (final entry in replacements.entries) { - value = value.replaceAllMapped(entry.key, entry.value); - } - value = value.replaceAll(RegExp(r'\s+'), ' ').trim(); - return value.isEmpty - ? context?.l10n.untitledResult ?? 'untitled result' - : value; -} String _relativeDocumentPath(Workspace workspace, String path) { for (final file in workspace.files) { @@ -19382,24 +18321,6 @@ IconData _documentKindIcon(DocumentKind kind) { }; } -String _documentKindLabel(BuildContext context, DocumentKind kind) { - return switch (kind) { - DocumentKind.markdown => context.l10n.documentKindMarkdownFile, - DocumentKind.writersideMarkdownTopic => - context.l10n.documentKindWritersideMarkdownTopic, - DocumentKind.writersideXmlTopic => - context.l10n.documentKindWritersideXmlTopic, - DocumentKind.tree => context.l10n.documentKindWritersideTree, - DocumentKind.config => context.l10n.documentKindConfigurationFile, - DocumentKind.variables => context.l10n.documentKindVariablesFile, - DocumentKind.categories => context.l10n.documentKindCategoriesFile, - DocumentKind.gitIgnore => context.l10n.documentKindConfigurationFile, - DocumentKind.image => context.l10n.image, - DocumentKind.resource => context.l10n.documentKindResourceFile, - DocumentKind.unknown => context.l10n.file, - }; -} - class _DiagnosticRow extends ConsumerWidget { const _DiagnosticRow({required this.diagnostic, required this.onRevealed}); diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index 53934b06..4a99f6bd 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; @@ -393,21 +392,14 @@ class _WritersideExistingTopicPickerState title: context.l10n.tocSelectExistingTopic, maxWidth: BusyMarkSizes.dialog, children: [ - Focus( - onKeyEvent: (_, event) { - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - final key = event.logicalKey; - if (key != LogicalKeyboardKey.arrowDown && - key != LogicalKeyboardKey.arrowUp) { - return KeyEventResult.ignored; - } + BusyMarkSearchField( + autofocus: true, + hintText: context.l10n.search, + onMoveSelection: (delta) { if (topics.isNotEmpty) { setState( - () => _selected = - (_selected + (key == LogicalKeyboardKey.arrowDown ? 1 : -1)) - .clamp(0, topics.length - 1), + () => + _selected = (_selected + delta).clamp(0, topics.length - 1), ); if (_scroll.hasClients) { _scroll.jumpTo( @@ -418,20 +410,15 @@ class _WritersideExistingTopicPickerState ); } } - return KeyEventResult.handled; }, - child: BusyMarkSearchField( - autofocus: true, - hintText: context.l10n.search, - onEscape: () => Navigator.pop(context), - onChanged: (value) => setState(() { - _query = value; - _selected = 0; - }), - onSubmitted: (_) { - if (topics.isNotEmpty) Navigator.pop(context, topics[_selected]); - }, - ), + onEscape: () => Navigator.pop(context), + onChanged: (value) => setState(() { + _query = value; + _selected = 0; + }), + onSubmitted: (_) { + if (topics.isNotEmpty) Navigator.pop(context, topics[_selected]); + }, ), SizedBox( height: 300, diff --git a/lib/src/workspace/recent_navigation.dart b/lib/src/workspace/recent_navigation.dart new file mode 100644 index 00000000..f7ec825c --- /dev/null +++ b/lib/src/workspace/recent_navigation.dart @@ -0,0 +1,192 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; + +import '../app/app_router.dart'; +import '../app/app_settings.dart'; +import '../app/busymark_toast.dart'; +import '../app/localization.dart'; +import '../nextcloud_notes/application/nextcloud_connection.dart'; +import '../nextcloud_notes/application/notes_navigation.dart'; +import '../search/search_session.dart'; +import 'document_navigation.dart'; +import 'workspace_controller.dart'; +import 'workspace_safety.dart'; + +Future openRecentResource( + BuildContext context, + WidgetRef ref, + RecentResource resource, +) async { + final controller = ref.read(workspaceControllerProvider.notifier); + final origin = ref.read(appRouterProvider).routeInformationProvider.value.uri; + final search = ref.read(searchSessionProvider); + bool currentRequest() => + context.mounted && + ref.read(appRouterProvider).routeInformationProvider.value.uri == + origin && + ref.read(searchSessionProvider).requestRevision == + search.requestRevision && + (!search.active || + ref.read(searchSessionProvider).active && + ref.read(searchSessionProvider).focusRequest == + search.focusRequest && + ref.read(searchSessionProvider).category == search.category && + ref.read(searchSessionProvider).query == search.query); + final current = ref.read(workspaceControllerProvider).workspace; + bool opened = false; + if (resource.accountId != null) { + final repository = await ref.read(nextcloudNotesRepositoryProvider.future); + if (!context.mounted || !currentRequest()) return; + final account = repository.accounts + .where((a) => a.id == resource.accountId) + .firstOrNull; + final note = resource.localId == null + ? null + : repository.noteById(resource.localId!); + if (account == null || + resource.isDocument && + (note?.accountId != resource.accountId || + isNotesRecovery(note!) || + repository.isCreationCandidateBinding(note.localId))) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.searchResourceUnavailable, + ); + return; + } + if (current?.nextcloudAccountId != resource.accountId) { + if (!await confirmSafeToContinue(context, ref) || + !context.mounted || + !currentRequest()) { + return; + } + if (!await controller.openNextcloudWorkspace( + resource.accountId!, + acceptNavigation: currentRequest, + )) { + return; + } + } + opened = + resource.localId == null || + await controller.openNextcloudNote( + resource.localId!, + acceptNavigation: currentRequest, + ); + } else { + if (!await FileSystemEntity.isFile(resource.path) && + !await Directory(resource.path).exists()) { + if (context.mounted) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.searchResourceUnavailable, + ); + } + return; + } + if (!context.mounted || !currentRequest()) return; + final sameOwner = + current != null && + (resource.ownerRoot != null + ? p.equals(current.rootPath, resource.ownerRoot!) + : current.files.any( + (f) => p.equals(f.absolutePath, resource.path), + )); + if (resource.isDocument && sameOwner) { + opened = await controller.openActiveFile( + resource.path, + acceptNavigation: currentRequest, + ); + } else { + if (!await confirmSafeToContinue(context, ref) || + !context.mounted || + !currentRequest()) { + return; + } + await controller.openPath( + resource.ownerRoot ?? resource.path, + acceptNavigation: currentRequest, + ); + if (!context.mounted || !currentRequest()) return; + final workspace = ref.read(workspaceControllerProvider).workspace; + if (workspace == null) return; + if (resource.ownerRoot != null && + workspace.writersideProject == null && + workspace.writersideModule == null) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.searchResourceUnavailable, + ); + return; + } + opened = + resource.ownerRoot == null || + await controller.openActiveFile( + resource.path, + acceptNavigation: currentRequest, + ); + } + } + if (opened && context.mounted) { + await ref + .read(appSettingsControllerProvider.notifier) + .recordRecent( + RecentResource( + path: resource.path, + kind: resource.kind, + accountId: resource.accountId, + accountLabel: resource.accountLabel, + localId: resource.localId, + title: resource.title, + ownerRoot: resource.ownerRoot, + document: resource.isDocument, + lastOpenedAt: DateTime.now(), + ), + ); + if (!context.mounted || !currentRequest()) return; + revealOpenedDocument(ref); + ref.read(searchSessionProvider.notifier).close(restoreFocus: false); + context.go('/workspace'); + } +} + +enum RecentNavigationAction { openInFiles, copyPath, removeFromRecent } + +Future runRecentNavigationAction( + BuildContext context, + WidgetRef ref, + RecentResource resource, + RecentNavigationAction action, +) async { + switch (action) { + case RecentNavigationAction.removeFromRecent: + await ref + .read(appSettingsControllerProvider.notifier) + .removeRecentWorkspace(resource.identity); + case RecentNavigationAction.copyPath: + await Clipboard.setData(ClipboardData(text: resource.path)); + case RecentNavigationAction.openInFiles: + final type = await FileSystemEntity.type(resource.path); + if (!context.mounted) return; + if (type == FileSystemEntityType.notFound) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.searchResourceUnavailable, + ); + return; + } + await launchUrl( + Uri.directory( + type == FileSystemEntityType.directory + ? resource.path + : p.dirname(resource.path), + ), + ); + } +} diff --git a/lib/src/workspace/recent_presentation.dart b/lib/src/workspace/recent_presentation.dart new file mode 100644 index 00000000..141773ca --- /dev/null +++ b/lib/src/workspace/recent_presentation.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; +import 'package:path/path.dart' as p; + +import '../app/app_settings.dart'; +import '../app/localization.dart'; +import '../platform/user_directories.dart'; + +String recentResourceTitle(BuildContext context, RecentResource resource) => + resource.title ?? + (resource.accountId == null + ? p.basename(resource.path) + : context.l10n.nextcloudNotes); + +String recentResourceLocation( + BuildContext context, + RecentResource resource, { + String? accountLabel, + String? homeDirectory, + String? workspaceRoot, + Iterable recentResources = const [], +}) { + if (resource.accountId != null) { + final label = accountLabel?.trim().isNotEmpty == true + ? accountLabel + : resource.accountLabel; + return label == null || label.trim().isEmpty + ? context.l10n.searchAccountUnavailable + : label; + } + final parent = p.dirname(p.normalize(resource.path)); + final home = homeDirectory ?? userHomeDirectory; + if (home == null || !(p.equals(home, parent) || p.isWithin(home, parent))) { + return parent; + } + String homeRelative(String path) => + p.equals(home, path) ? '~' : p.join('~', p.relative(path, from: home)); + if (!resource.isDocument || p.equals(p.dirname(parent), home)) { + return homeRelative(parent); + } + final roots = { + if (resource.ownerRoot != null) p.normalize(resource.ownerRoot!), + if (workspaceRoot != null) p.normalize(workspaceRoot), + for (final other in recentResources) ...[ + if (other.ownerRoot != null) p.normalize(other.ownerRoot!), + if (!other.isDocument && + other.accountId == null && + (other.kind == 'markdownFolder' || other.kind == 'writersideModule')) + p.normalize(other.path), + ], + }; + final owners = + roots + .where( + (root) => + (p.equals(home, root) || p.isWithin(home, root)) && + (p.equals(root, parent) || p.isWithin(root, parent)), + ) + .toList() + ..sort((a, b) => b.length.compareTo(a.length)); + if (owners.isEmpty || p.equals(owners.first, home)) { + return homeRelative(parent); + } + final owner = owners.first; + final ambiguous = roots.any( + (root) => p.basename(root) == p.basename(owner) && !p.equals(root, owner), + ); + final label = ambiguous ? homeRelative(owner) : p.basename(owner); + return p.equals(owner, parent) + ? label + : p.join(label, p.relative(parent, from: owner)); +} diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 06d78a48..2464cd11 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -128,16 +128,6 @@ final workspaceControllerProvider = WorkspaceController.new, ); -final workspaceSearchOpenRequestProvider = - NotifierProvider( - WorkspaceSearchRequestController.new, - ); - -final workspaceSearchCloseRequestProvider = - NotifierProvider( - WorkspaceSearchRequestController.new, - ); - /// Identifies the exact editor revision that an asynchronous save-related /// operation was started for. /// @@ -515,6 +505,15 @@ class WorkspaceController extends Notifier { if (!ref.mounted || state.workspace?.isRemote != true) return; final repository = _notesRepository; if (repository == null) return; + unawaited( + _settingsController.updateRecentNoteTitles({ + for (final recent in _settingsController.state.recentWorkspaces) + if (recent.accountId != null && recent.localId != null) + if (repository.noteById(recent.localId!) case final note? + when note.accountId == recent.accountId) + (note.accountId, note.localId): note.title, + }), + ); var contentChanged = false; final buffers = []; for (final buffer in state.documentBuffers) { @@ -617,7 +616,9 @@ class WorkspaceController extends Notifier { String accountId, { List? restoredTabs, String? activeBufferId, + bool Function()? acceptNavigation, }) async { + if (acceptNavigation?.call() == false) return false; if (!propertyEdits.flush()) return false; if (state.hasUnsavedChanges) return false; final prior = state; @@ -629,6 +630,7 @@ class WorkspaceController extends Notifier { } final settled = await _localHistory.flushAll(prior.documentBuffers); if (!ref.mounted || + acceptNavigation?.call() == false || priorRevision != _activeDocumentRevision || !identical(prior.workspace, state.workspace) || state.hasUnsavedChanges) { @@ -636,7 +638,7 @@ class WorkspaceController extends Notifier { } _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); - _invalidateActiveDocumentOperations(); + final operationRevision = _invalidateActiveDocumentOperations(); await _fileMonitor.stop(); for (final buffer in prior.documentBuffers) { await _localHistory.handleBufferClosed( @@ -645,6 +647,11 @@ class WorkspaceController extends Notifier { ); } final workspace = Workspace.nextcloudNotes(accountId); + if (!_isCurrentActiveDocumentOperation(operationRevision)) return false; + if (acceptNavigation?.call() == false) { + await _restoreCancelledNavigation(prior); + return false; + } final buffers = []; for (final tab in restoredTabs ?? const []) { final reference = tab.remoteNote; @@ -686,6 +693,18 @@ class WorkspaceController extends Notifier { ); } _schedulePersistence(); + if (restoredTabs == null) { + await _settingsController.recordOpenedWorkspace( + path: '', + kind: 'nextcloudNotes', + accountId: accountId, + title: repository.accounts + .where((a) => a.id == accountId) + .firstOrNull + ?.server + .toString(), + ); + } unawaited( _notesCoordinator ?.request(NotesSyncTrigger.opening) @@ -705,9 +724,14 @@ class WorkspaceController extends Notifier { } } - Future openNextcloudNote(String localId) async { + Future openNextcloudNote( + String localId, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false) return false; if (!propertyEdits.flush()) return false; final repository = await _ensureNotesRepository(); + if (!ref.mounted || acceptNavigation?.call() == false) return false; final note = repository.noteById(localId); final workspace = state.workspace; if (note == null || @@ -731,7 +755,9 @@ class WorkspaceController extends Notifier { buffer, documentBuffers: state.documentBuffers, openFilePaths: const [], + acceptNavigation: acceptNavigation, ); + if (result) await _recordOpenedDocument(); if (result && existing == null) { unawaited(_localHistory.observeOpened(buffer)); } @@ -772,19 +798,28 @@ class WorkspaceController extends Notifier { String title = 'New note', String category = '', String content = '', + bool Function()? acceptNavigation, }) async { + if (acceptNavigation?.call() == false) return false; if (!propertyEdits.flush()) return false; try { final accountId = state.workspace?.nextcloudAccountId; if (accountId == null) return false; final repository = await _ensureNotesRepository(); + if (acceptNavigation?.call() == false || + state.workspace?.nextcloudAccountId != accountId) { + return false; + } final note = await repository.create( accountId, title: title, category: category, content: content, ); - final opened = await openNextcloudNote(note.localId); + final opened = await openNextcloudNote( + note.localId, + acceptNavigation: acceptNavigation, + ); _scheduleRemoteSync(accountId); return opened; } on Object catch (error) { @@ -1220,6 +1255,7 @@ class WorkspaceController extends Notifier { /// Called after removal has committed, so session restoration cannot reopen /// an account whose durable store and keyring entry have been removed. Future closeRemovedNextcloudWorkspace(String accountId) async { + await _settingsController.removeRecentAccount(accountId); if (state.workspace?.nextcloudAccountId != accountId) return; _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); @@ -3883,12 +3919,21 @@ class WorkspaceController extends Notifier { await _startMonitoring(state.workspace ?? workspace); } - Future openPath(String path) async { + Future openPath( + String path, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false) return; if (!propertyEdits.flush()) return; - _invalidateFileMonitorWorkspace(); - _removalOperationRevision++; + final prior = state; + final recoveryOwners = Map.of( + _restoredRecoveryOwnerIdsByBuffer, + ); final closingBuffers = state.documentBuffers; final historySettled = await _localHistory.flushAll(closingBuffers); + if (!ref.mounted || acceptNavigation?.call() == false) return; + _invalidateFileMonitorWorkspace(); + _removalOperationRevision++; _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -3924,6 +3969,13 @@ class WorkspaceController extends Notifier { if (!_isCurrentActiveDocumentOperation(operationRevision)) { return; } + if (acceptNavigation?.call() == false) { + await _restoreCancelledNavigation( + prior, + recoveryOwners: recoveryOwners, + ); + return; + } state = WorkspaceState( workspace: loadedWorkspace, activeText: text, @@ -3947,6 +3999,10 @@ class WorkspaceController extends Notifier { path: recentPath, kind: workspace.kind.name, ); + if (buffer?.filePath != null && + p.equals(p.absolute(path), p.absolute(buffer!.filePath!))) { + unawaited(_recordOpenedDocument()); + } } on Object catch (error, stackTrace) { busyMarkDebugLogError( '[BusyMark] Open failed', @@ -3966,6 +4022,26 @@ class WorkspaceController extends Notifier { } } + Future _restoreCancelledNavigation( + WorkspaceState prior, { + Map? recoveryOwners, + }) async { + if (recoveryOwners != null) { + _restoredRecoveryOwnerIdsByBuffer + ..clear() + ..addAll(recoveryOwners); + } + state = prior; + _recordActivePreviewRevision(); + if (prior.workspace case final workspace?) { + await _startMonitoring(workspace); + } + for (final buffer in prior.documentBuffers) { + unawaited(_localHistory.observeOpened(buffer)); + _scheduleAutoSave(buffer.id); + } + } + Future createWritersideProject( WritersideProjectCreateRequest request, ) async { @@ -4147,15 +4223,68 @@ class WorkspaceController extends Notifier { Future openFolder(String path) => openPath(path); - Future openActiveFile(String path) async { - return _openActiveFile(path); + Future openActiveFile( + String path, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false || !propertyEdits.flush()) { + return false; + } + final opened = await _openActiveFile( + path, + acceptNavigation: acceptNavigation, + ); + if (opened) unawaited(_recordOpenedDocument()); + return opened; + } + + Future _recordOpenedDocument([DocumentBuffer? opened]) async { + final workspace = state.workspace; + final buffer = opened ?? state.activeBuffer; + if (workspace == null || buffer == null) return; + final remote = buffer.remoteNote; + if (remote != null) { + await _settingsController.recordRecent( + RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: remote.accountId, + accountLabel: _notesRepository + ?.accountById(remote.accountId) + ?.server + .toString(), + localId: remote.localId, + title: buffer.remoteTitle, + lastOpenedAt: DateTime.now(), + ), + ); + } else if (buffer.filePath case final path?) { + final context = resolveWorkspaceDocumentContext(workspace, buffer); + await _settingsController.recordRecent( + RecentResource( + path: p.normalize(p.absolute(path)), + kind: workspace.kind == WorkspaceKind.singleMarkdown + ? workspace.kind.name + : context.kind.name, + document: true, + ownerRoot: context.isWritersideOwned ? workspace.rootPath : null, + title: context.writersideTopic?.title, + lastOpenedAt: DateTime.now(), + ), + ); + } } Future activateNextOpenFileTab() => _activateOpenFileTab(1); Future activatePreviousOpenFileTab() => _activateOpenFileTab(-1); - Future activateDocumentBuffer(String bufferId) async { + Future activateDocumentBuffer( + String bufferId, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false) return false; final workspace = state.workspace; final buffer = state.documentBuffers .where((candidate) => candidate.id == bufferId) @@ -4165,14 +4294,18 @@ class WorkspaceController extends Notifier { } if (state.activeBufferId == bufferId && workspace.activeFilePath == buffer.filePath) { + await _recordOpenedDocument(); return true; } - return _activateBuffer( + final opened = await _activateBuffer( workspace, buffer, documentBuffers: state.documentBuffers, openFilePaths: workspace.openFilePaths, + acceptNavigation: acceptNavigation, ); + if (opened) unawaited(_recordOpenedDocument()); + return opened; } Future closeActiveOpenFileTab() async { @@ -4398,6 +4531,7 @@ class WorkspaceController extends Notifier { onOperationStaged: (value) => historyOperationId = value, ); committed = true; + await _settingsController.moveRecentPath(path, target); _remapOpenWorkspacePaths(workspace, path, target); await _localHistory.acknowledgeStagedPathOperation(historyOperationId); return _remapMovedPath(activeFilePath, path, target); @@ -4451,6 +4585,7 @@ class WorkspaceController extends Notifier { onOperationStaged: (value) => historyOperationId = value, ); committed = true; + await _settingsController.moveRecentPath(sourcePath, target); _remapOpenWorkspacePaths(workspace, sourcePath, target); await _localHistory.acknowledgeStagedPathOperation(historyOperationId); return _remapMovedPath(activeFilePath, sourcePath, target); @@ -4921,6 +5056,7 @@ class WorkspaceController extends Notifier { ); final target = result.newTopicPath; committed = true; + await _settingsController.moveRecentPath(plan.oldTopicPath, target); _remapOpenWorkspacePaths(workspace, plan.oldTopicPath, target); await _localHistory.acknowledgeStagedPathOperation(historyOperationId); return _remapMovedPath(activeFilePath, plan.oldTopicPath, target); @@ -5351,7 +5487,9 @@ class WorkspaceController extends Notifier { String path, { List? openFilePaths, List? documentBuffers, + bool Function()? acceptNavigation, }) async { + if (acceptNavigation?.call() == false) return false; final workspace = state.workspace; if (workspace == null) { return false; @@ -5374,6 +5512,7 @@ class WorkspaceController extends Notifier { existing, documentBuffers: buffers, openFilePaths: requestedOpenFilePaths, + acceptNavigation: acceptNavigation, ); } _cancelPendingDerivedRefresh(); @@ -5392,7 +5531,8 @@ class WorkspaceController extends Notifier { activeFileSnapshot: load.snapshot, openFilePaths: requestedOpenFilePaths, ); - if (!_isCurrentActiveDocumentOperation(operationRevision) || + if (acceptNavigation?.call() == false || + !_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; } @@ -5418,7 +5558,8 @@ class WorkspaceController extends Notifier { buffers: state.documentBuffers, ); } - if (!_isCurrentActiveDocumentOperation(operationRevision) || + if (acceptNavigation?.call() == false || + !_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; } @@ -5501,7 +5642,9 @@ class WorkspaceController extends Notifier { DocumentBuffer buffer, { required List documentBuffers, required List openFilePaths, + bool Function()? acceptNavigation, }) async { + if (acceptNavigation?.call() == false) return false; if (!propertyEdits.flush()) return false; _cancelPendingDerivedRefresh(); final operationRevision = _invalidateActiveDocumentOperations(); @@ -5595,7 +5738,8 @@ class WorkspaceController extends Notifier { parsedBuffer, buffers: documentBuffers, ); - if (!_isCurrentActiveDocumentOperation(operationRevision)) { + if (acceptNavigation?.call() == false || + !_isCurrentActiveDocumentOperation(operationRevision)) { return false; } if (state.workspace?.id != workspaceId) return false; @@ -5612,7 +5756,8 @@ class WorkspaceController extends Notifier { parsedBuffer, buffers: currentRequestedBuffers(), ); - if (!_isCurrentActiveDocumentOperation(operationRevision) || + if (acceptNavigation?.call() == false || + !_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; } @@ -7174,10 +7319,13 @@ class WorkspaceController extends Notifier { snapshot: activeAfterSave.diskSnapshot, ); } - await _settingsController.recordOpenedWorkspace( - path: path, - kind: savedWorkspace.kind.name, - ); + if (savedWorkspace.kind == WorkspaceKind.singleMarkdown) { + await _settingsController.recordOpenedWorkspace( + path: path, + kind: savedWorkspace.kind.name, + ); + } + unawaited(_recordOpenedDocument(savedBuffer)); _schedulePersistence(); return true; } on Object catch (error) { @@ -7497,6 +7645,7 @@ class WorkspaceController extends Notifier { final existed = await _service.pathExists(path); if (existed != overwriteExisting) return false; WorkspaceFileSnapshot? createdSnapshot; + void Function()? stopWatchingTarget; try { if (!existed) { createdSnapshot = await _service.saveNewFormattedText( @@ -7547,6 +7696,18 @@ class WorkspaceController extends Notifier { format: destination.format, editorState: destination.editorState, ); + var targetChanged = false; + // Latch actual target changes, including switching away and back. A + // workspace metadata refresh may invalidate other document operations + // without changing this destination or its protected source. + stopWatchingTarget = listenSelf((previous, next) { + if (next.workspace?.id != target.workspaceId || + next.activeBufferId != target.bufferId || + next.activeBuffer?.filePath != target.path || + next.activeBuffer?.editorState.mode != target.editorState.mode) { + targetChanged = true; + } + }); if (!await _localHistory.captureProtective( LocalHistoryBufferSnapshot.fromBuffer(destination), LocalHistoryCaptureReason.beforeRestore, @@ -7556,8 +7717,10 @@ class WorkspaceController extends Notifier { } return false; } - if (!_isLocalHistoryRestoreTargetCurrent(target) || + if (targetChanged || + !_isLocalHistoryRestoreTargetCurrent(target) || await _service.fileChangedSince(path, target.snapshot) || + targetChanged || !_isLocalHistoryRestoreTargetCurrent(target)) { if (createdSnapshot != null) { await _rollbackMissingHistoryDestination(path, null, createdSnapshot); @@ -7595,6 +7758,8 @@ class WorkspaceController extends Notifier { await _rollbackMissingHistoryDestination(path, null, createdSnapshot); } return false; + } finally { + stopWatchingTarget?.call(); } } @@ -7618,19 +7783,33 @@ class WorkspaceController extends Notifier { } bool _isLocalHistoryRestoreTargetCurrent(_LocalHistoryRestoreTarget target) { + if (!ref.mounted) return false; final current = state.documentBuffers .where((buffer) => buffer.id == target.bufferId) .firstOrNull; + // This restores a whole revision, independently of caret/viewport state. + // Mounting Editor initializes its rich selection and viewport while the + // protective capture is pending. Those session reports do not change the + // target source. Navigation, mode, source/undo and disk changes still reject + // the restore; use the live session when constructing its Undo entry. return current != null && state.workspace?.id == target.workspaceId && + state.activeBufferId == target.bufferId && 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); + current.format.hasUtf8Bom == target.format.hasUtf8Bom && + current.format.lineEnding == target.format.lineEnding && + current.format.hasFinalNewline == target.format.hasFinalNewline && + current.format.lfCount == target.format.lfCount && + current.format.crlfCount == target.format.crlfCount && + current.format.crCount == target.format.crCount && + !current.readonly && + current.editorState.mode == target.editorState.mode && + identical(current.editorState.undoState, target.editorState.undoState); } Future localHistoryRestorePathExists(String path) { @@ -9240,3 +9419,15 @@ class _RemovalBufferAuthorization { final List buffers; bool committed = false; } + +/// Application commands use the same sidebar destination handler as browsing. +final workspaceNavigationCommandProvider = + NotifierProvider( + WorkspaceNavigationCommandController.new, + ); + +class WorkspaceNavigationCommandController extends Notifier<(String, int)?> { + @override + (String, int)? build() => null; + void request(String id) => state = (id, (state?.$2 ?? 0) + 1); +} diff --git a/lib/src/workspace/workspace_glyphs.dart b/lib/src/workspace/workspace_glyphs.dart index 511e0bfa..0afb4b6d 100644 --- a/lib/src/workspace/workspace_glyphs.dart +++ b/lib/src/workspace/workspace_glyphs.dart @@ -18,7 +18,9 @@ abstract final class WorkspaceGlyphs { }; } - static IconData forRecent(RecentWorkspace recent) { + static IconData forRecent(RecentResource recent) { + if (recent.accountId != null) return BusyMarkGlyphs.documentHistory; + if (recent.ownerRoot != null) return BusyMarkGlyphs.document; return forKindName(recent.kind, fallbackPath: recent.path); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a48642c2..2f3ce821 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -12,6 +12,7 @@ #include "flutter/generated_plugin_registrant.h" #include "gtk_accent_host.h" #include "linux_chrome_host.h" +#include "native_shortcut_metadata.h" #include "rich_clipboard_host.h" #include "secure_credential_host.h" #include "video_player_host.h" @@ -69,60 +70,15 @@ struct _MyApplication { }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) -static gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { - if (shortcut == nullptr || shortcut[0] == '\0') { - return nullptr; - } - - guint key = 0; - GdkModifierType modifiers = static_cast(0); - gtk_accelerator_parse(shortcut, &key, &modifiers); - if (key != 0) { - return g_strdup(shortcut); - } - - gchar** parts = g_strsplit(shortcut, "+", -1); - const gsize part_count = g_strv_length(parts); - GString* accelerator = g_string_new(nullptr); - gboolean valid = part_count > 0; - for (gsize index = 0; valid && index + 1 < part_count; index++) { - const gchar* part = g_strstrip(parts[index]); - if (g_strcmp0(part, "Ctrl") == 0 || - g_strcmp0(part, "Control") == 0) { - g_string_append(accelerator, ""); - } else if (g_strcmp0(part, "Alt") == 0) { - g_string_append(accelerator, ""); - } else if (g_strcmp0(part, "Shift") == 0) { - g_string_append(accelerator, ""); - } else if (g_strcmp0(part, "Super") == 0) { - g_string_append(accelerator, ""); - } else if (g_strcmp0(part, "Meta") == 0) { - g_string_append(accelerator, ""); - } else { - valid = FALSE; - } - } - if (valid) { - const gchar* key_label = g_strstrip(parts[part_count - 1]); - g_string_append(accelerator, - g_strcmp0(key_label, "Esc") == 0 ? "Escape" : key_label); - key = 0; - modifiers = static_cast(0); - gtk_accelerator_parse(accelerator->str, &key, &modifiers); - valid = key != 0; - } - - g_strfreev(parts); - return g_string_free(accelerator, !valid); -} - -static void set_menu_item_accelerator(GMenuItem* item, +static bool set_menu_item_accelerator(GMenuItem* item, const gchar* shortcut) { g_autofree gchar* accelerator = gtk_accelerator_from_shortcut_label(shortcut); if (accelerator != nullptr) { g_menu_item_set_attribute(item, kMenuAccelAttribute, "s", accelerator); + return true; } + return false; } static GdkPixbuf* load_application_icon_at_size(gint size) { @@ -665,6 +621,8 @@ static gboolean validate_native_menu_entries(FlValue* entries, static_cast(G_MAXUINT32))) || (selected && !checkable) || (children != nullptr && (separator || checkable)) || + (fl_value_lookup_string(entry, "gtkAccelerator") != nullptr && + fl_lookup_string_arg(entry, "gtkAccelerator") == nullptr) || (fl_value_lookup_string(entry, "shortcut") != nullptr && fl_lookup_string_arg(entry, "shortcut") == nullptr)) { respond_native_menu_argument_error( @@ -764,7 +722,10 @@ static void build_native_menu_model(FlValue* entries, // this native boundary; action indexes and clipboard payloads stay intact. g_autofree gchar* literal_label = escape_native_menu_label(label); const gchar* icon_name = fl_lookup_string_arg(entry, "icon"); - const gchar* shortcut = fl_lookup_string_arg(entry, "shortcut"); + // Accelerator metadata is generated from logical keys in Dart. GMenu uses + // it for display; Flutter remains the single command dispatch mechanism. + const gchar* shortcut = fl_lookup_string_arg(entry, "gtkAccelerator"); + if (shortcut == nullptr) shortcut = fl_lookup_string_arg(entry, "shortcut"); gboolean enabled = TRUE; gboolean checkable = FALSE; gboolean selected = FALSE; @@ -810,8 +771,13 @@ static void build_native_menu_model(FlValue* entries, g_autoptr(GIcon) icon = create_native_menu_icon(icon_name, entry); g_menu_item_set_icon(item, icon); } - if (shortcut != nullptr && shortcut[0] != '\0') { - set_menu_item_accelerator(item, shortcut); + if (shortcut != nullptr && shortcut[0] != '\0' && + !set_menu_item_accelerator(item, shortcut)) { + const gchar* display = fl_lookup_string_arg(entry, "shortcut"); + if (display != nullptr && display[0] != '\0') { + g_autofree gchar* fallback = g_strdup_printf("%s (%s)", literal_label, display); + g_menu_item_set_label(item, fallback); + } } g_menu_append_item(section, item); g_object_unref(action); diff --git a/linux/runner/native_shortcut_metadata.h b/linux/runner/native_shortcut_metadata.h new file mode 100644 index 00000000..8c9b78c3 --- /dev/null +++ b/linux/runner/native_shortcut_metadata.h @@ -0,0 +1,90 @@ +#ifndef BUSYMARK_NATIVE_SHORTCUT_METADATA_H_ +#define BUSYMARK_NATIVE_SHORTCUT_METADATA_H_ + +#include +#include + +inline gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { + if (shortcut == nullptr || shortcut[0] == '\0') { + return nullptr; + } + + // Dart sends logical Unicode identity, never a translated shortcut badge. + // GDK supplies legacy keysym names such as eacute or Cyrillic_a. + const gchar* unicode = strstr(shortcut, "U+"); + if (unicode != nullptr) { + const gchar* digits = unicode + 2; + const gsize count = strlen(digits); + if (count == 0 || count > 6 || + strspn(digits, "0123456789abcdefABCDEF") != count) { + return nullptr; + } + const guint64 codepoint = g_ascii_strtoull(digits, nullptr, 16); + if (codepoint == 0 || codepoint > 0x10ffff || + !g_unichar_validate(codepoint)) { + return nullptr; + } + g_autofree gchar* prefix = g_strndup(shortcut, unicode - shortcut); + g_autofree gchar* sample = g_strconcat(prefix, "a", nullptr); + guint sample_key = 0; + GdkModifierType modifiers = static_cast(0); + gtk_accelerator_parse(sample, &sample_key, &modifiers); + if (sample_key != GDK_KEY_a) return nullptr; + // GTK normalizes alphabetic accelerator keys to lowercase. Shift remains + // a separate modifier, as it is in the structured application binding. + const guint expected_key = gdk_keyval_to_lower( + gdk_unicode_to_keyval(codepoint)); + g_autofree gchar* canonical = gtk_accelerator_name(expected_key, modifiers); + guint verified = 0; + GdkModifierType verified_modifiers = static_cast(0); + gtk_accelerator_parse(canonical, &verified, &verified_modifiers); + // Some Unicode keys have no GTK 3 accelerator name. The caller retains the + // truthful display label instead of publishing invalid accelerator syntax. + return verified == expected_key && verified_modifiers == modifiers + ? g_strdup(canonical) + : nullptr; + } + + guint key = 0; + GdkModifierType modifiers = static_cast(0); + gtk_accelerator_parse(shortcut, &key, &modifiers); + if (key != 0) { + return g_strdup(shortcut); + } + + gchar** parts = g_strsplit(shortcut, "+", -1); + const gsize part_count = g_strv_length(parts); + GString* accelerator = g_string_new(nullptr); + gboolean valid = part_count > 0; + for (gsize index = 0; valid && index + 1 < part_count; index++) { + const gchar* part = g_strstrip(parts[index]); + if (g_strcmp0(part, "Ctrl") == 0 || + g_strcmp0(part, "Control") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Alt") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Shift") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Super") == 0) { + g_string_append(accelerator, ""); + } else if (g_strcmp0(part, "Meta") == 0) { + g_string_append(accelerator, ""); + } else { + valid = FALSE; + } + } + if (valid) { + const gchar* key_label = g_strstrip(parts[part_count - 1]); + g_string_append(accelerator, + g_strcmp0(key_label, "Esc") == 0 ? "Escape" : key_label); + key = 0; + modifiers = static_cast(0); + gtk_accelerator_parse(accelerator->str, &key, &modifiers); + valid = key != 0; + } + + g_strfreev(parts); + return g_string_free(accelerator, !valid); +} + +#endif diff --git a/test/src/ai_edit_ui_test.dart b/test/src/ai_edit_ui_test.dart index 91cfe687..2d310c9f 100644 --- a/test/src/ai_edit_ui_test.dart +++ b/test/src/ai_edit_ui_test.dart @@ -68,7 +68,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => changed = text, onOpenSearch: () {}, onCloseSearch: () {}, diff --git a/test/src/app_router_test.dart b/test/src/app_router_test.dart index 2f3738c0..fcb3c220 100644 --- a/test/src/app_router_test.dart +++ b/test/src/app_router_test.dart @@ -70,6 +70,10 @@ void main() { test('settings page routes are explicit and validated', () { expect(settingsPageFromRouteValue(null), SettingsPage.appearance); expect(settingsPageFromRouteValue('editor'), SettingsPage.editor); + expect( + settingsPageFromRouteValue('keyboardShortcuts'), + SettingsPage.keyboardShortcuts, + ); expect( settingsPageFromRouteValue('spellingDictionaries'), SettingsPage.spellingDictionaries, @@ -120,7 +124,7 @@ void main() { ); await tester.tap( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), ); await tester.pumpAndSettle(); @@ -158,7 +162,7 @@ void main() { for (var index = 0; index < 30; index += 1) { await tester.pump(const Duration(milliseconds: 100)); if (find - .byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})') + .byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})') .evaluate() .isNotEmpty) { break; @@ -166,18 +170,18 @@ void main() { } expect( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), findsOneWidget, ); await tester.tap( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), ); await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 500)); expect(find.text(l10n.createMarkdownFile), findsOneWidget); expect( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), findsNothing, ); }); diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index eef8ad82..24f6fe17 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -1,3 +1,7 @@ +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; +import 'package:flutter/services.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -9,6 +13,636 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test( + 'new search defaults preserve saved assignments and resolve explicitly', + () async { + const y = BusyMarkKeyBinding( + LogicalKeyboardKey.keyL, + control: true, + shift: true, + ); + const h = BusyMarkKeyBinding( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ); + const k = BusyMarkKeyBinding( + LogicalKeyboardKey.keyK, + control: true, + alt: true, + ); + final store = _KeymapStore() + ..value = AppSettings.defaults() + .copyWith( + shortcutOverrides: { + 'editor.bold': y, + 'editor.italic': h, + 'help.keyboardShortcuts': k, + }, + ) + .toJson(); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + BusyMarkCommandRegistry effective() => + effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, current()); + expect(effective()['editor.bold']!.shortcut!.label, 'Ctrl+Shift+L'); + expect(effective()['editor.italic']!.shortcut!.label, 'Ctrl+Shift+H'); + expect(effective()['history.findLocal']!.shortcut, isNull); + expect(effective()['search.headings']!.shortcut, isNull); + expect( + effective()['help.keyboardShortcuts']!.shortcut!.label, + 'Ctrl+Alt+K', + ); + expect( + current().containsKey('history.findLocal'), + isFalse, + reason: 'No invented persisted override', + ); + final controller = container.read(keymapControllerProvider.notifier); + final refused = await controller.update( + 'history.findLocal', + null, + reset: true, + expected: current(), + ); + expect(refused.status, KeymapUpdateStatus.conflict); + expect(refused.conflicts.map((c) => c.id), ['editor.bold']); + expect(effective()['editor.bold']!.shortcut!.label, 'Ctrl+Shift+L'); + final accepted = await controller.update( + 'history.findLocal', + null, + reset: true, + expected: current(), + reassign: {'editor.bold'}, + ); + expect(accepted.status, KeymapUpdateStatus.saved); + expect(effective()['editor.bold']!.shortcut, isNull); + expect(effective()['history.findLocal']!.shortcut!.label, 'Ctrl+Shift+L'); + expect(effective()['editor.italic']!.shortcut!.label, 'Ctrl+Shift+H'); + expect(current().containsKey('search.headings'), isFalse); + await controller.resetAll(); + expect(effective()['search.headings']!.shortcut!.label, 'Ctrl+Shift+H'); + expect(effective()['history.findLocal']!.shortcut!.label, 'Ctrl+Shift+L'); + expect(effective()['help.keyboardShortcuts']!.shortcut, isNull); + }, + ); + + test( + 'changed Documents and Recent defaults yield to saved bindings and explicit removals', + () { + final saved = { + 'file.save': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyD, + control: true, + shift: true, + ), + 'file.open': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyR, + control: true, + shift: true, + ), + 'search.headings': null, + }; + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + saved, + ); + expect(registry['file.save']!.shortcut!.label, 'Ctrl+Shift+D'); + expect(registry['file.open']!.shortcut!.label, 'Ctrl+Shift+R'); + expect(registry['workspace.quickOpen']!.shortcut, isNull); + expect(registry['search.recent']!.shortcut, isNull); + expect(registry['search.headings']!.shortcut, isNull); + expect( + validatedShortcutOverrides(BusyMarkCommandCatalog.metadata, saved), + saved, + ); + expect(saved.containsKey('workspace.quickOpen'), isFalse); + }, + ); + + test( + 'successive keymap edits resolve defaults regardless of record position', + () async { + final store = _KeymapStore(); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final keymap = container.read(keymapControllerProvider.notifier); + for (final (id, binding) in [ + ('editor.bold', const BusyMarkKeyBinding(LogicalKeyboardKey.f12)), + ('file.save', const BusyMarkKeyBinding(LogicalKeyboardKey.f8)), + ( + 'editor.bold', + const BusyMarkKeyBinding(LogicalKeyboardKey.keyS, control: true), + ), + ]) { + expect( + (await keymap.update( + id, + binding, + expected: container + .read(appSettingsControllerProvider) + .shortcutOverrides, + )).status, + KeymapUpdateStatus.saved, + ); + } + final restored = AppSettings.fromJson(store.value); + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + restored.shortcutOverrides, + ); + expect(registry['editor.bold']!.shortcut!.label, 'Ctrl+S'); + expect(registry['file.save']!.shortcut!.label, 'F8'); + expect(restored.autoSave, isFalse); + }, + ); + + test( + 'resetting a default checks keypad aliases before one reassignment transaction', + () async { + final store = _KeymapStore(); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final keymap = container.read(keymapControllerProvider.notifier); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + expect( + (await keymap.update( + 'sidebar.outline', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + expect( + (await keymap.update( + 'file.save', + const BusyMarkKeyBinding(LogicalKeyboardKey.numpad3, control: true), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + final before = current(); + final rejected = await keymap.update( + 'sidebar.outline', + null, + expected: before, + reset: true, + ); + expect(rejected.status, KeymapUpdateStatus.conflict); + expect(rejected.conflicts.map((c) => c.id), ['file.save']); + expect(current(), before); + expect(store.writes, hasLength(2)); + expect( + (await keymap.update( + 'sidebar.outline', + null, + expected: before, + reset: true, + reassign: {'file.save'}, + )).status, + KeymapUpdateStatus.saved, + ); + expect(store.writes, hasLength(3)); + expect(current().containsKey('sidebar.outline'), isFalse); + expect(current()['file.save'], isNull); + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + current(), + ); + expect(registry['file.save']!.shortcut, isNull); + expect( + registry + .activators('sidebar.outline') + .whereType() + .map(BusyMarkKeyBinding.fromActivator), + contains( + const BusyMarkKeyBinding(LogicalKeyboardKey.numpad3, control: true), + ), + ); + }, + ); + + test( + 'editing another binding cannot revive a rejected stored assignment', + () async { + final store = _KeymapStore() + ..value = { + 'autoSave': false, + 'shortcutOverrides': { + 'editor.bold': { + 'key': LogicalKeyboardKey.keyS.keyId, + 'control': true, + }, + }, + }; + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + final loaded = container + .read(appSettingsControllerProvider) + .shortcutOverrides; + expect( + effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + loaded, + )['editor.bold']!.shortcut!.label, + 'Ctrl+B', + ); + expect( + (await container + .read(keymapControllerProvider.notifier) + .update( + 'file.save', + const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + expected: loaded, + )) + .status, + KeymapUpdateStatus.saved, + ); + final current = container + .read(appSettingsControllerProvider) + .shortcutOverrides; + expect(current.keys, ['file.save']); + expect( + effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + current, + )['editor.bold']!.shortcut!.label, + 'Ctrl+B', + ); + expect(store.value['autoSave'], isFalse); + }, + ); + + test( + 'keymap overrides survive the existing settings file and restart', + () async { + final directory = await Directory.systemTemp.createTemp( + 'busymark-keymap-', + ); + addTearDown(() => directory.delete(recursive: true)); + final path = p.join(directory.path, 'settings.json'); + ProviderContainer start() => ProviderContainer( + overrides: [ + localSettingsStoreProvider.overrideWithValue( + JsonFileLocalSettingsStore(settingsFilePathOverride: path), + ), + ], + ); + final first = start(); + final settings = first.read(appSettingsControllerProvider.notifier); + await settings.waitUntilLoaded(); + await settings.setAutoSave(false); + final keymap = first.read(keymapControllerProvider.notifier); + expect( + (await keymap.update( + 'editor.bold', + const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + expected: {}, + )).status, + KeymapUpdateStatus.saved, + ); + expect( + (await keymap.update( + 'search.find', + null, + expected: first.read(appSettingsControllerProvider).shortcutOverrides, + )).status, + KeymapUpdateStatus.saved, + ); + first.dispose(); + final restarted = start(); + addTearDown(restarted.dispose); + await restarted + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + final loaded = restarted.read(appSettingsControllerProvider); + expect(loaded.autoSave, isFalse); + expect(loaded.shortcutOverrides.keys, ['editor.bold', 'search.find']); + final effective = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + loaded.shortcutOverrides, + ); + expect(effective['editor.bold']!.shortcut!.label, 'F12'); + expect(effective['search.find']!.shortcut, isNull); + expect(effective['search.workspace']!.shortcut!.label, 'Ctrl+Shift+F'); + }, + ); + + test( + 'keymap validates each stored binding without resetting preferences', + () { + final settings = AppSettings.fromJson({ + 'autoSave': false, + 'shortcutOverrides': { + 'file.save': {'key': LogicalKeyboardKey.f12.keyId, 'control': true}, + 'search.find': null, + 'editor.bold': {'key': -1}, + 'editor.italic': { + 'key': LogicalKeyboardKey.keyI.keyId, + 'control': 'yes', + }, + 'bad id': {'key': LogicalKeyboardKey.f11.keyId}, + }, + }); + expect(settings.autoSave, isFalse); + expect(settings.shortcutOverrides.keys, ['file.save', 'search.find']); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.shortcutOverrides, settings.shortcutOverrides); + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + restored.shortcutOverrides, + ); + expect(registry['file.save']!.shortcut!.label, 'Ctrl+F12'); + expect(registry['search.find']!.shortcut, isNull); + expect( + BusyMarkCommandCatalog.metadata['search.find']!.shortcut!.label, + 'Ctrl+F', + ); + }, + ); + + test( + 'keymap update revalidates hydration, conflicts, resets and stale editors', + () async { + final store = _KeymapStore(); + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final settings = container.read(appSettingsControllerProvider.notifier); + final keymap = container.read(keymapControllerProvider.notifier); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + final binding = BusyMarkKeyBinding( + LogicalKeyboardKey.keyS, + control: true, + alt: true, + ); + final conflict = await keymap.update( + 'editor.bold', + binding, + expected: {}, + ); + expect(conflict.status, KeymapUpdateStatus.conflict); + expect(conflict.conflicts.map((c) => c.id), ['application.settings']); + expect(store.writes, isEmpty); + final result = await keymap.update( + 'editor.bold', + binding, + expected: {}, + reassign: {'application.settings'}, + ); + expect(result.status, KeymapUpdateStatus.saved); + expect(current(), {'editor.bold': binding, 'application.settings': null}); + expect(store.writes, hasLength(1)); + expect( + (await keymap.update('file.save', null, expected: {})).status, + KeymapUpdateStatus.changed, + ); + expect( + (await keymap.update( + 'application.settings', + null, + reset: true, + expected: current(), + )).status, + KeymapUpdateStatus.conflict, + ); + expect( + (await keymap.update( + 'application.settings', + null, + reset: true, + expected: current(), + reassign: {'editor.bold'}, + )).status, + KeymapUpdateStatus.saved, + ); + expect(current(), {'editor.bold': null}); + await settings.setAutoSave(false); + expect(await keymap.resetAll(), isTrue); + expect(current(), isEmpty); + expect(store.value['autoSave'], isFalse); + }, + ); + + test( + 'keymap serialized saves expose failure and retry the latest preferences', + () async { + final store = _KeymapStore()..fail = true; + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final keymap = container.read(keymapControllerProvider.notifier); + final first = await keymap.update( + 'file.save', + const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + expected: {}, + ); + expect(first.status, KeymapUpdateStatus.failed); + expect( + container.read(keymapControllerProvider), + KeymapUpdateStatus.failed, + ); + expect( + container + .read(appSettingsControllerProvider) + .shortcutOverrides['file.save']! + .key, + LogicalKeyboardKey.f12, + ); + await container + .read(appSettingsControllerProvider.notifier) + .setWordWrap(false); + store.fail = false; + final current = container + .read(appSettingsControllerProvider) + .shortcutOverrides; + final pending = keymap.update( + 'file.open', + const BusyMarkKeyBinding(LogicalKeyboardKey.f10, control: true), + expected: current, + ); + final stale = keymap.update('file.save', null, expected: current); + expect((await pending).status, KeymapUpdateStatus.saved); + expect((await stale).status, KeymapUpdateStatus.changed); + await keymap.retry(); + expect( + container.read(keymapControllerProvider), + KeymapUpdateStatus.saved, + ); + final persisted = AppSettings.fromJson(store.value); + expect(persisted.wordWrap, isFalse); + expect(persisted.shortcutOverrides.keys, ['file.save', 'file.open']); + final restart = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(restart.dispose); + await restart + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + expect( + restart.read(appSettingsControllerProvider).shortcutOverrides, + persisted.shortcutOverrides, + ); + }, + ); + + test( + 'stored mixed Recent normalizes timestamps and identities with one limit', + () { + final entries = [ + for (var i = 0; i < 20; i++) + RecentResource( + path: i.isEven ? '/docs/$i.md' : '/work/$i', + kind: i.isEven ? 'markdown' : 'markdownFolder', + document: i.isEven, + lastOpenedAt: DateTime.utc(2026, 1, i + 1), + ), + RecentResource( + path: '/docs/18.md', + kind: 'markdown', + document: true, + lastOpenedAt: DateTime.utc(2024), + ), + ]; + final settings = AppSettings.fromJson({ + 'recentWorkspaces': entries.reversed.map((r) => r.toJson()).toList(), + }); + expect(settings.recentWorkspaces.map((r) => r.path), [ + for (var i = 19; i >= 8; i--) i.isEven ? '/docs/$i.md' : '/work/$i', + ]); + expect( + settings.recentWorkspaces[1].lastOpenedAt, + DateTime.utc(2026, 1, 19), + ); + expect(settings.toJson().containsKey('recentWorkspaces'), isTrue); + expect( + AppSettings.fromJson( + settings.toJson(), + ).recentWorkspaces.map((r) => r.identity), + settings.recentWorkspaces.map((r) => r.identity), + ); + }, + ); + test( + 'Recent retains old timestamps and twelve mixed resources in timestamp order', + () async { + final oldTime = DateTime.utc(2024, 1, 2); + final store = _MemorySettingsStore() + ..value = { + 'recentWorkspaces': [ + RecentResource( + path: '/old/file.md', + kind: 'singleMarkdown', + lastOpenedAt: oldTime, + ).toJson(), + ], + }; + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final controller = container.read(appSettingsControllerProvider.notifier); + await controller.waitUntilLoaded(); + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .single + .lastOpenedAt, + oldTime, + ); + for (var i = 0; i < 13; i++) { + await controller.recordRecent( + RecentResource( + path: '/docs/$i.md', + kind: 'markdown', + document: true, + lastOpenedAt: DateTime.utc(2026, 1, i * 2 + 1), + ), + ); + await controller.recordRecent( + RecentResource( + path: '/work/$i', + kind: 'markdownFolder', + lastOpenedAt: DateTime.utc(2026, 1, i * 2 + 2), + ), + ); + } + var recent = container + .read(appSettingsControllerProvider) + .recentWorkspaces; + expect(recent, hasLength(12)); + expect(recent.map((r) => r.path), [ + for (var i = 12; i >= 7; i--) ...['/work/$i', '/docs/$i.md'], + ]); + expect(recent.where((r) => r.isDocument), hasLength(6)); + expect(recent.where((r) => !r.isDocument), hasLength(6)); + for (final account in ['account-a', 'account-b', 'account-a']) { + await controller.recordRecent( + RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: account, + localId: 'offline-local-id', + title: 'Same title', + lastOpenedAt: DateTime.now(), + ), + ); + } + recent = container.read(appSettingsControllerProvider).recentWorkspaces; + expect(recent.where((r) => r.accountId != null), hasLength(2)); + expect(recent.first.identity, 'account-a:offline-local-id'); + expect( + AppSettings.fromJson(store.value).recentWorkspaces.first.localId, + 'offline-local-id', + ); + await controller.removeRecentAccount('account-a'); + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .any((r) => r.accountId == 'account-a'), + isFalse, + ); + await controller.moveRecentPath('/docs/12.md', '/docs/renamed.md'); + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .any((r) => r.path == '/docs/renamed.md'), + isTrue, + ); + await controller.removeRecentWorkspace('account-b:offline-local-id'); + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .any((r) => r.accountId != null), + isFalse, + ); + }, + ); test( 'inspector starts closed after loading an old visible preference', () async { @@ -566,7 +1200,7 @@ void main() { .copyWith( wordWrap: false, recentWorkspaces: [ - RecentWorkspace( + RecentResource( path: '/tmp/old.md', kind: WorkspaceKindForTest.singleMarkdown, lastOpenedAt: DateTime(2026), @@ -602,12 +1236,12 @@ void main() { ..value = AppSettings.defaults() .copyWith( recentWorkspaces: [ - RecentWorkspace( + RecentResource( path: '/tmp/docs', kind: WorkspaceKindForTest.singleMarkdown, lastOpenedAt: DateTime(2026, 1, 2), ), - RecentWorkspace( + RecentResource( path: '/tmp/other', kind: WorkspaceKindForTest.singleMarkdown, lastOpenedAt: DateTime(2026, 1, 1), @@ -812,3 +1446,17 @@ class _DelayedSettingsStore implements LocalSettingsStore { } } } + +class _KeymapStore implements LocalSettingsStore { + Map value = {'autoSave': false}; + final writes = >[]; + bool fail = false; + @override + Future> load() async => value; + @override + Future save(Map next) async { + if (fail) throw FileSystemException('test persistence failure'); + writes.add(next); + value = next; + } +} diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 90b210f5..933565b0 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1,3 +1,11 @@ +import 'package:busymark/src/workspace/presentation/welcome_screen.dart'; +import 'package:busymark/src/workspace/presentation/settings_screen.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/keyboard_shortcut_badge.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; +import 'package:busymark/src/app/shortcut_capture.dart'; +import 'package:busymark/src/workspace/presentation/keyboard_shortcuts_settings.dart'; +import 'package:busymark/src/search/search_session.dart'; import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; import 'dart:async'; @@ -18,7 +26,6 @@ import 'package:busymark/src/app/busymark_app.dart'; import 'package:busymark/src/app/command_registry.dart'; import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_design.dart'; -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/linux/linux_header_style.dart'; @@ -41,7 +48,6 @@ 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'; @@ -57,6 +63,7 @@ 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/local_history/local_history_models.dart'; +import 'package:busymark/src/local_history/local_history_comparison_view.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'; @@ -72,7 +79,6 @@ import 'package:busymark/src/writerside/writerside_toc_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.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/document_buffer.dart'; import 'package:busymark/src/workspace/recovery_persistence.dart'; import 'package:busymark/src/workspace/session_persistence.dart'; @@ -115,455 +121,1019 @@ void main() { headerBarService = _FallbackHeaderBarService(); }); - test('editor shortcuts use documented cross-editor defaults', () { - expect( - { - for (final entry in BusyMarkEditorShortcuts.definitions.entries) - entry.key: entry.value.label, - }, - { - BusyMarkEditorShortcutAction.refineWithAi: 'Ctrl+G', - BusyMarkEditorShortcutAction.copyPlainText: 'Ctrl+Shift+C', - BusyMarkEditorShortcutAction.bold: 'Ctrl+B', - BusyMarkEditorShortcutAction.italic: 'Ctrl+I', - BusyMarkEditorShortcutAction.underline: 'Ctrl+U', - BusyMarkEditorShortcutAction.strikethrough: 'Alt+Shift+5', - BusyMarkEditorShortcutAction.inlineCode: 'Ctrl+Shift+`', - BusyMarkEditorShortcutAction.link: 'Ctrl+K', - BusyMarkEditorShortcutAction.paragraph: 'Ctrl+Alt+0', - BusyMarkEditorShortcutAction.heading1: 'Ctrl+Alt+1', - BusyMarkEditorShortcutAction.heading2: 'Ctrl+Alt+2', - BusyMarkEditorShortcutAction.heading3: 'Ctrl+Alt+3', - BusyMarkEditorShortcutAction.heading4: 'Ctrl+Alt+4', - BusyMarkEditorShortcutAction.heading5: 'Ctrl+Alt+5', - BusyMarkEditorShortcutAction.heading6: 'Ctrl+Alt+6', - BusyMarkEditorShortcutAction.orderedList: 'Ctrl+Shift+7', - BusyMarkEditorShortcutAction.unorderedList: 'Ctrl+Shift+8', - BusyMarkEditorShortcutAction.taskList: 'Ctrl+Shift+9', - BusyMarkEditorShortcutAction.indent: 'Ctrl+]', - BusyMarkEditorShortcutAction.outdent: 'Ctrl+[', - BusyMarkEditorShortcutAction.blockquote: 'Ctrl+Shift+Q', - BusyMarkEditorShortcutAction.codeBlock: 'Ctrl+Shift+K', - BusyMarkEditorShortcutAction.image: 'Ctrl+Shift+I', - BusyMarkEditorShortcutAction.thematicBreak: 'Ctrl+Shift+-', - BusyMarkEditorShortcutAction.hardLineBreak: 'Shift+Enter', + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.split, + ]) { + testWidgets( + 'effective Select All and Alt+Enter edit the full document in ${mode.name}', + (tester) async { + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final root = Directory.systemTemp.createTempSync( + 'busymark-effective-source-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + const original = + '## Fold\nfirst hidden\nsecond hidden\n\n## Next\nbefore target after\n'; + final file = File('${root.path}/document.md') + ..writeAsStringSync(original); + final store = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(autoSave: false, documentViewMode: mode) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(store), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(file.path), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _waitForSearchCondition( + tester, + () => find.byType(BusyMarkSourceEditor).evaluate().isNotEmpty, + ); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(BusyMarkSourceEditor)) + .searchSnapshot, + isNotNull, + ); + final source = _searchSourceController(tester); + final field = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && identical(w.controller, source), + ), + ); + final keymap = container.read(keymapControllerProvider.notifier); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + Future press( + LogicalKeyboardKey key, { + bool control = false, + bool alt = false, + bool shift = false, + }) async { + field.focusNode!.requestFocus(); + await tester.pump(); + if (control) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + } + if (alt) await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + if (shift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.sendKeyEvent(key); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + if (alt) await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + if (control) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + await tester.pumpAndSettle(); + } + + expect( + (await keymap.update( + 'text.selectAll', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + await tester.tap( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byTooltip( + l10n.collapseKind(l10n.foldKindSection), + ), + ) + .first, + ); + await tester.pumpAndSettle(); + expect(source.document.hiddenRanges.ranges, isNotEmpty); + await press(LogicalKeyboardKey.f8); + expect( + source.fullSelection, + const TextSelection(baseOffset: 0, extentOffset: original.length), + ); + expect(source.fullText, original); + await keymap.update('text.selectAll', null, expected: current()); + await tester.pumpAndSettle(); + source.fullSelection = const TextSelection.collapsed(offset: 0); + await press(LogicalKeyboardKey.f8); + expect(source.fullSelection, const TextSelection.collapsed(offset: 0)); + await press(LogicalKeyboardKey.keyA, control: true); + expect( + source.fullSelection, + const TextSelection.collapsed(offset: 0), + reason: 'No hidden platform default after unassignment', + ); + await keymap.update( + 'text.selectAll', + null, + reset: true, + expected: current(), + ); + await tester.pumpAndSettle(); + await press(LogicalKeyboardKey.keyA, control: true); + expect( + source.fullSelection, + const TextSelection(baseOffset: 0, extentOffset: original.length), + ); + await tester.tap( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byTooltip(l10n.expandKind(l10n.foldKindSection)), + ) + .first, + ); + await tester.pumpAndSettle(); + expect( + (await keymap.update( + 'editor.bold', + const BusyMarkKeyBinding(LogicalKeyboardKey.enter, alt: true), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + final start = original.indexOf('target'); + source.fullSelection = TextSelection( + baseOffset: start, + extentOffset: start + 6, + ); + await press(LogicalKeyboardKey.enter, alt: true); + final modified = original.replaceFirst('target', '**target**'); + expect(source.fullText, modified); + expect( + container.read(workspaceControllerProvider).activeText, + modified, + ); + await press(LogicalKeyboardKey.keyZ, control: true); + expect( + source.fullText, + original, + reason: 'Exactly one undo restores the complete document', + ); + await press(LogicalKeyboardKey.keyZ, control: true, shift: true); + expect(source.fullText, modified); + // Plain Enter retains its existing input behavior and its own undo unit. + source.fullSelection = TextSelection.collapsed(offset: modified.length); + await press(LogicalKeyboardKey.enter); + expect(source.fullText, '$modified\n'); + await press(LogicalKeyboardKey.keyZ, control: true); + expect(source.fullText, modified); + final workspace = container.read(workspaceControllerProvider.notifier); + bool? saved; + unawaited(workspace.saveActive().then((value) => saved = value)); + await _waitForSearchCondition(tester, () => saved != null); + expect(saved, isTrue); + await tester.pumpAndSettle(); + expect(file.readAsStringSync(), modified); + final id = container.read(workspaceControllerProvider).activeBufferId!; + bool? closed; + unawaited( + workspace.closeDocumentBuffer(id).then((value) => closed = value), + ); + await _waitForSearchCondition(tester, () => closed != null); + expect(closed, isTrue); + unawaited(workspace.openPath(file.path)); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == + modified, + ); + expect(file.readAsStringSync(), modified); + expect( + container.read(workspaceControllerProvider).activeText, + modified, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); }, ); - final pastePlainText = BusyMarkTextEditingShortcuts - .definitions[BusyMarkTextEditingShortcutAction.pastePlainText]!; - expect(pastePlainText.label, 'Ctrl+Shift+V'); - final activator = pastePlainText.activator as SingleActivator; - expect(activator.trigger, LogicalKeyboardKey.keyV); - expect(activator.control, isTrue); - expect(activator.shift, isTrue); - expect( - BusyMarkTextEditingShortcuts.definitions.values.where( - (definition) => definition.activator == activator, - ), - hasLength(1), - ); - }); + } testWidgets( - 'workspace resize retains the sidebar and leaves room for header controls', + 'effective keymap changes Source execution, disables defaults and updates native metadata', (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(tester.view.reset); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - workspaceServiceProvider.overrideWithValue( - _StartupWorkspaceService(), - ), - startupPathProvider.overrideWithValue( - 'test/fixtures/markdown/basic.md', + final (:container, :events) = await _pumpSearchRegression( + tester, + headerBarService, + {'a.md': 'needle other'}, + ); + final saveHandler = container + .read(busyMarkCommandRegistryProvider)['file.save']! + .execute; + final keymap = container.read(keymapControllerProvider.notifier); + Map overrides() => + container.read(appSettingsControllerProvider).shortcutOverrides; + expect( + (await keymap.update( + 'editor.bold', + const BusyMarkKeyBinding( + LogicalKeyboardKey.keyB, + control: true, + alt: true, ), - ], + expected: overrides(), + )).status, + KeymapUpdateStatus.saved, ); - addTearDown(container.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), + expect( + container.read(busyMarkCommandRegistryProvider)['file.save']!.execute, + same(saveHandler), ); - await _pumpUntilFound( - tester, - find.byKey(const ValueKey('workspace-page-frame')), + container.read(searchSessionProvider.notifier).close(restoreFocus: false); + await tester.pumpAndSettle(); + final field = tester.widget( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ) + .first, ); + final source = _searchSourceController(tester); + field.focusNode!.requestFocus(); + source.selection = const TextSelection(baseOffset: 0, extentOffset: 6); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); - final sidebarKey = ValueKey( - container.read(workspaceControllerProvider).workspace!.id, + expect( + container.read(workspaceControllerProvider).activeText, + 'needle other', ); - final sidebarState = tester.state(find.byKey(sidebarKey)); - final generation = container.read(sidebarTransitionGenerationProvider); - final viewport = find.byKey(const ValueKey('linux-sidebar-viewport')); - expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); - - tester.view.physicalSize = const Size(700, 720); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + '**needle** other', + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + 'needle other', + ); + expect( + (await keymap.update( + 'text.selectAll', + null, + expected: overrides(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + source.selection = const TextSelection.collapsed(offset: 2); + field.focusNode!.requestFocus(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); - expect(tester.getSize(viewport).width, 0); expect( - tester.state(find.byKey(sidebarKey, skipOffstage: false)), - same(sidebarState), + source.fullSelection, + const TextSelection.collapsed(offset: 2), + reason: 'No fallthrough to DefaultTextEditingShortcuts', + ); + await keymap.update('text.paste', null, expected: overrides()); + await tester.pumpAndSettle(); + var clipboardReads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + clipboardReads++; + } + if (call.method == 'Clipboard.getData') { + return {'text': 'unexpected paste'}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + field.focusNode!.requestFocus(); + final beforePaste = source.fullText; + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.insert); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect( + source.fullText, + beforePaste, + reason: 'Removing Paste also removes the platform Shift+Insert alias', ); + expect(clipboardReads, 0); expect( - container.read(appSettingsControllerProvider).sidebarVisible, - isTrue, + (await keymap.update( + 'view.source', + null, + expected: overrides(), + )).status, + KeymapUpdateStatus.saved, ); - expect(container.read(sidebarTransitionGenerationProvider), generation); - - tester.view.physicalSize = const Size(1280, 720); + expect( + (await keymap.update( + 'search.find', + const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + expected: overrides(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); - expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); - expect(tester.state(find.byKey(sidebarKey)), same(sidebarState)); - expect(container.read(sidebarTransitionGenerationProvider), generation); - }, - ); - - test('document view shortcuts are distinct from existing commands', () { - expect( - { - for (final entry in BusyMarkDocumentViewShortcuts.definitions.entries) - entry.key: entry.value.label, - }, - { - BusyMarkDocumentViewShortcutAction.editor: 'Ctrl+Shift+1', - BusyMarkDocumentViewShortcutAction.source: 'Ctrl+Shift+2', - BusyMarkDocumentViewShortcutAction.reading: 'Ctrl+Shift+3', - BusyMarkDocumentViewShortcutAction.split: 'Ctrl+Shift+4', - }, - ); - - final existingActivators = { - ...BusyMarkAppShortcuts.definitions.values.map( - (definition) => definition.activator, - ), - ...BusyMarkTextEditingShortcuts.definitions.values.map( - (definition) => definition.activator, - ), - ...BusyMarkEditorShortcuts.definitions.values.map( - (definition) => definition.activator, - ), - ...BusyMarkSidebarShortcuts.definitions.values.map( - (definition) => definition.activator, - ), - ...BusyMarkTreeShortcuts.definitions.values.map( - (definition) => definition.activator, - ), - }; - for (final definition in BusyMarkDocumentViewShortcuts.definitions.values) { - expect(existingActivators, isNot(contains(definition.activator))); - } - }); + expect(container.read(searchSessionProvider).active, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.f12); + await tester.pumpAndSettle(); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + expect(container.read(searchSessionProvider).active, isTrue); + BusyMarkPopupSelectorOption documentOption() => tester + .widget>( + find.byKey(const ValueKey('search-category')), + ) + .options + .singleWhere((o) => o.value == SearchCategory.file); + expect(documentOption().shortcut, 'F12'); + expect(documentOption().gtkAccelerator, 'F12'); + await tester.tap(find.byKey(const ValueKey('search-category'))); + await tester.pumpAndSettle(); + expect(find.text('F12'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(container.read(searchSessionProvider).active, isTrue); + await keymap.update('search.find', null, expected: overrides()); + await tester.pumpAndSettle(); + expect(documentOption().shortcut, isNull); + expect(documentOption().gtkAccelerator, isNull); + final unchangedRequest = container + .read(searchSessionProvider) + .focusRequest; + await tester.sendKeyEvent(LogicalKeyboardKey.f12); + await tester.pump(); + expect( + container.read(searchSessionProvider).focusRequest, + unchangedRequest, + ); + await keymap.update( + 'search.find', + null, + reset: true, + expected: overrides(), + ); + await tester.pumpAndSettle(); + expect(documentOption().shortcut, 'Ctrl+F'); + expect(documentOption().gtkAccelerator, 'f'); + expect( + container + .read(busyMarkCommandRegistryProvider)['view.source']! + .shortcut, + isNull, + ); + await keymap.update( + 'file.newDocument', + const BusyMarkKeyBinding(LogicalKeyboardKey.keyA, control: true), + expected: overrides(), + ); + await tester.pumpAndSettle(); + final queryField = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), + ), + ); + queryField.controller!.text = 'local query'; + queryField.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + queryField.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 11), + ); + expect(find.byType(Dialog), findsNothing); + await keymap.update('file.newDocument', null, expected: overrides()); + expect( + (await keymap.update( + BusyMarkCommandIds.quickOpen, + const BusyMarkKeyBinding(LogicalKeyboardKey.keyA, control: true), + expected: overrides(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + queryField.controller!.selection = const TextSelection.collapsed( + offset: 3, + ); + final request = container.read(searchSessionProvider).focusRequest; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + queryField.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 11), + ); + expect(container.read(searchSessionProvider).focusRequest, request); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + final replacement = tester.widget( + find.byKey(const ValueKey('source-search-replacement')), + ); + await tester.enterText( + find.byKey(const ValueKey('source-search-replacement')), + 'replacement', + ); + replacement.controller!.selection = const TextSelection.collapsed( + offset: 3, + ); + final beforeReplacementInput = container + .read(workspaceControllerProvider) + .activeText; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + replacement.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 11), + ); + expect(container.read(searchSessionProvider).focusRequest, request); + expect( + container.read(workspaceControllerProvider).activeText, + beforeReplacementInput, + ); + expect(tester.takeException(), isNull); + }, + ); testWidgets( - 'workspace routes paste shortcuts only to editable document views', + 'capture releases dispatch even when native key-up skips focus delivery', (tester) async { - var generation = 0; - var clipboardText = ''; - var readCount = 0; - const clipboardChannel = MethodChannel(richClipboardChannelName); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - clipboardChannel, - (call) async { - if (call.method != 'read') return null; - readCount += 1; - return { - 'text': clipboardText, - 'generation': generation, - }; + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'unchanged', + }); + final owner = Object(); + var candidates = 0; + BusyMarkShortcutCapture.begin( + owner, + onCandidate: (_) { + candidates++; + BusyMarkShortcutCapture.end(owner); }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - clipboardChannel, - null, + onCancel: () {}, + ); + addTearDown(() => BusyMarkShortcutCapture.end(owner)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f8); + expect(candidates, 1); + expect(BusyMarkShortcutCapture.suppressing, isTrue); + // Reproduce the native hardware-only release, without a subsequent + // FocusManager key message. The candidate must remain consumed while the + // key is held, then dispatch must become available after release. + HardwareKeyboard.instance.handleKeyEvent( + const KeyUpEvent( + physicalKey: PhysicalKeyboardKey.f8, + logicalKey: LogicalKeyboardKey.f8, + timeStamp: Duration.zero, ), ); + await tester.pump(); + expect(BusyMarkShortcutCapture.suppressing, isFalse); + expect( + harness.container.read(workspaceControllerProvider).activeText, + 'unchanged', + ); + }, + ); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith( - autoSave: false, - documentViewMode: DocumentViewModePreference.editor, + testWidgets( + 'capture suppresses the real workspace search route until key release', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'selected text', + }); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + session.close(restoreFocus: false); + await tester.pumpAndSettle(); + final field = tester.widget( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), ) - .toJson(); - const service = _SearchWorkspaceService('start'); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(service), - startupPathProvider.overrideWithValue('/tmp/workspace-paste.md'), - ], + .first, ); - addTearDown(container.dispose); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 8, + ); + await tester.pump(); + final request = container.read(searchSessionProvider).focusRequest; + final owner = Object(); + final candidates = []; + addTearDown(() async { + for (final key + in HardwareKeyboard.instance.logicalKeysPressed.toList()) { + await tester.sendKeyUpEvent(key); + } + BusyMarkShortcutCapture.end(owner); + }); + BusyMarkShortcutCapture.begin( + owner, + onCandidate: candidates.add, + onCancel: () => BusyMarkShortcutCapture.end(owner), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); + await tester.pump(); + expect(container.read(searchSessionProvider).active, isFalse); + expect(candidates, [ + const BusyMarkKeyBinding(LogicalKeyboardKey.keyF, control: true), + ]); + BusyMarkShortcutCapture.end(owner); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(container.read(searchSessionProvider).focusRequest, request); + expect( + container.read(workspaceControllerProvider).activeText, + 'selected text', + ); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 8), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(container.read(searchSessionProvider).active, isTrue); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + expect(container.read(searchSessionProvider).focusRequest, request + 1); + }, + ); - Future pressPaste(BusyMarkPasteMode mode) async { - final useShift = mode == BusyMarkPasteMode.plainText; + testWidgets( + 'shortcut Settings respects pending property edits before leaving the workspace', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'Original text', + }); + final container = harness.container; + container.read(searchSessionProvider.notifier).close(restoreFocus: false); + await tester.pumpAndSettle(); + final workspace = container.read(workspaceControllerProvider.notifier); + var attempted = 0; + final draft = workspace.propertyEdits.bind( + 'shortcut-settings-guard', + '', + (_) { + attempted++; + return false; + }, + ); + workspace.propertyEdits.edit(draft, 'pending'); + await container + .read(keymapControllerProvider.notifier) + .update( + BusyMarkCommandIds.keyboardShortcuts, + const BusyMarkKeyBinding( + LogicalKeyboardKey.keyK, + control: true, + alt: true, + ), + expected: container + .read(appSettingsControllerProvider) + .shortcutOverrides, + ); + + await tester.pumpAndSettle(); + expect( + container + .read( + busyMarkCommandRegistryProvider, + )[BusyMarkCommandIds.keyboardShortcuts]! + .shortcut! + .label, + 'Ctrl+Alt+K', + ); + Future openSettings() async { await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - if (useShift) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - } - await tester.sendKeyEvent(LogicalKeyboardKey.keyV); - if (useShift) { - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - } + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); } - Future selectView(DocumentViewModePreference mode) async { + await openSettings(); + expect(attempted, 1); + expect(find.byType(KeyboardShortcutsSettings), findsNothing); + expect(workspace.propertyEdits.hasPending, isTrue); + expect( + container.read(workspaceControllerProvider).activeText, + 'Original text', + ); + workspace.propertyEdits.revert(draft); + await openSettings(); + expect(find.byType(KeyboardShortcutsSettings), findsOneWidget); + expect( container - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(mode); - await container - .read(appSettingsControllerProvider.notifier) - .setDocumentViewMode(mode); - for (var index = 0; index < 10; index += 1) { - await tester.pump(const Duration(milliseconds: 100)); - } - } + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .queryParameters['returnTo'], + 'workspace', + ); + }, + ); - Future focusLastEditableTextField() async { - final field = find.byType(TextField).last; - await tester.tap(field); - await tester.showKeyboard(field); - final controller = tester.widget(field).controller!; - controller.selection = TextSelection.collapsed( - offset: controller.text.length, - ); - await tester.pump(); - } + testWidgets( + 'Actions keeps its captured target and selected command when a binding changes', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'selected text', + }); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + session.close(restoreFocus: false); + await tester.pumpAndSettle(); + final field = tester.widget( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ) + .first, + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 8, + ); + await tester.pump(); + session.open(category: SearchCategory.actions); + session.query('Bold'); + await tester.runAsync(session.submit); + await tester.pumpAndSettle(); + final query = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), + ), + ); + query.focusNode!.requestFocus(); + await tester.pump(); + final focus = FocusManager.instance.primaryFocus; + expect(focus, same(query.focusNode)); + final target = session.commandTarget; + await container + .read(keymapControllerProvider.notifier) + .update( + 'editor.bold', + const BusyMarkKeyBinding( + LogicalKeyboardKey.keyB, + control: true, + alt: true, + ), + expected: {}, + ); + await tester.pumpAndSettle(); + expect(session.commandTarget, same(target)); + expect( + target!.isCurrent(container.read(workspaceControllerProvider)), + isTrue, + ); + expect(FocusManager.instance.primaryFocus, same(focus)); + final state = container.read(searchSessionProvider); + expect(state.results[state.selected].identity, 'editor.bold'); + expect( + (state.results[state.selected] as ActionSearchResult).enabled, + isTrue, + ); + expect( + find.byWidgetPredicate( + (w) => w is BusyMarkKeyboardShortcutBadge && w.label == 'Ctrl+Alt+B', + ), + findsOneWidget, + ); + await tester.testTextInput.receiveAction(TextInputAction.search); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == + '**selected** text', + ); + expect( + container.read(workspaceControllerProvider).activeText, + '**selected** text', + ); + }, + ); - Future typeSuffix(String suffix) async { - final field = find.byType(TextField).last; - final controller = tester.widget(field).controller!; - await tester.enterText(field, '${controller.text}$suffix'); - await tester.pump(const Duration(milliseconds: 100)); - } - - Future expectUndoRedoSequence({ - required BusyMarkPasteMode mode, - BusyMarkClipboardPayload? historyPayload, - String pasteText = 'b', - bool replaceTypedCharacter = false, - }) async { - final workspace = container.read(workspaceControllerProvider.notifier); - final original = container.read(workspaceControllerProvider).activeText; - await typeSuffix('a'); - final afterTyping = container - .read(workspaceControllerProvider) - .activeText; - if (replaceTypedCharacter) { - final field = find.byType(TextField).last; - final controller = tester.widget(field).controller!; - controller.selection = TextSelection( - baseOffset: controller.text.length - 1, - extentOffset: controller.text.length, - ); - await tester.pump(); - } - if (historyPayload == null) { - clipboardText = pasteText; - generation += 1; - await pressPaste(mode); - } else { - expect( - await container - .read(clipboardInsertionRegistryProvider) - .paste(historyPayload, mode: mode), - ClipboardPasteResult.inserted, - ); - await tester.pump(const Duration(milliseconds: 100)); - } - final afterPaste = container - .read(workspaceControllerProvider) - .activeText; - await typeSuffix('c'); - final afterFollowingTyping = container - .read(workspaceControllerProvider) - .activeText; - - workspace.undoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - afterPaste, - ); - workspace.undoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - afterTyping, - ); - workspace.undoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - original, - ); - - workspace.redoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - afterTyping, - ); - workspace.redoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - afterPaste, - ); - workspace.redoActiveBuffer(); - await tester.pump(const Duration(milliseconds: 100)); - expect( - container.read(workspaceControllerProvider).activeText, - afterFollowingTyping, - ); - } - - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - for (var index = 0; index < 30; index += 1) { - await tester.pump(const Duration(milliseconds: 100)); - if (find - .byKey(const ValueKey('document-wysiwyg-pane')) - .evaluate() - .isNotEmpty) { - break; + testWidgets('application reassignment receives a retired editor combination', ( + tester, + ) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'Text', + }); + final container = harness.container; + final keymap = container.read(keymapControllerProvider.notifier); + container.read(searchSessionProvider.notifier).close(restoreFocus: false); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + await keymap.update('text.paste', null, expected: current()); + expect( + (await keymap.update( + 'help.keyboardShortcuts', + const BusyMarkKeyBinding(LogicalKeyboardKey.keyV, control: true), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + await tester.pumpAndSettle(); + tester + .widget( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ) + .first, + ) + .focusNode! + .requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.byType(KeyboardShortcutsSettings), findsOneWidget); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .queryParameters['returnTo'], + 'workspace', + ); + expect(container.read(workspaceControllerProvider).activeText, 'Text'); + await keymap.update('help.keyboardShortcuts', null, expected: current()); + expect( + (await keymap.update( + 'notes.sync', + const BusyMarkKeyBinding(LogicalKeyboardKey.keyV, control: true), + expected: current(), + )).status, + KeymapUpdateStatus.saved, + ); + container.read(appRouterProvider).go('/workspace'); + await tester.pumpAndSettle(); + expect( + container.read(busyMarkCommandRegistryProvider)['notes.sync']!.canExecute, + isFalse, + ); + var clipboardReads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + clipboardReads++; + return {'text': 'unexpected fallback'}; } - } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final source = tester.widget( + find + .descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ) + .first, + ); + source.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect( + clipboardReads, + 0, + reason: + 'A disabled replacement command must not fall through to unassigned Paste.', + ); + expect(container.read(workspaceControllerProvider).activeText, 'Text'); + }); - await focusLastEditableTextField(); - await expectUndoRedoSequence(mode: BusyMarkPasteMode.normal); - await focusLastEditableTextField(); - await expectUndoRedoSequence(mode: BusyMarkPasteMode.plainText); - await focusLastEditableTextField(); - await expectUndoRedoSequence( - mode: BusyMarkPasteMode.normal, - historyPayload: BusyMarkClipboardPayload( - id: 'workspace-history-paste', - acquiredAt: DateTime.utc(2026), - kind: BusyMarkClipboardContentKind.text, - text: 'b', - external: true, + testWidgets( + 'effective keymap replaces sidebar keypad aliases and tree deletion keys', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': '# Heading\nText', + 'b.md': 'Other', + }); + final container = harness.container; + final keymap = container.read(keymapControllerProvider.notifier); + Map current() => + container.read(appSettingsControllerProvider).shortcutOverrides; + await keymap.update( + 'sidebar.files', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: current(), + ); + await keymap.update( + 'tree.deleteSelection', + const BusyMarkKeyBinding( + LogicalKeyboardKey.keyD, + control: true, + alt: true, ), + expected: current(), ); - await focusLastEditableTextField(); - await expectUndoRedoSequence( - mode: BusyMarkPasteMode.normal, - replaceTypedCharacter: true, - ); - await focusLastEditableTextField(); - await expectUndoRedoSequence( - mode: BusyMarkPasteMode.normal, - pasteText: 'b\nline', + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(container.read(searchSessionProvider).active, isFalse); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'Outline navigation', ); - await focusLastEditableTextField(); - clipboardText = ' editor-normal'; - generation += 1; - await pressPaste(BusyMarkPasteMode.normal); - clipboardText = ' editor-plain'; - generation += 1; - await pressPaste(BusyMarkPasteMode.plainText); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.numpad1); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); expect( - container.read(workspaceControllerProvider).activeText, - contains('editor-normal editor-plain'), + FocusManager.instance.primaryFocus?.debugLabel, + 'Outline navigation', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.f8); + await tester.pumpAndSettle(); + final tree = find.byWidgetPredicate( + (w) => w is Focus && w.focusNode?.debugLabel == 'BusyMark files tree', ); - - await selectView(DocumentViewModePreference.source); - expect(find.byType(BusyMarkSourceEditor), findsOneWidget); - await focusLastEditableTextField(); - await expectUndoRedoSequence(mode: BusyMarkPasteMode.normal); - await focusLastEditableTextField(); - await expectUndoRedoSequence(mode: BusyMarkPasteMode.plainText); - await focusLastEditableTextField(); - clipboardText = ' source-normal'; - generation += 1; - await pressPaste(BusyMarkPasteMode.normal); - clipboardText = ' source-plain'; - generation += 1; - await pressPaste(BusyMarkPasteMode.plainText); expect( - container.read(workspaceControllerProvider).activeText, - contains('source-normal source-plain'), + tree, + findsOneWidget, + reason: 'The reassigned navigation key reveals Files', ); + tester.widget(tree).focusNode!.requestFocus(); + await tester.pump(); - await selectView(DocumentViewModePreference.split); - expect(find.byType(BusyMarkSourceEditor), findsOneWidget); - await focusLastEditableTextField(); - clipboardText = ' split-source'; - generation += 1; - await pressPaste(BusyMarkPasteMode.plainText); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsOneWidget); + expect(find.text(l10n.confirmDeleteFileTitle), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); expect( - container.read(workspaceControllerProvider).activeText, - endsWith(' split-source'), + container.read(workspaceControllerProvider).workspace!.files, + hasLength(2), ); - - await selectView(DocumentViewModePreference.preview); - expect(find.byType(BusyMarkSourceEditor), findsNothing); - expect(container.read(clipboardInsertionRegistryProvider).target, isNull); - final readingText = container - .read(workspaceControllerProvider) - .activeText; - final readingReadCount = readCount; - clipboardText = ' must-not-insert'; - generation += 1; - await pressPaste(BusyMarkPasteMode.normal); - await pressPaste(BusyMarkPasteMode.plainText); expect( container.read(workspaceControllerProvider).activeText, - readingText, + '# Heading\nText', ); - expect(readCount, readingReadCount); + expect(tester.takeException(), isNull); }, ); - - testWidgets( - 'workspace source history restores a pasted URL suffix through undo and redo', - (tester) async { - const url = 'https://example.com'; - const text = '$url*'; - const clipboardChannel = MethodChannel(richClipboardChannelName); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - clipboardChannel, - (call) async => call.method == 'read' - ? {'text': text, 'generation': 1} - : null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - clipboardChannel, - null, - ), - ); - final settings = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith( - autoSave: false, - documentViewMode: DocumentViewModePreference.editor, - ) - .toJson(); + test('editor shortcuts use documented cross-editor defaults', () { + expect( + { + for (final entry in BusyMarkEditorShortcuts.definitions.entries) + entry.key: entry.value.label, + }, + { + BusyMarkEditorShortcutAction.refineWithAi: 'Ctrl+G', + BusyMarkEditorShortcutAction.copyPlainText: 'Ctrl+Shift+C', + BusyMarkEditorShortcutAction.bold: 'Ctrl+B', + BusyMarkEditorShortcutAction.italic: 'Ctrl+I', + BusyMarkEditorShortcutAction.underline: 'Ctrl+U', + BusyMarkEditorShortcutAction.strikethrough: 'Alt+Shift+5', + BusyMarkEditorShortcutAction.inlineCode: 'Ctrl+Shift+`', + BusyMarkEditorShortcutAction.link: 'Ctrl+K', + BusyMarkEditorShortcutAction.paragraph: 'Ctrl+Alt+0', + BusyMarkEditorShortcutAction.heading1: 'Ctrl+Alt+1', + BusyMarkEditorShortcutAction.heading2: 'Ctrl+Alt+2', + BusyMarkEditorShortcutAction.heading3: 'Ctrl+Alt+3', + BusyMarkEditorShortcutAction.heading4: 'Ctrl+Alt+4', + BusyMarkEditorShortcutAction.heading5: 'Ctrl+Alt+5', + BusyMarkEditorShortcutAction.heading6: 'Ctrl+Alt+6', + BusyMarkEditorShortcutAction.orderedList: 'Ctrl+Shift+7', + BusyMarkEditorShortcutAction.unorderedList: 'Ctrl+Shift+8', + BusyMarkEditorShortcutAction.taskList: 'Ctrl+Shift+9', + BusyMarkEditorShortcutAction.indent: 'Ctrl+]', + BusyMarkEditorShortcutAction.outdent: 'Ctrl+[', + BusyMarkEditorShortcutAction.blockquote: 'Ctrl+Shift+Q', + BusyMarkEditorShortcutAction.codeBlock: 'Ctrl+Shift+K', + BusyMarkEditorShortcutAction.image: 'Ctrl+Shift+I', + BusyMarkEditorShortcutAction.thematicBreak: 'Ctrl+Shift+-', + BusyMarkEditorShortcutAction.hardLineBreak: 'Shift+Enter', + }, + ); + final pastePlainText = BusyMarkTextEditingShortcuts + .definitions[BusyMarkTextEditingShortcutAction.pastePlainText]!; + expect(pastePlainText.label, 'Ctrl+Shift+V'); + final activator = pastePlainText.activator as SingleActivator; + expect(activator.trigger, LogicalKeyboardKey.keyV); + expect(activator.control, isTrue); + expect(activator.shift, isTrue); + expect( + BusyMarkTextEditingShortcuts.definitions.values.where( + (definition) => definition.activator == activator, + ), + hasLength(1), + ); + }); + + testWidgets( + 'workspace resize retains the sidebar and leaves room for header controls', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.reset); final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settings), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue( - const _SearchWorkspaceService(''), + _StartupWorkspaceService(), + ), + startupPathProvider.overrideWithValue( + 'test/fixtures/markdown/basic.md', ), - startupPathProvider.overrideWithValue('/tmp/workspace-url-undo.md'), ], ); addTearDown(container.dispose); @@ -573,700 +1143,489 @@ void main() { child: const BusyMarkApp(), ), ); - for (var index = 0; index < 30; index++) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty) break; - } - Future shortcut( - LogicalKeyboardKey key, { - bool shift = false, - }) async { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(key); - if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(const Duration(milliseconds: 100)); - } - - BusyDocument live() => tester - .widget(find.byType(BusyMarkWysiwygEditor)) - .document; - TextField first() => tester.widget( - find - .descendant( - of: find.byType(BusyMarkWysiwygEditor), - matching: find.byType(TextField), - ) - .first, + await _pumpUntilFound( + tester, + find.byKey(const ValueKey('workspace-page-frame')), ); - String source() => container.read(workspaceControllerProvider).activeText; - void check({required bool recognized}) { - expect(first().controller!.text, text); - expect(first().controller!.text, isNot(contains(r'\'))); - expect(live().blocks.first.plainText, text); - final ranges = busyInlineStyleRanges(live().blocks.first.inlines); - if (recognized) { - final link = ranges.single; - expect( - (link.kind, link.start, link.end, link.destination), - (BusyInlineKind.link, 0, url.length, url), - ); - expect(link.attributes[busyMarkBareUrlAttribute], 'true'); - } else { - expect(ranges, isEmpty); - } - } + await tester.pumpAndSettle(); + final sidebarKey = ValueKey( + container.read(workspaceControllerProvider).workspace!.id, + ); + final sidebarState = tester.state(find.byKey(sidebarKey)); + final generation = container.read(sidebarTransitionGenerationProvider); + final viewport = find.byKey(const ValueKey('linux-sidebar-viewport')); + expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); - first().focusNode!.requestFocus(); - first().controller!.selection = const TextSelection.collapsed(offset: 0); + tester.view.physicalSize = const Size(700, 720); await tester.pump(); - await shortcut(LogicalKeyboardKey.keyV); - check(recognized: false); - final beforeEnter = source(); - expect(beforeEnter, r'https://example.com\*'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(const Duration(milliseconds: 100)); - check(recognized: true); - final afterEnter = source(); - // This workspace fixture has no final newline, so the buffer strips - // trailing document separators while retaining the live new paragraph. - expect(afterEnter, text); - final fields = tester - .widgetList( - find.descendant( - of: find.byType(BusyMarkWysiwygEditor), - matching: find.byType(TextField), - ), - ) - .toList(); - expect(fields.last.focusNode!.hasFocus, isTrue); + expect(tester.getSize(viewport).width, 0); expect( - fields.last.controller!.selection, - const TextSelection.collapsed(offset: 0), + tester.state(find.byKey(sidebarKey, skipOffstage: false)), + same(sidebarState), ); expect( - tester - .state( - find.byType(BusyMarkWysiwygEditor), - ) - .debugUndoSnapshotCount, - 0, + container.read(appSettingsControllerProvider).sidebarVisible, + isTrue, ); - await shortcut(LogicalKeyboardKey.keyZ); - expect(source(), beforeEnter); - check(recognized: false); - await shortcut(LogicalKeyboardKey.keyZ, shift: true); - expect(source(), afterEnter); - check(recognized: true); - await shortcut(LogicalKeyboardKey.keyZ); - check(recognized: false); - await shortcut(LogicalKeyboardKey.keyZ); - expect(first().controller!.text, ''); - await shortcut(LogicalKeyboardKey.keyZ, shift: true); - expect(source(), beforeEnter); - check(recognized: false); - await shortcut(LogicalKeyboardKey.keyZ, shift: true); - expect(source(), afterEnter); - check(recognized: true); + expect(container.read(sidebarTransitionGenerationProvider), generation); + + tester.view.physicalSize = const Size(1280, 720); + await tester.pump(); + expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); + expect(tester.state(find.byKey(sidebarKey)), same(sidebarState)); + expect(container.read(sidebarTransitionGenerationProvider), generation); }, ); - testWidgets('app wires generated localization delegates and locales', ( - tester, - ) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); - - final app = tester.widget(find.byType(MaterialApp)); - expect(app.localizationsDelegates, contains(AppLocalizations.delegate)); - expect(app.supportedLocales, AppLocalizations.supportedLocales); - expect(app.supportedLocales, contains(const Locale('de'))); - expect(app.supportedLocales, contains(const Locale('et'))); - expect(app.supportedLocales, contains(const Locale('it'))); - expect(app.supportedLocales, contains(const Locale('nb'))); - expect(app.supportedLocales, isNot(contains(const Locale('no')))); - expect(app.supportedLocales, contains(const Locale('fr'))); - expect(app.supportedLocales, contains(const Locale('ru'))); - expect(app.supportedLocales, contains(const Locale('uk'))); - expect(app.supportedLocales, contains(const Locale('pl'))); - expect(app.supportedLocales, contains(const Locale('es'))); - expect( - app.supportedLocales, - contains(Locale.fromSubtags(languageCode: 'pt', countryCode: 'BR')), - ); - expect(app.supportedLocales, contains(const Locale('ar'))); - expect(app.supportedLocales, contains(const Locale('fa'))); - expect(app.supportedLocales, contains(const Locale('hi'))); - expect(app.supportedLocales, contains(const Locale('id'))); - expect(app.supportedLocales, contains(const Locale('ja'))); - expect(app.supportedLocales, contains(const Locale('ko'))); - expect(app.supportedLocales, contains(const Locale('nl'))); - expect(app.supportedLocales, contains(const Locale('tr'))); - expect(app.supportedLocales, contains(const Locale('vi'))); + test('document view shortcuts are distinct from existing commands', () { expect( - app.supportedLocales, - contains(const Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN')), + { + for (final entry in BusyMarkDocumentViewShortcuts.definitions.entries) + entry.key: entry.value.label, + }, + { + BusyMarkDocumentViewShortcutAction.editor: 'Ctrl+Shift+1', + BusyMarkDocumentViewShortcutAction.source: 'Ctrl+Shift+2', + BusyMarkDocumentViewShortcutAction.reading: 'Ctrl+Shift+3', + BusyMarkDocumentViewShortcutAction.split: 'Ctrl+Shift+4', + }, ); - expect(app.onGenerateTitle, isNotNull); - expect(find.text(l10n.appTitle), findsWidgets); - }); - testWidgets('app boots to BusyMark welcome screen without login UI', ( - tester, - ) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - ], - child: const BusyMarkApp(), + final existingActivators = { + ...BusyMarkAppShortcuts.definitions.values.map( + (definition) => definition.activator, ), - ); - await tester.pumpAndSettle(); - - expect(find.text(l10n.appTitle), findsWidgets); - expect(find.text(l10n.createMarkdownFile), findsOneWidget); - expect(find.text(l10n.createWritersideProject), findsOneWidget); - expect(find.text(l10n.openMarkdownFile), findsOneWidget); - expect(find.text(l10n.markdownFolderOrWritersideProject), findsOneWidget); - expect(find.text(l10n.nextcloudNotes), findsOneWidget); - expect( - tester.getTopLeft(find.text(l10n.markdownFolderOrWritersideProject)).dy, - lessThan(tester.getTopLeft(find.text(l10n.nextcloudNotes)).dy), - ); - expect(find.text('File or folder path'), findsNothing); - expect(find.textContaining('sign in'), findsNothing); - expect( - find.byTooltip( - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + ...BusyMarkTextEditingShortcuts.definitions.values.map( + (definition) => definition.activator, ), - findsOneWidget, - ); - _expectNeutralSidebarToggle( - tester, - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', - selected: true, - ); - _expectHeaderGeometry(tester); + ...BusyMarkEditorShortcuts.definitions.values.map( + (definition) => definition.activator, + ), + ...BusyMarkSidebarShortcuts.definitions.values.map( + (definition) => definition.activator, + ), + ...BusyMarkTreeShortcuts.definitions.values.map( + (definition) => definition.activator, + ), + }; + for (final definition in BusyMarkDocumentViewShortcuts.definitions.values) { + expect(existingActivators, isNot(contains(definition.activator))); + } + }); - await tester.sendKeyEvent(LogicalKeyboardKey.f9); - await tester.pump(const Duration(milliseconds: 100)); + testWidgets( + 'workspace routes paste shortcuts only to editable document views', + (tester) async { + var generation = 0; + var clipboardText = ''; + var readCount = 0; + const clipboardChannel = MethodChannel(richClipboardChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + (call) async { + if (call.method != 'read') return null; + readCount += 1; + return { + 'text': clipboardText, + 'generation': generation, + }; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + null, + ), + ); - expect( - find.byTooltip( - '${l10n.showSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', - ), - findsOneWidget, - ); - _expectNeutralSidebarToggle( - tester, - '${l10n.showSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', - selected: false, - ); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + const service = _SearchWorkspaceService('start'); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/workspace-paste.md'), + ], + ); + addTearDown(container.dispose); - await tester.sendKeyEvent(LogicalKeyboardKey.f9); - await tester.pump(const Duration(milliseconds: 100)); + Future pressPaste(BusyMarkPasteMode mode) async { + final useShift = mode == BusyMarkPasteMode.plainText; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (useShift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + if (useShift) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + } - expect( - find.byTooltip( - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', - ), - findsOneWidget, - ); - _expectNeutralSidebarToggle( - tester, - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', - selected: true, - ); - }); + Future selectView(DocumentViewModePreference mode) async { + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + for (var index = 0; index < 10; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } + } - for (final readErrors in [0, 1]) { - testWidgets( - 'recovery shows one actionable notice and preserves damage warnings ($readErrors)', - (tester) async { - final recoveryStore = MemoryDocumentRecoveryStore() - ..value = RecoverySnapshot( - cleanShutdown: false, - readErrors: readErrors, - entries: [ - DocumentRecoveryEntry.fromBuffer( - DocumentBuffer.untitled( - id: 'recovered-draft', - name: 'Recovered draft', - text: '# Recovered draft\n', - ), - workspacePath: null, - ), - ], - ); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue( - _MemorySettingsStore(), - ), - localHistoryStoreProvider.overrideWithValue( - MemoryLocalHistoryStore(), - ), - documentSessionStoreProvider.overrideWithValue( - MemoryDocumentSessionStore(), - ), - documentRecoveryStoreProvider.overrideWithValue(recoveryStore), - startupPathProvider.overrideWithValue(null), - ], - ); - addTearDown(container.dispose); - await tester.runAsync(() async { - await container - .read(appSettingsControllerProvider.notifier) - .waitUntilLoaded(); - expect( - await container - .read(workspaceControllerProvider.notifier) - .restorePreviousSession(), - isTrue, - ); - }); - container.read(appRouterProvider).go('/workspace'); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), + Future focusLastEditableTextField() async { + final field = find.byType(TextField).last; + await tester.tap(field); + await tester.showKeyboard(field); + final controller = tester.widget(field).controller!; + controller.selection = TextSelection.collapsed( + offset: controller.text.length, ); - await tester.pumpAndSettle(); + await tester.pump(); + } - final buffer = container + Future typeSuffix(String suffix) async { + final field = find.byType(TextField).last; + final controller = tester.widget(field).controller!; + await tester.enterText(field, '${controller.text}$suffix'); + await tester.pump(const Duration(milliseconds: 100)); + } + + Future expectUndoRedoSequence({ + required BusyMarkPasteMode mode, + BusyMarkClipboardPayload? historyPayload, + String pasteText = 'b', + bool replaceTypedCharacter = false, + }) async { + final workspace = container.read(workspaceControllerProvider.notifier); + final original = container.read(workspaceControllerProvider).activeText; + await typeSuffix('a'); + final afterTyping = container .read(workspaceControllerProvider) - .activeBuffer!; - expect(buffer.recovered, isTrue); - expect(buffer.text, '# Recovered draft\n'); - expect(find.text(l10n.workspaceRecoveryRestored(1)), findsNothing); - final review = find.text( - l10n.recoveredDocumentReview(buffer.displayName), - ); - expect(review, findsOneWidget); - final actions = find - .ancestor(of: review, matching: find.byType(Row)) - .first; - for (final label in [l10n.save, l10n.saveAs, l10n.discard]) { - final button = find.ancestor( - of: find.descendant(of: actions, matching: find.text(label)), - matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton, - ), + .activeText; + if (replaceTypedCharacter) { + final field = find.byType(TextField).last; + final controller = tester.widget(field).controller!; + controller.selection = TextSelection( + baseOffset: controller.text.length - 1, + extentOffset: controller.text.length, ); - expect(button, findsOneWidget); - expect(tester.widget(button).onPressed, isNotNull); + await tester.pump(); + } + if (historyPayload == null) { + clipboardText = pasteText; + generation += 1; + await pressPaste(mode); + } else { + expect( + await container + .read(clipboardInsertionRegistryProvider) + .paste(historyPayload, mode: mode), + ClipboardPasteResult.inserted, + ); + await tester.pump(const Duration(milliseconds: 100)); } + final afterPaste = container + .read(workspaceControllerProvider) + .activeText; + await typeSuffix('c'); + final afterFollowingTyping = container + .read(workspaceControllerProvider) + .activeText; + + workspace.undoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); expect( - find.text(l10n.workspaceRecoveryDamaged(1)), - readErrors > 0 ? findsOneWidget : findsNothing, - ); - }, - ); - } - - for (final closeTabFirst in [false, true]) { - testWidgets( - 'Welcome creates a fresh Markdown workspace after leaving basic.md (close tab: $closeTabFirst)', - (tester) async { - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue( - _MemorySettingsStore(), - ), - startupPathProvider.overrideWithValue(null), - ], - ); - addTearDown(container.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), + container.read(workspaceControllerProvider).activeText, + afterPaste, ); - await tester.pumpAndSettle(); - final controller = container.read(workspaceControllerProvider.notifier); - await tester.runAsync( - () => controller.openPath('test/fixtures/markdown/basic.md'), + workspace.undoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + container.read(workspaceControllerProvider).activeText, + afterTyping, ); - container.read(appRouterProvider).go('/workspace'); - await tester.pumpAndSettle(); - final oldId = container - .read(workspaceControllerProvider) - .activeBufferId!; - if (closeTabFirst) { - await tester.runAsync(() => controller.closeDocumentBuffer(oldId)); - await tester.pumpAndSettle(); - } - await tester.tap( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + workspace.undoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + container.read(workspaceControllerProvider).activeText, + original, ); - await tester.pumpAndSettle(); - expect(find.text(l10n.createMarkdownFile), findsOneWidget); - await tester.tap(find.text(l10n.createMarkdownFile)); - await tester.pumpAndSettle(); - final state = container.read(workspaceControllerProvider); - expect(state.documentBuffers, hasLength(1)); - expect(state.activeBuffer!.filePath, isNull); - expect(state.activeBufferId, isNot(oldId)); - expect(state.workspace!.kind, WorkspaceKind.untitledMarkdown); - expect(state.workspace!.openFilePaths, isEmpty); - }, - ); - } - - testWidgets('main menu and F11 toggle full-screen mode', (tester) async { - final nativeWindow = _FakeNativeWindowController(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - nativeWindowControllerProvider.overrideWithValue(nativeWindow), - ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); - - Finder fullScreenMenuItem() => find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.fullScreen, - ); - - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - expect(find.text(l10n.fullScreen), findsOneWidget); - expect(find.text(BusyMarkAppShortcutLabels.fullScreen), findsOneWidget); - final exportMenuItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.export, - ); - expect(exportMenuItem, findsOneWidget); - expect( - tester.widget>(exportMenuItem).enabled, - isFalse, - ); - expect(find.text(BusyMarkAppShortcutLabels.export), findsOneWidget); - for (final documentCommand in { - l10n.clipboardHistory, - l10n.localHistory, - l10n.findLocalHistoryEllipsis, - }) { - expect(find.text(documentCommand), findsNothing); - } - expect( - tester - .widget>(fullScreenMenuItem()) - .checked, - isFalse, - ); - - await tester.tap(find.text(l10n.fullScreen)); - await tester.pumpAndSettle(); - expect(nativeWindow.fullScreenValues, [true]); - - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - expect( - tester - .widget>(fullScreenMenuItem()) - .checked, - isTrue, - ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - await tester.sendKeyEvent(LogicalKeyboardKey.f11); - await tester.pumpAndSettle(); - expect(nativeWindow.fullScreenValues, [true, false]); - }); - - testWidgets('Alt+Left activates the header Back command', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settings)); - await tester.pumpAndSettle(); - expect( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), - findsOneWidget, - ); - final backButton = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is BusyMarkLinuxHeaderIconButton && - widget.tooltip == l10n.back, - ), - ); - expect(backButton.nativeIcon, BusyMarkLinuxHeaderIcon.back); - expect(backButton.icon, BusyMarkGlyphs.headerBackFor(TextDirection.ltr)); - _expectHeaderGeometry(tester); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - await tester.pumpAndSettle(); - - expect(find.text(l10n.createMarkdownFile), findsOneWidget); - expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); - }); - testWidgets( - 'restored capture failure stays owned across Back create discard create', - (tester) async { - final root = (await tester.runAsync( - () => Directory.systemTemp.createTemp('busymark-history-navigation-'), - ))!; - addTearDown(() => root.delete(recursive: true)); - final path = p.join(root.path, 'Restored.md'); - await tester.runAsync( - () => File(path).writeAsString('original restored source'), - ); - final store = _NavigationHistoryStore(); - final sessions = MemoryDocumentSessionStore() - ..value = WorkspaceSessionSnapshot( - workspacePath: root.path, - tabs: [ - DocumentSessionEntry( - id: 'restored', - filePath: path, - untitledName: null, - editorState: const DocumentEditorState(), - ), - ], - activeBufferId: 'restored', + workspace.redoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + container.read(workspaceControllerProvider).activeText, + afterTyping, ); - final settings = _MemorySettingsStore() - ..value = AppSettings.defaults().copyWith(autoSave: false).toJson(); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settings), - localHistoryStoreProvider.overrideWithValue(store), - localHistoryTimerFactoryProvider.overrideWithValue( - (delay, callback) => _HistoryTestTimer(delay, callback), - ), - documentSessionStoreProvider.overrideWithValue(sessions), - documentRecoveryStoreProvider.overrideWithValue( - MemoryDocumentRecoveryStore(), - ), - startupPathProvider.overrideWithValue(null), - ], - ); - addTearDown(container.dispose); - await tester.runAsync(() async { - await container - .read(appSettingsControllerProvider.notifier) - .waitUntilLoaded(); + workspace.redoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); expect( - await container - .read(workspaceControllerProvider.notifier) - .restorePreviousSession(), - isTrue, + container.read(workspaceControllerProvider).activeText, + afterPaste, ); - await container - .read(localHistoryControllerProvider.notifier) - .flushAll( - container.read(workspaceControllerProvider).documentBuffers, - ); - }); - final history = container.read(localHistoryControllerProvider.notifier); - final originalId = container - .read(workspaceControllerProvider) - .activeBuffer! - .id; - expect( - history.warningForBuffer(originalId)?.kind, - LocalHistoryWarningKind.capture, - ); - container.read(appRouterProvider).go('/workspace'); + workspace.redoActiveBuffer(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + container.read(workspaceControllerProvider).activeText, + afterFollowingTyping, + ); + } + await tester.pumpWidget( UncontrolledProviderScope( container: container, child: const BusyMarkApp(), ), ); - await tester.pumpAndSettle(); - expect(find.textContaining('could not capture'), findsOneWidget); - Future back() async { - await container - .read(busyMarkCommandRegistryProvider) - .execute(BusyMarkCommandIds.back); - await tester.pumpAndSettle(); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find + .byKey(const ValueKey('document-wysiwyg-pane')) + .evaluate() + .isNotEmpty) { + break; + } } - await back(); - expect(find.text(l10n.createMarkdownFile), findsOneWidget); - await tester.tap(find.text(l10n.createMarkdownFile)); - await _pumpUntilCondition( - tester, - () => - container - .read(appRouterProvider) - .routeInformationProvider - .value - .uri - .path == - '/workspace', + await focusLastEditableTextField(); + await expectUndoRedoSequence(mode: BusyMarkPasteMode.normal); + await focusLastEditableTextField(); + await expectUndoRedoSequence(mode: BusyMarkPasteMode.plainText); + await focusLastEditableTextField(); + await expectUndoRedoSequence( + mode: BusyMarkPasteMode.normal, + historyPayload: BusyMarkClipboardPayload( + id: 'workspace-history-paste', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.text, + text: 'b', + external: true, + ), ); - await tester.pumpAndSettle(); - final firstDraft = container - .read(workspaceControllerProvider) - .activeBuffer!; - expect(firstDraft.id, isNot(originalId)); - expect(history.warningForBuffer(firstDraft.id), isNull); - expect(find.textContaining('could not capture'), findsNothing); - await back(); - expect(find.text(l10n.discard), findsOneWidget); - await tester.tap(find.text(l10n.discard)); - await tester.pumpAndSettle(); - expect(find.text(l10n.createMarkdownFile), findsOneWidget); - await tester.tap(find.text(l10n.createMarkdownFile)); - await tester.pumpAndSettle(); - await _pumpUntilCondition( - tester, - () => - container - .read(appRouterProvider) - .routeInformationProvider - .value - .uri - .path == - '/workspace', + await focusLastEditableTextField(); + await expectUndoRedoSequence( + mode: BusyMarkPasteMode.normal, + replaceTypedCharacter: true, + ); + await focusLastEditableTextField(); + await expectUndoRedoSequence( + mode: BusyMarkPasteMode.normal, + pasteText: 'b\nline', ); + await focusLastEditableTextField(); + clipboardText = ' editor-normal'; + generation += 1; + await pressPaste(BusyMarkPasteMode.normal); + clipboardText = ' editor-plain'; + generation += 1; + await pressPaste(BusyMarkPasteMode.plainText); expect( - history.warningForBuffer( - container.read(workspaceControllerProvider).activeBuffer!.id, - ), - isNull, + container.read(workspaceControllerProvider).activeText, + contains('editor-normal editor-plain'), ); - expect(find.textContaining('could not capture'), findsNothing); - expect(store.protectiveAttempts, 0); + + await selectView(DocumentViewModePreference.source); + expect(find.byType(BusyMarkSourceEditor), findsOneWidget); + await focusLastEditableTextField(); + await expectUndoRedoSequence(mode: BusyMarkPasteMode.normal); + await focusLastEditableTextField(); + await expectUndoRedoSequence(mode: BusyMarkPasteMode.plainText); + await focusLastEditableTextField(); + clipboardText = ' source-normal'; + generation += 1; + await pressPaste(BusyMarkPasteMode.normal); + clipboardText = ' source-plain'; + generation += 1; + await pressPaste(BusyMarkPasteMode.plainText); expect( - container.read(localHistoryControllerProvider).warning?.ownerBufferId, - originalId, + container.read(workspaceControllerProvider).activeText, + contains('source-normal source-plain'), ); - store.available = true; - // Drain UI-zone persistence before runAsync waits on a Local History - // retry that durably chains another session write behind it. - var persistenceDrained = false; - final persistence = container - .read(workspaceControllerProvider.notifier) - .flushPersistence() - .whenComplete(() => persistenceDrained = true); - await _pumpUntilCondition(tester, () => persistenceDrained); - await persistence; - var historyFlushed = false; - var historySettled = false; - final flush = history - .flushAll(container.read(workspaceControllerProvider).documentBuffers) - .then((settled) { - historySettled = settled; - historyFlushed = true; - }); - await _pumpUntilCondition(tester, () => historyFlushed); - await flush; - expect(historySettled, isTrue); - final snapshot = await store.load(); - expect(snapshot.revisions, hasLength(1)); + + await selectView(DocumentViewModePreference.split); + expect(find.byType(BusyMarkSourceEditor), findsOneWidget); + await focusLastEditableTextField(); + clipboardText = ' split-source'; + generation += 1; + await pressPaste(BusyMarkPasteMode.plainText); expect( - (await store.readRevision(snapshot.revisions.single.id))!.source, - 'original restored source', + container.read(workspaceControllerProvider).activeText, + endsWith(' split-source'), ); - expect(container.read(localHistoryControllerProvider).warning, isNull); - expect(tester.takeException(), isNull); + + await selectView(DocumentViewModePreference.preview); + expect(find.byType(BusyMarkSourceEditor), findsNothing); + expect(container.read(clipboardInsertionRegistryProvider).target, isNull); + final readingText = container + .read(workspaceControllerProvider) + .activeText; + final readingReadCount = readCount; + clipboardText = ' must-not-insert'; + generation += 1; + await pressPaste(BusyMarkPasteMode.normal); + await pressPaste(BusyMarkPasteMode.plainText); + expect( + container.read(workspaceControllerProvider).activeText, + readingText, + ); + expect(readCount, readingReadCount); }, ); - testWidgets('Escape closes header popup menus', (tester) async { - await tester.pumpWidget( - ProviderScope( + testWidgets( + 'workspace source history restores a pasted URL suffix through undo and redo', + (tester) async { + const url = 'https://example.com'; + const text = '$url*'; + const clipboardChannel = MethodChannel(richClipboardChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + (call) async => call.method == 'read' + ? {'text': text, 'generation': 1} + : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + null, + ), + ); + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settings), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService(''), + ), + startupPathProvider.overrideWithValue('/tmp/workspace-url-undo.md'), ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 30; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty) break; + } + Future shortcut( + LogicalKeyboardKey key, { + bool shift = false, + }) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(key); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + } - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); + BusyDocument live() => tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document; + TextField first() => tester.widget( + find + .descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byType(TextField), + ) + .first, + ); + String source() => container.read(workspaceControllerProvider).activeText; + void check({required bool recognized}) { + expect(first().controller!.text, text); + expect(first().controller!.text, isNot(contains(r'\'))); + expect(live().blocks.first.plainText, text); + final ranges = busyInlineStyleRanges(live().blocks.first.inlines); + if (recognized) { + final link = ranges.single; + expect( + (link.kind, link.start, link.end, link.destination), + (BusyInlineKind.link, 0, url.length, url), + ); + expect(link.attributes[busyMarkBareUrlAttribute], 'true'); + } else { + expect(ranges, isEmpty); + } + } - expect(find.text(l10n.settings), findsOneWidget); - expect(find.text(BusyMarkAppShortcutLabels.settings), findsOneWidget); - expect( - find.byTooltip( - '${l10n.settings} (${BusyMarkAppShortcutLabels.settings})', - ), - findsNothing, - ); - expect(find.text(l10n.keyboardShortcuts), findsOneWidget); - expect( - find.text(BusyMarkAppShortcutLabels.keyboardShortcuts), - findsOneWidget, - ); - expect( - find.byTooltip( - '${l10n.keyboardShortcuts} ' - '(${BusyMarkAppShortcutLabels.keyboardShortcuts})', - ), - findsNothing, - ); - expect(find.text(l10n.syntaxReference), findsOneWidget); - expect( - find.text(BusyMarkAppShortcutLabels.syntaxReference), - findsOneWidget, - ); - expect( - find.byTooltip( - '${l10n.syntaxReference} ' - '(${BusyMarkAppShortcutLabels.syntaxReference})', - ), - findsNothing, - ); - expect(find.text(l10n.reportIssue), findsOneWidget); - expect(find.text(l10n.aboutBusyMark), findsOneWidget); - expect( - tester.getTopLeft(find.text(l10n.reportIssue)).dy, - lessThan(tester.getTopLeft(find.text(l10n.aboutBusyMark)).dy), - ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.text(l10n.settings), findsNothing); - }); + first().focusNode!.requestFocus(); + first().controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await shortcut(LogicalKeyboardKey.keyV); + check(recognized: false); + final beforeEnter = source(); + expect(beforeEnter, r'https://example.com\*'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 100)); + check(recognized: true); + final afterEnter = source(); + // This workspace fixture has no final newline, so the buffer strips + // trailing document separators while retaining the live new paragraph. + expect(afterEnter, text); + final fields = tester + .widgetList( + find.descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byType(TextField), + ), + ) + .toList(); + expect(fields.last.focusNode!.hasFocus, isTrue); + expect( + fields.last.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + expect( + tester + .state( + find.byType(BusyMarkWysiwygEditor), + ) + .debugUndoSnapshotCount, + 0, + ); + await shortcut(LogicalKeyboardKey.keyZ); + expect(source(), beforeEnter); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), afterEnter); + check(recognized: true); + await shortcut(LogicalKeyboardKey.keyZ); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ); + expect(first().controller!.text, ''); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), beforeEnter); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), afterEnter); + check(recognized: true); + }, + ); - testWidgets('report issue is in the shared main menu, not Settings content', ( + testWidgets('app wires generated localization delegates and locales', ( tester, ) async { await tester.pumpWidget( @@ -1280,36 +1639,43 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settings)); - await tester.pumpAndSettle(); - + final app = tester.widget(find.byType(MaterialApp)); + expect(app.localizationsDelegates, contains(AppLocalizations.delegate)); + expect(app.supportedLocales, AppLocalizations.supportedLocales); + expect(app.supportedLocales, contains(const Locale('de'))); + expect(app.supportedLocales, contains(const Locale('et'))); + expect(app.supportedLocales, contains(const Locale('it'))); + expect(app.supportedLocales, contains(const Locale('nb'))); + expect(app.supportedLocales, isNot(contains(const Locale('no')))); + expect(app.supportedLocales, contains(const Locale('fr'))); + expect(app.supportedLocales, contains(const Locale('ru'))); + expect(app.supportedLocales, contains(const Locale('uk'))); + expect(app.supportedLocales, contains(const Locale('pl'))); + expect(app.supportedLocales, contains(const Locale('es'))); expect( - find.byKey(const ValueKey('settings-page-selector')), - findsOneWidget, + app.supportedLocales, + contains(Locale.fromSubtags(languageCode: 'pt', countryCode: 'BR')), ); - expect(find.text(l10n.reportIssue), findsNothing); - - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - expect(find.text(l10n.reportIssue), findsOneWidget); + expect(app.supportedLocales, contains(const Locale('ar'))); + expect(app.supportedLocales, contains(const Locale('fa'))); + expect(app.supportedLocales, contains(const Locale('hi'))); + expect(app.supportedLocales, contains(const Locale('id'))); + expect(app.supportedLocales, contains(const Locale('ja'))); + expect(app.supportedLocales, contains(const Locale('ko'))); + expect(app.supportedLocales, contains(const Locale('nl'))); + expect(app.supportedLocales, contains(const Locale('tr'))); + expect(app.supportedLocales, contains(const Locale('vi'))); expect( - tester.getTopLeft(find.text(l10n.reportIssue)).dy, - lessThan(tester.getTopLeft(find.text(l10n.aboutBusyMark)).dy), + app.supportedLocales, + contains(const Locale.fromSubtags(languageCode: 'zh', countryCode: 'CN')), ); - await tester.tap(find.text(l10n.reportIssue)); - await tester.pumpAndSettle(); - - expect(find.text(l10n.reportIssue), findsOneWidget); - expect(find.text(l10n.feedbackCategory), findsOneWidget); - expect(find.byKey(BusyMarkFeedbackKeys.cancel), findsOneWidget); + expect(app.onGenerateTitle, isNotNull); + expect(find.text(l10n.appTitle), findsWidgets); }); - testWidgets('global reference and settings shortcuts open targets', ( + testWidgets('app boots to BusyMark welcome screen without login UI', ( tester, ) async { - final l10n = AppLocalizationsEn(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -1321,483 +1687,532 @@ void main() { ); await tester.pumpAndSettle(); - Future pressShortcut( - LogicalKeyboardKey key, { - bool control = false, - bool shift = false, - bool alt = false, - }) async { - if (control) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - } - if (shift) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - } - if (alt) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); - } - await tester.sendKeyDownEvent(key); - await tester.sendKeyUpEvent(key); - if (alt) { - await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); - } - if (shift) { - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - } - if (control) { - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - } - await tester.pumpAndSettle(); - } - - await pressShortcut(LogicalKeyboardKey.keyK, control: true, alt: true); - expect( - find.text(l10n.shortcutKeyboardShortcutsDescription), - findsOneWidget, - ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - await pressShortcut(LogicalKeyboardKey.keyM, control: true, alt: true); + expect(find.text(l10n.appTitle), findsWidgets); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + expect(find.text(l10n.createWritersideProject), findsOneWidget); + expect(find.text(l10n.openMarkdownFile), findsOneWidget); + expect(find.text(l10n.markdownFolderOrWritersideProject), findsOneWidget); + expect(find.text(l10n.browseNotes), findsOneWidget); + expect(find.text(l10n.nextcloudNewNote), findsOneWidget); expect( - find.byKey(const ValueKey('syntax-reference-dialog')), - findsOneWidget, + tester.getTopLeft(find.text(l10n.markdownFolderOrWritersideProject)).dy, + lessThan(tester.getTopLeft(find.text(l10n.browseNotes)).dy), ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - await pressShortcut(LogicalKeyboardKey.keyS, control: true, alt: true); + expect(find.text('File or folder path'), findsNothing); + expect(find.textContaining('sign in'), findsNothing); expect( - find.byKey(const ValueKey('settings-page-selector')), + find.byTooltip( + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + ), findsOneWidget, ); - }); - - testWidgets('settings screen opens', (tester) async { - final l10n = AppLocalizationsEn(); - final settingsStore = _MemorySettingsStore(); - final nativeWindow = _FakeNativeWindowController(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - nativeWindowControllerProvider.overrideWithValue(nativeWindow), - ], - child: const BusyMarkApp(), - ), + _expectNeutralSidebarToggle( + tester, + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + selected: true, ); - await tester.pumpAndSettle(); + _expectHeaderGeometry(tester); - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settings)); - await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pump(const Duration(milliseconds: 100)); expect( - find.byKey(const ValueKey('settings-page-selector')), + find.byTooltip( + '${l10n.showSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + ), findsOneWidget, ); - expect(find.text(l10n.appLanguage), findsOneWidget); - expect(find.text(l10n.systemLanguage), findsWidgets); - expect(find.byType(DropdownButton), findsNothing); - - await tester.tap(find.byTooltip(l10n.appLanguage)); - await tester.pumpAndSettle(); - - expect(find.text('Deutsch'), findsOneWidget); - expect(find.text('Eesti'), findsOneWidget); - expect(find.text('العربية'), findsOneWidget); - expect(find.text('हिन्दी'), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - expect(find.text('Deutsch'), findsNothing); - expect(find.text('Eesti'), findsNothing); - expect(find.text('العربية'), findsNothing); - expect(find.text('हिन्दी'), findsNothing); + _expectNeutralSidebarToggle( + tester, + '${l10n.showSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + selected: false, + ); - await tester.tap(find.byTooltip(l10n.appLanguage)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.systemLanguage).last); - await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pump(const Duration(milliseconds: 100)); expect( - find.byType(BusyMarkPopupSelector), + find.byTooltip( + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + ), findsOneWidget, ); - expect( - find.byType(SegmentedButton), - findsNothing, + _expectNeutralSidebarToggle( + tester, + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', + selected: true, ); - await tester.tap(find.byTooltip(l10n.theme)); - await tester.pumpAndSettle(); - - expect(find.text(l10n.systemTheme), findsWidgets); - expect(find.text(l10n.lightTheme), findsOneWidget); - expect(find.text(l10n.darkTheme), findsOneWidget); - await tester.tap(find.text(l10n.darkTheme)); - await tester.pumpAndSettle(); - - expect(settingsStore.value['themeModePreference'], 'dark'); + }); - await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.editor)); - await tester.pumpAndSettle(); + for (final readErrors in [0, 1]) { + testWidgets( + 'recovery shows one actionable notice and preserves damage warnings ($readErrors)', + (tester) async { + final recoveryStore = MemoryDocumentRecoveryStore() + ..value = RecoverySnapshot( + cleanShutdown: false, + readErrors: readErrors, + entries: [ + DocumentRecoveryEntry.fromBuffer( + DocumentBuffer.untitled( + id: 'recovered-draft', + name: 'Recovered draft', + text: '# Recovered draft\n', + ), + workspacePath: null, + ), + ], + ); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue(recoveryStore), + startupPathProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + await tester.runAsync(() async { + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + expect( + await container + .read(workspaceControllerProvider.notifier) + .restorePreviousSession(), + isTrue, + ); + }); + container.read(appRouterProvider).go('/workspace'); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); - Finder groupedSection(String title) => find.byWidgetPredicate( - (widget) => widget is BusyMarkGroupedList && widget.title == title, + final buffer = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(buffer.recovered, isTrue); + expect(buffer.text, '# Recovered draft\n'); + expect(find.text(l10n.workspaceRecoveryRestored(1)), findsNothing); + final review = find.text( + l10n.recoveredDocumentReview(buffer.displayName), + ); + expect(review, findsOneWidget); + final actions = find + .ancestor(of: review, matching: find.byType(Row)) + .first; + for (final label in [l10n.save, l10n.saveAs, l10n.discard]) { + final button = find.ancestor( + of: find.descendant(of: actions, matching: find.text(label)), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton, + ), + ); + expect(button, findsOneWidget); + expect(tester.widget(button).onPressed, isNotNull); + } + expect( + find.text(l10n.workspaceRecoveryDamaged(1)), + readErrors > 0 ? findsOneWidget : findsNothing, + ); + }, ); - void expectControlInSection(String sectionTitle, String controlTitle) { - expect( - find.descendant( - of: groupedSection(sectionTitle), - matching: find.text(controlTitle), - ), - findsOneWidget, - ); - } + } - expect( - tester - .widgetList(find.byType(BusyMarkGroupedList)) - .map((section) => section.title), - [ - l10n.editor, - l10n.settingsEditingButtonsSectionTitle, - l10n.spelling, - l10n.settingsDictionariesSectionTitle, - ], - ); - expectControlInSection(l10n.editor, l10n.autoSave); - expectControlInSection(l10n.editor, l10n.editorFontSize); - expectControlInSection( - l10n.settingsEditingButtonsSectionTitle, - l10n.editingButtonsPosition, - ); - expectControlInSection( - l10n.settingsEditingButtonsSectionTitle, - l10n.editingButtonsDirection, - ); - expectControlInSection(l10n.spelling, l10n.automaticSpelling); - expectControlInSection(l10n.spelling, l10n.defaultSpellingLanguage); - expectControlInSection(l10n.spelling, l10n.projectSpellingLanguage); - expectControlInSection( - l10n.settingsDictionariesSectionTitle, - l10n.spellingDictionaries, - ); - expectControlInSection( - l10n.settingsDictionariesSectionTitle, - l10n.personalSpellingDictionary, - ); - expectControlInSection( - l10n.settingsDictionariesSectionTitle, - l10n.projectSpellingDictionary, + for (final closeTabFirst in [false, true]) { + testWidgets( + 'Welcome creates a fresh Markdown workspace after leaving basic.md (close tab: $closeTabFirst)', + (tester) async { + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(), + ), + startupPathProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + final controller = container.read(workspaceControllerProvider.notifier); + await tester.runAsync( + () => controller.openPath('test/fixtures/markdown/basic.md'), + ); + container.read(appRouterProvider).go('/workspace'); + await tester.pumpAndSettle(); + final oldId = container + .read(workspaceControllerProvider) + .activeBufferId!; + if (closeTabFirst) { + await tester.runAsync(() => controller.closeDocumentBuffer(oldId)); + await tester.pumpAndSettle(); + } + await tester.tap( + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), + ); + await tester.pumpAndSettle(); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + await tester.tap(find.text(l10n.createMarkdownFile)); + await tester.pumpAndSettle(); + final state = container.read(workspaceControllerProvider); + expect(state.documentBuffers, hasLength(1)); + expect(state.activeBuffer!.filePath, isNull); + expect(state.activeBufferId, isNot(oldId)); + expect(state.workspace!.kind, WorkspaceKind.untitledMarkdown); + expect(state.workspace!.openFilePaths, isEmpty); + }, ); - final dictionariesSection = tester.widget( - groupedSection(l10n.settingsDictionariesSectionTitle), + } + + testWidgets('main menu and F11 toggle full-screen mode', (tester) async { + final nativeWindow = _FakeNativeWindowController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + nativeWindowControllerProvider.overrideWithValue(nativeWindow), + ], + child: const BusyMarkApp(), + ), ); - expect(dictionariesSection.children, hasLength(3)); - expect(find.text(l10n.importSpellingDictionary), findsNothing); - expect( - tester.widget(find.byType(BusyMarkClamp)).scrollable, - isTrue, + await tester.pumpAndSettle(); + + Finder fullScreenMenuItem() => find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.fullScreen, ); - expect( - find.byWidgetPredicate((widget) => widget is SegmentedButton), - findsNothing, + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + expect(find.text(l10n.fullScreen), findsOneWidget); + expect(find.text(BusyMarkAppShortcuts.fullScreen.label), findsOneWidget); + final exportMenuItem = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.export, ); + expect(exportMenuItem, findsOneWidget); expect( - find.byType(BusyMarkPopupSelector), - findsOneWidget, + tester.widget>(exportMenuItem).enabled, + isFalse, ); + expect(find.text(BusyMarkAppShortcuts.export.label), findsOneWidget); + for (final documentCommand in { + l10n.clipboardHistory, + l10n.localHistory, + l10n.findLocalHistoryEllipsis, + }) { + expect(find.text(documentCommand), findsNothing); + } expect( - find.byType(BusyMarkPopupSelector), - findsOneWidget, + tester + .widget>(fullScreenMenuItem()) + .checked, + isFalse, ); - await tester.tap(find.byTooltip(l10n.editingButtonsPosition)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.bottomRight)); - await tester.pumpAndSettle(); - expect(settingsStore.value['editorToolbarPlacement'], 'bottomRight'); - - await tester.tap(find.byTooltip(l10n.editingButtonsDirection)); + await tester.tap(find.text(l10n.fullScreen)); await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.vertical)); - await tester.pumpAndSettle(); - - expect(settingsStore.value['editorToolbarDirection'], 'vertical'); - - expect(find.text(l10n.autoSave), findsOneWidget); - expect(find.text(l10n.autoSaveDescription), findsOneWidget); - expect(find.text(l10n.wordWrapDescription), findsOneWidget); - final autoSaveRow = find.byWidgetPredicate( - (widget) => widget is BusyMarkSwitchRow && widget.title == l10n.autoSave, - ); - expect(autoSaveRow, findsOneWidget); - expect( - tester.widget(autoSaveRow).value, - AppSettings.defaults().autoSave, - ); - - await tester.tap( - find.descendant(of: autoSaveRow, matching: find.byType(BusyMarkSwitch)), - ); - await tester.pumpAndSettle(); - expect(settingsStore.value['autoSave'], isFalse); - expect(tester.widget(autoSaveRow).value, isFalse); + expect(nativeWindow.fullScreenValues, [true]); - await tester.tap(find.text(l10n.autoSave)); + await tester.tap(find.byTooltip(l10n.mainMenu)); await tester.pumpAndSettle(); - expect(settingsStore.value['autoSave'], isTrue); - expect(tester.widget(autoSaveRow).value, isTrue); - - final header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), - ); - expect(header.configuration.title, l10n.editor); - expect(header.configuration.sidebarVisible, isFalse); expect( - find.byKey(const ValueKey('settings-page-selector')), - findsOneWidget, + tester + .widget>(fullScreenMenuItem()) + .checked, + isTrue, ); - expect(find.byType(BusyMarkGroupedList), findsNWidgets(4)); - expect(find.text(l10n.autoSave), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.validation)); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - expect(find.text(l10n.validateOnEdit), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settingsHistory)); + await tester.sendKeyEvent(LogicalKeyboardKey.f11); await tester.pumpAndSettle(); + expect(nativeWindow.fullScreenValues, [true, false]); + }); - final historySection = find.byWidgetPredicate( - (widget) => - widget is BusyMarkGroupedList && widget.title == l10n.settingsHistory, - ); - expect(historySection, findsOneWidget); - expect( - find.descendant( - of: historySection, - matching: find.byType(BusyMarkComboRow), - ), - findsNWidgets(3), - ); - expect( - find.descendant( - of: historySection, - matching: find.byType(DropdownButton), + testWidgets('Alt+Left activates the header Back command', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), ), - findsNothing, - ); - - BusyMarkComboRow historyRow(String title) => - tester.widget>( - find.byWidgetPredicate( - (widget) => - widget is BusyMarkComboRow && widget.title == title, - ), - ); - expect(historyRow(l10n.settingsHistoryCheckpoint).values, [ - 30, - 60, - 120, - 300, - 600, - ]); - expect(historyRow(l10n.settingsHistoryCheckpoint).selected, 60); - expect( - historyRow(l10n.settingsHistoryCheckpoint).labelFor(60), - l10n.settingsSecondsValue(60), - ); - expect(historyRow(l10n.settingsHistoryRetention).values, [7, 30, 90, 365]); - expect(historyRow(l10n.settingsHistoryRetention).selected, 30); - expect( - historyRow(l10n.settingsHistoryRetention).labelFor(30), - l10n.settingsDaysValue(30), - ); - expect(historyRow(l10n.settingsHistoryStorage).values, [ - 128, - 256, - 512, - 1024, - 2048, - 4096, - ]); - expect(historyRow(l10n.settingsHistoryStorage).selected, 512); - expect( - historyRow(l10n.settingsHistoryStorage).labelFor(512), - l10n.settingsMebibytesValue(512), ); - - await tester.tap(find.byTooltip(l10n.settingsHistoryCheckpoint)); - await tester.pumpAndSettle(); - for (final choice in [30, 60, 120, 300, 600]) { - expect(find.text(l10n.settingsSecondsValue(choice)), findsWidgets); - } - expect(find.byType(Radio), findsNothing); - expect(find.byType(RadioListTile), findsNothing); - await tester.tap(find.text(l10n.settingsSecondsValue(120)).last); await tester.pumpAndSettle(); - expect(settingsStore.value['localHistoryCheckpointSeconds'], 120); - expect(historyRow(l10n.settingsHistoryCheckpoint).selected, 120); - - historyRow(l10n.settingsHistoryRetention).onSelected(90); - await tester.pumpAndSettle(); - expect(settingsStore.value['localHistoryRetentionDays'], 90); - expect(historyRow(l10n.settingsHistoryRetention).selected, 90); - - historyRow(l10n.settingsHistoryStorage).onSelected(1024); - await tester.pumpAndSettle(); - expect(settingsStore.value['localHistoryMaximumStorageMiB'], 1024); - expect(historyRow(l10n.settingsHistoryStorage).selected, 1024); - - Future openExcludedPaths() async { - final row = find.byWidgetPredicate( - (widget) => - widget is BusyMarkActionRow && - widget.title == l10n.settingsHistoryExcludedPaths, - ); - await tester.ensureVisible(row); - await tester.tap(row); - await tester.pumpAndSettle(); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect(find.byType(AlertDialog), findsNothing); - } - await openExcludedPaths(); - final excludedEntry = find.byType(BusyMarkGroupedTextEntry); - final excluded = tester.widget(excludedEntry); - expect(excluded.controller!.text, ''); - expect(excluded.maxLines, 12); - expect(excluded.onSubmitted, isNull); - await tester.enterText(excludedEntry, '/tmp/build'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.testTextInput.receiveAction(TextInputAction.newline); - tester.testTextInput.updateEditingValue( - const TextEditingValue(text: '/tmp/build\n'), - ); - await tester.pump(); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect(excluded.controller!.text, contains('\n')); - await tester.enterText(excludedEntry, '/tmp/build\r\n/tmp/cache'); - await tester.tap(find.text(l10n.save)); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(settingsStore.value['localHistoryExcludedPaths'], [ - '/tmp/build', - '/tmp/cache', - ]); - await openExcludedPaths(); - expect( - tester.widget(excludedEntry).controller!.text, - '/tmp/build\n/tmp/cache', - ); - await tester.enterText(excludedEntry, 'discard'); - await tester.tap(find.text(l10n.cancel)); + await tester.tap(find.byTooltip(l10n.mainMenu)); await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); - expect(settingsStore.value['localHistoryExcludedPaths'], [ - '/tmp/build', - '/tmp/cache', - ]); - - await tester.ensureVisible(find.text(l10n.settingsLocalHistoryTitle)); - await tester.tap(find.text(l10n.settingsLocalHistoryTitle)); + await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); - expect(settingsStore.value['localHistoryRecordingEnabled'], isFalse); expect( - tester - .widgetList>( - find.descendant( - of: historySection, - matching: find.byType(BusyMarkComboRow), - ), - ) - .every((row) => !row.enabled), - isTrue, + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), + findsOneWidget, ); - - final clearRecentRow = tester.widget( + final backButton = tester.widget( find.byWidgetPredicate( (widget) => - widget is BusyMarkActionRow && - widget.title == l10n.clearRecentWorkspaces, + widget is BusyMarkLinuxHeaderIconButton && + widget.tooltip == l10n.back, ), ); - expect(clearRecentRow.destructive, isTrue); - expect(clearRecentRow.onTap, isNotNull); - final clearRecentTitle = find.text(l10n.clearRecentWorkspaces); - final clearRecentContext = tester.element(clearRecentTitle); - expect( - tester.widget(clearRecentTitle).style?.color, - busyMarkDestructiveForeground(clearRecentContext), - ); - expect(Theme.of(clearRecentContext).brightness, Brightness.dark); - expect( - busyMarkDestructiveForeground(clearRecentContext), - isNot(Theme.of(clearRecentContext).colorScheme.error), - ); + expect(backButton.nativeIcon, BusyMarkLinuxHeaderIcon.back); + expect(backButton.icon, BusyMarkGlyphs.headerBackFor(TextDirection.ltr)); + _expectHeaderGeometry(tester); - expect( - find.text(l10n.settingsReopenWorkspaceOnStartupTitle), - findsOneWidget, - ); - expect( - find.text(l10n.settingsReopenWorkspaceOnStartupDescription), - findsOneWidget, - ); - await tester.ensureVisible( - find.text(l10n.settingsReopenWorkspaceOnStartupTitle), - ); - await tester.tap(find.text(l10n.settingsReopenWorkspaceOnStartupTitle)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.pumpAndSettle(); - expect(settingsStore.value['reopenPreviousWorkspaceOnStartup'], isTrue); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + }); - expect( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), - findsOneWidget, - ); - expect( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesDescription), - findsOneWidget, - ); - await tester.ensureVisible( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), - ); - await tester.tap( - find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), + testWidgets( + 'restored capture failure stays owned across Back create discard create', + (tester) async { + final root = (await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-history-navigation-'), + ))!; + addTearDown(() => root.delete(recursive: true)); + final path = p.join(root.path, 'Restored.md'); + await tester.runAsync( + () => File(path).writeAsString('original restored source'), + ); + final store = _NavigationHistoryStore(); + final sessions = MemoryDocumentSessionStore() + ..value = WorkspaceSessionSnapshot( + workspacePath: root.path, + tabs: [ + DocumentSessionEntry( + id: 'restored', + filePath: path, + untitledName: null, + editorState: const DocumentEditorState(), + ), + ], + activeBufferId: 'restored', + ); + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults().copyWith(autoSave: false).toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settings), + localHistoryStoreProvider.overrideWithValue(store), + localHistoryTimerFactoryProvider.overrideWithValue( + (delay, callback) => _HistoryTestTimer(delay, callback), + ), + documentSessionStoreProvider.overrideWithValue(sessions), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + await tester.runAsync(() async { + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + expect( + await container + .read(workspaceControllerProvider.notifier) + .restorePreviousSession(), + isTrue, + ); + await container + .read(localHistoryControllerProvider.notifier) + .flushAll( + container.read(workspaceControllerProvider).documentBuffers, + ); + }); + final history = container.read(localHistoryControllerProvider.notifier); + final originalId = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + expect( + history.warningForBuffer(originalId)?.kind, + LocalHistoryWarningKind.capture, + ); + container.read(appRouterProvider).go('/workspace'); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('could not capture'), findsOneWidget); + Future back() async { + await container + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.back); + await tester.pumpAndSettle(); + } + + await back(); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + await tester.tap(find.text(l10n.createMarkdownFile)); + await _pumpUntilCondition( + tester, + () => + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/workspace', + ); + await tester.pumpAndSettle(); + final firstDraft = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(firstDraft.id, isNot(originalId)); + expect(history.warningForBuffer(firstDraft.id), isNull); + expect(find.textContaining('could not capture'), findsNothing); + await back(); + expect(find.text(l10n.discard), findsOneWidget); + await tester.tap(find.text(l10n.discard)); + await tester.pumpAndSettle(); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + await tester.tap(find.text(l10n.createMarkdownFile)); + await tester.pumpAndSettle(); + await _pumpUntilCondition( + tester, + () => + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/workspace', + ); + expect( + history.warningForBuffer( + container.read(workspaceControllerProvider).activeBuffer!.id, + ), + isNull, + ); + expect(find.textContaining('could not capture'), findsNothing); + expect(store.protectiveAttempts, 0); + expect( + container.read(localHistoryControllerProvider).warning?.ownerBufferId, + originalId, + ); + store.available = true; + // Drain UI-zone persistence before runAsync waits on a Local History + // retry that durably chains another session write behind it. + var persistenceDrained = false; + final persistence = container + .read(workspaceControllerProvider.notifier) + .flushPersistence() + .whenComplete(() => persistenceDrained = true); + await _pumpUntilCondition(tester, () => persistenceDrained); + await persistence; + var historyFlushed = false; + var historySettled = false; + final flush = history + .flushAll(container.read(workspaceControllerProvider).documentBuffers) + .then((settled) { + historySettled = settled; + historyFlushed = true; + }); + await _pumpUntilCondition(tester, () => historyFlushed); + await flush; + expect(historySettled, isTrue); + final snapshot = await store.load(); + expect(snapshot.revisions, hasLength(1)); + expect( + (await store.readRevision(snapshot.revisions.single.id))!.source, + 'original restored source', + ); + expect(container.read(localHistoryControllerProvider).warning, isNull); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Escape closes header popup menus', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), + ), ); await tester.pumpAndSettle(); - expect(settingsStore.value['confirmCloseWithUnsavedChanges'], isFalse); + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); - await tester.tap( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + expect(find.text(l10n.settings), findsOneWidget); + expect(find.text(BusyMarkAppShortcuts.settings.label), findsOneWidget); + expect( + find.byTooltip( + '${l10n.settings} (${BusyMarkAppShortcuts.settings.label})', + ), + findsNothing, + ); + expect(find.text(l10n.keyboardShortcuts), findsNothing); + expect(find.text('Ctrl+Alt+K'), findsNothing); + expect(find.text(l10n.syntaxReference), findsOneWidget); + expect( + find.text(BusyMarkAppShortcuts.syntaxReference.label), + findsOneWidget, + ); + expect( + find.byTooltip( + '${l10n.syntaxReference} ' + '(${BusyMarkAppShortcuts.syntaxReference.label})', + ), + findsNothing, + ); + expect(find.text(l10n.reportIssue), findsOneWidget); + expect(find.text(l10n.aboutBusyMark), findsOneWidget); + expect( + tester.getTopLeft(find.text(l10n.reportIssue)).dy, + lessThan(tester.getTopLeft(find.text(l10n.aboutBusyMark)).dy), ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - expect(find.text(l10n.createMarkdownFile), findsOneWidget); + expect(find.text(l10n.settings), findsNothing); }); - testWidgets('settings uses the regular split sidebar at desktop width', ( + testWidgets('report issue is in the shared main menu, not Settings content', ( tester, ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 760); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget( ProviderScope( overrides: [ @@ -1814,46 +2229,185 @@ void main() { await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); - expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); - expect(find.byType(BusyMarkSidebarNavigation), findsOneWidget); - expect( - find.byType(BusyMarkSidebarNavigationTile), - findsNWidgets(SettingsPage.values.length - 1), - ); expect( - tester.getSize(find.byType(BusyMarkSidebarSurface)).width, - BusyMarkSizes.sidebarWidth, - ); - expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); - - final appearanceTile = tester.widget( - find.byKey(const ValueKey('settings-navigation-appearance')), - ); - final editorTile = tester.widget( - find.byKey(const ValueKey('settings-navigation-editor')), - ); - expect(appearanceTile.selected, isTrue); - expect(editorTile.selected, isFalse); - - var header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, ); - expect(header.configuration.sidebarVisible, isTrue); - expect(header.configuration.sidebarToggleVisible, isFalse); - expect(header.configuration.title, l10n.appearance); + expect(find.text(l10n.reportIssue), findsNothing); - await tester.tap(find.byKey(const ValueKey('settings-navigation-editor'))); + await tester.tap(find.byTooltip(l10n.mainMenu)); await tester.pumpAndSettle(); - + expect(find.text(l10n.reportIssue), findsOneWidget); expect( - tester - .widget( - find.byKey(const ValueKey('settings-navigation-editor')), - ) - .selected, - isTrue, + tester.getTopLeft(find.text(l10n.reportIssue)).dy, + lessThan(tester.getTopLeft(find.text(l10n.aboutBusyMark)).dy), ); - expect( + await tester.tap(find.text(l10n.reportIssue)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.reportIssue), findsOneWidget); + expect(find.text(l10n.feedbackCategory), findsOneWidget); + expect(find.byKey(BusyMarkFeedbackKeys.cancel), findsOneWidget); + }); + + testWidgets('global reference and settings shortcuts open targets', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + Future pressShortcut( + LogicalKeyboardKey key, { + bool control = false, + bool shift = false, + bool alt = false, + }) async { + if (control) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + } + if (shift) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + } + if (alt) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + } + await tester.sendKeyDownEvent(key); + await tester.sendKeyUpEvent(key); + if (alt) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + } + if (shift) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } + if (control) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } + await tester.pumpAndSettle(); + } + + await pressShortcut(LogicalKeyboardKey.keyK, control: true, alt: true); + expect(find.byType(KeyboardShortcutsSettings), findsNothing); + await pressShortcut(LogicalKeyboardKey.keyS, control: true, alt: true); + await _selectShortcutSettingsPage(tester); + expect(find.byType(KeyboardShortcutsSettings), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(KeyboardShortcutsSettings), findsOneWidget); + await pressShortcut(LogicalKeyboardKey.arrowLeft, alt: true); + expect(find.byType(KeyboardShortcutsSettings), findsNothing); + + await pressShortcut(LogicalKeyboardKey.keyM, control: true, alt: true); + expect( + find.byKey(const ValueKey('syntax-reference-dialog')), + findsOneWidget, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await pressShortcut(LogicalKeyboardKey.keyS, control: true, alt: true); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); + }); + + testWidgets('settings screen opens', (tester) async { + final l10n = AppLocalizationsEn(); + final settingsStore = _MemorySettingsStore(); + final nativeWindow = _FakeNativeWindowController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + nativeWindowControllerProvider.overrideWithValue(nativeWindow), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); + expect(find.text(l10n.appLanguage), findsOneWidget); + expect(find.text(l10n.systemLanguage), findsWidgets); + expect(find.byType(DropdownButton), findsNothing); + + await tester.tap(find.byTooltip(l10n.appLanguage)); + await tester.pumpAndSettle(); + + expect(find.text('Deutsch'), findsOneWidget); + expect(find.text('Eesti'), findsOneWidget); + expect(find.text('العربية'), findsOneWidget); + expect(find.text('हिन्दी'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('Deutsch'), findsNothing); + expect(find.text('Eesti'), findsNothing); + expect(find.text('العربية'), findsNothing); + expect(find.text('हिन्दी'), findsNothing); + + await tester.tap(find.byTooltip(l10n.appLanguage)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.systemLanguage).last); + await tester.pumpAndSettle(); + + expect( + find.byType(BusyMarkPopupSelector), + findsOneWidget, + ); + expect( + find.byType(SegmentedButton), + findsNothing, + ); + await tester.tap(find.byTooltip(l10n.theme)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.systemTheme), findsWidgets); + expect(find.text(l10n.lightTheme), findsOneWidget); + expect(find.text(l10n.darkTheme), findsOneWidget); + await tester.tap(find.text(l10n.darkTheme)); + await tester.pumpAndSettle(); + + expect(settingsStore.value['themeModePreference'], 'dark'); + + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.editor)); + await tester.pumpAndSettle(); + + Finder groupedSection(String title) => find.byWidgetPredicate( + (widget) => widget is BusyMarkGroupedList && widget.title == title, + ); + void expectControlInSection(String sectionTitle, String controlTitle) { + expect( + find.descendant( + of: groupedSection(sectionTitle), + matching: find.text(controlTitle), + ), + findsOneWidget, + ); + } + + expect( tester .widgetList(find.byType(BusyMarkGroupedList)) .map((section) => section.title), @@ -1864,697 +2418,509 @@ void main() { l10n.settingsDictionariesSectionTitle, ], ); - expect(find.text(l10n.autoSave), findsOneWidget); - header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), + expectControlInSection(l10n.editor, l10n.autoSave); + expectControlInSection(l10n.editor, l10n.editorFontSize); + expectControlInSection( + l10n.settingsEditingButtonsSectionTitle, + l10n.editingButtonsPosition, ); - expect(header.configuration.sidebarVisible, isTrue); - expect(header.configuration.title, l10n.editor); - - expect(find.text(l10n.spellingDictionaries), findsOneWidget); - expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); - expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); - expect(find.byType(Scrollable), findsWidgets); + expectControlInSection( + l10n.settingsEditingButtonsSectionTitle, + l10n.editingButtonsDirection, + ); + expectControlInSection(l10n.spelling, l10n.automaticSpelling); + expectControlInSection(l10n.spelling, l10n.defaultSpellingLanguage); + expectControlInSection(l10n.spelling, l10n.projectSpellingLanguage); + expectControlInSection( + l10n.settingsDictionariesSectionTitle, + l10n.spellingDictionaries, + ); + expectControlInSection( + l10n.settingsDictionariesSectionTitle, + l10n.personalSpellingDictionary, + ); + expectControlInSection( + l10n.settingsDictionariesSectionTitle, + l10n.projectSpellingDictionary, + ); + final dictionariesSection = tester.widget( + groupedSection(l10n.settingsDictionariesSectionTitle), + ); + expect(dictionariesSection.children, hasLength(3)); + expect(find.text(l10n.importSpellingDictionary), findsNothing); expect( tester.widget(find.byType(BusyMarkClamp)).scrollable, isTrue, ); - header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), + + expect( + find.byWidgetPredicate((widget) => widget is SegmentedButton), + findsNothing, ); - expect(header.configuration.sidebarVisible, isTrue); - expect(header.configuration.title, l10n.editor); expect( - tester - .widget( - find.byKey(const ValueKey('settings-navigation-editor')), - ) - .selected, - isTrue, + find.byType(BusyMarkPopupSelector), + findsOneWidget, ); - header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), + expect( + find.byType(BusyMarkPopupSelector), + findsOneWidget, ); - expect(header.configuration.sidebarVisible, isTrue); - expect(header.configuration.title, l10n.editor); + await tester.tap(find.byTooltip(l10n.editingButtonsPosition)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.bottomRight)); + await tester.pumpAndSettle(); - final dictionariesNavigation = find.byKey( - const ValueKey('settings-spelling-dictionaries'), - ); - await tester.ensureVisible(dictionariesNavigation); - await tester.tap(dictionariesNavigation); + expect(settingsStore.value['editorToolbarPlacement'], 'bottomRight'); + + await tester.tap(find.byTooltip(l10n.editingButtonsDirection)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.vertical)); await tester.pumpAndSettle(); - expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); - expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + expect(settingsStore.value['editorToolbarDirection'], 'vertical'); + + expect(find.text(l10n.autoSave), findsOneWidget); + expect(find.text(l10n.autoSaveDescription), findsOneWidget); + expect(find.text(l10n.wordWrapDescription), findsOneWidget); + final autoSaveRow = find.byWidgetPredicate( + (widget) => widget is BusyMarkSwitchRow && widget.title == l10n.autoSave, + ); + expect(autoSaveRow, findsOneWidget); expect( - tester - .widget( - find.byKey(const ValueKey('settings-navigation-editor')), - ) - .selected, - isTrue, + tester.widget(autoSaveRow).value, + AppSettings.defaults().autoSave, ); - header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), - ); - expect(header.configuration.title, l10n.spellingDictionaries); - expect(find.text(l10n.importSpellingDictionary), findsOneWidget); - expect(find.text(l10n.spellingDictionaryProblems), findsNothing); await tester.tap( - find.byTooltip('${l10n.back} (${BusyMarkAppShortcutLabels.back})'), + find.descendant(of: autoSaveRow, matching: find.byType(BusyMarkSwitch)), ); await tester.pumpAndSettle(); - header = tester.widget( + expect(settingsStore.value['autoSave'], isFalse); + expect(tester.widget(autoSaveRow).value, isFalse); + + await tester.tap(find.text(l10n.autoSave)); + await tester.pumpAndSettle(); + expect(settingsStore.value['autoSave'], isTrue); + expect(tester.widget(autoSaveRow).value, isTrue); + + final header = tester.widget( find.byType(HeaderBarConfigurationPublisher), ); expect(header.configuration.title, l10n.editor); + expect(header.configuration.sidebarVisible, isFalse); + expect( + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, + ); + expect(find.byType(BusyMarkGroupedList), findsNWidgets(4)); expect(find.text(l10n.autoSave), findsOneWidget); - }); - testWidgets( - 'Spelling Dictionaries page groups downloadable and custom data', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 900); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.validation)); + await tester.pumpAndSettle(); - late Directory temporary; - late ({String bundle, String storage}) fixture; - late SpellingSessionController spelling; - await tester.runAsync(() async { - temporary = await Directory.systemTemp.createTemp( - 'busymark-settings-dictionaries-', - ); - fixture = await _createSettingsSpellingFixture(temporary); - spelling = SpellingSessionController( - bundledRoot: fixture.bundle, - applicationSupportRoot: p.join(temporary.path, 'support'), - dictionaryStorageRoot: fixture.storage, - verifyDictionaryChecksums: false, - ); - await spelling.prepareSettings(null); - }); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - spellingSessionControllerProvider.overrideWith((ref) => spelling), - ], - ); - addTearDown(() async { - container.dispose(); - if (await temporary.exists()) { - await temporary.delete(recursive: true); - } - }); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); + expect(find.text(l10n.validateOnEdit), findsOneWidget); - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.settings)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.editor)); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('settings-spelling-dictionaries')), - ); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settingsHistory)); + await tester.pumpAndSettle(); - final availableGroup = find.byWidgetPredicate( - (widget) => - widget is BusyMarkGroupedList && - widget.title == l10n.availableSpellingDictionaries, - ); - final customGroup = find.byWidgetPredicate( - (widget) => - widget is BusyMarkGroupedList && - widget.title == l10n.customSpellingDictionaries, - ); - final problemsGroup = find.byWidgetPredicate( - (widget) => - widget is BusyMarkGroupedList && - widget.title == l10n.spellingDictionaryProblems, - ); - expect(availableGroup, findsOneWidget); - expect(customGroup, findsOneWidget); - expect(problemsGroup, findsOneWidget); - expect( - find.byKey(const ValueKey('settings-page-selector')), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byKey(const ValueKey('settings-page-selector')), - matching: find.text(l10n.editor), - ), - findsOneWidget, - ); - expect( - container - .read(appRouterProvider) - .routerDelegate - .currentConfiguration - .uri - .queryParameters['page'], - 'spellingDictionaries', - ); - expect(find.byType(Scrollable), findsOneWidget); - expect( - tester.widget(find.byType(BusyMarkClamp)).scrollable, - isTrue, - ); - expect(find.text(l10n.importSpellingDictionary), findsOneWidget); - expect(find.text('Test English'), findsOneWidget); - expect(find.text('Test French'), findsOneWidget); - expect(find.text(l10n.spellingDictionaryInstalled), findsOneWidget); - expect(find.textContaining('Not installed ·'), findsAtLeastNWidgets(1)); - expect(find.text('Local Test English'), findsOneWidget); - expect(find.text('xx-Test'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Test English')).dy, - lessThan(tester.getTopLeft(find.text('Test French')).dy), - ); - expect( - tester.getTopLeft(find.text('Test French')).dy, - lessThan( - tester.getTopLeft(find.text(l10n.importSpellingDictionary)).dy, - ), - ); - expect( - tester.getTopLeft(find.text(l10n.importSpellingDictionary)).dy, - lessThan(tester.getTopLeft(find.text('Local Test English')).dy), - ); - expect( - tester.getTopLeft(find.text('Local Test English')).dy, - lessThan(tester.getTopLeft(find.text('xx-Test')).dy), - ); - for (final resourceTitle in ['Test English', 'Test French']) { - final row = find.byWidgetPredicate( - (widget) => - widget is BusyMarkActionRow && widget.title == resourceTitle, - ); - expect( - find.descendant( - of: row, - matching: find.byType(BusyMarkCompactIconButton), + final historySection = find.byWidgetPredicate( + (widget) => + widget is BusyMarkGroupedList && widget.title == l10n.settingsHistory, + ); + expect(historySection, findsOneWidget); + expect( + find.descendant( + of: historySection, + matching: find.byType(BusyMarkComboRow), + ), + findsNWidgets(3), + ); + expect( + find.descendant( + of: historySection, + matching: find.byType(DropdownButton), + ), + findsNothing, + ); + + BusyMarkComboRow historyRow(String title) => + tester.widget>( + find.byWidgetPredicate( + (widget) => + widget is BusyMarkComboRow && widget.title == title, ), - findsOneWidget, ); - final actionRow = tester.widget(row); - expect(actionRow.onTap, isNull); - expect(actionRow.destructive, isFalse); - } + expect(historyRow(l10n.settingsHistoryCheckpoint).values, [ + 30, + 60, + 120, + 300, + 600, + ]); + expect(historyRow(l10n.settingsHistoryCheckpoint).selected, 60); + expect( + historyRow(l10n.settingsHistoryCheckpoint).labelFor(60), + l10n.settingsSecondsValue(60), + ); + expect(historyRow(l10n.settingsHistoryRetention).values, [7, 30, 90, 365]); + expect(historyRow(l10n.settingsHistoryRetention).selected, 30); + expect( + historyRow(l10n.settingsHistoryRetention).labelFor(30), + l10n.settingsDaysValue(30), + ); + expect(historyRow(l10n.settingsHistoryStorage).values, [ + 128, + 256, + 512, + 1024, + 2048, + 4096, + ]); + expect(historyRow(l10n.settingsHistoryStorage).selected, 512); + expect( + historyRow(l10n.settingsHistoryStorage).labelFor(512), + l10n.settingsMebibytesValue(512), + ); - final importRow = tester.widget( - find.byKey(const ValueKey('import-spelling-dictionary')), + await tester.tap(find.byTooltip(l10n.settingsHistoryCheckpoint)); + await tester.pumpAndSettle(); + for (final choice in [30, 60, 120, 300, 600]) { + expect(find.text(l10n.settingsSecondsValue(choice)), findsWidgets); + } + expect(find.byType(Radio), findsNothing); + expect(find.byType(RadioListTile), findsNothing); + await tester.tap(find.text(l10n.settingsSecondsValue(120)).last); + await tester.pumpAndSettle(); + expect(settingsStore.value['localHistoryCheckpointSeconds'], 120); + expect(historyRow(l10n.settingsHistoryCheckpoint).selected, 120); + + historyRow(l10n.settingsHistoryRetention).onSelected(90); + await tester.pumpAndSettle(); + expect(settingsStore.value['localHistoryRetentionDays'], 90); + expect(historyRow(l10n.settingsHistoryRetention).selected, 90); + + historyRow(l10n.settingsHistoryStorage).onSelected(1024); + await tester.pumpAndSettle(); + expect(settingsStore.value['localHistoryMaximumStorageMiB'], 1024); + expect(historyRow(l10n.settingsHistoryStorage).selected, 1024); + + Future openExcludedPaths() async { + final row = find.byWidgetPredicate( + (widget) => + widget is BusyMarkActionRow && + widget.title == l10n.settingsHistoryExcludedPaths, ); - expect(importRow.onTap, isNotNull); - final importedRow = tester.widget( - find.byWidgetPredicate( - (widget) => - widget is BusyMarkActionRow && - widget.title == 'Local Test English', - ), - ); - expect(importedRow.destructive, isFalse); - expect(importedRow.onTap, isNull); - final importedRemove = importedRow.trailing! as BusyMarkCompactIconButton; - expect(importedRemove.onPressed, isNotNull); - expect( - importedRemove.foregroundColor, - Theme.of(tester.element(customGroup)).colorScheme.error, - ); - final invalidRow = tester.widget( - find.byWidgetPredicate( - (widget) => widget is BusyMarkActionRow && widget.title == 'xx-Test', - ), - ); - expect(invalidRow.destructive, isFalse); - expect(invalidRow.onTap, isNull); - final invalidRemove = invalidRow.trailing! as BusyMarkCompactIconButton; - expect(invalidRemove.onPressed, isNotNull); - expect( - invalidRemove.foregroundColor, - Theme.of(tester.element(problemsGroup)).colorScheme.error, - ); - - await tester.ensureVisible(find.text('Local Test English')); - await tester.tap(find.text('Local Test English')); + await tester.ensureVisible(row); + await tester.tap(row); await tester.pumpAndSettle(); - expect( - spelling.catalog?.installations.any( - (entry) => entry.id == 'local-Test' && entry.imported, - ), - isTrue, - ); - }, - ); - - testWidgets( - 'import dictionary prompt uses native controls and submits a trimmed ID', - (tester) async { - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: _MemorySettingsStore(), - downloadFile: _copySettingsDictionaryDownload, - ); - addTearDown(harness.dispose); - const fileSelectorChannel = MethodChannel( - 'plugins.flutter.io/file_selector', - ); - final dictionaryFixture = p.join( - Directory.current.path, - 'packages', - 'busymark_spellcheck_native', - 'test', - 'fixtures', - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - fileSelectorChannel, - (call) async { - expect(call.method, 'openFile'); - expect((call.arguments as Map)['multiple'], isTrue); - return [ - p.join(dictionaryFixture, 'test.aff'), - p.join(dictionaryFixture, 'test.dic'), - ]; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - fileSelectorChannel, - null, - ), - ); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + } - await _openImportDictionaryPrompt(tester); + await openExcludedPaths(); + final excludedEntry = find.byType(BusyMarkGroupedTextEntry); + final excluded = tester.widget(excludedEntry); + expect(excluded.controller!.text, ''); + expect(excluded.maxLines, 12); + expect(excluded.onSubmitted, isNull); + await tester.enterText(excludedEntry, '/tmp/build'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.testTextInput.receiveAction(TextInputAction.newline); + tester.testTextInput.updateEditingValue( + const TextEditingValue(text: '/tmp/build\n'), + ); + await tester.pump(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(excluded.controller!.text, contains('\n')); + await tester.enterText(excludedEntry, '/tmp/build\r\n/tmp/cache'); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(settingsStore.value['localHistoryExcludedPaths'], [ + '/tmp/build', + '/tmp/cache', + ]); + await openExcludedPaths(); + expect( + tester.widget(excludedEntry).controller!.text, + '/tmp/build\n/tmp/cache', + ); + await tester.enterText(excludedEntry, 'discard'); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(settingsStore.value['localHistoryExcludedPaths'], [ + '/tmp/build', + '/tmp/cache', + ]); - final dialog = find.byType(BusyMarkDialogShell); - expect(dialog, findsOneWidget); - final shell = tester.widget(dialog); - expect(shell.title, l10n.importSpellingDictionary); - expect(shell.closable, isFalse); - final entryFinder = find.descendant( - of: dialog, - matching: find.byType(BusyMarkFloatingTextEntry), - ); - expect(entryFinder, findsOneWidget); - final entry = tester.widget(entryFinder); - expect(entry.label, l10n.language); - expect(entry.hintText, 'en-US'); - expect(entry.autofocus, isTrue); - expect(entry.textInputAction, TextInputAction.done); - expect(entry.minLines, 1); - expect(entry.maxLines, 1); - final actions = tester - .widgetList( + await tester.ensureVisible(find.text(l10n.settingsLocalHistoryTitle)); + await tester.tap(find.text(l10n.settingsLocalHistoryTitle)); + await tester.pumpAndSettle(); + expect(settingsStore.value['localHistoryRecordingEnabled'], isFalse); + expect( + tester + .widgetList>( find.descendant( - of: dialog, - matching: find.byType(BusyMarkDialogButton), + of: historySection, + matching: find.byType(BusyMarkComboRow), ), ) - .toList(growable: false); - expect(actions, hasLength(2)); - expect(actions.map((action) => action.label), [l10n.cancel, l10n.save]); - expect(actions.first.suggested, isFalse); - expect(actions.last.suggested, isTrue); - expect(find.byType(AlertDialog), findsNothing); - - await tester.enterText(find.byType(TextFormField), ' '); - await tester.testTextInput.receiveAction(TextInputAction.done); - await _pumpSettingsUi(tester); - expect(dialog, findsOneWidget); - - await tester.enterText(find.byType(TextFormField), ' en-GB '); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(kThemeAnimationDuration); - await _pumpUntilCondition( - tester, - () => harness.spelling.catalog?.installedById('en-GB') != null, - timeout: const Duration(seconds: 15), - diagnostics: () { - final imported = Directory( - p.join( - harness.temporary.path, - 'dictionary-storage', - 'imported', - 'en-GB', - ), - ); - return 'imported directory=${imported.existsSync()}, ' - 'installed=${harness.spelling.catalog?.installedById('en-GB')}, ' - 'invalid=${harness.spelling.catalog?.invalidInstallations}, ' - 'dialog visible=${dialog.evaluate().isNotEmpty}'; - }, - ); - - expect(dialog, findsNothing); - expect(harness.spelling.catalog?.installedById('en-GB'), isNotNull); - }, - ); - - testWidgets('import dictionary prompt cancels and saves without redesign', ( - tester, - ) async { - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: _MemorySettingsStore(), - downloadFile: _copySettingsDictionaryDownload, - ); - addTearDown(harness.dispose); - const fileSelectorChannel = MethodChannel( - 'plugins.flutter.io/file_selector', - ); - final dictionaryFixture = p.join( - Directory.current.path, - 'packages', - 'busymark_spellcheck_native', - 'test', - 'fixtures', - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - fileSelectorChannel, - (call) async => [ - p.join(dictionaryFixture, 'test.aff'), - p.join(dictionaryFixture, 'test.dic'), - ], - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - fileSelectorChannel, - null, - ), + .every((row) => !row.enabled), + isTrue, ); - await _openImportDictionaryPrompt(tester); - await tester.tap( + final clearRecentRow = tester.widget( find.byWidgetPredicate( (widget) => - widget is BusyMarkDialogButton && widget.label == l10n.cancel, + widget is BusyMarkActionRow && + widget.title == l10n.clearRecentWorkspaces, ), ); - await _pumpSettingsUi(tester); - - expect(find.byType(BusyMarkDialogShell), findsNothing); - expect(harness.spelling.catalog?.installedById('en-GB'), isNull); + expect(clearRecentRow.destructive, isTrue); + expect(clearRecentRow.onTap, isNotNull); + final clearRecentTitle = find.text(l10n.clearRecentWorkspaces); + final clearRecentContext = tester.element(clearRecentTitle); + expect( + tester.widget(clearRecentTitle).style?.color, + busyMarkDestructiveForeground(clearRecentContext), + ); + expect(Theme.of(clearRecentContext).brightness, Brightness.dark); + expect( + busyMarkDestructiveForeground(clearRecentContext), + isNot(Theme.of(clearRecentContext).colorScheme.error), + ); - await tester.tap(find.byKey(const ValueKey('import-spelling-dictionary'))); - await _pumpUntilFound(tester, find.byType(BusyMarkDialogShell)); - await tester.enterText(find.byType(TextFormField), ' en-GB '); - await tester.tap( - find.byWidgetPredicate( - (widget) => widget is BusyMarkDialogButton && widget.label == l10n.save, - ), + expect( + find.text(l10n.settingsReopenWorkspaceOnStartupTitle), + findsOneWidget, ); - await tester.pump(kThemeAnimationDuration); - await _pumpUntilCondition( - tester, - () => harness.spelling.catalog?.installedById('en-GB') != null, - timeout: const Duration(seconds: 15), - diagnostics: () { - final imported = Directory( - p.join( - harness.temporary.path, - 'dictionary-storage', - 'imported', - 'en-GB', - ), - ); - return 'imported directory=${imported.existsSync()}, ' - 'catalog unavailable=${harness.spelling.catalog == null}, ' - 'installed=${harness.spelling.catalog?.installedById('en-GB')}'; - }, + expect( + find.text(l10n.settingsReopenWorkspaceOnStartupDescription), + findsOneWidget, ); + await tester.ensureVisible( + find.text(l10n.settingsReopenWorkspaceOnStartupTitle), + ); + await tester.tap(find.text(l10n.settingsReopenWorkspaceOnStartupTitle)); + await tester.pumpAndSettle(); - expect(find.byType(BusyMarkDialogShell), findsNothing); - expect(harness.spelling.catalog?.installedById('en-GB'), isNotNull); - }); - - testWidgets( - 'cancelling a default-language install keeps the previous selection', - (tester) async { - var downloadCount = 0; - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(defaultSpellingLanguage: 'en-Test') - .toJson(); - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: settingsStore, - downloadFile: - ({ - required source, - required destination, - required expectedBytes, - required cancellation, - required onProgress, - }) async { - downloadCount += 1; - await _copySettingsDictionaryDownload( - source: source, - destination: destination, - expectedBytes: expectedBytes, - cancellation: cancellation, - onProgress: onProgress, - ); - }, - ); - addTearDown(harness.dispose); - - await _selectSpellingLanguage( - tester, - selectorTooltip: l10n.defaultSpellingLanguage, - languageLabel: 'Test French', - ); + expect(settingsStore.value['reopenPreviousWorkspaceOnStartup'], isTrue); - expect(find.text(l10n.spellingDictionaryNotInstalled), findsOneWidget); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect(find.byType(AlertDialog), findsNothing); - expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); - await tester.tap(find.text(l10n.cancel)); - await _pumpSettingsUi(tester); + expect( + find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), + findsOneWidget, + ); + expect( + find.text(l10n.settingsConfirmCloseWithUnsavedChangesDescription), + findsOneWidget, + ); + await tester.ensureVisible( + find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), + ); + await tester.tap( + find.text(l10n.settingsConfirmCloseWithUnsavedChangesTitle), + ); + await tester.pumpAndSettle(); - expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); - expect( - _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, - 'Test English', - ); - expect(downloadCount, 0); - }, - ); + expect(settingsStore.value['confirmCloseWithUnsavedChanges'], isFalse); - testWidgets( - 'default language persists only after dictionary installation succeeds', - (tester) async { - var downloadCount = 0; - final allowDownload = Completer(); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(defaultSpellingLanguage: 'en-Test') - .toJson(); - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: settingsStore, - downloadFile: - ({ - required source, - required destination, - required expectedBytes, - required cancellation, - required onProgress, - }) async { - downloadCount += 1; - await allowDownload.future; - await _copySettingsDictionaryDownload( - source: source, - destination: destination, - expectedBytes: expectedBytes, - cancellation: cancellation, - onProgress: onProgress, - ); - }, - ); - addTearDown(harness.dispose); + await tester.tap( + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), + ); + await tester.pumpAndSettle(); - await _selectSpellingLanguage( - tester, - selectorTooltip: l10n.defaultSpellingLanguage, - languageLabel: 'Test French', - ); - await tester.tap(find.text(l10n.installSpellingDictionary)); - await _pumpUntilCondition(tester, () => downloadCount > 0); + expect(find.text(l10n.createMarkdownFile), findsOneWidget); + }); - expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); - expect( - _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, - 'Test English', - ); + testWidgets('settings uses the regular split sidebar at desktop width', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 760); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); - allowDownload.complete(); - await _pumpUntilCondition( - tester, - () => settingsStore.value['defaultSpellingLanguage'] == 'fr-Test', - timeout: const Duration(seconds: 15), - diagnostics: () => - 'downloads=$downloadCount, ' - 'installed=${harness.spelling.catalog?.installedById('fr-Test')}, ' - 'catalog unavailable=${harness.spelling.catalog == null}, ' - 'persisted=${settingsStore.value['defaultSpellingLanguage']}', - ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); - expect(harness.spelling.catalog?.installedById('fr-Test'), isNotNull); - expect(settingsStore.value['defaultSpellingLanguage'], 'fr-Test'); - expect( - _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, - 'Test French', - ); - expect(downloadCount, 2); - }, - ); + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); - testWidgets('failed dictionary install keeps the default language', ( - tester, - ) async { - var downloadCount = 0; - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(defaultSpellingLanguage: 'en-Test') - .toJson(); - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: settingsStore, - downloadFile: - ({ - required source, - required destination, - required expectedBytes, - required cancellation, - required onProgress, - }) async { - downloadCount += 1; - throw const SocketException('test dictionary download failure'); - }, + expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); + expect(find.byType(BusyMarkSidebarNavigation), findsOneWidget); + expect( + find.byType(BusyMarkSidebarNavigationTile), + findsNWidgets(SettingsPage.values.length - 1), ); - addTearDown(harness.dispose); + expect( + tester.getSize(find.byType(BusyMarkSidebarSurface)).width, + BusyMarkSizes.sidebarWidth, + ); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); - await _selectSpellingLanguage( - tester, - selectorTooltip: l10n.defaultSpellingLanguage, - languageLabel: 'Test French', + final appearanceTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-appearance')), ); - await tester.tap(find.text(l10n.installSpellingDictionary)); - await _pumpUntilCondition( - tester, - () => - harness.spelling.dictionaryInstallStatus?.phase == - SpellingDictionaryInstallPhase.failed, + final editorTile = tester.widget( + find.byKey(const ValueKey('settings-navigation-editor')), ); + expect(appearanceTile.selected, isTrue); + expect(editorTile.selected, isFalse); - expect(downloadCount, 1); - expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); - expect( - _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, - 'Test English', + var header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), ); - expect(find.text(l10n.commandUnavailableInContext), findsOneWidget); - }); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.sidebarToggleVisible, isFalse); + expect(header.configuration.title, l10n.appearance); - testWidgets( - 'cancelling a project-language install keeps the previous selection', - (tester) async { - var downloadCount = 0; - final harness = await _pumpSettingsSpellingHarness( - tester, - headerBarService: headerBarService, - settingsStore: _MemorySettingsStore(), - projectLanguage: 'en-Test', - downloadFile: - ({ - required source, - required destination, - required expectedBytes, - required cancellation, - required onProgress, - }) async { - downloadCount += 1; - await _copySettingsDictionaryDownload( - source: source, - destination: destination, - expectedBytes: expectedBytes, - cancellation: cancellation, - onProgress: onProgress, - ); - }, - ); - addTearDown(harness.dispose); + await tester.tap(find.byKey(const ValueKey('settings-navigation-editor'))); + await tester.pumpAndSettle(); - await _selectSpellingLanguage( - tester, - selectorTooltip: l10n.projectSpellingLanguage, - languageLabel: 'Test French', - ); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-editor')), + ) + .selected, + isTrue, + ); + expect( + tester + .widgetList(find.byType(BusyMarkGroupedList)) + .map((section) => section.title), + [ + l10n.editor, + l10n.settingsEditingButtonsSectionTitle, + l10n.spelling, + l10n.settingsDictionariesSectionTitle, + ], + ); + expect(find.text(l10n.autoSave), findsOneWidget); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.title, l10n.editor); - expect(find.text(l10n.spellingDictionaryNotInstalled), findsOneWidget); - expect(harness.spelling.projectWords.projectLanguage, 'en-Test'); - await tester.tap(find.text(l10n.cancel)); - await _pumpSettingsUi(tester); + expect(find.text(l10n.spellingDictionaries), findsOneWidget); + expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + expect(find.byType(Scrollable), findsWidgets); + expect( + tester.widget(find.byType(BusyMarkClamp)).scrollable, + isTrue, + ); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.title, l10n.editor); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-editor')), + ) + .selected, + isTrue, + ); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.sidebarVisible, isTrue); + expect(header.configuration.title, l10n.editor); - expect(harness.spelling.projectWords.projectLanguage, 'en-Test'); - expect( - _spellingLanguageSelector(tester, l10n.projectSpellingLanguage).label, - 'Test English', - ); - expect(downloadCount, 0); - final persistedSource = await tester.runAsync( - () => File(harness.projectSpellingPath!).readAsString(), - ); - final persisted = jsonDecode(persistedSource!); - expect((persisted as Map)['projectLanguage'], 'en-Test'); - }, - ); + final dictionariesNavigation = find.byKey( + const ValueKey('settings-spelling-dictionaries'), + ); + await tester.ensureVisible(dictionariesNavigation); + await tester.tap(dictionariesNavigation); + await tester.pumpAndSettle(); - testWidgets('settings main surface matches the headerbar in light and dark', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 760); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); + expect(find.byType(BusyMarkSidebarSurface), findsOneWidget); + expect(find.byKey(const ValueKey('settings-page-selector')), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-editor')), + ) + .selected, + isTrue, + ); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.title, l10n.spellingDictionaries); + expect(find.text(l10n.importSpellingDictionary), findsOneWidget); + expect(find.text(l10n.spellingDictionaryProblems), findsNothing); - for (final preference in [ - BusyMarkThemeModePreference.light, - BusyMarkThemeModePreference.dark, - ]) { - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(themeModePreference: preference) - .toJson(); - headerBarService = _FallbackHeaderBarService(); + await tester.tap( + find.byTooltip('${l10n.back} (${BusyMarkAppShortcuts.back.label})'), + ); + await tester.pumpAndSettle(); + header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + expect(header.configuration.title, l10n.editor); + expect(find.text(l10n.autoSave), findsOneWidget); + }); + testWidgets( + 'Spelling Dictionaries page groups downloadable and custom data', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + late Directory temporary; + late ({String bundle, String storage}) fixture; + late SpellingSessionController spelling; + await tester.runAsync(() async { + temporary = await Directory.systemTemp.createTemp( + 'busymark-settings-dictionaries-', + ); + fixture = await _createSettingsSpellingFixture(temporary); + spelling = SpellingSessionController( + bundledRoot: fixture.bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: fixture.storage, + verifyDictionaryChecksums: false, + ); + await spelling.prepareSettings(null); + }); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + spellingSessionControllerProvider.overrideWith((ref) => spelling), + ], + ); + addTearDown(() async { + container.dispose(); + if (await temporary.exists()) { + await temporary.delete(recursive: true); + } + }); await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - ], + UncontrolledProviderScope( + container: container, child: const BusyMarkApp(), ), ); @@ -2564,51 +2930,632 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text(l10n.settings)); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.editor)); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('settings-spelling-dictionaries')), + ); + await tester.pumpAndSettle(); - final surfaceFinder = find.byKey( - const ValueKey('settings-content-surface'), + final availableGroup = find.byWidgetPredicate( + (widget) => + widget is BusyMarkGroupedList && + widget.title == l10n.availableSpellingDictionaries, ); - expect(surfaceFinder, findsOneWidget); - final surface = tester.widget(surfaceFinder); - final surfaceContext = tester.element(surfaceFinder); - final colors = BusyMarkSurfaceColors.of(surfaceContext); - final header = tester.widget( - find.byType(HeaderBarConfigurationPublisher), + final customGroup = find.byWidgetPredicate( + (widget) => + widget is BusyMarkGroupedList && + widget.title == l10n.customSpellingDictionaries, ); - + final problemsGroup = find.byWidgetPredicate( + (widget) => + widget is BusyMarkGroupedList && + widget.title == l10n.spellingDictionaryProblems, + ); + expect(availableGroup, findsOneWidget); + expect(customGroup, findsOneWidget); + expect(problemsGroup, findsOneWidget); expect( - Theme.of(surfaceContext).brightness, - preference == BusyMarkThemeModePreference.dark - ? Brightness.dark - : Brightness.light, + find.byKey(const ValueKey('settings-page-selector')), + findsOneWidget, ); - expect(surface.color, colors.view); - expect(surface.color, header.configuration.theme.backgroundColor); - - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - } - }); - - testWidgets('stored language override localizes app text', (tester) async { - final de = AppLocalizationsDe(); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults().copyWith(localeTag: 'de').toJson(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip(de.mainMenu)); - await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const ValueKey('settings-page-selector')), + matching: find.text(l10n.editor), + ), + findsOneWidget, + ); + expect( + container + .read(appRouterProvider) + .routerDelegate + .currentConfiguration + .uri + .queryParameters['page'], + 'spellingDictionaries', + ); + expect(find.byType(Scrollable), findsOneWidget); + expect( + tester.widget(find.byType(BusyMarkClamp)).scrollable, + isTrue, + ); + expect(find.text(l10n.importSpellingDictionary), findsOneWidget); + expect(find.text('Test English'), findsOneWidget); + expect(find.text('Test French'), findsOneWidget); + expect(find.text(l10n.spellingDictionaryInstalled), findsOneWidget); + expect(find.textContaining('Not installed ·'), findsAtLeastNWidgets(1)); + expect(find.text('Local Test English'), findsOneWidget); + expect(find.text('xx-Test'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Test English')).dy, + lessThan(tester.getTopLeft(find.text('Test French')).dy), + ); + expect( + tester.getTopLeft(find.text('Test French')).dy, + lessThan( + tester.getTopLeft(find.text(l10n.importSpellingDictionary)).dy, + ), + ); + expect( + tester.getTopLeft(find.text(l10n.importSpellingDictionary)).dy, + lessThan(tester.getTopLeft(find.text('Local Test English')).dy), + ); + expect( + tester.getTopLeft(find.text('Local Test English')).dy, + lessThan(tester.getTopLeft(find.text('xx-Test')).dy), + ); + for (final resourceTitle in ['Test English', 'Test French']) { + final row = find.byWidgetPredicate( + (widget) => + widget is BusyMarkActionRow && widget.title == resourceTitle, + ); + expect( + find.descendant( + of: row, + matching: find.byType(BusyMarkCompactIconButton), + ), + findsOneWidget, + ); + final actionRow = tester.widget(row); + expect(actionRow.onTap, isNull); + expect(actionRow.destructive, isFalse); + } + + final importRow = tester.widget( + find.byKey(const ValueKey('import-spelling-dictionary')), + ); + expect(importRow.onTap, isNotNull); + final importedRow = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is BusyMarkActionRow && + widget.title == 'Local Test English', + ), + ); + expect(importedRow.destructive, isFalse); + expect(importedRow.onTap, isNull); + final importedRemove = importedRow.trailing! as BusyMarkCompactIconButton; + expect(importedRemove.onPressed, isNotNull); + expect( + importedRemove.foregroundColor, + Theme.of(tester.element(customGroup)).colorScheme.error, + ); + final invalidRow = tester.widget( + find.byWidgetPredicate( + (widget) => widget is BusyMarkActionRow && widget.title == 'xx-Test', + ), + ); + expect(invalidRow.destructive, isFalse); + expect(invalidRow.onTap, isNull); + final invalidRemove = invalidRow.trailing! as BusyMarkCompactIconButton; + expect(invalidRemove.onPressed, isNotNull); + expect( + invalidRemove.foregroundColor, + Theme.of(tester.element(problemsGroup)).colorScheme.error, + ); + + await tester.ensureVisible(find.text('Local Test English')); + await tester.tap(find.text('Local Test English')); + await tester.pumpAndSettle(); + expect( + spelling.catalog?.installations.any( + (entry) => entry.id == 'local-Test' && entry.imported, + ), + isTrue, + ); + }, + ); + + testWidgets( + 'import dictionary prompt uses native controls and submits a trimmed ID', + (tester) async { + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: _MemorySettingsStore(), + downloadFile: _copySettingsDictionaryDownload, + ); + addTearDown(harness.dispose); + const fileSelectorChannel = MethodChannel( + 'plugins.flutter.io/file_selector', + ); + final dictionaryFixture = p.join( + Directory.current.path, + 'packages', + 'busymark_spellcheck_native', + 'test', + 'fixtures', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + fileSelectorChannel, + (call) async { + expect(call.method, 'openFile'); + expect((call.arguments as Map)['multiple'], isTrue); + return [ + p.join(dictionaryFixture, 'test.aff'), + p.join(dictionaryFixture, 'test.dic'), + ]; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + fileSelectorChannel, + null, + ), + ); + + await _openImportDictionaryPrompt(tester); + + final dialog = find.byType(BusyMarkDialogShell); + expect(dialog, findsOneWidget); + final shell = tester.widget(dialog); + expect(shell.title, l10n.importSpellingDictionary); + expect(shell.closable, isFalse); + final entryFinder = find.descendant( + of: dialog, + matching: find.byType(BusyMarkFloatingTextEntry), + ); + expect(entryFinder, findsOneWidget); + final entry = tester.widget(entryFinder); + expect(entry.label, l10n.language); + expect(entry.hintText, 'en-US'); + expect(entry.autofocus, isTrue); + expect(entry.textInputAction, TextInputAction.done); + expect(entry.minLines, 1); + expect(entry.maxLines, 1); + final actions = tester + .widgetList( + find.descendant( + of: dialog, + matching: find.byType(BusyMarkDialogButton), + ), + ) + .toList(growable: false); + expect(actions, hasLength(2)); + expect(actions.map((action) => action.label), [l10n.cancel, l10n.save]); + expect(actions.first.suggested, isFalse); + expect(actions.last.suggested, isTrue); + expect(find.byType(AlertDialog), findsNothing); + + await tester.enterText(find.byType(TextFormField), ' '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _pumpSettingsUi(tester); + expect(dialog, findsOneWidget); + + await tester.enterText(find.byType(TextFormField), ' en-GB '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(kThemeAnimationDuration); + await _pumpUntilCondition( + tester, + () => harness.spelling.catalog?.installedById('en-GB') != null, + timeout: const Duration(seconds: 15), + diagnostics: () { + final imported = Directory( + p.join( + harness.temporary.path, + 'dictionary-storage', + 'imported', + 'en-GB', + ), + ); + return 'imported directory=${imported.existsSync()}, ' + 'installed=${harness.spelling.catalog?.installedById('en-GB')}, ' + 'invalid=${harness.spelling.catalog?.invalidInstallations}, ' + 'dialog visible=${dialog.evaluate().isNotEmpty}'; + }, + ); + + expect(dialog, findsNothing); + expect(harness.spelling.catalog?.installedById('en-GB'), isNotNull); + }, + ); + + testWidgets('import dictionary prompt cancels and saves without redesign', ( + tester, + ) async { + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: _MemorySettingsStore(), + downloadFile: _copySettingsDictionaryDownload, + ); + addTearDown(harness.dispose); + const fileSelectorChannel = MethodChannel( + 'plugins.flutter.io/file_selector', + ); + final dictionaryFixture = p.join( + Directory.current.path, + 'packages', + 'busymark_spellcheck_native', + 'test', + 'fixtures', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + fileSelectorChannel, + (call) async => [ + p.join(dictionaryFixture, 'test.aff'), + p.join(dictionaryFixture, 'test.dic'), + ], + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + fileSelectorChannel, + null, + ), + ); + + await _openImportDictionaryPrompt(tester); + await tester.tap( + find.byWidgetPredicate( + (widget) => + widget is BusyMarkDialogButton && widget.label == l10n.cancel, + ), + ); + await _pumpSettingsUi(tester); + + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(harness.spelling.catalog?.installedById('en-GB'), isNull); + + await tester.tap(find.byKey(const ValueKey('import-spelling-dictionary'))); + await _pumpUntilFound(tester, find.byType(BusyMarkDialogShell)); + await tester.enterText(find.byType(TextFormField), ' en-GB '); + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is BusyMarkDialogButton && widget.label == l10n.save, + ), + ); + await tester.pump(kThemeAnimationDuration); + await _pumpUntilCondition( + tester, + () => harness.spelling.catalog?.installedById('en-GB') != null, + timeout: const Duration(seconds: 15), + diagnostics: () { + final imported = Directory( + p.join( + harness.temporary.path, + 'dictionary-storage', + 'imported', + 'en-GB', + ), + ); + return 'imported directory=${imported.existsSync()}, ' + 'catalog unavailable=${harness.spelling.catalog == null}, ' + 'installed=${harness.spelling.catalog?.installedById('en-GB')}'; + }, + ); + + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(harness.spelling.catalog?.installedById('en-GB'), isNotNull); + }); + + testWidgets( + 'cancelling a default-language install keeps the previous selection', + (tester) async { + var downloadCount = 0; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(defaultSpellingLanguage: 'en-Test') + .toJson(); + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: settingsStore, + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloadCount += 1; + await _copySettingsDictionaryDownload( + source: source, + destination: destination, + expectedBytes: expectedBytes, + cancellation: cancellation, + onProgress: onProgress, + ); + }, + ); + addTearDown(harness.dispose); + + await _selectSpellingLanguage( + tester, + selectorTooltip: l10n.defaultSpellingLanguage, + languageLabel: 'Test French', + ); + + expect(find.text(l10n.spellingDictionaryNotInstalled), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); + await tester.tap(find.text(l10n.cancel)); + await _pumpSettingsUi(tester); + + expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); + expect( + _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, + 'Test English', + ); + expect(downloadCount, 0); + }, + ); + + testWidgets( + 'default language persists only after dictionary installation succeeds', + (tester) async { + var downloadCount = 0; + final allowDownload = Completer(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(defaultSpellingLanguage: 'en-Test') + .toJson(); + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: settingsStore, + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloadCount += 1; + await allowDownload.future; + await _copySettingsDictionaryDownload( + source: source, + destination: destination, + expectedBytes: expectedBytes, + cancellation: cancellation, + onProgress: onProgress, + ); + }, + ); + addTearDown(harness.dispose); + + await _selectSpellingLanguage( + tester, + selectorTooltip: l10n.defaultSpellingLanguage, + languageLabel: 'Test French', + ); + await tester.tap(find.text(l10n.installSpellingDictionary)); + await _pumpUntilCondition(tester, () => downloadCount > 0); + + expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); + expect( + _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, + 'Test English', + ); + + allowDownload.complete(); + await _pumpUntilCondition( + tester, + () => settingsStore.value['defaultSpellingLanguage'] == 'fr-Test', + timeout: const Duration(seconds: 15), + diagnostics: () => + 'downloads=$downloadCount, ' + 'installed=${harness.spelling.catalog?.installedById('fr-Test')}, ' + 'catalog unavailable=${harness.spelling.catalog == null}, ' + 'persisted=${settingsStore.value['defaultSpellingLanguage']}', + ); + + expect(harness.spelling.catalog?.installedById('fr-Test'), isNotNull); + expect(settingsStore.value['defaultSpellingLanguage'], 'fr-Test'); + expect( + _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, + 'Test French', + ); + expect(downloadCount, 2); + }, + ); + + testWidgets('failed dictionary install keeps the default language', ( + tester, + ) async { + var downloadCount = 0; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(defaultSpellingLanguage: 'en-Test') + .toJson(); + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: settingsStore, + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloadCount += 1; + throw const SocketException('test dictionary download failure'); + }, + ); + addTearDown(harness.dispose); + + await _selectSpellingLanguage( + tester, + selectorTooltip: l10n.defaultSpellingLanguage, + languageLabel: 'Test French', + ); + await tester.tap(find.text(l10n.installSpellingDictionary)); + await _pumpUntilCondition( + tester, + () => + harness.spelling.dictionaryInstallStatus?.phase == + SpellingDictionaryInstallPhase.failed, + ); + + expect(downloadCount, 1); + expect(settingsStore.value['defaultSpellingLanguage'], 'en-Test'); + expect( + _spellingLanguageSelector(tester, l10n.defaultSpellingLanguage).label, + 'Test English', + ); + expect(find.text(l10n.commandUnavailableInContext), findsOneWidget); + }); + + testWidgets( + 'cancelling a project-language install keeps the previous selection', + (tester) async { + var downloadCount = 0; + final harness = await _pumpSettingsSpellingHarness( + tester, + headerBarService: headerBarService, + settingsStore: _MemorySettingsStore(), + projectLanguage: 'en-Test', + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloadCount += 1; + await _copySettingsDictionaryDownload( + source: source, + destination: destination, + expectedBytes: expectedBytes, + cancellation: cancellation, + onProgress: onProgress, + ); + }, + ); + addTearDown(harness.dispose); + + await _selectSpellingLanguage( + tester, + selectorTooltip: l10n.projectSpellingLanguage, + languageLabel: 'Test French', + ); + + expect(find.text(l10n.spellingDictionaryNotInstalled), findsOneWidget); + expect(harness.spelling.projectWords.projectLanguage, 'en-Test'); + await tester.tap(find.text(l10n.cancel)); + await _pumpSettingsUi(tester); + + expect(harness.spelling.projectWords.projectLanguage, 'en-Test'); + expect( + _spellingLanguageSelector(tester, l10n.projectSpellingLanguage).label, + 'Test English', + ); + expect(downloadCount, 0); + final persistedSource = await tester.runAsync( + () => File(harness.projectSpellingPath!).readAsString(), + ); + final persisted = jsonDecode(persistedSource!); + expect((persisted as Map)['projectLanguage'], 'en-Test'); + }, + ); + + testWidgets('settings main surface matches the headerbar in light and dark', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 760); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + for (final preference in [ + BusyMarkThemeModePreference.light, + BusyMarkThemeModePreference.dark, + ]) { + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(themeModePreference: preference) + .toJson(); + headerBarService = _FallbackHeaderBarService(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + + final surfaceFinder = find.byKey( + const ValueKey('settings-content-surface'), + ); + expect(surfaceFinder, findsOneWidget); + final surface = tester.widget(surfaceFinder); + final surfaceContext = tester.element(surfaceFinder); + final colors = BusyMarkSurfaceColors.of(surfaceContext); + final header = tester.widget( + find.byType(HeaderBarConfigurationPublisher), + ); + + expect( + Theme.of(surfaceContext).brightness, + preference == BusyMarkThemeModePreference.dark + ? Brightness.dark + : Brightness.light, + ); + expect(surface.color, colors.view); + expect(surface.color, header.configuration.theme.backgroundColor); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + }); + + testWidgets('stored language override localizes app text', (tester) async { + final de = AppLocalizationsDe(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults().copyWith(localeTag: 'de').toJson(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip(de.mainMenu)); + await tester.pumpAndSettle(); await tester.tap(find.text(de.settings)); await tester.pumpAndSettle(); @@ -2848,160 +3795,115 @@ void main() { ); }); - testWidgets('keyboard shortcuts dialog opens from the header', ( - tester, - ) async { - final l10n = AppLocalizationsEn(); - await tester.pumpWidget( - ProviderScope( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - ], - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip(l10n.mainMenu)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.keyboardShortcuts)); - await tester.pumpAndSettle(); - - expect(find.text(l10n.keyboardShortcuts), findsWidgets); - expect(find.text(l10n.shortcutNewDocumentDescription), findsOneWidget); - expect(find.text(l10n.shortcutOpenDescription), findsOneWidget); - expect(find.text(l10n.shortcutSaveDescription), findsOneWidget); - expect(find.text(l10n.shortcutGroupGeneral), findsOneWidget); - expect(find.text(l10n.shortcutSearchDescription), findsOneWidget); - expect( - find.text(l10n.shortcutKeyboardShortcutsDescription), - findsOneWidget, - ); - expect(find.text(l10n.shortcutSyntaxReferenceDescription), findsOneWidget); - expect(find.text(l10n.shortcutSettingsDescription), findsOneWidget); - expect(find.text(l10n.shortcutNextTabDescription), findsOneWidget); - expect(find.text(l10n.shortcutPreviousTabDescription), findsOneWidget); - expect(find.text(l10n.shortcutCloseTabDescription), findsOneWidget); - expect(find.text(l10n.shortcutCloseAllTabsDescription), findsOneWidget); - expect(find.text('Show shortcuts over toolbar buttons'), findsNothing); - expect(find.text(l10n.shortcutUndoDescription), findsOneWidget); - expect(find.text(l10n.shortcutRedoDescription), findsOneWidget); - expect( - find.text(l10n.shortcutInsertIndentationDescription), - findsOneWidget, - ); - expect(find.text(l10n.shortcutOutdentSourceDescription), findsOneWidget); - expect(find.text(l10n.shortcutEscapeDescription), findsOneWidget); - expect(find.text(l10n.shortcutBoldDescription), findsOneWidget); - expect(find.text(l10n.shortcutUnderlineDescription), findsOneWidget); - expect(find.text(l10n.shortcutStrikethroughDescription), findsOneWidget); - expect(find.text(l10n.shortcutParagraphDescription), findsOneWidget); - expect(find.text(l10n.shortcutHeading1Description), findsOneWidget); - expect(find.text(l10n.shortcutHeading6Description), findsOneWidget); - expect(find.text(l10n.shortcutNumberedListDescription), findsOneWidget); - expect(find.text(l10n.shortcutBulletedListDescription), findsOneWidget); - 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); - expect(find.text(l10n.shortcutDeleteTreeItemDescription), findsOneWidget); - expect(find.text(l10n.viewMode), findsOneWidget); - expect(find.text(l10n.editor), findsOneWidget); - expect(find.text(l10n.source), findsOneWidget); - expect(find.text(l10n.reading), findsOneWidget); - expect(find.text(l10n.split), findsOneWidget); - - final expectedShortcutLabels = { - ...BusyMarkAppShortcuts.definitions.values.map( - (definition) => definition.label, - ), - ...BusyMarkDocumentViewShortcuts.definitions.values.map( - (definition) => definition.label, - ), - ...BusyMarkTextEditingShortcuts.definitions.values.map( - (definition) => definition.label, - ), - ...BusyMarkEditorShortcuts.definitions.values.map( - (definition) => definition.label, - ), - ...BusyMarkSidebarShortcuts.definitions.values.map( - (definition) => definition.label, - ), - ...BusyMarkTreeShortcuts.definitions.values.map( - (definition) => definition.label, - ), - 'F7', - 'Ctrl+Shift+Home', - 'Ctrl+Shift+End', - }; - final shortcutRowFinder = find.descendant( - of: find.byType(BusyMarkInformationalDialog), - matching: find.byType(BusyMarkActionRow), - ); - 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; - expect(trailing, isNotNull, reason: 'Shortcut rows need a badge.'); - final badgeLabels = tester - .widgetList( - find.descendant( - of: find.byWidget(trailing!), - matching: find.byType(Text), - ), - ) - .map((text) => text.data) - .whereType() - .toList(); - expect(badgeLabels, hasLength(1)); - displayedShortcutLabels.add(badgeLabels.single); - } - expect(shortcutRows, hasLength(expectedShortcutLabels.length)); - expect( - displayedShortcutLabels.toSet(), - expectedShortcutLabels, - reason: 'The popup must stay synchronized with every shortcut registry.', - ); - expect(find.text('Alt'), findsNothing); - expect(find.text('Esc'), findsOneWidget); - expect(find.text('Close'), findsNothing); - - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + testWidgets( + 'Keyboard Shortcuts opens its categorized Settings page and safely records assignments', + (tester) async { + final store = _MemorySettingsStore(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(store), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(l10n.mainMenu)); + await tester.pumpAndSettle(); + expect(find.text(l10n.searchDocumentsCommand), findsNothing); + expect(find.text(l10n.searchActionsCommand), findsNothing); + expect(find.text(l10n.keyboardShortcuts), findsNothing); + await tester.tap(find.text(l10n.settings)); + await tester.pumpAndSettle(); + await _selectShortcutSettingsPage(tester); + expect(find.byType(KeyboardShortcutsSettings), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + final container = ProviderScope.containerOf( + tester.element(find.byType(KeyboardShortcutsSettings)), + ); + final router = container.read(appRouterProvider); + expect( + router.routeInformationProvider.value.uri.queryParameters, + containsPair('returnTo', 'welcome'), + ); + await _browseShortcutRow(tester, 'file.save'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('record-shortcut'))); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyO); + await tester.pump(); + expect( + find.descendant( + of: find.byWidgetPredicate( + (w) => + w is BusyMarkActionRow && + w.title == l10n.shortcutProposedBinding, + ), + matching: find.byWidgetPredicate( + (w) => w is BusyMarkKeyboardShortcutBadge && w.label == 'Ctrl+O', + ), + ), + findsOneWidget, + ); + expect(find.text(l10n.shortcutConflicts), findsOneWidget); + expect( + find.byType(Dialog), + findsOneWidget, + reason: 'Capturing Ctrl+O must not open the application chooser', + ); + expect( + container.read(appSettingsControllerProvider).shortcutOverrides, + isEmpty, + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyO); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.text(l10n.cancel).last); + await tester.pumpAndSettle(); + expect( + container.read(appSettingsControllerProvider).shortcutOverrides, + isEmpty, + ); + await tester.tap(find.byKey(const ValueKey('shortcut-edit-file.save'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('record-shortcut'))); + await tester.sendKeyEvent(LogicalKeyboardKey.f12); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('assign-shortcut'))); + await tester.pumpAndSettle(); + expect( + container + .read(busyMarkCommandRegistryProvider)['file.save']! + .shortcut! + .label, + 'F12', + ); + expect(find.text('F12'), findsOneWidget); + expect( + find.byKey(const ValueKey('settings-shortcut-search')), + findsNothing, + ); + expect(find.byKey(const ValueKey('find-by-shortcut')), findsNothing); + await _browseShortcutRow(tester, 'view.source'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('remove-shortcut'))); + await tester.pumpAndSettle(); + expect( + container + .read(busyMarkCommandRegistryProvider)['view.source']! + .shortcut, + isNull, + ); + expect(find.byType(Dialog), findsNothing); - expect(find.text(l10n.shortcutNewDocumentDescription), findsNothing); - }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pumpAndSettle(); + expect(find.byType(WelcomeScreen), findsOneWidget); + }, + ); testWidgets('syntax reference categories expose supported authoring forms', ( tester, @@ -3330,7 +4232,7 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); - expect(BusyMarkAppShortcutLabels.syntaxReference, 'Ctrl+Alt+M'); + expect(BusyMarkAppShortcuts.syntaxReference.label, 'Ctrl+Alt+M'); expect( find.byKey(const ValueKey('syntax-reference-dialog')), findsOneWidget, @@ -3413,6 +4315,79 @@ void main() { expect(dialog, findsNothing); }); + testWidgets( + 'shortcut Settings keeps persistence failure visible and retries the current assignment', + (tester) async { + final store = _MemorySettingsStore(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(store), + ], + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + await _selectShortcutSettingsPage(tester); + await _browseShortcutRow(tester, 'file.save'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('record-shortcut'))); + await tester.sendKeyEvent(LogicalKeyboardKey.f8); + await tester.pump(); + final previous = Map.of(store.value); + store.failSaves = true; + await tester.tap(find.byKey(const ValueKey('assign-shortcut'))); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsOneWidget); + expect(find.byKey(const ValueKey('shortcut-feedback')), findsOneWidget); + expect(find.text(l10n.shortcutSaveFailed), findsWidgets); + expect( + store.value, + previous, + reason: 'Failed publication leaves persisted preferences intact', + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(KeyboardShortcutsSettings)), + ); + expect( + container + .read(busyMarkCommandRegistryProvider)['file.save']! + .shortcut! + .label, + 'F8', + ); + expect( + container.read(keymapControllerProvider), + KeymapUpdateStatus.failed, + ); + store.failSaves = false; + await tester.tap(find.byKey(const ValueKey('assign-shortcut'))); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + expect(find.text(l10n.shortcutSaveFailed), findsNothing); + expect( + container.read(keymapControllerProvider), + KeymapUpdateStatus.saved, + ); + expect( + AppSettings.fromJson(store.value).shortcutOverrides['file.save']!.key, + LogicalKeyboardKey.f8, + ); + expect( + AppSettings.fromJson(store.value).autoSave, + AppSettings.fromJson(previous).autoSave, + ); + }, + ); + testWidgets('keyboard shortcuts tabs section title is localized', ( tester, ) async { @@ -3434,13 +4409,34 @@ void main() { await tester.tap(find.byTooltip(fr.mainMenu)); await tester.pumpAndSettle(); - await tester.tap(find.text(fr.keyboardShortcuts)); + expect(find.text(fr.keyboardShortcuts), findsNothing); + await tester.tap(find.text(fr.settings)); await tester.pumpAndSettle(); + await _selectShortcutSettingsPage(tester); - expect(find.text(fr.tabs), findsOneWidget); - expect(fr.shortcutGroupSidebar, isNot('Sidebar')); - expect(find.text(fr.shortcutGroupSidebar), findsOneWidget); + await _revealShortcutRow(tester, 'tabs.next'); + expect( + find.byWidgetPredicate( + (w) => + w is Semantics && + w.properties.header == true && + w.child is Text && + (w.child as Text).data == fr.tabs, + ), + findsOneWidget, + ); expect(find.text('Tabs'), findsNothing); + await _revealShortcutRow(tester, 'view.toggleSidebar'); + expect( + find.byWidgetPredicate( + (w) => + w is Semantics && + w.properties.header == true && + w.child is Text && + (w.child as Text).data == fr.shortcutGroupSidebar, + ), + findsOneWidget, + ); expect(find.text('Sidebar'), findsNothing); }); @@ -7416,13 +8412,13 @@ code expect( find.byTooltip( - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', ), findsOneWidget, ); _expectNeutralSidebarToggle( tester, - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', selected: true, ); _expectHeaderControlGap( @@ -7496,13 +8492,13 @@ code ); expect( find.byTooltip( - '${l10n.showSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + '${l10n.showSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', ), findsOneWidget, ); _expectNeutralSidebarToggle( tester, - '${l10n.showSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + '${l10n.showSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', selected: false, ); @@ -7512,7 +8508,7 @@ code expect(tester.state(find.byKey(sidebarKey)), same(sidebarState)); _expectNeutralSidebarToggle( tester, - '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', + '${l10n.hideSidebar} (${BusyMarkSidebarShortcuts.toggleSidebar.label})', selected: true, ); _expectHeaderGeometry(tester); @@ -7656,14 +8652,8 @@ code expect(find.text('Intro.md'), findsWidgets); expect(find.byTooltip(temp.path), findsNothing); expect(find.byTooltip(l10n.gitActions), findsNothing); - expect( - tester - .widget( - find.byKey(const ValueKey('workspace-sidebar-view-menu')), - ) - .icon, - BusyMarkGlyphs.indent, - ); + expect(container.read(searchSessionProvider).active, isFalse); + expect(find.byKey(const ValueKey('shared-search-query')), findsNothing); final outlineFileMenu = find.byKey( const ValueKey('workspace-sidebar-outline-file-menu'), ); @@ -7694,15 +8684,17 @@ code await tester.pumpAndSettle(); expect(clipboardText, first.path); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); await tester.tap(find.byTooltip(l10n.sidebarViewMenu)); await tester.pumpAndSettle(); for (final (label, shortcut) in <(String, String)>[ - (l10n.files, BusyMarkSidebarShortcutLabels.files), - (l10n.outline, BusyMarkSidebarShortcutLabels.outline), - (l10n.git, BusyMarkSidebarShortcutLabels.git), - (l10n.localHistory, BusyMarkSidebarShortcutLabels.localHistory), - (l10n.clipboardHistory, BusyMarkSidebarShortcutLabels.clipboardHistory), + (l10n.files, BusyMarkSidebarShortcuts.files.label), + (l10n.outline, BusyMarkSidebarShortcuts.outline.label), + (l10n.git, BusyMarkSidebarShortcuts.git.label), + (l10n.localHistory, BusyMarkSidebarShortcuts.localHistory.label), + (l10n.clipboardHistory, BusyMarkSidebarShortcuts.clipboardHistory.label), ]) { expect(find.text(label), findsOneWidget); expect(find.text(shortcut), findsOneWidget); @@ -7717,7 +8709,17 @@ code expect(find.text(l10n.gitProjectHistory), findsNothing); expect(find.text(l10n.findLocalHistoryEllipsis), findsNothing); expect(find.text(l10n.export), findsNothing); - expect(find.byIcon(BusyMarkGlyphs.branch), findsOneWidget); + final gitEntry = find.ancestor( + of: find.text(l10n.git), + matching: find.byWidgetPredicate((widget) => widget is PopupMenuItem), + ); + expect( + find.descendant( + of: gitEntry, + matching: find.byIcon(BusyMarkGlyphs.branch), + ), + findsOneWidget, + ); expect(find.byIcon(BusyMarkGlyphs.sidebarLocalHistory), findsOneWidget); }); @@ -7873,8 +8875,12 @@ code }); expect(capturedSources, contains(formatted)); + container + .read(searchSessionProvider.notifier) + .openHistory(documentId: history.selectedDocumentId); + await tester.pump(); final historySearch = find.descendant( - of: find.byType(LocalHistoryPanel), + of: find.byKey(const ValueKey('shared-search-query')), matching: find.byType(TextField), ); await tester.enterText(historySearch, 'needle'); @@ -7885,24 +8891,37 @@ code 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, + await _waitForSearchCondition( + tester, + () => + !container.read(searchSessionProvider).searching && + container + .read(searchSessionProvider) + .results + .whereType() + .length == + 1, ); + history = container.read(localHistoryControllerProvider); - expect(history.searchQuery, 'needle'); - expect(history.searchMatches, hasLength(1)); + expect(container.read(searchSessionProvider).query, 'needle'); + expect( + container + .read(searchSessionProvider) + .results + .whereType(), + hasLength(1), + ); expect( tester.widget(historySearch).controller!.text, 'needle', ); + await tester.pump(BusyMarkMotion.search); + expect(find.byType(LocalHistoryPanel), findsNothing); expect( - find.descendant( - of: find.byType(LocalHistoryPanel), - matching: find.byWidgetPredicate( - (widget) => widget is BusyMarkSidebarRecordRow, - ), - ), - findsOneWidget, + history.selectedRevisions, + hasLength(3), + reason: 'Search never filters the ordinary timeline.', ); await tester.tap(sourceField); @@ -7945,10 +8964,7 @@ code ); now = now.add(const Duration(seconds: 60)); timers.lastWhere((timer) => timer.isActive).fire(); - await waitForHistory( - (history) => - history.selectedRevisions.length == 4 && !history.searching, - ); + await waitForHistory((history) => history.selectedRevisions.length == 4); history = container.read(localHistoryControllerProvider); final finalSources = []; await tester.runAsync(() async { @@ -7959,8 +8975,20 @@ code } }); expect(finalSources, contains(redone)); - expect(history.searchQuery, 'needle'); - expect(history.searchMatches, hasLength(2)); + await _waitForSearchCondition( + tester, + () => + !container.read(searchSessionProvider).searching && + container.read(searchSessionProvider).results.length == 2, + ); + expect(container.read(searchSessionProvider).query, 'needle'); + expect( + container + .read(searchSessionProvider) + .results + .whereType(), + hasLength(2), + ); }, ); @@ -9699,10 +10727,10 @@ code final initialToggleRect = tester.getRect(hideButton); final unorderedTooltip = '${l10n.unorderedList} ' - '(${BusyMarkEditorShortcutLabels.unorderedList})'; + '(${BusyMarkEditorShortcuts.unorderedList.label})'; final orderedTooltip = '${l10n.orderedList} ' - '(${BusyMarkEditorShortcutLabels.orderedList})'; + '(${BusyMarkEditorShortcuts.orderedList.label})'; final horizontalUnorderedRect = tester.getRect( find.byTooltip(unorderedTooltip), ); @@ -12048,6 +13076,38 @@ Gamma body. final headingBounds = tester.getRect(editorFields.first); expect(headingBounds.bottom, greaterThan(viewportBounds.top)); expect(headingBounds.top, lessThan(viewportBounds.bottom)); + final searchSession = container.read(searchSessionProvider.notifier); + searchSession.open(category: SearchCategory.outline); + searchSession.query('Unsaved'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + final result = + container.read(searchSessionProvider).results.single + as OutlineSearchResult; + expect( + result.heading.editorBlockId, + state.liveOutline!.headings.single.editorBlockId, + ); + editorList().itemScrollController!.jumpTo( + index: editorList().itemCount - 1, + ); + await tester.pump(); + expect(editorFieldWithText('Unsaved heading'), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await _waitForSearchCondition( + tester, + () => editorFieldWithText('Unsaved heading').evaluate().isNotEmpty, + ); + expect( + container.read(workspaceControllerProvider).activeText, + state.activeText, + ); + expect( + container.read(searchSessionProvider).results.single.identity, + result.identity, + ); }); testWidgets('untitled outline navigates before the first save', ( @@ -12340,217 +13400,1161 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final settingsStore = _MemorySettingsStore() ..value = AppSettings.defaults() - .copyWith(documentViewMode: DocumentViewModePreference.preview) + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MarkdownImageView), findsNWidgets(2)); + for (final (destination, expectedPath) in const [ + ('block-guide.md', blockTargetPath), + ('inline-guide.md', inlineTargetPath), + ]) { + await tester.tap( + find.byKey(ValueKey('preview-linked-image-$destination')), + ); + await tester.pump(); + expect(controller.openedFilePath, expectedPath); + } + }); + + testWidgets('new empty document leaves remembered preview mode for editor', ( + tester, + ) async { + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(_StartupWorkspaceService()), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(l10n.createMarkdownFile)); + await tester.pumpAndSettle(); + + expect( + container.read(appSettingsControllerProvider).documentViewMode, + DocumentViewModePreference.editor, + ); + expect( + find.byKey(const ValueKey('wysiwyg-document-scroll')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('preview-document-scroll')), findsNothing); + }); + + testWidgets('blocked remote image prompt allows the current workspace', ( + tester, + ) async { + const startupPath = '/tmp/remote-image.md'; + final service = _SearchWorkspaceService( + '![Remote](https://example.com/logo.png)\n', + ); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 20; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.text(l10n.remoteImageBlocked).evaluate().isNotEmpty) { + break; + } + } + + expect(find.text(l10n.remoteImageBlocked), findsOneWidget); + + await tester.tap(find.text(l10n.remoteImageBlocked)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.remoteImagesBlockedTitle), findsOneWidget); + + await tester.tap(find.text(l10n.loadRemoteImagesForWorkspace)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + settingsStore.value['remoteImageAllowedWorkspacePaths'], + contains(startupPath), + ); + }); + + testWidgets('Ctrl+O open chooser lists recent workspaces', (tester) async { + const startupPath = 'test/fixtures/markdown/basic.md'; + final recentDirectory = Directory.systemTemp.createTempSync( + 'busymark-recent-docs-', + ); + final recentPath = recentDirectory.path; + addTearDown(() => recentDirectory.deleteSync(recursive: true)); + final service = _StartupWorkspaceService(); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + recentWorkspaces: [ + RecentResource( + path: recentPath, + kind: 'markdownFolder', + lastOpenedAt: DateTime(2026, 1, 2), + ), + ], + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } + + expect(service.openedPath, startupPath); + expect(find.text(l10n.openMarkdownFile), findsNothing); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyO); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyO); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(find.text(l10n.openMarkdownFile), findsOneWidget); + expect(find.text(l10n.recent), findsOneWidget); + expect(find.text(p.basename(recentPath)), findsOneWidget); + + await tester.tap(find.text(p.basename(recentPath))); + await _waitForSearchCondition( + tester, + () => service.openedPath == recentPath, + ); + await tester.pumpAndSettle(); + + expect(service.openedPath, recentPath); + expect(find.text(l10n.openMarkdownFile), findsNothing); + }); + + testWidgets('recent workspace menus support pointer and keyboard actions', ( + tester, + ) async { + const recentPath = '/tmp/busymark-recent-actions'; + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final arguments = call.arguments as Map; + clipboardText = arguments['text'] as String?; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + recentWorkspaces: [ + RecentResource( + path: recentPath, + kind: 'markdownFolder', + lastOpenedAt: DateTime(2026, 1, 2), + ), + ], + ) .toJson(); final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceControllerProvider.overrideWith(() => controller), + startupPathProvider.overrideWithValue(null), ], ); addTearDown(container.dispose); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + await tester.pumpAndSettle(); + + final recentLabel = find.text('busymark-recent-actions'); + expect(recentLabel, findsOneWidget); + final recentRow = find + .ancestor(of: recentLabel, matching: find.byType(InkWell)) + .first; + final menuButton = find.descendant( + of: recentRow, + matching: find.byTooltip(l10n.actions), + ); + expect(menuButton, findsOneWidget); + + await tester.tap(menuButton); + await tester.pumpAndSettle(); + expect(find.text(l10n.openInFiles), findsOneWidget); + expect(find.text(l10n.copyPath), findsOneWidget); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + + await tester.tap(find.text(l10n.copyPath)); + await tester.pumpAndSettle(); + expect(clipboardText, recentPath); + + await tester.tap(recentRow, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.f10); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(find.text(l10n.removeFromRecent), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.tap(recentRow, buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.removeFromRecent)); + await tester.pumpAndSettle(); + + expect(recentLabel, findsNothing); + expect( + container.read(appSettingsControllerProvider).recentWorkspaces, + isEmpty, + ); + expect(AppSettings.fromJson(settingsStore.value).recentWorkspaces, isEmpty); + }); + + testWidgets('shared Markdown image renderer resolves local images', ( + tester, + ) async { + final temp = Directory.systemTemp.createTempSync('busymark_preview_image_'); + try { + File('${temp.path}/logo.png').writeAsBytesSync( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', + ), + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Logo](logo.png)\n'); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'logo.png', + alt: 'Logo', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + expect(find.text('logo.png'), findsNothing); + } finally { + temp.deleteSync(recursive: true); + } + }); + + testWidgets( + 'shared Markdown image renderer blocks remote images by default', + (tester) async { + var promptCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'https://example.com/logo.png', + alt: 'Logo', + activeFilePath: 'doc.md', + workspaceRoot: null, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: false, + onRemoteImageBlocked: () { + promptCount += 1; + }, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text(l10n.remoteImageBlocked), findsOneWidget); + expect(find.byType(Image), findsNothing); + + await tester.tap(find.text(l10n.remoteImageBlocked)); + await tester.pump(); + + expect(promptCount, 1); + }, + ); + + testWidgets( + 'shared Markdown image renderer resolves absolute paths with spaces', + (tester) async { + final temp = Directory.systemTemp.createTempSync( + 'busymark_preview_image_space_', + ); + try { + final image = File('${temp.path}/Screenshot From 2026.png') + ..writeAsBytesSync( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', + ), + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Screenshot](${image.path})\n'); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: image.path, + alt: 'Screenshot', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + expect(find.text(image.path), findsNothing); + } finally { + temp.deleteSync(recursive: true); + } + }, + ); + + testWidgets( + 'shared Markdown image renderer resolves absolute paths outside workspace', + (tester) async { + final workspace = Directory.systemTemp.createTempSync( + 'busymark_preview_image_workspace_', + ); + final outside = Directory.systemTemp.createTempSync( + 'busymark_preview_image_outside_', + ); + try { + final image = File('${outside.path}/outside.png') + ..writeAsBytesSync( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', + ), + ); + final markdown = File('${workspace.path}/image.md') + ..writeAsStringSync('# Image\n\n![Outside](${image.path})\n'); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: image.path, + alt: 'Outside', + activeFilePath: markdown.path, + workspaceRoot: workspace.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + expect(find.textContaining(image.path), findsNothing); + } finally { + workspace.deleteSync(recursive: true); + outside.deleteSync(recursive: true); + } + }, + ); + + testWidgets('shared Markdown image renderer resolves Snap real-home paths', ( + tester, + ) async { + final root = Directory.systemTemp.createTempSync( + 'busymark_preview_image_snap_home_', ); - await tester.pumpAndSettle(); + try { + final realHome = Directory(p.join(root.path, 'real-home'))..createSync(); + final snapHome = Directory(p.join(root.path, 'snap-home'))..createSync(); + final downloads = Directory(p.join(realHome.path, 'Downloads')) + ..createSync(); + File(p.join(downloads.path, 'example.jpg')).writeAsBytesSync( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', + ), + ); + debugLocalImageEnvironmentOverride = { + 'SNAP_REAL_HOME': realHome.path, + 'HOME': snapHome.path, + }; + addTearDown(() { + debugLocalImageEnvironmentOverride = null; + }); - expect(find.byType(MarkdownImageView), findsNWidgets(2)); - for (final (destination, expectedPath) in const [ - ('block-guide.md', blockTargetPath), - ('inline-guide.md', inlineTargetPath), - ]) { - await tester.tap( - find.byKey(ValueKey('preview-linked-image-$destination')), + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: MarkdownImageView( + source: '~/Downloads/example.jpg', + alt: 'Example', + activeFilePath: 'Untitled.md', + workspaceRoot: null, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), ); await tester.pump(); - expect(controller.openedFilePath, expectedPath); + + expect(find.byType(Image), findsOneWidget); + expect(find.textContaining('~/Downloads/example.jpg'), findsNothing); + } finally { + debugLocalImageEnvironmentOverride = null; + root.deleteSync(recursive: true); } }); - testWidgets('new empty document leaves remembered preview mode for editor', ( + testWidgets('shared Markdown image renderer resolves local SVG images', ( tester, ) async { - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(documentViewMode: DocumentViewModePreference.preview) - .toJson(); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(_StartupWorkspaceService()), - ], - ); - addTearDown(container.dispose); + final temp = Directory.systemTemp.createTempSync('busymark_preview_svg_'); + try { + File('${temp.path}/logo.svg').writeAsStringSync( + '' + '' + '', + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Logo](logo.svg)\n'); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'logo.svg', + alt: 'Logo', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); - await tester.tap(find.text(l10n.createMarkdownFile)); - await tester.pumpAndSettle(); + expect(find.byType(SvgPicture), findsNothing); + await _pumpUntilFound(tester, find.byType(SvgPicture)); - expect( - container.read(appSettingsControllerProvider).documentViewMode, - DocumentViewModePreference.editor, - ); - expect( - find.byKey(const ValueKey('wysiwyg-document-scroll')), - findsOneWidget, - ); - expect(find.byKey(const ValueKey('preview-document-scroll')), findsNothing); + expect(find.textContaining('logo.svg'), findsNothing); + expect(find.byType(SvgPicture), findsOneWidget); + } finally { + temp.deleteSync(recursive: true); + } }); - testWidgets('blocked remote image prompt allows the current workspace', ( + testWidgets('shared Markdown image renderer rejects oversized local SVGs', ( tester, ) async { - const startupPath = '/tmp/remote-image.md'; - final service = _SearchWorkspaceService( - '![Remote](https://example.com/logo.png)\n', - ); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith(documentViewMode: DocumentViewModePreference.preview) - .toJson(); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(service), - startupPathProvider.overrideWithValue(startupPath), - ], + final temp = Directory.systemTemp.createTempSync( + 'busymark_preview_svg_oversized_', ); - addTearDown(container.dispose); + try { + final padding = ''.padRight(1024 * 1024, 'x'); + File('${temp.path}/oversized.svg').writeAsStringSync( + '' + '' + '', + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Large](oversized.svg)\n'); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - for (var i = 0; i < 20; i += 1) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.remoteImageBlocked).evaluate().isNotEmpty) { - break; - } - } + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'oversized.svg', + alt: 'Large', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await _pumpUntilFound(tester, find.textContaining('oversized.svg')); - expect(find.text(l10n.remoteImageBlocked), findsOneWidget); + expect(find.byType(SvgPicture), findsNothing); + expect(find.textContaining('oversized.svg'), findsOneWidget); + } finally { + temp.deleteSync(recursive: true); + } + }); - await tester.tap(find.text(l10n.remoteImageBlocked)); - await tester.pumpAndSettle(); + testWidgets( + 'shared Markdown image renderer sanitizes embedded SVG image data', + (tester) async { + final temp = Directory.systemTemp.createTempSync( + 'busymark_preview_svg_data_', + ); + try { + final embeddedSvg = Uri.encodeComponent( + '' + '' + '', + ); + File('${temp.path}/badge.svg').writeAsStringSync( + '' + '' + '' + '', + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Badge](badge.svg)\n'); - expect(find.text(l10n.remoteImagesBlockedTitle), findsOneWidget); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MarkdownImageView( + source: 'badge.svg', + alt: 'Badge', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ); + await _pumpUntilFound(tester, find.byType(SvgPicture)); - await tester.tap(find.text(l10n.loadRemoteImagesForWorkspace)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.textContaining('badge.svg'), findsNothing); + } finally { + temp.deleteSync(recursive: true); + } + }, + ); - expect( - settingsStore.value['remoteImageAllowedWorkspacePaths'], - contains(startupPath), + testWidgets('shared Markdown image renderer renders generated SVG badges', ( + tester, + ) async { + final temp = Directory.systemTemp.createTempSync( + 'busymark_preview_svg_badge_', ); - }); + try { + final embeddedIcon = Uri.encodeComponent( + '' + '' + '' + '', + ); + File('${temp.path}/badge.svg').writeAsStringSync( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + 'BusyMark' + '' + '' + 'BusyMark' + '' + '' + 'latest/beta 0.1.1+1' + '' + '' + 'latest/beta 0.1.1+1' + '' + '' + '', + ); + final markdown = File('${temp.path}/image.md') + ..writeAsStringSync('# Image\n\n![Badge](badge.svg)\n'); - testWidgets('Ctrl+O open chooser lists recent workspaces', (tester) async { - const startupPath = 'test/fixtures/markdown/basic.md'; - const recentPath = '/tmp/busymark-recent-docs'; - final service = _StartupWorkspaceService(); - final settingsStore = _MemorySettingsStore() - ..value = AppSettings.defaults() - .copyWith( - recentWorkspaces: [ - RecentWorkspace( - path: recentPath, - kind: 'markdownFolder', - lastOpenedAt: DateTime(2026, 1, 2), + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DefaultTextStyle.merge( + style: const TextStyle( + decoration: TextDecoration.underline, + fontStyle: FontStyle.italic, ), - ], - ) - .toJson(); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), - workspaceServiceProvider.overrideWithValue(service), - startupPathProvider.overrideWithValue(startupPath), - ], - ); - addTearDown(container.dispose); + child: MarkdownImageView( + source: 'badge.svg', + alt: 'Badge', + activeFilePath: markdown.path, + workspaceRoot: temp.path, + writersideRoot: null, + imagesDir: 'images', + allowRemoteImages: true, + ), + ), + ), + ), + ); + await _pumpUntilFound(tester, find.byType(SvgPicture)); - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - await tester.pump(); - for (var i = 0; i < 10; i += 1) { - await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.text('BusyMark'), findsOneWidget); + expect(find.text('latest/beta 0.1.1+1'), findsOneWidget); + expect(find.textContaining('badge.svg'), findsNothing); + final badgeText = tester.widget(find.text('BusyMark')); + expect(badgeText.style?.fontStyle, FontStyle.normal); + expect(badgeText.style?.decoration, isNot(TextDecoration.underline)); + final iconRight = tester.getTopRight(find.byType(SvgPicture)).dx; + final textLeft = tester.getTopLeft(find.text('BusyMark')).dx; + expect(textLeft, greaterThan(iconRight + 2)); + } finally { + temp.deleteSync(recursive: true); } + }); - expect(service.openedPath, startupPath); - expect(find.text(l10n.openMarkdownFile), findsNothing); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.keyO); - await tester.sendKeyUpEvent(LogicalKeyboardKey.keyO); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pumpAndSettle(); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.split, + ]) { + for (final count in [81, 3000]) { + testWidgets( + 'shared Find/Replace navigates $count matches in ${mode.name}', + (tester) async { + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final original = + '# Group\n${List.generate(count, (i) => 'cat $i\n').join()}'; + final harness = await _pumpSearchRegression( + tester, + headerBarService, + {'a.md': original}, + mode: mode, + ); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query('cat'); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).searchResult.totalMatchCount == + count, + ); + final source = _searchSourceController(tester); + final editor = tester.widget( + find.byType(BusyMarkSourceEditor), + ); + expect(editor.searchSnapshot, isNotNull); + expect(container.read(searchSessionProvider).results, hasLength(80)); + expect(source.searchResult.currentMatchIndex, isNull); + + Future expectIndex(int index, String text) async { + final expected = RegExp('cat').allMatches(text).elementAt(index); + await _waitForSearchCondition( + tester, + () => + source.searchResult.currentMatchIndex == index && + source.fullSelection == + TextSelection( + baseOffset: expected.start, + extentOffset: expected.end, + ), + ); + final state = container.read(searchSessionProvider); + expect(source.fullText, text); + expect(state.results.length, lessThanOrEqualTo(80)); + expect(state.documentCurrentMatchIndex, index); + final selected = state.results[state.selected] as TextSearchResult; + expect(selected.match.startOffset, expected.start); + expect(selected.match.endOffset, expected.end); + final row = find.byKey(ValueKey(selected.identity)); + await _waitForSearchCondition( + tester, + () => + row.evaluate().isNotEmpty && + tester + .getRect(row) + .overlaps( + tester.getRect( + find.byKey(const ValueKey('sidebar-search-results')), + ), + ), + ); + expect( + tester.widget(row).selected, + isTrue, + ); + } - expect(find.text(l10n.openMarkdownFile), findsOneWidget); - expect(find.text(l10n.recent), findsOneWidget); - expect(find.text('busymark-recent-docs'), findsOneWidget); + Future choose(int index) async { + final match = RegExp( + 'cat', + ).allMatches(source.fullText).elementAt(index); + final editorState = tester.state( + find.byType(BusyMarkSourceEditor), + ); + editorState.scrollToSearchRange( + line: + '\n' + .allMatches(source.fullText.substring(0, match.start)) + .length + + 1, + startOffset: match.start, + endOffset: match.end, + ); + await expectIndex(index, source.fullText); + } - await tester.tap(find.text('busymark-recent-docs')); - await tester.pumpAndSettle(); + // Starting from no selection, Previous must find the actual last match + // inside folded content, rather than the last loaded sidebar row. + await tester.tap( + find.byTooltip(l10n.collapseKind(l10n.foldKindSection)).first, + ); + await tester.pump(); + expect(source.document.hiddenRanges.ranges, isNotEmpty); + await tester.tap(find.byTooltip(l10n.sourceSearchPreviousMatch)); + await expectIndex(count - 1, original); + expect(source.searchResult.currentMatch!.hidden, isFalse); + await tester.pumpAndSettle(); + final sourceField = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && identical(w.controller, source), + ), + ); + expect(sourceField.scrollController!.offset, greaterThan(0)); + await tester.tap(find.byTooltip(l10n.sourceSearchNextMatch)); + await expectIndex(0, original); + await choose(79); + await tester.tap(find.byTooltip(l10n.sourceSearchNextMatch)); + await expectIndex(80, original); + await tester.tap(find.byTooltip(l10n.sourceSearchPreviousMatch)); + await expectIndex(79, original); + await tester.tap(find.byTooltip(l10n.sourceSearchNextMatch)); + await expectIndex(80, original); + await tester.tap(find.byTooltip(l10n.sourceSearchNextMatch)); + await expectIndex(count == 81 ? 0 : 81, original); + + if (mode == DocumentViewModePreference.split && count == 3000) { + tester.view.physicalSize = const Size(1100, 900); + await tester.pumpAndSettle(); + } + final replaceIndex = count == 81 ? 80 : 2500; + await choose(replaceIndex); + final occurrence = RegExp( + 'cat', + ).allMatches(original).elementAt(replaceIndex); + final modified = original.replaceRange( + occurrence.start, + occurrence.end, + 'wolf', + ); + await tester.enterText( + find.byKey(const ValueKey('source-search-replacement')), + 'wolf', + ); + await tester.pump(); + await tester.tap(find.byTooltip(l10n.sourceSearchReplaceAndFindNext)); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == + modified, + ); + await expectIndex(count == 81 ? 0 : replaceIndex, modified); + expect(source.searchResult.totalMatchCount, count - 1); + expect(RegExp('wolf').allMatches(source.fullText), hasLength(1)); + await tester.pumpAndSettle(); + final editable = tester + .state( + find.descendant( + of: find.byWidgetPredicate( + (w) => w is TextField && identical(w.controller, source), + ), + matching: find.byType(EditableText), + ), + ) + .renderEditable; + final caret = editable.getLocalRectForCaret( + TextPosition(offset: source.selection.extentOffset), + ); + expect(caret.top, greaterThanOrEqualTo(0)); + expect(caret.bottom, lessThanOrEqualTo(editable.size.height)); + if (mode == DocumentViewModePreference.split && count == 3000) { + expect( + editable.localToGlobal(caret.topLeft).dy, + greaterThan( + tester + .getRect( + find.byKey(const ValueKey('source-search-replacement')), + ) + .bottom, + ), + reason: 'The selected match must clear the floating controls.', + ); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await _waitForSearchCondition( + tester, + () => + source.fullText == original && + source.searchResult.totalMatchCount == count, + ); + 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 _waitForSearchCondition( + tester, + () => + source.fullText == modified && + source.searchResult.totalMatchCount == count - 1, + ); + expect(RegExp('wolf').allMatches(source.fullText), hasLength(1)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + } + } - expect(service.openedPath, recentPath); - expect(find.text(l10n.openMarkdownFile), findsNothing); - }); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.split, + ]) { + for (final scenario in [ + 'separated', + 'double', + 'refresh', + 'query', + 'clear', + 'category', + 'document', + 'undo/redo', + 'caret', + ]) { + testWidgets('shared replacement refresh $scenario in ${mode.name}', ( + tester, + ) async { + tester.view.physicalSize = const Size(1440, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final original = 'cat\n${'gap\n' * 200}cat\n${'tail\n' * 200}cat\n'; + final modified = original.replaceRange(0, 3, 'wolf'); + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': original, + 'b.md': 'other cat buffer\n', + }, mode: mode); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + final workspace = container.read(workspaceControllerProvider.notifier); + session.open(category: SearchCategory.file); + session.query('cat'); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).searchResult.totalMatchCount == 3, + ); + var source = _searchSourceController(tester); + expect( + tester + .widget(find.byType(BusyMarkSourceEditor)) + .searchSnapshot, + isNotNull, + ); + await tester.tap(find.byTooltip(l10n.sourceSearchNextMatch)); + await _waitForSearchCondition( + tester, + () => + source.searchResult.currentMatchIndex == 0 && + source.fullSelection.extentOffset == 3, + ); + await tester.enterText( + find.byKey(const ValueKey('source-search-replacement')), + 'wolf', + ); + await tester.pump(); + await tester.tap(find.byTooltip(l10n.sourceSearchReplaceAndFindNext)); + if (scenario == 'double') { + await tester.tap(find.byTooltip(l10n.sourceSearchReplaceAndFindNext)); + } + // Stop after mutation, while the replacement's async refresh is pending. + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == + modified, + ); + expect(container.read(searchSessionProvider).searching, isTrue); + Future undoRedoKey({bool redo = false}) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (redo) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + if (redo) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + } - testWidgets('recent workspace menus support pointer and keyboard actions', ( - tester, - ) async { - const recentPath = '/tmp/busymark-recent-actions'; - String? clipboardText; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - if (call.method == 'Clipboard.setData') { - final arguments = call.arguments as Map; - clipboardText = arguments['text'] as String?; + switch (scenario) { + case 'query': + for (var i = 0; i < 12; i++) { + session.query('missing-$i'); + } + session.query('cat'); + case 'clear': + session.query(''); + session.query('cat'); + case 'category': + session.open(category: SearchCategory.workspace); + session.query('cat'); + session.open(category: SearchCategory.file); + case 'document': + await workspace.openActiveFile( + '${_SearchRegressionService.root}/b.md', + ); + case 'undo/redo': + await undoRedoKey(); + expect( + container.read(workspaceControllerProvider).activeText, + original, + ); + await undoRedoKey(redo: true); + case 'caret': + source.fullSelection = const TextSelection.collapsed(offset: 7); + case 'refresh': + await tester.runAsync(() => session.submit()); + expect( + container.read(searchSessionProvider).appliedSourceNavigation, + isNonZero, + ); + session.refresh(container.read(workspaceControllerProvider)); } - return null; - }, - ); - addTearDown(() { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - null, - ); - }); - final settingsStore = _MemorySettingsStore() + await tester.pump(); + source = _searchSourceController(tester); + final selectionAfterAction = source.fullSelection; + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + if (scenario == 'separated' || + scenario == 'double' || + scenario == 'refresh') { + final next = modified.indexOf('cat'); + await _waitForSearchCondition( + tester, + () => + source.fullSelection == + TextSelection(baseOffset: next, extentOffset: next + 3), + ); + final state = container.read(searchSessionProvider); + expect(state.documentMatchCount, 2); + expect(state.documentCurrentMatchIndex, 0); + expect( + (state.results[state.selected] as TextSearchResult) + .match + .startOffset, + next, + ); + await tester.pumpAndSettle(); + final field = find.byWidgetPredicate( + (w) => w is TextField && identical(w.controller, source), + ); + final editable = tester + .state( + find.descendant(of: field, matching: find.byType(EditableText)), + ) + .renderEditable; + final caret = editable.getLocalRectForCaret( + TextPosition(offset: source.selection.extentOffset), + ); + expect( + tester.widget(field).scrollController!.offset, + greaterThan(0), + ); + expect(caret.top, greaterThanOrEqualTo(0)); + expect(caret.bottom, lessThanOrEqualTo(editable.size.height)); + } else { + await tester.pump(const Duration(milliseconds: 700)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pumpAndSettle(); + expect(source.fullSelection, selectionAfterAction); + expect( + container.read(searchSessionProvider).appliedSourceNavigation, + 0, + ); + } + expect( + source.fullText, + scenario == 'document' ? 'other cat buffer\n' : modified, + ); + expect( + RegExp('wolf').allMatches(source.fullText).length, + scenario == 'document' ? 0 : 1, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }); + } + } + + testWidgets('Welcome, Recent search and Open use the same mixed chronology', ( + tester, + ) async { + final settings = _MemorySettingsStore() ..value = AppSettings.defaults() .copyWith( + reopenPreviousWorkspaceOnStartup: false, recentWorkspaces: [ - RecentWorkspace( - path: recentPath, + RecentResource( + path: '/old.md', + kind: 'markdown', + document: true, + title: 'Oldest entry', + lastOpenedAt: DateTime.utc(2026, 1, 1), + ), + RecentResource( + path: '/new-folder', kind: 'markdownFolder', - lastOpenedAt: DateTime(2026, 1, 2), + title: 'Newest entry', + lastOpenedAt: DateTime.utc(2026, 1, 3), + ), + RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: 'account-a', + localId: 'offline-id', + title: 'Middle entry', + lastOpenedAt: DateTime.utc(2026, 1, 2), ), ], ) @@ -12558,514 +14562,904 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final container = ProviderContainer( overrides: [ ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(settingsStore), startupPathProvider.overrideWithValue(null), + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settings), ], ); addTearDown(container.dispose); - await tester.pumpWidget( UncontrolledProviderScope( container: container, child: const BusyMarkApp(), ), ); - await container - .read(appSettingsControllerProvider.notifier) - .waitUntilLoaded(); await tester.pumpAndSettle(); + void expectChronologicalRows() { + final newest = tester.getTopLeft(find.text('Newest entry')).dy; + final middle = tester.getTopLeft(find.text('Middle entry')).dy; + final oldest = tester.getTopLeft(find.text('Oldest entry')).dy; + expect(newest, lessThan(middle)); + expect(middle, lessThan(oldest)); + } - final recentLabel = find.text('busymark-recent-actions'); - expect(recentLabel, findsOneWidget); - final recentRow = find - .ancestor(of: recentLabel, matching: find.byType(InkWell)) - .first; - final menuButton = find.descendant( - of: recentRow, - matching: find.byTooltip(l10n.actions), + expectChronologicalRows(); + final search = container.read(searchSessionProvider.notifier); + search.open(category: SearchCategory.recent); + search.query('entry'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, ); - expect(menuButton, findsOneWidget); - - await tester.tap(menuButton); - await tester.pumpAndSettle(); - expect(find.text(l10n.openInFiles), findsOneWidget); - expect(find.text(l10n.copyPath), findsOneWidget); - expect(find.text(l10n.removeFromRecent), findsOneWidget); - - await tester.tap(find.text(l10n.copyPath)); await tester.pumpAndSettle(); - expect(clipboardText, recentPath); - - await tester.tap(recentRow, buttons: kSecondaryMouseButton); - await tester.pumpAndSettle(); - expect(find.text(l10n.removeFromRecent), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect( + container + .read(searchSessionProvider) + .results + .whereType() + .map((r) => r.resource.title), + ['Newest entry', 'Middle entry', 'Oldest entry'], + ); + expectChronologicalRows(); + search.close(); await tester.pumpAndSettle(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.f10); - await tester.sendKeyUpEvent(LogicalKeyboardKey.f10); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + final opening = container + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.open); await tester.pumpAndSettle(); - expect(find.text(l10n.removeFromRecent), findsOneWidget); + final recentRows = tester + .widgetList( + find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(BusyMarkActionRow), + ), + ) + .where((r) => r.title.contains('entry')) + .map((r) => r.title.replaceAll('\u2066', '').replaceAll('\u2069', '')) + .toList(); + expect(recentRows, ['Newest entry', 'Middle entry', 'Oldest entry']); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - - await tester.tap(recentRow, buttons: kSecondaryMouseButton); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.removeFromRecent)); - await tester.pumpAndSettle(); - - expect(recentLabel, findsNothing); - expect( - container.read(appSettingsControllerProvider).recentWorkspaces, - isEmpty, - ); - expect(AppSettings.fromJson(settingsStore.value).recentWorkspaces, isEmpty); + await opening; }); - testWidgets('shared Markdown image renderer resolves local images', ( - tester, - ) async { - final temp = Directory.systemTemp.createTempSync('busymark_preview_image_'); - try { - File('${temp.path}/logo.png').writeAsBytesSync( - base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', - ), - ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Logo](logo.png)\n'); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: 'logo.png', - alt: 'Logo', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, + for (final mode in [ + DocumentViewModePreference.editor, + DocumentViewModePreference.preview, + ]) { + for (final change in ['initialization', 'edit', 'navigation']) { + testWidgets( + 'deleted History recovery in ${mode.name} tolerates only initialization: $change', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'busymark-history-initialization-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + final anchor = File(p.join(root.path, 'anchor.md')) + ..writeAsStringSync('# Anchor\n'); + final missing = File(p.join(root.path, 'deleted.md')); + final store = _PendingHistoryRestoreStore(); + const recovered = '# Recovered\n\nDeleted source.\n'; + final format = decodeUtf8Document(utf8.encode(recovered)).format; + final destinationSource = decodeUtf8Document(format.encode('')).text; + final capture = await store.capture( + LocalHistoryCaptureRequest( + path: missing.path, + displayName: 'deleted.md', + source: recovered, + format: format, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.beforeDelete, ), - ), - ), - ); - await tester.pump(); - - expect(find.byType(Image), findsOneWidget); - expect(find.text('logo.png'), findsNothing); - } finally { - temp.deleteSync(recursive: true); - } - }); - - testWidgets( - 'shared Markdown image renderer blocks remote images by default', - (tester) async { - var promptCount = 0; - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: 'https://example.com/logo.png', - alt: 'Logo', - activeFilePath: 'doc.md', - workspaceRoot: null, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: false, - onRemoteImageBlocked: () { - promptCount += 1; - }, + const LocalHistoryPolicy(), + ); + await store.markDeleted(missing.path, recursive: false); + final document = (await store.load()).documents.single; + final revision = (await store.readRevision(capture.revision!.id))!; + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + automaticSpelling: false, + documentViewMode: mode, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + workspaceFileMonitorProvider.overrideWith( + (ref) => _SearchTestFileMonitor(), + ), + localSettingsStoreProvider.overrideWithValue(settings), + localHistoryStoreProvider.overrideWithValue(store), + startupPathProvider.overrideWithValue(root.path), + ], + ); + addTearDown(container.dispose); + addTearDown(() { + if (!store.release.isCompleted) store.release.complete(); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), ), - ), - ), + ); + await _waitForSearchCondition( + tester, + () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath == + anchor.path, + attempts: 3000, + ); + final controller = container.read( + workspaceControllerProvider.notifier, + ); + container.read(localHistoryOpenRequestProvider.notifier).request(); + await tester.pumpAndSettle(); + final history = container.read( + localHistoryControllerProvider.notifier, + ); + history.inspectRetainedDocument(document.id); + await tester.runAsync( + () => history.selectRevision(revision.summary.id), + ); + await tester.pump(); + final restoring = controller.restoreMissingLocalHistoryRevision( + document: document, + revision: revision, + destinationPath: missing.path, + overwriteExisting: false, + ); + bool? restored; + unawaited(restoring.then((value) => restored = value)); + await _waitForSearchCondition( + tester, + () => store.started.isCompleted || restored != null, + attempts: 3000, + ); + expect( + store.started.isCompleted, + isTrue, + reason: + 'Recovery must reach protective capture before simulating navigation (returned $restored).', + ); + final before = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(before.filePath, missing.path); + expect(before.text, destinationSource); + expect(before.editorState.mode, mode); + // Mount the actual destination editor while the protective capture is + // suspended. Its session report must not masquerade as a source edit. + await tester.pumpAndSettle(); + final initialized = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(initialized.text, before.text); + expect(initialized.revision, before.revision); + if (mode == DocumentViewModePreference.editor) { + expect(find.byType(BusyMarkWysiwygEditor), findsOneWidget); + expect( + identical(initialized.editorState, before.editorState), + isFalse, + ); + } + if (change == 'edit') { + controller.updateActiveText('Concurrent user edit\n'); + } + if (change == 'navigation') { + await tester.runAsync(() => controller.openActiveFile(anchor.path)); + // Returning to the same tab must not revive the pending restore. + await tester.runAsync( + () => controller.openActiveFile(missing.path), + ); + } + store.release.complete(); + await _waitForSearchCondition( + tester, + () => restored != null, + attempts: 3000, + ); + await tester.pumpAndSettle(); + expect(restored, change == 'initialization'); + expect( + container.read(workspaceControllerProvider).activeText, + change == 'initialization' + ? recovered + : change == 'edit' + ? 'Concurrent user edit\n' + : destinationSource, + ); + if (change == 'initialization') { + expect(missing.readAsStringSync(), destinationSource); + expect(controller.undoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + destinationSource, + ); + expect(controller.redoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + recovered, + ); + bool? saved; + unawaited(controller.saveActive().then((value) => saved = value)); + // This includes real filesystem publication and protective History + // capture, rather than only the query debounce used by this helper. + await _waitForSearchCondition( + tester, + () => saved != null, + attempts: 3000, + ); + expect(saved, isTrue); + expect(missing.readAsStringSync(), recovered); + } + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, ); - await tester.pump(); - - expect(find.text(l10n.remoteImageBlocked), findsOneWidget); - expect(find.byType(Image), findsNothing); - - await tester.tap(find.text(l10n.remoteImageBlocked)); - await tester.pump(); - - expect(promptCount, 1); - }, - ); + } + } testWidgets( - 'shared Markdown image renderer resolves absolute paths with spaces', + 'shared History search inspects exact retained revisions and restores ordinary browsing', (tester) async { - final temp = Directory.systemTemp.createTempSync( - 'busymark_preview_image_space_', - ); - try { - final image = File('${temp.path}/Screenshot From 2026.png') - ..writeAsBytesSync( - base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', - ), - ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Screenshot](${image.path})\n'); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: image.path, - alt: 'Screenshot', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), + final store = MemoryLocalHistoryStore(); + final now = DateTime.now().toUtc(); + for (var i = 0; i < 2; i++) { + await store.capture( + LocalHistoryCaptureRequest( + path: '/retired/same.md', + displayName: 'Retired same', + source: i == 0 ? 'historical needle' : 'later retained revision', + format: TextFormatMetadata.utf8Lf, + capturedAt: now.add(Duration(seconds: i)), + reason: LocalHistoryCaptureReason.saved, ), + const LocalHistoryPolicy(), ); - await tester.pump(); - - expect(find.byType(Image), findsOneWidget); - expect(find.text(image.path), findsNothing); - } finally { - temp.deleteSync(recursive: true); } + await store.remapPath('/retired/same.md', '/deleted/same.md'); + await store.markDeleted('/deleted/same.md', recursive: false); + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': '# Live heading\nlive source\n', + }, historyStore: store); + final container = harness.container; + final history = container.read(localHistoryControllerProvider.notifier); + await history.refresh(); + final retained = container + .read(localHistoryControllerProvider) + .snapshot + .documents + .singleWhere((d) => d.deleted); + final before = container.read(workspaceControllerProvider).activeBuffer!; + final session = container.read(searchSessionProvider.notifier); + session.openHistory(); + session.query('historical needle'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + final result = + container.read(searchSessionProvider).results.single + as HistorySearchResult; + final revisionId = + (result.match as LocalHistoryRevisionMatch).revision.id; + await tester.tap(find.byKey(ValueKey(result.identity))); + await _waitForSearchCondition( + tester, + () => + container + .read(localHistoryControllerProvider) + .selectedRevision + ?.summary + .id == + revisionId, + ); + expect(container.read(searchSessionProvider).active, isTrue); + expect( + container.read(localHistoryControllerProvider).selectedDocumentId, + retained.id, + ); + expect( + container.read(localHistoryControllerProvider).selectedRevision!.source, + 'historical needle', + ); + expect( + container.read(workspaceControllerProvider).activeBuffer, + same(before), + ); + expect(find.byType(LocalHistoryComparisonView), findsOneWidget); + await container + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.localHistory); + await tester.pump(); + expect(container.read(searchSessionProvider).active, isFalse); + expect(find.byType(LocalHistoryPanel), findsOneWidget); + expect( + find.descendant( + of: find.byType(LocalHistoryPanel), + matching: find.byType(TextField), + ), + findsNothing, + ); + expect( + container.read(localHistoryControllerProvider).selectedRevisions, + hasLength(2), + ); + expect(find.byTooltip(l10n.findLocalHistoryEllipsis), findsNothing); + container + .read(searchSessionProvider.notifier) + .openHistory(documentId: retained.id); + await tester.pump(); + expect( + container.read(searchSessionProvider).historyDocumentId, + retained.id, + ); + await container + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.findLocalHistory); + expect(container.read(searchSessionProvider).historyDocumentId, isNull); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect( + container.read(searchSessionProvider).results.single.identity, + result.identity, + ); + await history.clearDocument(retained.id); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect(container.read(searchSessionProvider).results, isEmpty); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + isNull, + ); + expect( + container.read(workspaceControllerProvider).activeBuffer, + same(before), + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); }, ); testWidgets( - 'shared Markdown image renderer resolves absolute paths outside workspace', + 'ordinary Outline keeps its tree state independently of typed Outline search', (tester) async { - final workspace = Directory.systemTemp.createTempSync( - 'busymark_preview_image_workspace_', + const text = + '# Alpha\n## Shared\nalpha body\n# Beta\n## Shared\nbeta body\n'; + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': text, + }); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + Future outlineShortcut() async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + } + + await outlineShortcut(); + final tree = find.byKey(const ValueKey('workspace-sidebar-outline-tree')); + expect(tree, findsOneWidget); + expect(find.byKey(const ValueKey('shared-search-query')), findsNothing); + expect(find.byKey(const ValueKey('search-category')), findsNothing); + final ordinary = find.byWidgetPredicate( + (w) => w.runtimeType.toString() == '_OutlineTab', ); - final outside = Directory.systemTemp.createTempSync( - 'busymark_preview_image_outside_', + final ordinaryState = tester.state(ordinary); + final first = find.byKey( + const ValueKey('workspace-sidebar-outline-row-0'), ); - try { - final image = File('${outside.path}/outside.png') - ..writeAsBytesSync( - base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', - ), - ); - final markdown = File('${workspace.path}/image.md') - ..writeAsStringSync('# Image\n\n![Outside](${image.path})\n'); + final expander = find.descendant( + of: first, + matching: find.byWidgetPredicate( + (w) => w is GestureDetector && w.child is AnimatedRotation, + ), + ); + await tester.tap(expander); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-row-1')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-row-3')), + findsOneWidget, + ); + session.open(category: SearchCategory.outline); + await tester.pumpAndSettle(); + expect(tree, findsNothing); + expect(find.text(l10n.searchEnterText), findsOneWidget); + expect(container.read(searchSessionProvider).results, isEmpty); + await tester.enterText( + find.byKey(const ValueKey('shared-search-query')), + 'Shared', + ); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + final results = container + .read(searchSessionProvider) + .results + .cast(); + expect(results, hasLength(2)); + expect(results.map((r) => r.ancestors.single), ['Alpha', 'Beta']); + expect(find.byKey(ValueKey(results.first.identity)), findsOneWidget); + expect(tree, findsNothing); + session.moveSelection(1); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, text); + await tester.tap(find.byKey(ValueKey(results.last.identity))); + await _waitForSearchCondition( + tester, + () => + _searchSourceController(tester).fullSelection.start == + text.lastIndexOf('## Shared'), + ); + expect(container.read(searchSessionProvider).active, isTrue); + expect(container.read(workspaceControllerProvider).activeText, text); + await outlineShortcut(); + expect(container.read(searchSessionProvider).active, isFalse); + expect(tester.state(ordinary), same(ordinaryState)); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-row-1')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-row-3')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('search-category')), findsNothing); + // Section operations remain on the normal complete tree, outside search. + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-outline-row-2')), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); + expect(find.text(l10n.moveSectionUp), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(container.read(workspaceControllerProvider).activeText, text); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: image.path, - alt: 'Outside', - activeFilePath: markdown.path, - workspaceRoot: workspace.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), - ), - ); + testWidgets( + 'unified search scopes, query isolation and explicit shortcut destinations', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': '# Alpha\nneedle alpha\n', + 'b.md': '# Beta\nneedle beta\n', + }); + final container = harness.container; + final session = container.read(searchSessionProvider.notifier); + expect(session.categories, [ + SearchCategory.file, + SearchCategory.workspace, + SearchCategory.outline, + SearchCategory.documents, + SearchCategory.recent, + SearchCategory.localHistory, + SearchCategory.actions, + ]); + Future shortcut( + LogicalKeyboardKey key, { + bool shift = false, + bool alt = false, + bool search = true, + }) async { + final focusRequest = container.read(searchSessionProvider).focusRequest; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + if (alt) await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(key); + if (alt) await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); - - expect(find.byType(Image), findsOneWidget); - expect(find.textContaining(image.path), findsNothing); - } finally { - workspace.deleteSync(recursive: true); - outside.deleteSync(recursive: true); + expect( + container.read(searchSessionProvider).focusRequest, + focusRequest + (search ? 1 : 0), + ); } - }, - ); - testWidgets('shared Markdown image renderer resolves Snap real-home paths', ( - tester, - ) async { - final root = Directory.systemTemp.createTempSync( - 'busymark_preview_image_snap_home_', - ); - try { - final realHome = Directory(p.join(root.path, 'real-home'))..createSync(); - final snapHome = Directory(p.join(root.path, 'snap-home'))..createSync(); - final downloads = Directory(p.join(realHome.path, 'Downloads')) - ..createSync(); - File(p.join(downloads.path, 'example.jpg')).writeAsBytesSync( - base64Decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8Kz3wAAAABJRU5ErkJggg==', + final query = find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), + ); + session.close(); + await tester.pump(); + await tester.tap( + find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.controller is BusyMarkSourceEditingController, ), ); - debugLocalImageEnvironmentOverride = { - 'SNAP_REAL_HOME': realHome.path, - 'HOME': snapHome.path, - }; - addTearDown(() { - debugLocalImageEnvironmentOverride = null; - }); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( - body: MarkdownImageView( - source: '~/Downloads/example.jpg', - alt: 'Example', - activeFilePath: 'Untitled.md', - workspaceRoot: null, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, + await shortcut(LogicalKeyboardKey.keyF); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + await tester.enterText(query, 'needle'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect(container.read(searchSessionProvider).results, hasLength(1)); + // Dropdown Enter and Escape belong to the popup before search dismissal. + await tester.tap(find.byKey(const ValueKey('search-category'))); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(container.read(searchSessionProvider).active, isTrue); + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('search-category')), + matching: find.byType(TextButton), ), - ), - ), + ) + .focusNode! + .requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.text(l10n.searchWorkspaceTextCategory), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text(l10n.searchWorkspaceTextCategory), findsNothing); + expect(container.read(searchSessionProvider).active, isTrue); + await shortcut(LogicalKeyboardKey.keyF, shift: true); + expect( + container.read(searchSessionProvider).category, + SearchCategory.workspace, + ); + expect(container.read(searchSessionProvider).query, ''); + await tester.enterText(query, 'needle'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect(container.read(searchSessionProvider).results, hasLength(2)); + await shortcut(LogicalKeyboardKey.keyP, shift: true); + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + await tester.enterText(query, 'Bold'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect( + container + .read(searchSessionProvider) + .results + .whereType() + .any((r) => r.command.id == 'editor.bold'), + isTrue, ); + expect( + container + .read(searchSessionProvider) + .sourceSnapshot( + container.read(workspaceControllerProvider).activeBuffer, + ) + .result + .options + .query, + '', + ); + expect( + container.read(workspaceControllerProvider).activeText, + '# Alpha\nneedle alpha\n', + ); + await shortcut(LogicalKeyboardKey.keyF); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, + ); + expect(container.read(searchSessionProvider).query, 'needle'); + await shortcut(LogicalKeyboardKey.keyD, shift: true); + expect( + container.read(searchSessionProvider).category, + SearchCategory.documents, + ); + await tester.enterText(query, 'b.md'); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + expect(container.read(searchSessionProvider).error, isNull); + expect( + (container.read(searchSessionProvider).results.single + as DocumentSearchResult) + .document + .filePath, + endsWith('/b.md'), + ); + await shortcut(LogicalKeyboardKey.digit3, search: false); + expect(container.read(searchSessionProvider).active, isFalse); + expect( + find.byKey(const ValueKey('workspace-sidebar-outline-tree')), + findsOneWidget, + ); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'Outline navigation', + ); + session.open(category: SearchCategory.outline); await tester.pump(); - - expect(find.byType(Image), findsOneWidget); - expect(find.textContaining('~/Downloads/example.jpg'), findsNothing); - } finally { - debugLocalImageEnvironmentOverride = null; - root.deleteSync(recursive: true); - } - }); - - testWidgets('shared Markdown image renderer resolves local SVG images', ( - tester, - ) async { - final temp = Directory.systemTemp.createTempSync('busymark_preview_svg_'); - try { - File('${temp.path}/logo.svg').writeAsStringSync( - '' - '' - '', + expect( + container.read(searchSessionProvider).category, + SearchCategory.outline, ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Logo](logo.svg)\n'); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: 'logo.svg', - alt: 'Logo', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), - ), + await shortcut(LogicalKeyboardKey.keyR, shift: true); + expect( + container.read(searchSessionProvider).category, + SearchCategory.recent, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(container.read(searchSessionProvider).active, isFalse); + await tester.tap(find.byTooltip(l10n.search)); + await tester.pump(); + expect( + container.read(searchSessionProvider).category, + SearchCategory.recent, ); - - expect(find.byType(SvgPicture), findsNothing); - await _pumpUntilFound(tester, find.byType(SvgPicture)); - - expect(find.textContaining('logo.svg'), findsNothing); - expect(find.byType(SvgPicture), findsOneWidget); - } finally { - temp.deleteSync(recursive: true); - } - }); - - testWidgets('shared Markdown image renderer rejects oversized local SVGs', ( - tester, - ) async { - final temp = Directory.systemTemp.createTempSync( - 'busymark_preview_svg_oversized_', - ); - try { - final padding = ''.padRight(1024 * 1024, 'x'); - File('${temp.path}/oversized.svg').writeAsStringSync( - '' - '' - '', + session.close(); + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.editor); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.editor); + await tester.pumpAndSettle(); + await tester.tap( + find + .byWidgetPredicate( + (widget) => + widget is TextField && + widget.controller is BusyMarkWysiwygTextController, + ) + .first, ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Large](oversized.svg)\n'); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: 'oversized.svg', - alt: 'Large', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), - ), + await shortcut(LogicalKeyboardKey.keyF); + expect( + container.read(searchSessionProvider).category, + SearchCategory.file, ); - await _pumpUntilFound(tester, find.textContaining('oversized.svg')); - - expect(find.byType(SvgPicture), findsNothing); - expect(find.textContaining('oversized.svg'), findsOneWidget); - } finally { - temp.deleteSync(recursive: true); - } - }); + container.read(appRouterProvider).go('/'); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeBuffer, + isNotNull, + ); + expect(session.categories, [ + SearchCategory.recent, + SearchCategory.actions, + ]); + session.open(category: SearchCategory.actions); + await tester.pump(); + expect(session.commandTarget, isNull); + container.read(searchSessionProvider.notifier).close(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); testWidgets( - 'shared Markdown image renderer sanitizes embedded SVG image data', + 'unified Actions formats captured Source selection and rejects a later selection', (tester) async { - final temp = Directory.systemTemp.createTempSync( - 'busymark_preview_svg_data_', + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'cat dog\n', + }); + final container = harness.container; + container.read(searchSessionProvider.notifier).close(); + await tester.pump(); + final source = _searchSourceController(tester); + final editor = find.byWidgetPredicate( + (widget) => widget is TextField && identical(widget.controller, source), ); - try { - final embeddedSvg = Uri.encodeComponent( - '' - '' - '', - ); - File('${temp.path}/badge.svg').writeAsStringSync( - '' - '' - '' - '', - ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Badge](badge.svg)\n'); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: MarkdownImageView( - source: 'badge.svg', - alt: 'Badge', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), + tester.widget(editor).focusNode!.requestFocus(); + await tester.pump(); + source.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + await tester.pump(); + Future actions() async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyP); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.enterText( + find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), ), + 'Bold', + ); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, ); - await _pumpUntilFound(tester, find.byType(SvgPicture)); - - expect(tester.takeException(), isNull); - expect(find.byType(SvgPicture), findsOneWidget); - expect(find.textContaining('badge.svg'), findsNothing); - } finally { - temp.deleteSync(recursive: true); } - }, - ); - testWidgets('shared Markdown image renderer renders generated SVG badges', ( - tester, - ) async { - final temp = Directory.systemTemp.createTempSync( - 'busymark_preview_svg_badge_', - ); - try { - final embeddedIcon = Uri.encodeComponent( - '' - '' - '' - '', + await actions(); + final target = container + .read(searchSessionProvider.notifier) + .commandTarget; + expect(target, isNotNull); + expect(target!.context, isNotNull); + expect( + target.isCurrent(container.read(workspaceControllerProvider)), + isTrue, + reason: 'Captured Source target must remain valid after query editing', ); - File('${temp.path}/badge.svg').writeAsStringSync( - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - '' - 'BusyMark' - '' - '' - 'BusyMark' - '' - '' - 'latest/beta 0.1.1+1' - '' - '' - 'latest/beta 0.1.1+1' - '' - '' - '', + expect( + container.read(searchSessionProvider).results.map((r) => r.identity), + ['editor.bold'], ); - final markdown = File('${temp.path}/image.md') - ..writeAsStringSync('# Image\n\n![Badge](badge.svg)\n'); + expect( + container + .read(busyMarkCommandRegistryProvider) + .canExecuteInContext('editor.bold', target.context), + isTrue, + ); + final original = container.read(workspaceControllerProvider).activeText; + expect(original, 'cat dog\n'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == + '**cat** dog\n', + ); + expect(container.read(searchSessionProvider).active, isFalse); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await _waitForSearchCondition( + tester, + () => + container.read(workspaceControllerProvider).activeText == original, + ); + source.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + await tester.pump(); + await actions(); + source.selection = const TextSelection(baseOffset: 4, extentOffset: 7); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(container.read(workspaceControllerProvider).activeText, original); + expect(container.read(searchSessionProvider).active, isTrue); + container.read(searchSessionProvider.notifier).close(); + await tester.pump(); + tester.widget(editor).focusNode!.requestFocus(); + await tester.pump(); + await actions(); + final query = find.descendant( + of: find.byKey(const ValueKey('shared-search-query')), + matching: find.byType(TextField), + ); + await tester.enterText(query, 'Save'); + final controller = container.read(workspaceControllerProvider.notifier); + controller.updateDocumentText( + container.read(workspaceControllerProvider).activeBufferId!, + 'later edit\n', + ); + await _waitForSearchCondition( + tester, + () => !container.read(searchSessionProvider).searching, + ); + final save = container + .read(searchSessionProvider) + .results + .whereType() + .singleWhere((r) => r.command.id == BusyMarkCommandIds.save); + final saveRow = find.byKey(ValueKey(save.identity)); + container + .read(searchSessionProvider.notifier) + .select( + container + .read(searchSessionProvider) + .results + .indexWhere((r) => r.identity == save.identity), + ); + await _waitForSearchCondition( + tester, + () => + saveRow.evaluate().isNotEmpty && + tester.widget(saveRow).onTap == null, + ); + expect( + tester + .widget( + find.byKey(ValueKey(save.identity)), + ) + .onTap != + null, + isFalse, + ); + tester.widget(query).focusNode!.requestFocus(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(container.read(searchSessionProvider).active, isTrue); + expect( + container.read(workspaceControllerProvider).activeText, + 'later edit\n', + ); + container.read(searchSessionProvider.notifier).close(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: DefaultTextStyle.merge( - style: const TextStyle( - decoration: TextDecoration.underline, - fontStyle: FontStyle.italic, - ), - child: MarkdownImageView( - source: 'badge.svg', - alt: 'Badge', - activeFilePath: markdown.path, - workspaceRoot: temp.path, - writersideRoot: null, - imagesDir: 'images', - allowRemoteImages: true, - ), - ), - ), - ), + testWidgets( + 'unified search overlay retains source controller and initial hidden sidebar', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'needle\n', + }); + final container = harness.container; + container.read(searchSessionProvider.notifier).close(); + await container + .read(appSettingsControllerProvider.notifier) + .setSidebarVisible(false); + await tester.pump(); + final source = _searchSourceController(tester); + source.selection = const TextSelection(baseOffset: 1, extentOffset: 4); + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + container + .read(searchSessionProvider.notifier) + .open(category: SearchCategory.file); + await tester.pump(); + expect(identical(_searchSourceController(tester), source), isTrue); + expect( + source.fullSelection, + const TextSelection(baseOffset: 1, extentOffset: 4), ); - await _pumpUntilFound(tester, find.byType(SvgPicture)); - - expect(tester.takeException(), isNull); - expect(find.byType(SvgPicture), findsOneWidget); - expect(find.text('BusyMark'), findsOneWidget); - expect(find.text('latest/beta 0.1.1+1'), findsOneWidget); - expect(find.textContaining('badge.svg'), findsNothing); - final badgeText = tester.widget(find.text('BusyMark')); - expect(badgeText.style?.fontStyle, FontStyle.normal); - expect(badgeText.style?.decoration, isNot(TextDecoration.underline)); - final iconRight = tester.getTopRight(find.byType(SvgPicture)).dx; - final textLeft = tester.getTopLeft(find.text('BusyMark')).dx; - expect(textLeft, greaterThan(iconRight + 2)); - } finally { - temp.deleteSync(recursive: true); - } - }); + expect(find.byKey(const ValueKey('search-category')), findsOneWidget); + expect( + container.read(appSettingsControllerProvider).sidebarVisible, + isFalse, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(container.read(searchSessionProvider).active, isFalse); + expect( + container.read(appSettingsControllerProvider).sidebarVisible, + isFalse, + ); + container.read(searchSessionProvider.notifier).close(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); testWidgets('header Search button toggles the expanded search field', ( tester, @@ -13292,8 +15686,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. } await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); await tester.enterText(find.byType(TextField), 'needle'); @@ -13360,11 +15756,14 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); testWidgets( - 'workspace search native submit uses exact payload and selected sidebar occurrence', + 'document text 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.container + .read(searchSessionProvider.notifier) + .open(category: SearchCategory.file); session.events.add(const HeaderBarSearchSubmitted(' cat ')); await _waitForSearchCondition( tester, @@ -13443,14 +15842,21 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await _waitForSearchCondition( tester, () => - _searchSourceController(tester).searchResult.currentMatchIndex == 0, + _searchSourceController(tester).fullSelection == + const TextSelection(baseOffset: 14, extentOffset: 20), ); final source = _searchSourceController(tester); + await _waitForSearchCondition( + tester, + () => source.searchResult.matches.any( + (match) => match.fullStart == 14 && match.fullEnd == 20, + ), + ); expect( source.fullSelection, const TextSelection(baseOffset: 14, extentOffset: 20), ); - expect(source.searchResult.options.query, 'needle'); + expect(session.container.read(searchSessionProvider).query, 'needle'); await _waitForSearchCondition( tester, () => find.text('second needle result').evaluate().isNotEmpty, @@ -13469,6 +15875,199 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. }, ); + for (final resourceChange in [ + 'unchanged', + 'reopened', + 'changed', + 'deleted', + ]) { + testWidgets( + 'workspace result resolves a closed clean tab: $resourceChange', + (tester) async { + final sources = { + 'a.md': 'Before\noriginal needle result\n', + 'b.md': '# B\n', + }; + final harness = await _pumpSearchRegression( + tester, + headerBarService, + sources, + ); + final container = harness.container; + final controller = container.read(workspaceControllerProvider.notifier); + final original = container + .read(workspaceControllerProvider) + .activeBuffer!; + await controller.openActiveFile( + '${_SearchRegressionService.root}/b.md', + ); + harness.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => + !container.read(searchSessionProvider).searching && + container.read(searchSessionProvider).results.isNotEmpty, + ); + final result = + container.read(searchSessionProvider).results.single + as TextSearchResult; + expect(result.match.bufferId, original.id); + final generation = container.read(searchSessionProvider).generation; + expect(await controller.closeDocumentBuffer(original.id), isTrue); + await tester.pump(); + expect( + container + .read(workspaceControllerProvider) + .bufferForPath(original.filePath!), + isNull, + ); + if (resourceChange == 'changed') { + sources['a.md'] = 'Changed needle content\n'; + } + if (resourceChange == 'deleted') sources.remove('a.md'); + if (resourceChange == 'reopened') { + expect(await controller.openActiveFile(original.filePath!), isTrue); + await controller.openActiveFile( + '${_SearchRegressionService.root}/b.md', + ); + } + await tester.pump(const Duration(milliseconds: 200)); + expect(container.read(searchSessionProvider).generation, generation); + expect( + identical( + container.read(searchSessionProvider).results.single, + result, + ), + isTrue, + ); + await tester.tap(find.text('original needle result')); + if (resourceChange == 'unchanged' || resourceChange == 'reopened') { + await _waitForSearchCondition( + tester, + () => + container + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath == + original.filePath && + _searchSourceController(tester).fullSelection == + const TextSelection(baseOffset: 16, extentOffset: 22), + ); + expect( + container.read(workspaceControllerProvider).activeText, + sources['a.md'], + ); + expect( + container + .read(workspaceControllerProvider) + .documentBuffers + .where((b) => b.filePath == original.filePath), + hasLength(1), + ); + } else { + await _waitForSearchCondition( + tester, + () => find + .text(l10n.commandUnavailableInContext) + .evaluate() + .isNotEmpty, + ); + expect( + _searchSourceController(tester).fullSelection, + isNot(const TextSelection(baseOffset: 16, extentOffset: 22)), + ); + expect( + container.read(workspaceControllerProvider).activeText, + resourceChange == 'changed' ? sources['a.md'] : sources['b.md'], + ); + } + expect(container.read(searchSessionProvider).query, 'needle'); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + } + + testWidgets( + 'cancelled closed-tab result cannot navigate or publish feedback', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'original needle result\n', + 'b.md': '# B\n', + }); + final container = harness.container; + final controller = container.read(workspaceControllerProvider.notifier); + final original = container + .read(workspaceControllerProvider) + .activeBuffer!; + await controller.openActiveFile('${_SearchRegressionService.root}/b.md'); + final destination = container + .read(workspaceControllerProvider) + .activeBuffer!; + harness.events.add(const HeaderBarSearchQueryChanged('needle')); + await _waitForSearchCondition( + tester, + () => + !container.read(searchSessionProvider).searching && + container.read(searchSessionProvider).results.isNotEmpty, + ); + expect(await controller.closeDocumentBuffer(original.id), isTrue); + final started = Completer(); + final release = Completer(); + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + (container.read(workspaceServiceProvider) as _SearchRegressionService) + .beforeLoad = (path) async { + if (path == original.filePath) { + started.complete(); + await release.future; + } + }; + await tester.tap(find.text('original needle result')); + await _waitForSearchCondition(tester, () => started.isCompleted); + container + .read(searchSessionProvider.notifier) + .open(category: SearchCategory.actions); + release.complete(); + await tester.pumpAndSettle(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeBufferId, + destination.id, + ); + expect( + container + .read(workspaceControllerProvider) + .bufferForPath(original.filePath!), + isNull, + ); + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + expect( + find.ancestor( + of: find.text(l10n.commandUnavailableInContext), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.liveRegion == true, + ), + ), + findsNothing, + ); + expect( + container.read(workspaceControllerProvider).activeText, + destination.text, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + testWidgets('workspace search observes edits to inactive buffers', ( tester, ) async { @@ -13484,17 +16083,6 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. 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(); @@ -13521,10 +16109,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. _searchSourceController(tester).fullSelection == const TextSelection(baseOffset: 6, extentOffset: 12), ); - expect( - _searchSourceController(tester).searchResult.options.query, - 'needle', - ); + expect(session.container.read(searchSessionProvider).query, 'needle'); await tester.pumpWidget(const SizedBox()); await tester.pump(const Duration(seconds: 1)); }); @@ -13551,9 +16136,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .isEmpty, ); expect(find.text(l10n.workspaceSearchIncomplete), findsNothing); - final sidebarList = find.byKey( - const ValueKey('workspace-search-results'), - ); + final sidebarList = find.byKey(const ValueKey('sidebar-search-results')); final scrollable = find .descendant(of: sidebarList, matching: find.byType(Scrollable)) .first; @@ -13569,50 +16152,47 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. }, ); - 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 keeps skipped-file feedback for literal punctuation', + (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); + session.events.add(const HeaderBarSearchQueryChanged('[')); + await _waitForSearchCondition( + tester, + () => !session.container.read(searchSessionProvider).searching, + ); + expect(find.textContaining('large.md\nunreadable.md'), findsOneWidget); + expect(session.container.read(searchSessionProvider).results, isEmpty); + expect(find.text('Aa'), findsNothing); + expect(find.text('W'), findsNothing); + expect(find.text('.*'), findsNothing); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); testWidgets( - 'workspace search cancels expensive regex while UI keeps building', + 'workspace search cancels rapid literal queries while UI keeps building', (tester) async { final session = await _pumpSearchRegression(tester, headerBarService, { 'a.md': 'active\n', @@ -13620,17 +16200,8 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. }); 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 tester.pump(const Duration(milliseconds: 1)); await session.container .read(appSettingsControllerProvider.notifier) .setWordWrap(false); @@ -13638,7 +16209,9 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. session.events.add(const HeaderBarSearchQueryChanged('active')); await _waitForSearchCondition( tester, - () => find.text(l10n.searchResultLine('a.md', 1)).evaluate().isNotEmpty, + () => + !session.container.read(searchSessionProvider).searching && + find.text(l10n.searchResultLine('a.md', 1)).evaluate().isNotEmpty, ); expect( find.byKey(const ValueKey('workspace-search-progress')), @@ -13710,22 +16283,53 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. }, ); - testWidgets('preview search highlights preserve Unicode offsets', ( - tester, - ) async { - final session = await _pumpSearchRegression(tester, headerBarService, { - 'a.md': 'İ needle\n', - }, mode: DocumentViewModePreference.preview); - session.events.add(const HeaderBarSearchQueryChanged('needle')); - await _waitForSearchCondition( - tester, - () => find.text(l10n.searchResultLine('a.md', 1)).evaluate().isNotEmpty, - ); - expect(tester.takeException(), isNull); - expect(find.text('İ needle', findRichText: true), findsWidgets); - await tester.pumpWidget(const SizedBox()); - await tester.pump(const Duration(seconds: 1)); - }); + testWidgets( + 'preview search highlights preserve Unicode offsets and literal case-insensitive matching', + (tester) async { + final harness = await _pumpSearchRegression(tester, headerBarService, { + 'a.md': 'İ needle Cat catalog .*\n', + }, mode: DocumentViewModePreference.preview); + bool hasHighlight(InlineSpan span, String text) => + span is TextSpan && + (span.text == text && span.style?.backgroundColor != null || + (span.children?.any((child) => hasHighlight(child, text)) ?? + false)); + final session = harness.container.read(searchSessionProvider.notifier); + for (final (query, highlighted) in [ + ('needle', ['needle']), + ('cat', ['Cat', 'cat']), + ('.*', ['.*']), + ]) { + session.query(query); + await _waitForSearchCondition( + tester, + () => !harness.container.read(searchSessionProvider).searching, + ); + await tester.pump(); + expect( + harness.container.read(searchSessionProvider).results, + hasLength(highlighted.length), + ); + for (final text in highlighted) { + expect( + tester + .widgetList(find.byType(RichText)) + .any((rich) => hasHighlight(rich.text, text)), + isTrue, + reason: + '$text must use the same literal, case-insensitive matching in Reading', + ); + } + expect(tester.takeException(), isNull); + } + expect( + find.text('İ needle Cat catalog .*', 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', @@ -13739,7 +16343,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. tester, () => find.text(l10n.searchResultLine('b.md', 2)).evaluate().isNotEmpty, ); - final results = find.byKey(const ValueKey('workspace-search-results')); + final results = find.byKey(const ValueKey('sidebar-search-results')); final title = tester .widgetList( find.descendant(of: results, matching: find.byType(Text)), @@ -14859,6 +17463,23 @@ TapGestureRecognizer? _firstTapRecognizer(InlineSpan span) { return null; } +class _PendingHistoryRestoreStore extends MemoryLocalHistoryStore { + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return super.capture(request, policy); + } +} + class _NavigationHistoryStore extends MemoryLocalHistoryStore { var available = false; var protectiveAttempts = 0; @@ -15346,7 +17967,11 @@ class _MutableWorkspaceController extends WorkspaceController { } @override - Future openActiveFile(String path) async { + Future openActiveFile( + String path, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false) return false; openedFilePath = path; return true; } @@ -15613,9 +18238,10 @@ GitFileStatus _gitStatusFile( Future _waitForSearchCondition( WidgetTester tester, - bool Function() condition, -) async { - for (var attempt = 0; attempt < 300; attempt++) { + bool Function() condition, { + int attempts = 300, +}) async { + for (var attempt = 0; attempt < attempts; attempt++) { await tester.pump(const Duration(milliseconds: 20)); if (condition()) return; await tester.runAsync( @@ -15642,6 +18268,7 @@ _pumpSearchRegression( Set oversized = const {}, Set unreadable = const {}, DocumentViewModePreference mode = DocumentViewModePreference.source, + LocalHistoryStore? historyStore, }) async { final events = StreamController.broadcast(); final settings = _MemorySettingsStore() @@ -15658,6 +18285,8 @@ _pumpSearchRegression( linuxHeaderBarServiceProvider.overrideWithValue(headerBar), headerBarSearchEventsProvider.overrideWith((ref) => events.stream), localSettingsStoreProvider.overrideWithValue(settings), + if (historyStore != null) + localHistoryStoreProvider.overrideWithValue(historyStore), workspaceServiceProvider.overrideWithValue( _SearchRegressionService( sources, @@ -15683,6 +18312,10 @@ _pumpSearchRegression( container.read(workspaceControllerProvider).activeBuffer != null && find.byType(HeaderBarConfigurationPublisher).evaluate().isNotEmpty, ); + container + .read(searchSessionProvider.notifier) + .open(category: SearchCategory.workspace); + await tester.pump(); return (container: container, events: events); } @@ -15704,6 +18337,7 @@ class _SearchRegressionService extends WorkspaceService { final Map sources; final Set oversized; final Set unreadable; + Future Function(String path)? beforeLoad; @override Future openPath(String path) async => Workspace( @@ -15743,6 +18377,7 @@ class _SearchRegressionService extends WorkspaceService { @override Future loadTextWithSnapshot(String path) async { + await beforeLoad?.call(path); final text = await loadText(path); return WorkspaceFileLoad( text: text, @@ -16522,6 +19157,7 @@ Future _copySettingsDictionaryDownload({ } class _MemorySettingsStore implements LocalSettingsStore { + bool failSaves = false; Map value = {}; @override @@ -16529,6 +19165,7 @@ class _MemorySettingsStore implements LocalSettingsStore { @override Future save(Map json) async { + if (failSaves) throw const FileSystemException('Fixture save failed'); value = json; } } @@ -16587,3 +19224,42 @@ class _UnavailableAccentSource implements LinuxAccentSource { @override Stream watchAccentColor() => Stream.value(null); } + +Future _selectShortcutSettingsPage(WidgetTester tester) async { + final nav = find.byKey( + const ValueKey('settings-navigation-keyboardShortcuts'), + ); + if (nav.evaluate().isNotEmpty) { + await tester.tap(nav); + } else { + final l10n = AppLocalizations.of( + tester.element(find.byType(SettingsScreen)), + ); + await tester.tap(find.byKey(const ValueKey('settings-page-selector'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.keyboardShortcuts).last); + } + await tester.pumpAndSettle(); +} + +Future _revealShortcutRow(WidgetTester tester, String id) async { + final list = find.byKey(const ValueKey('settings-shortcut-list')); + final scrollable = find + .descendant(of: list, matching: find.byType(Scrollable)) + .first; + await tester.drag(scrollable, const Offset(0, 20000)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(ValueKey('shortcut-edit-$id')), + 300, + scrollable: scrollable, + maxScrolls: 100, + ); + await tester.pumpAndSettle(); +} + +Future _browseShortcutRow(WidgetTester tester, String id) async { + await _revealShortcutRow(tester, id); + await tester.tap(find.byKey(ValueKey('shortcut-edit-$id'))); + await tester.pumpAndSettle(); +} diff --git a/test/src/busymark_design_test.dart b/test/src/busymark_design_test.dart index eded232a..211682f6 100644 --- a/test/src/busymark_design_test.dart +++ b/test/src/busymark_design_test.dart @@ -820,6 +820,80 @@ void main() { ); }); + for (final native in [false, true]) { + testWidgets( + 'selector forwards effective shortcut metadata to ${native ? 'GTK' : 'fallback'}', + (tester) async { + const channel = MethodChannel(nativeMenuChannelName); + Map? arguments; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (!native) throw MissingPluginException(); + if (call.method == 'show') { + arguments = call.arguments as Map; + return 1; + } + return false; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + String? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMarkPopupSelector( + value: 'file', + label: 'Document Text', + tooltip: 'Search category', + fullWidth: true, + options: const [ + BusyMarkPopupSelectorOption( + value: 'file', + label: 'Document Text', + shortcut: 'F8', + gtkAccelerator: 'F8', + ), + BusyMarkPopupSelectorOption( + value: 'actions', + label: 'Actions', + ), + ], + onSelected: (v) => selected = v, + ), + ), + ), + ); + final button = tester.widget>( + find.byType(BusyMarkMenuButton), + ); + final item = button.items.first as BusyMarkPopupMenuItem; + expect(item.shortcut, 'F8'); + expect(item.gtkAccelerator, 'F8'); + await tester.tap(find.byTooltip('Search category')); + await tester.pumpAndSettle(); + if (native) { + final entries = arguments!['entries'] as List; + expect((entries.first as Map)['shortcut'], 'F8'); + expect((entries.first as Map)['gtkAccelerator'], 'F8'); + expect((entries[1] as Map)['shortcut'], isNull); + expect(selected, 'actions'); + } else { + expect(find.text('F8'), findsOneWidget); + expect(item.checked, isTrue); + await tester.tap(find.text('Actions')); + await tester.pumpAndSettle(); + expect(selected, 'actions'); + } + }, + ); + } + testWidgets('desktop tooltips use one explicit natural-width geometry', ( tester, ) async { @@ -1980,26 +2054,19 @@ void main() { final selector = tester.widget>(menuFinder); final selectorFinder = find.descendant( of: find.byType(BusyMarkPopupSelector), - matching: find.byType(FilledButton), + matching: find.byType(TextButton), ); - final selectorButton = tester.widget(selectorFinder); + final selectorButton = tester.widget(selectorFinder); expect( - selectorButton.style?.backgroundColor?.resolve({}), - BusyMarkLinuxPalette.transparent, - ); - expect(selectorButton.style?.side?.resolve({}), BorderSide.none); - expect( - selectorButton.style?.minimumSize?.resolve({}), - theme.outlinedButtonTheme.style?.minimumSize?.resolve({}), + selectorButton.style?.foregroundColor, + same(theme.outlinedButtonTheme.style?.foregroundColor), + reason: 'The borderless selector retains the neutral themed foreground', ); expect( - selectorButton.style?.padding?.resolve({}), - theme.outlinedButtonTheme.style?.padding?.resolve({}), - ); - expect( - selectorButton.style?.shape?.resolve({}), - theme.outlinedButtonTheme.style?.shape?.resolve({}), + selectorButton.style?.iconColor, + same(theme.outlinedButtonTheme.style?.iconColor), ); + expect(selectorButton.style?.side, isNull); expect(selector.enabled, isTrue); expect(selector.items, hasLength(2)); @@ -2011,6 +2078,110 @@ void main() { expect(selectedTheme, 'light'); }); + for (final brightness in Brightness.values) { + testWidgets('sidebar and Settings selectors share borderless theme states ' + 'in ${brightness.name}', (tester) async { + final theme = buildBusyMarkTheme( + brightness: brightness, + accentColor: const Color(0xFF3584E4), + ); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.6)), + child: SizedBox( + width: 300, + child: Column( + children: [ + Row( + children: [ + const Text('Search:'), + Expanded( + child: BusyMarkPopupSelector( + key: const ValueKey('sidebar-selector'), + value: 'document', + label: 'Document Text', + tooltip: 'Search category', + fullWidth: true, + maxLabelLines: 2, + options: const [ + BusyMarkPopupSelectorOption( + value: 'document', + label: 'Document Text', + ), + ], + onSelected: (_) {}, + ), + ), + ], + ), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkActionRow( + title: 'Theme', + trailing: BusyMarkPopupSelector( + key: const ValueKey('settings-selector'), + value: 'system', + label: 'System', + tooltip: 'Theme', + options: const [ + BusyMarkPopupSelectorOption( + value: 'system', + label: 'System', + ), + ], + onSelected: (_) {}, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + + for (final key in ['sidebar-selector', 'settings-selector']) { + final button = find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(TextButton), + ); + final control = tester.widget(button); + final materialFinder = find.descendant( + of: button, + matching: find.byType(Material), + ); + void expectBorderless() { + final material = tester.widget(materialFinder); + expect((material.shape! as OutlinedBorder).side, BorderSide.none); + expect(material.color, BusyMarkLinuxPalette.transparent); + expect(tester.takeException(), isNull); + } + + expectBorderless(); + control.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + expect(control.focusNode!.hasFocus, isTrue); + expectBorderless(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(button)); + await tester.pumpAndSettle(); + expectBorderless(); + await mouse.removePointer(); + await tester.pumpAndSettle(); + } + }); + } + for (final brightness in Brightness.values) { testWidgets('WYSIWYG toolbar uses accent controls in ${brightness.name}', ( tester, diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 0f40e993..e33e9edc 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -1,3 +1,6 @@ +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; import 'dart:async'; import 'package:busymark/src/app/app_theme.dart'; @@ -11,6 +14,77 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets( + 'modal text input preserves local editing when an application binding overlaps it', + (tester) async { + var executed = 0; + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.create( + executions: {BusyMarkCommandIds.newDocument: () => executed++}, + ), + { + 'file.newDocument': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyA, + control: true, + ), + 'text.selectAll': null, + }, + ); + final text = TextEditingController(text: 'local modal input'); + addTearDown(text.dispose); + late BuildContext host; + await tester.pumpWidget( + MaterialApp( + builder: (_, child) => BusyMarkCommandRegistryScope( + registry: registry, + child: Shortcuts( + shortcuts: registry.shortcutIntents( + scopes: {BusyMarkCommandScope.application}, + intentFor: BusyMarkCommandIntent.new, + ), + child: Actions( + actions: { + BusyMarkCommandIntent: CallbackAction( + onInvoke: (i) { + registry.execute(i.commandId); + return null; + }, + ), + }, + child: child!, + ), + ), + ), + home: Builder( + builder: (c) { + host = c; + return const SizedBox(); + }, + ), + ), + ); + final dialog = showBusyMarkModalDialog( + host, + builder: (_) => + Dialog(child: TextField(controller: text, autofocus: true)), + ); + await tester.pumpAndSettle(); + text.selection = const TextSelection.collapsed(offset: 1); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + text.selection, + TextSelection(baseOffset: 0, extentOffset: text.text.length), + ); + expect(executed, 0); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await dialog; + }, + ); + testWidgets('modal dialogs stop app and editor shortcuts', (tester) async { const channel = MethodChannel('com.busymark.test/modal-shortcuts'); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( @@ -33,11 +107,11 @@ void main() { MaterialApp( builder: (context, child) => Shortcuts( shortcuts: { - BusyMarkAppShortcutActivators.nextTab: const _AppShortcutIntent(), - BusyMarkAppShortcutActivators.previousTab: + BusyMarkAppShortcuts.nextTab.activator: const _AppShortcutIntent(), + BusyMarkAppShortcuts.previousTab.activator: const _AppShortcutIntent(), - BusyMarkAppShortcutActivators.closeTab: const _AppShortcutIntent(), - BusyMarkEditorShortcutActivators.heading1: + BusyMarkAppShortcuts.closeTab.activator: const _AppShortcutIntent(), + BusyMarkEditorShortcuts.heading1.activator: const _EditorShortcutIntent(), }, child: Actions( diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 1315a525..cebc3d7a 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -3999,8 +3999,8 @@ void main() {} await tester.pumpWidget( Shortcuts( shortcuts: { - BusyMarkAppShortcutActivators.nextTab: const _TestTabIntent(), - BusyMarkAppShortcutActivators.previousTab: const _TestTabIntent(), + BusyMarkAppShortcuts.nextTab.activator: const _TestTabIntent(), + BusyMarkAppShortcuts.previousTab.activator: const _TestTabIntent(), }, child: Actions( actions: >{ diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index 4db49b6b..c5d6cb21 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -44,10 +44,8 @@ void main() { .register(_PanelInsertionTarget()); await _pumpPanel(tester, container); - expect( - tester.widget(find.byType(TextField)).decoration?.hintText, - 'Search clipboard history', - ); + expect(find.byType(TextField), findsNothing); + expect(find.text('Clipboard History'), findsOneWidget); final row = tester.widget( find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), ); @@ -95,55 +93,63 @@ void main() { 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')); + testWidgets( + 'header owns the trailing management menu and preserves 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 actions = find.byKey( + const ValueKey('clipboard-history-actions-menu'), + ); + final title = find.text( + AppLocalizations.of( + tester.element(find.byType(ClipboardHistoryPanel)), + ).clipboardHistory, + ); + 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, + lessThan(tester.getCenter(title).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); + 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); - }); + 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, @@ -185,34 +191,33 @@ void main() { expect(selectedRect.bottom, lessThanOrEqualTo(listRect.bottom)); }); - testWidgets('Delete edits the focused search without removing history', ( - tester, - ) async { - final container = _container(); - container - .read(clipboardHistoryControllerProvider.notifier) - .retain( - const BusyMarkClipboardCapture( - kind: BusyMarkClipboardContentKind.text, - text: 'retained entry', - sourceText: 'retained entry', - ), - ); - await _pumpPanel(tester, container); - - final field = tester.widget(find.byType(TextField)); - await tester.tap(find.byType(TextField)); - await tester.enterText(find.byType(TextField), 'retained'); - field.controller!.selection = const TextSelection.collapsed(offset: 6); - await tester.sendKeyEvent(LogicalKeyboardKey.delete); - await tester.pump(); + testWidgets( + 'Delete and Enter on a focused menu never delete or paste a list entry', + (tester) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'retained entry', + sourceText: 'retained entry', + ), + ); + await _pumpPanel(tester, container); - expect(field.controller!.text, 'retaind'); - expect( - container.read(clipboardHistoryControllerProvider).entries, - hasLength(1), - ); - }); + await tester.tap( + find.byKey(const ValueKey('clipboard-history-actions-menu')), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pump(); + expect( + container.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); + }, + ); testWidgets('unsupported payload disables paste for the destination', ( tester, @@ -456,9 +461,8 @@ void main() { .read(clipboardHistoryControllerProvider.notifier) .refreshCurrentClipboard(); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField).first, 'External'); - await tester.pumpAndSettle(); - await _openEntryActions(tester); + expect(find.byType(TextField), findsNothing); + await _openEntryActions(tester, index: 1); await tester.tap(find.text('Paste')); await tester.pumpAndSettle(); expect(target.payloads, hasLength(2)); @@ -608,13 +612,13 @@ void main() { }); } -Future _openEntryActions(WidgetTester tester) async { +Future _openEntryActions(WidgetTester tester, {int index = 0}) 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); + expect(actions, findsWidgets); + await tester.tap(actions.at(index)); await tester.pumpAndSettle(); } diff --git a/test/src/command_registry_test.dart b/test/src/command_registry_test.dart index 2b72078e..defdb3d3 100644 --- a/test/src/command_registry_test.dart +++ b/test/src/command_registry_test.dart @@ -1,10 +1,557 @@ +import 'package:busymark/src/app/command_shortcuts.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; import 'package:busymark/src/app/command_registry.dart'; import 'package:busymark/src/app/busymark_shortcuts.dart'; +import 'package:busymark/src/search/search_session.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:busymark/l10n/generated/app_localizations.dart'; void main() { + testWidgets( + 'all eight search commands occur once in Search with only their new defaults', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ); + const expected = { + 'search.find': ('Ctrl+F', 'f'), + 'search.workspace': ('Ctrl+Shift+F', 'f'), + 'search.headings': ('Ctrl+Shift+H', 'h'), + 'workspace.quickOpen': ('Ctrl+Shift+D', 'd'), + 'search.topics': ('Ctrl+Shift+T', 't'), + 'search.recent': ('Ctrl+Shift+R', 'r'), + 'history.findLocal': ('Ctrl+Shift+L', 'l'), + 'view.commandPalette': ('Ctrl+Shift+P', 'p'), + }; + final registry = BusyMarkCommandCatalog.metadata; + expect( + registry.commands + .where((c) => c.category(context) == 'Search') + .map((c) => c.id) + .toSet(), + expected.keys.toSet(), + ); + for (final entry in expected.entries) { + expect(registry.commands.where((c) => c.id == entry.key), hasLength(1)); + expect(registry[entry.key]!.shortcut!.label, entry.value.$1); + expect(registry[entry.key]!.shortcut!.gtkAccelerator, entry.value.$2); + expect(registry.activators(entry.key), hasLength(1)); + } + for (final id in ['sidebar.outline', 'history.local']) { + final command = registry[id]; + expect(command, isNotNull); + expect(command!.category(context), 'Sidebar'); + } + }, + ); + + test( + 'tree browsing and Source line movement can share a combination within one scope', + () { + final registry = + effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, { + 'tree.renameSelection': const BusyMarkKeyBinding( + LogicalKeyboardKey.f8, + ), + 'tree.moveLineUp': const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + }); + expect(registry['tree.renameSelection']!.shortcut!.label, 'F8'); + expect(registry['tree.moveLineUp']!.shortcut!.label, 'F8'); + expect( + registry.conflicts( + 'tree.moveLineUp', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + ), + isEmpty, + ); + }, + ); + + test( + 'effective overrides resolve simultaneously without serialized-order winners', + () { + final defaults = BusyMarkCommandCatalog.metadata; + const bold = BusyMarkKeyBinding(LogicalKeyboardKey.keyS, control: true); + const save = BusyMarkKeyBinding(LogicalKeyboardKey.f8); + for (final map in [ + {'editor.bold': bold, 'file.save': save}, + {'file.save': save, 'editor.bold': bold}, + ]) { + final registry = effectiveCommandRegistry(defaults, map); + expect(registry['editor.bold']!.shortcut!.label, 'Ctrl+S'); + expect(registry['file.save']!.shortcut!.label, 'F8'); + } + for (final map in [ + {'editor.bold': save, 'file.save': save}, + {'file.save': save, 'editor.bold': save}, + {'editor.bold': save, 'file.save': save, 'editor.italic': bold}, + ]) { + final registry = effectiveCommandRegistry(defaults, map); + expect(registry['editor.bold']!.shortcut!.label, 'Ctrl+B'); + expect(registry['file.save']!.shortcut!.label, 'Ctrl+S'); + expect(registry['editor.italic']!.shortcut!.label, 'Ctrl+I'); + } + }, + ); + + test('Writerside Editor and Source operations have separate receivers', () { + final registry = effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, { + 'writerside.uiControl': const BusyMarkKeyBinding( + LogicalKeyboardKey.f7, + alt: true, + ), + }); + expect(registry['writerside.uiControl']!.shortcut!.label, 'Alt+F7'); + expect(registry['source.usages']!.shortcut!.label, 'Alt+F7'); + expect( + registry.conflicts( + 'writerside.uiControl', + const BusyMarkKeyBinding(LogicalKeyboardKey.f7, alt: true), + ), + isEmpty, + ); + expect( + registry + .conflicts( + 'editor.bold', + const BusyMarkKeyBinding(LogicalKeyboardKey.f7, alt: true), + ) + .map((c) => c.id), + containsAll(['source.usages', 'writerside.uiControl']), + ); + final reassigned = + effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, { + 'text.paste': null, + 'writerside.uiControl': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyV, + control: true, + ), + }); + const paste = SingleActivator(LogicalKeyboardKey.keyV, control: true); + expect( + reassigned.blockedDefaultIntents({ + BusyMarkCommandScope.editor, + BusyMarkCommandScope.textEditing, + }), + contains(paste), + ); + expect( + reassigned.blockedDefaultIntents({ + BusyMarkCommandScope.editor, + BusyMarkCommandScope.wysiwyg, + BusyMarkCommandScope.textEditing, + }), + isNot(contains(paste)), + ); + }); + + test( + 'resource-exclusive Notes and local Source receivers can reuse a binding', + () { + final registry = + effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, { + 'notes.sync': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyY, + control: true, + alt: true, + ), + 'source.declaration': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyY, + control: true, + alt: true, + ), + }); + expect(registry['notes.sync']!.shortcut!.label, 'Ctrl+Alt+Y'); + expect(registry['source.declaration']!.shortcut!.label, 'Ctrl+Alt+Y'); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['notes.sync']!, + registry['source.declaration']!, + ), + isFalse, + ); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['notes.sync']!, + registry['tree.deleteSelection']!, + ), + isFalse, + ); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['notes.sync']!, + registry['tree.moveLineUp']!, + ), + isFalse, + ); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['notes.sync']!, + registry['editor.bold']!, + ), + isTrue, + ); + expect( + registry['notes.sync']!.resourceContext, + BusyMarkCommandResourceContext.notesWorkspace, + ); + expect( + registry['source.declaration']!.resourceContext, + BusyMarkCommandResourceContext.localWorkspace, + ); + }, + ); + + test('conflicts reflect receiving contexts and all default aliases', () { + final registry = effectiveCommandRegistry( + BusyMarkCommandCatalog.metadata, + {}, + ); + expect( + registry + .conflicts( + 'editor.italic', + const BusyMarkKeyBinding(LogicalKeyboardKey.keyS, control: true), + ) + .map((c) => c.id), + ['file.save'], + ); + expect( + registry + .conflicts( + 'file.save', + const BusyMarkKeyBinding(LogicalKeyboardKey.numpad3, control: true), + ) + .map((c) => c.id), + ['sidebar.outline'], + ); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['tree.deleteSelection']!, + registry['editor.bold']!, + ), + isFalse, + ); + expect( + BusyMarkCommandRegistry.contextsOverlap( + registry['tree.moveLineUp']!, + registry['editor.bold']!, + ), + isTrue, + ); + final custom = effectiveCommandRegistry(BusyMarkCommandCatalog.metadata, { + 'sidebar.outline': const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + 'view.source': null, + }); + expect(custom.activators('sidebar.outline'), hasLength(1)); + expect( + BusyMarkKeyBinding.fromActivator( + custom.activators('sidebar.outline').single as SingleActivator, + ), + const BusyMarkKeyBinding(LogicalKeyboardKey.f12), + ); + expect(custom.activators('view.source'), isEmpty); + expect(custom['view.source']!.shortcut, isNull); + expect( + custom.blockedDefaultIntents({BusyMarkCommandScope.documentView}), + contains( + const SingleActivator( + LogicalKeyboardKey.digit2, + control: true, + shift: true, + ), + ), + ); + }); + test( + 'logical key serialization and GTK syntax do not depend on badge parsing', + () { + for (final key in [ + LogicalKeyboardKey.comma, + LogicalKeyboardKey.f12, + LogicalKeyboardKey.numpad7, + const LogicalKeyboardKey(0xe9), + ]) { + final binding = BusyMarkKeyBinding(key, control: true, alt: true); + expect(BusyMarkKeyBinding.fromJson(binding.toJson()), binding); + expect(binding.gtkAccelerator, startsWith('')); + } + expect( + const BusyMarkKeyBinding( + LogicalKeyboardKey.comma, + control: true, + ).gtkAccelerator, + 'comma', + ); + expect( + const BusyMarkKeyBinding( + LogicalKeyboardKey(0xe9), + control: true, + ).gtkAccelerator, + 'U+00E9', + ); + expect( + const BusyMarkKeyBinding( + LogicalKeyboardKey(0x26), + control: true, + ).gtkAccelerator, + 'U+0026', + ); + expect( + BusyMarkKeyBinding.fromJson({ + 'key': LogicalKeyboardKey.controlLeft.keyId, + }), + isNull, + ); + expect(BusyMarkKeyBinding.fromJson({'key': 0x85}), isNull); + final defaults = BusyMarkCommandCatalog.metadata; + expect( + shortcutAssignmentAllowed( + defaults['file.save']!, + const BusyMarkKeyBinding(LogicalKeyboardKey.keyA), + ), + isFalse, + ); + expect( + shortcutConfigurable(defaults['text.insertIndentation']!), + isFalse, + ); + }, + ); + + test( + 'search destinations exclude ordinary Outline and History navigation', + () { + expect(searchCommandDestinations, { + 'search.find': SearchCategory.file, + 'search.workspace': SearchCategory.workspace, + 'search.headings': SearchCategory.outline, + 'workspace.quickOpen': SearchCategory.documents, + 'search.topics': SearchCategory.topics, + 'search.recent': SearchCategory.recent, + 'history.findLocal': SearchCategory.localHistory, + 'view.commandPalette': SearchCategory.actions, + }); + expect(searchCommandDestinations.containsKey('sidebar.outline'), isFalse); + expect(searchCommandDestinations.containsKey('history.local'), isFalse); + }, + ); + test( + 'retired defaults block platform aliases while allowing explicit reassignment to a receiver', + () { + final defaults = BusyMarkCommandCatalog.metadata; + final unassigned = effectiveCommandRegistry(defaults, { + 'text.paste': null, + }); + final blocked = unassigned.blockedDefaultIntents({ + BusyMarkCommandScope.textEditing, + BusyMarkCommandScope.editor, + }); + expect( + blocked, + contains(const SingleActivator(LogicalKeyboardKey.keyV, control: true)), + ); + expect( + blocked, + contains(const SingleActivator(LogicalKeyboardKey.insert, shift: true)), + ); + expect(unassigned.activators('text.paste'), isEmpty); + final reassigned = effectiveCommandRegistry(defaults, { + 'text.paste': null, + 'file.save': const BusyMarkKeyBinding( + LogicalKeyboardKey.keyV, + control: true, + ), + }); + expect( + reassigned.blockedDefaultIntents({ + BusyMarkCommandScope.editor, + BusyMarkCommandScope.textEditing, + }), + isNot( + contains( + const SingleActivator(LogicalKeyboardKey.keyV, control: true), + ), + ), + ); + expect(reassigned['file.save']!.shortcut!.label, 'Ctrl+V'); + }, + ); + testWidgets( + 'disabled contextual shortcuts consume keys without executing a fallback action', + (tester) async { + var enabled = false, invoked = 0, fallthrough = 0; + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xff000000), + builder: (_, child) => Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.keyK, control: true): + ActivateIntent(), + }, + child: Actions( + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + fallthrough++; + return null; + }, + ), + }, + child: BusyMarkCommandShortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.keyK, control: true): + BusyMarkContextCommandIntent('editor.bold'), + }, + child: Actions( + actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + isCommandEnabled: (_) => enabled, + onCommand: (_) => invoked++, + ), + }, + child: const Focus(autofocus: true, child: SizedBox()), + ), + ), + ), + ), + ), + ); + await tester.pump(); + Future press() async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyK); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + } + + await press(); + expect(invoked, 0); + expect(fallthrough, 0); + enabled = true; + await press(); + expect(invoked, 1); + expect(fallthrough, 0); + }, + ); + test('document and workspace command targets are explicit', () { + final registry = BusyMarkCommandCatalog.metadata; + for (final id in [ + 'search.find', + 'file.save', + 'file.export', + 'tabs.close', + 'sidebar.outline', + 'editor.bold', + ]) { + expect( + registry.requiresDocumentTarget(registry[id]!), + isTrue, + reason: id, + ); + } + for (final id in [ + 'history.local', + 'history.findLocal', + 'sidebar.files', + 'sidebar.git', + 'tabs.next', + 'tabs.closeAll', + 'notes.sync', + 'notes.export', + 'notes.importFolder', + 'notes.importFile', + ]) { + expect( + registry.requiresWorkspaceTarget(registry[id]!), + isTrue, + reason: id, + ); + } + for (final id in [ + 'notes.sync', + 'notes.export', + 'notes.importFolder', + 'notes.importFile', + ]) { + expect(registry[id], isNotNull); + expect(registry[id]!.shortcut, isNull); + } + for (final id in [ + 'history.local', + 'history.findLocal', + 'file.open', + 'file.newDocument', + 'notes.create', + 'help.keyboardShortcuts', + ]) { + expect( + registry.requiresDocumentTarget(registry[id]!), + isFalse, + reason: id, + ); + } + }); + test('all agreed search shortcuts have no cross-scope collision', () { + final registry = BusyMarkCommandCatalog.metadata; + expect(registry['help.keyboardShortcuts'], isNotNull); + expect(registry['help.keyboardShortcuts']!.shortcut, isNull); + expect(registry.activators('help.keyboardShortcuts'), isEmpty); + const expected = { + 'search.find': 'Ctrl+F', + 'search.workspace': 'Ctrl+Shift+F', + 'sidebar.outline': 'Ctrl+3', + 'workspace.quickOpen': 'Ctrl+Shift+D', + 'search.topics': 'Ctrl+Shift+T', + 'search.recent': 'Ctrl+Shift+R', + 'view.commandPalette': 'Ctrl+Shift+P', + 'search.headings': 'Ctrl+Shift+H', + 'history.findLocal': 'Ctrl+Shift+L', + }; + for (final entry in expected.entries) { + expect(registry[entry.key]?.shortcut?.label, entry.value); + final activator = registry[entry.key]!.shortcut!.activator; + expect( + registry.commands + .where((c) => c.shortcut?.activator == activator) + .map((c) => c.id), + [entry.key], + ); + } + for (final id in [ + 'writerside.insertProcedure', + 'writerside.insertTabs', + 'writerside.insertDefinitionList', + 'writerside.insertInclude', + 'writerside.insertVariable', + 'writerside.insertVideo', + 'writerside.insertTldr', + 'writerside.insertConvertListToProcedure', + 'writerside.tip', + 'writerside.note', + 'writerside.warning', + 'writerside.quote', + 'writerside.uiControl', + 'writerside.filePath', + 'writerside.uiPath', + 'writerside.shortcut', + ]) { + expect(registry[id]?.execute, isNotNull, reason: id); + } + }); test('catalog includes every editor command with an executable callback', () { final registry = BusyMarkCommandCatalog.create(); diff --git a/test/src/editor_ui_primitives_audit_test.dart b/test/src/editor_ui_primitives_audit_test.dart index 88f1fced..8dfe547a 100644 --- a/test/src/editor_ui_primitives_audit_test.dart +++ b/test/src/editor_ui_primitives_audit_test.dart @@ -23,9 +23,14 @@ void main() { expect(searchPanel, isNot(contains('DecoratedBox('))); expect(searchPanel, isNot(contains('Border.all('))); expect(searchPanel, isNot(contains('elevation:'))); - expect(RegExp(r'YaruIconButton\(').allMatches(searchButtons).length, 2); + expect(searchPanel, isNot(contains('SearchOptionButton('))); + expect( + File('lib/src/search/search_option_button.dart').existsSync(), + isFalse, + ); + expect(RegExp(r'YaruIconButton\(').allMatches(searchButtons).length, 1); expect(searchButtons, contains('iconSize: 28')); - expect(searchButtons, contains('isSelected: selected')); + expect(searchButtons, isNot(contains('isSelected: selected'))); expect(searchButtons, isNot(contains('WidgetStateProperty.resolveWith'))); expect(searchButtons, isNot(contains('backgroundColor:'))); expect(searchButtons, isNot(contains('fixedSize:'))); @@ -76,7 +81,7 @@ void main() { 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); final problemsList = RegExp( - r'class _ProblemsList[\s\S]*?class _SearchSidebar', + r'class _ProblemsList[\s\S]*?@visibleForTesting', ).firstMatch(workspace)!.group(0)!; expect(problemsList, contains('return BusyMarkGroupedSurface(')); diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index bed0a97d..ea86edc0 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -447,6 +447,9 @@ void main() { ); expect(_labels.toMap(), isNot(contains('markdownAndHtml'))); for (final removedLabel in { + 'keyboardShortcuts', + 'keyboardShortcutsShortcut', + 'keyboardShortcutsGtkAccelerator', 'clipboardHistory', 'localHistory', 'findLocalHistory', @@ -517,9 +520,6 @@ const _labels = HeaderBarLabels( settings: 'Settings', settingsShortcut: 'Ctrl+,', settingsGtkAccelerator: 'comma', - keyboardShortcuts: 'Keyboard Shortcuts', - keyboardShortcutsShortcut: 'Ctrl+?', - keyboardShortcutsGtkAccelerator: 'question', syntaxReference: 'Syntax Reference', syntaxReferenceShortcut: 'F1', syntaxReferenceGtkAccelerator: 'F1', diff --git a/test/src/keyboard_shortcut_badge_test.dart b/test/src/keyboard_shortcut_badge_test.dart new file mode 100644 index 00000000..8806c35c --- /dev/null +++ b/test/src/keyboard_shortcut_badge_test.dart @@ -0,0 +1,39 @@ +import 'package:busymark/src/app/keyboard_shortcut_badge.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('scaled shortcut badges wrap between complete modifier names', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.6)), + child: const Center( + child: SizedBox( + width: 120, + child: BusyMarkKeyboardShortcutBadge('Ctrl+Shift+P'), + ), + ), + ), + ), + ); + final text = tester.widget(find.byType(Text)); + expect(text.semanticsLabel, 'Ctrl+Shift+P'); + expect(text.data!.replaceAll('\u200B', ''), 'Ctrl+Shift+P'); + final paragraph = tester.renderObject( + find.byType(RichText), + ); + final start = text.data!.indexOf('Shift'); + expect( + paragraph.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: start + 'Shift'.length), + ), + hasLength(1), + reason: 'The modifier must stay together even when the badge wraps.', + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 3dc4ab3e..183eb691 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1221,9 +1221,14 @@ void main() { 'base', ); await controller.selectDocumentForBuffer(opened); - await controller.search('needle'); + final id = container + .read(localHistoryControllerProvider) + .selectedDocumentId; expect( - container.read(localHistoryControllerProvider).searchMatches, + (await controller.findRetained( + query: 'needle', + documentId: id, + ))!.matches, isEmpty, ); @@ -1233,14 +1238,14 @@ void main() { timers.last.fire(); await _waitForHistory( container, - (state) => - !state.searching && - state.searchQuery == 'needle' && - state.searchMatches.isNotEmpty, + (state) => state.selectedRevisions.length == 2, ); var state = container.read(localHistoryControllerProvider); expect( - state.selectedRevisions.where(state.revisionVisible), + (await controller.findRetained( + query: 'needle', + documentId: id, + ))!.matches, hasLength(1), ); @@ -1250,12 +1255,15 @@ void main() { timers.last.fire(); await _waitForHistory( container, - (value) => !value.searching && value.selectedRevisions.length == 3, + (value) => value.selectedRevisions.length == 3, ); state = container.read(localHistoryControllerProvider); - expect(state.searchQuery, 'needle'); + expect(state.selectedRevisions, hasLength(3)); expect( - state.selectedRevisions.where(state.revisionVisible), + (await controller.findRetained( + query: 'needle', + documentId: id, + ))!.matches, hasLength(1), ); }, @@ -1974,15 +1982,19 @@ void main() { contains('authentication'), ); - controller.beginDocumentSearch(); - await controller.search('authentication'); + controller.browseDocuments(); + final contents = await controller.findRetained(query: 'authentication'); var state = container.read(localHistoryControllerProvider); - expect(state.findingDocuments, isTrue); - expect(state.documentSearchMatches, {state.snapshot.documents.single.id}); + expect(state.browsingDocuments, isTrue); + expect(contents!.matches.map((m) => m.document.id).toSet(), { + state.snapshot.documents.single.id, + }); - await controller.search('/retired/guides'); + final paths = await controller.findRetained(query: '/retired/guides'); state = container.read(localHistoryControllerProvider); - expect(state.documentSearchMatches, {state.snapshot.documents.single.id}); + expect(paths!.matches.map((m) => m.document.id).toSet(), { + state.snapshot.documents.single.id, + }); expect(state.snapshot.documents.single.deleted, isTrue); }, ); @@ -2015,28 +2027,28 @@ void main() { .read(localHistoryControllerProvider) .selectedDocumentId; - controller.beginDocumentSearch(); - await controller.search('Unique retained'); + controller.browseDocuments(); + final matches = await controller.findRetained(query: '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}); + expect(matches!.matches.map((m) => m.document.id).toSet(), {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); + expect(state.selectedRevisions, isNotEmpty); await controller.selectDocumentForBuffer(current); state = container.read(localHistoryControllerProvider); expect(state.inspectingRetainedDocument, isFalse); - expect(state.findingDocuments, isFalse); + expect(state.browsingDocuments, isFalse); expect(state.selectedDocumentId, currentId); - expect(state.searchQuery, isEmpty); + expect(state.selectedRevisions, isNotEmpty); }, ); @@ -2079,15 +2091,25 @@ void main() { 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')); + history.browseDocuments(); + expect( + (await tester.runAsync( + () => history.findRetained(query: 'Unique retained'), + ))!.matches, + isNotEmpty, + ); final retained = container .read(localHistoryControllerProvider) .snapshot .documents .singleWhere((document) => document.deleted); history.inspectRetainedDocument(retained.id); - await tester.runAsync(() => history.search('Unique retained')); + expect( + (await tester.runAsync( + () => history.findRetained(query: 'Unique retained'), + ))!.matches, + isNotEmpty, + ); await tester.pumpWidget( UncontrolledProviderScope( @@ -2104,13 +2126,10 @@ void main() { final state = container.read(localHistoryControllerProvider); expect(state.inspectingRetainedDocument, isTrue); expect(state.selectedDocumentId, retained.id); - expect(state.searchQuery, 'Unique retained'); - expect( - tester.widget(find.byType(TextField)).controller!.text, - 'Unique retained', - ); + expect(find.byType(TextField), findsNothing); + expect(state.selectedRevisions, isNotEmpty); expect( - find.byKey(const ValueKey('local-history-context-header')), + find.byKey(const ValueKey('local-history-document-context')), findsOneWidget, ); expect(find.text('deleted.md'), findsOneWidget); @@ -2141,7 +2160,10 @@ void main() { await controller.refresh(); controller.selectDocument(documentA.id); - final search = controller.search('Only A'); + final search = controller.findRetained( + query: 'Only A', + documentId: documentA.id, + ); await store.started.future; controller.selectDocument(documentB.id); expect(container.read(localHistoryControllerProvider).loading, isFalse); @@ -2150,17 +2172,55 @@ void main() { documentB.id, ); store.release.complete(); - await search; + final page = await search; + expect(page!.matches.map((m) => m.document.id).toSet(), {documentA.id}); 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.selectedRevisions.every((r) => r.documentId == documentB.id), + isTrue, + ); + expect(state.selectedRevision, isNull); expect(state.loading, isFalse); }, ); + test( + 'cancelled search activation never publishes a delayed revision body', + () async { + final memory = MemoryLocalHistoryStore(); + await _capturePath(memory, '/retired/deleted.md', 'retained secret'); + await memory.markDeleted('/retired/deleted.md', recursive: false); + final snapshot = await memory.load(); + final document = snapshot.documents.single; + final revision = snapshot.revisions.single; + final store = _BlockingRevisionReadStore(memory, revision.id); + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final history = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + await history.refresh(); + history.inspectRetainedDocument(document.id); + var currentRequest = true; + final loading = history.selectRevision( + revision.id, + acceptSelection: () => currentRequest, + ); + await store.started.future; + expect(container.read(localHistoryControllerProvider).loading, isTrue); + currentRequest = false; + store.release.complete(); + await loading; + final after = container.read(localHistoryControllerProvider); + expect(after.selectedRevision, isNull); + expect(after.selectedRevisionId, isNull); + expect(after.loading, isFalse); + expect(after.selectedDocumentId, document.id); + expect((await memory.load()).revisions.map((r) => r.id), [revision.id]); + }, + ); + test( 'revision search failure finishes searching and remains in scope', () async { @@ -2181,19 +2241,21 @@ void main() { controller.selectDocument(document.id); store.failReads = true; - await controller.search('matching'); + await expectLater( + controller.findRetained(query: 'matching', documentId: document.id), + throwsA(isA()), + ); final state = container.read(localHistoryControllerProvider); - expect(state.searching, isFalse); - expect(state.searchQuery, 'matching'); + expect(state.loading, isFalse); expect(state.selectedDocumentId, document.id); - expect(state.searchMatches, isEmpty); - expect(state.warning?.kind, LocalHistoryWarningKind.revisionRead); + expect(state.selectedRevision, isNull); + expect(state.warning, isNull); }, ); testWidgets( - 'Local History follows active tabs and clears the visible query without a document dropdown', + 'ordinary Local History follows active tabs without a private query or document dropdown', (tester) async { final directory = await tester.runAsync( () => Directory.systemTemp.createTemp('busymark-history-follow-'), @@ -2249,36 +2311,29 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('local-history-context-header')), - findsNothing, + find.byKey(const ValueKey('local-history-document-context')), + findsOneWidget, ); - expect(find.text('B.md'), findsNothing); + expect(find.text('B.md'), findsOneWidget); 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', - ), + expect(searchField, findsNothing); + final scope = container + .read(localHistoryControllerProvider) + .selectedDocumentId; + final matches = await tester.runAsync( + () => container + .read(localHistoryControllerProvider.notifier) + .findRetained(query: 'Unique B', documentId: scope), ); + expect(matches!.matches, hasLength(1)); final selectedBeforeFocus = container .read(localHistoryControllerProvider) .selectedDocumentId; - await tester.tap(searchField); await tester.pump(); expect( container.read(localHistoryControllerProvider).selectedDocumentId, @@ -2294,29 +2349,33 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('local-history-context-header')), - findsNothing, + find.byKey(const ValueKey('local-history-document-context')), + findsOneWidget, ); - expect(find.text('A.md'), findsNothing); + expect(find.text('A.md'), findsOneWidget); + expect(searchField, findsNothing); expect( - container.read(localHistoryControllerProvider).searchQuery, - isEmpty, + container.read(localHistoryControllerProvider).selectedRevisions, + isNotEmpty, ); - 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), + find.byTooltip( + AppLocalizations.of( + tester.element(find.byType(LocalHistoryPanel)), + ).findLocalHistoryEllipsis, + ), + findsNothing, ); 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('Find in Local History…'), findsNothing); expect(find.text('Clear This Document’s History'), findsOneWidget); expect(find.text('Clear All Local History'), findsOneWidget); }, @@ -2410,10 +2469,14 @@ void main() { const ValueKey('local-history-actions-menu'), ); expect(actionsMenu, findsOneWidget); - expect(searchField, findsOneWidget); + expect(searchField, findsNothing); expect( - tester.getCenter(actionsMenu).dx, - greaterThan(tester.getTopRight(searchField).dx), + find.byTooltip( + AppLocalizations.of( + tester.element(find.byType(LocalHistoryPanel)), + ).findLocalHistoryEllipsis, + ), + findsNothing, ); expect(find.text(beforeLabel), findsOneWidget); expect(find.text(afterLabel), findsOneWidget); diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index c00262aa..b838e76b 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -894,6 +894,115 @@ void main() { }, ); + for (final change in [ + 'session', + 'refresh', + 'edit-undone', + 'mode', + 'mode-return', + 'navigation', + 'disk', + 'encoding', + ]) { + test( + 'History restore validates source and target rather than session identity: $change', + () async { + final destination = p.join(root.path, 'destination.md'); + final anchor = p.join(root.path, 'anchor.md'); + await File(destination).writeAsString('Protected destination\n'); + await File(anchor).writeAsString('# Anchor\n'); + final memory = MemoryLocalHistoryStore(); + final captured = await _capture( + memory, + p.join(root.path, 'deleted.md'), + 'Recovered source\n', + ); + final revision = (await memory.readRevision(captured.revision!.id))!; + final blocking = _BlockingProtectiveStore(memory); + final harness = await _harness(blocking); + await harness.controller.openPath(root.path); + await harness.controller.openActiveFile(destination); + final before = harness.state.activeBuffer!; + final restore = harness.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: destination, + overwriteExisting: true, + ); + await blocking.started.future; + switch (change) { + case 'session': + harness.controller.updateActiveEditorState( + before.editorState.copyWith(scrollOffset: 12), + ); + case 'refresh': + expect( + await harness.controller + .refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + case 'edit-undone': + harness.controller.updateActiveText('Concurrent edit\n'); + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, before.text); + case 'mode': + harness.controller.updateActiveEditorMode( + DocumentViewModePreference.source, + ); + case 'mode-return': + harness.controller.updateActiveEditorMode( + DocumentViewModePreference.source, + ); + harness.controller.updateActiveEditorMode(before.editorState.mode); + case 'navigation': + await harness.controller.openActiveFile(anchor); + await harness.controller.openActiveFile(destination); + case 'disk': + await File( + destination, + ).writeAsString('Concurrent disk replacement\n'); + case 'encoding': + await File( + destination, + ).writeAsString('\uFEFFProtected destination\r\n'); + expect( + await harness.controller + .refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + expect(harness.state.activeText, before.text); + } + blocking.release.complete(); + final accepted = change == 'session' || change == 'refresh'; + expect(await restore, accepted); + expect( + harness.state.activeText, + accepted ? revision.source : before.text, + ); + expect( + await File(destination).readAsString(), + change == 'disk' + ? 'Concurrent disk replacement\n' + : change == 'encoding' + ? 'Protected destination\r\n' + : before.text, + ); + if (change == 'encoding') { + expect((await File(destination).readAsBytes()).take(3), [ + 0xef, + 0xbb, + 0xbf, + ]); + } + if (change == 'session') { + expect(harness.controller.undoActiveBuffer(), isTrue); + expect(harness.state.activeText, before.text); + expect(harness.state.activeBuffer!.editorState.scrollOffset, 12); + } + }, + ); + } + test( 'autosaved existing-destination recovery never publishes an empty file', () async { diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 781ead6c..c1e6a6cc 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -1337,6 +1337,7 @@ const _localeSpecificEnglishMatches = >{ 'shortcutGroupGeneral', }, 'fr': { + 'searchDocumentsCategory', // Documents is also the French plural. 'wsModule', // Module is also the French noun. 'htmlInstance', // Instance is also the French technical term. 'actions', @@ -1405,7 +1406,6 @@ const _localeSpecificEnglishMatches = >{ 'file', 'folder', 'link', - 'sourceSearchRegex', 'tip', 'pdfMarginNormal', 'instanceStatus', @@ -1436,7 +1436,6 @@ const _localeSpecificEnglishMatches = >{ 'link', 'editorPlaceholderCode', 'toc', - 'sourceSearchRegex', 'tip', 'tab', 'procedure', diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 6feb3f91..83ecb44b 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -459,6 +459,7 @@ void main() { expect(nativeMenu, contains('cairo_curve_to(')); expect(nativeMenu, contains('gtk_icon_info_load_symbolic(')); expect(design, contains('shortcut: item.shortcut')); + expect(design, contains('gtkAccelerator: item.gtkAccelerator')); expect(design, contains('checkable: item.trailingCheck')); expect(design, contains('mutuallyExclusive: item.mutuallyExclusive')); expect(probe, contains('command == "assert-check"')); @@ -476,10 +477,7 @@ void main() { 'heading5', 'heading6', ]) { - expect( - toolbar, - contains('shortcut: BusyMarkEditorShortcutLabels.$shortcut'), - ); + expect(toolbar, contains("['editor.$shortcut']")); } }); diff --git a/test/src/nextcloud_editor_boundary_test.dart b/test/src/nextcloud_editor_boundary_test.dart index 92d9c210..93b7bcbe 100644 --- a/test/src/nextcloud_editor_boundary_test.dart +++ b/test/src/nextcloud_editor_boundary_test.dart @@ -65,7 +65,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => edits.add(text), onOpenSearch: () {}, onCloseSearch: () {}, diff --git a/test/src/nextcloud_notes/notes_workspace_workflows_test.dart b/test/src/nextcloud_notes/notes_workspace_workflows_test.dart index 1277c4a8..78df1015 100644 --- a/test/src/nextcloud_notes/notes_workspace_workflows_test.dart +++ b/test/src/nextcloud_notes/notes_workspace_workflows_test.dart @@ -1075,7 +1075,7 @@ void main() { ); } test( - 'FTS operators and punctuation stay literal; Unicode whole words and short terms', + 'FTS operators and punctuation stay literal; Unicode substrings and short terms', () async { final note = await f.repository.create( testAccount().id, @@ -1089,17 +1089,17 @@ void main() { addTearDown(search.dispose); await search.search('AND'); expect(search.state.hits.length, 1); - await search.search('foo', wholeWord: true); - expect(search.state.hits.length, 1); + await search.search('foo'); + expect(search.state.hits.length, 3); expect( note.content.substring( - search.state.hits.single.start!, - search.state.hits.single.end!, + search.state.hits.first.start!, + search.state.hits.first.end!, ), 'foo', ); - await search.search('βήτα', wholeWord: true); - expect(search.state.hits.length, 1); + await search.search('βήτα'); + expect(search.state.hits.length, 2); await search.search('100%'); expect(search.state.hits.length, 1); await search.search('"OR foo_bar"'); diff --git a/test/src/nextcloud_notes_ui_test.dart b/test/src/nextcloud_notes_ui_test.dart index b475a499..91ef85c4 100644 --- a/test/src/nextcloud_notes_ui_test.dart +++ b/test/src/nextcloud_notes_ui_test.dart @@ -1,3 +1,8 @@ +import 'dart:async'; +import 'package:busymark/src/search/search_session.dart'; +import 'package:busymark/src/search/search_sidebar.dart'; +import 'package:busymark/src/search/welcome_notes_action.dart'; +import 'package:busymark/src/app/command_registry.dart'; import 'dart:io'; import 'dart:convert'; @@ -38,7 +43,7 @@ Future settleStorage(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 30)), ); - await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); } await tester.pumpAndSettle(); } @@ -308,6 +313,13 @@ void main() { nextcloudNotesRepositoryProvider.overrideWith( (ref) async => repository, ), + localSettingsStoreProvider.overrideWithValue(_TabSettings()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -316,15 +328,266 @@ void main() { body: SizedBox( width: 500, height: 1000, - child: NextcloudNotesSidebar(accountId: accountId), + child: _NotesSearchHarness(accountId: accountId), ), ), ), ), ); + final scope = ProviderScope.containerOf( + tester.element(find.byType(_NotesSearchHarness)), + ); + scope.read(appRouterProvider).go('/workspace'); + await tester.runAsync( + () => scope + .read(workspaceControllerProvider.notifier) + .openNextcloudWorkspace(accountId), + ); + await settleStorage(tester); + } + + Future pumpWelcome( + WidgetTester tester, { + NextcloudConnectionController? connection, + }) async { + tester.view.physicalSize = const Size(1100, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final container = ProviderContainer( + overrides: [ + if (connection != null) + nextcloudConnectionProvider.overrideWith(() => connection), + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + localSettingsStoreProvider.overrideWithValue(_TabSettings()), + linuxAccentPlatformProvider.overrideWithValue(false), + localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); await settleStorage(tester); + return container; } + testWidgets( + 'Welcome New Note without an account cancels on returning from settings', + (tester) async { + await tester.runAsync(() async { + await repository.dispose(); + repository = _CachedNotesRepository( + store: await NotesStore.open(path: '${root.path}/empty.sqlite3'), + ); + await repository.initialize(); + }); + final container = await pumpWelcome(tester); + await tester.tap( + find.byKey(const ValueKey('welcome-new-nextcloud-note')), + ); + await settleStorage(tester); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path, + '/settings', + ); + expect( + container.read(welcomeNotesActionProvider), + WelcomeNotesAction.create, + ); + expect(repository.notes, isEmpty); + container.read(appRouterProvider).go('/'); + await settleStorage(tester); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path, + '/', + ); + expect(container.read(welcomeNotesActionProvider), isNull); + expect(repository.notes, isEmpty); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets( + 'Welcome resumes exactly one requested note after successful connection', + (tester) async { + final connection = _ConnectingWelcomeAccount(repository.accounts.first); + final container = await pumpWelcome(tester, connection: connection); + final before = repository.notes.length; + await tester.tap( + find.byKey(const ValueKey('welcome-new-nextcloud-note')), + ); + await settleStorage(tester); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path, + '/settings', + ); + expect(repository.notes.length, before); + await tester.tap(find.text('Connect')); + await settleStorage(tester); + await tester.runAsync(() async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (container.read(welcomeNotesActionProvider.notifier).busy && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + await settleStorage(tester); + expect(repository.notes.length, before + 1); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path, + '/workspace', + ); + expect(container.read(welcomeNotesActionProvider), isNull); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .remoteNote! + .accountId, + accountId, + ); + container.read(appRouterProvider).go('/'); + await settleStorage(tester); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await tester.pump(); + // Settings created this stream inside FakeAsync. Flush its cancellation + // while closing the fixture's real SQLite worker. + var closed = false; + await tester.runAsync(() async { + unawaited(repository.dispose().then((_) => closed = true)); + }); + for (var i = 0; i < 100 && !closed; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect( + closed, + isTrue, + reason: 'Fixture storage and provider subscriptions must close.', + ); + }, + ); + + testWidgets( + 'Welcome New Note claims double activation once and records the individual note', + (tester) async { + final container = await pumpWelcome(tester); + final before = repository.notes.length; + final registry = container.read(busyMarkCommandRegistryProvider); + await tester.runAsync(() async { + await Future.wait([ + registry.execute(BusyMarkCommandIds.newNote), + registry.execute(BusyMarkCommandIds.newNote), + ]); + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (container.read(welcomeNotesActionProvider.notifier).busy && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + await settleStorage(tester); + expect( + repository.notes.length, + before + 1, + reason: + '${container.read(workspaceControllerProvider).message}; ${container.read(appRouterProvider).routeInformationProvider.value.uri}', + ); + final active = container.read(workspaceControllerProvider).activeBuffer!; + expect(active.remoteNote!.accountId, accountId); + expect( + container + .read(appSettingsControllerProvider) + .recentWorkspaces + .where( + (r) => + r.accountId == accountId && + r.localId == active.remoteNote!.localId, + ), + hasLength(1), + ); + expect( + container + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path, + '/workspace', + ); + container.read(appRouterProvider).go('/'); + await settleStorage(tester); + expect(container.read(searchSessionProvider.notifier).categories, [ + SearchCategory.recent, + SearchCategory.actions, + ]); + expect( + find.byKey(const ValueKey('welcome-new-nextcloud-note')), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets( + 'pending Welcome Notes operation is cancelled when leaving connection settings', + (tester) async { + final container = await pumpWelcome(tester); + final pending = container.read(welcomeNotesActionProvider.notifier); + final before = repository.notes.length; + pending.request(WelcomeNotesAction.create); + container + .read(appRouterProvider) + .go( + '${settingsLocation(SettingsReturnTarget.welcome)}&page=nextcloudNotes', + ); + await settleStorage(tester); + expect( + container.read(welcomeNotesActionProvider), + WelcomeNotesAction.create, + ); + container.read(appRouterProvider).go('/workspace'); + await settleStorage(tester); + expect(container.read(welcomeNotesActionProvider), isNull); + expect(repository.notes.length, before); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + test('Nextcloud settings route selects its dedicated page', () { expect( settingsPageFromRouteValue('nextcloudNotes'), @@ -339,10 +602,10 @@ void main() { await tester.tap(find.text('Recovery').first); await settleStorage(tester); expect(find.text('Deleted note'), findsOneWidget); - await tester.tap(find.byType(TextField).first); + await tester.tap(find.byKey(const ValueKey('notes-search-entry'))); await settleStorage(tester); final field = find.descendant( - of: find.byType(NotesSearchDialog), + of: find.byKey(const ValueKey('shared-search-query')), matching: find.byType(TextField), ); await tester.enterText(field, 'title:Deleted'); @@ -350,14 +613,14 @@ void main() { await settleStorage(tester); expect( find.descendant( - of: find.byType(NotesSearchDialog), - matching: find.widgetWithText(ListTile, 'Deleted note'), + of: find.byType(SearchSidebar), + matching: find.text('Deleted note'), ), findsOneWidget, ); expect( find.descendant( - of: find.byType(NotesSearchDialog), + of: find.byType(SearchSidebar), matching: find.text('Personal note'), ), findsNothing, @@ -369,7 +632,7 @@ void main() { ); testWidgets( - 'correction: Show more reaches all 320 indexed occurrences through the production dialog', + 'correction: Show more reaches all 320 indexed occurrences through the shared sidebar', (tester) async { final expected = {}; await tester.runAsync(() async { @@ -391,11 +654,11 @@ void main() { } }); await pump(tester); - await tester.tap(find.byType(TextField).first); + await tester.tap(find.byKey(const ValueKey('notes-search-entry'))); await settleStorage(tester); await tester.enterText( find.descendant( - of: find.byType(NotesSearchDialog), + of: find.byKey(const ValueKey('shared-search-query')), matching: find.byType(TextField), ), 'pagingneedle', @@ -404,12 +667,12 @@ void main() { await settleStorage(tester); for (final count in [80, 160, 240, 320]) { final list = tester.widget( - find.byKey(const ValueKey('notes-search-results')), + find.byKey(const ValueKey('sidebar-search-results')), ); final delegate = list.childrenDelegate as SliverChildBuilderDelegate; expect(delegate.estimatedChildCount, count); final context = tester.element( - find.byKey(const ValueKey('notes-search-results')), + find.byKey(const ValueKey('sidebar-search-results')), ); final keys = { for (var i = 0; i < count; i++) delegate.builder(context, i)!.key!, @@ -460,6 +723,7 @@ void main() { await controller.openNextcloudWorkspace(accountId); await controller.openNextcloudNote(personalId); }); + container.read(appRouterProvider).go('/workspace'); await tester.pumpWidget( UncontrolledProviderScope( container: container, @@ -470,7 +734,7 @@ void main() { body: SizedBox( width: 500, height: 1000, - child: NextcloudNotesSidebar(accountId: accountId), + child: _NotesSearchHarness(accountId: accountId), ), ), ), @@ -479,11 +743,11 @@ void main() { await settleStorage(tester); await tester.tap(find.text('Recovery').first); await settleStorage(tester); - await tester.tap(find.byType(TextField).first); + await tester.tap(find.byKey(const ValueKey('notes-search-entry'))); await settleStorage(tester); await tester.enterText( find.descendant( - of: find.byType(NotesSearchDialog), + of: find.byKey(const ValueKey('shared-search-query')), matching: find.byType(TextField), ), 'title:Deleted', @@ -493,7 +757,7 @@ void main() { expect(find.byTooltip('Source location'), findsNothing); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await settleStorage(tester); - expect(find.byType(NotesSearchDialog), findsNothing); + expect(find.byType(SearchSidebar), findsNothing); expect(find.byType(NotesRecoveryDialog), findsOneWidget); expect( tester @@ -735,11 +999,11 @@ void main() { expect(find.text('Personal note'), findsOneWidget); expect(find.text('Shared note'), findsOneWidget); expect(find.text('Deleted note'), findsNothing); - await tester.tap(find.byType(TextField)); + await tester.tap(find.byKey(const ValueKey('notes-search-entry'))); await settleStorage(tester); - final dialog = find.byType(NotesSearchDialog); + final dialog = find.byType(SearchSidebar); final field = find.descendant( - of: dialog, + of: find.byKey(const ValueKey('shared-search-query')), matching: find.byType(TextField), ); await tester.enterText(field, 'private search'); @@ -751,13 +1015,32 @@ void main() { await tester.pump(); } expect( - find.descendant(of: dialog, matching: find.text('Personal note')), + find.descendant( + of: dialog, + matching: find.textContaining('Personal note'), + ), findsNWidgets(2), ); expect( - find.descendant(of: dialog, matching: find.text('Shared note')), + find.descendant( + of: dialog, + matching: find.textContaining('Shared note'), + ), findsNothing, ); + final results = ProviderScope.containerOf( + tester.element(dialog), + ).read(searchSessionProvider).results.cast(); + expect(results, hasLength(2)); + expect( + results.every( + (r) => + r.accountId == accountId && + r.hit.localId == '223e4567-e89b-42d3-a456-426614174000', + ), + isTrue, + ); + expect(results.map((r) => (r.hit.start, r.hit.end)), [(0, 7), (8, 14)]); await tester.pumpWidget(const SizedBox.shrink()); }, ); @@ -1538,6 +1821,27 @@ void main() { } } +class _ConnectingWelcomeAccount extends NextcloudConnectionController { + _ConnectingWelcomeAccount(this.account); + final NextcloudAccount account; + bool connected = false; + @override + NextcloudConnectionState build() => const NextcloudConnectionState(); + @override + Future reload() async => state = NextcloudConnectionState( + account: connected ? account : null, + phase: connected + ? NextcloudConnectionPhase.connected + : NextcloudConnectionPhase.idle, + ); + @override + Future connect(String serverUrl) async { + connected = true; + await reload(); + return true; + } +} + class _TabSettings implements LocalSettingsStore { @override Future> load() async => {'automaticSpelling': false}; @@ -1550,6 +1854,12 @@ class _CachedNotesRepository extends NotesRepository { : super( clientForAccount: (account) async => NotesApiClient( client: MockClient((request) async { + if (request.url.path.endsWith('/settings')) { + return http.Response( + jsonEncode({'notesPath': 'Notes', 'fileSuffix': '.md'}), + 200, + ); + } final id = int.parse(request.url.pathSegments.last); final candidate = (await store.notes()).firstWhere( (n) => n.serverId == id, @@ -1576,3 +1886,26 @@ class _CachedNotesRepository extends NotesRepository { bool onlyFreshWrites = false, }) async {} } + +class _NotesSearchHarness extends ConsumerWidget { + const _NotesSearchHarness({required this.accountId}); + final String accountId; + @override + Widget build(BuildContext context, WidgetRef ref) { + final search = ref.watch(searchSessionProvider); + return Column( + children: [ + if (search.active) const SearchHeaderField(), + Expanded( + child: IndexedStack( + index: search.active ? 1 : 0, + children: [ + NextcloudNotesSidebar(accountId: accountId), + const SearchSidebar(), + ], + ), + ), + ], + ); + } +} diff --git a/test/src/notes_workspace_m2_ui_test.dart b/test/src/notes_workspace_m2_ui_test.dart index e376485b..9bceb10b 100644 --- a/test/src/notes_workspace_m2_ui_test.dart +++ b/test/src/notes_workspace_m2_ui_test.dart @@ -1,3 +1,6 @@ +import 'package:busymark/src/search/search_session.dart'; +import 'package:busymark/src/search/search_sidebar.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/quick_open.dart'; import 'package:busymark/src/app/busymark_design.dart'; @@ -8,15 +11,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -Future _drain(WidgetTester tester) async { - for (var i = 0; i < 25; i++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(); - } -} - Widget _app(Widget child) => MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -28,14 +22,11 @@ void main() { const windowEvents = EventChannel('yaru_window/events'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - late int stateRequests; setUp(() { - stateRequests = 0; // The real Yaru dialog title bar subscribes to native window state. Keep // this file independent of plugin registration and async completion timing. messenger.setMockMethodCallHandler(windowChannel, (call) async { if (call.method == 'state') { - stateRequests++; return {}; } return null; @@ -72,7 +63,7 @@ void main() { .metadata[BusyMarkCommandIds.quickOpen]! .shortcut! .label, - 'Ctrl+P', + 'Ctrl+Shift+D', ); expect( BusyMarkCommandCatalog @@ -84,44 +75,98 @@ void main() { }, ); testWidgets( - 'Quick Open arrow/Enter opens the chosen identity and Escape dismisses', + 'Documents sidebar renders stable identities and pointer activation', (tester) async { - QuickOpenDocument? result; + SidebarSearchResult? selected; + const first = DocumentSearchResult( + 'account-a', + QuickOpenDocument(title: 'Same', path: 'Cat', localId: 'a'), + accountId: 'a', + ); + const second = DocumentSearchResult( + 'account-a', + QuickOpenDocument(title: 'Same', path: 'Cat', localId: 'b'), + accountId: 'a', + ); await tester.pumpWidget( - _app( - Builder( - builder: (context) => TextButton( - onPressed: () async { - result = await showDialog( - context: context, - builder: (_) => const QuickOpenDialog( - documents: [ - QuickOpenDocument(title: 'A', path: 'Cat', localId: 'a'), - QuickOpenDocument(title: 'B', path: 'Cat', localId: 'b'), - ], + ProviderScope( + child: _app( + SearchResultList( + results: const [first, second], + selected: 1, + onActivate: (result) async => selected = result, + ), + ), + ), + ); + expect(find.text('Same'), findsNWidgets(2)); + expect( + tester + .widget( + find.byKey(ValueKey(second.identity)), + ) + .selected, + isTrue, + ); + await tester.tap(find.byKey(ValueKey(second.identity))); + expect((selected as DocumentSearchResult).document.localId, 'b'); + expect((selected as DocumentSearchResult).document.filePath, isNull); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + 'lazy search selection reveals wrapped rows at increased text size', + (tester) async { + var selected = 0; + late StateSetter update; + final results = [ + for (var i = 0; i < 80; i++) + DocumentSearchResult( + 'workspace', + QuickOpenDocument( + title: 'Document $i ${'a long descriptive title ' * 8}', + path: 'nested/path/with/context/document-$i.md', + filePath: '/document-$i.md', + ), + ), + ]; + await tester.pumpWidget( + ProviderScope( + child: _app( + StatefulBuilder( + builder: (context, setState) { + update = setState; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(1.6)), + child: SizedBox( + width: 280, + height: 260, + child: SearchResultList( + results: results, + selected: selected, + onActivate: (_) async {}, + ), ), ); }, - child: const Text('launch'), ), ), ), ); - await tester.tap(find.text('launch')); - await _drain(tester); - expect(stateRequests, greaterThan(0)); - await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(result?.localId, 'b'); - expect(result?.filePath, isNull); - await tester.tap(find.text('launch')); - await _drain(tester); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + update(() => selected = 40); await tester.pumpAndSettle(); - expect(find.byType(QuickOpenDialog), findsNothing); - expect(result, isNull); - expect(tester.takeException(), isNull); + final row = find.byKey(ValueKey(results[40].identity)); + expect(row.hitTestable(), findsOneWidget); + expect(tester.widget(row).selected, isTrue); + update(() => selected = 0); + await tester.pumpAndSettle(); + expect( + find.byKey(ValueKey(results.first.identity)).hitTestable(), + findsOneWidget, + ); }, ); testWidgets( diff --git a/test/src/recent_presentation_test.dart b/test/src/recent_presentation_test.dart new file mode 100644 index 00000000..bfef247d --- /dev/null +++ b/test/src/recent_presentation_test.dart @@ -0,0 +1,169 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/path_label.dart'; +import 'package:busymark/src/workspace/recent_presentation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Recent locations follow resource owners and the actual home', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ); + RecentResource resource( + String path, { + bool document = false, + String? title, + String? owner, + }) => RecentResource( + path: path, + kind: document ? 'singleMarkdown' : 'markdownFolder', + document: document, + title: title, + ownerRoot: owner, + lastOpenedAt: DateTime(2026), + ); + const home = '/actual-home'; + final workspace = resource('$home/Documents/dev-notes', title: 'dev-notes'); + final topic = resource( + '$home/Documents/dev-notes/topics/security.md', + document: true, + title: 'Snap Security Updates', + ); + String location(RecentResource r, {List? owners}) => + recentResourceLocation( + context, + r, + homeDirectory: home, + recentResources: owners ?? [workspace], + ); + expect(location(workspace), '~/Documents'); + expect(location(topic), 'dev-notes/topics'); + expect( + location( + resource('$home/Downloads/software_engineer_resume.md', document: true), + ), + '~/Downloads', + ); + expect( + location( + resource( + '$home/Downloads/books-client-gateway-agent.md', + document: true, + title: 'A custom title', + ), + ), + '~/Downloads', + ); + expect( + recentResourceLocation( + context, + resource('$home/Downloads/resume.md', document: true), + homeDirectory: home, + workspaceRoot: '$home/Downloads', + ), + '~/Downloads', + ); + expect( + location(resource('/home/albert/Downloads/resume.md', document: true)), + '/home/albert/Downloads', + ); + expect( + location( + resource('/actual-home-other/Downloads/resume.md', document: true), + ), + '/actual-home-other/Downloads', + ); + expect( + location( + resource( + '/srv/projects/topic.topic', + document: true, + owner: '/srv/projects', + ), + ), + '/srv/projects', + ); + final duplicate = resource('$home/Archive/dev-notes'); + expect( + location(topic, owners: [workspace, duplicate]), + '~/Documents/dev-notes/topics', + ); + final otherTopic = resource( + '$home/Archive/dev-notes/topics/security.md', + document: true, + owner: '$home/Archive/dev-notes', + ); + expect( + location( + resource(topic.path, document: true, owner: workspace.path), + owners: [otherTopic], + ), + '~/Documents/dev-notes/topics', + ); + final remote = RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: 'private-id', + localId: 'stable-id', + accountLabel: 'Alex · notes.example', + lastOpenedAt: DateTime(2026), + ); + expect(location(remote), 'Alex · notes.example'); + expect(topic.path, '$home/Documents/dev-notes/topics/security.md'); + }); + + for (final direction in TextDirection.values) { + testWidgets( + 'one measured path elision retains the useful end in $direction', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: direction, + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.6)), + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 180, + child: BusyMarkPathLabel( + path: + '/a-very-long-parent-name/another-long-parent/topics', + style: TextStyle(fontSize: 12), + ), + ), + ), + ), + ), + ), + ); + final text = tester.widget( + find.descendant( + of: find.byType(BusyMarkPathLabel), + matching: find.byType(Text), + ), + ); + expect(text.data, startsWith('/…/')); + expect(text.data, endsWith('/topics')); + expect('…'.allMatches(text.data!).length, 1); + expect(text.overflow, TextOverflow.clip); + expect(text.maxLines, 1); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/src/references/reference_ui_test.dart b/test/src/references/reference_ui_test.dart index d3816072..2625a266 100644 --- a/test/src/references/reference_ui_test.dart +++ b/test/src/references/reference_ui_test.dart @@ -23,7 +23,10 @@ class FixtureIndex extends ReferenceController { class FixtureWorkspace extends WorkspaceController { @override - Future openActiveFile(String path) async => true; + Future openActiveFile( + String path, { + bool Function()? acceptNavigation, + }) async => acceptNavigation?.call() != false; @override WorkspaceState build() => WorkspaceState( workspace: Workspace( @@ -69,7 +72,11 @@ class NavigableFixtureWorkspace extends FixtureWorkspace { ], ); @override - Future openActiveFile(String path) async { + Future openActiveFile( + String path, { + bool Function()? acceptNavigation, + }) async { + if (acceptNavigation?.call() == false) return false; final doc = index.documents[path]!; state = state.copyWith( activeBufferId: path, diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index b2b4f4fc..b9de480d 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -12,8 +12,8 @@ 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); + test('Unicode literal replacement preserves complete characters', () async { + const options = SourceSearchOptions(query: '😀'); final worker = SearchReplacementWorker(); addTearDown(worker.dispose); final all = await worker.previewText( @@ -43,15 +43,15 @@ void main() { } }); - test('zero-length replacement limitation survives worker transfer', () async { + test('regex anchors are literal across worker transfer', () async { final worker = SearchReplacementWorker(); addTearDown(worker.dispose); final preview = await worker.previewText( source: 'cat', - options: const SourceSearchOptions(query: '^', regex: true), + options: const SourceSearchOptions(query: '^'), replacement: 'X', ); - expect(preview!.hasZeroLengthMatches, isTrue); + expect(preview!.matches, isEmpty); expect(preview.apply(), 'cat'); }); @@ -89,7 +89,7 @@ void main() { }); } - test('workspace replacement cancels a running expensive regex', () async { + test('workspace replacement cancels an asynchronous preview', () async { final directory = await Directory.systemTemp.createTemp( 'busymark-search-cancel-', ); @@ -111,11 +111,10 @@ void main() { final pending = replacementService.previewWorkspace( state: state, workspaceService: const WorkspaceService(), - options: const SourceSearchOptions(query: r'^(a+)+$', regex: true), + options: const SourceSearchOptions(query: 'a'), 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); @@ -191,15 +190,19 @@ void main() { }, ); - test('previews and applies plain whole-word replacements', () { + test('previews and applies case-insensitive substring replacements', () { final preview = replacementService.previewText( source: 'cat scatter Cat', - options: const SourceSearchOptions(query: 'cat', wholeWord: true), + options: const SourceSearchOptions(query: 'cat'), replacement: 'dog', ); - expect(preview.matches.map((match) => match.original), ['cat', 'Cat']); - expect(preview.apply(), 'dog scatter dog'); + expect(preview.matches.map((match) => match.original), [ + 'cat', + 'cat', + 'Cat', + ]); + expect(preview.apply(), 'dog sdogter dog'); }); test('text replacement previews enforce their match limit', () { @@ -215,21 +218,24 @@ void main() { expect(preview.truncated, isTrue); }); - test('replacement worker expands one accepted regex match', () async { - final worker = SearchReplacementWorker(); - addTearDown(worker.dispose); - - final preview = await worker.previewText( - source: 'Ada Lovelace', - options: const SourceSearchOptions(query: r'(\w+) (\w+)', regex: true), - replacement: r'$2, $1', - targetStart: 0, - targetEnd: 12, - ); + test( + 'replacement worker keeps replacement references literal for one accepted match', + () async { + final worker = SearchReplacementWorker(); + addTearDown(worker.dispose); + + final preview = await worker.previewText( + source: 'Ada Lovelace', + options: const SourceSearchOptions(query: 'Ada Lovelace'), + replacement: r'$2, $1', + targetStart: 0, + targetEnd: 12, + ); - expect(preview, isNotNull); - expect(preview!.matches.single.replacement, 'Lovelace, Ada'); - }); + expect(preview, isNotNull); + expect(preview!.matches.single.replacement, r'$2, $1'); + }, + ); test('replacement worker cancels stale plans', () async { final worker = SearchReplacementWorker(); @@ -249,23 +255,20 @@ void main() { expect((await current)!.apply(), 'fresh'); }); - test('expands numbered and named regex capture groups', () { + test('numbered and named replacement references are literal', () { final numbered = replacementService.previewText( source: 'Ada Lovelace; Grace Hopper', - options: const SourceSearchOptions(query: r'(\w+) (\w+)', regex: true), + options: const SourceSearchOptions(query: 'Ada Lovelace'), replacement: r'$2, $1', ); final named = replacementService.previewText( source: 'x=42', - options: const SourceSearchOptions( - query: r'(?\w+)=(?\d+)', - regex: true, - ), + options: const SourceSearchOptions(query: 'x=42'), replacement: r'${value}:${name}:$$:$&', ); - expect(numbered.apply(), 'Lovelace, Ada; Hopper, Grace'); - expect(named.apply(), r'42:x:$:x=42'); + expect(numbered.apply(), r'$2, $1; Grace Hopper'); + expect(named.apply(), r'${value}:${name}:$$:$&'); }); test('applies only selected preview matches', () { diff --git a/test/src/shortcut_capture_test.dart b/test/src/shortcut_capture_test.dart new file mode 100644 index 00000000..5879bc71 --- /dev/null +++ b/test/src/shortcut_capture_test.dart @@ -0,0 +1,102 @@ +import 'package:busymark/src/app/shortcut_binding.dart'; +import 'package:busymark/src/app/shortcut_capture.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'capture ignores composition and repeats, quarantines release and cancels', + (tester) async { + final owner = Object(); + final controller = TextEditingController(); + final focus = FocusNode(); + addTearDown(() async { + for (final key + in HardwareKeyboard.instance.logicalKeysPressed.toList()) { + await tester.sendKeyUpEvent(key); + } + BusyMarkShortcutCapture.end(owner); + controller.dispose(); + focus.dispose(); + }); + var executed = 0, cancelled = 0; + final candidates = []; + await tester.pumpWidget( + MaterialApp( + home: CallbackShortcuts( + bindings: { + const SingleActivator( + LogicalKeyboardKey.f12, + control: true, + ): () => + executed++, + }, + child: Material( + child: TextField( + controller: controller, + focusNode: focus, + autofocus: true, + ), + ), + ), + ), + ); + await tester.pump(); + BusyMarkShortcutCapture.begin( + owner, + onCandidate: candidates.add, + onCancel: () { + cancelled++; + BusyMarkShortcutCapture.end(owner); + }, + ); + controller.value = const TextEditingValue( + text: 'é', + composing: TextRange(start: 0, end: 1), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.f12); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(candidates, isEmpty); + expect(executed, 0); + controller.value = const TextEditingValue(text: 'é'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + expect( + candidates, + isEmpty, + reason: 'A modifier alone is not a candidate.', + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.f12); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.f12); + expect(candidates, [ + const BusyMarkKeyBinding(LogicalKeyboardKey.f12, control: true), + ]); + BusyMarkShortcutCapture.end(owner); + expect(BusyMarkShortcutCapture.suppressing, isTrue); + await tester.sendKeyRepeatEvent(LogicalKeyboardKey.f12); + await tester.sendKeyUpEvent(LogicalKeyboardKey.f12); + expect(BusyMarkShortcutCapture.suppressing, isTrue); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(BusyMarkShortcutCapture.suppressing, isFalse); + expect(executed, 0); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.f12); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + expect(executed, 1); + BusyMarkShortcutCapture.begin( + owner, + onCandidate: candidates.add, + onCancel: () { + cancelled++; + BusyMarkShortcutCapture.end(owner); + }, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(cancelled, 1); + expect(candidates, hasLength(1)); + expect(BusyMarkShortcutCapture.suppressing, isFalse); + expect(controller.text, 'é'); + }, + ); +} diff --git a/test/src/sidebar_search_session_test.dart b/test/src/sidebar_search_session_test.dart new file mode 100644 index 00000000..6d3dd0c4 --- /dev/null +++ b/test/src/sidebar_search_session_test.dart @@ -0,0 +1,1834 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/l10n/generated/app_localizations_en.dart'; +import 'package:busymark/src/app/quick_open.dart'; +import 'package:busymark/src/search/search_sidebar.dart'; +import 'package:busymark/src/search/search_result_presentation.dart'; +import 'dart:collection'; +import 'package:busymark/src/editor/source/source_document.dart'; +import 'package:busymark/src/markdown/document_outline.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/text_format_metadata.dart'; +import 'dart:async'; + +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/editor/source/source_search.dart'; +import 'package:busymark/src/search/search_session.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/workspace/workspace_model.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + ProviderContainer containerFor( + WorkspaceState state, { + _GatedService? service, + LocalHistoryStore? historyStore, + SourceSearchWorker? worker, + LocalHistoryController? history, + }) { + final container = ProviderContainer( + overrides: [ + if (worker != null) + searchSessionProvider.overrideWith( + () => SearchSessionController(worker: worker), + ), + if (history != null) + localHistoryControllerProvider.overrideWith(() => history), + workspaceControllerProvider.overrideWith(() => _Workspace(state)), + localSettingsStoreProvider.overrideWithValue(_Settings()), + if (historyStore != null) + localHistoryStoreProvider.overrideWithValue(historyStore), + if (service != null) + workspaceServiceProvider.overrideWithValue(service), + ], + ); + final router = container.read(appRouterProvider); + addTearDown(() { + container.dispose(); + router.dispose(); + }); + return container; + } + + test( + 'empty queries gate all seven waiting categories before provider work', + () async { + final fixture = await const WorkspaceService().openPath( + 'test/fixtures/writerside/basic_project', + ); + final worker = _CountingWorker(); + final history = _CountingHistory(); + final files = _ReadCountingList(fixture.files); + final module = fixture.writersideProject!.modules.first; + final topics = _ReadCountingList(module.topics); + final headings = _ReadCountingList([ + const DocumentOutlineHeading(level: 1, text: 'needle', id: 'needle'), + ]); + final initial = _state().copyWith( + workspace: fixture.copyWith( + files: files, + writersideProject: null, + writersideModule: module.copyWith(topics: topics), + ), + ); + final container = containerFor( + initial.copyWith( + liveOutline: ActiveDocumentOutline( + workspaceId: fixture.id, + bufferId: 'scratch-a', + filePath: null, + source: initial.activeText, + headings: headings, + ), + ), + worker: worker, + history: history, + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + // The expected categories are the agreed contract, not the production helper. + for (final category in [ + SearchCategory.file, + SearchCategory.workspace, + SearchCategory.outline, + SearchCategory.documents, + SearchCategory.topics, + SearchCategory.recent, + SearchCategory.localHistory, + ]) { + files.reads = topics.reads = headings.reads = 0; + session.open(category: category); + expect(container.read(searchSessionProvider).waitingForInput, isTrue); + expect(container.read(searchSessionProvider).searching, isFalse); + await session.submit(); + session.showMore(); + session.refresh(container.read(workspaceControllerProvider)); + session.query(''); + await session.submit(); + await Future.delayed(const Duration(milliseconds: 150)); + final state = container.read(searchSessionProvider); + expect( + state.results, + isEmpty, + reason: '$category must wait for a query', + ); + expect(state.searching, isFalse); + expect(state.truncated, isFalse); + expect(state.skippedFiles, isEmpty); + expect(state.error, isNull); + expect(worker.requests, 0); + expect(history.requests, 0); + expect( + history.builds, + 0, + reason: 'Empty general History does not initialize its provider', + ); + expect( + files.reads, + 0, + reason: 'No empty-query document inventory discovery', + ); + expect( + topics.reads, + 0, + reason: 'No empty-query topic inventory discovery', + ); + expect(headings.reads, 0, reason: 'No empty-query outline projection'); + } + await container + .read(appSettingsControllerProvider.notifier) + .recordOpenedWorkspace( + path: '/workspace/recent.md', + kind: 'singleMarkdown', + ); + session.open(category: SearchCategory.recent); + await session.submit(); + expect(container.read(searchSessionProvider).waitingForInput, isTrue); + expect(container.read(searchSessionProvider).results, isEmpty); + session.query('recent'); + await session.submit(); + expect( + container.read(searchSessionProvider).results.single, + isA(), + ); + session.query(''); + session.showMore(); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + + session.configureCommands([ + ActionSearchResult( + BusyMarkCommand( + id: 'test.action', + label: (_) => 'Action', + category: (_) => 'Test', + scope: BusyMarkCommandScope.application, + execute: () {}, + ), + 'Action', + 'Test', + null, + ), + ]); + session.open(category: SearchCategory.actions); + await session.submit(); + expect( + container.read(searchSessionProvider).results.single, + isA(), + ); + }, + ); + + test( + 'clearing a query rejects delayed document work and empty submit never waits for it', + () async { + final worker = _CountingWorker()..gate = Completer(); + final container = containerFor(_state(), worker: worker); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query('needle'); + final running = session.submit(); + expect(worker.requests, 1); + session.query(''); + await session.submit().timeout(const Duration(seconds: 1)); + worker.gate!.complete( + searchSourceDocument( + SourceDocument(fullText: 'prefix needle suffix'), + const SourceSearchOptions(query: 'needle'), + ), + ); + await running; + final state = container.read(searchSessionProvider); + expect(state.results, isEmpty); + expect(state.documentMatchCount, 0); + expect(state.documentCurrentMatchIndex, isNull); + expect(state.sourceNavigationRequest, 0); + expect(state.waitingForInput, isTrue); + expect(worker.requests, 1); + }, + ); + + test( + 'literal whitespace remains a document and local-workspace query', + () async { + final worker = _CountingWorker(); + final container = containerFor( + _state(text: 'a b\nc', files: []), + worker: worker, + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query(' '); + await session.submit(); + final state = container.read(searchSessionProvider); + expect(state.waitingForInput, isFalse); + expect(worker.requests, 1); + expect((state.results.single as TextSearchResult).match.startOffset, 1); + session.open(category: SearchCategory.workspace); + session.query(' '); + expect(container.read(searchSessionProvider).waitingForInput, isFalse); + await session.submit(); + }, + ); + + test( + 'Outline search projects live heading identity, levels and duplicate ancestor context', + () async { + final initial = _state(text: '# A\n## Shared\n# B\n## Shared'); + final headings = [ + const DocumentOutlineHeading( + level: 1, + text: 'A', + id: 'a', + editorBlockId: 'a', + ), + const DocumentOutlineHeading( + level: 2, + text: 'Shared', + id: 'shared', + editorBlockId: 'first', + ), + const DocumentOutlineHeading( + level: 1, + text: 'B', + id: 'b', + editorBlockId: 'b', + ), + const DocumentOutlineHeading( + level: 2, + text: 'Shared', + id: 'shared-1', + editorBlockId: 'second', + ), + ]; + final container = containerFor( + initial.copyWith( + liveOutline: ActiveDocumentOutline( + workspaceId: 'workspace', + bufferId: 'scratch-a', + filePath: null, + source: initial.activeText, + headings: headings, + ), + ), + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.outline); + session.query('shared'); + await session.submit(); + final results = container + .read(searchSessionProvider) + .results + .cast(); + expect(results.map((r) => r.heading.editorBlockId), ['first', 'second']); + expect(results.map((r) => r.heading.level), [2, 2]); + expect(results.map((r) => r.ancestors.single), ['A', 'B']); + expect(results.map((r) => r.identity).toSet(), hasLength(2)); + session.moveSelection(1); + expect(container.read(searchSessionProvider).selected, 1); + expect( + container.read(workspaceControllerProvider).liveOutline!.headings, + same(headings), + ); + expect( + container.read(workspaceControllerProvider).activeText, + initial.activeText, + ); + }, + ); + + testWidgets( + 'Actions groups browsing by catalog category and ranks filtered matches before availability', + (tester) async { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + ActionSearchResult action( + String id, + String title, + String category, { + bool enabled = true, + }) => ActionSearchResult( + BusyMarkCommand( + id: id, + label: (_) => title, + category: (_) => category, + scope: BusyMarkCommandScope.application, + execute: () => fail('Presentation cannot execute'), + ), + title, + category, + 'Formatting command', + enabled: enabled, + ); + session.configureCommands( + [ + action('app.other', 'Zebra', 'General'), + action('edit.exact', 'Bold', 'Formatting', enabled: false), + action('edit.prefix', 'Bold text', 'Formatting'), + action('edit.token', 'Make Bold', 'Formatting'), + action('edit.weak', 'Some Boldness', 'Formatting'), + action('app.category', 'Bold settings', 'General'), + ], + categoryOrder: ['General', 'Formatting'], + ); + session.open(category: SearchCategory.actions); + await session.submit(); + expect( + container.read(searchSessionProvider).results.map((r) => r.identity), + [ + 'app.category', + 'app.other', + 'edit.prefix', + 'edit.token', + 'edit.weak', + 'edit.exact', + ], + ); + session.select(3); + session.query('Bold'); + await session.submit(); + var state = container.read(searchSessionProvider); + expect(state.results.map((r) => r.identity), [ + 'edit.exact', + 'app.category', + 'edit.prefix', + 'edit.token', + 'edit.weak', + ]); + expect(state.results[state.selected].identity, 'edit.token'); + session.select(0); + session.query(''); + await session.submit(); + state = container.read(searchSessionProvider); + expect(state.results[state.selected].identity, 'edit.exact'); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SearchResultList( + groupActions: true, + results: state.results, + selected: state.selected, + onActivate: (_) async => fail('Disabled action'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('General'), findsOneWidget); + expect(find.text('Formatting'), findsOneWidget); + expect( + find.byWidgetPredicate((w) => w is BusyMarkSidebarRecordRow), + findsNWidgets(6), + ); + final disabled = tester.widget( + find.byKey(const ValueKey('edit.exact')), + ); + expect(disabled.onTap, isNull); + session.moveSelection(-1); + expect( + container.read(searchSessionProvider).selected, + 4, + reason: 'Headings are not selection indices', + ); + }, + ); + + testWidgets( + 'search header uses one Settings selector and the eight agreed category labels', + (tester) async { + final container = containerFor(_state()); + container.read(appRouterProvider).go('/workspace'); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SizedBox(width: 250, height: 500, child: SearchSidebar()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final context = tester.element(find.byType(SearchSidebar)); + expect( + SearchCategory.values.map((c) => searchCategoryLabel(context, c)), + [ + 'Document Text', + 'Workspace Text', + 'Headings', + 'Documents', + 'Topics', + 'Recent', + 'Local History', + 'Actions', + ], + ); + expect(find.text('Search:'), findsOneWidget); + final selector = tester.widget>( + find.byKey(const ValueKey('search-category')), + ); + expect(selector.fullWidth, isTrue); + expect(selector.options.map((o) => o.value), session.categories); + final shortcuts = { + for (final option in selector.options) option.value: option.shortcut, + }; + expect(shortcuts[SearchCategory.file], 'Ctrl+F'); + expect(shortcuts[SearchCategory.workspace], 'Ctrl+Shift+F'); + expect(shortcuts[SearchCategory.outline], 'Ctrl+Shift+H'); + expect(shortcuts[SearchCategory.documents], 'Ctrl+Shift+D'); + expect(shortcuts[SearchCategory.recent], 'Ctrl+Shift+R'); + expect(shortcuts[SearchCategory.localHistory], 'Ctrl+Shift+L'); + expect(shortcuts[SearchCategory.actions], 'Ctrl+Shift+P'); + expect( + selector.options + .singleWhere((o) => o.value == SearchCategory.outline) + .gtkAccelerator, + 'h', + ); + expect(find.byType(DropdownButton), findsNothing); + }, + ); + + testWidgets( + 'Actions sort available first by localized metadata and preserve selected identity on availability change', + (tester) async { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + ActionSearchResult command( + String id, + String name, { + bool enabled = true, + }) => ActionSearchResult( + BusyMarkCommand( + id: id, + label: (_) => name, + category: (_) => 'Test', + scope: BusyMarkCommandScope.application, + execute: () => fail('Sorting must never execute actions'), + ), + name, + 'Test', + null, + enabled: enabled, + ); + session.configureCommands([ + command('z', 'Zulu'), + command('a', 'Alpha', enabled: false), + command('b', 'Beta'), + ]); + session.open(category: SearchCategory.actions); + await session.submit(); + expect( + container.read(searchSessionProvider).results.map((r) => r.identity), + ['b', 'z', 'a'], + ); + session.select(2); + session.configureCommands([ + command('z', 'Zulu'), + command('a', 'Alpha'), + command('b', 'Beta'), + ]); + await tester.pump(); + final state = container.read(searchSessionProvider); + expect(state.results.map((r) => r.identity), ['a', 'b', 'z']); + expect(state.results[state.selected].identity, 'a'); + }, + ); + + testWidgets( + 'Actions keep the focused command identity across availability reordering', + (tester) async { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + ActionSearchResult command( + String id, + String title, { + bool enabled = true, + }) => ActionSearchResult( + BusyMarkCommand( + id: id, + label: (_) => title, + category: (_) => 'Test', + scope: BusyMarkCommandScope.application, + execute: () {}, + ), + title, + 'Test', + null, + enabled: enabled, + ); + session.configureCommands([ + command('z', 'Zulu'), + command('a', 'Alpha', enabled: false), + command('b', 'Beta'), + ]); + session.open(category: SearchCategory.actions); + await session.submit(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + final state = ref.watch(searchSessionProvider); + return SearchResultList( + results: state.results, + selected: state.selected, + onActivate: (_) async {}, + ); + }, + ), + ), + ), + ), + ); + final originalFocus = tester + .widget(find.byKey(const ValueKey('z'))) + .focusNode!; + originalFocus.requestFocus(); + await tester.pump(); + expect(container.read(searchSessionProvider).selected, 1); + session.configureCommands([ + command('z', 'Zulu'), + command('a', 'Alpha'), + command('b', 'Beta'), + ]); + await tester.pumpAndSettle(); + final state = container.read(searchSessionProvider); + expect(state.results.map((r) => r.identity), ['a', 'b', 'z']); + expect(state.results[state.selected].identity, 'z'); + expect(FocusManager.instance.primaryFocus, same(originalFocus)); + expect( + tester + .widget(find.byKey(const ValueKey('z'))) + .focusNode, + same(originalFocus), + ); + }, + ); + + testWidgets( + 'Recent waits for input and distinguishes a completed no-results query', + (tester) async { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.recent); + await session.submit(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SizedBox(width: 280, height: 400, child: SearchSidebar()), + ), + ), + ), + ); + await tester.runAsync(session.submit); + await tester.pumpAndSettle(); + expect(find.text(AppLocalizationsEn().searchEnterText), findsOneWidget); + expect(find.text(AppLocalizationsEn().noResults), findsNothing); + session.query('missing'); + await tester.runAsync(session.submit); + await tester.pump(); + expect(find.text(AppLocalizationsEn().noResults), findsOneWidget); + session.query(''); + await tester.runAsync(session.submit); + await tester.pump(); + expect(find.text(AppLocalizationsEn().searchEnterText), findsOneWidget); + expect(find.text(AppLocalizationsEn().noResults), findsNothing); + }, + ); + + testWidgets( + 'result metadata stays concise and never exposes remote identities', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + final local = RecentResource( + path: '/project/topics/a.topic', + ownerRoot: '/project', + kind: 'writersideModule', + title: 'A topic', + document: true, + lastOpenedAt: DateTime(2026), + ); + final recent = presentSearchResult(context, RecentSearchResult(local)); + expect(recent.subtitle, '/project/topics'); + expect(recent.details, '/project/topics/a.topic'); + for (final label in [null, 'Alex · notes.example']) { + final resource = RecentResource( + path: '', + kind: 'nextcloudNotes', + title: 'A note', + accountId: 'private-account-id', + localId: 'private-local-id', + accountLabel: label, + document: true, + lastOpenedAt: DateTime(2026), + ); + final remote = presentSearchResult( + context, + RecentSearchResult(resource), + ); + expect( + remote.subtitle, + label ?? AppLocalizationsEn().searchAccountUnavailable, + ); + expect(remote.subtitle, isNot(contains('private-'))); + final cached = presentSearchResult( + context, + RecentSearchResult(resource), + accountLabel: (_) => 'Cached user · notes.example', + ); + expect(cached.subtitle, 'Cached user · notes.example'); + } + final document = presentSearchResult( + context, + const DocumentSearchResult( + 'workspace', + QuickOpenDocument( + title: 'Same', + path: 'folder/Same.md', + filePath: '/project/folder/Same.md', + ), + ), + ); + expect(document.title, 'Same.md'); + expect(document.subtitle, 'folder'); + expect(document.details, '/project/folder/Same.md'); + final topic = presentSearchResult( + context, + const TopicSearchResult( + 'workspace', + '/project/a.topic', + 'Same title', + 'different-id', + 'module A', + 'topics/a.topic', + ), + ); + expect(topic.subtitle, contains('module A')); + expect(topic.subtitle, contains('different-id')); + final historical = LocalHistoryDocument( + id: 'history-owner', + displayName: 'Deleted.md', + historicalPaths: const ['/old/Deleted.md'], + deleted: true, + updatedAt: DateTime(2026), + ); + final history = presentSearchResult( + context, + HistorySearchResult(LocalHistoryDocumentMatch(historical)), + ); + expect(history.title, 'Deleted.md'); + expect(history.subtitle, '/old'); + expect(history.subtitle, isNot(contains('Local History'))); + }, + ); + + testWidgets( + 'Recent rows in a Notes workspace never request a filesystem root', + (tester) async { + final container = containerFor( + _state().copyWith(workspace: Workspace.nextcloudNotes('account-one')), + ); + final resource = RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: 'account-one', + localId: 'stable-note', + accountLabel: 'Alex · notes.example', + title: 'A note', + lastOpenedAt: DateTime(2026), + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SearchResultList( + results: [RecentSearchResult(resource)], + onActivate: (_) async {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('A note'), findsOneWidget); + expect(find.text('Alex · notes.example'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + for (final scale in [1.0, 1.8]) { + testWidgets('Recent uses uniform two-line identity rows at scale $scale', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final resources = [ + RecentResource( + path: '/', + title: 'Root', + kind: 'markdownFolder', + lastOpenedAt: DateTime(2026), + ), + RecentResource( + path: '/one/Same.md', + kind: 'markdownFolder', + document: true, + lastOpenedAt: DateTime(2026), + ), + RecentResource( + path: '/very/long/unique-parent/deep/Same.md', + kind: 'markdownFolder', + document: true, + lastOpenedAt: DateTime(2026), + ), + RecentResource( + path: '/projects/Guide/topics/a-very-long-topic-name.topic', + ownerRoot: '/projects/Guide', + title: 'A long Writerside title that must remain on one line', + kind: 'writersideModule', + document: true, + lastOpenedAt: DateTime(2026), + ), + RecentResource( + path: '', + title: 'Same.md', + kind: 'nextcloudNotes', + document: true, + accountId: 'account-one', + localId: 'same-id', + accountLabel: 'Alex · one.example', + lastOpenedAt: DateTime(2026), + ), + RecentResource( + path: '', + title: 'Same.md', + kind: 'nextcloudNotes', + document: true, + accountId: 'account-two', + localId: 'same-id', + accountLabel: 'Alex · two.example', + lastOpenedAt: DateTime(2026), + ), + ]; + final rows = resources.map(RecentSearchResult.new).toList(); + final activated = []; + final container = containerFor(_state()); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: SizedBox( + width: 250, + height: 1200, + child: SearchResultList( + results: rows, + onActivate: (r) async => activated.add(r.identity), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + double? height; + final subtitles = {}; + for (var i = 0; i < rows.length; i++) { + final finder = find.byKey(ValueKey(rows[i].identity)); + final row = tester.widget(finder); + expect(row.content, isNull); + expect(row.title, isNotEmpty); + expect(row.subtitle, isNotEmpty); + expect(subtitles.add(row.subtitle!), isTrue); + final measured = tester.getSize(finder).height; + height ??= measured; + expect(measured, moreOrLessEquals(height)); + final nativeTile = tester.widget( + find.descendant(of: finder, matching: find.byType(YaruListTile)), + ); + expect(nativeTile.contentPadding, isNull); + final nativeSpacing = const YaruListTile( + titleText: 'Native reference', + ).verticalGap; + final rowRect = tester.getRect(finder); + final labels = find.descendant(of: finder, matching: find.byType(Text)); + final firstLabel = tester.getRect(labels.first); + final lastLabel = tester.getRect(labels.last); + expect( + firstLabel.top - rowRect.top, + greaterThanOrEqualTo(nativeSpacing), + ); + expect( + rowRect.bottom - lastLabel.bottom, + greaterThanOrEqualTo(nativeSpacing), + ); + final full = resources[i].accountId == null + ? resources[i].path + : resources[i].accountLabel!; + expect(row.tooltip, full); + expect(row.semanticsLabel, contains(full)); + for (final text in tester.widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + )) { + expect(text.maxLines, 1); + } + await tester.tap(finder); + } + expect(activated, rows.map((r) => r.identity).toList()); + expect(resources[2].path, '/very/long/unique-parent/deep/Same.md'); + }); + } + + testWidgets( + 'shared result rows are compact, accessible and truly disable unavailable actions', + (tester) async { + final container = containerFor(_state()); + final disabled = ActionSearchResult( + BusyMarkCommand( + id: 'disabled', + label: (_) => 'Unavailable action', + category: (_) => 'Notes', + scope: BusyMarkCommandScope.application, + execute: () => fail('Disabled execution'), + ), + 'Unavailable action', + 'Notes', + null, + enabled: false, + disabledReason: 'Requires a Notes workspace', + ); + final rows = [ + const DocumentSearchResult( + 'workspace', + QuickOpenDocument( + title: 'Short', + path: 'folder/Short.md', + filePath: '/project/folder/Short.md', + ), + ), + disabled, + ]; + var activations = 0; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 280, + height: 400, + child: SearchResultList( + results: rows, + selected: 1, + onActivate: (_) async { + activations++; + }, + ), + ), + ), + ), + ), + ); + expect(find.byType(ListTile), findsNothing); + final short = find.byKey(ValueKey(rows.first.identity)); + expect(tester.getSize(short).height, lessThan(80)); + final unavailable = tester.widget( + find.byKey(const ValueKey('disabled')), + ); + expect(unavailable.onTap, isNull); + expect(unavailable.selected, isTrue); + expect( + unavailable.semanticsLabel, + contains('Requires a Notes workspace'), + ); + await tester.tap(find.byKey(const ValueKey('disabled'))); + expect(activations, 0); + await tester.tap(short); + expect(activations, 1); + }, + ); + + test('Welcome omits hidden document categories and command targets', () { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + expect(session.categories, [SearchCategory.recent, SearchCategory.actions]); + session.open(); + expect( + container.read(searchSessionProvider).category, + SearchCategory.recent, + ); + session.open(category: SearchCategory.actions); + expect(session.commandTarget, isNull); + session.open(category: SearchCategory.file); + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + }); + + test( + 'a visible workspace without a buffer retains general History search', + () async { + final container = containerFor( + _state().copyWith(documentBuffers: []), + historyStore: MemoryLocalHistoryStore(), + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + expect(session.categories, [ + SearchCategory.workspace, + SearchCategory.documents, + SearchCategory.recent, + SearchCategory.localHistory, + SearchCategory.actions, + ]); + session.openHistory(); + await session.submit(); + expect( + container.read(searchSessionProvider).category, + SearchCategory.localHistory, + ); + expect(container.read(searchSessionProvider).error, isNull); + expect(container.read(workspaceControllerProvider).activeBuffer, isNull); + }, + ); + + test('settings cannot retain an invisible search presentation', () async { + final container = containerFor(_state()); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.actions); + container + .read(appRouterProvider) + .go(settingsLocation(SettingsReturnTarget.welcome)); + await Future.delayed(Duration.zero); + expect(session.categories, isEmpty); + expect(container.read(searchSessionProvider).active, isFalse); + session.open(category: SearchCategory.actions); + expect(container.read(searchSessionProvider).active, isFalse); + expect(session.commandTarget, isNull); + }); + + test( + 'Writerside categories retain the agreed order and use source topic identity', + () async { + final workspace = await const WorkspaceService().openPath( + 'test/fixtures/writerside/basic_project', + ); + final container = containerFor(_state().copyWith(workspace: workspace)); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + expect(session.categories, [ + SearchCategory.file, + SearchCategory.workspace, + SearchCategory.outline, + SearchCategory.documents, + SearchCategory.topics, + SearchCategory.recent, + SearchCategory.localHistory, + SearchCategory.actions, + ]); + session.open(category: SearchCategory.topics); + session.query('install.topic'); + await session.submit(); + final result = + container.read(searchSessionProvider).results.single + as TopicSearchResult; + expect(result.topicId, 'install'); + expect(result.title, 'Install'); + expect(result.path, endsWith('/topics/install.topic')); + }, + ); + + testWidgets( + 'header search preserves a sidebar origin and restores its focus', + (tester) async { + final container = containerFor(_state()); + container.read(appRouterProvider).go('/workspace'); + await tester.pump(); + final session = container.read(searchSessionProvider.notifier); + final editor = FocusNode(), + navigation = FocusNode(), + header = FocusNode(); + addTearDown(() { + editor.dispose(); + navigation.dispose(); + header.dispose(); + }); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Actions( + actions: { + BusyMarkContextCommandIntent: BusyMarkContextCommandAction( + isCommandEnabled: (_) => true, + onCommand: (_) {}, + ), + }, + child: Focus(focusNode: editor, child: const Text('Editor')), + ), + BusyMarkSidebarSurface( + child: Focus( + focusNode: navigation, + child: const Text('Files'), + ), + ), + Focus(focusNode: header, child: const Text('Search')), + ], + ), + ), + ), + ); + editor.requestFocus(); + await tester.pump(); + navigation.requestFocus(); + await tester.pump(); + header.requestFocus(); + await tester.pump(); + session.open(category: SearchCategory.actions); + expect(session.commandTarget!.focus, same(navigation)); + session.close(); + // This focus-only harness has no search widget to schedule its frame. + tester.binding.scheduleFrame(); + await tester.pump(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus, same(navigation)); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + test( + 'untitled Text uses its buffer and rebinds positions after a tab change', + () async { + final container = containerFor(_state()); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + expect(session.categories, [ + SearchCategory.file, + SearchCategory.workspace, + SearchCategory.outline, + SearchCategory.documents, + SearchCategory.recent, + SearchCategory.localHistory, + SearchCategory.actions, + ]); + session.open(category: SearchCategory.file); + session.query('needle'); + await session.submit(); + final first = + container.read(searchSessionProvider).results.single + as TextSearchResult; + expect(first.match.bufferId, 'scratch-a'); + expect(first.match.filePath, isNull); + expect(first.match.startOffset, 7); + expect(first.match.endOffset, 13); + session.open(category: SearchCategory.actions); + session.query('Bold'); + await session.submit(); + expect( + container + .read(searchSessionProvider) + .sourceSnapshot( + container.read(workspaceControllerProvider).activeBuffer, + ) + .result + .options, + const SourceSearchOptions(), + ); + session.open(category: SearchCategory.file); + expect(container.read(searchSessionProvider).query, 'needle'); + final next = _state(text: 'needle at the start', id: 'scratch-b'); + (container.read(workspaceControllerProvider.notifier) as _Workspace) + .replace(next); + expect(container.read(searchSessionProvider).results, isEmpty); + await session.submit(); + final second = + container.read(searchSessionProvider).results.single + as TextSearchResult; + expect(second.match.bufferId, 'scratch-b'); + expect(second.match.startOffset, 0); + expect(second.match.digest, isNot(first.match.digest)); + }, + ); + + for (final count in [81, 3000]) { + test( + 'document navigation counts $count matches with bounded pages', + () async { + final source = 'cat\n' * count; + final container = containerFor(_state(text: source)); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query('cat'); + await session.submit(); + var result = container.read(searchSessionProvider); + expect(result.results, hasLength(80)); + expect(result.documentMatchCount, count); + expect(result.documentCurrentMatchIndex, isNull); + expect( + result + .sourceSnapshot( + container.read(workspaceControllerProvider).activeBuffer, + ) + .result + .totalMatchCount, + count, + ); + for (final index in [79, 80, count - 1, 0]) { + final id = session.navigateSource( + SourceSearchNavigationRequest( + documentId: 'scratch-a', + source: source, + options: const SourceSearchOptions(query: 'cat'), + matchIndex: index, + ), + ); + expect(id, isNotNull); + await session.submit(); + result = container.read(searchSessionProvider); + final snapshot = result.sourceSnapshot( + container.read(workspaceControllerProvider).activeBuffer, + ); + expect(result.results.length, lessThanOrEqualTo(80)); + expect(snapshot.result.totalMatchCount, count); + expect(snapshot.result.currentMatchIndex, index); + expect(snapshot.result.currentMatch!.fullStart, index * 4); + expect(result.selected, index - result.documentFirstMatchIndex); + expect( + (result.results[result.selected] as TextSearchResult) + .match + .startOffset, + index * 4, + ); + expect(result.appliedSourceNavigation, id); + } + }, + ); + } + + test( + 'post-edit navigation retains its revision and cancels across queries and buffers', + () async { + final container = containerFor(_state(text: 'cat${'\n' * 200}cat')); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query('cat'); + await session.submit(); + final workspace = container.read(workspaceControllerProvider); + final changed = workspace.activeBuffer!.copyWith( + text: 'dog${'\n' * 200}cat', + revision: 1, + ); + (container.read(workspaceControllerProvider.notifier) as _Workspace) + .replace(workspace.copyWith(documentBuffers: [changed])); + final id = session.navigateSource( + SourceSearchNavigationRequest( + documentId: changed.id, + source: changed.text, + options: const SourceSearchOptions(query: 'cat'), + minimumFullOffset: 3, + ), + ); + await session.submit(); + var result = container.read(searchSessionProvider); + expect(result.appliedSourceNavigation, id); + expect( + (result.results[result.selected] as TextSearchResult).match.startOffset, + 203, + ); + session.navigateSource( + SourceSearchNavigationRequest( + documentId: changed.id, + source: changed.text, + options: const SourceSearchOptions(query: 'cat'), + minimumFullOffset: 206, + ), + ); + final pending = session.submit(); + session.query('dog'); + session.query('cat'); + (container.read(workspaceControllerProvider.notifier) as _Workspace) + .replace(_state(text: 'cat in another buffer', id: 'scratch-b')); + await pending; + await session.submit(); + result = container.read(searchSessionProvider); + expect(result.sourceNavigationRequest, 0); + expect(result.appliedSourceNavigation, 0); + expect(result.documentCurrentMatchIndex, isNull); + expect( + (result.results.single as TextSearchResult).match.bufferId, + 'scratch-b', + ); + }, + ); + + test( + 'file and workspace categories own independent queries and supported options', + () async { + final container = containerFor(_state(text: 'Needle needle')); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + expect( + searchCommandDestinations[BusyMarkCommandIds.search], + SearchCategory.file, + ); + expect( + searchCommandDestinations[BusyMarkCommandIds.searchWorkspace], + SearchCategory.workspace, + ); + expect( + searchCommandDestinations.containsKey( + BusyMarkCommandIds.sidebarOutline, + ), + isFalse, + ); + session.open(category: SearchCategory.file); + session.set( + container.read(searchSessionProvider).copyWith(query: 'Needle'), + ); + await session.submit(); + expect(container.read(searchSessionProvider).documentMatchCount, 2); + session.open(category: SearchCategory.workspace); + expect(container.read(searchSessionProvider).query, isEmpty); + session.set( + container + .read(searchSessionProvider) + .copyWith(query: 'workspace query'), + ); + session.open(category: SearchCategory.file); + expect( + container.read(searchSessionProvider).options, + const SourceSearchOptions(query: 'Needle'), + ); + await session.submit(); + session.open(category: SearchCategory.workspace); + expect( + container.read(searchSessionProvider).options, + const SourceSearchOptions(query: 'workspace query'), + ); + expect( + container.read(workspaceControllerProvider).activeText, + 'Needle needle', + ); + }, + ); + + test( + 'shared History finds historical paths and revision bodies without changing comparison or source', + () async { + final store = MemoryLocalHistoryStore(); + final now = DateTime.now().toUtc(); + for (final (path, source) in [ + ('/retired/old.md', 'retained needle'), + ('/current/b.md', 'another revision'), + ]) { + await store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: path.split('/').last, + source: source, + format: TextFormatMetadata.utf8Lf, + capturedAt: now, + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + await store.remapPath('/retired/old.md', '/renamed/new.md'); + await store.markDeleted('/renamed/new.md', recursive: false); + final container = containerFor(_state(), historyStore: store); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final history = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + await history.refresh(); + final retained = container.read(localHistoryControllerProvider).snapshot; + final deleted = retained.documents.singleWhere((d) => d.deleted); + final other = retained.documents.singleWhere((d) => !d.deleted); + history.inspectRetainedDocument(other.id); + await history.selectRevision(retained.revisionsFor(other.id).single.id); + final before = container.read(localHistoryControllerProvider); + final live = container.read(workspaceControllerProvider).activeBuffer; + final session = container.read(searchSessionProvider.notifier); + session.openHistory(); + session.query('needle'); + await session.submit(); + var result = + (container.read(searchSessionProvider).results.single + as HistorySearchResult) + .match; + expect(result, isA()); + expect(result.document.id, deleted.id); + expect( + (result as LocalHistoryRevisionMatch).revision.id, + retained.revisionsFor(deleted.id).single.id, + ); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + same(before.selectedRevision), + ); + expect( + container.read(workspaceControllerProvider).activeBuffer, + same(live), + ); + expect((await store.load()).revisions, hasLength(2)); + session.query('/retired/old.md'); + await session.submit(); + result = + (container.read(searchSessionProvider).results.single + as HistorySearchResult) + .match; + expect(result, isA()); + expect(result.document.id, deleted.id); + session.openHistory(documentId: other.id); + session.query('needle'); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + session.clearHistoryScope(); + await session.submit(); + expect(container.read(searchSessionProvider).results, hasLength(1)); + final id = container.read(searchSessionProvider).results.single.identity; + session.select(0); + session.open(category: SearchCategory.actions); + session.query('Bold'); + session.open(category: SearchCategory.localHistory); + await session.submit(); + expect(container.read(searchSessionProvider).query, 'needle'); + expect( + container + .read(searchSessionProvider) + .results[container.read(searchSessionProvider).selected] + .identity, + id, + ); + await history.clearDocument(deleted.id); + expect(container.read(searchSessionProvider).results, isEmpty); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + expect( + container.read(localHistoryControllerProvider).selectedRevision, + same(before.selectedRevision), + ); + }, + ); + + test( + 'obsolete History reads cannot publish after category, scope or snapshot changes', + () async { + final store = _SlowHistoryStore(); + await store.capture( + LocalHistoryCaptureRequest( + path: '/past/a.md', + displayName: 'a.md', + source: 'retained needle', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + final container = containerFor(_state(), historyStore: store); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final history = container.read(localHistoryControllerProvider.notifier); + await history.refresh(); + final session = container.read(searchSessionProvider.notifier); + store.block = true; + session.openHistory(); + session.query('needle'); + final old = session.submit(); + await store.started.future; + session.open(category: SearchCategory.actions); + session.query('Bold'); + await session.submit(); + store.release.complete(); + await old; + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + expect( + container + .read(searchSessionProvider) + .results + .whereType(), + isEmpty, + ); + session.openHistory(documentId: 'missing-owner'); + session.query('needle'); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + session.clearHistoryScope(); + await session.submit(); + expect(container.read(searchSessionProvider).results, hasLength(1)); + await history.clearAll(); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + }, + ); + + test('late workspace text cannot publish into another category', () async { + final service = _GatedService(); + final container = containerFor( + _state(files: [_file('unopened.md')]), + service: service, + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.workspace); + session.query('needle'); + final oldSearch = session.submit(); + await service.started.future; + session.open(category: SearchCategory.actions); + session.query('Bold'); + await session.submit(); + service.release.complete('old needle result'); + await oldSearch; + final current = container.read(searchSessionProvider); + expect(current.category, SearchCategory.actions); + expect(current.query, 'Bold'); + expect(current.results, isEmpty); + expect(current.searching, isFalse); + expect(current.error, isNull); + }); + + test( + 'workspace publishes useful bounded results before scanning completes', + () async { + final service = _ProgressService(); + final container = containerFor( + _state(files: [_file('a.md'), _file('z.md')]), + service: service, + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final first = Completer(); + final subscription = container.listen(searchSessionProvider, (_, state) { + if (state.searching && state.results.isNotEmpty && !first.isCompleted) { + first.complete(); + } + }); + addTearDown(subscription.close); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.workspace); + session.query('needle'); + final running = session.submit(); + await first.future; + expect(container.read(searchSessionProvider).searching, isTrue); + expect(container.read(searchSessionProvider).results, hasLength(1)); + session.open(category: SearchCategory.actions); + service.release.complete('needle stale'); + await running; + await session.submit(); + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + expect(container.read(searchSessionProvider).results, isEmpty); + }, + ); + + test( + 'workspace queries do not rescan for unrelated editor state changes', + () async { + final service = _ReadAheadService(); + final initial = _state(files: [_file('unopened.md')]); + final container = containerFor(initial, service: service); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.workspace); + session.query('needle'); + final running = session.submit(); + await Future.delayed(Duration.zero); + service.release.complete('needle'); + await running; + expect(service.reads, 1); + final revision = container.read(searchSessionProvider).requestRevision; + final controller = + container.read(workspaceControllerProvider.notifier) as _Workspace; + // An unrelated scratch tab does not belong to the workspace inventory. + controller.replace( + initial.copyWith( + documentBuffers: [ + ...initial.documentBuffers, + const DocumentBuffer( + id: 'outside', + text: 'needle outside', + lastSavedText: '', + dirty: true, + untitledName: 'External scratch', + ), + ], + activeBufferId: 'outside', + ), + ); + await Future.delayed(const Duration(milliseconds: 160)); + expect(service.reads, 1); + expect(container.read(searchSessionProvider).requestRevision, revision); + expect(container.read(searchSessionProvider).results, hasLength(1)); + }, + ); + + test( + 'workspace read-ahead remains bounded and stops after cancellation', + () async { + final service = _ReadAheadService(); + final container = containerFor( + _state(files: [for (var i = 0; i < 50; i++) _file('file-$i.md')]), + service: service, + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.workspace); + session.query('needle'); + final opening = session.submit(); + await service.windowStarted.future; + expect(service.reads, 8); + session.open(category: SearchCategory.actions); + await session.submit(); + service.release.complete('needle'); + await opening; + expect(service.reads, 8); + expect(container.read(searchSessionProvider).results, isEmpty); + expect( + container.read(searchSessionProvider).category, + SearchCategory.actions, + ); + }, + ); + + test( + 'Documents retains configuration and resource inventory without body search', + () async { + final container = containerFor( + _state( + files: [ + _file('writerside.cfg', kind: DocumentKind.config), + _file('resources/terms.txt', kind: DocumentKind.resource), + _file('other.md'), + ], + ), + ); + container.read(appRouterProvider).go('/workspace'); + await Future.delayed(Duration.zero); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.documents); + session.query('writerside'); + await session.submit(); + expect( + (container.read(searchSessionProvider).results.single + as DocumentSearchResult) + .document + .filePath, + '/workspace/writerside.cfg', + ); + session.query('terms'); + await session.submit(); + expect( + (container.read(searchSessionProvider).results.single + as DocumentSearchResult) + .document + .filePath, + '/workspace/resources/terms.txt', + ); + session.query('needle'); + await session.submit(); + expect(container.read(searchSessionProvider).results, isEmpty); + }, + ); +} + +DocumentFile _file(String path, {DocumentKind kind = DocumentKind.markdown}) => + DocumentFile( + absolutePath: '/workspace/$path', + relativePath: path, + kind: kind, + size: 32, + lastModified: DateTime(2026), + ); + +WorkspaceState _state({ + String text = 'prefix needle suffix', + String id = 'scratch-a', + List files = const [], +}) => WorkspaceState( + workspace: Workspace( + id: 'workspace', + rootPath: '/workspace', + kind: WorkspaceKind.markdownFolder, + openedAt: DateTime(2026), + files: files, + diagnostics: const [], + ), + documentBuffers: [ + DocumentBuffer( + id: id, + text: text, + lastSavedText: '', + dirty: true, + untitledName: 'Untitled', + ), + ], + activeBufferId: id, +); + +class _Workspace extends WorkspaceController { + _Workspace(this.initial); + final WorkspaceState initial; + @override + WorkspaceState build() => initial; + void replace(WorkspaceState next) => state = next; +} + +class _GatedService extends WorkspaceService { + final started = Completer(); + final release = Completer(); + @override + Future loadText(String path) { + started.complete(); + return release.future; + } +} + +class _ReadAheadService extends _GatedService { + int reads = 0; + final windowStarted = Completer(); + @override + Future loadText(String path) { + reads++; + if (reads == 8) windowStarted.complete(); + return release.future; + } +} + +class _ProgressService extends _GatedService { + @override + Future loadText(String path) => + path.endsWith('/a.md') ? Future.value('needle') : release.future; +} + +class _Settings implements LocalSettingsStore { + @override + Future> load() async => AppSettings.defaults().toJson(); + @override + Future save(Map json) async {} +} + +class _SlowHistoryStore extends MemoryLocalHistoryStore { + bool block = false; + final started = Completer(); + final release = Completer(); + @override + Future readRevision(String id) async { + if (block) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return super.readRevision(id); + } +} + +class _CountingWorker extends SourceSearchWorker { + int requests = 0; + Completer? gate; + @override + Future search( + SourceDocument document, + SourceSearchOptions options, { + int? currentMatchIndex, + int firstMatchIndex = 0, + int? minimumFullOffset, + int maximumMatches = sourceInteractiveSearchMatchLimit, + bool stopAfterMaximumMatches = false, + bool reuseWorker = false, + }) { + requests++; + return gate?.future ?? + super.search( + document, + options, + currentMatchIndex: currentMatchIndex, + firstMatchIndex: firstMatchIndex, + minimumFullOffset: minimumFullOffset, + maximumMatches: maximumMatches, + stopAfterMaximumMatches: stopAfterMaximumMatches, + reuseWorker: reuseWorker, + ); + } +} + +class _CountingHistory extends LocalHistoryController { + int requests = 0, builds = 0; + @override + LocalHistoryState build() { + builds++; + return const LocalHistoryState(); + } + + @override + Future findRetained({ + required String query, + String? documentId, + int maximumResults = 80, + bool Function()? isCancelled, + void Function(LocalHistorySearchPage)? onProgress, + }) async { + requests++; + return const LocalHistorySearchPage([]); + } +} + +class _ReadCountingList extends ListBase { + _ReadCountingList(this.values); + final List values; + int reads = 0; + @override + int get length => values.length; + @override + set length(int value) => throw UnsupportedError('Read only'); + @override + T operator [](int index) { + reads++; + return values[index]; + } + + @override + void operator []=(int index, T value) => throw UnsupportedError('Read only'); +} diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index db8681c5..7f69c146 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -200,11 +200,11 @@ void main() { ]) { expect(combined, isNot(contains(removed))); } - // Ctrl+P is now the approved Quick Open binding. Printing remains absent. + // Ctrl+Shift+D is now the approved Quick Open binding. Printing remains absent. final shortcuts = File( 'lib/src/app/busymark_shortcuts.dart', ).readAsStringSync(); - expect(shortcuts, contains("quickOpenLabel = 'Ctrl+P'")); + expect(shortcuts, contains("quickOpenLabel = 'Ctrl+Shift+D'")); expect(shortcuts, contains("commandPaletteLabel = 'Ctrl+Shift+P'")); final registry = File( 'lib/src/app/command_registry.dart', @@ -412,7 +412,7 @@ void main() { expect(sidebarSurface, contains('end: BorderSide(')); expect(sidebarSurface, contains('color: colors.sidebarBorder')); expect(workspace, contains('return BusyMarkSidebarSurface(')); - expect(welcome, contains('return BusyMarkSidebarSurface(')); + expect(welcome, contains('BusyMarkSidebarSurface(')); expect( workspace, isNot(contains('decoration: BoxDecoration(color: colors.sidebar)')), @@ -481,19 +481,24 @@ void main() { 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(')); - final searchField = RegExp( - r'class _HeaderSearchField[\s\S]*?class _Sidebar ', - ).firstMatch(workspace)!.group(0)!; - expect(searchField, isNot(contains('TextField('))); - expect(searchField, isNot(contains('OutlineInputBorder('))); - expect(searchField, contains('onEscape: widget.onEscape')); - expect(workspace, contains('class _SearchSidebar')); - expect(workspace, contains('_workspaceSearchResults')); - expect(workspace, contains('_loadWorkspaceSearchMatches')); - expect(workspace, contains('await loadText(file.absolutePath)')); + final sidebar = File( + 'lib/src/search/search_sidebar.dart', + ).readAsStringSync(); + final session = File( + 'lib/src/search/search_session.dart', + ).readAsStringSync(); + final matcher = File( + 'lib/src/search/workspace_text_search.dart', + ).readAsStringSync(); + expect(workspace, contains('searchSessionProvider')); + expect(sidebar, contains('class SearchHeaderField')); + expect(sidebar, contains('child: BusyMarkSearchField(')); + expect(sidebar, isNot(contains('TextField('))); + expect(sidebar, isNot(contains('OutlineInputBorder('))); + expect(sidebar, contains('onEscape: controller.close')); + expect(sidebar, contains('class SearchSidebar')); + expect(session, contains('loadWorkspaceTextMatches')); + expect(matcher, contains('await loadText(file.absolutePath)')); expect(workspace, isNot(contains('readAsStringSync()'))); expect(workspace, contains('_searchNavigationTargetProvider')); }); @@ -736,9 +741,10 @@ void main() { singleMarkdownClause.indexOf('_SidebarTab.localHistory'), lessThan(singleMarkdownClause.indexOf('_SidebarTab.clipboard')), ); + expect(workspace, contains('showTabMenu: tabs.length > 1')); expect( workspace, - contains('showTabMenu: !widget.searchState.active && tabs.length > 1'), + contains(RegExp(r'if \(!widget\.searchState\.active\)\s+_SidebarHeader')), ); expect(workspace, contains('_preferredSidebarTabIndex')); expect(workspace, contains('_shouldShowOutlineForOpenFile')); @@ -867,10 +873,16 @@ void main() { expect(workspace, contains('shortcut: _sidebarTabShortcut(context, tab)')); expect( workspace, - isNot(contains('BusyMarkSidebarShortcutActivators.history')), + isNot(contains('BusyMarkSidebarShortcuts.history.activator')), + ); + expect( + File('lib/src/app/command_registry.dart').readAsStringSync(), + contains('LogicalKeyboardKey.numpad5'), + ); + expect( + File('lib/src/app/command_registry.dart').readAsStringSync(), + contains('LogicalKeyboardKey.numpad6'), ); - expect(workspace, contains('LogicalKeyboardKey.numpad5')); - expect(workspace, contains('LogicalKeyboardKey.numpad6')); expect( workspace, contains('_SidebarTab.files => BusyMarkGlyphs.documentOpen'), @@ -1031,7 +1043,7 @@ void main() { expect(workspace, isNot(contains('_outlineTreeHeadings'))); expect( workspace, - contains('widget.searchState.active ? null : selectedTab'), + isNot(contains('widget.searchState.category == SearchCategory.outline')), ); expect(workspace, contains('selectedTab == _SidebarTab.git')); expect( @@ -1238,14 +1250,16 @@ void main() { r'InputDecorationThemeData busyMarkGroupedInputDecorationTheme', ).firstMatch(design)!.group(0)!; expect(selector, contains('BusyMarkMenuButton(')); - expect(selector, contains('Theme.of(context).outlinedButtonTheme.style')); - expect( - selector, - contains('side: const WidgetStatePropertyAll(BorderSide.none)'), - ); + expect(selector, contains('child: TextButton(')); + expect(selector, isNot(contains('OutlinedButton('))); + expect(selector, isNot(contains('BusyMarkPushButton.standard('))); + expect(selector, isNot(contains('contentPadding'))); + expect(selector, contains('shortcut: option.shortcut')); + expect(selector, contains('gtkAccelerator: option.gtkAccelerator')); + expect(selector, isNot(contains('BorderSide.none'))); expect(selector, contains('BusyMarkPopupMenuItem(')); - expect(selector, contains('softWrap: false')); - expect(selector, contains('BusyMarkPushButton.standard(')); + expect(selector, contains('softWrap: maxLabelLines > 1')); + expect(selector, contains('this.maxLabelLines = 1')); expect(selector, isNot(contains('WidgetStatesController()'))); expect(selector, isNot(contains('showMenu('))); expect(selector, isNot(contains('MouseRegion('))); @@ -1559,20 +1573,31 @@ void main() { 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')); + final sidebar = File( + 'lib/src/search/search_sidebar.dart', + ).readAsStringSync(); + expect(welcome, contains('SearchResultList(')); + final navigation = File( + 'lib/src/workspace/recent_navigation.dart', + ).readAsStringSync(); + expect(navigation, contains('enum RecentNavigationAction')); + expect(sidebar, isNot(contains('Future openRecentResource'))); + expect(sidebar, isNot(contains('Future activateSearchResult'))); + expect(sidebar, isNot(contains('DropdownButton'))); + expect(sidebar, isNot(contains('FilterChip'))); + expect(sidebar, contains('_recentMenuItems')); expect( - welcome, - contains('BusyMarkSidebarRecordRow<_RecentWorkspaceAction>'), + sidebar, + contains('BusyMarkSidebarRecordRow'), ); - expect(welcome, contains('menuItemsBuilder: _recentWorkspaceMenuItems')); + expect(sidebar, contains('menuItemsBuilder:')); 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(sidebar, contains('label: context.l10n.openInFiles')); + expect(sidebar, contains('label: context.l10n.copyPath')); + expect(sidebar, contains('label: context.l10n.removeFromRecent')); expect(settings, contains('removeRecentWorkspace(String path)')); }); @@ -1608,7 +1633,9 @@ void main() { contains('BusyMarkHeaderPopupMenuButton<_ClipboardHistoryAction>'), ); expect(clipboard, contains("'clipboard-history-actions-menu'")); - expect(clipboard, contains('textDirection: TextDirection.ltr')); + expect(clipboard, isNot(contains('BusyMarkSearchField('))); + expect(clipboard, contains('context.l10n.clipboardHistory')); + expect(clipboard, contains('if (!_listFocusNode.hasPrimaryFocus)')); expect(clipboard, isNot(contains('colorScheme.primaryContainer'))); expect(clipboard, isNot(contains('expandedChild:'))); expect(clipboard, isNot(contains('_ClipboardPreview'))); @@ -1620,7 +1647,8 @@ void main() { contains('BusyMarkHeaderPopupMenuButton<_HistoryAction>'), ); expect(localHistory, contains("'local-history-actions-menu'")); - expect(localHistory, contains('textDirection: TextDirection.ltr')); + expect(localHistory, isNot(contains('searchSessionProvider'))); + expect(localHistory, contains('context.l10n.localHistory')); expect(localHistory, contains('_HistoryAction.refresh')); expect(localHistory, contains('padding: BusyMarkInsets.sidebarList')); expect(localHistory, isNot(contains('child: ListTile('))); @@ -1634,16 +1662,19 @@ void main() { 'lib/src/editor/source/source_editor.dart', ).readAsStringSync(); - expect(workspace, contains('_outlineNavigationTargetProvider')); - expect(workspace, contains('_OutlineNavigationTarget')); + final navigation = File( + 'lib/src/workspace/outline_navigation.dart', + ).readAsStringSync(); + expect(workspace, contains('outlineNavigationTargetProvider')); + expect(navigation, contains('class OutlineNavigationTarget')); expect( workspace, - contains('final documentOutline = _activeDocumentOutline(state);'), + contains('final documentOutline = activeDocumentOutline(state);'), ); expect(workspace, contains('outline: documentOutline')); - expect(workspace, contains('return preview.outline')); - expect(workspace, contains('headingId: heading.id')); - expect(workspace, contains('line: heading.sourceStartLine')); + expect(navigation, contains('return preview.outline')); + expect(navigation, contains('headingId: heading.id')); + expect(navigation, contains('line: heading.sourceStartLine')); expect(workspace, contains('workspaceId: widget.workspace.id')); expect(workspace, isNot(contains('widget.workspace.markdown?.headings'))); expect(workspace, contains('_sourceEditorKey.currentState?.scrollToLine')); @@ -1710,10 +1741,7 @@ void main() { expect(workspace, contains('_scrollPreviewToApproximateLine')); expect(workspace, contains('nearestBeforeIndex')); expect(workspace, isNot(contains('return fallbackIndex'))); - expect( - workspace, - contains('Future.delayed(BusyMarkMotion.previewSearchDelay'), - ); + expect(workspace, contains('Timer(BusyMarkMotion.previewSearchDelay')); expect(preview, contains('sourceStartLine')); expect(preview, contains('sourceStartOffset')); expect(preview, contains('span.startLine')); @@ -1750,27 +1778,18 @@ void main() { ]) { expect(toolbar, contains(label)); } - for (final shortcut in [ - 'BusyMarkEditorShortcutLabels.textStyle', - 'BusyMarkEditorShortcutLabels.indent', - 'BusyMarkEditorShortcutLabels.outdent', - 'BusyMarkEditorShortcutLabels.blockquote', - 'BusyMarkEditorShortcutLabels.codeBlock', - 'BusyMarkEditorShortcutLabels.image', - 'BusyMarkEditorShortcutLabels.thematicBreak', - 'BusyMarkEditorShortcutLabels.hardLineBreak', - ]) { - expect(toolbar, contains(shortcut)); - } - for (final shortcut in [ - 'BusyMarkEditorShortcutLabels.toggleTask', - 'BusyMarkEditorShortcutLabels.codeBlockLanguage', - 'BusyMarkEditorShortcutLabels.inlineImage', - 'BusyMarkEditorShortcutLabels.table', - 'BusyMarkEditorShortcutLabels.htmlBlock', + for (final id in [ + 'indent', + 'outdent', + 'blockquote', + 'codeBlock', + 'image', + 'thematicBreak', + 'hardLineBreak', ]) { - expect(toolbar, isNot(contains(shortcut))); + expect(toolbar, contains("['editor.$id']")); } + expect(toolbar, isNot(contains('BusyMarkEditorShortcuts.'))); expect(toolbar, isNot(contains('context.l10n.codeBlockLanguage'))); expect(editor, contains('BusyMarkEditorShortcutAction.codeBlockLanguage')); expect(editor, contains('_applyCodeLanguageCommand()')); diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index c136bcb2..452c0501 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1,4 +1,5 @@ import 'package:busymark/src/editor/link_destination.dart'; +import 'package:busymark/src/assets/document_media_context.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -6,7 +7,6 @@ import 'dart:math' as math; import 'dart:ui' show BoxHeightStyle; import 'package:busymark/l10n/generated/app_localizations.dart'; -import 'package:busymark/l10n/generated/app_localizations_de.dart'; 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'; @@ -41,6 +41,11 @@ import 'package:busymark/src/platform/rich_clipboard_service.dart'; import 'package:busymark/src/spellcheck/spelling_coordinator.dart'; import 'package:busymark/src/spellcheck/spelling_projection.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/search/search_session.dart'; +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:flutter/gestures.dart'; @@ -52,6 +57,183 @@ import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; void main() { + testWidgets( + 'Source consumes shared pages and replaces in an unsaved buffer', + (tester) async { + final source = 'needle\n' * 81; + final container = ProviderContainer( + overrides: [ + workspaceControllerProvider.overrideWith( + () => _SharedSourceWorkspace(source), + ), + localSettingsStoreProvider.overrideWithValue(_SharedSearchSettings()), + ], + ); + final router = container.read(appRouterProvider); + addTearDown(() { + container.dispose(); + router.dispose(); + }); + router.go('/workspace'); + await tester.pump(); + final session = container.read(searchSessionProvider.notifier); + session.open(category: SearchCategory.file); + session.query('needle'); + final key = GlobalKey(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + final workspace = ref.watch(workspaceControllerProvider); + final search = ref.watch(searchSessionProvider); + return BusyMarkSourceEditor( + key: key, + text: workspace.activeText, + documentId: workspace.activeBufferId, + language: SourceSyntaxLanguage.markdown, + filePath: null, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: search.textActive, + searchOptions: search.options, + searchSnapshot: search.sourceSnapshot( + workspace.activeBuffer, + ), + onNavigateSearch: session.navigateSource, + onCancelSearchNavigation: session.cancelSourceNavigation, + searchReplacement: 'X', + onSearchReplacementChanged: (_) {}, + onChanged: (text, _) => + (ref.read(workspaceControllerProvider.notifier) + as _SharedSourceWorkspace) + .edit(text), + onCloseSearch: session.close, + ); + }, + ), + ), + ), + ), + ); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + await _pumpUntil( + tester, + () => controller.searchResult.totalMatchCount == 81, + ); + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchPreviousMatch), + ); + await _pumpUntil( + tester, + () => + controller.fullSelection.start == 80 * 7 && + controller.searchResult.currentMatchIndex == 80, + ); + expect( + container.read(searchSessionProvider).results.length, + lessThanOrEqualTo(80), + ); + expect(controller.searchResult.firstMatchIndex, 80); + await tester.tap( + find.byTooltip(AppLocalizationsEn().sourceSearchReplaceAndFindNext), + ); + await _pumpUntil( + tester, + () => + controller.fullText.endsWith('X\n') && + controller.searchResult.totalMatchCount == 80 && + controller.fullSelection == + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + final state = container.read(searchSessionProvider); + expect(state.documentCurrentMatchIndex, 0); + expect( + (state.results[state.selected] as TextSearchResult).match.startOffset, + 0, + ); + expect(controller.fullText, '${'needle\n' * 80}X\n'); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + }, + ); + + for (final readOnly in [false, true]) { + testWidgets( + 'Source projects shared matches and preserves read-only replacement ($readOnly)', + (tester) async { + const source = 'cat cat'; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DocumentReadOnlyScope( + readOnly: readOnly, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/shared.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + searchSnapshot: SourceSearchSnapshot( + source: source, + result: SourceSearchResult( + options: const SourceSearchOptions(query: 'cat'), + matches: const [ + SourceSearchMatch( + fullStart: 4, + fullEnd: 7, + visibleStart: 4, + visibleEnd: 7, + hidden: false, + ), + ], + ), + ), + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 400)); + final controller = tester + .widgetList(find.byType(TextField)) + .map((field) => field.controller) + .whereType() + .single; + expect(controller.searchResult.totalMatchCount, 1); + expect(controller.searchResult.matches.single.fullStart, 4); + expect(controller.searchResult.matches.single.fullEnd, 7); + final l10n = AppLocalizationsEn(); + for (final tooltip in [ + l10n.sourceSearchReplaceCurrent, + l10n.sourceSearchReplaceAndFindNext, + l10n.sourceSearchReplaceAll, + ]) { + final button = tester + .widgetList(find.byType(YaruIconButton)) + .singleWhere((b) => b.tooltip == tooltip); + expect(button.onPressed, readOnly ? isNull : isNotNull); + } + }, + ); + } for (final stale in [false, true]) { testWidgets( 'Source target chooser ${stale ? 'rejects changed selection' : 'preserves selected text and formatting'}', @@ -163,7 +345,6 @@ void main() { searchOptions: const SourceSearchOptions(query: 'cat'), searchReplacement: 'bat', onSearchReplacementChanged: (_) {}, - onSearchOptionsChanged: (_) {}, onChanged: (value, _) => setState(() => text = value), onOpenSearch: () {}, onCloseSearch: () {}, @@ -263,7 +444,6 @@ void main() { searchOptions: const SourceSearchOptions(query: 'cat'), searchReplacement: 'dog', onSearchReplacementChanged: (_) {}, - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => setState(() => currentText = text), onOpenSearch: () {}, onCloseSearch: () {}, @@ -354,7 +534,6 @@ void main() { searchActive: true, searchOptions: const SourceSearchOptions(query: 'cat'), initialFoldedRegionKeys: {region.key}, - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -415,7 +594,6 @@ void main() { searchOptions: const SourceSearchOptions(), initialSelection: TextSelection.collapsed(offset: target), initialFoldedRegionKeys: {firstRegion.key}, - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -463,7 +641,6 @@ void main() { searchActive: false, searchOptions: const SourceSearchOptions(), initialFoldedRegionKeys: {region.key}, - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -569,7 +746,6 @@ void main() { searchOptions: const SourceSearchOptions(), initialSelection: const TextSelection.collapsed(offset: 4), spellingAnnotations: [annotation], - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -735,7 +911,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => changedText = text, onOpenSearch: () {}, onCloseSearch: () {}, @@ -879,7 +1054,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -960,7 +1134,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1078,7 +1251,6 @@ void main() { searchActive: false, searchOptions: const SourceSearchOptions(), initialFoldedRegionKeys: {region.key}, - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1141,7 +1313,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => changedText = text, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1224,7 +1395,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1274,7 +1444,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1313,7 +1482,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1419,7 +1587,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1432,10 +1599,7 @@ void main() { expect(find.byTooltip('Duplicate heading ID "intro".'), findsOneWidget); }); - testWidgets('source search localizes an invalid regular expression', ( - tester, - ) async { - final de = AppLocalizationsDe(); + testWidgets('source search treats bracket input as literal', (tester) async { await tester.pumpWidget( MaterialApp( locale: const Locale('de'), @@ -1446,15 +1610,14 @@ void main() { width: 900, height: 600, child: BusyMarkSourceEditor( - text: 'Text', + text: 'Text [', language: SourceSyntaxLanguage.markdown, filePath: '/project/topic.md', diagnostics: const [], editorFontSize: 14, wordWrap: true, searchActive: true, - searchOptions: const SourceSearchOptions(query: '[', regex: true), - onSearchOptionsChanged: (_) {}, + searchOptions: const SourceSearchOptions(query: '['), onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1464,7 +1627,8 @@ void main() { ), ); - expect(find.text(de.sourceSearchInvalidRegex), findsOneWidget); + await _pumpUntil(tester, () => find.text('– / 1').evaluate().isNotEmpty); + expect(find.text('– / 1'), findsOneWidget); }); testWidgets('source search reports no current match before navigation', ( @@ -1488,7 +1652,6 @@ void main() { wordWrap: true, searchActive: true, searchOptions: const SourceSearchOptions(query: 'cat'), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1517,70 +1680,58 @@ void main() { ); }); - testWidgets('source fold and search options use semantic icon buttons', ( - tester, - ) async { - final en = AppLocalizationsEn(); - SourceSearchOptions? updatedOptions; - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: buildBusyMarkTheme( - brightness: Brightness.light, - accentColor: BusyMarkLinuxPalette.blueAccent, - ), - home: Scaffold( - body: SizedBox( - width: 900, - height: 600, - child: BusyMarkSourceEditor( - text: '# Intro\nBody\n', - language: SourceSyntaxLanguage.markdown, - filePath: '/project/topic.md', - diagnostics: const [], - editorFontSize: 14, - wordWrap: true, - searchActive: true, - searchOptions: const SourceSearchOptions(caseSensitive: true), - onSearchOptionsChanged: (options) => updatedOptions = options, - onChanged: (_, _) {}, - onOpenSearch: () {}, - onCloseSearch: () {}, + testWidgets( + 'source fold and navigation use semantic buttons without matching toggles', + (tester) async { + final en = AppLocalizationsEn(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: '# Intro\nBody\n', + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'Intro'), + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), ), ), ), - ), - ); - - final foldTooltip = find.byTooltip(en.collapseKind(en.foldKindSection)); - final foldButton = find.ancestor( - of: foldTooltip, - matching: find.byType(BusyMarkCompactIconButton), - ); - expect(foldTooltip, findsOneWidget); - expect(foldButton, findsOneWidget); + ); - final caseButton = find.ancestor( - of: find.byTooltip(en.sourceSearchCaseSensitive), - matching: find.byType(YaruIconButton), - ); - final wholeWordButton = find.ancestor( - of: find.byTooltip(en.sourceSearchWholeWord), - matching: find.byType(YaruIconButton), - ); - expect(tester.widget(caseButton).isSelected, isTrue); - expect(tester.widget(wholeWordButton).isSelected, isFalse); + final foldTooltip = find.byTooltip(en.collapseKind(en.foldKindSection)); + final foldButton = find.ancestor( + of: foldTooltip, + matching: find.byType(BusyMarkCompactIconButton), + ); + expect(foldTooltip, findsOneWidget); + expect(foldButton, findsOneWidget); - await tester.tap(wholeWordButton); - await tester.pump(); - expect(updatedOptions?.caseSensitive, isTrue); - expect(updatedOptions?.wholeWord, isTrue); + expect(find.text('Aa'), findsNothing); + expect(find.text('W'), findsNothing); + expect(find.text('.*'), findsNothing); + expect(find.byTooltip(en.sourceSearchNextMatch), findsOneWidget); - await tester.tap(foldButton); - await tester.pump(); - expect(find.byTooltip(en.expandKind(en.foldKindSection)), findsOneWidget); - }); + await tester.tap(foldButton); + await tester.pump(); + expect(find.byTooltip(en.expandKind(en.foldKindSection)), findsOneWidget); + }, + ); testWidgets('source Replace All is one editor operation', (tester) async { final en = AppLocalizationsEn(); @@ -1608,7 +1759,6 @@ void main() { searchReplacement: replacement, onSearchReplacementChanged: (value) => setState(() => replacement = value), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) { undoText = currentText; setState(() => currentText = text); @@ -1676,7 +1826,6 @@ void main() { searchOptions: const SourceSearchOptions(query: 'a'), searchReplacement: 'x', onSearchReplacementChanged: (_) {}, - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => setState(() => currentText = text), onOpenSearch: () {}, onCloseSearch: () {}, @@ -1727,7 +1876,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => currentText = text, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1804,7 +1952,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1839,7 +1986,6 @@ void main() { SourceSearchResult( options: const SourceSearchOptions(query: 'Line'), matches: const [], - invalidRegex: true, ), ); await tester.pump(); @@ -1889,7 +2035,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -1977,7 +2122,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -2046,7 +2190,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => setState(() => source = text), onOpenSearch: () {}, onCloseSearch: () {}, @@ -2139,7 +2282,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => setState(() => source = text), onOpenSearch: () {}, onCloseSearch: () {}, @@ -2207,7 +2349,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => setState(() => source = text), onOpenSearch: () {}, onCloseSearch: () {}, @@ -2303,7 +2444,6 @@ void main() { wordWrap: false, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -2345,7 +2485,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => changedText = text, onOpenSearch: () {}, onCloseSearch: () {}, @@ -2455,7 +2594,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (text, _) => changedText = text, onOpenSearch: () {}, onCloseSearch: () {}, @@ -9425,7 +9563,6 @@ Future _pumpAutocompleteSourceEditor( wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: onChanged ?? (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, @@ -9504,7 +9641,6 @@ Future _pumpClipboardSourceEditor( wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: onChanged ?? (_, _) {}, onTransactionalChanged: onTransactionalChanged, onUndo: onUndo, @@ -9550,6 +9686,50 @@ bool _sourceContainsLoneLf(String value) { return false; } +class _SharedSearchSettings implements LocalSettingsStore { + @override + Future> load() async => AppSettings.defaults().toJson(); + @override + Future save(Map values) async {} +} + +class _SharedSourceWorkspace extends WorkspaceController { + _SharedSourceWorkspace(this.source); + final String source; + @override + WorkspaceState build() => WorkspaceState( + workspace: Workspace( + id: 'shared', + rootPath: '/shared', + kind: WorkspaceKind.untitledMarkdown, + openedAt: DateTime(2026), + files: const [], + diagnostics: const [], + ), + documentBuffers: [ + DocumentBuffer( + id: 'shared-buffer', + text: source, + lastSavedText: '', + dirty: true, + untitledName: 'Untitled', + editorState: const DocumentEditorState( + mode: DocumentViewModePreference.source, + ), + ), + ], + activeBufferId: 'shared-buffer', + ); + void edit(String text) { + final buffer = state.activeBuffer!; + state = state.copyWith( + documentBuffers: [ + buffer.copyWith(text: text, revision: buffer.revision + 1), + ], + ); + } +} + class _SourceTestClipboard extends RichClipboardService { _SourceTestClipboard({ this.writeResult = true, diff --git a/test/src/source_search_test.dart b/test/src/source_search_test.dart index d7c18a17..cc1e0e0c 100644 --- a/test/src/source_search_test.dart +++ b/test/src/source_search_test.dart @@ -4,29 +4,69 @@ 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), + test( + 'workspace worker reuses sequential requests and cancels an active request', + () async { + final worker = SourceSearchWorker(); + addTearDown(worker.dispose); + for (final source in ['first needle', 'needle second']) { + final result = await worker.search( + SourceDocument(fullText: source), + const SourceSearchOptions(query: 'needle'), + reuseWorker: true, + ); + expect( + result!.matches.single.fullStart, + source.startsWith('needle') ? 0 : 6, + ); + } + final slow = worker.search( + SourceDocument(fullText: 'a ' * 400000), + const SourceSearchOptions(query: 'a'), + reuseWorker: 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, - ); - }); + worker.cancel(); + expect(await slow.timeout(const Duration(seconds: 5)), isNull); + final next = await worker.search( + SourceDocument(fullText: 'new needle'), + const SourceSearchOptions(query: 'needle'), + reuseWorker: true, + ); + expect(next!.matches.single.fullStart, 4); + }, + ); + test( + 'regex-looking punctuation is literal and Unicode offsets remain valid', + () { + const source = r'😀 .* ^ $ (?=😀) \q ['; + final document = SourceDocument(fullText: source); + for (final query in ['😀', '.*', '^', r'$', '(?=😀)', r'\q', '[']) { + final result = searchSourceDocument( + document, + SourceSearchOptions(query: query), + ); + expect(result.matches, isNotEmpty); + for (final match in result.matches) { + expect(source.substring(match.fullStart, match.fullEnd), query); + expect( + sourceSearchRangeHasSafeBoundaries( + source, + match.fullStart, + match.fullEnd, + ), + isTrue, + ); + } + } + expect( + searchSourceDocument( + document, + const SourceSearchOptions(query: '.'), + ).totalMatchCount, + 1, + ); + }, + ); test('workspace-style bounded matching stops after the sentinel match', () { final result = searchSourceDocument( @@ -39,14 +79,13 @@ void main() { expect(result.totalMatchCount, 81); }); - test('search worker terminates a running backtracking expression', () async { + test('search worker cancels a superseded request', () async { final worker = SourceSearchWorker(); addTearDown(worker.dispose); final slow = worker.search( - SourceDocument(fullText: '${'a' * 100}b'), - const SourceSearchOptions(query: r'^(a+)+$', regex: true), + SourceDocument(fullText: 'a ' * 400000), + const SourceSearchOptions(query: 'a'), ); - await Future.delayed(const Duration(milliseconds: 100)); final current = worker.search( SourceDocument(fullText: 'needle'), const SourceSearchOptions(query: 'needle'), @@ -68,60 +107,59 @@ void main() { expect(result.matches.map((match) => match.fullStart), [0, 6]); }); - test('case-sensitive and whole-word options constrain matches', () { + test('literal matching includes case differences and partial words', () { final document = SourceDocument(fullText: 'Alpha alpha alphabet'); expect( searchSourceDocument( document, - const SourceSearchOptions(query: 'Alpha', caseSensitive: true), + const SourceSearchOptions(query: 'Alpha'), ).totalMatchCount, - 1, + 3, ); expect( searchSourceDocument( document, - const SourceSearchOptions(query: 'alpha', wholeWord: true), + const SourceSearchOptions(query: 'alpha'), ).matches.map((match) => match.fullStart), - [0, 6], + [0, 6, 12], ); }); - test('whole-word boundaries recognize Unicode letters and marks', () { + test('Unicode substrings preserve authored source coordinates', () { final cyrillic = searchSourceDocument( SourceDocument(fullText: 'привет рив'), - const SourceSearchOptions(query: 'рив', wholeWord: true), + const SourceSearchOptions(query: 'рив'), ); final combining = searchSourceDocument( SourceDocument(fullText: 'cafe\u0301 fe'), - const SourceSearchOptions(query: 'fe', wholeWord: true), + const SourceSearchOptions(query: 'fe'), ); final cjk = searchSourceDocument( SourceDocument(fullText: '中文 文'), - const SourceSearchOptions(query: '文', wholeWord: true), + const SourceSearchOptions(query: '文'), ); - expect(cyrillic.matches.map((match) => match.fullStart), [7]); - expect(combining.matches.map((match) => match.fullStart), [6]); - expect(cjk.matches.map((match) => match.fullStart), [3]); + expect(cyrillic.matches.map((match) => match.fullStart), [1, 7]); + expect(combining.matches.map((match) => match.fullStart), [2, 6]); + expect(cjk.matches.map((match) => match.fullStart), [1, 3]); }); - test('regex search and invalid regex are safe', () { - final document = SourceDocument(fullText: 'v1 v22 vx'); - + test('regex-looking input never becomes an expression', () { + final document = SourceDocument(fullText: r'v1 v22 vx v\d+ ['); expect( searchSourceDocument( document, - const SourceSearchOptions(query: r'v\d+', regex: true), - ).matches.map((match) => match.fullEnd), - [2, 6], + const SourceSearchOptions(query: r'v\d+'), + ).matches.map((m) => m.fullStart), + [10], ); expect( searchSourceDocument( document, - const SourceSearchOptions(query: r'[', regex: true), - ).invalidRegex, - isTrue, + const SourceSearchOptions(query: '['), + ).totalMatchCount, + 1, ); }); diff --git a/test/src/spelling_editor_integration_test.dart b/test/src/spelling_editor_integration_test.dart index e5ebb955..60769147 100644 --- a/test/src/spelling_editor_integration_test.dart +++ b/test/src/spelling_editor_integration_test.dart @@ -95,7 +95,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onTransactionalChanged: (text, _, before, after, group) { transactions.add(( diff --git a/test/src/spelling_service_test.dart b/test/src/spelling_service_test.dart index 59b337b0..dd2ba7e6 100644 --- a/test/src/spelling_service_test.dart +++ b/test/src/spelling_service_test.dart @@ -5980,9 +5980,6 @@ const _spellingSettingsHeaderLabels = HeaderBarLabels( settings: 'Settings', settingsShortcut: '', settingsGtkAccelerator: '', - keyboardShortcuts: 'Keyboard shortcuts', - keyboardShortcutsShortcut: '', - keyboardShortcutsGtkAccelerator: '', syntaxReference: 'Syntax reference', syntaxReferenceShortcut: '', syntaxReferenceGtkAccelerator: '', diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 3c4054ed..4e5dac5b 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -41,6 +41,66 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + testWidgets('cancelled Recent workspace opening restores the prior buffers', ( + tester, + ) async { + final service = _ClosingAutosaveWorkspaceService(); + final creating = _createControllerHarness(service: service); + await tester.pump(Duration.zero); + final harness = await creating; + await harness.settingsController.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(service.path); + final before = controller.state; + final recents = harness.settingsController.state.recentWorkspaces; + service.pauseLoad = true; + var current = true; + final opening = controller.openPath( + service.bPath, + acceptNavigation: () => current, + ); + await tester.pump(); + expect(service.loadStarted.isCompleted, isTrue); + current = false; + service.releaseLoad.complete(); + await tester.pump(); + await opening; + expect(controller.state.workspace!.id, before.workspace!.id); + expect(controller.state.activeBufferId, before.activeBufferId); + expect(controller.state.activeText, before.activeText); + expect(controller.state.isLoading, isFalse); + expect(harness.settingsController.state.recentWorkspaces, same(recents)); + }); + testWidgets('cancelled search activation cannot publish a parsed tab', ( + 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.setAutoSave(false); + final controller = harness.controller; + await controller.openPath(service.path); + await controller.openActiveFile(service.bPath); + await controller.openActiveFile(service.path); + final original = controller.state.activeBufferId; + final recents = harness.settingsController.state.recentWorkspaces; + service.pauseActivation = true; + var current = true; + final opening = controller.openActiveFile( + service.bPath, + acceptNavigation: () => current, + ); + await tester.pump(); + expect(service.activationStarted.isCompleted, isTrue); + current = false; + service.releaseActivation.complete(); + await tester.pump(); + expect(await opening, isFalse); + expect(controller.state.activeBufferId, original); + expect(harness.settingsController.state.recentWorkspaces, same(recents)); + }); for (final change in ['session', 'source']) { testWidgets( 'clean close validates source identity during $change updates', @@ -4409,7 +4469,8 @@ class _WorkspaceControllerDriver { WorkspaceState get state => _container.read(workspaceControllerProvider); - Future openPath(String path) => _notifier.openPath(path); + Future openPath(String path, {bool Function()? acceptNavigation}) => + _notifier.openPath(path, acceptNavigation: acceptNavigation); Future createMarkdownFile() => _notifier.createMarkdownFile(); @@ -4473,7 +4534,10 @@ class _WorkspaceControllerDriver { Future deleteWritersideTopicFile(String topicPath) => _notifier.deleteWritersideTopicFile(topicPath); - Future openActiveFile(String path) => _notifier.openActiveFile(path); + Future openActiveFile( + String path, { + bool Function()? acceptNavigation, + }) => _notifier.openActiveFile(path, acceptNavigation: acceptNavigation); Future activateNextOpenFileTab() => _notifier.activateNextOpenFileTab(); @@ -4926,6 +4990,19 @@ class _ClosingAutosaveWorkspaceService extends _AutosaveWorkspaceService { var pauseActivation = false; final activationStarted = Completer(); final releaseActivation = Completer(); + bool pauseLoad = false; + final loadStarted = Completer(); + final releaseLoad = Completer(); + + @override + Future loadTextWithSnapshot(String path) async { + if (pauseLoad) { + pauseLoad = false; + loadStarted.complete(); + await releaseLoad.future; + } + return super.loadTextWithSnapshot(path); + } @override Future reparseDocument( diff --git a/test/src/workspace_document_context_test.dart b/test/src/workspace_document_context_test.dart index b3318029..4a1b794a 100644 --- a/test/src/workspace_document_context_test.dart +++ b/test/src/workspace_document_context_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/markdown/document_outline.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; @@ -6,6 +7,41 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test( + 'Outline filtering retains ancestors, document order and duplicate title identities', + () { + const headings = [ + DocumentOutlineHeading(level: 1, text: 'Root', id: 'root'), + DocumentOutlineHeading(level: 2, text: 'First branch', id: 'first'), + DocumentOutlineHeading( + level: 3, + text: 'Target', + id: 'target', + editorBlockId: 'live-a', + ), + DocumentOutlineHeading(level: 2, text: 'Unrelated', id: 'unrelated'), + DocumentOutlineHeading( + level: 2, + text: 'Target', + id: 'target-1', + editorBlockId: 'live-b', + ), + ]; + expect(matchDocumentOutline(headings, ''), isEmpty); + final filtered = matchDocumentOutline(headings, 'TARGET'); + expect(filtered.map((m) => m.heading.id), ['target', 'target-1']); + expect(filtered.map((m) => m.heading.level), [3, 2]); + expect(filtered.first.heading, same(headings[2])); + expect(filtered.last.heading.editorBlockId, 'live-b'); + expect(filtered.first.ancestors.map((h) => h.id), ['root', 'first']); + expect(filtered.last.ancestors.map((h) => h.id), ['root']); + expect( + matchDocumentOutline(headings, 'target', maximumResults: 1), + hasLength(1), + ); + expect(matchDocumentOutline(headings, 'missing'), isEmpty); + }, + ); DocumentBuffer untitled(String id) => DocumentBuffer.untitled(id: id, name: id); diff --git a/test/src/writerside_toc_source_widget_test.dart b/test/src/writerside_toc_source_widget_test.dart index 7f2eb75d..1f48c37f 100644 --- a/test/src/writerside_toc_source_widget_test.dart +++ b/test/src/writerside_toc_source_widget_test.dart @@ -34,7 +34,6 @@ void main() { wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), - onSearchOptionsChanged: (_) {}, onChanged: (_, _) {}, onTransactionalChanged: (value, _, previousSelection, selection, _) { diff --git a/tools/history_visual_smoke.dart b/tools/history_visual_smoke.dart index 76bdc0ee..04ef2689 100644 --- a/tools/history_visual_smoke.dart +++ b/tools/history_visual_smoke.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/search/search_session.dart'; // 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. @@ -29,6 +30,7 @@ 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:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -283,7 +285,11 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { ); await _capture('${widget.mode}-local-history-1280x800.png'); - await localHistory.search('Monitoring'); + final search = ref.read(searchSessionProvider.notifier); + search.openHistory( + documentId: ref.read(localHistoryControllerProvider).selectedDocumentId, + ); + search.query('Monitoring'); buffer = ref.read(workspaceControllerProvider).activeBuffer!; final current = _currentVersion(buffer.text); workspace.updateActiveText(current, sourceFilePath: buffer.filePath); @@ -306,14 +312,18 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { } await _waitFor( () => - !ref.read(localHistoryControllerProvider).searching && - ref.read(localHistoryControllerProvider).searchQuery == - 'Monitoring' && - ref.read(localHistoryControllerProvider).searchMatches.length >= 2, + !ref.read(searchSessionProvider).searching && + ref.read(searchSessionProvider).query == 'Monitoring' && + ref + .read(searchSessionProvider) + .results + .whereType() + .length >= + 2, 'live filtered Local History rows', ); await _capture('${widget.mode}-local-history-filtered-1280x800.png'); - await localHistory.search(''); + search.close(restoreFocus: false); final historyState = ref.read(localHistoryControllerProvider); final historyDocument = historyState.selectedDocument!; @@ -815,43 +825,132 @@ class _HistoryVisualHarnessState extends ConsumerState<_HistoryVisualHarness> { 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), - ), + final trace = Platform.environment['BUSYMARK_HISTORY_TRACE'] == '1' + ? ref.listenManual(localHistoryControllerProvider, (before, after) { + stdout.writeln( + 'History inspection: document=${after.selectedDocumentId} ' + 'revision=${after.selectedRevisionId} loaded=${after.selectedRevision != null} ' + 'retained=${after.inspectingRetainedDocument} loading=${after.loading} ' + 'warning=${after.warning?.kind}', + ); + if (before?.selectedRevisionId != null && + after.selectedRevisionId == null) { + stdout.writeln(StackTrace.current); + } + }) + : null; + try { + for (final view in [ + ( + BusyMarkCommandIds.viewEditor, + 'Editor', + DocumentViewModePreference.editor, + ), + ( + BusyMarkCommandIds.viewReading, + 'Reading', + DocumentViewModePreference.preview, + ), + ]) { + _check( + await ref.read(busyMarkCommandRegistryProvider).execute(view.$1), + '${view.$2} view for missing-document recovery', ); - 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(); + _check( + await workspace.openActiveFile(deletePath), + '${view.$2} recovery file open', + ); + _check( + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .mode == + view.$3, + '${view.$2} recovery view is active', + ); + _check( + await workspace.deleteWorkspaceEntity(deletePath), + '${view.$2} document delete', + ); + await _waitForEditorSourceToSettle(); + 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.inspectRetainedDocument(deletedDocument.id); + final revisionSummary = ref + .read(localHistoryControllerProvider) + .selectedRevisions + .first; + await localHistory.selectRevision(revisionSummary.id); + await _waitFor( + () => + ref.read(localHistoryControllerProvider).selectedDocumentId == + deletedDocument.id && + ref + .read(localHistoryControllerProvider) + .selectedRevision + ?.summary + .id == + revisionSummary.id, + 'exact deleted document revision selected for inspection', + ); + await _capture( + '${widget.mode}-${view.$2}-deleted-comparison-1920x1080.png', + ); + final revision = ref + .read(localHistoryControllerProvider) + .selectedRevision!; + _check( + await workspace.restoreMissingLocalHistoryRevision( + document: deletedDocument, + revision: revision, + destinationPath: deletePath, + overwriteExisting: false, + ), + '${view.$2} deleted document recovery', + ); + _check( + ref.read(workspaceControllerProvider).activeText == revision.source, + '${view.$2} recovered exact revision contents', + ); + await _capture( + '${widget.mode}-${view.$2}-deleted-recovered-1920x1080.png', + ); + _check(workspace.undoActiveBuffer(), '${view.$2} recovery Undo'); + final destinationSource = decodeUtf8Document( + revision.format.encode(''), + ).text; + _check( + ref.read(workspaceControllerProvider).activeText == destinationSource, + '${view.$2} recovery Undo restores formatted destination', + ); + _check(workspace.redoActiveBuffer(), '${view.$2} recovery Redo'); + _check( + ref.read(workspaceControllerProvider).activeText == revision.source, + '${view.$2} recovery Redo restores exact source', + ); + _check(await workspace.saveActive(), '${view.$2} recovery save'); + _check( + await File(deletePath).readAsString() == revision.source, + '${view.$2} recovered file contents', + ); + _checks['${view.$2}DeletedFileRecovered'] = true; + _checks['deletedFileRecovered'] = await File(deletePath).exists(); + } + } finally { + trace?.close(); + } } String _savedVersion(String source) => source.replaceFirst( diff --git a/tools/native_shortcut_metadata_probe.cc b/tools/native_shortcut_metadata_probe.cc new file mode 100644 index 00000000..be492f04 --- /dev/null +++ b/tools/native_shortcut_metadata_probe.cc @@ -0,0 +1,26 @@ +#include +#include "native_shortcut_metadata.h" + +int main(int argc, char** argv) { + gtk_init(&argc, &argv); + const char* inputs[] = {"comma", "U+00E9", + "U+0026", "KP_7", "Ctrl+S", "U+0410"}; + const guint keys[] = {GDK_KEY_comma, GDK_KEY_eacute, GDK_KEY_ampersand, + GDK_KEY_KP_7, GDK_KEY_s, GDK_KEY_Cyrillic_a}; + for (size_t i = 0; i < G_N_ELEMENTS(inputs); ++i) { + g_autofree gchar* canonical = gtk_accelerator_from_shortcut_label(inputs[i]); + g_assert_nonnull(canonical); + guint key = 0; + GdkModifierType modifiers = static_cast(0); + gtk_accelerator_parse(canonical, &key, &modifiers); + g_assert_cmpuint(key, ==, keys[i]); + g_assert_true((modifiers & GDK_CONTROL_MASK) != 0); + } + const char* rejected[] = {"", "U+110000", "U+D800", "U+oops", "U+4E2D"}; + for (const auto* input : rejected) { + g_autofree gchar* invalid = gtk_accelerator_from_shortcut_label(input); + g_assert_null(invalid); + } + g_print("Native shortcut metadata: 6 round trips, 5 safe rejections passed.\n"); + return 0; +} diff --git a/tools/nextcloud_notes_desktop_acceptance.dart b/tools/nextcloud_notes_desktop_acceptance.dart index 3f102972..f16a7614 100644 --- a/tools/nextcloud_notes_desktop_acceptance.dart +++ b/tools/nextcloud_notes_desktop_acceptance.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/search/search_session.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -51,6 +52,7 @@ import 'package:window_manager/window_manager.dart'; enum _Scenario { full('full'), workspace('workspace'), + unifiedSearch('unified-search'), offlineStart('offline-start'), offlineResume('offline-resume'), references('references'), @@ -183,6 +185,8 @@ Future main(List args) async { output: output, containerName: args[3], scenario: scenario, + secondaryLogin: credentials['secondaryLoginName'] as String?, + secondaryPassword: credentials['secondaryAppPassword'] as String?, ), ), ); @@ -282,20 +286,18 @@ class _Settings implements LocalSettingsStore { } class _Secrets implements NextcloudSecretStore { - _Secrets(this.id, this.password); - final String id; - String? password; + _Secrets(String id, String password) : passwords = {id: password}; + final Map passwords; @override - Future read(String accountId) async => - accountId == id ? password : null; + Future read(String accountId) async => passwords[accountId]; @override Future write(String accountId, String value) async { - password = value; + passwords[accountId] = value; } @override Future delete(String accountId) async { - password = null; + passwords.remove(accountId); } } @@ -307,6 +309,8 @@ class _Harness extends ConsumerStatefulWidget { required this.output, required this.containerName, required this.scenario, + this.secondaryLogin, + this.secondaryPassword, }); final NextcloudAccount account; final _Transport transport; @@ -314,6 +318,8 @@ class _Harness extends ConsumerStatefulWidget { final Directory output; final String containerName; final _Scenario scenario; + final String? secondaryLogin; + final String? secondaryPassword; bool get resumeOffline => scenario == _Scenario.offlineResume; bool get stopOffline => scenario == _Scenario.offlineStart; bool get correctionsStart => scenario == _Scenario.recoveryStart; @@ -362,9 +368,28 @@ class _HarnessState extends ConsumerState<_Harness> { } } + SearchSessionState get searchState => ref.read(searchSessionProvider); + Future openSidebarSearch( + SearchCategory category, { + String query = '', + String? account, + bool recovery = false, + }) async { + if (account != null && + ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId != + account) { + throw StateError('Search account changed.'); + } + final controller = ref.read(searchSessionProvider.notifier); + controller.open(category: category, recovery: recovery); + controller.query(query); + await WidgetsBinding.instance.endOfFrame; + } + List widgets() { final result = []; void visit(Element e) { + if (e.widget is Offstage && (e.widget as Offstage).offstage) return; if (e.widget is T) result.add(e.widget as T); e.visitChildren(visit); } @@ -770,13 +795,15 @@ class _HarnessState extends ConsumerState<_Harness> { return; } } - final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + final quick = openSidebarSearch(SearchCategory.documents); await wait( - () => widgets().isNotEmpty, + () => + (searchState.active && + searchState.category == SearchCategory.documents), 'quick open dialog', ); widgets() - .firstWhere((f) => f.decoration?.labelText == 'Quick Open') + .firstWhere((f) => f.decoration?.hintText == 'Search Documents…') .onChanged!('Workspace desktop'); await wait( () => widgets().any( @@ -801,13 +828,14 @@ class _HarnessState extends ConsumerState<_Harness> { draft.localId, 'workspaceQuickOpenStableIdentity', ); - final search = showNotesSearch( - rootNavigatorKey.currentContext!, - ref, + final search = openSidebarSearch( + SearchCategory.workspace, query: '"alpha beta" category:Workspace', ); await wait( - () => widgets().isNotEmpty, + () => + (searchState.active && + searchState.category == SearchCategory.workspace), 'notes search dialog', ); await wait( @@ -1466,7 +1494,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); edit( widgets().firstWhere( - (f) => f.decoration?.labelText == 'Quick Open', + (f) => f.decoration?.hintText == 'Search Documents…', ), search, ); @@ -1638,7 +1666,7 @@ class _HarnessState extends ConsumerState<_Harness> { ); edit( widgets().firstWhere( - (f) => f.decoration?.labelText == 'Quick Open', + (f) => f.decoration?.hintText == 'Search Documents…', ), 'References portable target', ); @@ -1798,9 +1826,8 @@ class _HarnessState extends ConsumerState<_Harness> { } Future correctionSearch(String query, List counts) async { - final searching = showNotesSearch( - rootNavigatorKey.currentContext!, - ref, + final searching = openSidebarSearch( + SearchCategory.workspace, account: widget.account.id, query: query, ); @@ -1808,13 +1835,13 @@ class _HarnessState extends ConsumerState<_Harness> { await wait( () => widgets().any( (l) => - l.key == const ValueKey('notes-search-results') && + l.key == const ValueKey('sidebar-search-results') && l.childrenDelegate.estimatedChildCount == count, ), 'correction results $count', ); final list = widgets().singleWhere( - (l) => l.key == const ValueKey('notes-search-results'), + (l) => l.key == const ValueKey('sidebar-search-results'), ); final delegate = list.childrenDelegate as SliverChildBuilderDelegate; final keys = {}; @@ -1831,10 +1858,12 @@ class _HarnessState extends ConsumerState<_Harness> { !widgets().any((t) => t.data == 'Show more results'), 'correctionSearchNoHiddenFinalOccurrences${counts.last}', ); - Navigator.of(rootNavigatorKey.currentContext!).pop(); + ref.read(searchSessionProvider.notifier).close(); await searching; await wait( - () => widgets().isEmpty, + () => + (!searchState.active || + searchState.category != SearchCategory.workspace), 'correction search route closed', ); } @@ -1890,20 +1919,21 @@ class _HarnessState extends ConsumerState<_Harness> { .activeBuffer ?.remoteNote ?.localId; - widgets() - .firstWhere((f) => f.readOnly && f.decoration?.hintText == 'Search') - .onTap!(); + ref + .read(searchSessionProvider.notifier) + .open(category: SearchCategory.workspace, recovery: true); await wait( - () => widgets().isNotEmpty, + () => + (searchState.active && + searchState.category == SearchCategory.workspace), 'correction Recovery Search', ); - check( - widgets().single.recovery, - 'correctionRecoverySearchExplicitScope', - ); + check(searchState.recovery, 'correctionRecoverySearchExplicitScope'); edit( widgets().firstWhere( - (f) => f.controller != null && f.decoration?.labelText == 'Search', + (f) => + f.controller != null && + f.decoration?.hintText == 'Search in Workspace…', ), 'retainedcorrectionneedle', ); @@ -1918,7 +1948,9 @@ class _HarnessState extends ConsumerState<_Harness> { ); widgets().firstWhere(recoveryHit).onTap!(); await wait( - () => widgets().isEmpty, + () => + (!searchState.active || + searchState.category != SearchCategory.workspace), 'correction Recovery search route closed', ); await wait( @@ -2288,10 +2320,11 @@ class _HarnessState extends ConsumerState<_Harness> { await capture('workspace-10000-sidebar'); Future quickSample(String? screenshot) async { final watch = Stopwatch()..start(); - final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + final quick = openSidebarSearch(SearchCategory.documents); await wait( () => - widgets().isNotEmpty && + (searchState.active && + searchState.category == SearchCategory.documents) && widgets().any( (t) => t.title is Text && @@ -2301,10 +2334,12 @@ class _HarnessState extends ConsumerState<_Harness> { ); watch.stop(); if (screenshot != null) await capture(screenshot); - Navigator.of(rootNavigatorKey.currentContext!).pop(); + ref.read(searchSessionProvider.notifier).close(); await quick; await wait( - () => widgets().isEmpty, + () => + (!searchState.active || + searchState.category != SearchCategory.documents), 'quick open dismissal before the next sample', ); return watch.elapsedMilliseconds; @@ -2336,19 +2371,19 @@ class _HarnessState extends ConsumerState<_Harness> { await querying; check(search.state.hits.isNotEmpty, 'workspaceLargeUiSearchDuringEdit'); await search.dispose(); - final dialog = showNotesSearch( - rootNavigatorKey.currentContext!, - ref, + final dialog = openSidebarSearch( + SearchCategory.workspace, query: repository.notes.first.title, ); await wait( () => - widgets().isNotEmpty && + (searchState.active && + searchState.category == SearchCategory.workspace) && widgets().any((b) => b.tooltip == 'Source location'), 'large indexed search UI', ); await capture('workspace-10000-search'); - Navigator.of(rootNavigatorKey.currentContext!).pop(); + ref.read(searchSessionProvider.notifier).close(); await dialog; await File('${widget.output.path}/ui-performance.json').writeAsString( jsonEncode({ @@ -2367,11 +2402,16 @@ class _HarnessState extends ConsumerState<_Harness> { NextcloudNote note, ) async { await workspace.openNextcloudNote(note.localId); - await nativeShortcut('ctrl+p'); - await wait(() => widgets().isNotEmpty, 'native Ctrl+P'); + await nativeShortcut('ctrl+shift+d'); + await wait( + () => + (searchState.active && + searchState.category == SearchCategory.documents), + 'native Ctrl+Shift+D', + ); edit( widgets().firstWhere( - (f) => f.decoration?.labelText == 'Quick Open', + (f) => f.decoration?.hintText == 'Search Documents…', ), note.title, ); @@ -2384,14 +2424,18 @@ class _HarnessState extends ConsumerState<_Harness> { // The imported copy has the same title/category. Capture the selected // document identity before Enter instead of guessing from duplicate titles. final selectedIdentity = rankQuickOpen( - widgets().single.documents, + searchState.results + .whereType() + .map((r) => r.document) + .toList(), note.title, ).first.localId; await capture('workspace-restart-native-quick-open'); await nativeShortcut('enter'); await wait( () => - widgets().isEmpty && + (!searchState.active || + searchState.category != SearchCategory.documents) && ref .read(workspaceControllerProvider) .activeBuffer @@ -2401,24 +2445,35 @@ class _HarnessState extends ConsumerState<_Harness> { 'native Quick Open Enter', ); check(true, 'nativeCtrlPEnterStableIdentity'); - await nativeShortcut('ctrl+p'); + await nativeShortcut('ctrl+shift+d'); await wait( - () => widgets().isNotEmpty, + () => + (searchState.active && + searchState.category == SearchCategory.documents), 'Quick Open reopened', ); await nativeShortcut('escape'); - await wait(() => widgets().isEmpty, 'Quick Open Escape'); + await wait( + () => + (!searchState.active || + searchState.category != SearchCategory.documents), + 'Quick Open Escape', + ); check(true, 'nativeQuickOpenEscape'); await nativeShortcut('ctrl+shift+p'); await wait( () => - widgets().any((t) => t.data == 'Command Palette') && - widgets().isEmpty, + (searchState.active && + searchState.category == SearchCategory.actions) && + (!searchState.active || + searchState.category != SearchCategory.documents), 'native Ctrl+Shift+P', ); await nativeShortcut('escape'); await wait( - () => !widgets().any((t) => t.data == 'Command Palette'), + () => + !(searchState.active && + searchState.category == SearchCategory.actions), 'command palette Escape', ); check(true, 'nativeCtrlShiftPCommandPalette'); @@ -2430,8 +2485,13 @@ class _HarnessState extends ConsumerState<_Harness> { await file.writeAsString('# Local Markdown\nlocal search smoke'); await workspace.openFolder(folder.path); await workspace.openActiveFile(file.path); - final quick = showQuickOpen(rootNavigatorKey.currentContext!, ref); - await wait(() => widgets().isNotEmpty, 'local Quick Open'); + final quick = openSidebarSearch(SearchCategory.documents); + await wait( + () => + (searchState.active && + searchState.category == SearchCategory.documents), + 'local Quick Open', + ); await wait( () => widgets().any( (t) => t.title is Text && (t.title as Text).data == 'local', @@ -2462,10 +2522,11 @@ class _HarnessState extends ConsumerState<_Harness> { await workspace.openFolder(writerside.path); await workspace.openActiveFile('${writerside.path}/topics/intro.md'); await localSearchSmoke('Introduction', 'workspace-writerside-search'); - final writerQuick = showQuickOpen(rootNavigatorKey.currentContext!, ref); + final writerQuick = openSidebarSearch(SearchCategory.documents); await wait( () => - widgets().isNotEmpty && + (searchState.active && + searchState.category == SearchCategory.documents) && widgets().any( (t) => t.title is Text && (t.title as Text).data == 'install', ), @@ -2495,7 +2556,7 @@ class _HarnessState extends ConsumerState<_Harness> { await wait( () => widgets().any( (f) => - f.decoration?.hintText == 'Search' && + f.decoration?.hintText == 'Search this document…' && f.onChanged != null && f.controller != null, ), @@ -2504,7 +2565,7 @@ class _HarnessState extends ConsumerState<_Harness> { edit( widgets().lastWhere( (f) => - f.decoration?.hintText == 'Search' && + f.decoration?.hintText == 'Search this document…' && f.onChanged != null && f.controller != null, ), @@ -2515,7 +2576,7 @@ class _HarnessState extends ConsumerState<_Harness> { 'local indexed result $query', ); await capture(screenshot); - ref.read(workspaceSearchCloseRequestProvider.notifier).request(); + ref.read(searchSessionProvider.notifier).close(); check(true, screenshot); } @@ -2531,6 +2592,207 @@ class _HarnessState extends ConsumerState<_Harness> { } } + Future unifiedSearchJourney(WorkspaceController workspace) async { + Future query(String value) async { + await wait( + () => widgets().any( + (f) => + f.decoration?.hintText?.startsWith('Search ') == true && + f.onChanged != null, + ), + 'shared query', + ); + edit( + widgets().singleWhere( + (f) => + f.decoration?.hintText?.startsWith('Search ') == true && + f.onChanged != null, + ), + value, + ); + await wait(() => !searchState.searching, 'completed shared search'); + check(searchState.error == null, 'unifiedQueryHasNoError'); + } + + final note = await repository.create( + widget.account.id, + title: 'Connected shared search', + category: 'Qualification/Search', + content: '# Connected\nunique-connected-body', + ); + await repository.synchronize(widget.account.id); + created.add(repository.noteById(note.localId)!.serverId!); + check( + await workspace.openNextcloudNote(note.localId), + 'connectedOpenIndividualNote', + ); + await nativeShortcut('ctrl+f'); + await query('unique-connected-body'); + check( + searchState.fileActive && searchState.results.single is TextSearchResult, + 'connectedCtrlFUsesOnlyNoteBuffer', + ); + await nativeShortcut('ctrl+shift+f'); + await query('unique-connected-body'); + check( + searchState.results.whereType().single.hit.localId == + note.localId, + 'connectedSynchronizedBodySearch', + ); + await nativeShortcut('ctrl+shift+d'); + await query('unique-connected-body'); + check(searchState.results.isEmpty, 'connectedDocumentsExcludesBodies'); + await query('Qualification/Search'); + check( + searchState.results + .whereType() + .single + .document + .localId == + note.localId, + 'connectedDocumentsUsesCategories', + ); + await nativeShortcut('escape'); + widget.transport.offline = true; + check( + await workspace.createNextcloudNote( + title: 'Offline unified identity', + content: 'offline-connected-needle', + ), + 'connectedOfflineCreation', + ); + final localId = ref + .read(workspaceControllerProvider) + .activeBuffer! + .remoteNote! + .localId; + check( + repository.noteById(localId)!.serverId == null, + 'connectedOfflineHasNoServerId', + ); + check(await workspace.saveActive(), 'connectedOfflineDurability'); + widget.transport.offline = false; + await repository.synchronize(widget.account.id); + final synchronized = repository.noteById(localId)!; + check(synchronized.serverId != null, 'connectedOfflineLaterSynchronized'); + created.add(synchronized.serverId!); + check( + ref + .read(appSettingsControllerProvider) + .recentWorkspaces + .where( + (r) => r.accountId == widget.account.id && r.localId == localId, + ) + .length == + 1, + 'connectedRecentStableLocalIdentity', + ); + await nativeShortcut('ctrl+shift+f'); + await query('offline-connected-needle'); + check( + searchState.results.whereType().single.hit.localId == + localId, + 'connectedSearchRefreshAfterSynchronization', + ); + + final login = widget.secondaryLogin; + final password = widget.secondaryPassword; + if (login == null || password == null) { + throw StateError('unified-search requires a second disposable account'); + } + final capabilities = await fetchNotesCapabilities( + client: widget.transport, + server: widget.account.server, + loginName: login, + appPassword: password, + ); + final other = NextcloudAccount( + id: const Uuid().v4(), + server: widget.account.server, + loginName: login, + appVersion: capabilities.appVersion, + apiVersion: capabilities.apiVersion, + ); + await ref.read(nextcloudSecretStoreProvider).write(other.id, password); + await repository.upsertAccount(other); + final second = await repository.create( + other.id, + title: 'Connected shared search', + content: 'other-account-only-needle', + ); + await repository.synchronize(other.id); + final otherApi = NotesApiClient( + client: widget.transport, + account: other, + appPassword: password, + ); + try { + ref.read(searchSessionProvider.notifier).query('unique-connected-body'); + check( + await workspace.openNextcloudWorkspace(other.id), + 'connectedSwitchAccountDuringSearch', + ); + await query('unique-connected-body'); + check(searchState.results.isEmpty, 'connectedSearchAccountIsolation'); + await query('other-account-only-needle'); + check( + searchState.results.whereType().single.accountId == + other.id, + 'connectedSecondAccountSearch', + ); + await nativeShortcut('ctrl+shift+r'); + await query('Offline unified identity'); + await nativeShortcut('enter'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + localId, + 'connected Recent direct note reopening', + ); + check( + ref.read(workspaceControllerProvider).workspace?.nextcloudAccountId == + widget.account.id, + 'connectedRecentPreservesOriginatingAccount', + ); + await capture('connected-recent-note'); + // Remove only these fixture notes before revoking the fixture credential. + for (final id in created.toList()) { + await api.delete(id); + } + created.clear(); + original = null; + check( + await ref.read(nextcloudConnectionProvider.notifier).disconnect(), + 'connectedAccountRemoval', + ); + check( + ref + .read(appSettingsControllerProvider) + .recentWorkspaces + .every((r) => r.accountId != widget.account.id), + 'connectedRemovalClearsRecent', + ); + check( + await workspace.openNextcloudWorkspace(other.id), + 'connectedRemainingAccountAvailable', + ); + await nativeShortcut('ctrl+shift+r'); + await query('Offline unified identity'); + check( + searchState.results.isEmpty && + repository.accounts.every((a) => a.id != widget.account.id), + 'connectedRemovedAccountDoesNotReconnectOrFallback', + ); + } finally { + final remote = repository.noteById(second.localId)?.serverId; + if (remote != null) await otherApi.delete(remote); + } + } + Future run() async { Object? failure; StackTrace? failureStack; @@ -2572,6 +2834,8 @@ class _HarnessState extends ConsumerState<_Harness> { await correctionJourney(workspace); case _Scenario.performance: await performanceJourney(workspace); + case _Scenario.unifiedSearch: + await unifiedSearchJourney(workspace); case _Scenario.workspace || _Scenario.offlineStart || _Scenario.offlineResume: diff --git a/tools/nextcloud_notes_native_key.py b/tools/nextcloud_notes_native_key.py index 1e851b7b..4402f769 100644 --- a/tools/nextcloud_notes_native_key.py +++ b/tools/nextcloud_notes_native_key.py @@ -11,15 +11,45 @@ if os.environ.get('BUSYMARK_NATIVE_ACCEPTANCE') != '1': raise SystemExit('Only the isolated Notes desktop acceptance harness may invoke this helper.') bindings = { + 'ctrl+shift+h': [b'Control_L', b'Shift_L', b'h'], + 'ctrl+shift+d': [b'Control_L', b'Shift_L', b'd'], + 'ctrl+shift+r': [b'Control_L', b'Shift_L', b'r'], + 'ctrl+shift+l': [b'Control_L', b'Shift_L', b'l'], + 'ctrl+c': [b'Control_L', b'c'], + 'ctrl+space': [b'Control_L', b'space'], + 'ctrl+shift+4': [b'Control_L', b'Shift_L', b'4'], + 'ctrl+o': [b'Control_L', b'o'], + 'F8': [b'F8'], + 'ctrl+a': [b'Control_L', b'a'], + 'alt+enter': [b'Alt_L', b'Return'], + 'ctrl+alt+h': [b'Control_L', b'Alt_L', b'h'], + 'ctrl+alt+s': [b'Control_L', b'Alt_L', b's'], + 'alt+Left': [b'Alt_L', b'Left'], + 'ctrl+b': [b'Control_L', b'b'], + 'altgr+e': [b'ISO_Level3_Shift', b'e'], + 'ctrl+shift+a': [b'Control_L', b'Shift_L', b'a'], + 'ctrl+alt+y': [b'Control_L', b'Alt_L', b'y'], + 'ctrl+alt+b': [b'Control_L', b'Alt_L', b'b'], + 'F12': [b'F12'], 'ctrl+1': [b'Control_L', b'1'], 'ctrl+2': [b'Control_L', b'2'], 'ctrl+f': [b'Control_L', b'f'], + 'ctrl+shift+f': [b'Control_L', b'Shift_L', b'f'], + 'ctrl+3': [b'Control_L', b'3'], + 'ctrl+5': [b'Control_L', b'5'], + 'ctrl+shift+t': [b'Control_L', b'Shift_L', b't'], + 'ctrl+alt+o': [b'Control_L', b'Alt_L', b'o'], + 'ctrl+alt+k': [b'Control_L', b'Alt_L', b'k'], + 'ctrl+shift+1': [b'Control_L', b'Shift_L', b'1'], + 'ctrl+shift+2': [b'Control_L', b'Shift_L', b'2'], 'ctrl+k': [b'Control_L', b'k'], 'ctrl+p': [b'Control_L', b'p'], 'ctrl+shift+p': [b'Control_L', b'Shift_L', b'p'], 'escape': [b'Escape'], 'home': [b'Home'], 'down': [b'Down'], + 'tab': [b'Tab'], + 'shift+tab': [b'Shift_L', b'Tab'], 'ctrl+z': [b'Control_L', b'z'], 'ctrl+shift+z': [b'Control_L', b'Shift_L', b'z'], 'enter': [b'Return'], diff --git a/tools/sidebar_search_smoke.dart b/tools/sidebar_search_smoke.dart new file mode 100644 index 00000000..1f55ffdb --- /dev/null +++ b/tools/sidebar_search_smoke.dart @@ -0,0 +1,2866 @@ +import 'package:busymark/src/platform/header_bar_configuration.dart'; +import 'package:busymark/src/workspace/presentation/keyboard_shortcuts_settings.dart'; +import 'package:busymark/src/workspace/presentation/settings_screen.dart'; +// Native Linux search workflows with disposable files and a cached offline +// Notes account. All artifacts and navigation history stay in PRIVATE_OUTPUT. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/app/busymark_search_field.dart'; +import 'package:busymark/src/app/startup_path.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/source_highlighter.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/local_history/local_history_controller.dart'; +import 'package:busymark/src/local_history/local_history_store.dart'; +import 'package:busymark/src/local_history/local_history_models.dart'; +import 'package:busymark/src/local_history/local_history_panel.dart'; +import 'package:busymark/src/clipboard/clipboard_history_panel.dart'; +import 'package:busymark/src/clipboard/clipboard_history_controller.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/workspace/text_format_metadata.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/search/search_sidebar.dart'; +import 'package:busymark/src/app/command_registry.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.dart'; +import 'package:busymark/src/app/shortcut_capture.dart'; +import 'package:busymark/src/nextcloud_notes/application/nextcloud_connection.dart'; +import 'package:busymark/src/nextcloud_notes/application/notes_repository.dart'; +import 'package:busymark/src/nextcloud_notes/data/notes_store.dart'; +import 'package:busymark/src/nextcloud_notes/domain/notes_models.dart'; +import 'package:busymark/src/search/search_session.dart'; +import 'package:busymark/src/search/welcome_notes_action.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:window_manager/window_manager.dart'; +import 'package:yaru/yaru.dart'; + +Future main(List args) async { + if (args.isEmpty || + args.length > 2 || + args.length == 2 && args.last != '--large') { + throw ArgumentError('PRIVATE_OUTPUT [--large]'); + } + final large = args.length == 2; + WidgetsFlutterBinding.ensureInitialized(); + FlutterError.onError = (details) { + FlutterError.presentError(details); + exitCode = 1; + }; + ui.PlatformDispatcher.instance.onError = (error, stack) { + stderr.writeln('$error\n$stack'); + exitCode = 1; + return true; + }; + await windowManager.ensureInitialized(); + await YaruWindow.ensureInitialized(); + await windowManager.setSize(const Size(1100, 850)); + final output = await Directory(args.first).create(recursive: true); + final store = await NotesStore.open(path: '${output.path}/notes.sqlite3'); + const account = '123e4567-e89b-42d3-a456-426614174000'; + await store.saveAccount( + NextcloudAccount( + id: account, + server: Uri.parse('https://offline.example.invalid'), + loginName: 'cached-fixture', + appVersion: '6.1.0', + ), + ); + await store.saveNote( + const NextcloudNote( + localId: '223e4567-e89b-42d3-a456-426614174000', + accountId: account, + title: 'Cached individual note', + category: 'Work/Offline', + content: 'cached needle in an unopened note', + revision: 1, + ackRevision: 1, + ), + ); + await store.saveAccount( + NextcloudAccount( + id: '423e4567-e89b-42d3-a456-426614174001', + server: Uri.parse('https://other-offline.example.invalid'), + loginName: 'other-cached-fixture', + appVersion: '6.1.0', + ), + ); + await store.saveNote( + const NextcloudNote( + localId: '523e4567-e89b-42d3-a456-426614174001', + accountId: '423e4567-e89b-42d3-a456-426614174001', + title: 'Cached individual note', + category: 'Work/Offline', + content: 'other account confidential needle', + revision: 1, + ackRevision: 1, + ), + ); + if (large) { + for (var batch = 0; batch < 10000; batch += 100) { + await store.commit( + notes: [ + for (var i = batch; i < batch + 100; i++) + NextcloudNote( + localId: + '323e4567-e89b-42d3-a456-${i.toString().padLeft(12, '0')}', + accountId: account, + serverId: i + 1, + title: 'Duplicate ${i % 250}', + category: 'Area ${i % 20}/Group ${i % 71}', + content: List.generate( + 14, + (line) => + 'Row $line: café 中文 日本語 Привет مرحبا Ελληνικά. ' + 'Independent alpha beta record-$i corpus-token${i % 97}.', + ).join('\n'), + revision: 1, + ackRevision: 1, + ), + ], + ); + } + } + final repository = NotesRepository( + store: store, + clientForAccount: (_) async => throw const NotesException( + NotesFailureCode.network, + 'Offline fixture', + scope: NotesRequestScope.account, + ), + ); + await repository.initialize(); + runApp( + ProviderScope( + overrides: [ + startupPathProvider.overrideWithValue(null), + localSettingsStoreProvider.overrideWithValue(_Settings()), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + workspaceServiceProvider.overrideWithValue(_TraceWorkspaceService()), + localHistoryStoreProvider.overrideWithValue( + _TraceHistoryStore( + rootDirectory: () async => Directory('${output.path}/history'), + ), + ), + nextcloudNotesRepositoryProvider.overrideWith( + (ref) async => repository, + ), + ], + child: _Driver(output, repository, large), + ), + ); +} + +class _Settings implements LocalSettingsStore { + bool failSaves = false; + Map value = AppSettings.defaults() + .copyWith( + localeTag: 'en', + themeModePreference: BusyMarkThemeModePreference.light, + autoSave: false, + automaticSpelling: false, + reopenPreviousWorkspaceOnStartup: false, + confirmCloseWithUnsavedChanges: false, + ) + .toJson(); + @override + Future> load() async => value; + @override + Future save(Map json) async { + if (failSaves) { + throw const FileSystemException('Disposable settings fixture'); + } + value = json; + } +} + +class _TraceWorkspaceService extends WorkspaceService { + int reads = 0; + int readMicroseconds = 0; + @override + Future loadText(String path) async { + reads++; + final watch = Stopwatch()..start(); + try { + return await super.loadText(path); + } finally { + readMicroseconds += watch.elapsedMicroseconds; + } + } +} + +class _TraceHistoryStore extends FileLocalHistoryStore { + _TraceHistoryStore({required super.rootDirectory}); + int reads = 0; + @override + Future readRevision(String revisionId) { + reads++; + return super.readRevision(revisionId); + } +} + +class _Driver extends ConsumerStatefulWidget { + const _Driver(this.output, this.repository, this.large); + final Directory output; + final NotesRepository repository; + final bool large; + @override + ConsumerState<_Driver> createState() => _DriverState(); +} + +class _DriverState extends ConsumerState<_Driver> { + final boundary = GlobalKey(); + final measurements = {}; + double _textScale = 1; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(run())); + } + + @override + Widget build(BuildContext context) => MediaQuery( + data: MediaQueryData.fromView( + View.of(context), + ).copyWith(textScaler: TextScaler.linear(_textScale)), + child: RepaintBoundary(key: boundary, child: const BusyMarkApp()), + ); + SearchSessionState get search => ref.read(searchSessionProvider); + WorkspaceController get workspace => + ref.read(workspaceControllerProvider.notifier); + List widgets() { + final result = []; + void visit(Element e) { + if (e.widget is Offstage && (e.widget as Offstage).offstage) return; + if (e.widget is T) result.add(e.widget as T); + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return result; + } + + void check(bool value, String message) { + if (!value) throw StateError(message); + stdout.writeln('PASS $message'); + } + + Rect boundsForKey(Key key) { + Rect? bounds; + void visit(Element element) { + if (element.widget.key == key) { + final box = element.findRenderObject()! as RenderBox; + bounds = box.localToGlobal(Offset.zero) & box.size; + } else { + element.visitChildren(visit); + } + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return bounds ?? (throw StateError('Missing mounted bounds for $key')); + } + + Future wait(bool Function() test, String description) async { + final end = DateTime.now().add(const Duration(seconds: 30)); + while (!test()) { + if (DateTime.now().isAfter(end)) { + throw StateError( + 'Timed out: $description; ${search.error}; ' + 'route=${ref.read(appRouterProvider).routeInformationProvider.value.uri}; ' + 'focus=${FocusManager.instance.primaryFocus}; ' + 'pressed=${HardwareKeyboard.instance.logicalKeysPressed}', + ); + } + await Future.delayed(const Duration(milliseconds: 30)); + } + } + + Future key(String key, {bool verifySearch = true}) async { + final focusRequest = search.focusRequest; + await windowManager.focus(); + final result = await Process.run( + 'python3', + ['tools/nextcloud_notes_native_key.py', key], + environment: {'BUSYMARK_NATIVE_ACCEPTANCE': '1'}, + ); + check(result.exitCode == 0, 'native $key delivery'); + await Future.delayed(const Duration(milliseconds: 100)); + if (verifySearch && + const { + 'ctrl+f', + 'ctrl+shift+f', + 'ctrl+shift+d', + 'ctrl+shift+t', + 'ctrl+shift+r', + 'ctrl+shift+h', + 'ctrl+shift+l', + 'ctrl+shift+p', + }.contains(key)) { + check( + search.focusRequest == focusRequest + 1, + 'native $key opens search exactly once ' + '($focusRequest → ${search.focusRequest})', + ); + } + } + + Future query(String text) async { + await wait( + () => widgets().any( + (f) => f.key == const ValueKey('shared-search-query'), + ), + 'shared header query mounted', + ); + changeQuery(text); + await wait(() => !search.searching, 'query $text'); + check(search.error == null, 'query $text completed without error'); + await WidgetsBinding.instance.endOfFrame; + } + + void changeQuery(String text) { + final field = widgets().singleWhere( + (f) => f.key == const ValueKey('shared-search-query'), + ); + field.controller!.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + field.onChanged!(text); + } + + // Provider events measure application work, excluding native injection and + // the harness's polling intervals. Debounce remains part of user latency. + Future> measuredQuery(String text) async { + final files = ref.read(workspaceServiceProvider) as _TraceWorkspaceService; + final history = ref.read(localHistoryStoreProvider) as _TraceHistoryStore; + final reads = files.reads, historyReads = history.reads; + final io = files.readMicroseconds; + int? first, completed; + var started = false; + final watch = Stopwatch(); + final listener = ref.listenManual(searchSessionProvider, (_, state) { + if (state.query != text) return; + if (state.searching) started = true; + if (started && state.results.isNotEmpty) { + first ??= watch.elapsedMicroseconds; + } + if (started && !state.searching) completed ??= watch.elapsedMicroseconds; + }); + try { + watch.start(); + changeQuery(text); + await wait(() => completed != null, 'measured query $text'); + check(search.error == null, 'measured query succeeds'); + return { + 'firstUsefulUs': first, + 'completedUs': completed, + 'fileReads': files.reads - reads, + 'summedFileReadUs': files.readMicroseconds - io, + 'revisionReads': history.reads - historyReads, + }; + } finally { + listener.close(); + } + } + + Future measuredCancellation( + String label, { + bool history = false, + }) async { + final files = ref.read(workspaceServiceProvider) as _TraceWorkspaceService; + final revisions = ref.read(localHistoryStoreProvider) as _TraceHistoryStore; + int reads() => history ? revisions.reads : files.reads; + final before = reads(); + changeQuery('cancel-no-match-$label'); + await wait(() => reads() > before, 'active $label scan'); + final atSwitch = reads(); + final watch = Stopwatch()..start(); + int? first, complete; + final subscription = ref.listenManual(searchSessionProvider, (_, value) { + if (value.category != SearchCategory.actions) return; + first ??= watch.elapsedMicroseconds; + if (!value.searching && value.results.isNotEmpty) { + complete ??= watch.elapsedMicroseconds; + } + }); + widgets>().single.onSelected( + SearchCategory.actions, + ); + try { + await wait(() => complete != null, 'Actions after $label cancellation'); + await Future.delayed(const Duration(milliseconds: 180)); + check( + search.results.every((r) => r is ActionSearchResult), + '$label cancellation rejects stale progress and completion', + ); + check(reads() == atSwitch, '$label cancellation starts no further reads'); + measurements['${label}Cancellation'] = { + 'categoryChangedUs': first, + 'actionsCompleteUs': complete, + 'readsBeforeSwitch': atSwitch - before, + 'readsAfterSwitch': reads() - atSwitch, + }; + } finally { + subscription.close(); + } + } + + Future historyJourney() async { + final store = ref.read(localHistoryStoreProvider); + final path = '${widget.output.path}/retired.md'; + final now = DateTime.now().toUtc(); + for (var i = 0; i < 20; i++) { + await store.capture( + LocalHistoryCaptureRequest( + path: path, + displayName: 'Retired document', + source: i.isEven + ? 'historical unique-needle $i' + : 'another revision $i', + format: TextFormatMetadata.utf8Lf, + capturedAt: now.add(Duration(seconds: i)), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + await store.remapPath(path, '${widget.output.path}/renamed.md'); + await store.markDeleted( + '${widget.output.path}/renamed.md', + recursive: false, + ); + final history = ref.read(localHistoryControllerProvider.notifier); + await history.refresh(); + final before = ref.read(workspaceControllerProvider).activeBuffer!; + await key('ctrl+shift+p'); + await query('Bold'); + await key('ctrl+f'); + await query('target'); + check( + search.fileActive, + 'Actions to Ctrl+F selects only Search in Document', + ); + await key('ctrl+shift+f'); + await query('scope needle'); + await key('ctrl+3'); + check(!search.active, 'ordinary Outline closes workspace search'); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.findLocalHistory); + await wait( + () => search.active && search.category == SearchCategory.localHistory, + 'Find in Local History opens shared search', + ); + await WidgetsBinding.instance.endOfFrame; + measurements['historySearchApplicationTiming'] = [ + await measuredQuery('unique-needle'), + await measuredQuery('another revision'), + await measuredQuery('unique-needle'), + ]; + check( + search.results.length == 10, + 'all retained matching revisions discovered', + ); + await measuredCancellation('history', history: true); + ref.read(searchSessionProvider.notifier).openHistory(); + await query('unique-needle'); + final result = search.results.first as HistorySearchResult; + final revision = (result.match as LocalHistoryRevisionMatch).revision; + await key('enter'); + await wait( + () => + ref + .read(localHistoryControllerProvider) + .selectedRevision + ?.summary + .id == + revision.id, + 'exact retained revision comparison', + ); + check(search.active, 'revision comparison keeps sidebar search'); + check( + ref.read(workspaceControllerProvider).activeText == before.text && + ref.read(workspaceControllerProvider).activeBufferId == before.id, + 'history inspection never opens or restores a live document', + ); + await key('ctrl+5'); + await wait( + () => + !search.active && + widgets().every( + (f) => f.key != const ValueKey('local-history-search'), + ), + 'ordinary History timeline', + ); + check( + ref.read(localHistoryControllerProvider).selectedRevisions.length == 20, + 'ordinary History retains every revision without a hidden filter', + ); + ref.read(searchSessionProvider.notifier).toggle(); + await wait(() => !search.searching, 'return to remembered History query'); + check( + search.query == 'unique-needle' && search.results.length == 10, + 'History query remains in shared session', + ); + await key('ctrl+shift+r'); + await query('a.md'); + await key('enter'); + await wait( + () => !search.active, + 'Recent document activation after history', + ); + check( + ref.read(localHistoryControllerProvider).selectedRevision == null, + 'Recent opening reveals editor instead of stale history comparison', + ); + await writeMeasurements(); + } + + Future rapidChanges(String label, String finalQuery) async { + for (var i = 0; i < 20; i++) { + changeQuery('rapid-$i'); + await Future.delayed(const Duration(milliseconds: 10)); + } + measurements['${label}AfterRapidChangesApplicationTiming'] = + await measuredQuery(finalQuery); + changeQuery('$finalQuery stale'); + await Future.delayed(const Duration(milliseconds: 140)); + final cancelled = Stopwatch()..start(); + await key('ctrl+shift+p'); + await query('Bold'); + measurements['${label}SwitchToActionsWithNativeInjectionUs'] = + cancelled.elapsedMicroseconds; + check( + search.category == SearchCategory.actions && + search.results.every((r) => r is ActionSearchResult), + 'no stale $label results after category switch', + ); + await key('ctrl+shift+f'); + await query(finalQuery); + await writeMeasurements(); + } + + Future screenshot(String name) async { + await WidgetsBinding.instance.endOfFrame; + final render = + boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await render.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await File( + '${widget.output.path}/$name.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + } + + Future captureNativeDisplay(String name, {Offset? hover}) async { + final capture = await Process.run('/usr/bin/python3', [ + '-c', + r""" +import gi, sys +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk +root = Gdk.get_default_root_window() +if len(sys.argv) == 4: + display = Gdk.Display.get_default() + display.get_default_seat().get_pointer().warp(display.get_default_screen(), int(sys.argv[2]), int(sys.argv[3])) + display.sync() + import time + time.sleep(.15) +pix = Gdk.pixbuf_get_from_window(root, 0, 0, root.get_width(), root.get_height()) +pix.savev(sys.argv[1], 'png', [], []) +""", + '${widget.output.path}/$name-native.png', + if (hover != null) ...['${hover.dx.round()}', '${hover.dy.round()}'], + ]); + check(capture.exitCode == 0, '$name isolated native capture'); + } + + Future selectorPresentation(String appearance) async { + await key('ctrl+f'); + await query(''); + final rect = boundsForKey(const ValueKey('search-category')); + final origin = await windowManager.getPosition(); + await captureNativeDisplay( + '$appearance-selector-rest', + hover: const Offset(1500, 1000), + ); + await captureNativeDisplay( + '$appearance-selector-hover', + hover: origin + rect.center, + ); + // Traverse into the same selector with actual keys, then open its GTK menu. + bool categoryFocused() { + var found = false; + FocusManager.instance.primaryFocus?.context?.visitAncestorElements((e) { + if (e.widget.key == const ValueKey('search-category')) { + found = true; + return false; + } + return true; + }); + return found; + } + + for (var i = 0; i < 30 && !categoryFocused(); i++) { + await key('tab'); + } + check(categoryFocused(), '$appearance selector keyboard focus'); + await captureNativeDisplay( + '$appearance-selector-focus', + hover: const Offset(1500, 1000), + ); + await key('enter'); + await wait( + () => widgets().any((s) => s.properties.expanded == true), + '$appearance selector open', + ); + await captureNativeDisplay('$appearance-selector-open'); + await key('escape'); + check(search.active, '$appearance popup Escape preserves search'); + await key('escape'); + } + + Future action(String name) async { + await key('ctrl+shift+p'); + await query(name); + check(search.results.isNotEmpty, 'Action $name found'); + await key('enter'); + } + + Future findReplaceNavigation() async { + final original = ref.read(workspaceControllerProvider).activeText; + for (final mode in ['source', 'split']) { + await ref.read(busyMarkCommandRegistryProvider).execute('view.$mode'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.editorState + .mode + .name == + mode, + '$mode view is active', + ); + for (final count in [81, 3000]) { + final text = + '# Find navigation\n${List.generate(count, (i) => 'needle $i\n').join()}'; + workspace.updateActiveText(text); + await key('ctrl+f'); + await query('needle'); + await wait( + () => widgets().any( + (field) => + field.controller is BusyMarkSourceEditingController && + (field.controller! as BusyMarkSourceEditingController) + .searchResult + .totalMatchCount == + count, + ), + '$mode $count full match count', + ); + final field = widgets().singleWhere( + (f) => f.controller is BusyMarkSourceEditingController, + ); + final source = field.controller! as BusyMarkSourceEditingController; + final editor = + (widgets().single.key! as GlobalKey) + .currentState! + as BusyMarkSourceEditorState; + void press(String tooltip) => widgets() + .singleWhere((b) => b.tooltip == tooltip) + .onPressed!(); + Future selected(int index, String expectedText) async { + final occurrence = RegExp( + 'needle', + ).allMatches(expectedText).elementAt(index); + await wait( + () => + source.searchResult.currentMatchIndex == index && + source.fullSelection == + TextSelection( + baseOffset: occurrence.start, + extentOffset: occurrence.end, + ), + '$mode selected occurrence $index', + ); + check( + search.results.length <= 80 && + (search.results[search.selected] as TextSearchResult) + .match + .startOffset == + occurrence.start, + '$mode sidebar identifies occurrence $index in a bounded page', + ); + await wait( + () => widgets().any( + (row) => + row.key == + ValueKey(search.results[search.selected].identity) && + row.selected, + ), + '$mode selected sidebar row revealed', + ); + } + + Future choose(int index) async { + final occurrence = RegExp('needle').allMatches(text).elementAt(index); + editor.scrollToSearchRange( + line: index + 2, + startOffset: occurrence.start, + endOffset: occurrence.end, + ); + await selected(index, text); + } + + editor.scrollToOffset(0); + await wait( + () => widgets().any( + (button) => button.tooltip == 'Collapse section', + ), + '$mode first section is visible for folding', + ); + widgets() + .firstWhere((button) => button.tooltip == 'Collapse section') + .onPressed!(); + await WidgetsBinding.instance.endOfFrame; + check( + source.document.hiddenRanges.ranges.isNotEmpty, + '$mode search starts with folded content', + ); + press('Previous match'); + await selected(count - 1, text); + check( + !source.searchResult.currentMatch!.hidden, + '$mode navigation unfolds the actual final occurrence', + ); + press('Next match'); + await selected(0, text); + await choose(79); + press('Next match'); + await selected(80, text); + press('Previous match'); + await selected(79, text); + final index = count == 81 ? 80 : 2500; + await choose(index); + final replacement = widgets().singleWhere( + (f) => f.key == const ValueKey('source-search-replacement'), + ); + replacement.controller!.text = 'changed'; + replacement.onChanged!('changed'); + await WidgetsBinding.instance.endOfFrame; + final occurrence = RegExp('needle').allMatches(text).elementAt(index); + final modified = text.replaceRange( + occurrence.start, + occurrence.end, + 'changed', + ); + press('Replace and find next'); + await wait( + () => source.fullText == modified, + '$mode replacement beyond first page', + ); + await selected(count == 81 ? 0 : index, modified); + check( + RegExp('changed').allMatches(source.fullText).length == 1, + '$mode replaces exactly once', + ); + final editable = field.focusNode!.context! + .findAncestorStateOfType()! + .renderEditable; + await wait(() { + final caret = editable.getLocalRectForCaret( + TextPosition(offset: source.selection.extentOffset), + ); + return caret.top >= 0 && + caret.bottom <= editable.size.height && + (mode != 'split' || + count != 3000 || + editable.localToGlobal(caret.topLeft).dy > + boundsForKey( + const ValueKey('source-search-replacement'), + ).bottom); + }, '$mode replacement caret becomes visible'); + check( + source.fullSelection.extentOffset == + RegExp( + 'needle', + ).allMatches(modified).elementAt(count == 81 ? 0 : index).end, + '$mode replacement caret remains visible', + ); + await screenshot('find-replace-$mode-$count'); + await key('ctrl+z'); + await wait(() => source.fullText == text, '$mode replacement Undo'); + await key('ctrl+shift+z'); + await wait(() => source.fullText == modified, '$mode replacement Redo'); + await key('escape'); + } + } + workspace.updateActiveText(original); + await ref.read(busyMarkCommandRegistryProvider).execute('view.source'); + await wait( + () => widgets().any( + (f) => + f.controller is BusyMarkSourceEditingController && + (f.controller! as BusyMarkSourceEditingController).fullText == + original, + ), + 'restore native workflow document', + ); + } + + Future presentationJourney(String appearance) async { + final controller = ref.read(searchSessionProvider.notifier); + final before = ref.read(workspaceControllerProvider).activeText; + final files = ref.read(workspaceServiceProvider) as _TraceWorkspaceService; + final history = ref.read(localHistoryStoreProvider) as _TraceHistoryStore; + // These expected initial behaviors are independent of coordinator helpers. + final samples = { + SearchCategory.file: 'semantic', + SearchCategory.workspace: 'semantic', + SearchCategory.outline: 'Outline presentation', + SearchCategory.documents: 'outside-search', + SearchCategory.topics: 'outside-search', + SearchCategory.recent: 'a.md', + SearchCategory.localHistory: 'unique-needle', + SearchCategory.actions: '', + }; + for (final entry in samples.entries) { + controller.open(category: entry.key); + await query(''); + final fileReads = files.reads, revisionReads = history.reads; + final listsInitially = entry.key == SearchCategory.actions; + controller.showMore(); + await controller.submit(); + await Future.delayed(const Duration(milliseconds: 150)); + check( + search.waitingForInput != listsInitially && + (listsInitially + ? search.results.isNotEmpty + : search.results.isEmpty), + '$appearance ${entry.key.name} correct initial list', + ); + check( + files.reads == fileReads && history.reads == revisionReads, + '$appearance ${entry.key.name} empty query does not read providers', + ); + check( + widgets() + .where((f) => f.key == const ValueKey('shared-search-query')) + .length == + 1, + '$appearance one shared query', + ); + if (!listsInitially) { + await screenshot('$appearance-${entry.key.name}-empty'); + await query('zz-no-search-match-984134'); + check( + search.results.isEmpty && !search.waitingForInput, + '$appearance ${entry.key.name} completed no-results', + ); + } + await query(entry.value); + check( + search.results.isNotEmpty, + '$appearance ${entry.key.name} representative results', + ); + check( + widgets().isEmpty, + '$appearance uses native sidebar record rows', + ); + check( + !widgets().any((t) => ['Aa', 'W', '.*'].contains(t.data)), + '$appearance no matching-mode controls', + ); + await screenshot('$appearance-${entry.key.name}-results'); + if (entry.key == SearchCategory.actions) { + await query(BusyMarkCommandIds.syncNotes); + check( + search.results.single is ActionSearchResult && + !(search.results.single as ActionSearchResult).enabled, + '$appearance Notes action has truthful unavailable context', + ); + await WidgetsBinding.instance.endOfFrame; + final row = widgets().singleWhere( + (r) => r.key == ValueKey(BusyMarkCommandIds.syncNotes), + ); + check( + row.onTap == null, + '$appearance disabled action has no active callback', + ); + await screenshot('$appearance-actions-disabled'); + await key('enter'); + check( + search.active && search.query == BusyMarkCommandIds.syncNotes, + '$appearance disabled Enter keeps the same result and search', + ); + } + + check( + ref.read(workspaceControllerProvider).activeText == before, + '$appearance ${entry.key.name} query is source-neutral', + ); + } + if (appearance == 'light') { + final missing = File('${widget.output.path}/documents/a.md'); + await missing.delete(); + final owner = ref.read(workspaceControllerProvider).workspace!.id; + controller.open(category: SearchCategory.recent); + await query('a.md'); + check( + search.results.single is RecentSearchResult && + (search.results.single as RecentSearchResult).resource.path == + missing.path, + 'missing Recent retains the exact resource identity', + ); + await key('enter'); + await wait( + () => widgets().any( + (t) => t.data == 'This resource is unavailable.', + ), + 'missing Recent resource feedback', + ); + check( + ref.read(workspaceControllerProvider).workspace!.id == owner && + ref.read(workspaceControllerProvider).activeText == before, + 'missing Recent cannot substitute another resource or change the editor', + ); + await screenshot('missing-recent-resource'); + } + controller.close(); + await selectorPresentation(appearance); + await shortcutSettingsPresentation(appearance); + await historyPanelsPresentation(appearance); + } + + Future literalMatcherJourney() async { + final original = ref.read(workspaceControllerProvider).activeText; + for (final mode in ['source', 'split']) { + await ref.read(busyMarkCommandRegistryProvider).execute('view.$mode'); + const text = r'Cat catalog .* $1 😀'; + workspace.updateActiveText(text); + await key('ctrl+f'); + await query('cat'); + check( + search.documentMatchCount == 2, + '$mode case-insensitive partial words', + ); + await query('.*'); + check(search.documentMatchCount == 1, '$mode punctuation is literal'); + final source = + widgets() + .singleWhere( + (f) => f.controller is BusyMarkSourceEditingController, + ) + .controller! + as BusyMarkSourceEditingController; + void press(String tooltip) => widgets() + .singleWhere((b) => b.tooltip == tooltip) + .onPressed!(); + await wait( + () => + source.searchResult.totalMatchCount == 1 && + widgets().any( + (b) => b.tooltip == 'Next match' && b.onPressed != null, + ), + '$mode Source receives literal query snapshot', + ); + press('Next match'); + await wait( + () => + source.fullSelection == + const TextSelection(baseOffset: 12, extentOffset: 14), + '$mode literal occurrence selection', + ); + final replacement = widgets().singleWhere( + (f) => f.key == const ValueKey('source-search-replacement'), + ); + replacement.controller!.text = r'$1'; + replacement.onChanged!(r'$1'); + await WidgetsBinding.instance.endOfFrame; + press('Replace and find next'); + await wait( + () => source.fullText == r'Cat catalog $1 $1 😀' && !search.searching, + '$mode literal replacement', + ); + await key('ctrl+z'); + await wait( + () => source.fullText == text, + '$mode literal replacement Undo', + ); + await key('ctrl+shift+z'); + await wait( + () => source.fullText == r'Cat catalog $1 $1 😀', + '$mode literal replacement Redo', + ); + await query('😀'); + check(search.documentMatchCount == 1, '$mode Unicode literal occurrence'); + final match = (search.results.single as TextSearchResult).match; + check( + source.fullText.substring(match.startOffset, match.endOffset) == '😀', + '$mode complete Unicode source boundaries', + ); + await key('escape'); + } + workspace.updateActiveText(original); + await WidgetsBinding.instance.endOfFrame; + } + + Future openShortcutSettings() async { + await key('ctrl+alt+s', verifySearch: false); + await wait(() => widgets().isNotEmpty, 'Settings route'); + final nav = widgets().where( + (r) => r.key == const ValueKey('settings-navigation-keyboardShortcuts'), + ); + if (nav.isNotEmpty) { + nav.single.onTap(); + } else { + widgets>().single.onSelected( + SettingsPage.keyboardShortcuts, + ); + } + await wait( + () => widgets().isNotEmpty, + 'Keyboard Shortcuts through Settings', + ); + } + + Future categoryShortcutsJourney() async { + const bindings = { + SearchCategory.file: 'ctrl+f', + SearchCategory.workspace: 'ctrl+shift+f', + SearchCategory.outline: 'ctrl+shift+h', + SearchCategory.documents: 'ctrl+shift+d', + SearchCategory.topics: 'ctrl+shift+t', + SearchCategory.recent: 'ctrl+shift+r', + SearchCategory.localHistory: 'ctrl+shift+l', + SearchCategory.actions: 'ctrl+shift+p', + }; + final before = ref.read(workspaceControllerProvider).activeText; + for (final entry in bindings.entries) { + await key(entry.value); + check( + search.category == entry.key, + '${entry.key.name} direct command destination', + ); + final selector = widgets>().single; + final option = selector.options.singleWhere((o) => o.value == entry.key); + final command = ref.read( + busyMarkCommandRegistryProvider, + )[searchCommandForCategory(entry.key)]!; + check( + option.shortcut == command.shortcut!.label && + option.gtkAccelerator == command.shortcut!.gtkAccelerator, + '${entry.key.name} effective selector metadata', + ); + check( + widgets() + .singleWhere( + (f) => identical( + f.controller, + widgets() + .singleWhere( + (f) => f.key == const ValueKey('shared-search-query'), + ) + .controller, + ), + ) + .focusNode! + .hasFocus, + '${entry.key.name} focuses shared query', + ); + final keymap = ref.read(keymapControllerProvider.notifier); + final id = searchCommandForCategory(entry.key); + Map overrides() => + ref.read(appSettingsControllerProvider).shortcutOverrides; + check( + (await keymap.update( + id, + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: overrides(), + )).status == + KeymapUpdateStatus.saved, + '${entry.key.name} custom assignment', + ); + await WidgetsBinding.instance.endOfFrame; + var request = search.focusRequest; + await key(entry.value, verifySearch: false); + check( + search.focusRequest == request, + '${entry.key.name} former default removed', + ); + await key('F8', verifySearch: false); + check( + search.focusRequest == request + 1 && search.category == entry.key, + '${entry.key.name} custom binding executes once', + ); + check( + widgets>().single.options + .singleWhere((o) => o.value == entry.key) + .shortcut == + 'F8', + '${entry.key.name} custom option label', + ); + await keymap.update(id, null, expected: overrides()); + await WidgetsBinding.instance.endOfFrame; + request = search.focusRequest; + await key('F8', verifySearch: false); + await key(entry.value, verifySearch: false); + check( + search.focusRequest == request, + '${entry.key.name} unassigned binding and default inactive', + ); + final unassigned = widgets>() + .single + .options + .singleWhere((o) => o.value == entry.key); + check( + unassigned.shortcut == null && unassigned.gtkAccelerator == null, + '${entry.key.name} unassigned metadata removed', + ); + await keymap.update(id, null, reset: true, expected: overrides()); + await key(entry.value); + check( + search.category == entry.key, + '${entry.key.name} reset default destination', + ); + } + await key('escape'); + for (final old in ['ctrl+p', 'ctrl+alt+h', 'ctrl+alt+o', 'ctrl+alt+y']) { + final request = search.focusRequest; + await key(old, verifySearch: false); + check( + !search.active && search.focusRequest == request, + 'old default $old has no implicit alias', + ); + } + await key('ctrl+alt+k', verifySearch: false); + check( + widgets().isEmpty, + 'retired Ctrl+Alt+K does not open Settings', + ); + check( + ref.read(workspaceControllerProvider).activeText == before, + 'category shortcuts preserve source', + ); + } + + Future historyPanelsPresentation(String stage) async { + final originalSize = await windowManager.getSize(); + // Ordinary browsing has a workspace breakpoint; its sidebar itself stays + // 300 px wide. Search and Settings are separately exercised at 600 px. + final expand = + originalSize.width < BusyMarkSizes.workspaceSidebarBreakpoint; + if (expand) { + await windowManager.setSize( + Size(BusyMarkSizes.workspaceSidebarBreakpoint, originalSize.height), + ); + await wait( + () => + (boundary.currentContext?.size?.width ?? 0) >= + BusyMarkSizes.workspaceSidebarBreakpoint, + 'ordinary sidebar width threshold', + ); + } + try { + final before = ref.read(workspaceControllerProvider).activeText; + bool hasInput(Type panel) { + var input = false; + void descendants(Element e) { + if (e.widget is EditableText) input = true; + e.visitChildren(descendants); + } + + void visit(Element e) { + if (e.widget.runtimeType == panel) { + descendants(e); + } else { + e.visitChildren(visit); + } + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return input; + } + + await key('ctrl+5', verifySearch: false); + await wait( + () => widgets().isNotEmpty, + 'ordinary History panel', + ); + check( + !search.active && !hasInput(LocalHistoryPanel), + '$stage ordinary History has no search', + ); + final historyMenu = widgets().singleWhere( + (m) => m.key == const ValueKey('local-history-actions-menu'), + ); + final historyItems = (await historyMenu.itemBuilder( + rootNavigatorKey.currentContext!, + )).whereType().toList(); + check( + historyItems.length == 3 && + historyItems.every((m) => m.icon != BusyMarkGlyphs.search), + '$stage History only has Refresh and confirmed clear operations', + ); + (historyMenu as dynamic).onSelected(historyItems.first.menuValue); + await wait( + () => !ref.read(localHistoryControllerProvider).loading, + 'History refresh', + ); + await screenshot('ordinary-history-$stage'); + await ref + .read(busyMarkCommandRegistryProvider) + .execute(BusyMarkCommandIds.clipboardHistory); + await wait( + () => widgets().isNotEmpty, + 'Clipboard panel', + ); + check( + !hasInput(ClipboardHistoryPanel), + '$stage Clipboard History has no search', + ); + await screenshot('clipboard-history-initial-$stage'); + final clipboard = ref.read(clipboardHistoryControllerProvider.notifier); + clipboard.retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'Retained panel entry', + sourceText: 'Retained panel entry', + ), + ); + await WidgetsBinding.instance.endOfFrame; + await screenshot('clipboard-history-populated-$stage'); + final menu = widgets().singleWhere( + (m) => m.key == const ValueKey('clipboard-history-actions-menu'), + ); + final items = (await menu.itemBuilder( + rootNavigatorKey.currentContext!, + )).whereType().toList(); + check(items.length == 2, '$stage Clipboard management menu'); + (menu as dynamic).onSelected(items.first.menuValue); + await wait( + () => !ref.read(clipboardHistoryControllerProvider).refreshing, + 'Clipboard refresh', + ); + (menu as dynamic).onSelected(items.last.menuValue); + check( + ref.read(clipboardHistoryControllerProvider).entries.isEmpty, + '$stage Clipboard clear', + ); + check( + ref.read(workspaceControllerProvider).activeText == before, + '$stage History browsing preserves source', + ); + } finally { + if (expand) { + await windowManager.setSize(originalSize); + await wait( + () => + (boundary.currentContext?.size?.width ?? 0) <= originalSize.width, + 'restore narrow search viewport', + ); + } + } + } + + Future browseShortcut(String id) async { + Element? locate(Key key) { + Element? found; + void visit(Element e) { + if (e.widget.key == key) { + found = e; + } else { + e.visitChildren(visit); + } + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return found; + } + + final list = locate(const ValueKey('settings-shortcut-list'))!; + ScrollableState? scrolling; + void findScroll(Element e) { + if (e is StatefulElement && e.state is ScrollableState) { + scrolling ??= e.state as ScrollableState; + } + e.visitChildren(findScroll); + } + + findScroll(list); + final position = scrolling!.position; + position.jumpTo(0); + await WidgetsBinding.instance.endOfFrame; + for (var step = 0; step < 100; step++) { + final row = locate(ValueKey('shortcut-edit-$id')); + if (row != null) { + await Scrollable.ensureVisible( + row, + alignment: .5, + duration: const Duration(milliseconds: 80), + ); + await WidgetsBinding.instance.endOfFrame; + check( + (row.widget as BusyMarkActionRow).enabled, + 'browse configurable $id', + ); + (row.widget as BusyMarkActionRow).onTap!(); + return; + } + final next = (position.pixels + position.viewportDimension * .8).clamp( + 0.0, + position.maxScrollExtent, + ); + if (next == position.pixels) break; + position.jumpTo(next); + await WidgetsBinding.instance.endOfFrame; + } + throw StateError('Command row not found by browsing: $id'); + } + + Future shortcutSettingsPresentation(String stage) async { + final before = ref.read(workspaceControllerProvider).activeText; + await openShortcutSettings(); + await wait( + () => widgets().isNotEmpty, + 'shortcut Settings $stage', + ); + check( + !BusyMarkShortcutCapture.suppressing, + 'ordinary Settings browsing owns no capture session', + ); + check( + widgets().every( + (f) => f.key != const ValueKey('settings-shortcut-search'), + ), + 'shortcut Settings has no query control', + ); + await screenshot('shortcuts-settings-$stage'); + await browseShortcut('file.save'); + await wait( + () => widgets().isNotEmpty, + 'Settings assignment dialog', + ); + await Future.delayed(const Duration(milliseconds: 220)); + await screenshot('shortcut-assignment-$stage'); + widgets() + .singleWhere((r) => r.key == const ValueKey('record-shortcut')) + .onTap!(); + await key('ctrl+o', verifySearch: false); + check( + !BusyMarkShortcutCapture.suppressing, + '$stage capture releases native dispatch', + ); + await Future.delayed(const Duration(milliseconds: 220)); + await screenshot('shortcut-conflict-$stage'); + await key('escape'); + await wait( + () => widgets().isEmpty, + 'assignment cancellation $stage', + ); + + if (stage == 'light' || stage == 'dark') { + widgets() + .singleWhere( + (r) => r.key == const ValueKey('settings-navigation-appearance'), + ) + .onTap(); + await wait( + () => widgets>().isNotEmpty, + 'Settings language selector', + ); + await captureNativeDisplay('settings-selectors-$stage'); + } + check( + ref.read(workspaceControllerProvider).activeText == before, + 'Settings $stage leaves source unchanged', + ); + await key('alt+Left'); + await wait( + () => + ref.read(appRouterProvider).routeInformationProvider.value.uri.path == + '/workspace', + 'Settings return $stage', + ); + } + + Future keymapJourney() async { + final router = ref.read(appRouterProvider); + await workspace.saveActive(); + router.go('/'); + await wait( + () => router.routeInformationProvider.value.uri.path == '/', + 'Welcome before keymap', + ); + await openShortcutSettings(); + await wait( + () => widgets().isNotEmpty, + 'shortcut Settings from Welcome', + ); + check( + router.routeInformationProvider.value.uri.queryParameters['returnTo'] == + 'welcome', + 'Welcome Settings return target', + ); + await openShortcutSettings(); + check( + router.routeInformationProvider.value.uri.queryParameters['returnTo'] == + 'welcome', + 'Settings shortcut keeps original return target', + ); + await key('alt+Left'); + await wait( + () => router.routeInformationProvider.value.uri.path == '/', + 'return to Welcome from shortcut Settings', + ); + + Future edit(String id) async { + await openShortcutSettings(); + await wait( + () => widgets().isNotEmpty, + 'shortcut editor', + ); + await browseShortcut(id); + await wait( + () => widgets().any( + (b) => b.key == const ValueKey('record-shortcut'), + ), + 'record button', + ); + } + + Future assign(String id, String combination) async { + await edit(id); + widgets() + .singleWhere((b) => b.key == const ValueKey('record-shortcut')) + .onTap!(); + final before = ref.read(workspaceControllerProvider).activeText; + await key(combination, verifySearch: false); + check( + !BusyMarkShortcutCapture.suppressing, + 'capture $combination releases native dispatch after key up', + ); + check( + ref.read(workspaceControllerProvider).activeText == before, + 'capture $combination does not execute', + ); + await wait( + () => widgets().any( + (b) => + b.key == const ValueKey('assign-shortcut') && b.onPressed != null, + ), + 'candidate captured', + ); + widgets() + .singleWhere((b) => b.key == const ValueKey('assign-shortcut')) + .onPressed!(); + await wait(() => widgets().isEmpty, 'assignment saved'); + await key('alt+Left'); + await wait( + () => router.routeInformationProvider.value.uri.path == '/workspace', + 'return to keymap document', + ); + } + + // Qualify a previously unbound typed Writerside operation through real keys. + await workspace.openPath('${widget.output.path}/writerside'); + await workspace.openActiveFile( + '${widget.output.path}/writerside/topics/outside-search.topic', + ); + router.go('/workspace'); + await key('ctrl+shift+1', verifySearch: false); + await assign('source.declaration', 'ctrl+alt+y'); + await assign('writerside.uiControl', 'ctrl+alt+y'); + check( + ref + .read(busyMarkCommandRegistryProvider)['source.declaration']! + .shortcut + ?.label == + 'Ctrl+Alt+Y' && + ref + .read( + busyMarkCommandRegistryProvider, + )['writerside.uiControl']! + .shortcut + ?.label == + 'Ctrl+Alt+Y', + 'separate Source and Editor receivers keep both assignments', + ); + await wait( + () => widgets().any( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text.contains('semantic selection'), + ), + 'Writerside shortcut target', + ); + final semantic = widgets().firstWhere( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text.contains('semantic selection'), + ); + semantic.focusNode!.requestFocus(); + final offset = semantic.controller!.text.indexOf('selection'); + semantic.controller!.selection = TextSelection( + baseOffset: offset, + extentOffset: offset + 'selection'.length, + ); + await WidgetsBinding.instance.endOfFrame; + final beforeSemantic = ref.read(workspaceControllerProvider).activeText; + await key('ctrl+alt+y', verifySearch: false); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('selection'), + 'assigned Writerside Control', + ); + await key('ctrl+z'); + await wait( + () => ref.read(workspaceControllerProvider).activeText == beforeSemantic, + 'assigned Writerside Undo', + ); + await key('ctrl+shift+z'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('selection'), + 'assigned Writerside Redo', + ); + await workspace.saveActive(); + await screenshot('keymap-writerside-command'); + + final file = File('${widget.output.path}/keymap-document.md'); + await file.writeAsString('Binding target\n'); + await workspace.openPath(file.path); + router.go('/workspace'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + file.path, + 'keymap document', + ); + + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.split, + ]) { + await ref + .read(busyMarkCommandRegistryProvider) + .execute( + mode == DocumentViewModePreference.source + ? BusyMarkCommandIds.viewSource + : BusyMarkCommandIds.viewSplit, + ); + const original = + '## Fold\nfirst hidden\nsecond hidden\n\n## Next\nbefore target after\n'; + workspace.updateActiveText(original); + await workspace.saveActive(); + await assign('text.selectAll', 'F8'); + await wait( + () => widgets().isNotEmpty, + '$mode Source field', + ); + BusyMarkSourceEditingController source() => widgets() + .map((f) => f.controller) + .whereType() + .single; + void focusSource() => widgets() + .singleWhere((f) => f.controller is BusyMarkSourceEditingController) + .focusNode! + .requestFocus(); + widgets() + .firstWhere((b) => b.tooltip == 'Collapse section') + .onPressed!(); + await wait( + () => source().document.hiddenRanges.ranges.isNotEmpty, + '$mode folded content', + ); + focusSource(); + await key('F8', verifySearch: false); + check( + source().fullSelection == + const TextSelection(baseOffset: 0, extentOffset: original.length), + '$mode F8 selects entire folded document', + ); + await edit('text.selectAll'); + widgets() + .singleWhere((b) => b.key == const ValueKey('remove-shortcut')) + .onTap!(); + await wait(() => widgets().isEmpty, '$mode unassignment saved'); + await key('alt+Left'); + focusSource(); + source().fullSelection = const TextSelection.collapsed(offset: 0); + await key('F8', verifySearch: false); + await key('ctrl+a', verifySearch: false); + check( + source().fullSelection == const TextSelection.collapsed(offset: 0), + '$mode unassigned Select All has no hidden default', + ); + await edit('text.selectAll'); + widgets() + .singleWhere( + (b) => b.key == const ValueKey('restore-shortcut-default'), + ) + .onTap!(); + await wait(() => widgets().isEmpty, '$mode reset saved'); + await key('alt+Left'); + focusSource(); + await key('ctrl+a', verifySearch: false); + check( + source().fullSelection == + const TextSelection(baseOffset: 0, extentOffset: original.length), + '$mode reset Ctrl+A selects complete document', + ); + widgets() + .firstWhere((b) => b.tooltip == 'Expand section') + .onPressed!(); + await assign('editor.bold', 'alt+enter'); + focusSource(); + source().fullSelection = TextSelection( + baseOffset: original.indexOf('target'), + extentOffset: original.indexOf('target') + 6, + ); + await key('alt+enter', verifySearch: false); + final modified = original.replaceFirst('target', '**target**'); + await wait( + () => source().fullText == modified, + '$mode Alt+Enter exact Bold markup', + ); + await key('ctrl+z'); + check(source().fullText == original, '$mode Alt+Enter one Undo'); + await key('ctrl+shift+z'); + check(source().fullText == modified, '$mode Alt+Enter Redo'); + source().fullSelection = TextSelection.collapsed(offset: modified.length); + await key('enter'); + check(source().fullText == '$modified\n', '$mode plain Enter'); + await key('ctrl+z'); + check(source().fullText == modified, '$mode Enter separate Undo'); + check( + await workspace.saveActive() && await file.readAsString() == modified, + '$mode Alt+Enter saved source', + ); + check( + await workspace.closeDocumentBuffer( + ref.read(workspaceControllerProvider).activeBufferId!, + ), + '$mode close modified document', + ); + await workspace.openPath(file.path); + await wait( + () => ref.read(workspaceControllerProvider).activeText == modified, + '$mode Alt+Enter save/reopen', + ); + } + workspace.updateActiveText('Binding target\n'); + await workspace.saveActive(); + + await assign('editor.bold', 'ctrl+alt+b'); + for (final mode in [ + DocumentViewModePreference.editor, + DocumentViewModePreference.source, + ]) { + await key( + mode == DocumentViewModePreference.source + ? 'ctrl+shift+2' + : 'ctrl+shift+1', + verifySearch: false, + ); + await wait( + () => widgets().any( + (f) => mode == DocumentViewModePreference.source + ? f.controller is BusyMarkSourceEditingController + : f.controller is BusyMarkWysiwygTextController, + ), + '$mode keymap field', + ); + final field = widgets().firstWhere( + (f) => mode == DocumentViewModePreference.source + ? f.controller is BusyMarkSourceEditingController + : f.controller is BusyMarkWysiwygTextController, + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 7, + ); + await WidgetsBinding.instance.endOfFrame; + if (mode == DocumentViewModePreference.editor) { + check( + widgets().any((t) => t.message == 'Bold (Ctrl+Alt+B)'), + 'toolbar displays the reassigned formatting shortcut', + ); + } + await key('ctrl+b', verifySearch: false); + check( + !ref.read(workspaceControllerProvider).activeText.contains('**'), + '$mode old formatting binding removed', + ); + await key('ctrl+alt+b', verifySearch: false); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('**Binding**'), + '$mode reassigned formatting', + ); + await key('ctrl+z'); + await wait( + () => !ref.read(workspaceControllerProvider).activeText.contains('**'), + '$mode reassigned formatting Undo', + ); + await key('ctrl+shift+z'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('**Binding**'), + '$mode reassigned formatting Redo', + ); + await workspace.saveActive(); + check( + (await file.readAsString()).contains('**Binding**'), + '$mode saved formatting', + ); + await key('ctrl+z'); + await wait( + () => !ref.read(workspaceControllerProvider).activeText.contains('**'), + '$mode reset text', + ); + await workspace.saveActive(); + } + final savedField = widgets().singleWhere( + (f) => f.controller is BusyMarkSourceEditingController, + ); + savedField.focusNode!.requestFocus(); + savedField.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 7, + ); + await WidgetsBinding.instance.endOfFrame; + await key('ctrl+alt+b', verifySearch: false); + await wait( + () => + ref.read(workspaceControllerProvider).activeText == + '**Binding** target\n', + 'formatted source before reopening', + ); + check( + await workspace.saveActive(), + 'save reassigned formatting for reopening', + ); + check( + await workspace.closeDocumentBuffer( + ref.read(workspaceControllerProvider).activeBufferId!, + ), + 'close saved formatting document', + ); + await workspace.openPath(file.path); + check( + ref.read(workspaceControllerProvider).activeText == + '**Binding** target\n', + 'reopen retains the reassigned formatting source', + ); + workspace.updateActiveText('Binding target\n'); + await workspace.saveActive(); + await key('ctrl+shift+2', verifySearch: false); + final layout = await Process.run('setxkbmap', [ + '-display', + Platform.environment['DISPLAY']!, + '-layout', + 'fr', + ]); + check(layout.exitCode == 0, 'isolated French keyboard layout'); + try { + PhysicalKeyboardKey? capturedPhysical; + bool observeFrenchKey(KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.keyA) { + capturedPhysical = event.physicalKey; + } + return false; + } + + HardwareKeyboard.instance.addHandler(observeFrenchKey); + try { + await assign('editor.bold', 'ctrl+shift+a'); + } finally { + HardwareKeyboard.instance.removeHandler(observeFrenchKey); + } + final binding = ref + .read(appSettingsControllerProvider) + .shortcutOverrides['editor.bold']!; + check( + binding.key == LogicalKeyboardKey.keyA && + binding.control && + binding.shift && + capturedPhysical == PhysicalKeyboardKey.keyQ, + 'capture follows French logical A on physical Q ' + '(${binding.label}, physical ${capturedPhysical?.usbHidUsage})', + ); + final source = widgets().singleWhere( + (f) => f.controller is BusyMarkSourceEditingController, + ); + source.focusNode!.requestFocus(); + source.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 7, + ); + await WidgetsBinding.instance.endOfFrame; + await key('ctrl+shift+a', verifySearch: false); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('**Binding**'), + 'French logical-key formatting', + ); + await key('ctrl+z'); + await wait( + () => !ref.read(workspaceControllerProvider).activeText.contains('**'), + 'French logical-key Undo', + ); + await workspace.saveActive(); + final input = source.controller! as BusyMarkSourceEditingController; + source.focusNode!.requestFocus(); + final sourceBeforeAltGr = input.fullText; + await key('altgr+e', verifySearch: false); + await wait( + () => input.fullText.contains('€'), + 'AltGr stays in the editor', + ); + await key('ctrl+z'); + check( + input.fullText == sourceBeforeAltGr, + 'AltGr has ordinary source Undo', + ); + await screenshot('keymap-french-input'); + measurements['nonUsKeymap'] = + 'French layout: logical Ctrl+Shift+A captured and executed on physical Q'; + } finally { + final restored = await Process.run('setxkbmap', [ + '-display', + Platform.environment['DISPLAY']!, + '-layout', + 'us', + ]); + check(restored.exitCode == 0, 'restore isolated keyboard layout'); + } + await edit('file.save'); + widgets() + .singleWhere((r) => r.key == const ValueKey('record-shortcut')) + .onTap!(); + await key('F8', verifySearch: false); + final settingsStore = ref.read(localSettingsStoreProvider) as _Settings; + final persistedBeforeFailure = jsonEncode(settingsStore.value); + settingsStore.failSaves = true; + widgets() + .singleWhere((b) => b.key == const ValueKey('assign-shortcut')) + .onPressed!(); + await wait( + () => widgets().any( + (t) => t.key == const ValueKey('shortcut-feedback'), + ), + 'visible settings persistence failure', + ); + check( + jsonEncode(settingsStore.value) == persistedBeforeFailure && + widgets().isNotEmpty, + 'failed save preserves persisted settings and dialog', + ); + await screenshot('shortcut-persistence-failure'); + settingsStore.failSaves = false; + widgets() + .singleWhere((b) => b.key == const ValueKey('assign-shortcut')) + .onPressed!(); + await wait(() => widgets().isEmpty, 'settings persistence retry'); + check( + ref.read(busyMarkCommandRegistryProvider)['file.save']!.shortcut!.label == + 'F8', + 'retry keeps current assignment', + ); + await key('alt+Left'); + + await edit('view.source'); + widgets() + .singleWhere((b) => b.key == const ValueKey('remove-shortcut')) + .onTap!(); + await wait(() => widgets().isEmpty, 'view unassignment saved'); + await key('alt+Left'); + await wait( + () => router.routeInformationProvider.value.uri.path == '/workspace', + 'return after view unassignment', + ); + await key('ctrl+shift+1', verifySearch: false); + await wait( + () => + widgets().any( + (f) => f.controller is BusyMarkWysiwygTextController, + ) && + widgets().isEmpty, + 'Editor after view unassignment', + ); + final beforeUnassignedView = ref + .read(workspaceControllerProvider) + .activeText; + await key('ctrl+shift+2', verifySearch: false); + check( + widgets().isEmpty && + ref.read(workspaceControllerProvider).activeText == + beforeUnassignedView, + 'old unassigned view key cannot switch modes', + ); + // The shared Flutter page frame publishes this configuration even when + // the legacy native header service is unavailable on the current runner. + final labels = + widgets().single.configuration.labels; + check( + labels.sourceShortcut.isEmpty && labels.sourceGtkAccelerator.isEmpty, + 'mounted header removes unassigned view metadata', + ); + await assign('search.find', 'F12'); + await key('ctrl+f', verifySearch: false); + check(!search.active, 'old search shortcut removed'); + final request = search.focusRequest; + await key('F12', verifySearch: false); + final documentOption = widgets>() + .single + .options + .singleWhere((o) => o.value == SearchCategory.file); + check( + documentOption.shortcut == 'F12' && + documentOption.gtkAccelerator == 'F12', + 'custom search selector metadata', + ); + check( + search.active && + search.category == SearchCategory.file && + search.focusRequest == request + 1, + 'custom document search executes once', + ); + await query('Binding'); + check( + search.documentMatchCount == 1, + 'custom search retains document scope', + ); + await key('ctrl+shift+f'); + check( + search.category == SearchCategory.workspace, + 'workspace search remains separate', + ); + await key('ctrl+shift+p'); + await query('Keyboard Shortcuts'); + await key('enter'); + await wait( + () => widgets().isNotEmpty, + 'shortcut Settings from Actions', + ); + await screenshot('keymap-custom-settings'); + await key('alt+Left'); + await openShortcutSettings(); + await wait( + () => widgets().any( + (b) => b.key == const ValueKey('reset-all-shortcuts'), + ), + 'Reset All button', + ); + widgets() + .singleWhere((b) => b.key == const ValueKey('reset-all-shortcuts')) + .onPressed!(); + await wait(() => widgets().isNotEmpty, 'Reset All confirmation'); + widgets() + .singleWhere((b) => b.onPressed != null) + .onPressed!(); + await wait( + () => ref.read(appSettingsControllerProvider).shortcutOverrides.isEmpty, + 'Reset All saved', + ); + await key('alt+Left'); + await key('ctrl+f'); + check( + search.category == SearchCategory.file, + 'Reset All restores default document binding', + ); + await key('escape'); + measurements['configurableKeymapNativeWorkflows'] = + 'Welcome, Settings, Actions, Editor, Source, Undo/Redo, save, custom search, reset defaults'; + } + + Future recentPresentationJourney() async { + await windowManager.setSize(const Size(1100, 850)); + await wait( + () => + View.of(context).physicalSize.width / + View.of(context).devicePixelRatio >= + 1000, + 'wide Welcome Recent presentation', + ); + final router = ref.read(appRouterProvider); + final files = []; + for (final parent in [ + 'short', + 'a-long-owner-name/nested/another-long-folder/final-parent', + ]) { + final file = File('${widget.output.path}/recent/$parent/Same.md'); + await file.parent.create(recursive: true); + await file.writeAsString('Recent exact resource: $parent\n'); + await workspace.openPath(file.path); + files.add(file); + } + // Keep a genuine project-qualified topic in the mixed chronological list. + await workspace.openPath('${widget.output.path}/writerside'); + await workspace.openActiveFile( + '${widget.output.path}/writerside/topics/outside-search.topic', + ); + check( + await workspace.openNextcloudWorkspace( + '123e4567-e89b-42d3-a456-426614174000', + ), + 'mixed Recent Notes owner', + ); + check( + await workspace.openNextcloudNote('223e4567-e89b-42d3-a456-426614174000'), + 'mixed Recent explicit note opening', + ); + router.go('/'); + await wait( + () => router.routeInformationProvider.value.uri.path == '/', + 'Welcome mixed Recent', + ); + await wait( + () => widgets().isNotEmpty, + 'Welcome Recent rows mounted', + ); + final before = ref.read(appSettingsControllerProvider).recentWorkspaces; + check( + before.any((r) => r.isDocument && r.accountId != null), + 'mixed Recent retains account-qualified note', + ); + check( + before.any( + (r) => r.isDocument && r.ownerRoot != null && r.path.endsWith('.topic'), + ), + 'mixed Recent retains project-qualified topic', + ); + for (final scale in [1.0, 1.6]) { + setState(() => _textScale = scale); + await WidgetsBinding.instance.endOfFrame; + final rows = widgets(); + check( + rows.isNotEmpty && + rows.every( + (r) => r.content == null && r.title != null && r.subtitle != null, + ), + 'Welcome standard Recent identity rows $scale', + ); + final heights = rows.map((r) => boundsForKey(r.key!).height).toList(); + check( + heights.every((h) => (h - heights.first).abs() < .1), + 'Welcome uniform mixed Recent heights $scale', + ); + check( + rows.every( + (r) => r.tooltip != null && r.semanticsLabel!.contains(r.tooltip!), + ), + 'Welcome complete accessible locations $scale', + ); + await screenshot('welcome-mixed-recent-$scale'); + } + setState(() => _textScale = 1); + await key('ctrl+shift+r'); + await query(''); + check( + search.waitingForInput && search.results.isEmpty, + 'Recent search waits independently of Welcome browsing', + ); + await query('Same.md'); + check( + search.results.whereType().length == 2, + 'Recent duplicate filenames retain separate identities', + ); + // Welcome retains its ordinary list behind the search presentation. + // Inspect just these two resource identities, rather than counting hidden + // browsing rows in the framework's retained IndexedStack children. + final expectedKeys = {for (final r in search.results) ValueKey(r.identity)}; + final records = widgets().where( + (r) => expectedKeys.contains(r.key), + ); + check( + records.map((r) => r.subtitle).toSet().length == 2, + 'Recent duplicate filename locations distinguish owners', + ); + await query(''); + await key('ctrl+shift+p'); + await key('ctrl+shift+r'); + check( + search.waitingForInput && search.results.isEmpty, + 'Recent stays empty after clear and category change', + ); + await key('escape'); + check( + jsonEncode( + ref + .read(appSettingsControllerProvider) + .recentWorkspaces + .map((r) => r.toJson()) + .toList(), + ) == + jsonEncode(before.map((r) => r.toJson()).toList()), + 'search leaves Welcome Recent collection unchanged', + ); + for (final file in files) { + await key('ctrl+shift+r'); + await query(file.path); + check( + search.results.single is RecentSearchResult, + 'exact long-path Recent resource', + ); + await key('enter'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + file.path, + 'Recent exact resource activation', + ); + check( + ref.read(workspaceControllerProvider).activeText == + await file.readAsString(), + 'Recent exact resource contents', + ); + router.go('/'); + await WidgetsBinding.instance.endOfFrame; + } + } + + Future run() async { + try { + await Future.delayed(const Duration(seconds: 2)); + check( + ref.read(appRouterProvider).routeInformationProvider.value.uri.path == + '/', + 'Welcome visible', + ); + await key('ctrl+shift+r'); + await query(''); + check(search.category == SearchCategory.recent, 'Welcome Recent sidebar'); + bool categoryHasFocus() { + var found = false; + FocusManager.instance.primaryFocus?.context?.visitAncestorElements(( + element, + ) { + if (element.widget.key == const ValueKey('search-category')) { + found = true; + return false; + } + return true; + }); + return found; + } + + for (var i = 0; i < 30 && !categoryHasFocus(); i++) { + await key('tab'); + } + check(categoryHasFocus(), 'keyboard traversal reaches category selector'); + await key('enter'); + await wait( + () => + widgets().any( + (s) => s.properties.label == 'Search category', + ) && + widgets().any((s) => s.properties.expanded == true), + 'native category popup opened by keyboard', + ); + await key('escape'); + check( + search.active, + 'category popup consumes Escape before closing search', + ); + await screenshot('welcome-recent'); + await key('escape'); + final folder = await Directory( + '${widget.output.path}/documents', + ).create(); + final file = File('${folder.path}/a.md'); + await file.writeAsString( + '# Title\n\nFormatting target\n\nscope needle alpha\n', + ); + await File( + '${folder.path}/b.md', + ).writeAsString('# Other\n\nscope needle beta\n'); + if (widget.large) { + for (var i = 0; i < 200; i++) { + await File('${folder.path}/corpus-$i.md').writeAsString( + '# Corpus $i\n\n${List.generate(40, (line) => 'Paragraph $line: representative local content café 中文 ' + 'corpus-token${i % 97} alpha beta gamma delta.').join('\n\n')}', + ); + } + } + await workspace.openFolder(folder.path); + await workspace.openActiveFile(file.path); + ref.read(appRouterProvider).go('/workspace'); + await wait( + () => widgets().any( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text == 'Formatting target', + ), + 'Editor ready', + ); + final field = widgets().singleWhere( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text == 'Formatting target', + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 10, + ); + await WidgetsBinding.instance.endOfFrame; + await action('Bold'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('**Formatting**'), + 'Editor Bold uses captured text', + ); + await key('ctrl+z'); + await wait( + () => !ref + .read(workspaceControllerProvider) + .activeText + .contains('**Formatting**'), + 'Editor Undo', + ); + await key('ctrl+shift+z'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('**Formatting**'), + 'Editor Redo', + ); + check(await workspace.saveActive(), 'save formatting'); + check( + (await file.readAsString()).contains('**Formatting**'), + 'formatting persisted', + ); + // Settle the preceding save's inventory metadata before measuring clean + // tab navigation. A later genuine metadata refresh is a valid reason to + // invalidate workspace results, independently of a tab being closed. + check( + await workspace.refreshWorkspaceFromDiskPreservingOpenTabs(), + 'refresh saved file metadata before workspace navigation', + ); + await key('ctrl+f'); + await query('scope needle'); + check( + search.category == SearchCategory.file && search.results.length == 1, + 'Ctrl+F document-only', + ); + await key('ctrl+shift+f'); + await query('scope needle'); + check(search.results.length == 2, 'Ctrl+Shift+F workspace'); + final beforeArrowing = ref.read(workspaceControllerProvider); + await key('down'); + check( + search.selected == 1 && + ref.read(workspaceControllerProvider).activeBufferId == + beforeArrowing.activeBufferId && + ref.read(workspaceControllerProvider).activeText == + beforeArrowing.activeText, + 'arrow selection does not open or edit a document', + ); + final queryFocus = FocusManager.instance.primaryFocus; + await key('tab'); + check( + FocusManager.instance.primaryFocus != queryFocus, + 'Tab leaves the query', + ); + await key('shift+tab'); + check( + FocusManager.instance.primaryFocus == queryFocus, + 'Shift+Tab returns to the query', + ); + await key('enter'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + '${folder.path}/b.md', + 'Enter activates the selected text occurrence', + ); + final tracedFiles = + ref.read(workspaceServiceProvider) as _TraceWorkspaceService; + final readsAfterActivation = tracedFiles.reads; + final searchGeneration = search.generation; + await Future.delayed(const Duration(milliseconds: 180)); + check( + tracedFiles.reads == readsAfterActivation && + search.generation == searchGeneration, + 'clean document activation and outline refresh do not rescan workspace', + ); + final retainedResult = + search.results[search.selected] as TextSearchResult; + final closedBuffer = ref.read(workspaceControllerProvider).activeBuffer!; + check( + await workspace.closeDocumentBuffer(closedBuffer.id), + 'close clean search destination tab', + ); + await Future.delayed(const Duration(milliseconds: 180)); + check( + search.generation == searchGeneration && + identical(search.results[search.selected], retainedResult), + 'closing clean tab retains workspace result without rescanning ' + '(generation $searchGeneration → ${search.generation}, ' + 'selected ${search.selected}, retained ' + '${identical(search.results.elementAtOrNull(search.selected), retainedResult)})', + ); + widgets() + .singleWhere( + (f) => f.decoration?.hintText?.startsWith('Search ') == true, + ) + .focusNode! + .requestFocus(); + await key('enter'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + closedBuffer.filePath, + 'retained workspace result reopens closed clean file', + ); + await wait( + () => widgets() + .map((f) => f.controller) + .whereType() + .any( + (c) => + c.text.contains('scope needle beta') && + c.selection.isValid && + c.selection.textInside(c.text) == 'scope needle', + ), + 'reopened workspace result reveals exact occurrence', + ); + check( + search.generation == searchGeneration, + 'reopening does not rescan workspace', + ); + final samples = >[]; + for (var i = 0; i < 10; i++) { + samples.add( + await measuredQuery( + widget.large + ? (i.isEven ? 'corpus-token42' : 'corpus-token17') + : (i.isEven ? 'needle' : 'scope'), + ), + ); + } + measurements['localSearchApplicationTiming'] = samples; + measurements['localDocumentCount'] = widget.large ? 202 : 2; + if (widget.large) await measuredCancellation('local'); + await key('ctrl+shift+f'); + await query('scope needle'); + await writeMeasurements(); + await screenshot('local-search-light'); + await rapidChanges('local', 'scope needle'); + await key('ctrl+shift+d'); + await query('beta'); + check( + search.results.isEmpty, + 'Documents excludes body-only local matches', + ); + await query('b.md'); + check( + search.results.single is DocumentSearchResult, + 'Documents path ranking', + ); + await key('enter'); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.filePath + ?.endsWith('b.md') == + true, + 'Documents Enter opens file', + ); + await workspace.openActiveFile(file.path); + await key('ctrl+shift+2'); + await wait( + () => widgets().isNotEmpty, + 'Source mode', + ); + final sourceField = widgets().singleWhere( + (f) => f.controller is BusyMarkSourceEditingController, + ); + sourceField.focusNode!.requestFocus(); + final source = ref.read(workspaceControllerProvider).activeText; + final offset = source.indexOf('target'); + sourceField.controller!.selection = TextSelection( + baseOffset: offset, + extentOffset: offset + 6, + ); + await action('Italic'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('*target*'), + 'Source action uses captured selection', + ); + check(await workspace.saveActive(), 'Source formatting saved'); + await key('ctrl+f'); + await query('target'); + check( + search.category == SearchCategory.file && + search.results.every((r) => r is TextSearchResult), + 'Source Ctrl+F uses current-buffer occurrences', + ); + await key('escape'); + await findReplaceNavigation(); + await literalMatcherJourney(); + workspace.updateActiveText( + '${ref.read(workspaceControllerProvider).activeText}\n## Unsaved target heading\n', + ); + await key('ctrl+3'); + check( + !search.active && widgets().isEmpty, + 'Ctrl+3 opens ordinary Outline without a query', + ); + await key('ctrl+f'); + widgets>().single.onSelected( + SearchCategory.outline, + ); + await query('Unsaved target'); + await wait( + () => widgets().any((t) => t.data == 'Unsaved target heading'), + 'unsaved Outline heading', + ); + await key('enter'); + await key('escape'); + check(await workspace.saveActive(), 'save outline edit'); + await historyJourney(); + ref.read(appRouterProvider).go('/'); + await WidgetsBinding.instance.endOfFrame; + await key('ctrl+shift+r'); + await query('a.md'); + await key('enter'); + await wait( + () => + ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/workspace', + 'Recent local reopen', + ); + check( + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + file.path, + 'Recent document identity', + ); + ref.read(appRouterProvider).go('/'); + await WidgetsBinding.instance.endOfFrame; + final browse = widgets() + .where( + (t) => t.title is Text && (t.title as Text).data == 'Browse Notes', + ) + .firstOrNull; + // Welcome uses BusyMark action rows, so invoke the registered command. + await ref.read(busyMarkCommandRegistryProvider).execute('notes.browse'); + await wait( + () => ref.read(workspaceControllerProvider).workspace?.isRemote == true, + 'Browse Notes', + ); + check( + browse == null || browse.onTap != null, + 'Notes navigation available', + ); + await workspace.openNextcloudNote('223e4567-e89b-42d3-a456-426614174000'); + await key('ctrl+f'); + await query('cached needle'); + check( + search.category == SearchCategory.file && + search.results.single is TextSearchResult && + (search.results.single as TextSearchResult).match.bufferId == + ref.read(workspaceControllerProvider).activeBufferId, + 'Notes Ctrl+F searches only the active in-memory note', + ); + await key('ctrl+shift+f'); + final noteSamples = >[]; + for (var i = 0; i < 10; i++) { + noteSamples.add( + await measuredQuery( + widget.large + ? (i.isEven ? 'corpus-token42' : 'category:"Area 7"') + : (i.isEven ? 'cached needle' : 'category:Offline'), + ), + ); + } + measurements['cachedNotesApplicationTiming'] = noteSamples; + measurements['cachedNoteCount'] = widget.repository.notes + .where((n) => n.accountId == '123e4567-e89b-42d3-a456-426614174000') + .length; + await writeMeasurements(); + await query('cached needle'); + check( + search.results.whereType().isNotEmpty, + 'cached Notes occurrences', + ); + await rapidChanges('notes', 'cached needle'); + changeQuery(widget.large ? 'corpus-token42' : 'cached needle'); + await Future.delayed(const Duration(milliseconds: 140)); + check( + await workspace.openNextcloudWorkspace( + '423e4567-e89b-42d3-a456-426614174001', + ), + 'switch account during Notes search', + ); + await query('confidential'); + check( + search.results.whereType().every( + (r) => r.accountId == '423e4567-e89b-42d3-a456-426614174001', + ), + 'Notes results remain in the new account', + ); + await key('escape'); + final otherIds = widget.repository.notes + .where((n) => n.accountId == '423e4567-e89b-42d3-a456-426614174001') + .map((n) => n.localId) + .toSet(); + final otherNotes = widget.repository.notes + .where((n) => n.accountId == '423e4567-e89b-42d3-a456-426614174001') + .length; + await action('New note'); + await wait( + () => + widget.repository.notes + .where( + (n) => n.accountId == '423e4567-e89b-42d3-a456-426614174001', + ) + .length == + otherNotes + 1, + 'Actions New Note retains the active account', + ); + final createdOther = widget.repository.notes.singleWhere( + (n) => + n.accountId == '423e4567-e89b-42d3-a456-426614174001' && + !otherIds.contains(n.localId), + ); + await wait( + () => + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + createdOther.localId && + !ref.read(welcomeNotesActionProvider.notifier).busy, + 'new note is open in its originating account', + ); + check( + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.accountId == + '423e4567-e89b-42d3-a456-426614174001', + 'created note belongs to the explicit active account', + ); + check( + await workspace.openNextcloudWorkspace( + '123e4567-e89b-42d3-a456-426614174000', + ), + 'return to original Notes account', + ); + await key('ctrl+shift+f'); + await query('confidential'); + check( + search.results.isEmpty, + 'previous account results cannot cross accounts', + ); + await query('cached needle'); + await key('ctrl+shift+d'); + await query('needle'); + check(search.results.isEmpty, 'Documents excludes note-body matches'); + await key('escape'); + ref.read(appRouterProvider).go('/'); + await WidgetsBinding.instance.endOfFrame; + final before = widget.repository.notes.length; + await ref.read(busyMarkCommandRegistryProvider).execute('notes.create'); + await wait( + () => widget.repository.notes.length == before + 1, + 'New Note exactly once', + ); + check( + ref + .read(appSettingsControllerProvider) + .recentWorkspaces + .where( + (r) => + r.localId == + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId, + ) + .length == + 1, + 'offline note Recent identity', + ); + check(await workspace.saveActive(), 'offline note durable'); + ref.read(appRouterProvider).go('/'); + await WidgetsBinding.instance.endOfFrame; + await key('ctrl+shift+r'); + await query('Cached individual'); + await key('enter'); + await wait( + () => + ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/workspace', + 'Recent individual note reopen', + ); + check( + ref + .read(workspaceControllerProvider) + .activeBuffer + ?.remoteNote + ?.localId == + '223e4567-e89b-42d3-a456-426614174000', + 'Recent exact account-qualified note', + ); + final project = Directory('${widget.output.path}/writerside'); + await project.create(); + final copied = await Process.run('cp', [ + '-a', + 'test/fixtures/writerside/basic_project/.', + project.path, + ]); + check(copied.exitCode == 0, 'disposable Writerside project'); + final hidden = File('${project.path}/topics/outside-search.topic'); + await hidden.writeAsString( + '

semantic selection

Section text.

', + ); + await workspace.openFolder(project.path); + await key('ctrl+shift+t'); + await query('Outside Search Topic'); + check( + search.category == SearchCategory.topics && + search.results.single is TopicSearchResult, + 'topic outside selected TOC', + ); + check( + (search.results.single as TopicSearchResult).path == hidden.path, + 'actual topic identity', + ); + await key('enter'); + await wait( + () => + ref.read(workspaceControllerProvider).activeBuffer?.filePath == + hidden.path, + 'topic activation', + ); + await key('ctrl+shift+1'); + await wait( + () => widgets().any( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text == 'semantic selection', + ), + 'XML Editor ready', + ); + final semantic = widgets().singleWhere( + (f) => + f.controller is BusyMarkWysiwygTextController && + f.controller!.text == 'semantic selection', + ); + semantic.focusNode!.requestFocus(); + semantic.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 8, + ); + await WidgetsBinding.instance.endOfFrame; + await action('UI Control'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('semantic'), + 'typed Writerside semantic action', + ); + await key('ctrl+z'); + await wait( + () => !ref + .read(workspaceControllerProvider) + .activeText + .contains('semantic'), + 'semantic Undo', + ); + await key('ctrl+shift+z'); + await wait( + () => ref + .read(workspaceControllerProvider) + .activeText + .contains('semantic'), + 'semantic Redo', + ); + check(await workspace.saveActive(), 'semantic save'); + check( + (await hidden.readAsString()).contains('semantic'), + 'semantic serialization persisted', + ); + final savedId = ref.read(workspaceControllerProvider).activeBufferId!; + check(await workspace.closeDocumentBuffer(savedId), 'close saved topic'); + check( + await workspace.openActiveFile(hidden.path), + 'reopen saved topic with project context', + ); + check( + ref.read(workspaceControllerProvider).workspace?.writersideProject != + null, + 'project owner preserved', + ); + // Add this previously orphaned source to the disposable publication only + // after proving Topics can find it outside the TOC. + final tree = File('${project.path}/user-guide.tree'); + await tree.writeAsString( + (await tree.readAsString()).replaceFirst( + '', + '', + ), + ); + await screenshot('writerside-semantic-action'); + await categoryShortcutsJourney(); + await presentationJourney('light'); + await openShortcutSettings(); + await wait( + () => widgets().any((t) => t.data == 'Keyboard Shortcuts'), + 'Keyboard Shortcuts Settings page', + ); + await screenshot('keyboard-shortcuts'); + ref.read(appRouterProvider).go('/workspace'); + await wait( + () => + ref + .read(appRouterProvider) + .routeInformationProvider + .value + .uri + .path == + '/workspace', + 'return from Keyboard Shortcuts Settings', + ); + await ref + .read(appSettingsControllerProvider.notifier) + .setThemeModePreference(BusyMarkThemeModePreference.dark); + await key('ctrl+shift+p'); + await query('Bold'); + await screenshot('search-dark'); + await presentationJourney('dark'); + await key('ctrl+shift+p'); + await query('Bold'); + await windowManager.setSize(const Size(600, 800)); + await wait( + () => + View.of(context).physicalSize.width / + View.of(context).devicePixelRatio < + 700, + 'narrow window size', + ); + await screenshot('search-narrow'); + await presentationJourney('narrow'); + await key('ctrl+shift+p'); + await query('Bold'); + setState(() => _textScale = 1.6); + await screenshot('search-scaled'); + await presentationJourney('scaled'); + await key('ctrl+shift+p'); + await query('Bold'); + await ref.read(appSettingsControllerProvider.notifier).setLocaleTag('ar'); + ref.read(searchSessionProvider.notifier).query(''); + await wait(() => !search.searching, 'RTL action results'); + await screenshot('search-rtl'); + await presentationJourney('rtl'); + await key('ctrl+shift+p'); + await query(''); + await ref.read(appSettingsControllerProvider.notifier).setLocaleTag('en'); + setState(() => _textScale = 1); + await WidgetsBinding.instance.endOfFrame; + await key('escape'); + await keymapJourney(); + await recentPresentationJourney(); + measurements['rssBeforeThirtyOpenCloseCycles'] = ProcessInfo.currentRss; + measurements['fileDescriptorsBeforeCycles'] = await Directory( + '/proc/self/fd', + ).list().length; + final cycles = Stopwatch()..start(); + for (var cycle = 0; cycle < 30; cycle++) { + await key('ctrl+shift+p'); + await query(cycle.isEven ? 'Bold' : 'Italic'); + await key('escape'); + } + measurements['thirtyOpenCloseCyclesMicroseconds'] = + cycles.elapsedMicroseconds; + await Future.delayed(const Duration(seconds: 2)); + check( + !search.active && + search.results.isEmpty && + ref.read(searchSessionProvider.notifier).commandTarget == null, + 'closed search releases results and command targets', + ); + measurements['rssAfterThirtyOpenCloseCycles'] = ProcessInfo.currentRss; + measurements['fileDescriptorsAfterCycles'] = await Directory( + '/proc/self/fd', + ).list().length; + measurements['rssBytes'] = ProcessInfo.currentRss; + measurements['maximumRssBytes'] = ProcessInfo.maxRss; + stdout.writeln(jsonEncode(measurements)); + await File( + '${widget.output.path}/measurements.json', + ).writeAsString(jsonEncode(measurements)); + // The native runner can return success after SystemNavigator.pop even + // when Dart's exitCode was set by an unexpected framework error. + if (exitCode != 0) exit(exitCode); + await workspace.discardRecoveryForShutdown(); + if (exitCode != 0) exit(exitCode); + await SystemNavigator.pop(); + } catch (error, stack) { + stderr.writeln('$error\n$stack'); + await screenshot('failure'); + exit(1); + } + } + + Future writeMeasurements() => File( + '${widget.output.path}/measurements.json', + ).writeAsString(jsonEncode(measurements)); +} diff --git a/tools/test_native_shortcut_metadata.sh b/tools/test_native_shortcut_metadata.sh new file mode 100755 index 00000000..445763eb --- /dev/null +++ b/tools/test_native_shortcut_metadata.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$(mktemp -d /tmp/busymark-native-shortcuts.XXXXXX)" +trap 'rm -rf "$fixture_root"' EXIT +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + "$project_root/tools/native_shortcut_metadata_probe.cc" \ + -I"$project_root/linux/runner" $(pkg-config --cflags --libs gtk+-3.0) \ + -o "$fixture_root/probe" +"$fixture_root/probe" From 122627c7104af92309277cc1e52b8b2deb6a7cd3 Mon Sep 17 00:00:00 2001 From: Albert G Date: Wed, 21 Oct 2026 03:46:53 -0700 Subject: [PATCH 3/4] Fix/performance issue (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove eager video resources and completed-render disk barriers * Eliminate repeated XML span scans and editor tree traversals * Publish prepared documents without weakening persistence barriers * Make asynchronous widget and subprocess tests wait for real readiness * Apply cache regression test style checks * Wait for saved retention policy before startup history maintenance * Wait for history and workspace readiness in widget fixtures * perf: reduce bounded render-cache backpressure * fix(linux): retain video widgets until host cleanup * perf(snap): generate portable GTK icon caches during packaging * test: advance the UI clock while awaiting settings I/O * perf: expose restoration queues and capture blocked native operations * Implement natural application close verification and Linux CI smoke testing * Add `tools/application_close_smoke.py` and `tools/window_close_smoke.dart` to verify natural process exit, clean shutdown persistence, and child process cleanup upon X11 window close requests. * Support application close scenarios covering plain windows, pending visualization rendering, active video playback, dirty-document save/discard/cancel flows, and document reopening. * Add native shutdown tracing logs in `web_render_host.cc` and `video_player_host.cc` when `BUSYMARK_NATIVE_TRACE=1` is set. * Integrate application close smoke steps into Linux CI workflow to require 100 natural closes for release builds and strictly confined Snap packages. * Add `close-testsrc.mp4` test fixture for video player shutdown tests. * Document live application close procedures and acceptance requirements in `docs/development/visualizations.md`. * Require complete child observation and verified close-test cleanup * Wait for child workers after their thread-group leader exits A confined WebKit close left its leader in Z state with another thread still running. Do not classify that process as terminated or stop tracking its children. Preserve natural-exit failures while verifying bounded identity-pinned cleanup, including a worker that ignores SIGTERM. The actual fork/pthread_exit regression and all 13 observer tests pass in both system and Conda Python. This corrects observation; it does not resolve the separate native shutdown failure. * Add pending-initialization scenario and controlled harness to application close smoke tests * Add the `pending-initialization` smoke test scenario and an isolated `PENDING_HARNESS` that preserves the production content security policy while withholding readiness. * Add unit test coverage verifying exact content security policy parity between the controlled pending harness and production templates. * Add active buffer dirty-save validation and hardware keyboard event monitoring to the window close smoke test runner. * Update visualization documentation and thread-group process cleanup details. * Strengthen natural-close observation and document readiness checks * Correct clipboard path expectations and asynchronous test diagnostics * Capture Local History operation state on checkpoint failures * test: correct real I/O scheduling and bound suite resource use Start clipboard and dictionary fixture operations in the real event loop so filesystem continuations do not depend on each fake widget frame. Add a controlled import scheduling regression while preserving the original behavior assertions and wall budget. Bound independent test-file workers to two, retain internal race concurrency, and add opt-in PID/interval diagnostics for scoped syscall correlation. Document the earlier Source helper budget change separately from performance acceptance. No production source, durability barrier, or timeout is changed. * Require the reviewed C6 engine in Linux development and Snap builds Pin the existing framework, engine and toolchain with the unchanged lifetime, regression and separate ATK patches. Build and cache matching debug, profile and release inputs privately; verify actual CMake assembly, bundle bytes and mapped runtime identity without an official-engine fallback. Integrate native lifetime and natural-exit acceptance into CI and genuine strict Snap builds. Preserve engine ELF notes from the packager's unnecessary rewrite, make cached native fixtures portable, and track resource aliases before staging so incremental fixture rebuilds work. Keep widget-test acceptance separate from the GTK engine. Full-suite storage-sensitive assertion failures remain under investigation; this commit makes no release-readiness or performance claim. * Retain bounded Chromium startup diagnostics in functional tests * Handle retired Yaru windows in pinned Linux plugin builds * Reuse unchanged durable Local History tombstone journals * Protect unattended close probes from owned-window input interference * Wait for workspace recovery publication before normal shutdown decisions * Audit normal window input and distinguish activation from keyboard focus * Record unavailable desktop observation before launch and use system Python in CI * Reject unmanaged attach that cannot verify the loaded GTK engine * Keep long-path topic rename previews scrollable Place rename metadata and shared comparison rows in one lazy scroll body while retaining the header and action footer. Preserve the existing flow assertions and cover long absolute paths; the complete Flutter suite passes. * Bound restored tab reads and reuse acknowledged view settings Admit up to four independent restored buffers, preserve order, and drain admitted work on failure. Reconcile unchanged view settings inside the durable write queue; failed loads or saves cannot acknowledge new state. Recovery ownership and close barriers remain; controlled before/after regressions and compiled restoration checks pass. * Trace startup phases and require XI2-aware input observation Separate map detection from observer snapshot I/O and add opt-in native/editor phase boundaries. Observe GTK keyboard delivery through owned-window XI2 as well as core events, treating unavailable observation as incomplete. Add a live disposable-display CI regression that fails with the former core-only observer, plus event-scope and failure tests. Preserve natural-exit and identity-verified cleanup acceptance. * Preserve child startup diagnostics and isolate browser font configuration * Initialize Linux portal settings asynchronously from local preferences Keep C6 lifetime/ATK and all SDK/toolchain pins unchanged. Acquire the portal asynchronously with cancellation, weak ownership, coherent settings publication and stale-reply protection. Preserve saved app appearance in the first frame using a one-use acknowledged local-settings handoff. Add controlled owned-bus and compiled-application regressions through the maintained close observer. Preserve the failing complete-startup GetAll gate: production GTK still synchronously loads unused Inhibit properties; the separate private GTK correction is not integrated. Require content-based native build invalidation and verified export stamps so changed reviewed sources cannot reuse stale objects. Validated all three native modes (771+4+21 cases each), 4591 widget tests, 83 observer/build/watchdog tests, and affected compiled standalone settings, natural-close and document checks. Strict final-artifact checks remain in progress and are not represented as passing. * Remove trailing whitespace from the portal fixture * Isolate all GTK accent probe settings and cache directories Keep the private AT-SPI bus and fatal-warning coverage while preventing GTK, GSettings and font-cache access through inherited HOME/XDG paths. The actual native lifecycle probe passes with complete child observation and no cleanup survivors. * Track the actual GTK content window in close input audits Pre-map discovery could bind to GTK’s hidden leader, and children realized after initial selection escaped keyboard/focus observation. Require the normal content window, verify its mapped identity, and refresh bounded subtree subscriptions through lifecycle notifications. Report subscription times without claiming retrospective input coverage. The maintained live GTK/XI2 test fails against the unchanged observer and passes after correction, covering pre-content helpers, late child input, and retirement. The 47 observer and 33 engine-tool tests pass. Frozen compiled plain, rendering, pending initialization, and Save/Discard/Cancel/reopen checks pass with short isolated runtime paths. Historical altered-fixture and overlong-runtime failures remain external evidence; this does not establish their application-side cause. * Run private permission transactions on a bounded worker isolate * Verify launch failures and watchdog descendant cleanup * Preserve close-observer launch timing across setup failures * Restore official Flutter workflows and preserve upstream correction evidence * Handle owned X11 window retirement during input audits * Verify descendant admission and anchor owned process captures * Validate every Yaru generated destination before writing * Keep independent CI checks running after native gate failures * Require informative content in restoration image probes * Remove redundant visualization supersession check * Reject blocked Yaru parent paths before modifying outputs * Exercise official SDK build selection with an executable control * Test History stores with prepared, owned runtime fixtures * Clean up bounded tracing and preserved patch checks * Repair permission normalization for unreadable owned directories On directory-open EACCES, pin the inode with O_PATH and protect it through the held procfs descriptor on the existing serial worker. Preserve no-follow checks, error propagation, descriptor cleanup and publication ordering. Cover actual mode-0300 atomic publication, worker progress during pathname replacement, symlink rejection and native failure recovery. * Correlate performance trace spans by invocation Use process-scoped native IDs and existing UUIDs for Dart spans, including product engines without usable TimelineTask IDs. Keep operation names as filters and reject ambiguous legacy overlap instead of losing pending work. Cover both native completion orders with real GDB, Dart JIT/AOT and worker producers, process/producer scope and historical log compatibility. * Protect unreadable state files and recovery lock permissions * Validate Yaru generated paths against the selected build root * Respect explicit Flutter SDK and staging destinations * Prepare History process fixture through public Flutter build * Correct upstream shutdown review reference * Distinguish recent-workspace persistence warnings from open failures * Prepare History process runtime only for interprocess tests * Handle reaped process snapshots without admitting unowned births * Separate application CI from native and upstream acceptance * Share prerequisites across native and upstream CI checks * Archive runtime experiments and retain product acceptance * Restore unmodified upstream Yaru window integration Remove local native plugin patching and its dedicated build and probe infrastructure. Preserve the original locked dependency and initialization, and extend the maintained compiled fixture to verify actual window controls, state events and dragging. Functional document and confined media checks pass. Official-runtime early-close fatal exits remain release blockers; no Yaru-specific BusyMark failure was established. * Remove startup investigation machinery and obsolete engine receipts Unwrap tracing callbacks into direct awaited operations and delete the Dart, native and test-only tracing protocols. Retain ordinary diagnostics and the focused pending-initialization shutdown assertion. Require an actual ELF engine library in the natural-close observer, preserving mapped inode/device checks, retained descriptors and hashing. Supervise browser descendants before deleting isolated profiles and keep bounded failure output. * Restore automatic release-target native regression coverage Run normal pull requests to main and release/v0.6.3 through the integrated application workflow. Restore the bounded GTK host, WebKit, renderer and compiled visualization/PDF checks while keeping stress and strict Snap as explicit release acceptance. Prepare actual desktop, sandbox, artifact-inspection and named-Snap session prerequisites. Keep independent checks running after other failures and test trigger coverage, invocation and prerequisite relationships. Update maintained build and regression commands without historical investigation prose. * Keep runtime documentation focused on maintained contracts * Reject unreadable settings before acknowledging deduplicated saves * Provide Xvfb for the native shortcut metadata check * Restore automatic native and strict Snap product checks * Make Recent persistence warnings accurate after opening or saving * fix: preserve linked application state directories * refactor: remove startup investigation machinery and redundant work --- .github/workflows/flutter-linux-native.yml | 620 ++++++++++ .github/workflows/flutter-linux.yml | 399 ++----- README.md | 25 +- docs/development/application-smoke-tests.md | 60 + docs/development/export-verification.md | 10 +- docs/development/history.md | 33 +- docs/development/linux-engine.md | 44 + docs/development/visualizations.md | 68 +- docs/snap-confinement.md | 5 + lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 5 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_pt_BR.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_CN.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 5 + lib/l10n/generated/app_localizations_de.dart | 5 + lib/l10n/generated/app_localizations_en.dart | 5 + lib/l10n/generated/app_localizations_es.dart | 5 + lib/l10n/generated/app_localizations_et.dart | 5 + lib/l10n/generated/app_localizations_fa.dart | 5 + lib/l10n/generated/app_localizations_fr.dart | 5 + lib/l10n/generated/app_localizations_hi.dart | 5 + lib/l10n/generated/app_localizations_id.dart | 5 + lib/l10n/generated/app_localizations_it.dart | 5 + lib/l10n/generated/app_localizations_ja.dart | 5 + lib/l10n/generated/app_localizations_ko.dart | 5 + lib/l10n/generated/app_localizations_nb.dart | 5 + lib/l10n/generated/app_localizations_nl.dart | 5 + lib/l10n/generated/app_localizations_pl.dart | 5 + lib/l10n/generated/app_localizations_pt.dart | 10 + lib/l10n/generated/app_localizations_ru.dart | 5 + lib/l10n/generated/app_localizations_tr.dart | 5 + lib/l10n/generated/app_localizations_uk.dart | 5 + lib/l10n/generated/app_localizations_vi.dart | 5 + lib/l10n/generated/app_localizations_zh.dart | 10 + lib/main.dart | 13 +- lib/src/app/app_settings.dart | 191 +++- lib/src/app/busymark_app.dart | 4 +- lib/src/core/linux_private_permissions.dart | 163 +++ .../wysiwyg/writerside_editing_adapter.dart | 10 +- .../wysiwyg/wysiwyg_document_controller.dart | 96 +- .../local_history_comparison_view.dart | 18 +- .../local_history_controller.dart | 16 + .../local_history/local_history_store.dart | 59 +- lib/src/markdown/markdown_parser.dart | 45 +- .../visualization/visualization_cache.dart | 78 +- .../visualization_coordinator.dart | 8 +- .../presentation/welcome_screen.dart | 37 +- .../presentation/workspace_screen.dart | 144 ++- lib/src/workspace/recent_navigation.dart | 28 +- lib/src/workspace/recovery_persistence.dart | 15 +- lib/src/workspace/session_persistence.dart | 14 +- lib/src/workspace/workspace_controller.dart | 244 +++- lib/src/workspace/workspace_message.dart | 3 + .../writerside_document_parser.dart | 8 + linux/CMakeLists.txt | 11 +- linux/cmake/BundleInstall.cmake | 19 + linux/runner/video_player_host.cc | 131 ++- linux/runner/web_render_host.cc | 60 +- snap/snapcraft.yaml | 140 ++- test/fixtures/media/README.md | 11 + test/fixtures/media/close-testsrc.mp4 | Bin 0 -> 84025 bytes test/src/ai_packaging_audit_test.dart | 7 +- test/src/app_router_test.dart | 6 +- test/src/app_settings_test.dart | 544 ++++++++- test/src/app_smoke_test.dart | 1018 ++++++++++++----- test/src/asset_ingestion_service_test.dart | 4 +- test/src/document_persistence_test.dart | 125 ++ test/src/headless_chrome_support_test.dart | 181 +++ test/src/headless_chrome_test.dart | 42 + test/src/linked_state_test.dart | 101 ++ test/src/linux_private_permissions_test.dart | 348 ++++++ test/src/local_history_controller_test.dart | 5 + .../local_history_startup_policy_test.dart | 174 +++ test/src/local_history_store_test.dart | 535 ++++++++- test/src/local_history_workspace_test.dart | 48 +- test/src/markdown_parser_test.dart | 33 + test/src/native_headerbar_audit_test.dart | 79 +- test/src/nextcloud_notes_ui_test.dart | 115 +- test/src/pdf_title_page_ui_test.dart | 2 + test/src/source_editor_widget_test.dart | 22 +- test/src/spelling_packaging_audit_test.dart | 2 +- test/src/spelling_service_test.dart | 10 +- test/src/spelling_workspace_test.dart | 1 + test/src/visualization_coordinator_test.dart | 126 +- test/src/visualization_models_test.dart | 128 +++ .../visualization_packaging_audit_test.dart | 37 +- test/src/widget_async_test.dart | 20 + test/src/workspace_controller_test.dart | 322 +++++- test/src/workspace_safety_test.dart | 21 +- test/src/writerside_document_test.dart | 46 + ...writerside_native_list_inspector_test.dart | 37 + .../src/writerside_template_dialogs_test.dart | 12 +- test/src/wysiwyg_clipboard_test.dart | 115 +- test/support/headless_chrome.dart | 238 +++- test/support/local_history_store_process.dart | 33 +- test/support/private_permissions_barrier.c | 73 ++ test/support/widget_async.dart | 16 +- tools/application_close_smoke.py | 587 ++++++++++ tools/build_install_snap_local.sh | 105 +- tools/history_visual_smoke.dart | 226 ++-- tools/owned_processes.py | 301 +++++ tools/run_owned_fixture.py | 128 +++ tools/sidebar_search_smoke.dart | 4 + tools/test_application_close_smoke.py | 628 ++++++++++ tools/test_gtk_accent_host.sh | 6 +- tools/test_official_flutter_workflow.py | 692 +++++++++++ tools/test_owned_processes.py | 147 +++ tools/test_run_owned_fixture.py | 173 +++ tools/test_web_render_host.sh | 16 +- tools/web_render_host_probe.cc | 203 +++- tools/window_close_smoke.dart | 375 ++++++ 131 files changed, 9760 insertions(+), 1435 deletions(-) create mode 100644 .github/workflows/flutter-linux-native.yml create mode 100644 docs/development/application-smoke-tests.md create mode 100644 docs/development/linux-engine.md create mode 100644 lib/src/core/linux_private_permissions.dart create mode 100644 linux/cmake/BundleInstall.cmake create mode 100644 test/fixtures/media/README.md create mode 100644 test/fixtures/media/close-testsrc.mp4 create mode 100644 test/src/headless_chrome_support_test.dart create mode 100644 test/src/linked_state_test.dart create mode 100644 test/src/linux_private_permissions_test.dart create mode 100644 test/src/local_history_startup_policy_test.dart create mode 100644 test/src/widget_async_test.dart create mode 100644 test/support/private_permissions_barrier.c create mode 100644 tools/application_close_smoke.py create mode 100644 tools/owned_processes.py create mode 100644 tools/run_owned_fixture.py create mode 100644 tools/test_application_close_smoke.py create mode 100644 tools/test_official_flutter_workflow.py create mode 100644 tools/test_owned_processes.py create mode 100644 tools/test_run_owned_fixture.py create mode 100644 tools/window_close_smoke.dart diff --git a/.github/workflows/flutter-linux-native.yml b/.github/workflows/flutter-linux-native.yml new file mode 100644 index 00000000..18ce63d5 --- /dev/null +++ b/.github/workflows/flutter-linux-native.yml @@ -0,0 +1,620 @@ +name: Linux native acceptance + +# Automatic CI reuses the ordinary package and desktop checks. Stress campaigns +# remain explicit release acceptance; native fatal exits still fail their checks. +on: + workflow_dispatch: + inputs: + run-stress: + description: Include hundred-close, compiled-close and debug/profile campaigns + type: boolean + default: true + workflow_call: + inputs: + run-stress: + type: boolean + default: false + +permissions: + contents: read + +jobs: + verify: + name: Standalone release stress + if: ${{ inputs.run-stress }} + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install Linux build dependencies + id: linux-dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + apparmor-profiles \ + at-spi2-core \ + bubblewrap \ + clang \ + cmake \ + dbus-daemon \ + gsettings-desktop-schemas \ + dconf-cli \ + fonts-noto-core \ + fonts-noto-cjk \ + fonts-noto-mono \ + ninja-build \ + pkg-config \ + libgtk-3-dev \ + libglib2.0-dev \ + libpango1.0-dev \ + libhandy-1-dev \ + libsecret-1-dev \ + libwebkit2gtk-4.1-dev \ + python3-gi \ + rsync \ + xvfb \ + xz-utils + + - name: Enable Bubblewrap sandbox for WebKitGTK tests + id: web-sandbox + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} + run: | + probe_bwrap() { + bwrap --ro-bind / / --unshare-user --unshare-pid \ + --unshare-net /usr/bin/true + } + if ! probe_bwrap; then + profile=/usr/share/apparmor/extra-profiles/bwrap-userns-restrict + test -f "$profile" + sudo install -m 0644 "$profile" \ + /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace \ + /etc/apparmor.d/bwrap-userns-restrict + fi + probe_bwrap + + - name: Set up Node.js for bundled web engines + id: node + if: ${{ !cancelled() }} + uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + cache-dependency-path: tools/visualization/package-lock.json + + - name: Select official pinned Flutter SDK + id: flutter-sdk + if: ${{ !cancelled() }} + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.47.5' + cache: true + + - name: Enable Linux desktop + if: ${{ !cancelled() && steps.flutter-sdk.outcome == 'success' }} + run: flutter config --enable-linux-desktop + + - name: Resolve locked dependencies + id: flutter-packages + if: ${{ !cancelled() && steps.flutter-sdk.outcome == 'success' }} + run: flutter pub get --enforce-lockfile + + - name: Build Linux release + id: release-build + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' && steps.node.outcome == 'success' && steps.flutter-packages.outcome == 'success' }} + run: flutter build linux --release + + # Explicit status conditions keep independent checks running after a + # native failure. Failed checks still fail the job; none is optional. + - name: Require 100 natural closes of the shipping release + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} + run: | + xvfb-run -a -s '-screen 0 1280x1024x24' \ + dbus-run-session -- env GDK_BACKEND=x11 \ + /usr/bin/python3 -u tools/application_close_smoke.py \ + --output "${RUNNER_TEMP}/close-release" --cycles 100 --plain \ + --expected-engine build/linux/x64/release/bundle/lib/libflutter_linux_gtk.so -- \ + build/linux/x64/release/bundle/busymark + + + - name: Build compiled-close fixture + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} + id: release-fixture + run: flutter build linux --release --no-pub -t tools/window_close_smoke.dart + + - name: Exercise live application close, persistence, media and reopening + if: ${{ !cancelled() && steps.release-fixture.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} + run: | + xvfb-run -a -s '-screen 0 1280x1024x24' \ + dbus-run-session -- env GDK_BACKEND=x11 \ + /usr/bin/python3 -u tools/application_close_smoke.py \ + --output "${RUNNER_TEMP}/close-live-application" --cycles 3 --fresh-profile \ + --expected-engine build/linux/x64/release/bundle/lib/libflutter_linux_gtk.so \ + --scenarios plain visualization pending-initialization \ + dirty-save dirty-discard dirty-cancel video \ + --video test/fixtures/media/close-testsrc.mp4 -- \ + build/linux/x64/release/bundle/busymark + + - name: Preserve natural-close evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: natural-close-release + path: | + ${{ runner.temp }}/close-release + ${{ runner.temp }}/close-live-application + if-no-files-found: ignore + + + snap: + name: Native release acceptance (snap) + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install Snap artifact inspection prerequisites + id: snap-inspection + run: | + sudo apt-get update + sudo apt-get install -y squashfs-tools + + - name: Prepare clean Snap artifact selection + run: | + rm -f ./*.snap + touch "${RUNNER_TEMP}/busymark-snap-build-start" + + - name: Build strict Snap from a clean core24 environment (attempt 1) + id: snapcraft + uses: snapcore/action-build@v1 + continue-on-error: true + + - name: Retry strict Snap build after an infrastructure failure + id: snapcraft-retry + if: steps.snapcraft.outcome == 'failure' + uses: snapcore/action-build@v1 + + - name: Select built Snap + if: ${{ !cancelled() && steps.snap-inspection.outcome == 'success' && (steps.snapcraft.outcome == 'success' || steps.snapcraft-retry.outcome == 'success') }} + id: snap-artifact + env: + PRIMARY_SNAP: ${{ steps.snapcraft.outputs.snap }} + PRIMARY_OUTCOME: ${{ steps.snapcraft.outcome }} + RETRY_SNAP: ${{ steps.snapcraft-retry.outputs.snap }} + RETRY_OUTCOME: ${{ steps.snapcraft-retry.outcome }} + run: | + if [ "$PRIMARY_OUTCOME" = success ]; then + snap_path="$PRIMARY_SNAP" + else + test "$PRIMARY_OUTCOME" = failure + test "$RETRY_OUTCOME" = success + snap_path="$RETRY_SNAP" + fi + test -n "$snap_path" + test -f "$snap_path" + test "$snap_path" -nt "${RUNNER_TEMP}/busymark-snap-build-start" + snap_path="$(realpath "$snap_path")" + case "$snap_path" in + "$GITHUB_WORKSPACE"/*.snap) ;; + *) echo "Refusing Snap outside the clean checkout: $snap_path" >&2; exit 1 ;; + esac + test "$(unsquashfs -cat "$snap_path" snap/snapcraft.yaml | sha256sum | cut -d' ' -f1)" = \ + "$(sha256sum snap/snapcraft.yaml | cut -d' ' -f1)" + echo "snap=$snap_path" >> "$GITHUB_OUTPUT" + + - name: Upload Snap artifact + if: ${{ !cancelled() && steps.snap-artifact.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: busymark-snap + path: ${{ steps.snap-artifact.outputs.snap }} + + - name: Install desktop smoke dependencies + id: strict-desktop + if: ${{ !cancelled() && steps.snap-artifact.outcome == 'success' }} + run: | + sudo apt-get update + sudo apt-get install -y weston xvfb python3-gi gir1.2-gtk-3.0 gsettings-desktop-schemas dconf-cli dbus-daemon dbus-user-session + + - name: Install strict Snap + id: strict-install + if: ${{ !cancelled() && steps.snap-artifact.outcome == 'success' && steps.strict-desktop.outcome == 'success' }} + run: >- + sudo snap install --dangerous + "${{ steps.snap-artifact.outputs.snap }}" + + - name: Prepare strict-Snap user session + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' }} + id: strict-session + run: | + session_uid="$(id -u)" + sudo systemctl start "user@${session_uid}.service" + runtime_dir="/run/user/${session_uid}" + test -S "$runtime_dir/bus" + echo "XDG_RUNTIME_DIR=$runtime_dir" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=unix:path=$runtime_dir/bus" >> "$GITHUB_ENV" + + - name: Enable parallel Snap instances + if: ${{ !cancelled() && inputs.run-stress && steps.strict-install.outcome == 'success' }} + id: strict-parallel + run: sudo snap set system experimental.parallel-instances=true + + - name: Install disposable close instance + if: ${{ !cancelled() && inputs.run-stress && steps.strict-install.outcome == 'success' && steps.strict-parallel.outcome == 'success' }} + id: strict-close-install + run: | + sudo snap install --dangerous --name=busymark_close \ + "${{ steps.snap-artifact.outputs.snap }}" + + - name: Require 100 natural closes under strict confinement + if: ${{ !cancelled() && inputs.run-stress && steps.strict-close-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + xvfb-run -a -s '-screen 0 1280x1024x24' \ + env GDK_BACKEND=x11 \ + /usr/bin/python3 -u tools/application_close_smoke.py \ + --output "${RUNNER_TEMP}/close-strict-snap" --cycles 100 \ + --snap-instance busymark_close \ + --expected-engine /snap/busymark_close/current/lib/libflutter_linux_gtk.so + + - name: Preserve strict natural-close evidence + if: ${{ always() && inputs.run-stress }} + uses: actions/upload-artifact@v7 + with: + name: natural-close-strict-snap + path: ${{ runner.temp }}/close-strict-snap + if-no-files-found: ignore + + - name: Verify GTK SVG icon loader and portable icon caches + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + snap run --shell busymark -c ' + set -eu + test ! -e \ + "$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/librsvg-2.so.2" + query="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders" + loader="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so" + "$query" "$loader" | grep -q "\"svg\" 6 \"gdk-pixbuf\"" + cache_tool="$SNAP_DESKTOP_RUNTIME/usr/bin/gtk-update-icon-cache" + test -x "$cache_tool" + for icon_theme in \ + "$SNAP"/usr/share/icons/* "$SNAP"/share/icons/*; do + if [ -f "$icon_theme/index.theme" ]; then + if [ -f "$icon_theme/icon-theme.cache" ]; then + "$cache_tool" --validate "$icon_theme" + fi + fi + done + test -s "$SNAP/usr/share/icons/Yaru/icon-theme.cache" + ' + + - name: Verify shared runtimes, packaged tools, media, and resources + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + snap run --shell busymark -c ' + set -eu + inspect_dependencies() { + dependency_object="$1" + if dependency_output="$(ldd "$dependency_object" 2>&1)"; then + : + else + printf "Dependency inspection failed: %s\n%s\n" \ + "$dependency_object" "$dependency_output" >&2 + exit 1 + fi + case "$dependency_output" in + *"not found"*) + printf "Unresolved dependencies: %s\n%s\n" \ + "$dependency_object" "$dependency_output" >&2 + exit 1 + ;; + esac + } + require_resolution() { + required_library="$1" + required_path="$2" + resolved_path="$(printf "%s\n" "$dependency_output" | \ + awk -v name="$required_library" \ + "\$1 == name && \$2 == \"=>\" { print \$3; exit }")" + if [ -z "$resolved_path" ] || \ + ! expected_real="$(readlink -e "$required_path")" || \ + ! resolved_real="$(readlink -e "$resolved_path")" || \ + [ "$resolved_real" != "$expected_real" ]; then + printf "Unexpected resolution for %s in %s; expected %s\n%s\n" \ + "$required_library" "$dependency_object" \ + "$required_path" "$dependency_output" >&2 + exit 1 + fi + } + app_lib="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" + inspect_dependencies "$SNAP/busymark" + for library in \ + libhandy-1.so.0 libsecret-1.so.0 libwebkit2gtk-4.1.so.0 \ + libgtk-3.so.0 libglib-2.0.so.0 libpango-1.0.so.0; do + test ! -e "$app_lib/$library" + require_resolution "$library" \ + "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/$library" + done + graphics_lib="$SNAP/gpu-2404/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" + for library in \ + libX11.so.6 libXdamage.so.1 libXext.so.6 libXfixes.so.3 \ + libxcb-shm.so.0 libxcb.so.1 libwayland-client.so.0 \ + libwayland-cursor.so.0 libwayland-egl.so.1; do + test ! -e "$app_lib/$library" + test -e "$graphics_lib/$library" + require_resolution "$library" "$graphics_lib/$library" + done + for helper in WebKitWebProcess WebKitNetworkProcess WebKitGPUProcess; do + test -x "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/webkit2gtk-4.1/$helper" + done + test -x "$SNAP/usr/bin/git" + test -x "$SNAP/usr/bin/ssh" + test -x "$SNAP/usr/bin/setsid" + inspect_dependencies "$SNAP/usr/lib/git-core/git-remote-http" + require_resolution libcurl-gnutls.so.4 \ + "$app_lib/libcurl-gnutls.so.4" + GIT_EXEC_PATH="$SNAP/usr/lib/git-core" \ + "$SNAP/usr/bin/git" --exec-path | grep -Fx "$SNAP/usr/lib/git-core" + "$SNAP/usr/bin/ssh" -V + provider_plugins="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" + for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ + libgstogg.so libgsttheora.so libgstvpx.so; do + test -f "$provider_plugins/$plugin" + inspect_dependencies "$provider_plugins/$plugin" + done + private_plugins="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" + for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ + libgstogg.so libgsttheora.so libgstvpx.so; do + test ! -e "$private_plugins/$plugin" + done + for plugin in libgstlibav.so libgstvideoparsersbad.so \ + libgstmpeg2dec.so; do + test -f "$private_plugins/$plugin" + inspect_dependencies "$private_plugins/$plugin" + done + test -L "$SNAP/share/busymark/fonts" + test "$(readlink "$SNAP/share/busymark/fonts")" = \ + ../../usr/share/fonts/truetype/noto + test -f "$SNAP/share/busymark/fonts/NotoSans-Regular.ttf" + test -f "$SNAP/usr/share/doc/fonts-noto-core/copyright" + test -f "$SNAP/usr/share/doc/fonts-noto-mono/copyright" + for resource_kind in themes icons; do + for resource_name in Yaru Yaru-dark; do + resource="$SNAP/share/$resource_kind/$resource_name" + test -L "$resource" + test -e "$resource" + done + done + ' + + - name: Install one strict-Snap dictionary and verify refresh/offline use + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + report="$HOME/snap/busymark/common/spelling-release.json" + rm -f "$report" + xvfb-run -a -s '-screen 0 1280x1024x24' \ + env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 snap run busymark \ + --spelling-release-smoke="$report" + python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["bundledDictionaryFileCount"] == 0, report; assert report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report' "$report" + test -d "$HOME/snap/busymark/common/spelling/dictionaries/downloaded/en-US" + before_revision="$(snap list busymark | awk 'NR == 2 {print $3}')" + sudo snap install --dangerous \ + "${{ steps.snap-artifact.outputs.snap }}" + after_revision="$(snap list busymark | awk 'NR == 2 {print $3}')" + test -n "$before_revision" + test -n "$after_revision" + test "$before_revision" != "$after_revision" + sudo snap disconnect busymark:network + trap 'sudo snap connect busymark:network >/dev/null' EXIT + xvfb-run -a -s '-screen 0 1280x1024x24' \ + env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 snap run busymark \ + --spelling-release-smoke="$report" + python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert not report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report; assert report["checks"]["personalWordPresentBeforeRun"], report' "$report" + sudo snap connect busymark:network + trap - EXIT + + - name: Exercise strict Snap visualization and PDF paths under X11 + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + report="$HOME/snap/busymark/common/visualization-release-x11.json" + mkdir -p "$(dirname "$report")" + timeout --signal=TERM 300s \ + xvfb-run -a -s '-screen 0 1280x1024x24' \ + 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" + test -s "$HOME/snap/busymark/common/visualization-smoke.pdf" + + - name: Exercise strict Snap spelling and visualization under Wayland + if: ${{ !cancelled() && steps.strict-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + runtime_dir="/run/user/$(id -u)" + sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" + XDG_RUNTIME_DIR="$runtime_dir" \ + weston --backend=headless-backend.so --socket=wayland-99 \ + --idle-time=0 --log="${RUNNER_TEMP}/weston-snap.log" & + weston_pid=$! + sudo snap disconnect busymark:network + cleanup_wayland() { + result=$? + trap - EXIT + if ! sudo snap connect busymark:network >/dev/null; then + result=1 + fi + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + return "$result" + } + trap cleanup_wayland EXIT + for attempt in {1..40}; do + if [ -S "$runtime_dir/wayland-99" ]; then + break + fi + if ! kill -0 "$weston_pid" 2>/dev/null; then + cat "${RUNNER_TEMP}/weston-snap.log" + exit 1 + fi + sleep 0.25 + done + test -S "$runtime_dir/wayland-99" + export runtime_dir + checks_failed=0 + bash -euo pipefail <<'SPELLING' || checks_failed=1 + spelling_report="$HOME/snap/busymark/common/spelling-release-wayland.json" + XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ + GDK_BACKEND=wayland BUSYMARK_RELEASE_SMOKE=1 \ + LIBGL_ALWAYS_SOFTWARE=1 \ + timeout --signal=TERM 300s snap run busymark \ + --spelling-release-smoke="$spelling_report" + python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["sourceEditorTransaction"], report; assert report["checks"]["formattedRichEditorTransaction"], report; assert report["checks"]["tableCellEditorTransaction"], report' "$spelling_report" + SPELLING + bash -euo pipefail <<'COMPILED' || checks_failed=1 + 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 \ + 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" + test -s "$HOME/snap/busymark/common/visualization-smoke.pdf" + COMPILED + exit "$checks_failed" + + - name: Prepare a separate compiled-close fixture package + if: ${{ !cancelled() && inputs.run-stress && steps.strict-install.outcome == 'success' }} + id: strict-fixture-source + run: | + cp "${{ steps.snap-artifact.outputs.snap }}" "${RUNNER_TEMP}/shipping-main.snap" + cp snap/snapcraft.yaml "${RUNNER_TEMP}/shipping-snapcraft.yaml" + rm -f ./*.snap + python3 - <<'PYCODE' + from pathlib import Path + recipe = Path('snap/snapcraft.yaml') + text = recipe.read_text() + assert text.count('--target lib/main.dart') == 1 + recipe.write_text(text.replace('--target lib/main.dart', '--target tools/window_close_smoke.dart')) + PYCODE + - name: Build genuine strict compiled-close fixture + if: ${{ !cancelled() && inputs.run-stress && steps.strict-fixture-source.outcome == 'success' }} + id: close-fixture-snap + uses: snapcore/action-build@v1 + - name: Install strict compiled-close fixture + if: ${{ !cancelled() && inputs.run-stress && steps.close-fixture-snap.outcome == 'success' && steps.strict-parallel.outcome == 'success' }} + id: strict-fixture-install + run: | + fixture="${{ steps.close-fixture-snap.outputs.snap }}" + test -f "$fixture" + test "$(unsquashfs -cat "$fixture" snap/snapcraft.yaml | sha256sum | cut -d' ' -f1)" = \ + "$(sha256sum snap/snapcraft.yaml | cut -d' ' -f1)" + sudo snap install --dangerous --name=busymark_fixture "$fixture" + fixture_root="$HOME/snap/busymark_fixture/common" + mkdir -p "$fixture_root/fixtures" + cp test/fixtures/media/close-testsrc.mp4 "$fixture_root/fixtures/" + + - name: Verify strict Save Discard Cancel media rendering and reopening + if: ${{ !cancelled() && inputs.run-stress && steps.strict-fixture-install.outcome == 'success' && steps.strict-session.outcome == 'success' }} + run: | + fixture_root="$HOME/snap/busymark_fixture/common" + xvfb-run -a /usr/bin/python3 tools/application_close_smoke.py \ + --output "$fixture_root/close-live" --cycles 3 --fresh-profile \ + --snap-instance busymark_fixture --snap-fixture \ + --expected-engine /snap/busymark_fixture/current/lib/libflutter_linux_gtk.so \ + --scenarios plain visualization pending-initialization dirty-save dirty-discard dirty-cancel video \ + --video "$fixture_root/fixtures/close-testsrc.mp4" + - name: Preserve strict compiled-close evidence + if: ${{ always() && inputs.run-stress }} + uses: actions/upload-artifact@v7 + with: + name: natural-close-strict-fixture + path: | + ~/snap/busymark_fixture/common/close-live + if-no-files-found: ignore + + development-modes: + name: Native release acceptance (${{ matrix.mode }}) + if: ${{ inputs.run-stress }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + mode: [debug, profile] + steps: + - uses: actions/checkout@v7 + - name: Install Linux application prerequisites + id: linux-dependencies + run: | + sudo apt-get update + sudo apt-get install -y apparmor-profiles bubblewrap clang cmake ninja-build pkg-config curl xz-utils \ + libgtk-3-dev libhandy-1-dev libsecret-1-dev libwebkit2gtk-4.1-dev \ + fonts-noto-core fonts-noto-mono gsettings-desktop-schemas dconf-cli python3-gi gir1.2-webkit2-4.1 xvfb dbus-daemon + - name: Enable Bubblewrap sandbox for WebKitGTK tests + id: web-sandbox + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} + run: | + probe_bwrap() { + bwrap --ro-bind / / --unshare-user --unshare-pid \ + --unshare-net /usr/bin/true + } + if ! probe_bwrap; then + profile=/usr/share/apparmor/extra-profiles/bwrap-userns-restrict + test -f "$profile" + sudo install -m 0644 "$profile" \ + /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace \ + /etc/apparmor.d/bwrap-userns-restrict + fi + probe_bwrap + + - uses: actions/setup-node@v7 + id: node + if: ${{ !cancelled() }} + with: + node-version: '22' + - name: Select official pinned Flutter SDK + id: flutter-sdk + if: ${{ !cancelled() }} + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.47.5' + cache: true + - run: flutter pub get --enforce-lockfile + id: flutter-packages + if: ${{ !cancelled() && steps.flutter-sdk.outcome == 'success' }} + - name: Build official development mode + id: mode-build + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' && steps.node.outcome == 'success' && steps.flutter-packages.outcome == 'success' }} + run: flutter build linux --${{ matrix.mode }} --no-pub + - name: Verify development mode GTK engine and natural close + if: ${{ !cancelled() && steps.mode-build.outcome == 'success' }} + run: | + xvfb-run -a dbus-run-session -- /usr/bin/python3 tools/application_close_smoke.py \ + --output "${RUNNER_TEMP}/close-${{ matrix.mode }}" --cycles 10 --fresh-profile --plain \ + --expected-engine build/linux/x64/${{ matrix.mode }}/bundle/lib/libflutter_linux_gtk.so -- \ + build/linux/x64/${{ matrix.mode }}/bundle/busymark + - name: Build development-mode compiled-close fixture + if: ${{ !cancelled() && steps.mode-build.outcome == 'success' }} + id: mode-fixture + run: flutter build linux --${{ matrix.mode }} --no-pub -t tools/window_close_smoke.dart + - name: Verify development-mode documents media and reopening + if: ${{ !cancelled() && steps.mode-fixture.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} + run: | + xvfb-run -a dbus-run-session -- /usr/bin/python3 tools/application_close_smoke.py \ + --output "${RUNNER_TEMP}/features-${{ matrix.mode }}" --cycles 1 --fresh-profile \ + --expected-engine build/linux/x64/${{ matrix.mode }}/bundle/lib/libflutter_linux_gtk.so \ + --scenarios plain visualization pending-initialization dirty-save dirty-discard dirty-cancel video \ + --video test/fixtures/media/close-testsrc.mp4 -- \ + build/linux/x64/${{ matrix.mode }}/bundle/busymark + - uses: actions/upload-artifact@v7 + if: always() + with: + name: development-mode-${{ matrix.mode }} + path: | + ${{ runner.temp }}/close-${{ matrix.mode }} + ${{ runner.temp }}/features-${{ matrix.mode }} diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 58532845..85df4900 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -4,11 +4,20 @@ on: pull_request: branches: - main + - release/v0.6.3 push: branches: - main +permissions: + contents: read + jobs: + native-product: + uses: ./.github/workflows/flutter-linux-native.yml + with: + run-stress: false + writerside-conformance: runs-on: ubuntu-24.04 @@ -27,6 +36,7 @@ jobs: uses: actions/checkout@v7 - name: Install Linux build dependencies + id: linux-dependencies run: | sudo apt-get update sudo apt-get install -y \ @@ -36,6 +46,8 @@ jobs: clang \ cmake \ dbus-daemon \ + gsettings-desktop-schemas \ + dconf-cli \ fonts-noto-core \ fonts-noto-cjk \ fonts-noto-mono \ @@ -51,11 +63,15 @@ jobs: poppler-utils \ python3-gi \ python3-gi-cairo \ + python3-yaml \ + rsync \ xvfb \ weston \ xz-utils - name: Enable Bubblewrap sandbox for WebKitGTK tests + id: web-sandbox + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} run: | probe_bwrap() { bwrap --ro-bind / / --unshare-user --unshare-pid \ @@ -72,106 +88,102 @@ jobs: probe_bwrap - name: Set up Node.js for bundled web engines + id: node + if: ${{ !cancelled() }} uses: actions/setup-node@v7 with: node-version: '22' cache: npm cache-dependency-path: tools/visualization/package-lock.json - - name: Set up Flutter + - name: Select official pinned Flutter SDK + id: flutter-sdk + if: ${{ !cancelled() }} uses: subosito/flutter-action@v2 with: channel: stable - flutter-version-file: pubspec.yaml + flutter-version: '3.47.5' cache: true - name: Enable Linux desktop + if: ${{ !cancelled() && steps.flutter-sdk.outcome == 'success' }} run: flutter config --enable-linux-desktop - name: Resolve locked dependencies + id: flutter-packages + if: ${{ !cancelled() && steps.flutter-sdk.outcome == 'success' }} run: flutter pub get --enforce-lockfile - name: Test production GTK accent lookup and observer + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} run: xvfb-run -a env GDK_BACKEND=x11 bash tools/test_gtk_accent.sh - name: Test secure credential namespace policy + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} run: bash tools/test_secure_credential_policy.sh + - name: Test native shortcut metadata + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} + run: xvfb-run -a env GDK_BACKEND=x11 bash tools/test_native_shortcut_metadata.sh + - name: Generate localizations + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' }} run: flutter gen-l10n - name: Audit localization catalogs + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' }} run: flutter test test/src/localization_audit_test.dart - name: Verify generated files are committed + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' }} run: git diff --exit-code - name: Check formatting + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' }} run: dart format --set-exit-if-changed . - name: Analyze + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' }} run: flutter analyze - name: Test native Hunspell package explicitly + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' && steps.linux-dependencies.outcome == 'success' }} working-directory: packages/busymark_spellcheck_native run: | dart pub get dart test - name: Prepare all-language spelling test resources + id: spelling-resources + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} run: tools/fetch_spelling_dictionaries.sh build/spelling-test - name: Validate every supported dictionary with the native engine + if: ${{ !cancelled() && steps.spelling-resources.outcome == 'success' && steps.flutter-packages.outcome == 'success' }} env: BUSYMARK_TEST_SPELLING_ROOT: ${{ github.workspace }}/build/spelling-test run: flutter test test/src/spelling_bundle_integration_test.dart - name: Build Linux release + if: ${{ !cancelled() && steps.flutter-packages.outcome == 'success' && steps.linux-dependencies.outcome == 'success' && steps.node.outcome == 'success' }} + id: release-build run: flutter build linux --release - name: Test production GTK accent channel host and lifecycle + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} run: xvfb-run -a env GDK_BACKEND=x11 bash tools/test_gtk_accent_host.sh - - name: Verify release ships catalog and notices without language pairs - run: | - spelling_root="build/linux/x64/release/bundle/share/busymark/spelling" - test -s "$spelling_root/dictionaries.json" - test -s "$spelling_root/NOTICE" - test -z "$(find "$spelling_root" -type f \( -name '*.aff' -o -name '*.dic' \) -print -quit)" - - - name: Install one dictionary and exercise it after an offline restart - run: | - report="${RUNNER_TEMP}/spelling-release.json" - profile="${RUNNER_TEMP}/spelling-profile" - mkdir -p "$profile" - xvfb-run -a -s '-screen 0 1280x1024x24' \ - env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ - XDG_DATA_HOME="$profile" LIBGL_ALWAYS_SOFTWARE=1 \ - build/linux/x64/release/bundle/busymark \ - --spelling-release-smoke="$report" - python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["bundledDictionaryFileCount"] == 0, report; assert report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report' "$report" - sudo unshare --net --fork -- \ - runuser -u "$(id -un)" -- \ - xvfb-run -a -s '-screen 0 1280x1024x24' \ - env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ - XDG_DATA_HOME="$profile" LIBGL_ALWAYS_SOFTWARE=1 \ - build/linux/x64/release/bundle/busymark \ - --spelling-release-smoke="$report" - python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert not report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["personalWordPresentBeforeRun"], report' "$report" - - - name: Test including bundled D2 and Typst paths - env: - BUSYMARK_D2_PATH: ${{ github.workspace }}/build/linux/x64/release/bundle/libexec/busymark/d2 - BUSYMARK_TYPST_PATH: ${{ github.workspace }}/build/linux/x64/release/bundle/libexec/busymark/typst - run: flutter test - name: Exercise demand-driven native visualization lifecycle + if: ${{ !cancelled() && steps.release-build.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} run: | xvfb-run -a -s '-screen 0 1280x1024x24' \ env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 NO_AT_BRIDGE=1 \ bash tools/test_web_render_host.sh + - name: Run real WebKit, PlantUML, Mermaid, OpenAPI, and D2 smoke tests + if: ${{ !cancelled() && steps.release-build.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} run: | xvfb-run -a -s '-screen 0 1280x1024x24' \ env GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 \ @@ -181,19 +193,53 @@ jobs: --demo test/fixtures/markdown/basic.md \ --plantuml-corpus test/fixtures/visualization/plantuml-conformance.md + - name: Exercise the release binary visualization and PDF paths under X11 + if: ${{ !cancelled() && steps.release-build.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} run: | report="${RUNNER_TEMP}/visualization-release-x11.json" timeout --signal=TERM 300s \ xvfb-run -a -s '-screen 0 1280x1024x24' \ env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ + XDG_DATA_HOME="${RUNNER_TEMP}/visualization-profile" \ LIBGL_ALWAYS_SOFTWARE=1 \ build/linux/x64/release/bundle/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" test -s "${RUNNER_TEMP}/visualization-smoke.pdf" + + - name: Verify release ships catalog and notices without language pairs + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} + run: | + spelling_root="build/linux/x64/release/bundle/share/busymark/spelling" + test -s "$spelling_root/dictionaries.json" + test -s "$spelling_root/NOTICE" + test -z "$(find "$spelling_root" -type f \( -name '*.aff' -o -name '*.dic' \) -print -quit)" + + - name: Install one dictionary and exercise it after an offline restart + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} + run: | + report="${RUNNER_TEMP}/spelling-release.json" + profile="${RUNNER_TEMP}/spelling-profile" + mkdir -p "$profile" + xvfb-run -a -s '-screen 0 1280x1024x24' \ + dbus-run-session -- env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ + XDG_DATA_HOME="$profile" LIBGL_ALWAYS_SOFTWARE=1 \ + build/linux/x64/release/bundle/busymark \ + --spelling-release-smoke="$report" + python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["bundledDictionaryFileCount"] == 0, report; assert report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report' "$report" + sudo unshare --net --fork -- \ + runuser -u "$(id -un)" -- \ + xvfb-run -a -s '-screen 0 1280x1024x24' \ + dbus-run-session -- env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ + XDG_DATA_HOME="$profile" LIBGL_ALWAYS_SOFTWARE=1 \ + build/linux/x64/release/bundle/busymark \ + --spelling-release-smoke="$report" + python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert not report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["personalWordPresentBeforeRun"], report' "$report" + - name: Run spelling and visualization smoke tests under Wayland + if: ${{ !cancelled() && steps.release-build.outcome == 'success' && steps.web-sandbox.outcome == 'success' }} run: | runtime_dir="/run/user/$(id -u)" sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" @@ -201,7 +247,7 @@ jobs: weston --backend=headless-backend.so --socket=wayland-99 \ --idle-time=0 --log="${RUNNER_TEMP}/weston.log" & weston_pid=$! - trap 'kill "$weston_pid" 2>/dev/null || true' EXIT + trap 'kill "$weston_pid" 2>/dev/null || true; wait "$weston_pid" 2>/dev/null || true' EXIT for attempt in {1..40}; do if [ -S "$runtime_dir/wayland-99" ]; then break @@ -213,25 +259,35 @@ jobs: sleep 0.25 done test -S "$runtime_dir/wayland-99" + export runtime_dir + checks_failed=0 + bash -euo pipefail <<'SPELLING' || checks_failed=1 spelling_report="${RUNNER_TEMP}/spelling-release-wayland.json" spelling_profile="${RUNNER_TEMP}/spelling-profile" sudo unshare --net --fork -- \ runuser -u "$(id -un)" -- \ + dbus-run-session -- \ env XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ XDG_DATA_HOME="$spelling_profile" GDK_BACKEND=wayland \ BUSYMARK_RELEASE_SMOKE=1 LIBGL_ALWAYS_SOFTWARE=1 \ build/linux/x64/release/bundle/busymark \ --spelling-release-smoke="$spelling_report" python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["sourceEditorTransaction"], report; assert report["checks"]["formattedRichEditorTransaction"], report; assert report["checks"]["tableCellEditorTransaction"], report' "$spelling_report" - XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ + SPELLING + bash -euo pipefail <<'RENDERERS' || checks_failed=1 + dbus-run-session -- env \ + XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ 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 \ --demo test/fixtures/markdown/basic.md \ --plantuml-corpus test/fixtures/visualization/plantuml-conformance.md + RENDERERS + bash -euo pipefail <<'COMPILED' || checks_failed=1 report="${RUNNER_TEMP}/visualization-release-wayland.json" - XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ + dbus-run-session -- env \ + XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ GDK_BACKEND=wayland BUSYMARK_RELEASE_SMOKE=1 \ LIBGL_ALWAYS_SOFTWARE=1 \ timeout --signal=TERM 300s \ @@ -239,254 +295,33 @@ jobs: --visualization-release-smoke="$report" python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report' "$report" test -s "${RUNNER_TEMP}/visualization-smoke.pdf" + COMPILED + exit "$checks_failed" - snap: - runs-on: ubuntu-24.04 - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Prepare clean Snap artifact selection + # Independent application checks retain their own failure outcomes. + - name: Test process ownership and application lifecycle tooling + if: ${{ !cancelled() && steps.linux-dependencies.outcome == 'success' }} run: | - rm -f ./*.snap - touch "${RUNNER_TEMP}/busymark-snap-build-start" - - - name: Build strict Snap from a clean core24 environment (attempt 1) - id: snapcraft - uses: snapcore/action-build@v1 - continue-on-error: true - - - name: Retry strict Snap build after an infrastructure failure - id: snapcraft-retry - if: steps.snapcraft.outcome == 'failure' - uses: snapcore/action-build@v1 + /usr/bin/python3 -m unittest tools/test_owned_processes.py \ + tools/test_application_close_smoke.py \ + tools/test_run_owned_fixture.py \ + tools/test_official_flutter_workflow.py - - name: Select built Snap - id: snap-artifact + - name: Test including bundled D2 and Typst paths + if: ${{ !cancelled() && steps.release-build.outcome == 'success' }} env: - PRIMARY_SNAP: ${{ steps.snapcraft.outputs.snap }} - PRIMARY_OUTCOME: ${{ steps.snapcraft.outcome }} - RETRY_SNAP: ${{ steps.snapcraft-retry.outputs.snap }} - RETRY_OUTCOME: ${{ steps.snapcraft-retry.outcome }} - run: | - if [ "$PRIMARY_OUTCOME" = success ]; then - snap_path="$PRIMARY_SNAP" - else - test "$PRIMARY_OUTCOME" = failure - test "$RETRY_OUTCOME" = success - snap_path="$RETRY_SNAP" - fi - test -n "$snap_path" - test -f "$snap_path" - test "$snap_path" -nt "${RUNNER_TEMP}/busymark-snap-build-start" - snap_path="$(realpath "$snap_path")" - case "$snap_path" in - "$GITHUB_WORKSPACE"/*.snap) ;; - *) echo "Refusing Snap outside the clean checkout: $snap_path" >&2; exit 1 ;; - esac - test "$(unsquashfs -cat "$snap_path" snap/snapcraft.yaml | sha256sum | cut -d' ' -f1)" = \ - "$(sha256sum snap/snapcraft.yaml | cut -d' ' -f1)" - echo "snap=$snap_path" >> "$GITHUB_OUTPUT" - - - name: Upload Snap artifact + BUSYMARK_D2_PATH: ${{ github.workspace }}/build/linux/x64/release/bundle/libexec/busymark/d2 + BUSYMARK_TYPST_PATH: ${{ github.workspace }}/build/linux/x64/release/bundle/libexec/busymark/typst + # Real durable fixtures share the runner's filesystem. Keep the full + # suite bounded independently of a developer machine's CPU count. + run: flutter test --concurrency=2 + + - name: Preserve compiled visualization report + if: always() uses: actions/upload-artifact@v7 with: - name: busymark-snap - path: ${{ steps.snap-artifact.outputs.snap }} - - - name: Install desktop smoke dependencies - run: | - sudo apt-get update - sudo apt-get install -y weston xvfb - - - name: Install strict Snap - run: >- - sudo snap install --dangerous - "${{ steps.snap-artifact.outputs.snap }}" - - - name: Verify GTK SVG icon loader - run: | - snap run --shell busymark -c ' - test ! -e \ - "$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/librsvg-2.so.2" - query="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders" - loader="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader_svg.so" - "$query" "$loader" | grep -q "\"svg\" 6 \"gdk-pixbuf\"" - ' - - - name: Verify shared runtimes, packaged tools, media, and resources - run: | - snap run --shell busymark -c ' - set -eu - inspect_dependencies() { - dependency_object="$1" - if dependency_output="$(ldd "$dependency_object" 2>&1)"; then - : - else - printf "Dependency inspection failed: %s\n%s\n" \ - "$dependency_object" "$dependency_output" >&2 - exit 1 - fi - case "$dependency_output" in - *"not found"*) - printf "Unresolved dependencies: %s\n%s\n" \ - "$dependency_object" "$dependency_output" >&2 - exit 1 - ;; - esac - } - require_resolution() { - required_library="$1" - required_path="$2" - resolved_path="$(printf "%s\n" "$dependency_output" | \ - awk -v name="$required_library" \ - "\$1 == name && \$2 == \"=>\" { print \$3; exit }")" - if [ -z "$resolved_path" ] || \ - ! expected_real="$(readlink -e "$required_path")" || \ - ! resolved_real="$(readlink -e "$resolved_path")" || \ - [ "$resolved_real" != "$expected_real" ]; then - printf "Unexpected resolution for %s in %s; expected %s\n%s\n" \ - "$required_library" "$dependency_object" \ - "$required_path" "$dependency_output" >&2 - exit 1 - fi - } - app_lib="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" - inspect_dependencies "$SNAP/busymark" - for library in \ - libhandy-1.so.0 libsecret-1.so.0 libwebkit2gtk-4.1.so.0 \ - libgtk-3.so.0 libglib-2.0.so.0 libpango-1.0.so.0; do - test ! -e "$app_lib/$library" - require_resolution "$library" \ - "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/$library" - done - graphics_lib="$SNAP/gpu-2404/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" - for library in \ - libX11.so.6 libXdamage.so.1 libXext.so.6 libXfixes.so.3 \ - libxcb-shm.so.0 libxcb.so.1 libwayland-client.so.0 \ - libwayland-cursor.so.0 libwayland-egl.so.1; do - test ! -e "$app_lib/$library" - test -e "$graphics_lib/$library" - require_resolution "$library" "$graphics_lib/$library" - done - for helper in WebKitWebProcess WebKitNetworkProcess WebKitGPUProcess; do - test -x "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/webkit2gtk-4.1/$helper" - done - test -x "$SNAP/usr/bin/git" - test -x "$SNAP/usr/bin/ssh" - test -x "$SNAP/usr/bin/setsid" - inspect_dependencies "$SNAP/usr/lib/git-core/git-remote-http" - require_resolution libcurl-gnutls.so.4 \ - "$app_lib/libcurl-gnutls.so.4" - GIT_EXEC_PATH="$SNAP/usr/lib/git-core" \ - "$SNAP/usr/bin/git" --exec-path | grep -Fx "$SNAP/usr/lib/git-core" - "$SNAP/usr/bin/ssh" -V - provider_plugins="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" - for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ - libgstogg.so libgsttheora.so libgstvpx.so; do - test -f "$provider_plugins/$plugin" - inspect_dependencies "$provider_plugins/$plugin" - done - private_plugins="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" - for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ - libgstogg.so libgsttheora.so libgstvpx.so; do - test ! -e "$private_plugins/$plugin" - done - for plugin in libgstlibav.so libgstvideoparsersbad.so \ - libgstmpeg2dec.so; do - test -f "$private_plugins/$plugin" - inspect_dependencies "$private_plugins/$plugin" - done - test -L "$SNAP/share/busymark/fonts" - test "$(readlink "$SNAP/share/busymark/fonts")" = \ - ../../usr/share/fonts/truetype/noto - test -f "$SNAP/share/busymark/fonts/NotoSans-Regular.ttf" - test -f "$SNAP/usr/share/doc/fonts-noto-core/copyright" - test -f "$SNAP/usr/share/doc/fonts-noto-mono/copyright" - for resource_kind in themes icons; do - for resource_name in Yaru Yaru-dark; do - resource="$SNAP/share/$resource_kind/$resource_name" - test -L "$resource" - test -e "$resource" - done - done - ' - - - name: Install one strict-Snap dictionary and verify refresh/offline use - run: | - report="$HOME/snap/busymark/common/spelling-release.json" - rm -f "$report" - xvfb-run -a -s '-screen 0 1280x1024x24' \ - env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ - LIBGL_ALWAYS_SOFTWARE=1 snap run busymark \ - --spelling-release-smoke="$report" - python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["bundledDictionaryFileCount"] == 0, report; assert report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report' "$report" - test -d "$HOME/snap/busymark/common/spelling/dictionaries/downloaded/en-US" - before_revision="$(snap list busymark | awk 'NR == 2 {print $3}')" - sudo snap install --dangerous \ - "${{ steps.snap-artifact.outputs.snap }}" - after_revision="$(snap list busymark | awk 'NR == 2 {print $3}')" - test -n "$before_revision" - test -n "$after_revision" - test "$before_revision" != "$after_revision" - sudo snap disconnect busymark:network - trap 'sudo snap connect busymark:network >/dev/null' EXIT - xvfb-run -a -s '-screen 0 1280x1024x24' \ - env GDK_BACKEND=x11 BUSYMARK_RELEASE_SMOKE=1 \ - LIBGL_ALWAYS_SOFTWARE=1 snap run busymark \ - --spelling-release-smoke="$report" - python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert not report["checks"]["dictionaryInstalledDuringRun"], report; assert report["checks"]["installedDictionaryCount"] == 1, report; assert report["checks"]["personalWordPresentBeforeRun"], report' "$report" - sudo snap connect busymark:network - trap - EXIT - - - name: Exercise strict Snap visualization and PDF paths under X11 - run: | - report="$HOME/snap/busymark/common/visualization-release-x11.json" - mkdir -p "$(dirname "$report")" - timeout --signal=TERM 300s \ - xvfb-run -a -s '-screen 0 1280x1024x24' \ - 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" - test -s "$HOME/snap/busymark/common/visualization-smoke.pdf" - - - name: Exercise strict Snap spelling and visualization under Wayland - run: | - runtime_dir="/run/user/$(id -u)" - sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "$runtime_dir" - XDG_RUNTIME_DIR="$runtime_dir" \ - weston --backend=headless-backend.so --socket=wayland-99 \ - --idle-time=0 --log="${RUNNER_TEMP}/weston-snap.log" & - weston_pid=$! - sudo snap disconnect busymark:network - trap 'sudo snap connect busymark:network >/dev/null; kill "$weston_pid" 2>/dev/null || true' EXIT - for attempt in {1..40}; do - if [ -S "$runtime_dir/wayland-99" ]; then - break - fi - if ! kill -0 "$weston_pid" 2>/dev/null; then - cat "${RUNNER_TEMP}/weston-snap.log" - exit 1 - fi - sleep 0.25 - done - test -S "$runtime_dir/wayland-99" - spelling_report="$HOME/snap/busymark/common/spelling-release-wayland.json" - XDG_RUNTIME_DIR="$runtime_dir" WAYLAND_DISPLAY=wayland-99 \ - GDK_BACKEND=wayland BUSYMARK_RELEASE_SMOKE=1 \ - LIBGL_ALWAYS_SOFTWARE=1 \ - timeout --signal=TERM 300s snap run busymark \ - --spelling-release-smoke="$spelling_report" - python3 -c 'import json,sys; report=json.load(open(sys.argv[1], encoding="utf-8")); assert report["ok"], report; assert report["checks"]["sourceEditorTransaction"], report; assert report["checks"]["formattedRichEditorTransaction"], report; assert report["checks"]["tableCellEditorTransaction"], report' "$spelling_report" - 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 \ - 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" - test -s "$HOME/snap/busymark/common/visualization-smoke.pdf" - sudo snap connect busymark:network - trap 'kill "$weston_pid" 2>/dev/null || true' EXIT + name: visualization-release + path: | + ${{ runner.temp }}/visualization-release-x11.json + ${{ runner.temp }}/visualization-smoke.pdf + if-no-files-found: ignore diff --git a/README.md b/README.md index 4f4d7b7c..8aec2d51 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ ninja-build \ pkg-config \ + python3 \ xz-utils ``` @@ -67,17 +68,16 @@ components. Prepare and run the application: ```bash -flutter doctor -flutter pub get +flutter pub get --enforce-lockfile flutter run -d linux ``` -Pass a Markdown file or documentation directory after `--` to open it at -startup: +Pass a Markdown file or documentation directory as a Dart entrypoint argument +to open it at startup: ```bash -flutter run -d linux -- /absolute/path/to/document.md -flutter run -d linux -- /absolute/path/to/documentation +flutter run -d linux --dart-entrypoint-args=/absolute/path/to/document.md +flutter run -d linux --dart-entrypoint-args=/absolute/path/to/documentation ``` Build and run the standard checks with: @@ -91,9 +91,16 @@ flutter test The build assembles BusyMark's runtime components. Packaged users do not need separate Typst, Java, Node.js, or Chromium installations. +Linux development and packaging use the official Flutter SDK version pinned in +`pubspec.yaml` (currently 3.47.5). No custom Flutter engine is built or selected. +See [Linux development](docs/development/linux-engine.md) for build modes and +[compiled application checks](docs/development/application-smoke-tests.md) for +native and document regression commands. A successful widget suite or build +does not establish native release acceptance. + 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 +local build; see [native acceptance](.github/workflows/flutter-linux-native.yml) when reproducing release verification. ## Snap release and security builds @@ -113,7 +120,7 @@ sha256sum ./busymark_*.snap Inspect and test the exact file emitted by the final `snapcraft` command. A failed build must not fall back to an older `.snap`. The `snap` job in -[the Linux workflow](.github/workflows/flutter-linux.yml) follows this route, +[native acceptance](.github/workflows/flutter-linux-native.yml) follows this route, installs that selected artifact, and runs the strict-confinement smoke checks. Build information embeds the original recipe for the workflow's identity check. @@ -122,6 +129,8 @@ the Flutter payload in an installed Snap scaffold for quick development and may retain that scaffold's Ubuntu libraries and bundled tools. Even with `--no-install`, its output is not a dependency refresh and must not be used for a release, a security update, or validation of `stage-packages` changes. +It selects Flutter from PATH; `BUSYMARK_FLUTTER_BIN` can select an executable +by command name or path. The selected SDK must match the pinned version. ## Screenshots diff --git a/docs/development/application-smoke-tests.md b/docs/development/application-smoke-tests.md new file mode 100644 index 00000000..aa7d3ff6 --- /dev/null +++ b/docs/development/application-smoke-tests.md @@ -0,0 +1,60 @@ +# Compiled application checks + +Use the official pinned Flutter SDK and a disposable desktop with isolated +profiles. Never install test builds over an existing application. + +`tools/application_close_smoke.py` requires natural process exit, durable clean +shutdown and verified descendant retirement. A Dart checkpoint, disappearing +window or forced cleanup does not pass. It identifies only the launched +application's window and records failed prerequisites and native exits. +`--expected-engine` accepts the actual official `libflutter_linux_gtk.so` and +verifies its mapped inode, device and bytes through retained descriptors. + +```sh +python3 tools/application_close_smoke.py \ + --output /absolute/new/shipping-close --cycles 3 --plain \ + --expected-engine /path/to/official/libflutter_linux_gtk.so -- \ + build/linux/x64/release/bundle/busymark + +flutter build linux --release -t tools/window_close_smoke.dart +python3 tools/application_close_smoke.py \ + --output /absolute/new/document-close --cycles 1 --fresh-profile \ + --scenarios plain visualization pending-initialization \ + dirty-save dirty-discard dirty-cancel video \ + --video test/fixtures/media/close-testsrc.mp4 -- \ + build/linux/x64/release/bundle/busymark +flutter build linux --release -t lib/main.dart +``` + +The alternate entry point exercises the real application, editors and close +listener. It verifies Save/Discard/Cancel, saved-file reopening and rendered +content before closing. Frame callbacks confirm raster activity; video checks +require changing decoded frames. The pending-initialization case requires +`visualization-shutdown initPending=1`, using a harness that withholds readiness. + +The `window-controls` scenario also tests Yaru maximize, restore, minimize, close +and titlebar dragging. It requires a window manager and the explicit +`--disposable-display` option because it injects pointer input. + +`--snap-instance busymark_close` tests an owned parallel instance in a disposable +machine through its ordinary strict launcher and enforced AppArmor label. Use +the normal systemd user-session bus. Standalone checks can use a private D-Bus +session. Keep WebKit sandboxing and ordinary graphics settings enabled. + +`tools/owned_processes.py` and `tools/run_owned_fixture.py` are also used by +History and browser regressions. Each supervisor owns a dedicated child tree; +PID/start-time checks and held descriptors prevent signalling reused or unrelated +processes. Incomplete observation or unresolved cleanup fails verification and +prevents another case. History fixtures compile once per test file before their +operation deadlines, preserving interprocess lock and cancellation checks. + +```sh +python3 -m unittest tools/test_owned_processes.py \ + tools/test_application_close_smoke.py tools/test_run_owned_fixture.py \ + tools/test_official_flutter_workflow.py +``` + +These tooling tests require Clang and PyYAML in addition to system Python. +Automatic CI runs bounded native, X11/Wayland, dictionary/offline restart and +strict-Snap product checks. Hundred-close and debug/profile campaigns remain +explicitly selected release checks; see the existing workflows. diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md index e1fcfef8..5286bc5f 100644 --- a/docs/development/export-verification.md +++ b/docs/development/export-verification.md @@ -140,7 +140,11 @@ 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, X11 and Wayland runs, and the -strict Snap verification. Keep ordinary build prerequisites in the main README; +The automatic `.github/workflows/flutter-linux.yml` workflow runs bounded X11 +host, renderer and compiled visualization/PDF checks on pull requests to `main` +and `release/v0.6.3`, and pushes to `main`. The same job reuses its release build for dictionary and Wayland checks. It +calls `.github/workflows/flutter-linux-native.yml` for genuine strict-Snap +checks; hundred-close and debug/profile campaigns remain opt-in. +Each workflow declares its native +packages and desktop prerequisites. 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 index ee2609b1..8e464bf4 100644 --- a/docs/development/history.md +++ b/docs/development/history.md @@ -215,18 +215,21 @@ file in the full application and requires the exact synthetic table fixture checked by the driver; use an isolated application profile and disposable files for that mode. -## Shared Local History search - -`SearchCategory.localHistory` owns the header query and optional retained-document -ID. `findRetained()` reads the existing store and returns bounded document or -revision metadata without changing browsing, comparison, recording or buffer -ownership. Each request checks cancellation and the retained snapshot generation; -clear and account removal invalidate affected results. Revision bodies are read -sequentially and discarded after matching, rather than kept in widget state. - -Activation revalidates stable document/revision IDs and checksum before the -existing comparison workflow reads a revision. Its selection callback also checks -the originating search request after the read. Ordinary History navigation keeps -the complete timeline and has no search entry points or private query controller. -The shared coordinator retains explicit removable document scopes independently -of the browsing panel. +Session and recovery JSON publication protects each staged inode before its +atomic rename. Linux performs permission changes off the UI isolate through a +held descriptor. Configured directory links are followed and their targets +pinned; final state-file links are rejected. Retargeting a pathname cannot +redirect an admitted permission change. Unreadable inodes use the same held +identity through procfs. Permission or inspection failures fail the transaction; +private modes, lock ownership, flush and recovery ordering remain mandatory. + +Startup capture and pruning wait for the saved History policy. Restoration +reads at most four files concurrently, preserves session order and drains +admitted reads after a failure. Adopted recovery remains protected until +restoration and required persistence finish, including when close is requested +before an editor is published. + +Opening can display validated content while Recent persistence completes. A +failed Recent update remains a warning after a successful open or save. Settings +writes retain ordered completion and retry failed publications; shutdown drains +the queue before retiring recovery ownership. diff --git a/docs/development/linux-engine.md b/docs/development/linux-engine.md new file mode 100644 index 00000000..bcd93ee5 --- /dev/null +++ b/docs/development/linux-engine.md @@ -0,0 +1,44 @@ +# Linux development with official Flutter + +Use the official Flutter SDK pinned in `pubspec.yaml` (3.47.5) on `PATH`. +BusyMark does not build or select a custom engine or GTK runtime. + +```sh +flutter pub get --enforce-lockfile +flutter run -d linux +flutter run -d linux --profile +flutter run -d linux --release +flutter build linux --debug +flutter build linux --profile +flutter build linux --release +flutter analyze +flutter test +``` + +CI selects the same official version. Snapcraft downloads the checksum-pinned +SDK and packages its unmodified release engine. Follow the README's clean +Snapcraft procedure in a disposable supported environment; do not replace an +installed application for acceptance. The local scaffold repack helper is not +release or confinement validation. Strict-Snap checks use the normal systemd +user-session bus for launcher cgroup tracking, not a private D-Bus session. +Standalone smoke checks use disposable D-Bus sessions to retire desktop helpers. + +`Flutter Linux` automatically runs bounded GTK/WebKit, compiled visualization/PDF, +dictionary installation/offline restart, Wayland and strict-Snap build/install +checks for PRs to `main` and `release/v0.6.3`, and pushes to `main`. It reuses +`Linux native acceptance` with `run-stress: false`. Run that workflow manually +with `run-stress` enabled for hundred-close, compiled document/media and +debug/profile campaigns. See [compiled application checks](application-smoke-tests.md). +A widget-suite pass does not validate the GTK embedder. + +Yaru uses the unmodified dependencies in `pubspec.lock` through Flutter's +generated plugin build. No local plugin source preparation is required. +Compiled window, document and media checks exercise the actual application; +runtime shutdown failures still fail native release acceptance. A disappearing +window or forced cleanup is not successful natural shutdown. + +Startup resolves the GTK accent or its portal fallback before building the UI, +using the existing readiness deadline. A valid GTK accent does not wait for the +portal. Saved preferences are read once before `runApp()` and handed to the +settings controller; failed reads use defaults without acknowledging them as a +persisted snapshot. diff --git a/docs/development/visualizations.md b/docs/development/visualizations.md index 9856797f..0ca91fa1 100644 --- a/docs/development/visualizations.md +++ b/docs/development/visualizations.md @@ -11,6 +11,11 @@ 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. +Successful renders are published before optional disk-cache persistence finishes. +The cache warms memory before its first I/O await; the coordinator retains the +render's concurrency slot until persistence settles, bounding pending writes +without putting disk flushes on the visible-result path. + 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 @@ -62,9 +67,8 @@ view in the same ephemeral context. Process recovery also creates a fresh registry. Each engine retains its existing validation, configuration, and dependencies. Math includes all offline NewCM font tables. First use still pays for the WebKit process, harness, and requested engine; -subsequent requests reuse them. Those costs must be measured alongside plain -startup when assessing performance, including preparation that an eager -baseline completed before receiving its first request. +subsequent requests reuse them. Measure complete launch-to-output and +request-to-output paths separately. Opening an interactive reference first creates the shared context and its visible view, without loading the offscreen harness. A later render uses that @@ -82,11 +86,9 @@ callbacks retain the host until completion, check its state and view identity, and cannot recreate resources after shutdown. Private resource callbacks own their resource path independently of the host. -Set `BUSYMARK_VISUALIZATION_TRACE=1` for opt-in monotonic timings on stderr: -context construction, view construction, offscreen realization/show, harness -dispatch, and request acceptance, start, JavaScript delivery, and completion. It logs no document source. Moving -construction to first use removes unused startup work; it does not establish -the cause or elimination of a rare WebKit construction stall. +Demand-driven construction avoids initializing unused hosts. Constructor stalls +remain a separate runtime issue from the application's startup and lifecycle +contracts. ## Dependencies and packaging @@ -98,7 +100,8 @@ sources are: - `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. +- `.github/workflows/flutter-linux.yml` for automatic native and compiled PDF checks; +- `.github/workflows/flutter-linux-native.yml` for automatic Wayland/Snap checks and opt-in release stress campaigns. 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 @@ -151,3 +154,50 @@ 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. + +Host registration remains lightweight; only a validated video `show` request +creates video resources. + +Local video passes its single validated file through WebKit's File API and an +allowed `blob:` URL. This lets WebKit broker the file into the existing media +pipeline; GStreamer's direct `file:` reader cannot see arbitrary local paths +inside the WebProcess sandbox. The fixed local-player shell alone can select +that file. No chooser is shown, no video copy is staged, and the sandbox, CSP, +network restrictions, and separate visualization/video contexts are retained. +Each video player owns its WebKit widget until host cleanup. Closing the parent +window can destroy the overlay first; host cleanup must still disconnect player +callbacks and release that widget safely. The native `video-parent-destroy` +regression exercises this order and observes ownership through shutdown. + +The native regression script accepts individual operations and an optional +repeat count, with a fresh isolated profile for each process: + +```bash +BUSYMARK_NATIVE_PROBE_REPETITIONS=100 \ + bash tools/test_web_render_host.sh renderMermaid +bash tools/test_web_render_host.sh video-no-init video-first video-failure video-view-failure +bash tools/test_web_render_host.sh video-playback video-mini-playback +``` + +Render results are delivered before optional disk-cache publication completes. +Cache publication still occupies a bounded rendering slot, including the awaited +write, atomic rename and LRU maintenance. Derived images do not request `fsync`: +their disk cache is disposable, and invalid or incomplete entries are rejected +and recomputed after interruption. Document, session, recovery and Local History +durability use their separate stores. Full-directory metadata scans run in one short-lived +maintenance isolate at a time per cache, using only its path and numeric limits. +This avoids one main-isolate asynchronous round trip per cached file. The caller +still awaits maintenance: no unlimited background writes or maintenance queue +are introduced. + +## Compiled application lifecycle checks + +See [compiled application checks](application-smoke-tests.md) for natural closure, +Save/Discard/Cancel, reopening, native window controls and video verification. +Keep these product assertions separate from startup measurements. + +Widget tests that perform real filesystem work must start and await that work +with `tester.runAsync`, pumping UI transitions outside that callback. Readiness +helpers advance both real I/O and the widget clock within their existing +budgets. Focused History double-click tests retain pointer-gesture coverage +alongside keyboard navigation. diff --git a/docs/snap-confinement.md b/docs/snap-confinement.md index 882b7238..2bd1456f 100644 --- a/docs/snap-confinement.md +++ b/docs/snap-confinement.md @@ -4,6 +4,11 @@ The Snap Store build uses strict confinement. BusyMark bundles Git, OpenSSH, Typst, D2, WebKitGTK, and its other runtime dependencies rather than executing arbitrary host programs. +The build generates and validates portable GTK icon-theme caches with the GNOME +SDK. The GNOME launcher can reuse each bundled theme's index on first launch. +These indices describe the packaged icons; graphics and media preparation still +occurs in the confined application profile. + The application plugs the standard desktop, display, audio, network, `home`, `removable-media`, `ssh-keys`, and `password-manager-service` interfaces. This supports documentation projects in ordinary home-directory locations and diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index dcea6973..765a5abd 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "تعذر تحديث العناصر الأخيرة: {error}.", "workspaceErrorOpenFailed": "فشل الفتح: ⁨{error}⁩", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bb3ace0b..c1b4a6c0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Zuletzt verwendete Elemente konnten nicht aktualisiert werden: {error}.", "workspaceErrorOpenFailed": "Öffnen fehlgeschlagen: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1b391441..74bf3b5f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1311,6 +1311,11 @@ "documentKindResourceFile": "Resource file", "@documentKindResourceFile": {"description": "Search result document kind label."}, + "workspaceErrorRecentWorkspaceUpdateFailed": "Could not update Recent items: {error}.", + "@workspaceErrorRecentWorkspaceUpdateFailed": { + "description": "Warning when updating Recent items fails after a successful open or save.", + "placeholders": {"error": {"type": "String"}} + }, "workspaceErrorOpenFailed": "Open failed: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9b516a28..e3dd878d 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "No se pudieron actualizar los elementos recientes: {error}.", "workspaceErrorOpenFailed": "Error al abrir: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index bba52b19..ea1dc7b6 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1987,6 +1987,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Hiljutisi üksusi ei saanud uuendada: {error}.", "workspaceErrorOpenFailed": "Avamine nurjus: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 285a30d0..3227ee72 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1702,6 +1702,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "به‌روزرسانی موارد اخیر ممکن نشد: {error}.", "workspaceErrorOpenFailed": "باز کردن ناموفق بود: ⁨{error}⁩", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 506ac090..ea7658d1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Impossible de mettre à jour les éléments récents : {error}.", "workspaceErrorOpenFailed": "Échec de l’ouverture : {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9a08d875..e801432c 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "हाल के आइटम अपडेट नहीं किए जा सके: {error}.", "workspaceErrorOpenFailed": "खोलना विफल रहा: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2649c65d..89de091e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Tidak dapat memperbarui item terbaru: {error}.", "workspaceErrorOpenFailed": "Gagal dibuka: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ed6e899e..8d902b5f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Impossibile aggiornare gli elementi recenti: {error}.", "workspaceErrorOpenFailed": "Apertura non riuscita: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 81f53078..5c863e92 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "最近使った項目を更新できませんでした: {error}。", "workspaceErrorOpenFailed": "開くことができませんでした:{error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c37940ae..ce44a223 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "최근 항목을 업데이트하지 못했습니다: {error}.", "workspaceErrorOpenFailed": "열기 실패: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index d53d1dce..07f7abb0 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Kunne ikke oppdatere nylige elementer: {error}.", "workspaceErrorOpenFailed": "Åpning mislyktes: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 1ff57a90..7bdd47fc 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Recente items konden niet worden bijgewerkt: {error}.", "workspaceErrorOpenFailed": "Openen mislukt: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a6745499..47f823c5 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Nie udało się zaktualizować ostatnich elementów: {error}.", "workspaceErrorOpenFailed": "Otwieranie nie powiodło się: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index db505749..50d3df51 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Não foi possível atualizar os itens recentes: {error}.", "workspaceErrorOpenFailed": "Falha ao abrir: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 54831952..d7fac0de 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Não foi possível atualizar os itens recentes: {error}.", "workspaceErrorOpenFailed": "Falha ao abrir: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fdd307a1..173d599f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Не удалось обновить недавние элементы: {error}.", "workspaceErrorOpenFailed": "Не удалось открыть: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 43dfbc93..ba3ed0d5 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Son öğeler güncellenemedi: {error}.", "workspaceErrorOpenFailed": "Açma başarısız oldu: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 217f43b4..ad8dfd6d 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1694,6 +1694,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Не вдалося оновити нещодавні елементи: {error}.", "workspaceErrorOpenFailed": "Не вдалося відкрити: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index e0a23f42..7c2c385a 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "Không thể cập nhật các mục gần đây: {error}.", "workspaceErrorOpenFailed": "Mở không thành công: {error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index df6e1b09..64ab467b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2254,6 +2254,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "无法更新最近使用的项目:{error}。", "workspaceErrorOpenFailed": "打开失败:{error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 6f2e5d0f..7762b8a9 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -2252,6 +2252,7 @@ "@documentKindResourceFile": { "description": "Search result document kind label." }, + "workspaceErrorRecentWorkspaceUpdateFailed": "无法更新最近使用的项目:{error}。", "workspaceErrorOpenFailed": "打开失败:{error}", "@workspaceErrorOpenFailed": { "description": "Workspace error message shown when opening a path fails.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ae26f8f2..f4b1639e 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3769,6 +3769,12 @@ abstract class AppLocalizations { /// **'Resource file'** String get documentKindResourceFile; + /// Warning when updating Recent items fails after a successful open or save. + /// + /// In en, this message translates to: + /// **'Could not update Recent items: {error}.'** + String workspaceErrorRecentWorkspaceUpdateFailed(String error); + /// Workspace error message shown when opening a path fails. /// /// 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 70d3ed99..1928d609 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2071,6 +2071,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get documentKindResourceFile => 'ملف الموارد'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'تعذر تحديث العناصر الأخيرة: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'فشل الفتح: ⁨$error⁩'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c9f086f5..c1e4fc78 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2092,6 +2092,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get documentKindResourceFile => 'Ressourcendatei'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Zuletzt verwendete Elemente konnten nicht aktualisiert werden: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Öffnen fehlgeschlagen: $error'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 2362581f..7eb1d5c8 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2069,6 +2069,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get documentKindResourceFile => 'Resource file'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Could not update Recent items: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Open failed: $error'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 8fa585ae..d7601a32 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2088,6 +2088,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get documentKindResourceFile => 'Archivo de recursos'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'No se pudieron actualizar los elementos recientes: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Error al abrir: $error'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 56abfe6d..ee9693e8 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -2065,6 +2065,11 @@ class AppLocalizationsEt extends AppLocalizations { @override String get documentKindResourceFile => 'Ressursifail'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Hiljutisi üksusi ei saanud uuendada: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Avamine nurjus: $error'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index bd7be340..4e282793 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2108,6 +2108,11 @@ class AppLocalizationsFa extends AppLocalizations { @override String get documentKindResourceFile => 'فایل منبع'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'به‌روزرسانی موارد اخیر ممکن نشد: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'باز کردن ناموفق بود: ⁨$error⁩'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 36b3cf25..cf358904 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2095,6 +2095,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get documentKindResourceFile => 'Fichier de ressources'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Impossible de mettre à jour les éléments récents : $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Échec de l’ouverture : $error'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 88276b47..9b64027f 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2063,6 +2063,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String get documentKindResourceFile => 'संसाधन फ़ाइल'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'हाल के आइटम अपडेट नहीं किए जा सके: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'खोलना विफल रहा: $error'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 69616ae5..c740bc34 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -2068,6 +2068,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get documentKindResourceFile => 'File sumber daya'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Tidak dapat memperbarui item terbaru: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Gagal dibuka: $error'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 19ec1b15..93a92aa0 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2086,6 +2086,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get documentKindResourceFile => 'File di risorse'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Impossibile aggiornare gli elementi recenti: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Apertura non riuscita: $error'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index ca2606ef..bdce986a 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -1999,6 +1999,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get documentKindResourceFile => 'リソースファイル'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return '最近使った項目を更新できませんでした: $error。'; + } + @override String workspaceErrorOpenFailed(String error) { return '開くことができませんでした:$error'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 27104bc9..c43b1118 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -1996,6 +1996,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get documentKindResourceFile => '리소스 파일'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return '최근 항목을 업데이트하지 못했습니다: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return '열기 실패: $error'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 5678951d..1f408c27 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -2073,6 +2073,11 @@ class AppLocalizationsNb extends AppLocalizations { @override String get documentKindResourceFile => 'Ressursfil'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Kunne ikke oppdatere nylige elementer: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Åpning mislyktes: $error'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index f6dd8897..b65789c9 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -2083,6 +2083,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get documentKindResourceFile => 'Bronbestand'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Recente items konden niet worden bijgewerkt: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Openen mislukt: $error'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index a7103665..d08315d3 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -2093,6 +2093,11 @@ class AppLocalizationsPl extends AppLocalizations { @override String get documentKindResourceFile => 'Plik zasobów'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Nie udało się zaktualizować ostatnich elementów: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Otwieranie nie powiodło się: $error'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index b96412b6..9024710b 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2081,6 +2081,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get documentKindResourceFile => 'Ficheiro de recursos'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Não foi possível atualizar os itens recentes: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Falha ao abrir: $error'; @@ -7675,6 +7680,11 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get documentKindResourceFile => 'Arquivo de recursos'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Não foi possível atualizar os itens recentes: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Falha ao abrir: $error'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index c65ea905..8e592db7 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2089,6 +2089,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get documentKindResourceFile => 'Файл ресурсов'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Не удалось обновить недавние элементы: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Не удалось открыть: $error'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 8aa8a53b..770a3dd3 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -2065,6 +2065,11 @@ class AppLocalizationsTr extends AppLocalizations { @override String get documentKindResourceFile => 'Kaynak dosyası'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Son öğeler güncellenemedi: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Açma başarısız oldu: $error'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index a2de1b9c..bbed81cb 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -2097,6 +2097,11 @@ class AppLocalizationsUk extends AppLocalizations { @override String get documentKindResourceFile => 'Файл ресурсів'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Не вдалося оновити нещодавні елементи: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Не вдалося відкрити: $error'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index a6733207..a3c3d9ea 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2062,6 +2062,11 @@ class AppLocalizationsVi extends AppLocalizations { @override String get documentKindResourceFile => 'Tệp tài nguyên'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return 'Không thể cập nhật các mục gần đây: $error.'; + } + @override String workspaceErrorOpenFailed(String error) { return 'Mở không thành công: $error'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 411f2047..c681b604 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1971,6 +1971,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get documentKindResourceFile => '资源文件'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return '无法更新最近使用的项目:$error。'; + } + @override String workspaceErrorOpenFailed(String error) { return '打开失败:$error'; @@ -7263,6 +7268,11 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get documentKindResourceFile => '资源文件'; + @override + String workspaceErrorRecentWorkspaceUpdateFailed(String error) { + return '无法更新最近使用的项目:$error。'; + } + @override String workspaceErrorOpenFailed(String error) { return '打开失败:$error'; diff --git a/lib/main.dart b/lib/main.dart index 5d2a8fcd..4af70c27 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,9 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'package:yaru/yaru.dart'; +import 'src/app/app_settings.dart'; import 'src/app/busymark_app.dart'; import 'src/app/startup_path.dart'; import 'src/app/system_accent.dart'; + import 'src/git/application/git_controller.dart'; import 'src/git/data/git_cli_gateway.dart'; import 'src/platform/gtk_animation_settings_service.dart'; @@ -21,7 +23,9 @@ import 'src/visualization/visualization_release_smoke.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); - if (Platform.isLinux) await YaruWindow.ensureInitialized(); + if (Platform.isLinux) { + await YaruWindow.ensureInitialized(); + } if (visualizationReleaseSmokeReportPath(args) case final reportPath?) { exit(await runVisualizationReleaseSmoke(reportPath)); } @@ -40,9 +44,16 @@ Future main(List args) async { ]); final headerIcons = GtkHeaderIconService(); await headerIcons.initialize(); + const settingsStore = JsonFileLocalSettingsStore(); + final startupSettings = await StartupAppSettings.load(settingsStore); + runApp( ProviderScope( overrides: [ + localSettingsStoreProvider.overrideWithValue(settingsStore), + appSettingsControllerProvider.overrideWith( + () => AppSettingsController(startupSettings: startupSettings), + ), initialGtkAnimationsEnabledProvider.overrideWithValue( chrome[0] as bool?, ), diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index b1c5ef33..70f0b970 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -581,6 +581,41 @@ abstract class LocalSettingsStore { Future save(Map json); } +/// One-use handoff of local preferences read before the first application frame. +/// Bound to the store that acknowledged them; later controller initialization +/// reads the store again instead of reusing a startup snapshot. +class StartupAppSettings { + StartupAppSettings._(this._store, this._snapshot); + + final LocalSettingsStore _store; + ({AppSettings settings, String? acknowledgedJson})? _snapshot; + + static Future load(LocalSettingsStore store) async { + AppSettings settings; + String? acknowledgedJson; + try { + settings = AppSettings.fromJson(await store.load()); + acknowledgedJson = _canonicalSettingsJson(settings.toJson()); + } on Object { + // Match the controller's existing failed-load policy. Defaults are usable + // but are not an acknowledged disk snapshot for write reconciliation. + settings = AppSettings.defaults(); + } + return StartupAppSettings._(store, ( + settings: settings, + acknowledgedJson: acknowledgedJson, + )); + } + + ({AppSettings settings, String? acknowledgedJson})? _take( + LocalSettingsStore store, + ) { + final snapshot = _snapshot; + _snapshot = null; + return identical(store, _store) ? snapshot : null; + } +} + class JsonFileLocalSettingsStore implements LocalSettingsStore { const JsonFileLocalSettingsStore({ String? settingsFilePathOverride, @@ -594,10 +629,26 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { @override Future> load() async { final file = await _settingsFile(); - if (!await file.exists()) { - return {}; + final type = await FileSystemEntity.type(file.path); + if (type != FileSystemEntityType.file && + type != FileSystemEntityType.notFound) { + throw FileSystemException( + 'Settings path is not a regular file', + file.path, + ); + } + // Other read failures must leave the controller's acknowledged snapshot + // unset. A notFound type can also mean that the path is inaccessible. + final String content; + try { + content = await file.readAsString(); + } on FileSystemException catch (error) { + final code = error.osError?.errorCode; + if (code == 2 || Platform.isWindows && code == 3) { + return {}; + } + rethrow; } - final content = await file.readAsString(); if (content.trim().isEmpty) { return {}; } @@ -648,6 +699,10 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { } class AppSettingsController extends Notifier { + AppSettingsController({StartupAppSettings? startupSettings}) + : _startupSettings = startupSettings; + + final StartupAppSettings? _startupSettings; bool Function()? processPendingProperties; Future setElementPropertiesVisible(bool value) { if (!value && processPendingProperties?.call() == false) { @@ -660,13 +715,24 @@ class AppSettingsController extends Notifier { _mutate((s) => s.copyWith(elementPropertiesWidth: value.clamp(260, 600))); late LocalSettingsStore _store; Future? _lastSave; + String? _persistedSettingsJson; final _pendingInitialMutations = []; + final _pendingInitialPersistence = >[]; var _loaded = false; late Future _loadFuture; @override AppSettings build() { _store = ref.read(localSettingsStoreProvider); + final startup = _startupSettings?._take(_store); + if (startup != null) { + _loaded = true; + _persistedSettingsJson = startup.acknowledgedJson; + _loadFuture = Future.value(); + return startup.settings.copyWith(elementPropertiesVisible: false); + } + _loaded = false; + _persistedSettingsJson = null; _loadFuture = _load(); unawaited(_loadFuture); return AppSettings.defaults(); @@ -674,6 +740,15 @@ class AppSettingsController extends Notifier { Future waitUntilLoaded() => _loadFuture; + Future flushPersistence() async { + await waitUntilLoaded(); + while (true) { + final pending = _lastSave; + await pending; + if (identical(pending, _lastSave)) return; + } + } + /// Invoked after validation against the current in-memory assignments. All /// preferences still use the same ordered writer and settings file. bool _trackShortcutSaves = false; @@ -681,12 +756,30 @@ class AppSettingsController extends Notifier { Map overrides, ) { _trackShortcutSaves = true; + final snapshot = Map.unmodifiable(overrides); + if (!_loaded) return _saveShortcutsAfterLoad(snapshot); return _save( - state.copyWith(shortcutOverrides: Map.unmodifiable(overrides)), + state.copyWith(shortcutOverrides: snapshot), + reusePersisted: true, ); } - Future retryShortcutSave() => _save(state); + Future _saveShortcutsAfterLoad( + Map overrides, + ) async { + await waitUntilLoaded(); + if (!ref.mounted) return false; + return _save( + state.copyWith(shortcutOverrides: overrides), + reusePersisted: true, + ); + } + + Future retryShortcutSave() async { + await waitUntilLoaded(); + if (!ref.mounted) return false; + return _save(state, reusePersisted: true); + } Future setPdfExportOptions(PdfExportOptions options) { options.validateOrThrow(); @@ -752,6 +845,7 @@ class AppSettingsController extends Notifier { documentViewMode: mode, previewVisible: mode != DocumentViewModePreference.source, ), + reusePersisted: true, ); } @@ -1036,7 +1130,7 @@ class AppSettingsController extends Notifier { ? resource.path : settings.lastOpenedPath, ); - }); + }, propagateFailure: true); Future clearRecentWorkspaces() => _mutate((settings) => settings.copyWith(recentWorkspaces: [])); @@ -1128,10 +1222,18 @@ class AppSettingsController extends Notifier { AppSettings loaded; try { loaded = AppSettings.fromJson(await _store.load()); + _persistedSettingsJson = _canonicalSettingsJson(loaded.toJson()); } on Object { loaded = AppSettings.defaults(); } - if (!ref.mounted) return; + if (!ref.mounted) { + for (final completion in _pendingInitialPersistence) { + completion.completeError(StateError('Settings controller disposed')); + } + _pendingInitialPersistence.clear(); + _pendingInitialMutations.clear(); + return; + } // Visibility belongs to this run; retain the preferred inspector width. // Explicit requests made during hydration are applied below. loaded = loaded.copyWith(elementPropertiesVisible: false); @@ -1140,43 +1242,88 @@ class AppSettingsController extends Notifier { loaded = mutation(loaded); } _pendingInitialMutations.clear(); + final completions = List>.of(_pendingInitialPersistence); + _pendingInitialPersistence.clear(); _loaded = true; state = loaded; if (hadPendingInitialMutations) { - await _save(loaded); + try { + // Initial actions share one publication, but opening callers retain + // its authoritative completion/error instead of a dispatch future. + await _save(loaded, propagateFailure: completions.isNotEmpty); + for (final completion in completions) { + completion.complete(); + } + } on Object catch (error, stack) { + for (final completion in completions) { + completion.completeError(error, stack); + } + } } } - Future _mutate(AppSettings Function(AppSettings) mutation) { + Future _mutate( + AppSettings Function(AppSettings) mutation, { + bool propagateFailure = false, + bool reusePersisted = false, + }) { if (!_loaded) { _pendingInitialMutations.add(mutation); state = mutation(state); + if (propagateFailure) { + final completion = Completer(); + _pendingInitialPersistence.add(completion); + return completion.future; + } return Future.value(); } - return _save(mutation(state)); + return _save( + mutation(state), + propagateFailure: propagateFailure, + reusePersisted: reusePersisted, + ); } - Future _save(AppSettings next) async { + Future _save( + AppSettings next, { + bool propagateFailure = false, + bool reusePersisted = false, + }) async { + if (!ref.mounted) return false; state = next; final json = next.toJson(); + final encoded = _canonicalSettingsJson(json); final previousSave = _lastSave; + Object? failure; + StackTrace? failureStack; final save = () async { await previousSave; try { - await _store.save(json); + // Compare the entire serialized snapshot only after preceding writes + // settle. Failed loads and writes never acknowledge unpersisted state. + if (!reusePersisted || encoded != _persistedSettingsJson) { + await _store.save(json); + _persistedSettingsJson = encoded; + } return true; - } on Object { - // Runtime preferences stay visible; keymap callers observe this outcome. + } on Object catch (error, stack) { + failure = error; + failureStack = stack; return false; } }(); - _lastSave = save; + // The queue tail always settles successfully; each caller retains its own + // outcome/error without preventing the next admitted publication. + _lastSave = save.then((_) {}); final succeeded = await save; if (ref.mounted && _trackShortcutSaves && mapEquals(state.shortcutOverrides, next.shortcutOverrides)) { ref.read(shortcutPersistenceOutcomeProvider.notifier).complete(succeeded); } + if (propagateFailure && failure != null) { + Error.throwWithStackTrace(failure!, failureStack!); + } return succeeded; } } @@ -1327,3 +1474,17 @@ String? _normalizedStoredGitWorkspacePath(String? value) { } return p.normalize(p.absolute(value)); } + +// Map insertion order is not a preference change. Lists retain their order, +// including the release target's qualified Recent resources. +String _canonicalSettingsJson(Map json) => + jsonEncode(_canonicalSettingsValue(json)); + +Object? _canonicalSettingsValue(Object? value) { + if (value is Map) { + final keys = value.keys.cast().toList()..sort(); + return {for (final key in keys) key: _canonicalSettingsValue(value[key])}; + } + if (value is List) return value.map(_canonicalSettingsValue).toList(); + return value; +} diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index 1d85ae95..2684b955 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -1114,13 +1114,15 @@ class _BusyMarkWindowLifecycleState if (!context.mounted) { return; } + final controller = ref.read(workspaceControllerProvider.notifier); + await controller.waitForWorkspaceOpening(); + if (!mounted || !context.mounted) return; final settings = ref.read(appSettingsControllerProvider); var workspace = ref.read(workspaceControllerProvider); if (settings.autoSave && workspace.hasUnsavedChanges) { await ref.read(workspaceControllerProvider.notifier).saveAll(); workspace = ref.read(workspaceControllerProvider); } - final controller = ref.read(workspaceControllerProvider.notifier); await _windowControlService.handleCloseRequest( hasUnsavedChanges: workspace.hasUnsavedChanges, confirmCloseWithUnsavedChanges: settings.confirmCloseWithUnsavedChanges, diff --git a/lib/src/core/linux_private_permissions.dart b/lib/src/core/linux_private_permissions.dart new file mode 100644 index 00000000..221b6c3e --- /dev/null +++ b/lib/src/core/linux_private_permissions.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +typedef _OpenNative = Int32 Function(Pointer, Int32); +typedef _OpenDart = int Function(Pointer, int); +typedef _FchmodNative = Int32 Function(Int32, Uint32); +typedef _FchmodDart = int Function(int, int); +typedef _ChmodNative = Int32 Function(Pointer, Uint32); +typedef _ChmodDart = int Function(Pointer, int); +typedef _CloseNative = Int32 Function(Int32); +typedef _CloseDart = int Function(int); +typedef _StatxNative = + Int32 Function(Int32, Pointer, Int32, Uint32, Pointer); +typedef _StatxDart = int Function(int, Pointer, int, int, Pointer); +typedef _ErrnoNative = Pointer Function(); +typedef _ErrnoDart = Pointer Function(); + +/// Restricts an existing Linux state inode without launching a subprocess. +/// State directories may be configured through symbolic links. Files reject +/// final-component links; protection always applies to the opened inode, even +/// if another process replaces the pathname in the meantime. +final class LinuxPrivatePermissions { + LinuxPrivatePermissions._() : _library = 'libc.so.6'; + + /// Native barrier/error fixture only; production always selects libc. + LinuxPrivatePermissions.forTesting({required String nativeLibrary}) + : _library = nativeLibrary; + + static final instance = LinuxPrivatePermissions._(); + final String _library; + + // At most one native worker is active per calling isolate. UI transaction + // queues await admission/completion; no operation is dropped or cancelled. + // Isolate.run retires its worker after each operation, including failures, + // so an idle permission service cannot keep application shutdown alive. + static Future _tail = Future.value(); + + Future restrict(String path, {required bool directory}) async { + if (path.contains('\u0000')) { + throw FileSystemException('Invalid application state path', path); + } + final operation = _tail.then( + (_) => _restrictOffIsolate(path, directory, _library), + ); + _tail = operation.then((_) {}, onError: (Object _, StackTrace _) {}); + await operation; + } +} + +// Explicit inputs prevent a closure from transferring a service, transaction, +// open descriptor or UI object. Construction, allocation, open/fchmod/close +// and error/errno handling all execute together on the worker isolate. +Future _restrictOffIsolate(String path, bool directory, String library) => + Isolate.run( + () => _NativePermissions(library).restrict(path, directory: directory), + debugName: 'busymark-private-permissions', + ); + +final class _NativePermissions { + _NativePermissions(String library) { + final libc = DynamicLibrary.open(library); + _open = libc.lookupFunction<_OpenNative, _OpenDart>('open'); + _fchmod = libc.lookupFunction<_FchmodNative, _FchmodDart>('fchmod'); + _chmod = libc.lookupFunction<_ChmodNative, _ChmodDart>('chmod'); + _close = libc.lookupFunction<_CloseNative, _CloseDart>('close'); + _statx = libc.lookupFunction<_StatxNative, _StatxDart>('statx'); + _errno = libc.lookupFunction<_ErrnoNative, _ErrnoDart>('__errno_location'); + } + + late final _OpenDart _open; + late final _FchmodDart _fchmod; + late final _ChmodDart _chmod; + late final _CloseDart _close; + late final _StatxDart _statx; + late final _ErrnoDart _errno; + + void restrict(String path, {required bool directory}) { + if (path.contains('\u0000')) { + throw FileSystemException('Invalid application state path', path); + } + // Linux UAPI: O_DIRECTORY follows a configured directory link and pins its + // target. Files use O_NOFOLLOW. O_NONBLOCK prevents an unexpected FIFO from + // blocking the worker; O_CLOEXEC keeps descriptors out of child processes. + final typeFlags = directory ? 0x10000 : 0x20000; + final nativePath = path.toNativeUtf8(); + var descriptor = -1; + var pathDescriptor = false; + int? error; + try { + descriptor = _open(nativePath, 0x800 | 0x80000 | typeFlags); + if (descriptor < 0 && _errno().value == 13) { + // chmod does not require read permission on an owned inode. A creation + // mask can remove read access even from a just-written staging file. + // Pin the inode without I/O; validate O_PATH symlinks below before use. + descriptor = _open(nativePath, 0x200000 | 0x80000 | typeFlags); + pathDescriptor = true; + } + if (descriptor < 0) error = _errno().value; + } finally { + malloc.free(nativePath); + } + if (error != null) _fail(path, error); + try { + if (pathDescriptor) { + // O_DIRECTORY already guarantees a directory. O_PATH | O_NOFOLLOW + // permits holding a file symlink itself, so inspect that descriptor. + if (!directory) _rejectHeldSymlink(descriptor, path); + // fchmod rejects O_PATH descriptors. This procfs magic link resolves + // the held inode, not the original (possibly replaced) pathname. Keep + // the descriptor open until chmod completes; unavailable procfs fails + // the transaction rather than falling back to an unpinned path. + final heldPath = '/proc/self/fd/$descriptor'.toNativeUtf8(); + try { + if (_chmod(heldPath, directory ? 448 : 384) != 0) { + error = _errno().value; + } + } finally { + malloc.free(heldPath); + } + } else if (_fchmod(descriptor, directory ? 448 : 384) != 0) { + error = _errno().value; + } + } finally { + if (_close(descriptor) != 0) error ??= _errno().value; + } + if (error != null) _fail(path, error); + } + + void _rejectHeldSymlink(int descriptor, String path) { + // O_PATH | O_NOFOLLOW can open a symlink itself. AT_EMPTY_PATH inspects the + // held inode, never a replaced pathname. Use the fixed Linux statx ABI, + // as in LinuxFileIdentity, rather than architecture-dependent struct stat. + final empty = ''.toNativeUtf8(); + final buffer = calloc(256); + try { + // AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW, STATX_TYPE. + if (_statx(descriptor, empty, 0x1100, 1, buffer) != 0) { + _fail(path, _errno().value); + } + final data = ByteData.sublistView(buffer.asTypedList(256)); + if (data.getUint32(0, Endian.host) & 1 == 0) { + _fail(path, 95); // EOPNOTSUPP: no authoritative inode type. + } + if (data.getUint16(0x1c, Endian.host) & 0xf000 == 0xa000) { + _fail(path, 40); // ELOOP, as for O_NOFOLLOW open. + } + } finally { + calloc.free(buffer); + malloc.free(empty); + } + } + + Never _fail(String path, int code) => throw FileSystemException( + 'Could not restrict application state permissions', + path, + OSError('Native permission operation failed', code), + ); +} diff --git a/lib/src/editor/wysiwyg/writerside_editing_adapter.dart b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart index 506b6f33..fdc23ecf 100644 --- a/lib/src/editor/wysiwyg/writerside_editing_adapter.dart +++ b/lib/src/editor/wysiwyg/writerside_editing_adapter.dart @@ -370,6 +370,12 @@ class WritersideEditingAdapter { filePath: filePath, source: source, ); + return fromAuthoredXml(authored); + } + + /// Projects a validated, unresolved authored tree without parsing it again. + /// Callers must match the tree's path and source to the editor revision. + BusyDocument? fromAuthoredXml(WritersideDocument authored) { if (!authored.isWellFormed || authored.rootElement?.name != 'topic' || authored.nodes.whereType().length != 1) { @@ -377,9 +383,9 @@ class WritersideEditingAdapter { } var index = 0; return BusyDocument( - filePath: filePath, + filePath: authored.filePath, mode: MarkdownMode.writersideMarkdown, - source: source, + source: authored.source, authoredXml: authored, title: authored.rootElement?.attributes['title'], blocks: [ diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 1da726b6..0159079d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -60,6 +60,20 @@ BusyBlock busyMarkWysiwygImmutableBlockSnapshot(BusyBlock block) { enum BusyWysiwygReplacementScope { fieldContent, documentRange } +class _BlockPosition { + const _BlockPosition(this.block, this.parent, this.rootIndex); + final BusyBlock block; + final _BlockPosition? parent; + final int rootIndex; +} + +class _HeadingPosition { + const _HeadingPosition(this.block, this.parent, this.level); + final BusyBlock block; + final _HeadingPosition? parent; + final int level; +} + class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyMarkWysiwygDocumentController({ required BusyDocument document, @@ -72,6 +86,40 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyDocument _document; final BusyMarkMarkdownSerializer _serializer; int _generatedBlockIndex = 0; + BusyDocument? _indexedDocument; + final _positions = {}; + final _headingsByRoot = <_HeadingPosition?>[]; + + void _ensureBlockIndex() { + if (identical(_indexedDocument, _document)) return; + _positions.clear(); + _headingsByRoot.clear(); + void visit(BusyBlock block, _BlockPosition? parent, int rootIndex) { + final position = _BlockPosition(block, parent, rootIndex); + // Match the existing depth-first first-match behavior, including models + // with duplicate IDs. Parent links use positions, rather than ID lookups. + _positions.putIfAbsent(block.id, () => position); + for (final child in block.children) { + visit(child, position, rootIndex); + } + } + + _HeadingPosition? heading; + final firstRootIndices = {}; + for (var i = 0; i < _document.blocks.length; i++) { + final block = _document.blocks[i]; + if (block.kind == BusyBlockKind.heading) { + final level = int.tryParse(block.attributes['level'] ?? '') ?? 1; + while (heading != null && heading.level >= level) { + heading = heading.parent; + } + heading = _HeadingPosition(block, heading, level); + } + _headingsByRoot.add(heading); + visit(block, null, firstRootIndices.putIfAbsent(block.id, () => i)); + } + _indexedDocument = _document; + } BusyDocument get document => _document; @@ -215,6 +263,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { @override void notifyListeners() { + _indexedDocument = null; _document = _ensureEditableDocument(_document); super.notifyListeners(); } @@ -232,30 +281,27 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } List elementPath(String blockId) { - List? find(List blocks) { - for (final block in blocks) { - if (block.id == blockId) return [block]; - final path = find(block.children); - if (path != null) return [block, ...path]; - } - return null; - } - - final path = find(_document.blocks) ?? []; + _ensureBlockIndex(); + final position = _positions[blockId]; + if (position == null) return []; + final reversed = []; + for (var current = position; ;) { + reversed.add(current.block); + final parent = current.parent; + if (parent == null) break; + current = parent; + } + final path = reversed.reversed.toList(); if (!_document.isXmlTopic && path.isNotEmpty) { - final rootIndex = _document.blocks.indexWhere( - (b) => b.id == path.first.id, - ); final headings = []; - for (final candidate in _document.blocks.take(rootIndex + 1)) { - if (candidate.kind != BusyBlockKind.heading) continue; - final level = int.tryParse(candidate.attributes['level'] ?? '') ?? 1; - headings.removeWhere( - (h) => (int.tryParse(h.attributes['level'] ?? '') ?? 1) >= level, - ); - if (candidate.id != blockId) headings.add(candidate); + for ( + var heading = _headingsByRoot[position.rootIndex]; + heading != null; + heading = heading.parent + ) { + if (heading.block.id != blockId) headings.add(heading.block); } - return [...headings, ...path]; + return [...headings.reversed, ...path]; } return path; } @@ -2178,12 +2224,8 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } BusyBlock? blockById(String blockId) { - for (final block in _flatten(_document.blocks)) { - if (block.id == blockId) { - return block; - } - } - return null; + _ensureBlockIndex(); + return _positions[blockId]?.block; } String admonitionTargetId(String blockId) { diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index aa3a676e..16e008eb 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -27,13 +27,18 @@ typedef LocalHistoryComparisonComputer = /// Read-only rendering of the shared source-comparison model. Refactoring /// previews reuse this instead of maintaining a second diff presentation. class SourceComparisonView extends StatelessWidget { - const SourceComparisonView({super.key, required this.comparison}); + const SourceComparisonView({super.key, required this.comparison}) + : _sliver = false; + + const SourceComparisonView.sliver({super.key, required this.comparison}) + : _sliver = true; final SourceComparison comparison; + final bool _sliver; @override Widget build(BuildContext context) => - _UnifiedComparisonPane(comparison: comparison); + _UnifiedComparisonPane(comparison: comparison, sliver: _sliver); } final localHistoryComparisonComputerProvider = @@ -527,9 +532,10 @@ class _ComparisonBody extends StatelessWidget { } class _UnifiedComparisonPane extends StatelessWidget { - const _UnifiedComparisonPane({required this.comparison}); + const _UnifiedComparisonPane({required this.comparison, this.sliver = false}); final SourceComparison comparison; + final bool sliver; @override Widget build(BuildContext context) { @@ -595,6 +601,12 @@ class _UnifiedComparisonPane extends StatelessWidget { if (rows.isEmpty) { rows.add(_UnifiedSourceChunk(source: comparison.currentInput.source)); } + if (sliver) { + return SliverPadding( + padding: const EdgeInsets.all(BusyMarkSpacing.md), + sliver: SliverList.list(children: rows), + ); + } return ListView( padding: const EdgeInsets.all(BusyMarkSpacing.md), children: rows, diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index c6c36fc0..eb3eee8f 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -585,6 +585,12 @@ class LocalHistoryController extends Notifier { final generation = ++_loadGeneration; if (ref.mounted) state = state.copyWith(loading: true); try { + // Defaults are presentation state while settings hydrate. In particular, + // the default retention age must not delete history before a longer saved + // policy is known. A newer authoritative refresh supersedes this one + // before maintenance starts, rather than reusing its eventual snapshot. + await ref.read(appSettingsControllerProvider.notifier).waitUntilLoaded(); + if (!ref.mounted || generation != _loadGeneration) return false; await _store.prune(policy, _clock()); final snapshot = await _store.load(); if (!ref.mounted || generation != _loadGeneration) return false; @@ -4702,6 +4708,16 @@ class LocalHistoryController extends Notifier { (requireProtection || retainedProtection ? _newPathOperationId() : null); + // Keep the accepted generations across hydration: disabling recording or + // clearing history while settings load must cancel this queued capture. + if (!_operationIsCurrent( + snapshot.bufferId, + captureHistoryGeneration, + captureBufferGeneration, + )) { + return !requireProtection; + } + await ref.read(appSettingsControllerProvider.notifier).waitUntilLoaded(); if (!_operationIsCurrent( snapshot.bufferId, captureHistoryGeneration, diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index 8fe98cb7..477e01b8 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/foundation.dart' show listEquals, visibleForTesting; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; @@ -357,14 +357,13 @@ class FileLocalHistoryStore implements LocalHistoryStore { await _deleteFileBestEffort(target); throw const LocalHistoryCaptureCancelled(); } - final retained = await _applyRetention( + await _applyRetention( root, index, policy, revisionId, request.capturedAt.toUtc(), ); - if (!identical(retained, index)) await _publishIndex(root, retained); return LocalHistoryCaptureResult(document: document, revision: summary); }); }); @@ -394,14 +393,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { (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); + await _applyRetention(root, index, policy, null, now.toUtc()); }), ); @@ -627,10 +619,12 @@ class FileLocalHistoryStore implements LocalHistoryStore { 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)), ); @@ -678,6 +672,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { break; } on FileSystemException catch (error) { if (!_isLockContention(error)) rethrow; + final remaining = _lockBudget - deadline.elapsed; if (remaining <= Duration.zero) throw LocalHistoryLockTimeout(error); await Future.delayed( @@ -1077,7 +1072,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { } } - Future<_Index> _applyRetention( + Future _applyRetention( Directory root, _Index index, LocalHistoryPolicy policy, @@ -1111,7 +1106,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (retainedDocumentIds.contains(document.id)) document, ]; if (removed.isEmpty && documents.length == index.documents.length) { - return index; + return; } final retained = index.copyWith( documents: documents, @@ -1127,22 +1122,46 @@ class FileLocalHistoryStore implements LocalHistoryStore { _revisionFile(root, revision.documentId, revision.id), ); } - return retained; + // The retained index is already durable before deletion. Republishing the + // identical index afterward adds two flushes without another state change. } Future _publishIndex(Directory root, _Index index) async { - await _publishReplacingFile( - File(p.join(root.path, 'tombstones.json')), - const JsonEncoder.withIndent( - ' ', - ).convert(index.tombstones.toList()..sort()), - ); + final journal = File(p.join(root.path, 'tombstones.json')); + final tombstones = const JsonEncoder.withIndent( + ' ', + ).convert(index.tombstones.toList()..sort()); + // Every caller holds the store's interprocess lock. Reuse only the exact + // regular journal already on disk; never cache completed authority across + // transactions. New tombstones still commit before the index or deletion. + if (!await _journalMatches(journal, tombstones)) { + await _publishReplacingFile(journal, tombstones); + } await _publishReplacingFile( File(p.join(root.path, 'index.json')), const JsonEncoder.withIndent(' ').convert(index.toJson()), ); } + Future _journalMatches(File journal, String content) async { + try { + if (await FileSystemEntity.type(journal.path, followLinks: false) != + FileSystemEntityType.file) { + return false; + } + final expected = utf8.encode(content); + if (await journal.length() != expected.length) return false; + final matches = listEquals(await journal.readAsBytes(), expected); + return matches && + await FileSystemEntity.type(journal.path, followLinks: false) == + FileSystemEntityType.file; + } on FileSystemException { + // A missing/unreadable journal cannot be reused. Attempt the same atomic + // publication as before; any publication failure still propagates. + return false; + } + } + Future _publishNewFile(File target, String content) async { if (await target.exists()) { throw LocalHistoryStorageException('Revision identity collision.'); diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 8c0f87a4..97b89135 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -24,7 +24,32 @@ import '../writerside/writerside_schema.dart'; // resolve inside the opened workspace. Targets outside that root, unsupported // file types, and oversized files are not inspected. const int _maxLocalReferenceTargetBytes = 2 * 1024 * 1024; -const int _backgroundParseThresholdBytes = 64 * 1024; +// Release measurements put structured 4 Ki code-unit inputs beyond a frame +// budget, with about 1–3 ms of worker overhead. Count UTF-16 code units (the +// parser's offsets), rather than doing a UTF-8 encoding just for this decision. +const int _backgroundParseThresholdCodeUnits = 4 * 1024; + +typedef _MarkdownParseInput = ({ + String filePath, + String source, + MarkdownMode mode, + String? workspaceRoot, + bool preserveHtmlSemantics, +}); + +// Keep the Isolate.run closure outside the parser method: it owns only this +// explicit input, never the parser receiver or caller's controller graph. +Future _parseInWorker(_MarkdownParseInput input) => + Isolate.run( + () => MarkdownParser(preserveHtmlSemantics: input.preserveHtmlSemantics) + .parse( + filePath: input.filePath, + source: input.source, + mode: input.mode, + workspaceRoot: input.workspaceRoot, + validateLocalReferences: false, + ), + ); class MarkdownParser { const MarkdownParser({this.preserveHtmlSemantics = false}); @@ -78,7 +103,7 @@ class MarkdownParser { Map sourceOverrides = const {}, }) async { final parsed = - source.length < _backgroundParseThresholdBytes || + source.length < _backgroundParseThresholdCodeUnits || runtimeType != MarkdownParser ? parse( filePath: filePath, @@ -87,15 +112,13 @@ class MarkdownParser { workspaceRoot: workspaceRoot, validateLocalReferences: false, ) - : await Isolate.run( - () => parse( - filePath: filePath, - source: source, - mode: mode, - workspaceRoot: workspaceRoot, - validateLocalReferences: false, - ), - ); + : await _parseInWorker(( + filePath: filePath, + source: source, + mode: mode, + workspaceRoot: workspaceRoot, + preserveHtmlSemantics: preserveHtmlSemantics, + )); if (!validateLocalReferences) { return parsed; } diff --git a/lib/src/visualization/visualization_cache.dart b/lib/src/visualization/visualization_cache.dart index 181ad223..1b0af78a 100644 --- a/lib/src/visualization/visualization_cache.dart +++ b/lib/src/visualization/visualization_cache.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; import 'package:path/path.dart' as p; @@ -25,6 +26,7 @@ class VisualizationCache { final LinkedHashMap _memory = LinkedHashMap(); + Future _trimTail = Future.value(); Future get(String key) async { final memoryResult = _memory.remove(key); @@ -80,7 +82,11 @@ class VisualizationCache { final temporary = File( '${file.path}.tmp-$pid-${DateTime.now().microsecondsSinceEpoch}', ); - await temporary.writeAsBytes(bytes, flush: true); + // Derived images are disposable. Await the complete write and atomic + // rename, but avoid a durable flush that can hold rendering slots for + // seconds behind unrelated filesystem writes. Incomplete cache entries + // are rejected on read and recomputed. + await temporary.writeAsBytes(bytes); try { await temporary.rename(file.path); } on FileSystemException { @@ -115,32 +121,25 @@ class VisualizationCache { } } - Future _trimDiskBestEffort() async { - try { - final files = <({File file, FileStat stat})>[]; - await for (final entity in diskRoot.list(followLinks: false)) { - if (entity is! File || p.extension(entity.path) != '.json') { - continue; - } - final stat = await entity.stat(); - files.add((file: entity, stat: stat)); - } - files.sort( - (left, right) => left.stat.modified.compareTo(right.stat.modified), - ); - var bytes = files.fold(0, (sum, entry) => sum + entry.stat.size); - var count = files.length; - for (final entry in files) { - if (count <= maximumDiskEntries && bytes <= maximumDiskBytes) { - break; - } - await entry.file.delete(); - count--; - bytes -= entry.stat.size; + Future _trimDiskBestEffort() { + final previous = _trimTail; + final root = diskRoot.absolute.path; + final entryLimit = maximumDiskEntries; + final byteLimit = maximumDiskBytes; + + final trim = () async { + await previous; + try { + // One maintenance worker at a time. Callers still await trimming and + // occupy their bounded rendering slots; no background writes accrue. + // Avoid a main-isolate round trip for every cached file's metadata. + await _trimInWorker(root, entryLimit, byteLimit); + } on Object { + // Best-effort LRU maintenance only. Keep the queue usable after failure. } - } on Object { - // Best-effort LRU maintenance only. - } + }(); + _trimTail = trim; + return trim; } static Directory _defaultDiskRoot(Map? environment) { @@ -156,6 +155,33 @@ class VisualizationCache { } } +// This closure's scope contains only explicit primitive inputs, rather than a +// cache, rendered images, queued Futures or other main-isolate state. +Future _trimInWorker(String root, int maximumEntries, int maximumBytes) => + Isolate.run( + () => _trimDirectory(root, maximumEntries, maximumBytes), + debugName: 'Visualization cache maintenance', + ); + +void _trimDirectory(String root, int maximumEntries, int maximumBytes) { + final files = <({File file, FileStat stat})>[]; + for (final entity in Directory(root).listSync(followLinks: false)) { + if (entity is! File || p.extension(entity.path) != '.json') continue; + files.add((file: entity, stat: entity.statSync())); + } + files.sort( + (left, right) => left.stat.modified.compareTo(right.stat.modified), + ); + var bytes = files.fold(0, (sum, entry) => sum + entry.stat.size); + var count = files.length; + for (final entry in files) { + if (count <= maximumEntries && bytes <= maximumBytes) break; + entry.file.deleteSync(); + count--; + bytes -= entry.stat.size; + } +} + Map _encodeResult( String key, VisualizationRenderResult result, diff --git a/lib/src/visualization/visualization_coordinator.dart b/lib/src/visualization/visualization_coordinator.dart index f09e198b..eb7ffba6 100644 --- a/lib/src/visualization/visualization_coordinator.dart +++ b/lib/src/visualization/visualization_coordinator.dart @@ -216,12 +216,16 @@ class VisualizationCoordinator { _throwIfSuperseded(queued.request, queued.token); if (result.isSuccessful) { _rememberLastSuccessful(queued.request.blockKey, result); - await cache.put(queued.request.cacheKey, result); } - _throwIfSuperseded(queued.request, queued.token); if (!queued.completer.isCompleted) { queued.completer.complete(result); } + if (result.isSuccessful) { + // Publishing a rendered result does not require best-effort disk I/O. + // put warms the memory cache before its first await. Keep awaiting the + // write here so pending cache work still occupies a bounded queue slot. + await cache.put(queued.request.cacheKey, result); + } } on VisualizationCancelledException { if (!queued.completer.isCompleted) { queued.completer.completeError( diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index ca4a8de7..fa94abb1 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -43,6 +43,8 @@ class WelcomeScreen extends ConsumerStatefulWidget { } class _WelcomeScreenState extends ConsumerState { + bool _navigateWhenContentReady = false; + XTypeGroup _markdownTypes(BuildContext context) => XTypeGroup( label: context.l10n.fileTypeMarkdown, extensions: ['md', 'markdown'], @@ -51,6 +53,16 @@ class _WelcomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(workspaceControllerProvider); + ref.listen(workspaceControllerProvider, (previous, next) { + if (_navigateWhenContentReady && + previous?.isLoading == true && + !next.isLoading && + next.workspace != null && + mounted) { + _navigateWhenContentReady = false; + context.go('/workspace'); + } + }); final settings = ref.watch(appSettingsControllerProvider); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final colors = BusyMarkSurfaceColors.of(context); @@ -97,19 +109,11 @@ class _WelcomeScreenState extends ConsumerState { if (!mounted) { return; } - final settingsController = ref.read( - appSettingsControllerProvider.notifier, - ); - await settingsController.waitUntilLoaded(); - if (!mounted) { - return; - } - final reopenCleanSession = ref - .read(appSettingsControllerProvider) - .reopenPreviousWorkspaceOnStartup; + // Register startup restoration before awaiting settings so an early + // close also waits for recovery publication and sees its dirty state. final restored = await ref .read(workspaceControllerProvider.notifier) - .restoreStartupSession(reopenCleanSession: reopenCleanSession); + .restoreStartupSession(); if (restored && mounted) { this.context.go('/workspace'); } @@ -537,7 +541,15 @@ class _WelcomeScreenState extends ConsumerState { if (!safe) { return; } - await ref.read(workspaceControllerProvider.notifier).openPath(path); + _navigateWhenContentReady = true; + try { + // The operation still awaits bookkeeping and reports its errors. The + // listener may publish the prepared, monitored editor while that write + // is pending, without making it a fire-and-forget persistence operation. + await ref.read(workspaceControllerProvider.notifier).openPath(path); + } finally { + _navigateWhenContentReady = false; + } if (!mounted) { return; } @@ -891,6 +903,7 @@ BusyMarkStatusKind busyMarkWorkspaceMessageStatusKind( BusyMarkStatusKind.information, WorkspaceMessageCode.recoveryRestored => BusyMarkStatusKind.information, WorkspaceMessageCode.saveBlockedFileChangedOnDisk || + WorkspaceMessageCode.recentWorkspaceUpdateFailed || WorkspaceMessageCode.recoveryDamaged => BusyMarkStatusKind.warning, WorkspaceMessageCode.openFailed || WorkspaceMessageCode.createWritersideProjectFailed || diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index def3fb32..d95c6c8a 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -64,6 +64,7 @@ import '../../core/diagnostic.dart'; import '../../core/diagnostic_localizations.dart'; import '../../core/path_utils.dart' show isTextDocumentationPath, slugForHeading; + import '../../core/source_span.dart'; import '../../comparison/source_comparison.dart'; import '../../core/uri_utils.dart'; @@ -3793,71 +3794,88 @@ class _WritersideTopicRenameSidebarState ), ), ), - Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.topicRenamePathChange( - widget.plan.oldTopicPath, - widget.plan.newTopicPath, - ), - key: const ValueKey('topic-rename-path-change'), - ), - if (widget.plan.updatesXmlTopicId) - Text( - context.l10n.topicRenameIdChange( - widget.plan.oldTopicId, - widget.plan.newTopicId, + Expanded( + child: CustomScrollView( + key: const ValueKey('topic-rename-preview-scroll'), + primary: false, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.topicRenamePathChange( + widget.plan.oldTopicPath, + widget.plan.newTopicPath, + ), + key: const ValueKey('topic-rename-path-change'), + ), + if (widget.plan.updatesXmlTopicId) + Text( + context.l10n.topicRenameIdChange( + widget.plan.oldTopicId, + widget.plan.newTopicId, + ), + key: const ValueKey('topic-rename-id-change'), + ), + for (final change in widget.plan.webFileNameChanges) + if (change.changed) + Text( + context.l10n.topicRenameUrlChange( + change.instanceId, + change.oldWebFileName, + change.newWebFileName, + ), + ), + const SizedBox(height: BusyMarkSpacing.sm), + Text( + context.l10n.topicRenameAffectedFiles, + style: Theme.of(context).textTheme.labelLarge, + ), + ], ), - key: const ValueKey('topic-rename-id-change'), ), - for (final change in widget.plan.webFileNameChanges) - if (change.changed) - Text( - context.l10n.topicRenameUrlChange( - change.instanceId, - change.oldWebFileName, - change.newWebFileName, + ), + SliverToBoxAdapter( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: ListView.builder( + key: const ValueKey('topic-rename-affected-files'), + primary: false, + itemCount: files.length, + itemBuilder: (context, index) => ListTile( + dense: true, + selected: index == _selected, + leading: Icon( + index == 0 + ? BusyMarkGlyphs.edit + : BusyMarkGlyphs.document, + size: BusyMarkSizes.iconSm, + ), + title: Text( + index == 0 + ? '${p.basename(widget.plan.oldTopicPath)} → ${p.basename(widget.plan.newTopicPath)}' + : p.basename(files[index].path), + ), + subtitle: Text( + files[index].path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => setState(() => _selected = index), ), ), - const SizedBox(height: BusyMarkSpacing.sm), - Text( - context.l10n.topicRenameAffectedFiles, - style: Theme.of(context).textTheme.labelLarge, - ), - ], - ), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 180), - child: ListView.builder( - key: const ValueKey('topic-rename-affected-files'), - itemCount: files.length, - itemBuilder: (context, index) => ListTile( - dense: true, - selected: index == _selected, - leading: Icon( - index == 0 ? BusyMarkGlyphs.edit : BusyMarkGlyphs.document, - size: BusyMarkSizes.iconSm, - ), - title: Text( - index == 0 - ? '${p.basename(widget.plan.oldTopicPath)} → ${p.basename(widget.plan.newTopicPath)}' - : p.basename(files[index].path), + ), ), - subtitle: Text( - files[index].path, - maxLines: 1, - overflow: TextOverflow.ellipsis, + SliverToBoxAdapter( + child: Divider(height: 1, color: colors.subtleBorder), ), - onTap: () => setState(() => _selected = index), - ), + SourceComparisonView.sliver(comparison: comparison), + ], ), ), - Divider(height: 1, color: colors.subtleBorder), - Expanded(child: SourceComparisonView(comparison: comparison)), Padding( padding: const EdgeInsets.all(BusyMarkSpacing.sm), child: BusyMarkPushButton.standardIcon( @@ -14862,10 +14880,14 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { return _cachedWysiwygDocument; } if (context.kind == DocumentKind.writersideXmlTopic) { - final document = const WritersideEditingAdapter().parseXml( - filePath: activePath, - source: buffer.text, - ); + final authored = context.writersideTopic?.document; + final adapter = const WritersideEditingAdapter(); + final document = + authored != null && + p.equals(authored.filePath, activePath) && + authored.source == buffer.text + ? adapter.fromAuthoredXml(authored) + : adapter.parseXml(filePath: activePath, source: buffer.text); _clearWysiwygCache(); if (document != null) _cacheWysiwygDocument(buffer.id, document); return document; diff --git a/lib/src/workspace/recent_navigation.dart b/lib/src/workspace/recent_navigation.dart index f7ec825c..eeab54b6 100644 --- a/lib/src/workspace/recent_navigation.dart +++ b/lib/src/workspace/recent_navigation.dart @@ -134,21 +134,19 @@ Future openRecentResource( } } if (opened && context.mounted) { - await ref - .read(appSettingsControllerProvider.notifier) - .recordRecent( - RecentResource( - path: resource.path, - kind: resource.kind, - accountId: resource.accountId, - accountLabel: resource.accountLabel, - localId: resource.localId, - title: resource.title, - ownerRoot: resource.ownerRoot, - document: resource.isDocument, - lastOpenedAt: DateTime.now(), - ), - ); + await controller.recordRecentResource( + RecentResource( + path: resource.path, + kind: resource.kind, + accountId: resource.accountId, + accountLabel: resource.accountLabel, + localId: resource.localId, + title: resource.title, + ownerRoot: resource.ownerRoot, + document: resource.isDocument, + lastOpenedAt: DateTime.now(), + ), + ); if (!context.mounted || !currentRequest()) return; revealOpenedDocument(ref); ref.read(searchSessionProvider.notifier).close(restoreFocus: false); diff --git a/lib/src/workspace/recovery_persistence.dart b/lib/src/workspace/recovery_persistence.dart index ba1c2c11..b991315c 100644 --- a/lib/src/workspace/recovery_persistence.dart +++ b/lib/src/workspace/recovery_persistence.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../core/linux_private_permissions.dart'; + import 'document_buffer.dart'; import 'session_persistence.dart'; import 'text_format_metadata.dart'; @@ -538,6 +540,7 @@ class JsonDocumentRecoveryStore implements DocumentRecoveryStore { Future _serialized(Future Function() operation) { final completer = Completer(); + _queue = _queue.then((_) async { try { completer.complete(await operation()); @@ -577,10 +580,19 @@ int? _linuxProcessStartIdentity(int processId) { Future _withRecoveryLock(File recovery, Future Function() body) async { await recovery.parent.create(recursive: true); - final lock = await File('${recovery.path}.lock').open(mode: FileMode.append); + final lockFile = File('${recovery.path}.lock'); + final lock = await lockFile.open(mode: FileMode.append); final deadline = DateTime.now().add(const Duration(seconds: 30)); var acquired = false; try { + // Protect a newly created lock before releasing its first handle. Otherwise + // an owner-read-stripping creation mask prevents subsequent checkpoints. + if (Platform.isLinux) { + await LinuxPrivatePermissions.instance.restrict( + lockFile.path, + directory: false, + ); + } while (!acquired) { try { await lock.lock(FileLock.exclusive); @@ -593,6 +605,7 @@ Future _withRecoveryLock(File recovery, Future Function() body) async { ? code == 33 : false; if (!contention || !DateTime.now().isBefore(deadline)) rethrow; + await Future.delayed(const Duration(milliseconds: 25)); } } diff --git a/lib/src/workspace/session_persistence.dart b/lib/src/workspace/session_persistence.dart index 5c806192..35274fb4 100644 --- a/lib/src/workspace/session_persistence.dart +++ b/lib/src/workspace/session_persistence.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../core/linux_private_permissions.dart'; + import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -676,6 +678,7 @@ Future _withSessionLock( break; } on FileSystemException catch (error) { if (!_isSessionLockContention(error)) rethrow; + final remaining = budget - elapsed.elapsed; if (remaining <= Duration.zero) { throw FileSystemException( @@ -729,16 +732,16 @@ Future _writeAtomic(File target, Map json) async { await target.parent.create(recursive: true); await _setPrivatePermissions(target.parent, directory: true); final staging = await target.parent.createTemp('.busymark-state-'); - await _setPrivatePermissions(staging, directory: true); final staged = File(p.join(staging.path, p.basename(target.path))); try { + await _setPrivatePermissions(staging, directory: true); await staged.writeAsString( const JsonEncoder.withIndent(' ').convert(json), flush: true, ); await _setPrivatePermissions(staged, directory: false); await staged.rename(target.path); - await _setPrivatePermissions(target, directory: false); + // Rename publishes the already-private inode, preserving its permissions. } finally { try { if (await staged.exists()) { @@ -764,6 +767,13 @@ Future _setPrivatePermissions( if (Platform.isWindows) { return; } + if (Platform.isLinux) { + await LinuxPrivatePermissions.instance.restrict( + entity.path, + directory: directory, + ); + return; + } final result = await Process.run('chmod', [ directory ? '700' : '600', entity.path, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 2464cd11..e7a2a16f 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -3,6 +3,7 @@ import '../references/markdown_move_service.dart'; import '../nextcloud_notes/application/notes_navigation.dart'; import 'dart:async'; import 'dart:io'; + import 'dart:math' as math; import 'package:flutter/services.dart'; @@ -274,6 +275,7 @@ class WorkspaceController extends Notifier { final _restoredRecoveryOwnerIdsByBuffer = {}; late Future _recoveryStart; + final _workspaceOpenings = >{}; Future _persistenceWrites = Future.value(); int get editRevision => state.activeBuffer?.revision ?? _editRevision; @@ -694,15 +696,17 @@ class WorkspaceController extends Notifier { } _schedulePersistence(); if (restoredTabs == null) { - await _settingsController.recordOpenedWorkspace( - path: '', - kind: 'nextcloudNotes', - accountId: accountId, - title: repository.accounts - .where((a) => a.id == accountId) - .firstOrNull - ?.server - .toString(), + await _publishRecent( + () => _settingsController.recordOpenedWorkspace( + path: '', + kind: 'nextcloudNotes', + accountId: accountId, + title: repository.accounts + .where((a) => a.id == accountId) + .firstOrNull + ?.server + .toString(), + ), ); } unawaited( @@ -1269,7 +1273,30 @@ class WorkspaceController extends Notifier { await flushPersistence(); } - Future restorePreviousSession() async { + Future restorePreviousSession() => + _trackWorkspaceOpening(() => _restorePreviousSession()); + + Future _trackWorkspaceOpening(Future Function() operation) async { + final settled = Completer(); + _workspaceOpenings.add(settled.future); + try { + return await operation(); + } finally { + _workspaceOpenings.remove(settled.future); + settled.complete(); + } + } + + /// Wait before reading dirty state or persisting a shutdown snapshot. + /// Recovery ownership can already be adopted while the editor is unpublished. + /// Opening itself must not await this barrier (including its persistence). + Future waitForWorkspaceOpening() async { + while (_workspaceOpenings.isNotEmpty) { + await Future.wait(_workspaceOpenings.toList(growable: false)); + } + } + + Future _restorePreviousSession() async { if (state.workspace != null) { return state.documentBuffers.isNotEmpty; } @@ -1285,7 +1312,9 @@ class WorkspaceController extends Notifier { // an authoritative snapshot before consuming any durable journal: an // early failure must leave the persisted workspace and its obligations // untouched for the next startup. - if (!await _localHistory.refresh()) return false; + if (!await _localHistory.refresh()) { + return false; + } if (session?.pendingLocalHistoryReconciliations.any( (operation) => _localHistoryOperationOwnerIsLiveOtherProcess( operation.operationId, @@ -1553,7 +1582,8 @@ class WorkspaceController extends Notifier { } final buffers = []; final consumedRecoveries = <(String?, String)>{}; - for (final entry in sessionEntries) { + Future<({DocumentBuffer? buffer, DocumentRecoveryEntry? recovery})> + loadSessionEntry(DocumentSessionEntry entry) async { final ownedById = recoverEntries .where( (recovery) => @@ -1577,11 +1607,36 @@ class WorkspaceController extends Notifier { : ownedById.isEmpty && ownedByPath.length == 1 ? ownedByPath.single : null; + final buffer = await _restoreSessionBuffer(entry, recovered); - if (buffer != null) { - buffers.add(buffer); - if (recovered != null) { - consumedRecoveries.add((recovered.ownerId, recovered.id)); + return (buffer: buffer, recovery: recovered); + } + + // Bound file reads/decoding while preserving session order. A failed + // batch drains every admitted read before recovery can be released; + // no partial batch is published or left running after failure. + const readBatchSize = 4; + for ( + var offset = 0; + offset < sessionEntries.length; + offset += readBatchSize + ) { + if (!ref.mounted) return false; + final end = offset + readBatchSize < sessionEntries.length + ? offset + readBatchSize + : sessionEntries.length; + final loaded = await Future.wait([ + for (var index = offset; index < end; index++) + loadSessionEntry(sessionEntries[index]), + ], eagerError: false); + for (final item in loaded) { + final buffer = item.buffer; + if (buffer != null) { + buffers.add(buffer); + final recovered = item.recovery; + if (recovered != null) { + consumedRecoveries.add((recovered.ownerId, recovered.id)); + } } } } @@ -1635,6 +1690,7 @@ class WorkspaceController extends Notifier { if (!ref.mounted || !_preparedInputsCurrent(reparsed, buffers)) { return false; } + state = WorkspaceState( workspace: reparsed, preview: _safePreview(reparsed, active), @@ -1652,6 +1708,7 @@ class WorkspaceController extends Notifier { ) : null, ); + for (final entry in sessionEntries) { final recoveryOwnerId = entry.recoveryOwnerId; if (recoveryOwnerId != null && @@ -2203,15 +2260,19 @@ class WorkspaceController extends Notifier { /// Restores startup state when the previous run needs recovery, or when the /// user has explicitly enabled clean-session reopening. - Future restoreStartupSession({required bool reopenCleanSession}) async { - final recovery = await _recoveryStart; - final needsRecovery = - !recovery.cleanShutdown || recovery.entries.isNotEmpty; - if (!reopenCleanSession && !needsRecovery) { - return false; - } - return restorePreviousSession(); - } + Future restoreStartupSession({bool? reopenCleanSession}) => + _trackWorkspaceOpening(() async { + await _settingsController.waitUntilLoaded(); + final recovery = await _recoveryStart; + final needsRecovery = + !recovery.cleanShutdown || recovery.entries.isNotEmpty; + if (!(reopenCleanSession ?? + _settingsController.state.reopenPreviousWorkspaceOnStartup) && + !needsRecovery) { + return false; + } + return _restorePreviousSession(); + }); Future _persistRestoredLocalHistoryJournal( WorkspaceSessionSnapshot original, { @@ -2392,6 +2453,7 @@ class WorkspaceController extends Notifier { final pendingHistorySaveAs = _localHistory.pendingSaveAsOperations; final retainedHistoryCaptures = _localHistory.retainedDetachedCaptures; final prior = _persistenceWrites.then((_) {}, onError: (_, _) {}); + final write = prior.then( (_) => _persistSnapshot( snapshot, @@ -2599,8 +2661,10 @@ class WorkspaceController extends Notifier { } Future _settleWritesForShutdown() async { + await waitForWorkspaceOpening(); _cancelAllAutoSaves(); await _drainBufferWrites(); + await _settingsController.flushPersistence(); // A write that retained a newer dirty revision can schedule another // debounce while the queue is draining. Shutdown owns the final save or // recovery decision, so do not let that timer outlive it. @@ -3919,7 +3983,12 @@ class WorkspaceController extends Notifier { await _startMonitoring(state.workspace ?? workspace); } - Future openPath( + Future openPath(String path, {bool Function()? acceptNavigation}) => + _trackWorkspaceOpening( + () => _openPath(path, acceptNavigation: acceptNavigation), + ); + + Future _openPath( String path, { bool Function()? acceptNavigation, }) async { @@ -3953,7 +4022,7 @@ class WorkspaceController extends Notifier { ? null : await _service.loadTextWithSnapshot(active); final text = load?.text ?? ''; - final loadedWorkspace = load == null + var loadedWorkspace = load == null ? workspace : workspace.copyWith(activeFileSnapshot: load.snapshot); final buffer = load == null || active == null @@ -3963,6 +4032,21 @@ class WorkspaceController extends Notifier { load, mode: _settingsController.state.documentViewMode, ); + // The service model and this validated reread can straddle an external + // edit. Prepare the reread revision before publishing it, so the initial + // editor never synchronously reparses a mismatching model during build. + final markdown = loadedWorkspace.markdown; + if (buffer != null && + (_writersideSourceMismatch(loadedWorkspace, buffer) || + (markdown != null && + p.equals(markdown.filePath, active!) && + markdown.source != buffer.text))) { + loadedWorkspace = await _service.prepareDocument( + loadedWorkspace, + buffer, + {active!: buffer.text}, + ); + } final preview = buffer == null ? null : _safePreview(loadedWorkspace, buffer); @@ -3977,6 +4061,7 @@ class WorkspaceController extends Notifier { return; } state = WorkspaceState( + isLoading: true, workspace: loadedWorkspace, activeText: text, preview: preview, @@ -3990,18 +4075,22 @@ class WorkspaceController extends Notifier { _requestDerivedRefresh(rebuildPreview: true); } await _startMonitoring(loadedWorkspace); + if (!_isCurrentActiveDocumentOperation(operationRevision)) return; + state = state.copyWith(isLoading: false); _schedulePersistence(); _resetSaveTracking(); final recentPath = workspace.kind == WorkspaceKind.singleMarkdown ? workspace.activeFilePath ?? workspace.rootPath : workspace.rootPath; - await _settingsController.recordOpenedWorkspace( - path: recentPath, - kind: workspace.kind.name, + await _publishRecent( + () => _settingsController.recordOpenedWorkspace( + path: recentPath, + kind: workspace.kind.name, + ), ); if (buffer?.filePath != null && p.equals(p.absolute(path), p.absolute(buffer!.filePath!))) { - unawaited(_recordOpenedDocument()); + await _recordOpenedDocument(); } } on Object catch (error, stackTrace) { busyMarkDebugLogError( @@ -4101,9 +4190,11 @@ class WorkspaceController extends Notifier { await _startMonitoring(loadedWorkspace); _schedulePersistence(); _resetSaveTracking(); - await _settingsController.recordOpenedWorkspace( - path: workspace.rootPath, - kind: workspace.kind.name, + await _publishRecent( + () => _settingsController.recordOpenedWorkspace( + path: workspace.rootPath, + kind: workspace.kind.name, + ), ); return true; } on Object catch (error, stackTrace) { @@ -4238,39 +4329,70 @@ class WorkspaceController extends Notifier { return opened; } + Future recordRecentResource(RecentResource resource) => + _publishRecent(() => _settingsController.recordRecent(resource)); + + Future _publishRecent(Future Function() publish) async { + final workspaceId = state.workspace?.id; + try { + await publish(); + } on Object catch (error, stackTrace) { + busyMarkDebugLogError( + '[BusyMark] Could not update recent resources', + error, + stackTrace, + ); + // Content and its required persistence have their own completion. A + // failed ancillary Recent write must not turn a successful open into an + // opening failure, or publish into a different/disposed workspace. + if (ref.mounted && state.workspace?.id == workspaceId) { + state = state.copyWith( + message: WorkspaceMessage( + WorkspaceMessageCode.recentWorkspaceUpdateFailed, + error: error, + ), + ); + } + } + } + Future _recordOpenedDocument([DocumentBuffer? opened]) async { final workspace = state.workspace; final buffer = opened ?? state.activeBuffer; if (workspace == null || buffer == null) return; final remote = buffer.remoteNote; if (remote != null) { - await _settingsController.recordRecent( - RecentResource( - path: '', - kind: 'nextcloudNote', - document: true, - accountId: remote.accountId, - accountLabel: _notesRepository - ?.accountById(remote.accountId) - ?.server - .toString(), - localId: remote.localId, - title: buffer.remoteTitle, - lastOpenedAt: DateTime.now(), + await _publishRecent( + () => _settingsController.recordRecent( + RecentResource( + path: '', + kind: 'nextcloudNote', + document: true, + accountId: remote.accountId, + accountLabel: _notesRepository + ?.accountById(remote.accountId) + ?.server + .toString(), + localId: remote.localId, + title: buffer.remoteTitle, + lastOpenedAt: DateTime.now(), + ), ), ); } else if (buffer.filePath case final path?) { final context = resolveWorkspaceDocumentContext(workspace, buffer); - await _settingsController.recordRecent( - RecentResource( - path: p.normalize(p.absolute(path)), - kind: workspace.kind == WorkspaceKind.singleMarkdown - ? workspace.kind.name - : context.kind.name, - document: true, - ownerRoot: context.isWritersideOwned ? workspace.rootPath : null, - title: context.writersideTopic?.title, - lastOpenedAt: DateTime.now(), + await _publishRecent( + () => _settingsController.recordRecent( + RecentResource( + path: p.normalize(p.absolute(path)), + kind: workspace.kind == WorkspaceKind.singleMarkdown + ? workspace.kind.name + : context.kind.name, + document: true, + ownerRoot: context.isWritersideOwned ? workspace.rootPath : null, + title: context.writersideTopic?.title, + lastOpenedAt: DateTime.now(), + ), ), ); } @@ -7320,9 +7442,11 @@ class WorkspaceController extends Notifier { ); } if (savedWorkspace.kind == WorkspaceKind.singleMarkdown) { - await _settingsController.recordOpenedWorkspace( - path: path, - kind: savedWorkspace.kind.name, + await _publishRecent( + () => _settingsController.recordOpenedWorkspace( + path: path, + kind: savedWorkspace.kind.name, + ), ); } unawaited(_recordOpenedDocument(savedBuffer)); diff --git a/lib/src/workspace/workspace_message.dart b/lib/src/workspace/workspace_message.dart index 0ca247e8..cb290131 100644 --- a/lib/src/workspace/workspace_message.dart +++ b/lib/src/workspace/workspace_message.dart @@ -5,6 +5,7 @@ import '../core/busymark_exception.dart'; enum WorkspaceMessageCode { openFailed, + recentWorkspaceUpdateFailed, createWritersideProjectFailed, createWritersideTopicFailed, couldNotOpenFile, @@ -32,6 +33,8 @@ String localizeWorkspaceMessage( final error = _localizeWorkspaceError(context, message.error); return switch (message.code) { WorkspaceMessageCode.openFailed => l10n.workspaceErrorOpenFailed(error), + WorkspaceMessageCode.recentWorkspaceUpdateFailed => + l10n.workspaceErrorRecentWorkspaceUpdateFailed(error), WorkspaceMessageCode.createWritersideProjectFailed => l10n.workspaceErrorCreateWritersideProjectFailed(error), WorkspaceMessageCode.createWritersideTopicFailed => diff --git a/lib/src/writerside/writerside_document_parser.dart b/lib/src/writerside/writerside_document_parser.dart index 79993152..98218d5d 100644 --- a/lib/src/writerside/writerside_document_parser.dart +++ b/lib/src/writerside/writerside_document_parser.dart @@ -13,6 +13,14 @@ class WritersideDocumentParser { WritersideDocument parseXml({ required String filePath, required String source, + }) => SourceLocationMapper.withSource( + source, + () => _parseXml(filePath: filePath, source: source), + ); + + WritersideDocument _parseXml({ + required String filePath, + required String source, }) { try { return WritersideDocument( diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 070476dd..05851558 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) +include(cmake/BundleInstall.cmake) + # The name of the executable created for the application. Change this to change # the on-disk name of your application. set(BINARY_NAME "busymark") @@ -209,15 +211,8 @@ include(flutter/generated_plugins.cmake) # === Installation === # By default, "installing" just makes a relocatable bundle in the build # directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) +busymark_clean_default_bundle() set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") diff --git a/linux/cmake/BundleInstall.cmake b/linux/cmake/BundleInstall.cmake new file mode 100644 index 00000000..114b4eb6 --- /dev/null +++ b/linux/cmake/BundleInstall.cmake @@ -0,0 +1,19 @@ +# Set the default before dependency discovery: a failed first configure must +# not leave /usr/local cached as the prefix for a later successful build. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH + "BusyMark installation directory" FORCE) +endif() + +function(busymark_clean_default_bundle) + # Clean only our ordinary build bundle. Custom prefixes and DESTDIR installs + # must preserve both their existing contents and the unstaged build bundle. + # Evaluate the prefix and DESTDIR at installation time. + install(CODE " + if(CMAKE_INSTALL_PREFIX STREQUAL \"${BUILD_BUNDLE_DIR}\" + AND \"\$ENV{DESTDIR}\" STREQUAL \"\") + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + endif() + " COMPONENT Runtime) +endfunction() diff --git a/linux/runner/video_player_host.cc b/linux/runner/video_player_host.cc index 42e4d80b..a12f26d1 100644 --- a/linux/runner/video_player_host.cc +++ b/linux/runner/video_player_host.cc @@ -45,7 +45,9 @@ void video_player_free(gpointer data) { return; } if (player->web_view != nullptr && GTK_IS_WIDGET(player->web_view)) { + g_signal_handlers_disconnect_by_data(player->web_view, player); gtk_widget_destroy(player->web_view); + g_clear_object(&player->web_view); } g_free(player->allowed_local_uri); g_free(player); @@ -354,6 +356,79 @@ gboolean context_menu_cb(WebKitWebView*, return TRUE; } +gboolean local_file_chooser_cb(WebKitWebView* view, + WebKitFileChooserRequest* request, + gpointer user_data) { + auto* player = static_cast(user_data); + // Only BusyMark's fixed local-player shell may obtain the one file that + // the user already authorized with Play. Never open a chooser or grant a + // hosted document access to this path. + if (player->kind != PlayerKind::kLocalFile || + g_strcmp0(webkit_web_view_get_uri(view), "file:///") != 0 || + webkit_file_chooser_request_get_select_multiple(request)) { + webkit_file_chooser_request_cancel(request); + return TRUE; + } + g_autofree gchar* path = + g_filename_from_uri(player->allowed_local_uri, nullptr, nullptr); + if (path == nullptr || !g_file_test(path, G_FILE_TEST_IS_REGULAR)) { + webkit_file_chooser_request_cancel(request); + return TRUE; + } + const gchar* files[] = {path, nullptr}; + webkit_file_chooser_request_select_files(request, files); + return TRUE; +} + +void local_source_script_finished_cb(GObject* object, + GAsyncResult* result, + gpointer) { + g_autoptr(GError) error = nullptr; + g_autoptr(JSCValue) value = webkit_web_view_evaluate_javascript_finish( + WEBKIT_WEB_VIEW(object), result, &error); + if (error != nullptr && !g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + g_warning("The local video source could not be initialized: %s", error->message); + } +} + +void local_source_load_changed_cb(WebKitWebView* view, + WebKitLoadEvent event, + gpointer) { + if (event != WEBKIT_LOAD_FINISHED || + g_strcmp0(webkit_web_view_get_uri(view), "file:///") != 0) { + return; + } + // A direct file: URI makes GStreamer's filesrc open the path inside the + // WebProcess sandbox, where it is deliberately unavailable. WebKit's File + // API instead brokers the selected file and streams an existing allowed + // blob: URL. This neither copies the video nor widens filesystem access. + // Native evaluation carries the already-authorized Play gesture. + constexpr char script[] = R"JS((() => { + if (document.getElementById('busymark-local-media')) return; + const video = document.querySelector('video'); + const input = document.createElement('input'); + input.id = 'busymark-local-media'; + input.type = 'file'; + input.accept = 'video/*'; + input.hidden = true; + const playFile = file => { + const url = URL.createObjectURL(file); + video.src = url; + window.addEventListener('pagehide', () => URL.revokeObjectURL(url), {once: true}); + void video.play(); + }; + // Cancellation (for example, a file removed during opening) must produce + // the normal media error rather than leave a source-less player pending. + input.onchange = () => playFile(input.files[0] || new Blob()); + input.oncancel = () => playFile(new Blob()); + document.body.append(input); + input.click(); + })())JS"; + webkit_web_view_evaluate_javascript(view, script, -1, nullptr, nullptr, + nullptr, local_source_script_finished_cb, + nullptr); +} + WebKitSettings* create_player_settings() { WebKitSettings* settings = webkit_settings_new(); webkit_settings_set_enable_javascript(settings, TRUE); @@ -379,7 +454,6 @@ WebKitSettings* create_player_settings() { gchar* player_html(PlayerKind kind, const gchar* value, - const gchar* local_uri, gboolean mini_player, const gchar* play_label, const gchar* pause_label, @@ -401,14 +475,13 @@ gchar* player_html(PlayerKind kind, constexpr char kTail[] = ""; g_autofree gchar* head = g_strdup_printf(kHeadTemplate, border_effect); if (kind == PlayerKind::kLocalFile) { - g_autofree gchar* escaped_uri = g_markup_escape_text(local_uri, -1); if (mini_player) { g_autofree gchar* escaped_play_label = g_markup_escape_text(play_label, -1); g_autofree gchar* escaped_pause_label = g_markup_escape_text(pause_label, -1); return g_strdup_printf( - "%s