From a6d2af7a57cc9adffc2f44e1fddca30944dc1683 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:42:30 +0700 Subject: [PATCH 1/6] fix(linux): keep dropped tabs in place, read CSV previews off the GTK thread and drop Relm4 --- .../fix-issue/references/research-linux.md | 2 +- Cargo.lock | 42 - Cargo.toml | 1 - linux/ARCHITECTURE.md | 6 +- linux/README.md | 2 +- linux/build-aux/debian/copyright | 13 +- linux/crates/app/Cargo.toml | 1 - linux/crates/app/clippy.toml | 18 - linux/crates/app/src/crash_report.rs | 9 +- linux/crates/app/src/i18n.rs | 61 +- linux/crates/app/src/runtime/deliver_input.rs | 40 +- linux/crates/app/src/runtime/input_queue.rs | 33 +- linux/crates/app/src/runtime/mod.rs | 2 +- .../app/src/services/grid/display_text.rs | 8 +- .../app/src/services/grid/edit_policy.rs | 2 +- .../app/src/ui/assistant/assistant_pane.rs | 3 +- .../src/ui/browse_tab/count_failure_text.rs | 2 +- .../app/src/ui/browse_tab/grid_handlers.rs | 3 +- .../app/src/ui/browse_tab/paging_handlers.rs | 2 +- .../src/ui/browse_tab/pending_changes_bar.rs | 2 +- .../app/src/ui/clipboard/clipboard_copy.rs | 11 +- linux/crates/app/src/ui/data_grid/columns.rs | 4 +- .../app/src/ui/data_grid/edit/deletion.rs | 2 +- .../crates/app/src/ui/data_grid/edit/paste.rs | 8 +- .../app/src/ui/data_grid/row_detail/imp.rs | 1 + .../app/src/ui/data_grid/row_json_dialog.rs | 4 +- .../app/src/ui/data_grid/row_number_column.rs | 1 + .../app/src/ui/delete_connection_dialog.rs | 2 +- linux/crates/app/src/ui/diagram/describe.rs | 2 +- linux/crates/app/src/ui/diagram/tab.rs | 4 +- linux/crates/app/src/ui/discard_alert.rs | 2 +- .../app/src/ui/editor/editor_actions.rs | 2 +- linux/crates/app/src/ui/editor/mod.rs | 12 +- .../app/src/ui/editor/result_widgets.rs | 2 +- .../crates/app/src/ui/editor/results_pane.rs | 2 +- linux/crates/app/src/ui/editor/run_log.rs | 9 +- linux/crates/app/src/ui/error_text/ddl.rs | 2 +- linux/crates/app/src/ui/error_text/files.rs | 2 +- .../app/src/ui/error_text/saved_connection.rs | 2 +- linux/crates/app/src/ui/error_text/syntax.rs | 2 +- linux/crates/app/src/ui/export/dialog.rs | 7 +- .../app/src/ui/export/export_failure_text.rs | 10 +- .../app/src/ui/export/export_ledger/mod.rs | 3 +- .../crates/app/src/ui/export/export_scope.rs | 11 +- linux/crates/app/src/ui/format/mod.rs | 6 - .../ui/history_dialog/history_formatting.rs | 6 +- linux/crates/app/src/ui/history_dialog/mod.rs | 13 +- linux/crates/app/src/ui/import_dialog.rs | 667 -------------- linux/crates/app/src/ui/import_dialog/imp.rs | 82 ++ linux/crates/app/src/ui/import_dialog/mod.rs | 349 ++++++++ .../app/src/ui/import_dialog/preview_text.rs | 79 ++ .../crates/app/src/ui/import_dialog/tests.rs | 433 +++++++++ .../crates/app/src/ui/import_from_app/mod.rs | 4 +- .../app/src/ui/import_from_app/wording.rs | 8 +- linux/crates/app/src/ui/import_preview.rs | 2 +- .../crates/app/src/ui/key_tab/key_summary.rs | 23 +- linux/crates/app/src/ui/mod.rs | 1 + .../preferences_dialog/data_browser_group.rs | 2 +- .../ui/preferences_dialog/history_group.rs | 2 +- linux/crates/app/src/ui/result_pane/mod.rs | 4 +- .../app/src/ui/result_pane/result_summary.rs | 10 +- .../crates/app/src/ui/saved_queries_dialog.rs | 847 ------------------ .../app/src/ui/saved_queries_dialog/imp.rs | 73 ++ .../app/src/ui/saved_queries_dialog/mod.rs | 586 ++++++++++++ .../saved_query_listing.rs | 191 ++++ .../saved_query_object.rs | 48 + .../saved_query_object_imp.rs | 19 + .../saved_queries_dialog/saved_query_row.rs | 87 ++ .../app/src/ui/saved_queries_dialog/tests.rs | 490 ++++++++++ .../app/src/ui/session_window/connections.rs | 2 +- .../src/ui/session_window/import_from_app.rs | 12 +- linux/crates/app/src/ui/session_window/mod.rs | 27 +- .../ui/session_window/session_window_input.rs | 9 +- .../src/ui/session_window/tests/sessions.rs | 109 ++- linux/crates/app/src/ui/source_dialog.rs | 22 +- linux/crates/app/src/ui/structure_tab/imp.rs | 1 + .../app/src/ui/structure_tab/review_dialog.rs | 9 +- .../app/src/ui/structure_tab/save_handlers.rs | 2 +- .../app/src/ui/tags/manage_tags_dialog.rs | 9 +- linux/crates/app/src/ui/text_view_teardown.rs | 49 + linux/crates/app/src/ui/workspace/import.rs | 40 +- .../app/src/ui/workspace/import_ledger.rs | 3 +- linux/crates/app/src/ui/workspace/mod.rs | 10 +- .../app/src/ui/workspace/status_pages.rs | 31 +- .../src/ui/workspace/transaction_banner.rs | 2 +- .../app/src/ui/workspace/workspace_shell.rs | 58 +- .../src/ui/workspace/workspace_shell_imp.rs | 13 +- .../src/ui/workspace/workspace_tab_view.rs | 6 + .../app/src/ui/workspace/workspace_tabs.rs | 9 +- linux/crates/app/src/ui/write_confirmation.rs | 6 +- .../app/src/workspace/dialog_response.rs | 3 +- .../platform/src/secrets/keyring_error.rs | 14 + linux/data/licenses/THIRD-PARTY-NOTICE.md | 3 - linux/data/licenses/third-party.json | 8 +- linux/data/resources/resources.gresource.xml | 3 + linux/data/resources/ui/import-csv-dialog.ui | 88 ++ .../data/resources/ui/saved-queries-dialog.ui | 98 ++ linux/data/resources/ui/saved-query-row.ui | 51 ++ linux/docs/README.md | 1 + .../docs/decisions/0003-relm4-architecture.md | 2 +- .../decisions/0005-supply-chain-policy.md | 2 +- .../0033-mysql-ca-bundle-removal-condition.md | 17 + linux/docs/decisions/README.md | 5 +- linux/docs/error-handling.md | 4 +- linux/docs/state-management.md | 27 +- linux/docs/storage.md | 4 +- linux/docs/toolkit-workarounds.md | 43 + linux/docs/troubleshooting.md | 8 +- linux/po/POTFILES.in | 17 +- 109 files changed, 3274 insertions(+), 1944 deletions(-) delete mode 100644 linux/crates/app/src/ui/import_dialog.rs create mode 100644 linux/crates/app/src/ui/import_dialog/imp.rs create mode 100644 linux/crates/app/src/ui/import_dialog/mod.rs create mode 100644 linux/crates/app/src/ui/import_dialog/preview_text.rs create mode 100644 linux/crates/app/src/ui/import_dialog/tests.rs delete mode 100644 linux/crates/app/src/ui/saved_queries_dialog.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/imp.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/mod.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/saved_query_listing.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/saved_query_object.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/saved_query_object_imp.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/saved_query_row.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog/tests.rs create mode 100644 linux/crates/app/src/ui/text_view_teardown.rs create mode 100644 linux/data/resources/ui/import-csv-dialog.ui create mode 100644 linux/data/resources/ui/saved-queries-dialog.ui create mode 100644 linux/data/resources/ui/saved-query-row.ui create mode 100644 linux/docs/decisions/0033-mysql-ca-bundle-removal-condition.md create mode 100644 linux/docs/toolkit-workarounds.md diff --git a/.claude/skills/fix-issue/references/research-linux.md b/.claude/skills/fix-issue/references/research-linux.md index 2b9ff80e2a..1f503499c5 100644 --- a/.claude/skills/fix-issue/references/research-linux.md +++ b/.claude/skills/fix-issue/references/research-linux.md @@ -8,7 +8,7 @@ Where to find the correct behavior for the Linux app, and what counts as evidenc - **The Rust bindings** gate each symbol behind a feature such as `v4_22` or `v1_9`. The workspace enables the GNOME 50 set in the top `Cargo.toml`, so read the gate in the crate's source when the question is whether a binding exists: it is under `linux/_build/cargo-home/registry/src/` once the tree has been built. - **The web documentation is newer than the floor.** docs.gtk.org and the GtkSourceView site document later releases than GNOME 50, and even the installed GIR files carry the odd newer symbol. A web page gives the intent; the GIR version and the feature gate settle whether TablePro can use it. - **GNOME Human Interface Guidelines**: `https://developer.gnome.org/hig/`. For a database client the pages that matter most are header bars, sidebars, utility panes, boxed lists, list and column views, selection mode, tabs, search, menus, dialogs, toasts, banners, placeholders, keyboard and accessibility. Quote the rule; "the HIG says so" without a quote is not evidence. -- **API references**: GTK at `https://docs.gtk.org/gtk4/`, libadwaita for the floor at `https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.9/`, GtkSourceView at `https://gnome.pages.gitlab.gnome.org/gtksourceview/gtksourceview5/`, gtk4-rs at `https://gtk-rs.org/gtk4-rs/stable/latest/docs/gtk4/`, libadwaita-rs at `https://world.pages.gitlab.gnome.org/Rust/libadwaita-rs/stable/latest/docs/libadwaita/`. The Relm4 book trails the version in `Cargo.lock`, so read the locked crate's source for Relm4. +- **API references**: GTK at `https://docs.gtk.org/gtk4/`, libadwaita for the floor at `https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1.9/`, GtkSourceView at `https://gnome.pages.gitlab.gnome.org/gtksourceview/gtksourceview5/`, gtk4-rs at `https://gtk-rs.org/gtk4-rs/stable/latest/docs/gtk4/`, libadwaita-rs at `https://world.pages.gitlab.gnome.org/Rust/libadwaita-rs/stable/latest/docs/libadwaita/`. - **freedesktop specifications**, for anything that talks to the desktop: Secret Service (`https://specifications.freedesktop.org/secret-service/latest/`), XDG Desktop Portal (`https://flatpak.github.io/xdg-desktop-portal/docs/api-reference.html`), Desktop Entry (`https://specifications.freedesktop.org/desktop-entry/latest/`), AppStream (validate locally with `appstreamcli validate --explain`) and the Flathub MetaInfo guidelines (`https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines`). - **TablePro's own decisions** in `linux/docs/decisions/` and `linux/ARCHITECTURE.md`: a fix must not contradict a recorded decision without saying so. diff --git a/Cargo.lock b/Cargo.lock index 242c45e66d..c348011ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2207,9 +2207,6 @@ name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -dependencies = [ - "getrandom 0.4.3", -] [[package]] name = "ferroid" @@ -2298,7 +2295,6 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "fastrand", "futures-core", "futures-sink", "spin", @@ -2358,15 +2354,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fragile" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" -dependencies = [ - "futures-core", -] - [[package]] name = "fs-err" version = "2.11.0" @@ -5520,34 +5507,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "relm4" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198" -dependencies = [ - "flume", - "fragile", - "futures", - "gtk4", - "libadwaita", - "once_cell", - "relm4-macros", - "tokio", - "tracing", -] - -[[package]] -name = "relm4-macros" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "resolv-conf" version = "0.7.6" @@ -6995,7 +6954,6 @@ dependencies = [ "num-bigint 0.4.8", "proptest", "pulldown-cmark", - "relm4", "rustix", "secrecy", "semver", diff --git a/Cargo.toml b/Cargo.toml index 58484c4b03..129fe9f1ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,7 +185,6 @@ tempfile = "3" testcontainers = { version = "0.27", default-features = false, features = ["aws-lc-rs"] } testcontainers-modules = { version = "0.15", default-features = false, features = ["aws-lc-rs"] } oo7 = { version = "0.6", default-features = false, features = ["tokio", "native_crypto"] } -relm4 = { version = "0.11", default-features = false, features = ["macros", "libadwaita"] } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4991546617..0fb243fdda 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -57,8 +57,6 @@ The GTK main loop owns every widget and runs on the main thread. Everything else Views cross between the two through `crate::runtime` (`spawn_and_deliver`, `spawn_held`, the input queues and `WatchForwarder`), and `crates/app/clippy.toml` bans the ways around it; [state-management.md](docs/state-management.md#rules-for-every-view) has the rules every view follows. -One second runtime remains. Relm4 runs a component's commands (`sender.command`, `oneshot_command`) on a runtime of its own, built the first time one runs, with one worker since nothing sets `RELM_THREADS`. The saved-queries dialog still loads through commands, so that runtime starts when it opens. - ## The application `TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the liveness policy, the `SessionRegistry`, the settings, the storage, the saved connection list and its editor, the SSH services, the credential sources, the file revealer, `Tasks`, the process hold, the open-sessions record and the query history. @@ -93,9 +91,7 @@ The `WorkspaceShell` (`crates/app/src/ui/workspace/workspace_shell.rs`) is what ## UI architecture -The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history dialog and its rows, the export operations button, the import from another app dialog, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. - -A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the workspace through `connect_receiver`, which puts it on the workspace's input queue. The two patterns coexist until a rewrite turns each component into a subclass. +The application, its windows, the workspace and every view are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history and saved queries dialogs and their rows, the CSV import dialog, the export operations button, the import from another app dialog, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. diff --git a/linux/README.md b/linux/README.md index 0f38337814..8d73cfb5d6 100644 --- a/linux/README.md +++ b/linux/README.md @@ -60,7 +60,7 @@ TablePro makes no network requests of its own: no telemetry, no update check and |---|---| | Language | Rust: 1.98 in `rust-toolchain.toml`, 1.97 the `rust-version` floor | | GUI toolkit | GTK 4, libadwaita, GtkSourceView and GLib at the GNOME 50 floor ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | -| App architecture | gtk-rs subclasses with composite templates, one window per connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)); the saved-queries and CSV import dialogs are still [Relm4](https://relm4.org) 0.11 components | +| App architecture | gtk-rs subclasses with composite templates ([ADR 0012](docs/decisions/0012-window-per-session.md)), several connection sessions to a window ([ADR 0028](docs/decisions/0028-sessions-share-a-window.md)) | | Async | the app's tokio runtime (`AppRuntime`) for background work, results delivered to the GLib main loop | | Database drivers | tokio-postgres with deadpool-postgres (PostgreSQL), sqlx 0.9 (MySQL and MariaDB, SQLite), tiberius 0.13 (SQL Server), the official `clickhouse` crate, scylla 1.9 (Cassandra and ScyllaDB), redis 1.7 (Redis), mongodb 3.9 (MongoDB) and the `duckdb` crate with DuckDB compiled in, all from crates.io ([ADR 0016](docs/decisions/0016-driver-contract-v2.md)) | | SSH | the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)) | diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index b8e53feefe..f7388f04f1 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -1076,8 +1076,6 @@ Files: vendor/async-channel-2.5.0/* vendor/quote-1.0.47/* vendor/ref-cast-1.0.27/* vendor/ref-cast-impl-1.0.27/* - vendor/relm4-0.11.0/* - vendor/relm4-macros-0.11.0/* vendor/rustversion-1.0.23/* vendor/semver-1.0.28/* vendor/serde-1.0.229/* @@ -1103,10 +1101,10 @@ Copyright: the authors of async-channel, async-lock, async-task, async-trait, lexical-parse-float, lexical-parse-integer, lexical-util, lexical-write-float, lexical-write-integer, once_cell, ordered-stream, parking, paste, pastey, pin-project-lite, piper, portable-atomic, - proc-macro-crate, proc-macro2, quote, ref-cast, ref-cast-impl, relm4, - relm4-macros, rustversion, semver, serde, serde_bytes, serde_core, - serde_derive, serde_derive_internals, serde_json, serde_repr, sqlformat, - syn, thiserror, thiserror-impl, typed-builder, typed-builder-macro, + proc-macro-crate, proc-macro2, quote, ref-cast, ref-cast-impl, + rustversion, semver, serde, serde_bytes, serde_core, serde_derive, + serde_derive_internals, serde_json, serde_repr, sqlformat, syn, + thiserror, thiserror-impl, typed-builder, typed-builder-macro, typed-path License: Apache-2.0 or Expat Permission is hereby granted, free of charge, to any @@ -3724,8 +3722,7 @@ License: Unicode-3.0 ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. Files: vendor/flagset-0.4.7/* - vendor/fragile-2.1.0/* -Copyright: the authors of flagset, fragile +Copyright: the authors of flagset License: Apache-2.0 On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 8316f68202..845832fd32 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -90,7 +90,6 @@ libadwaita.workspace = true sourceview5.workspace = true glib.workspace = true glycin.workspace = true -relm4.workspace = true # CLOCK_BOOTTIME, which std does not expose, tells a suspend apart. rustix = { workspace = true, features = ["time"] } secrecy.workspace = true diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index 4d4d39d206..9cc08d9e32 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -37,27 +37,9 @@ disallowed-methods = [ { path = "tokio::task::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, { path = "tokio::runtime::Handle::spawn", reason = "use Tasks::spawn_task" }, { path = "tokio::runtime::Handle::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, - { path = "relm4::spawn", reason = "use Tasks::spawn_task" }, - { path = "relm4::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, - { path = "relm4::spawn_local", reason = "use glib::spawn_future_local" }, # Blocking the GTK main loop freezes every window, and quitting from # under it skips the shutdown that flushes pending writes. { path = "gtk4::glib::MainContext::block_on", reason = "await in glib::spawn_future_local instead of blocking the main loop" }, { path = "gio::prelude::ApplicationExt::quit", reason = "use TableProApplication::request_quit, which closes each window through its prompts" }, - - # A window can close while work it started is still on its way back. - # These panic on a component that has shut down; deliver drops the - # message instead. - { path = "relm4::ComponentSender::input", reason = "use crate::runtime::DeliverInput::deliver" }, - { path = "relm4::FactorySender::input", reason = "use crate::runtime::DeliverInput::deliver" }, - - # TableProApplication owns the windows and the app actions. Relm4's - # own application builds a second one that knows neither. - { path = "relm4::RelmApp::run", reason = "open windows with TableProApplication::open_window" }, - { path = "relm4::main_application", reason = "take the application from the window or its WindowHost" }, - { path = "relm4::main_adw_application", reason = "take the application from the window or its WindowHost" }, - { path = "relm4::actions::RelmActionGroup::register_for_main_application", reason = "add actions with add_action_entries on the window or the application" }, - { path = "relm4::ComponentBuilder::transient_for", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, - { path = "relm4::ComponentBuilder::transient_for_native", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, ] diff --git a/linux/crates/app/src/crash_report.rs b/linux/crates/app/src/crash_report.rs index 733a930480..50b674a9e3 100644 --- a/linux/crates/app/src/crash_report.rs +++ b/linux/crates/app/src/crash_report.rs @@ -24,10 +24,11 @@ const ANNOUNCED: &str = "announced"; /// oldest go once there are more than this. const KEPT_REPORTS: usize = 20; -/// Keep the default hook, which writes the panic to stderr and so to -/// the journal, and also write it with a backtrace to a file of its own -/// in `dir`. A GTK callback that panics panics a second time when it -/// cannot unwind, and that goes in the same file. +/// Keep the default hook, which writes the panic to stderr, and also +/// write it with a backtrace to a file of its own in `dir`. A GTK +/// callback that panics panics a second time when it cannot unwind, and +/// that goes in the same file. Signals such as a segmentation fault are +/// left to the system's core dump handler. pub fn install(dir: PathBuf) { let report = OnceLock::new(); let default_hook = std::panic::take_hook(); diff --git a/linux/crates/app/src/i18n.rs b/linux/crates/app/src/i18n.rs index 5d0a03ed68..103abdbd99 100644 --- a/linux/crates/app/src/i18n.rs +++ b/linux/crates/app/src/i18n.rs @@ -3,7 +3,7 @@ use thiserror::Error; use crate::config; -pub use gettextrs::{gettext, ngettext, npgettext, pgettext}; +pub use gettextrs::{gettext, npgettext, pgettext}; #[derive(Debug, Error)] pub enum I18nError { @@ -52,10 +52,46 @@ pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { substitute(&gettext(msgid), args) } -pub fn ngettext_f(msgid: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String { +pub trait Quantity: Copy { + fn quantity(self) -> u64; +} + +impl Quantity for u64 { + fn quantity(self) -> u64 { + self + } +} + +impl Quantity for u32 { + fn quantity(self) -> u64 { + u64::from(self) + } +} + +impl Quantity for usize { + fn quantity(self) -> u64 { + self as u64 + } +} + +pub fn ngettext(msgid: &str, plural: &str, n: impl Quantity) -> String { + let n = gettext_quantity(n.quantity(), glib::ffi::gulong::BITS) as glib::ffi::gulong; + glib::dngettext(None, msgid, plural, n).into() +} + +pub fn ngettext_f(msgid: &str, plural: &str, n: impl Quantity, args: &[(&str, &str)]) -> String { substitute(&ngettext(msgid, plural, n), args) } +// gettext takes an unsigned long. Past it, the gettext manual (Plural forms) keeps the last six digits, +// which decide every language's form. +fn gettext_quantity(n: u64, ulong_bits: u32) -> u64 { + match n.checked_shr(ulong_bits).unwrap_or(0) { + 0 => n, + _ => n % 1_000_000 + 1_000_000, + } +} + pub fn pgettext_f(context: &str, msgid: &str, args: &[(&str, &str)]) -> String { substitute(&pgettext(context, msgid), args) } @@ -130,8 +166,25 @@ mod tests { #[test] fn ngettext_f_picks_singular_or_plural_in_the_c_locale() { - assert_eq!(ngettext_f("{n} row", "{n} rows", 1, &[("n", "1")]), "1 row"); - assert_eq!(ngettext_f("{n} row", "{n} rows", 4, &[("n", "4")]), "4 rows"); + assert_eq!(ngettext_f("{n} row", "{n} rows", 1_u64, &[("n", "1")]), "1 row"); + assert_eq!(ngettext_f("{n} row", "{n} rows", 4_usize, &[("n", "4")]), "4 rows"); + assert_eq!(ngettext_f("{n} row", "{n} rows", 0_u32, &[("n", "0")]), "0 rows"); + } + + #[test] + fn a_count_past_u32_reaches_gettext_whole() { + let count = u64::from(u32::MAX) + 2; + + assert_eq!(gettext_quantity(count, 64), count); + } + + #[test] + fn a_count_past_unsigned_long_keeps_its_last_six_digits() { + let ulong_max = u64::from(u32::MAX); + + assert_eq!(gettext_quantity(ulong_max + 2, 32), 1_967_297); + assert_eq!(gettext_quantity(5_000_000_001, 32), 1_000_001); + assert_eq!(gettext_quantity(ulong_max, 32), ulong_max); } #[test] diff --git a/linux/crates/app/src/runtime/deliver_input.rs b/linux/crates/app/src/runtime/deliver_input.rs index 9f18633d3c..7ed8054342 100644 --- a/linux/crates/app/src/runtime/deliver_input.rs +++ b/linux/crates/app/src/runtime/deliver_input.rs @@ -6,12 +6,8 @@ pub enum Delivery { ReceiverGone, } -/// Send a component its input without panicking once it has shut down. -/// -/// relm4's `ComponentSender::input` and `FactorySender::input` call -/// `expect` on a closed channel, and a window can close while a read or -/// a save it started is still on its way back, which would take every -/// other window down with it. The message is dropped instead. +/// Send a view its input whether or not it has shut down: a window can +/// close while a read or a save it started is still on its way back. pub trait DeliverInput { fn try_deliver(&self, message: M) -> Delivery; @@ -22,27 +18,6 @@ pub trait DeliverInput { } } -impl DeliverInput for relm4::Sender { - fn try_deliver(&self, message: M) -> Delivery { - match self.send(message) { - Ok(()) => Delivery::Delivered, - Err(_) => Delivery::ReceiverGone, - } - } -} - -impl DeliverInput for relm4::ComponentSender { - fn try_deliver(&self, message: C::Input) -> Delivery { - self.input_sender().try_deliver(message) - } -} - -impl DeliverInput for relm4::FactorySender { - fn try_deliver(&self, message: C::Input) -> Delivery { - self.input_sender().try_deliver(message) - } -} - impl DeliverInput for tokio::sync::mpsc::UnboundedSender { fn try_deliver(&self, message: M) -> Delivery { match self.send(message) { @@ -56,18 +31,9 @@ impl DeliverInput for tokio::sync::mpsc::UnboundedSender { mod tests { use super::*; - #[test] - fn a_message_to_a_live_receiver_arrives() { - let (sender, receiver) = relm4::channel::(); - - sender.deliver(7); - - assert_eq!(receiver.recv_sync(), Some(7)); - } - #[test] fn a_message_after_shutdown_is_dropped_without_panicking() { - let (sender, receiver) = relm4::channel::(); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); drop(receiver); sender.deliver(7); diff --git a/linux/crates/app/src/runtime/input_queue.rs b/linux/crates/app/src/runtime/input_queue.rs index 45c4be4477..55c233367f 100644 --- a/linux/crates/app/src/runtime/input_queue.rs +++ b/linux/crates/app/src/runtime/input_queue.rs @@ -2,8 +2,6 @@ use gtk4::glib; use gtk4::glib::prelude::*; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use super::{DeliverInput, Delivery}; - /// What a widget's actions, signal handlers and background work send it. /// It is `Send`, so a task on the runtime can answer through it. pub type InputSender = UnboundedSender; @@ -74,27 +72,13 @@ impl Drop for Leftovers { } } -/// A relm4 sender whose messages land in `input`, for a relm4 child that -/// can only forward its output to one. Abort the handle with the widget -/// that owns `input`. -pub fn relm4_bridge(input: InputSender) -> (relm4::Sender, glib::JoinHandle<()>) { - let (sender, receiver) = relm4::channel::(); - let forwarding = glib::spawn_future_local(async move { - while let Some(message) = receiver.recv().await { - if input.try_deliver(message) == Delivery::ReceiverGone { - return; - } - } - }); - (sender, forwarding) -} - #[cfg(test)] mod tests { use std::cell::RefCell; use std::rc::Rc; use super::*; + use crate::runtime::{DeliverInput, Delivery}; use crate::test_support::drain_main_context; #[gtk4::test] @@ -159,19 +143,4 @@ mod tests { "a message sent after the pump stopped was taken" ); } - - #[gtk4::test] - fn a_bridge_forwards_until_its_target_is_gone() { - let (input, mut receiver) = input_queue::(); - let (bridge, _forwarding) = relm4_bridge(input); - - bridge.deliver(3); - drain_main_context(); - assert_eq!(receiver.try_recv().ok(), Some(3)); - - drop(receiver); - bridge.deliver(4); - drain_main_context(); - assert_eq!(bridge.try_deliver(5), Delivery::ReceiverGone); - } } diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index 3c304fb8c6..f7d7397f79 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -27,7 +27,7 @@ pub use detect_resume::{RESUME_THRESHOLD, detect_resume}; pub use failed_requests::FailedRequests; pub use gio_process_hold::GioProcessHold; pub use gio_reachability::GioReachability; -pub use input_queue::{InputSender, input_queue, pump_inputs, pump_inputs_with_leftovers, relm4_bridge}; +pub use input_queue::{InputSender, input_queue, pump_inputs, pump_inputs_with_leftovers}; pub use network_watch::NetworkWatch; pub use reachability_fanout::ReachabilityFanout; pub use reachability_probe::ReachabilityProbe; diff --git a/linux/crates/app/src/services/grid/display_text.rs b/linux/crates/app/src/services/grid/display_text.rs index a6c9b17205..a6a3f1d70c 100644 --- a/linux/crates/app/src/services/grid/display_text.rs +++ b/linux/crates/app/src/services/grid/display_text.rs @@ -63,7 +63,7 @@ pub fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut *scratch = crate::i18n::ngettext_f( "<{count} byte>", "<{count} bytes>", - plural_count(count), + count, &[("count", &count.to_string())], ); DisplayText::placeholder(scratch) @@ -173,7 +173,7 @@ pub fn more_bytes(count: usize) -> String { crate::i18n::ngettext_f( "… ({count} more byte)", "… ({count} more bytes)", - plural_count(count), + count, &[("count", &count.to_string())], ) } @@ -200,10 +200,6 @@ fn push_without_nul(scratch: &mut String, text: &str) { } } -fn plural_count(count: usize) -> u32 { - u32::try_from(count).unwrap_or(u32::MAX) -} - #[cfg(test)] mod tests { use tablepro_core::Value; diff --git a/linux/crates/app/src/services/grid/edit_policy.rs b/linux/crates/app/src/services/grid/edit_policy.rs index 2ed340b109..b148b55073 100644 --- a/linux/crates/app/src/services/grid/edit_policy.rs +++ b/linux/crates/app/src/services/grid/edit_policy.rs @@ -51,7 +51,7 @@ pub fn edit_policy(read_only: bool, has_primary_key: bool, has_columns: bool) -> /// The count shown once several rows are selected. It names the Delete /// key only where Delete would act (inventory D12). -pub fn selection_hint(selected: u32, delete: Result<(), EditRefusal>) -> Option { +pub fn selection_hint(selected: usize, delete: Result<(), EditRefusal>) -> Option { if selected <= 1 { return None; } diff --git a/linux/crates/app/src/ui/assistant/assistant_pane.rs b/linux/crates/app/src/ui/assistant/assistant_pane.rs index 863b96b289..e28845688d 100644 --- a/linux/crates/app/src/ui/assistant/assistant_pane.rs +++ b/linux/crates/app/src/ui/assistant/assistant_pane.rs @@ -111,6 +111,7 @@ mod imp { } fn dispose(&self) { + crate::ui::text_view_teardown::release(&self.composer.get()); self.obj().stop_work(); self.settings_watch.take(); self.hooks.take(); @@ -614,7 +615,7 @@ impl AssistantPane { crate::i18n::ngettext_f( "{n} token", "{n} tokens", - crate::ui::format::plural_count(tokens), + tokens, &[("n", &crate::ui::format::format_thousands(tokens))], ) }); diff --git a/linux/crates/app/src/ui/browse_tab/count_failure_text.rs b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs index 11439e3a19..2a51cb219a 100644 --- a/linux/crates/app/src/ui/browse_tab/count_failure_text.rs +++ b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs @@ -19,7 +19,7 @@ pub(crate) fn count_failure_text(error: &OperationError, count_deadline: Option< crate::i18n::ngettext_f( "Counting took longer than {seconds} second and was stopped.", "Counting took longer than {seconds} seconds and was stopped.", - u32::try_from(seconds).unwrap_or(u32::MAX), + seconds, &[("seconds", &seconds.to_string())], ) } diff --git a/linux/crates/app/src/ui/browse_tab/grid_handlers.rs b/linux/crates/app/src/ui/browse_tab/grid_handlers.rs index 1709abcab2..d48daafada 100644 --- a/linux/crates/app/src/ui/browse_tab/grid_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/grid_handlers.rs @@ -67,8 +67,7 @@ impl BrowseModel { None => Vec::new(), }; let policy = self.edit_policy(); - let selected = u32::try_from(rows.len()).unwrap_or(u32::MAX); - let hint = selection_hint(selected, policy.delete); + let hint = selection_hint(rows.len(), policy.delete); let label = &self.widgets.selection_label; label.set_label(hint.as_deref().unwrap_or_default()); label.set_visible(hint.is_some()); diff --git a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs index cca30c6acf..83061d8c9c 100644 --- a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -127,7 +127,7 @@ impl BrowseModel { .set_tooltip_text(Some(&crate::i18n::ngettext_f( "{n} Filter Rule Active", "{n} Filter Rules Active", - u32::try_from(n).unwrap_or(u32::MAX), + n, &[("n", &n.to_string())], ))); } diff --git a/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs b/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs index 3dcee46344..64d62899d3 100644 --- a/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs +++ b/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs @@ -124,7 +124,7 @@ impl PendingChangesBar { crate::i18n::ngettext_f( "{n} unsaved change", "{n} unsaved changes", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], ) }; diff --git a/linux/crates/app/src/ui/clipboard/clipboard_copy.rs b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs index af03960f57..4f4e41d5c7 100644 --- a/linux/crates/app/src/ui/clipboard/clipboard_copy.rs +++ b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs @@ -11,7 +11,7 @@ use tablepro_session::runtime::Tasks; use super::clipboard_writer::{holds_nul, write}; use super::{ClipboardError, ClipboardPayload, CopyError, CopyKind, CopyRequest, render_copy}; use crate::i18n::{gettext, gettext_f, ngettext_f}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; use crate::ui::handler_guard::HandlerGuard; /// The copy asked for last, in the whole process: every window copies @@ -85,16 +85,11 @@ impl Copied { let n = rows as u64; match self { Self::Value => gettext("Copied to clipboard"), - Self::Rows => ngettext_f( - "Copied {n} row", - "Copied {n} rows", - plural_count(n), - &[("n", &format_thousands(n))], - ), + Self::Rows => ngettext_f("Copied {n} row", "Copied {n} rows", n, &[("n", &format_thousands(n))]), Self::InList => ngettext_f( "Copied {n} value", "Copied {n} values", - plural_count(n), + n, &[("n", &format_thousands(n))], ), } diff --git a/linux/crates/app/src/ui/data_grid/columns.rs b/linux/crates/app/src/ui/data_grid/columns.rs index 5831256e40..4a7ffe816d 100644 --- a/linux/crates/app/src/ui/data_grid/columns.rs +++ b/linux/crates/app/src/ui/data_grid/columns.rs @@ -198,8 +198,8 @@ impl DataGrid { let Some(cell) = item.downcast_ref::() else { return; }; - // Set through the property: under GTK 4.12 and later gtk-rs maps - // `set_focusable` to the GtkListItem field the cell never reads. + // Set through the property: gtk-rs maps `set_focusable` to the GtkListItem field the cell never + // reads (docs/toolkit-workarounds.md, "ColumnViewCell.set_focusable in gtk-rs"). cell.set_property("focusable", true); let grid_cell = GridCell::new(setup_paint.clone()); grid_cell.attach(cell); diff --git a/linux/crates/app/src/ui/data_grid/edit/deletion.rs b/linux/crates/app/src/ui/data_grid/edit/deletion.rs index dece7a4f7a..c46a5854ed 100644 --- a/linux/crates/app/src/ui/data_grid/edit/deletion.rs +++ b/linux/crates/app/src/ui/data_grid/edit/deletion.rs @@ -101,7 +101,7 @@ impl EditController { let title = ngettext_f( "Delete {n} Row?", "Delete {n} Rows?", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], ); let dialog = adw::AlertDialog::new(Some(&title), Some(&deletion.confirmation_body())); diff --git a/linux/crates/app/src/ui/data_grid/edit/paste.rs b/linux/crates/app/src/ui/data_grid/edit/paste.rs index 007df81391..c9016072fe 100644 --- a/linux/crates/app/src/ui/data_grid/edit/paste.rs +++ b/linux/crates/app/src/ui/data_grid/edit/paste.rs @@ -11,7 +11,7 @@ use crate::i18n::{gettext, gettext_f, ngettext_f}; use crate::services::grid::{cell_presets, parse_cell_text}; use crate::ui::data_grid::{DataGrid, PinnedRow}; use crate::ui::error_text::{edit_parse_message, row_error_message}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; /// A paste of more rows asks first: each becomes a pending row, and /// Import CSV is the tool for a lot of data. @@ -174,7 +174,7 @@ impl EditController { grid.toast(ngettext_f( "Pasted {n} row", "Pasted {n} rows", - plural_count(n), + n, &[("n", &format_thousands(n))], )); if skipped > 0 { @@ -182,7 +182,7 @@ impl EditController { grid.toast(ngettext_f( "Left out {n} value no column could take", "Left out {n} values no column could take", - plural_count(n), + n, &[("n", &format_thousands(n))], )); } @@ -193,7 +193,7 @@ impl EditController { let title = ngettext_f( "Paste {n} Row?", "Paste {n} Rows?", - plural_count(count), + count, &[("n", &format_thousands(count))], ); let body = gettext( diff --git a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs index 32623cfecd..d71dbbd2e0 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs @@ -128,6 +128,7 @@ impl ObjectSubclass for RowDetailPane { impl ObjectImpl for RowDetailPane { fn dispose(&self) { + crate::ui::text_view_teardown::release(&self.value_view.get()); if let Some(load) = self.picture_load.take() { load.stop(); } diff --git a/linux/crates/app/src/ui/data_grid/row_json_dialog.rs b/linux/crates/app/src/ui/data_grid/row_json_dialog.rs index ad24938871..f53f97a7d4 100644 --- a/linux/crates/app/src/ui/data_grid/row_json_dialog.rs +++ b/linux/crates/app/src/ui/data_grid/row_json_dialog.rs @@ -13,7 +13,7 @@ use tablepro_session::runtime::Tasks; use crate::services::grid::RowSnapshot; use crate::ui::clipboard::{ClipboardFormat, ClipboardPayload}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; use crate::ui::source_dialog::{SourceText, cut, more_bytes, source_dialog}; /// Longer text is cut in the dialog: the view lays out every character @@ -81,7 +81,7 @@ fn shown(value: Value, shortened: &mut bool) -> Value { kept.push(Value::Text(crate::i18n::ngettext_f( "… ({count} more item)", "… ({count} more items)", - plural_count(more), + more, &[("count", &format_thousands(more))], ))); } diff --git a/linux/crates/app/src/ui/data_grid/row_number_column.rs b/linux/crates/app/src/ui/data_grid/row_number_column.rs index 19114d5711..1ce4720d78 100644 --- a/linux/crates/app/src/ui/data_grid/row_number_column.rs +++ b/linux/crates/app/src/ui/data_grid/row_number_column.rs @@ -23,6 +23,7 @@ pub fn build(grid: &DataGrid) -> gtk::ColumnViewColumn { let Some(cell) = item.downcast_ref::() else { return; }; + // Not `set_focusable`: docs/toolkit-workarounds.md, "ColumnViewCell.set_focusable in gtk-rs". cell.set_property("focusable", false); let icon = gtk::Image::builder().pixel_size(16).visible(false).build(); icon.set_accessible_role(gtk::AccessibleRole::Presentation); diff --git a/linux/crates/app/src/ui/delete_connection_dialog.rs b/linux/crates/app/src/ui/delete_connection_dialog.rs index e8a520e940..9fbcf7e7bd 100644 --- a/linux/crates/app/src/ui/delete_connection_dialog.rs +++ b/linux/crates/app/src/ui/delete_connection_dialog.rs @@ -52,7 +52,7 @@ impl DeleteConnectionDialog { self.history.set_subtitle(&crate::i18n::ngettext_f( "Removes {n} query run on this connection", "Removes {n} queries run on this connection", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], )); } diff --git a/linux/crates/app/src/ui/diagram/describe.rs b/linux/crates/app/src/ui/diagram/describe.rs index 33328afc08..477483e831 100644 --- a/linux/crates/app/src/ui/diagram/describe.rs +++ b/linux/crates/app/src/ui/diagram/describe.rs @@ -13,7 +13,7 @@ pub(crate) fn table_description(diagram: &SchemaDiagram, table: usize) -> String let mut sentences = vec![ngettext_f( "{n} column.", "{n} columns.", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], )]; let (references, referenced_by) = diagram.neighbours(table); diff --git a/linux/crates/app/src/ui/diagram/tab.rs b/linux/crates/app/src/ui/diagram/tab.rs index dd112a1429..0afeb522d9 100644 --- a/linux/crates/app/src/ui/diagram/tab.rs +++ b/linux/crates/app/src/ui/diagram/tab.rs @@ -325,7 +325,7 @@ impl DiagramTab { &crate::i18n::ngettext_f( "Drew {n} table", "Drew {n} tables", - u32::try_from(tables).unwrap_or(u32::MAX), + tables, &[("n", &tables.to_string())], ), gtk::AccessibleAnnouncementPriority::Medium, @@ -489,7 +489,7 @@ impl DiagramTab { total => crate::i18n::ngettext_f( "{done} of {total} table read", "{done} of {total} tables read", - u32::try_from(total).unwrap_or(u32::MAX), + total, &[("done", &progress.done.to_string()), ("total", &total.to_string())], ), }; diff --git a/linux/crates/app/src/ui/discard_alert.rs b/linux/crates/app/src/ui/discard_alert.rs index 54cd549382..27fb6d3ad2 100644 --- a/linux/crates/app/src/ui/discard_alert.rs +++ b/linux/crates/app/src/ui/discard_alert.rs @@ -13,7 +13,7 @@ pub(crate) fn confirm_discard( let body = crate::i18n::ngettext_f( "{n} unsaved change on this tab will be lost, and Undo cannot bring it back.", "{n} unsaved changes on this tab will be lost, and Undo cannot bring them back.", - u32::try_from(pending).unwrap_or(u32::MAX), + pending, &[("n", &pending.to_string())], ); let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Discard Changes?")), Some(&body)); diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs index fecf7ccd06..b13a22e02a 100644 --- a/linux/crates/app/src/ui/editor/editor_actions.rs +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -203,7 +203,7 @@ impl SqlEditor { /// line that counts them says how many there are. fn announce_carets(&self) { let imp = self.imp(); - let count = u32::try_from(imp.source_view.caret_count()).unwrap_or(u32::MAX); + let count = imp.source_view.caret_count(); crate::ui::a11y::announce( &imp.cursor_info.get(), &crate::i18n::ngettext_f( diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 8a8027f644..5682155daf 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -784,20 +784,14 @@ impl SqlEditor { providers.push(words.upcast()); } - // GtkSourceView before 5.21 never frees a view that has drawn its line - // numbers, so the closing editor takes back the script and the session. - // GTK 4.24 gives the input method the view from construction until the - // view's own dispose, and IBus takes a reference on it, so the view is - // never disposed unless it drops its input method first - // (https://github.com/ibus/ibus/issues/2968). + // The view can outlive the tab, so it lets go of the script and the session here. fn release_view(&self) { let view = self.imp().source_view.get(); - view.set_im_module(Some("gtk-im-context-none")); let completion = view.completion(); for provider in self.imp().completion_providers.take() { completion.remove_provider(&provider); } - view.set_buffer(Some(&sourceview5::Buffer::new(None))); + crate::ui::text_view_teardown::release(&view); } fn connect_buffer(&self, buffer: &sourceview5::Buffer) { @@ -999,7 +993,7 @@ fn show_cursor_position(label: >k4::Label, view: &EditorView) { let iter = buffer.iter_at_mark(&buffer.get_insert()); let line = (iter.line() + 1).to_string(); let column = (iter.line_offset() + 1).to_string(); - let count = u32::try_from(view.caret_count()).unwrap_or(u32::MAX); + let count = view.caret_count(); label.set_label(&if count > 1 { crate::i18n::ngettext_f( "Ln {line}, Col {column}, {count} cursor", diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index 629333d89e..8475ebf0c2 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -58,7 +58,7 @@ where Some(rows) => crate::i18n::ngettext_f( "{n} Row Affected", "{n} Rows Affected", - u32::try_from(*rows).unwrap_or(u32::MAX), + *rows, &[("n", &rows.to_string())], ), None => crate::i18n::gettext("Command Completed"), diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index 440c68e8b6..22330c8c6f 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -317,7 +317,7 @@ pub fn entry_title(index: usize, entry: &RunLogEntry) -> (String, &'static str) crate::i18n::ngettext_f( "{n} Row Affected", "{n} Rows Affected", - u32::try_from(*rows).unwrap_or(u32::MAX), + *rows, &[("n", &rows.to_string())], ), icons::SUCCESS, diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs index 7e0a3dbb55..d57d4bdf71 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -481,18 +481,13 @@ impl RunLog { let results = crate::i18n::ngettext_f( "{n} result in {ms} ms", "{n} results in {ms} ms", - u32::try_from(shown).unwrap_or(u32::MAX), + shown, &[("n", &shown.to_string()), ("ms", &ms)], ); if failures == 0 { return results; } - let failed = crate::i18n::ngettext_f( - "{n} failed", - "{n} failed", - u32::try_from(failures).unwrap_or(u32::MAX), - &[("n", &failures.to_string())], - ); + let failed = crate::i18n::ngettext_f("{n} failed", "{n} failed", failures, &[("n", &failures.to_string())]); format!("{results} · {failed}") } } diff --git a/linux/crates/app/src/ui/error_text/ddl.rs b/linux/crates/app/src/ui/error_text/ddl.rs index 112a00b6f8..9d7ea766a6 100644 --- a/linux/crates/app/src/ui/error_text/ddl.rs +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -46,7 +46,7 @@ pub fn refusal_text(reason: &RefusalReason) -> String { RefusalReason::InsertedByPosition(triggers) => crate::i18n::ngettext_f( "Trigger {triggers} inserts into this table without naming its columns, so adding or removing a column would break it.", "Triggers {triggers} insert into this table without naming its columns, so adding or removing a column would break them.", - u32::try_from(triggers.len()).unwrap_or(u32::MAX), + triggers.len(), &[("triggers", &triggers.join(", "))], ), RefusalReason::ColumnsConverted => { diff --git a/linux/crates/app/src/ui/error_text/files.rs b/linux/crates/app/src/ui/error_text/files.rs index 9a3e730161..0e333e3fd6 100644 --- a/linux/crates/app/src/ui/error_text/files.rs +++ b/linux/crates/app/src/ui/error_text/files.rs @@ -53,7 +53,7 @@ pub(super) fn certificate_problem_text(problem: &CertificateFileProblem) -> Stri CertificateFileProblem::MultipleCertificates { count } => ngettext_f( "it holds {count} certificate, and only one is allowed", "it holds {count} certificates, and only one is allowed", - u32::try_from(*count).unwrap_or(u32::MAX), + *count, &[("count", &count.to_string())], ), CertificateFileProblem::NoPrivateKey => gettext("it holds no private key"), diff --git a/linux/crates/app/src/ui/error_text/saved_connection.rs b/linux/crates/app/src/ui/error_text/saved_connection.rs index bd49de1502..a2390271b2 100644 --- a/linux/crates/app/src/ui/error_text/saved_connection.rs +++ b/linux/crates/app/src/ui/error_text/saved_connection.rs @@ -26,7 +26,7 @@ pub fn saved_connection_error_message(error: &SavedConnectionError) -> String { SavedConnectionError::Storage(StorageError::GroupTooDeep { max }) => crate::i18n::ngettext_f( "Groups can be nested only {max} level deep.", "Groups can be nested only {max} levels deep.", - u32::try_from(*max).unwrap_or(u32::MAX), + *max, &[("max", &max.to_string())], ), SavedConnectionError::Storage(StorageError::GroupExists { .. }) => { diff --git a/linux/crates/app/src/ui/error_text/syntax.rs b/linux/crates/app/src/ui/error_text/syntax.rs index 2749f3a400..69f0084b47 100644 --- a/linux/crates/app/src/ui/error_text/syntax.rs +++ b/linux/crates/app/src/ui/error_text/syntax.rs @@ -71,7 +71,7 @@ fn too_long(max: usize) -> String { ngettext_f( "Use at most {max} character.", "Use at most {max} characters.", - u32::try_from(max).unwrap_or(u32::MAX), + max, &[("max", &max.to_string())], ) } diff --git a/linux/crates/app/src/ui/export/dialog.rs b/linux/crates/app/src/ui/export/dialog.rs index f26377b643..d6aec91f4c 100644 --- a/linux/crates/app/src/ui/export/dialog.rs +++ b/linux/crates/app/src/ui/export/dialog.rs @@ -2,9 +2,10 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{adw, gtk}; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; use tablepro_core::export::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote, XLSX_MAX_DATA_ROWS}; diff --git a/linux/crates/app/src/ui/export/export_failure_text.rs b/linux/crates/app/src/ui/export/export_failure_text.rs index 6ac1eef582..0da3f37813 100644 --- a/linux/crates/app/src/ui/export/export_failure_text.rs +++ b/linux/crates/app/src/ui/export/export_failure_text.rs @@ -13,13 +13,13 @@ use super::{AbortOutcome, ExpectedRows, ExportFailure, ExportOutcome, ExportStre use crate::i18n::{gettext, gettext_f, ngettext_f}; use crate::ui::error_text::operation_error_message; use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; pub(crate) fn exported_text(rows: u64, name: &str) -> String { ngettext_f( "Exported {rows} row to {name}", "Exported {rows} rows to {name}", - plural_count(rows), + rows, &[("rows", &format_thousands(rows)), ("name", name)], ) } @@ -39,16 +39,16 @@ pub(crate) fn progress_text(rows: u64, expected: Option) -> String Some(ExpectedRows::Exact(total)) => ngettext_f( "{rows} of {total} row", "{rows} of {total} rows", - plural_count(total), + total, &[("rows", &count), ("total", &format_thousands(total))], ), Some(ExpectedRows::About(total)) => ngettext_f( "{rows} of about {total} row", "{rows} of about {total} rows", - plural_count(total), + total, &[("rows", &count), ("total", &format_thousands(total))], ), - None => ngettext_f("{rows} row", "{rows} rows", plural_count(rows), &[("rows", &count)]), + None => ngettext_f("{rows} row", "{rows} rows", rows, &[("rows", &count)]), } } diff --git a/linux/crates/app/src/ui/export/export_ledger/mod.rs b/linux/crates/app/src/ui/export/export_ledger/mod.rs index c9bec9168e..5e062fbc27 100644 --- a/linux/crates/app/src/ui/export/export_ledger/mod.rs +++ b/linux/crates/app/src/ui/export/export_ledger/mod.rs @@ -21,7 +21,6 @@ use super::{ ExportSinkParts, ExportSource, ExportStreamFailure, FinishError, ReplaceFileWriter, }; use crate::i18n::{gettext, ngettext_f}; -use crate::ui::format::plural_count; use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor, WorkOutcome}; /// Chunks in flight between the encoder and the file. Few, so a slow @@ -274,7 +273,7 @@ impl CloseBlocker for ExportLedger { body: ngettext_f( "{n} export is still running. Stopping it leaves the file unfinished.", "{n} exports are still running. Stopping them leaves the files unfinished.", - plural_count(running as u64), + running, &[("n", &running.to_string())], ), keep_label: gettext("_Keep Exporting"), diff --git a/linux/crates/app/src/ui/export/export_scope.rs b/linux/crates/app/src/ui/export/export_scope.rs index e7ae4bebf0..98d219de1f 100644 --- a/linux/crates/app/src/ui/export/export_scope.rs +++ b/linux/crates/app/src/ui/export/export_scope.rs @@ -6,7 +6,7 @@ use super::dialog::SqlTarget; use super::{ExpectedRows, ExportQuery, ExportSource}; use crate::i18n::{gettext, ngettext_f, pgettext}; use crate::ui::browse_tab::RowTotal; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; /// What a tab asks the window to export. #[derive(Debug, Clone)] @@ -161,18 +161,13 @@ impl ExportOrigin { } fn rows_text(rows: u64, format: &impl Fn(u64) -> String) -> String { - ngettext_f("{n} row", "{n} rows", plural_count(rows), &[("n", &format(rows))]) + ngettext_f("{n} row", "{n} rows", rows, &[("n", &format(rows))]) } fn table_total(total: &RowTotal, format: &impl Fn(u64) -> String) -> String { match (total.exact(), total.estimate()) { (Some(exact), _) => rows_text(exact, format), - (None, Some(estimate)) => ngettext_f( - "About {n} row", - "About {n} rows", - plural_count(estimate), - &[("n", &format(estimate))], - ), + (None, Some(estimate)) => ngettext_f("About {n} row", "About {n} rows", estimate, &[("n", &format(estimate))]), (None, None) => gettext("Every row"), } } diff --git a/linux/crates/app/src/ui/format/mod.rs b/linux/crates/app/src/ui/format/mod.rs index b05034fe7f..732b8973f0 100644 --- a/linux/crates/app/src/ui/format/mod.rs +++ b/linux/crates/app/src/ui/format/mod.rs @@ -5,9 +5,3 @@ mod thousands; #[cfg(test)] pub(crate) use self::thousands::Thousands; pub(crate) use self::thousands::{format_decimal, format_thousands}; - -/// The count gettext picks a plural form by. It takes a u32, and a -/// result past that is rare enough for the largest u32 to stand in. -pub(crate) fn plural_count(n: u64) -> u32 { - u32::try_from(n).unwrap_or(u32::MAX) -} diff --git a/linux/crates/app/src/ui/history_dialog/history_formatting.rs b/linux/crates/app/src/ui/history_dialog/history_formatting.rs index 730771b05c..f23dc11ccd 100644 --- a/linux/crates/app/src/ui/history_dialog/history_formatting.rs +++ b/linux/crates/app/src/ui/history_dialog/history_formatting.rs @@ -3,7 +3,7 @@ use std::time::SystemTime; use tablepro_storage::query_history::Entry; use crate::i18n::{gettext, ngettext_f}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; const TITLE_CHARS: usize = 80; const ERROR_CHARS: usize = 48; @@ -39,7 +39,7 @@ pub(super) fn subtitle(entry: &Entry, now: SystemTime) -> String { parts.push(ngettext_f( "{n} row", "{n} rows", - plural_count(rows), + rows, &[("n", &format_thousands(rows))], )); } @@ -69,7 +69,7 @@ pub(super) fn outcome_icon(entry: &Entry) -> &'static str { fn relative_time(when: SystemTime, now: SystemTime) -> String { let secs = now.duration_since(when).unwrap_or_default().as_secs(); - let count = |n: u64| (plural_count(n), n.to_string()); + let count = |n: u64| (n, n.to_string()); if secs < 60 { gettext("just now") } else if secs < 3_600 { diff --git a/linux/crates/app/src/ui/history_dialog/mod.rs b/linux/crates/app/src/ui/history_dialog/mod.rs index b804278e5f..35a22177ab 100644 --- a/linux/crates/app/src/ui/history_dialog/mod.rs +++ b/linux/crates/app/src/ui/history_dialog/mod.rs @@ -39,7 +39,6 @@ use crate::i18n::{gettext, ngettext_f}; use crate::runtime::spawn_and_deliver; use crate::services::history_service::HistoryService; use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; -use crate::ui::format::plural_count; use history_export::{ExportKind, history_export_failure_text, save_export, show_export_failure}; use connection_filter::ConnectionFilterModel; @@ -75,7 +74,7 @@ pub(crate) enum QueryDestination { } impl QueryDestination { - fn signal(self) -> &'static str { + pub(crate) fn signal(self) -> &'static str { match self { Self::NewTab => "open-in-new-tab", Self::CurrentTab => "replace-current-tab", @@ -373,12 +372,8 @@ impl HistoryDialog { return; } let n = count as u64; - imp.selection_label.set_label(&ngettext_f( - "{n} selected", - "{n} selected", - plural_count(n), - &[("n", &n.to_string())], - )); + imp.selection_label + .set_label(&ngettext_f("{n} selected", "{n} selected", n, &[("n", &n.to_string())])); imp.selection_bar.set_reveal_child(true); } @@ -641,7 +636,7 @@ impl HistoryDialog { let toast = crate::ui::plain_toast(&ngettext_f( "{count} query deleted", "{count} queries deleted", - plural_count(count), + count, &[("count", &count.to_string())], )); toast.set_button_label(Some(&gettext("_Undo"))); diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs deleted file mode 100644 index 08173e85ef..0000000000 --- a/linux/crates/app/src/ui/import_dialog.rs +++ /dev/null @@ -1,667 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use relm4::adw::prelude::*; -use relm4::prelude::*; -use relm4::{adw, gtk}; - -use tablepro_core::export::CsvDelimiter; -use tablepro_core::import::{ - CsvImportOptions, CsvSheet, ImportColumn, ImportError, MAX_PREVIEW_ROWS, read_csv, suggest_mapping, -}; - -use crate::runtime::DeliverInput; - -/// How many values from the file each mapping row shows, so the user -/// can see they picked the field they meant. -const SAMPLE_VALUES: usize = 3; - -pub struct ImportCsvDialog { - root: adw::Dialog, - schema: Option, - table: String, - bytes: Vec, - columns: Vec, - sheet: CsvSheet, - /// Why the file could not be read under the options as they stand. - read_error: Option, - options: CsvImportOptions, - /// One row per table column, in column order. The selected index is - /// 0 for "Skip" and the field number plus one otherwise. - mapping_rows: Rc>>, - mapping_group: adw::PreferencesGroup, - summary: gtk::Label, - delimiter_row: adw::ComboRow, - header_row: adw::SwitchRow, - null_row: adw::EntryRow, - import_button: gtk::Button, -} - -pub struct ImportCsvDialogInit { - pub schema: Option, - pub table: String, - pub columns: Vec, - pub bytes: Vec, -} - -#[derive(Debug)] -pub enum ImportCsvDialogInput { - /// The delimiter, header switch or NULL marker moved, so the file - /// has to be read again. - Reread, - /// A mapping combo moved, so the samples have to be redrawn. - MappingChanged, - Import, -} - -/// Everything an import needs, once the mapping dialog has settled it. -/// -/// It travels as one value because every part of it is a decision the -/// dialog made together with the others: the mapping only means -/// anything against these columns, read under these options. -#[derive(Debug, Clone)] -pub struct CsvImport { - pub schema: Option, - pub table: String, - pub columns: Vec, - /// One entry per table column: the CSV field it takes, or `None` - /// for a column the user set to Skip or the server fills. - pub mapping: Vec>, - pub options: CsvImportOptions, - pub bytes: Vec, -} - -#[derive(Debug)] -pub enum ImportCsvDialogOutput { - Import(Box), -} - -impl Component for ImportCsvDialog { - type Init = ImportCsvDialogInit; - type Input = ImportCsvDialogInput; - type Output = ImportCsvDialogOutput; - type CommandOutput = (); - type Root = adw::Dialog; - type Widgets = (); - - fn init_root() -> Self::Root { - adw::Dialog::builder() - .title(crate::i18n::gettext("Import CSV")) - .content_width(560) - .content_height(680) - .build() - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let toolbar = adw::ToolbarView::new(); - let header = adw::HeaderBar::builder().show_end_title_buttons(true).build(); - let subtitle = qualified(init.schema.as_deref(), &init.table); - header.set_title_widget(Some(&adw::WindowTitle::new( - &crate::i18n::gettext("Import CSV"), - &subtitle, - ))); - let import_button = gtk::Button::builder().label(crate::i18n::gettext("Import")).build(); - import_button.add_css_class("suggested-action"); - let sender_for_import = sender.clone(); - import_button.connect_clicked(move |_| sender_for_import.deliver(ImportCsvDialogInput::Import)); - header.pack_end(&import_button); - toolbar.add_top_bar(&header); - - let page = adw::PreferencesPage::new(); - - let file_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("File")) - .build(); - let delimiter_row = adw::ComboRow::builder() - .title(crate::i18n::gettext("Delimiter")) - .model(>k::StringList::new(&[ - &crate::i18n::gettext("Comma"), - &crate::i18n::gettext("Semicolon"), - &crate::i18n::gettext("Tab"), - &crate::i18n::gettext("Pipe"), - ])) - .build(); - let header_row = adw::SwitchRow::builder() - .title(crate::i18n::gettext("First Row Is a Header")) - .subtitle(crate::i18n::gettext( - "Its values name the fields instead of being imported.", - )) - .active(true) - .build(); - let null_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Text that means NULL")) - .build(); - file_group.add(&delimiter_row); - file_group.add(&header_row); - file_group.add(&null_row); - page.add(&file_group); - - let mapping_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("Columns")) - .description(crate::i18n::gettext( - "Each table column takes one field from the file. A column set to Skip keeps its own default.", - )) - .build(); - page.add(&mapping_group); - - // Problems with the file are told here, inside the dialog that - // covers the window a toast would go to, and long enough to read. - let summary = gtk::Label::builder() - .xalign(0.0) - .wrap(true) - .wrap_mode(gtk::pango::WrapMode::WordChar) - .selectable(true) - .build(); - let summary_group = adw::PreferencesGroup::new(); - summary_group.add(&summary); - page.add(&summary_group); - - toolbar.set_content(Some(&page)); - root.set_child(Some(&toolbar)); - - let sender_for_header = sender.clone(); - header_row.connect_active_notify(move |_| sender_for_header.deliver(ImportCsvDialogInput::Reread)); - let sender_for_delimiter = sender.clone(); - delimiter_row.connect_selected_notify(move |_| sender_for_delimiter.deliver(ImportCsvDialogInput::Reread)); - let sender_for_null = sender.clone(); - null_row.connect_changed(move |_| sender_for_null.deliver(ImportCsvDialogInput::MappingChanged)); - - let mut model = ImportCsvDialog { - root: root.clone(), - schema: init.schema, - table: init.table, - bytes: init.bytes, - columns: init.columns, - sheet: CsvSheet::default(), - read_error: None, - options: CsvImportOptions::default(), - mapping_rows: Rc::new(RefCell::new(Vec::new())), - mapping_group, - summary, - delimiter_row, - header_row, - null_row, - import_button, - }; - model.reread(&sender); - - ComponentParts { model, widgets: () } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { - match msg { - ImportCsvDialogInput::Reread => self.reread(&sender), - ImportCsvDialogInput::MappingChanged => { - self.options = self.read_options(); - self.show_samples(); - self.show_summary(); - } - ImportCsvDialogInput::Import => { - let mapping = self.mapping(); - if self.blocker().is_some() { - return; - } - let _ = sender.output(ImportCsvDialogOutput::Import(Box::new(CsvImport { - schema: self.schema.clone(), - table: self.table.clone(), - columns: self.columns.clone(), - mapping, - options: self.read_options(), - bytes: std::mem::take(&mut self.bytes), - }))); - self.root.close(); - } - } - } -} - -impl ImportCsvDialog { - pub fn dialog(&self) -> &adw::Dialog { - &self.root - } - - fn read_options(&self) -> CsvImportOptions { - CsvImportOptions { - delimiter: match self.delimiter_row.selected() { - 1 => CsvDelimiter::Semicolon, - 2 => CsvDelimiter::Tab, - 3 => CsvDelimiter::Pipe, - _ => CsvDelimiter::Comma, - }, - has_header: self.header_row.is_active(), - null_marker: self.null_row.text().to_string(), - } - } - - /// Read the file again under the options as they stand and rebuild - /// the mapping rows, which are named after the file's own fields. - fn reread(&mut self, sender: &ComponentSender) { - self.options = self.read_options(); - match read_csv(&self.bytes, &self.options, Some(MAX_PREVIEW_ROWS)) { - Ok(sheet) => { - self.sheet = sheet; - self.read_error = None; - } - Err(error) => { - self.sheet = CsvSheet::default(); - self.read_error = Some(import_error_text(&error)); - } - } - self.rebuild_mapping(sender); - self.show_summary(); - } - - fn rebuild_mapping(&mut self, sender: &ComponentSender) { - for row in self.mapping_rows.borrow_mut().drain(..) { - self.mapping_group.remove(&row); - } - - let choices: Vec = std::iter::once(crate::i18n::gettext("Skip")) - .chain( - self.sheet - .headers - .iter() - .enumerate() - .map(|(index, header)| field_label(index, header)), - ) - .collect(); - let choice_refs: Vec<&str> = choices.iter().map(String::as_str).collect(); - // One list for every row: each row keeps its own selection, and - // a wide file is held once rather than once per column. - let choices = gtk::StringList::new(&choice_refs); - let suggested = suggest_mapping(&self.sheet.headers, &self.columns); - - for (column, field) in self.columns.iter().zip(&suggested) { - // The subtitle shows values from the file, which may hold & or <. - let row = adw::ComboRow::builder() - .use_markup(false) - .title(column.info.name.as_str()) - .model(&choices) - .sensitive(!column.server_filled) - .build(); - row.set_selected(field.map_or(0, |index| index as u32 + 1)); - let sender = sender.clone(); - row.connect_selected_notify(move |_| sender.deliver(ImportCsvDialogInput::MappingChanged)); - self.mapping_group.add(&row); - self.mapping_rows.borrow_mut().push(row); - } - self.show_samples(); - } - - /// Show what the mapped field actually holds, under each column. - fn show_samples(&self) { - let rows = self.mapping_rows.borrow(); - for ((row, field), column) in rows.iter().zip(self.mapping()).zip(&self.columns) { - row.set_subtitle(&match field { - _ if column.server_filled => crate::i18n::gettext("The database fills this column"), - Some(index) => sample_text(&self.sheet, index, &self.options), - None => crate::i18n::gettext("Uses the column's own default"), - }); - } - } - - /// What stands in the way of importing, if anything. - fn blocker(&self) -> Option { - if let Some(error) = &self.read_error { - return Some(error.clone()); - } - if self.sheet.rows.is_empty() { - return Some(crate::i18n::gettext("The file has no rows to import.")); - } - if self.mapping().iter().all(Option::is_none) { - return Some(crate::i18n::gettext( - "Every column is set to Skip. Pick a field for at least one column to import.", - )); - } - None - } - - fn show_summary(&self) { - let blocker = self.blocker(); - self.import_button.set_sensitive(blocker.is_none()); - let text = match blocker { - Some(blocker) => { - self.summary.remove_css_class("dim-label"); - self.summary.add_css_class("error"); - self.summary.set_label(&blocker); - return; - } - None if self.sheet.truncated => crate::i18n::gettext_f( - "{fields} fields. Showing the first {rows} rows; the whole file is imported.", - &[ - ("fields", &self.sheet.headers.len().to_string()), - ("rows", &self.sheet.rows.len().to_string()), - ], - ), - None => crate::i18n::ngettext_f( - "{fields} fields, {rows} row.", - "{fields} fields, {rows} rows.", - self.sheet.rows.len() as u32, - &[ - ("fields", &self.sheet.headers.len().to_string()), - ("rows", &self.sheet.rows.len().to_string()), - ], - ), - }; - self.summary.remove_css_class("error"); - self.summary.add_css_class("dim-label"); - self.summary.set_label(&text); - } - - fn mapping(&self) -> Vec> { - self.mapping_rows - .borrow() - .iter() - .zip(&self.columns) - .map(|(row, column)| match row.selected() { - _ if column.server_filled => None, - 0 => None, - index => Some(index as usize - 1), - }) - .collect() - } -} - -/// What went wrong reading the file, in words the user can act on. -pub(crate) fn import_error_text(error: &ImportError) -> String { - match error { - ImportError::NotUtf8 { offset } => crate::i18n::gettext_f( - "The file isn't UTF-8 text from byte {offset} on. Save it as UTF-8 and import it again.", - &[("offset", &offset.to_string())], - ), - ImportError::Malformed { line: Some(line) } => crate::i18n::gettext_f( - "The file couldn't be read as CSV at line {line}.", - &[("line", &line.to_string())], - ), - ImportError::Malformed { line: None } => crate::i18n::gettext("The file couldn't be read as CSV."), - ImportError::NoColumns => crate::i18n::gettext("The file has no columns."), - ImportError::TooManyFields { line, limit } => crate::i18n::gettext_f( - "Line {line} has more than {limit} fields, more than any table holds. Check the delimiter.", - &[("line", &line.to_string()), ("limit", &limit.to_string())], - ), - } -} - -/// A field as the mapping offers it: its header, or its position when -/// the file names none. -fn field_label(index: usize, header: &str) -> String { - match header.trim().is_empty() { - true => crate::i18n::gettext_f("Column {n}", &[("n", &(index + 1).to_string())]), - false => header.to_owned(), - } -} - -/// The first few values of one field, as one line. -fn sample_text(sheet: &CsvSheet, field: usize, options: &CsvImportOptions) -> String { - let values: Vec = sheet - .rows - .iter() - .filter_map(|row| row.get(field)) - .take(SAMPLE_VALUES) - .map(|text| match text == &options.null_marker { - true => crate::i18n::gettext("NULL"), - false => text.clone(), - }) - .collect(); - match values.is_empty() { - true => crate::i18n::gettext("No values in the file"), - false => values.join(" · "), - } -} - -fn qualified(schema: Option<&str>, table: &str) -> String { - match schema { - Some(schema) => format!("{schema}.{table}"), - None => table.to_owned(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sheet() -> CsvSheet { - CsvSheet { - headers: vec!["id".to_owned(), "name".to_owned()], - rows: vec![ - vec!["1".to_owned(), "ada".to_owned()], - vec!["2".to_owned(), String::new()], - vec!["3".to_owned(), "grace".to_owned()], - vec!["4".to_owned(), "alan".to_owned()], - ], - truncated: false, - } - } - - #[test] - fn a_sample_shows_the_first_few_values_of_its_field() { - assert_eq!( - sample_text(&sheet(), 0, &CsvImportOptions::default()), - "1 · 2 · 3", - "a sample past {SAMPLE_VALUES} values would not fit a subtitle" - ); - } - - #[test] - fn a_sample_names_the_values_that_would_import_as_null() { - assert_eq!( - sample_text(&sheet(), 1, &CsvImportOptions::default()), - "ada · NULL · grace" - ); - } - - #[test] - fn a_field_the_file_never_fills_says_so_rather_than_showing_nothing() { - assert_eq!( - sample_text(&CsvSheet::default(), 0, &CsvImportOptions::default()), - "No values in the file" - ); - } - - #[test] - fn a_table_in_a_schema_is_named_with_it() { - assert_eq!(qualified(Some("public"), "orders"), "public.orders"); - assert_eq!(qualified(None, "orders"), "orders"); - } - - #[test] - fn a_file_the_csv_reader_refused_says_where_in_the_apps_words() { - assert_eq!( - import_error_text(&ImportError::Malformed { line: Some(12) }), - "The file couldn't be read as CSV at line 12." - ); - assert_eq!( - import_error_text(&ImportError::Malformed { line: None }), - "The file couldn't be read as CSV." - ); - } - - #[test] - fn a_field_with_no_name_is_offered_by_its_position() { - assert_eq!(field_label(0, ""), "Column 1"); - assert_eq!(field_label(2, " "), "Column 3"); - assert_eq!(field_label(1, "city"), "city"); - } -} - -#[cfg(test)] -mod dialog_tests { - use super::*; - use tablepro_core::ColumnInfo; - use tablepro_core::column::{ColumnDefault, ColumnType}; - - /// The dialog only reads a column's name: the type decides what a - /// value parses into, which happens after the dialog is gone. - fn column(name: &str) -> ImportColumn { - ImportColumn { - info: ColumnInfo { - name: name.to_owned(), - column_type: ColumnType::unknown(), - nullable: true, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, - default: ColumnDefault::None, - comment: None, - }, - server_filled: false, - } - } - - fn launch(csv: &str) -> relm4::component::Connector { - ImportCsvDialog::builder().launch(ImportCsvDialogInit { - schema: Some("public".to_owned()), - table: "people".to_owned(), - columns: vec![column("id"), column("name"), column("nickname")], - bytes: csv.as_bytes().to_vec(), - }) - } - - #[gtk4::test] - fn names_and_values_with_markup_characters_show_as_written() { - let dialog = ImportCsvDialog::builder().launch(ImportCsvDialogInit { - schema: Some("public".to_owned()), - table: "people".to_owned(), - columns: vec![column("R&D ")], - bytes: b"R&D \nAT&T \n".to_vec(), - }); - crate::test_support::drain_main_context(); - - let model = dialog.model(); - let rows = model.mapping_rows.borrow(); - let row = rows.first().expect("a mapping row"); - - assert!(!row.uses_markup()); - assert_eq!(row.title(), "R&D "); - assert_eq!(row.subtitle().as_deref(), Some("AT&T ")); - } - - #[gtk4::test] - fn a_column_named_in_the_file_starts_mapped_to_its_field() { - let dialog = launch("name,id\nada,1\n"); - - crate::test_support::drain_main_context(); - - // id is the file's second field, name its first, and nothing in - // the file is called nickname. - assert_eq!(dialog.model().mapping(), vec![Some(1), Some(0), None]); - } - - #[gtk4::test] - fn an_unmapped_column_says_it_keeps_its_default() { - let dialog = launch("name,id\nada,1\n"); - crate::test_support::drain_main_context(); - - let subtitles: Vec = dialog - .model() - .mapping_rows - .borrow() - .iter() - .map(|row| row.subtitle().unwrap_or_default().to_string()) - .collect(); - - assert_eq!(subtitles[0], "1"); - assert_eq!(subtitles[1], "ada"); - assert_eq!(subtitles[2], "Uses the column's own default"); - } - - #[gtk4::test] - fn turning_the_header_switch_off_remaps_against_positional_names() { - let dialog = launch("name,id\nada,1\n"); - crate::test_support::drain_main_context(); - assert_eq!(dialog.model().mapping(), vec![Some(1), Some(0), None]); - - dialog.model().header_row.set_active(false); - crate::test_support::drain_main_context(); - - // Fields with no name match no table column, so every row falls - // back to Skip and the header line reads as data. - assert_eq!(dialog.model().mapping(), vec![None, None, None]); - let offered: Vec = (0..3) - .map(|index| { - dialog.model().mapping_rows.borrow()[0] - .model() - .and_downcast::() - .and_then(|list| list.string(index)) - .map(|text| text.to_string()) - .unwrap_or_default() - }) - .collect(); - assert_eq!(offered, vec!["Skip", "Column 1", "Column 2"]); - } - - #[gtk4::test] - fn a_column_the_server_fills_cannot_take_a_field() { - let mut id = column("id"); - id.server_filled = true; - let dialog = ImportCsvDialog::builder().launch(ImportCsvDialogInit { - schema: None, - table: "people".to_owned(), - columns: vec![id, column("name")], - bytes: b"id,name\n1,ada\n".to_vec(), - }); - crate::test_support::drain_main_context(); - - let rows = dialog.model().mapping_rows.borrow().clone(); - - assert_eq!(dialog.model().mapping(), vec![None, Some(1)]); - assert!( - !rows[0].is_sensitive(), - "a field could be picked for a column the server fills" - ); - assert_eq!( - rows[0].subtitle().unwrap_or_default().as_str(), - "The database fills this column" - ); - } - - #[gtk4::test] - fn a_file_that_is_not_csv_leaves_the_import_button_off_and_says_why_in_the_dialog() { - let dialog = ImportCsvDialog::builder().launch(ImportCsvDialogInit { - schema: None, - table: "people".to_owned(), - columns: vec![column("id")], - bytes: b"id\n\xff\n".to_vec(), - }); - - crate::test_support::drain_main_context(); - - assert!(!dialog.model().import_button.is_sensitive()); - assert_eq!( - dialog.model().summary.label().as_str(), - "The file isn't UTF-8 text from byte 3 on. Save it as UTF-8 and import it again." - ); - assert!(dialog.model().summary.has_css_class("error")); - } - - #[gtk4::test] - fn every_column_set_to_skip_turns_the_import_button_off() { - let dialog = launch("name,id\nada,1\n"); - crate::test_support::drain_main_context(); - assert!(dialog.model().import_button.is_sensitive()); - - for row in dialog.model().mapping_rows.borrow().iter() { - row.set_selected(0); - } - crate::test_support::drain_main_context(); - - assert!(!dialog.model().import_button.is_sensitive()); - assert_eq!( - dialog.model().summary.label().as_str(), - "Every column is set to Skip. Pick a field for at least one column to import." - ); - } - - #[gtk4::test] - fn a_file_with_only_a_header_has_nothing_to_import() { - let dialog = launch("name,id\n"); - crate::test_support::drain_main_context(); - - assert!(!dialog.model().import_button.is_sensitive()); - assert_eq!( - dialog.model().summary.label().as_str(), - "The file has no rows to import." - ); - } -} diff --git a/linux/crates/app/src/ui/import_dialog/imp.rs b/linux/crates/app/src/ui/import_dialog/imp.rs new file mode 100644 index 0000000000..4485c39660 --- /dev/null +++ b/linux/crates/app/src/ui/import_dialog/imp.rs @@ -0,0 +1,82 @@ +use std::cell::{OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_core::import::CsvSheet; + +use super::{CsvImport, CsvRequest, DialogParts}; +use crate::runtime::RequestSequencer; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/import-csv-dialog.ui")] +pub struct ImportCsvDialog { + #[template_child] + pub window_title: TemplateChild, + #[template_child] + pub import_button: TemplateChild, + #[template_child] + pub delimiter_row: TemplateChild, + #[template_child] + pub header_row: TemplateChild, + #[template_child] + pub null_row: TemplateChild, + #[template_child] + pub mapping_group: TemplateChild, + #[template_child] + pub summary: TemplateChild, + + pub(super) parts: OnceCell, + pub(super) sheet: RefCell, + pub(super) read_error: RefCell>, + // One per table column, in column order. Position 0 is Skip, position n + 1 is field n. + pub(super) mapping_rows: RefCell>, + pub(super) sequencer: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for ImportCsvDialog { + const NAME: &'static str = "TableProImportCsvDialog"; + type Type = super::ImportCsvDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for ImportCsvDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("import") + .param_types([CsvImport::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + self.sequencer.borrow_mut().cancel_all(); + } +} + +impl WidgetImpl for ImportCsvDialog {} + +impl AdwDialogImpl for ImportCsvDialog { + // The dialog lives on through its close animation, which a read landing then must not fill. + fn closed(&self) { + self.sequencer.borrow_mut().cancel_all(); + self.parent_closed(); + } +} diff --git a/linux/crates/app/src/ui/import_dialog/mod.rs b/linux/crates/app/src/ui/import_dialog/mod.rs new file mode 100644 index 0000000000..e79ff0097d --- /dev/null +++ b/linux/crates/app/src/ui/import_dialog/mod.rs @@ -0,0 +1,349 @@ +mod imp; +mod preview_text; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::export::CsvDelimiter; +use tablepro_core::import::{ + CsvImportOptions, CsvSheet, ImportColumn, ImportError, MAX_PREVIEW_ROWS, read_csv, suggest_mapping, +}; +use tablepro_session::runtime::{TaskFailure, Tasks}; + +use crate::i18n::gettext; +use crate::runtime::{RequestStamp, RequestTicket}; +use crate::ui::error_text::task_failure_message; +pub(crate) use preview_text::import_error_text; +use preview_text::{field_label, qualified, sample_text, summary_text}; + +glib::wrapper! { + pub struct ImportCsvDialog(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) struct ImportCsvDialogInit { + pub schema: Option, + pub table: String, + pub columns: Vec, + pub bytes: Arc<[u8]>, + pub tasks: Tasks, +} + +// The mapping only means anything against these columns, read under these options, so they travel together. +#[derive(Debug, Clone, glib::Boxed)] +#[boxed_type(name = "TableProCsvImport")] +pub struct CsvImport { + pub schema: Option, + pub table: String, + pub columns: Vec, + // One per table column: the field it takes, or None for Skip and for a column the server fills. + pub mapping: Vec>, + pub options: CsvImportOptions, + pub bytes: Arc<[u8]>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum CsvRequest { + PreviewRead, +} + +// None when the read was replaced or its dialog closed before it started. +type PreviewRead = Option>; + +pub(super) struct DialogParts { + schema: Option, + table: String, + columns: Vec, + bytes: Arc<[u8]>, + tasks: Tasks, +} + +impl ImportCsvDialog { + pub(crate) fn new(init: ImportCsvDialogInit) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + imp.window_title + .set_subtitle(&qualified(init.schema.as_deref(), &init.table)); + let _ = imp.parts.set(DialogParts { + schema: init.schema, + table: init.table, + columns: init.columns, + bytes: init.bytes, + tasks: init.tasks, + }); + dialog.connect_inputs(); + dialog.read_preview(); + dialog + } + + pub(crate) fn connect_import(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "import", + false, + glib::closure_local!(move |dialog: &Self, import: CsvImport| f(dialog, import)), + ) + } + + fn connect_inputs(&self) { + let imp = self.imp(); + let dialog = self.downgrade(); + imp.import_button.connect_clicked(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.import(); + } + }); + let dialog = self.downgrade(); + imp.delimiter_row.connect_selected_notify(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.read_preview(); + } + }); + let dialog = self.downgrade(); + imp.header_row.connect_active_notify(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.read_preview(); + } + }); + // The marker decides what imports as NULL, not how the file splits, so the preview stands. + let dialog = self.downgrade(); + imp.null_row.connect_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.redraw(); + } + }); + } + + fn read_options(&self) -> CsvImportOptions { + let imp = self.imp(); + CsvImportOptions { + delimiter: match imp.delimiter_row.selected() { + 1 => CsvDelimiter::Semicolon, + 2 => CsvDelimiter::Tab, + 3 => CsvDelimiter::Pipe, + _ => CsvDelimiter::Comma, + }, + has_header: imp.header_row.is_active(), + null_marker: imp.null_row.text().to_string(), + } + } + + fn reading(&self) -> bool { + self.imp().sequencer.borrow().is_pending(CsvRequest::PreviewRead) + } + + // The mapping rows are named after the file's own fields, so a new delimiter or header switch reads it again. + fn read_preview(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let RequestTicket { stamp, cancel } = imp.sequencer.borrow_mut().issue(CsvRequest::PreviewRead); + let bytes = parts.bytes.clone(); + let options = self.read_options(); + let reading = parts.tasks.spawn_blocking_task(move || { + if cancel.is_cancelled() { + return None; + } + Some(read_csv(&bytes, &options, Some(MAX_PREVIEW_ROWS))) + }); + let dialog = self.downgrade(); + glib::spawn_future_local(async move { + let read = reading.await; + if let Some(dialog) = dialog.upgrade() { + dialog.read_landed(stamp, read); + } + }); + self.show_summary(); + } + + fn read_landed(&self, stamp: RequestStamp, read: Result) { + let preview = match read { + Ok(Some(Ok(sheet))) => Ok(sheet), + Ok(Some(Err(error))) => Err(import_error_text(&error)), + Err(failure) => Err(task_failure_message(&failure)), + Ok(None) => return, + }; + let imp = self.imp(); + let current = imp.sequencer.borrow_mut().accept(CsvRequest::PreviewRead, stamp); + if !current { + return; + } + let (sheet, error) = match preview { + Ok(sheet) => (sheet, None), + Err(error) => (CsvSheet::default(), Some(error)), + }; + imp.sheet.replace(sheet); + imp.read_error.replace(error); + self.rebuild_mapping(); + self.show_summary(); + } + + fn rebuild_mapping(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + for row in imp.mapping_rows.take() { + imp.mapping_group.remove(&row); + } + let (choices, suggested) = { + let sheet = imp.sheet.borrow(); + let labels: Vec = std::iter::once(gettext("Skip")) + .chain( + sheet + .headers + .iter() + .enumerate() + .map(|(index, header)| field_label(index, header)), + ) + .collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + // One list for every row: each row keeps its own selection, and a wide file is held once. + ( + gtk::StringList::new(&labels), + suggest_mapping(&sheet.headers, &parts.columns), + ) + }; + let rows: Vec = parts + .columns + .iter() + .zip(suggested) + .map(|(column, field)| self.mapping_row(column, field, &choices)) + .collect(); + for row in &rows { + imp.mapping_group.add(row); + } + imp.mapping_rows.replace(rows); + self.show_samples(); + } + + fn mapping_row(&self, column: &ImportColumn, field: Option, choices: >k::StringList) -> adw::ComboRow { + // The subtitle shows values from the file, which may hold & or <. + let row = adw::ComboRow::builder() + .use_markup(false) + .title(column.info.name.as_str()) + .model(choices) + .sensitive(!column.server_filled) + .build(); + row.set_selected(field.and_then(|index| u32::try_from(index + 1).ok()).unwrap_or(0)); + let dialog = self.downgrade(); + row.connect_selected_notify(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.redraw(); + } + }); + row + } + + fn redraw(&self) { + self.show_samples(); + self.show_summary(); + } + + fn show_samples(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let options = self.read_options(); + let sheet = imp.sheet.borrow(); + let rows = imp.mapping_rows.borrow(); + for ((row, field), column) in rows.iter().zip(self.mapping()).zip(&parts.columns) { + row.set_subtitle(&match field { + _ if column.server_filled => gettext("The database fills this column"), + Some(index) => sample_text(&sheet, index, &options), + None => gettext("Uses the column's own default"), + }); + } + } + + fn blocker(&self) -> Option { + let imp = self.imp(); + if let Some(error) = imp.read_error.borrow().as_ref() { + return Some(error.clone()); + } + if imp.sheet.borrow().rows.is_empty() { + return Some(gettext("The file has no rows to import.")); + } + if self.mapping().iter().all(Option::is_none) { + return Some(gettext( + "Every column is set to Skip. Pick a field for at least one column to import.", + )); + } + None + } + + fn show_summary(&self) { + let imp = self.imp(); + let reading = self.reading(); + let (text, problem) = match (reading, self.blocker()) { + (true, _) => (gettext("Reading the file…"), false), + (false, Some(blocker)) => (blocker, true), + (false, None) => { + let sheet = imp.sheet.borrow(); + ( + summary_text(sheet.headers.len(), sheet.rows.len(), sheet.truncated), + false, + ) + } + }; + imp.import_button.set_sensitive(!reading && !problem); + let summary = &imp.summary; + match problem { + true => { + summary.remove_css_class("dim-label"); + summary.add_css_class("error"); + } + false => { + summary.remove_css_class("error"); + summary.add_css_class("dim-label"); + } + } + summary.set_label(&text); + } + + fn mapping(&self) -> Vec> { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return Vec::new(); + }; + imp.mapping_rows + .borrow() + .iter() + .zip(&parts.columns) + .map(|(row, column)| match row.selected() { + _ if column.server_filled => None, + 0 | gtk::INVALID_LIST_POSITION => None, + position => usize::try_from(position - 1).ok(), + }) + .collect() + } + + fn import(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + if self.reading() || self.blocker().is_some() { + return; + } + let import = CsvImport { + schema: parts.schema.clone(), + table: parts.table.clone(), + columns: parts.columns.clone(), + mapping: self.mapping(), + options: self.read_options(), + bytes: parts.bytes.clone(), + }; + self.emit_by_name::<()>("import", &[&import]); + self.close(); + } +} diff --git a/linux/crates/app/src/ui/import_dialog/preview_text.rs b/linux/crates/app/src/ui/import_dialog/preview_text.rs new file mode 100644 index 0000000000..d4e0057d08 --- /dev/null +++ b/linux/crates/app/src/ui/import_dialog/preview_text.rs @@ -0,0 +1,79 @@ +use tablepro_core::import::{CsvImportOptions, CsvSheet, ImportError}; + +use crate::i18n::{gettext, gettext_f, ngettext_f, pgettext_f}; +use crate::ui::format::format_thousands; + +// Enough to tell the field is the one meant, few enough to fit a subtitle. +pub(super) const SAMPLE_VALUES: usize = 3; + +pub(crate) fn import_error_text(error: &ImportError) -> String { + match error { + ImportError::NotUtf8 { offset } => gettext_f( + "The file isn't UTF-8 text from byte {offset} on. Save it as UTF-8 and import it again.", + &[("offset", &offset.to_string())], + ), + ImportError::Malformed { line: Some(line) } => gettext_f( + "The file couldn't be read as CSV at line {line}.", + &[("line", &line.to_string())], + ), + ImportError::Malformed { line: None } => gettext("The file couldn't be read as CSV."), + ImportError::NoColumns => gettext("The file has no columns."), + ImportError::TooManyFields { line, limit } => gettext_f( + "Line {line} has more than {limit} fields, more than any table holds. Check the delimiter.", + &[("line", &line.to_string()), ("limit", &limit.to_string())], + ), + } +} + +pub(super) fn summary_text(fields: usize, rows: usize, truncated: bool) -> String { + let fields = ngettext_f( + "{n} field", + "{n} fields", + fields, + &[("n", &format_thousands(fields as u64))], + ); + let rows = ngettext_f("{n} row", "{n} rows", rows, &[("n", &format_thousands(rows as u64))]); + let counts: [(&str, &str); 2] = [("fields", &fields), ("rows", &rows)]; + match truncated { + // Translators: {fields} is "3 fields" and {rows} is "50 rows", each already translated. + true => pgettext_f( + "CSV preview summary", + "{fields}. Showing the first {rows}; the whole file is imported.", + &counts, + ), + // Translators: {fields} is "3 fields" and {rows} is "2 rows", each already translated. + false => pgettext_f("CSV preview summary", "{fields}, {rows}.", &counts), + } +} + +// A file without a header names no field, so the mapping offers it by position. +pub(super) fn field_label(index: usize, header: &str) -> String { + match header.trim().is_empty() { + true => gettext_f("Column {n}", &[("n", &(index + 1).to_string())]), + false => header.to_owned(), + } +} + +pub(super) fn sample_text(sheet: &CsvSheet, field: usize, options: &CsvImportOptions) -> String { + let values: Vec = sheet + .rows + .iter() + .filter_map(|row| row.get(field)) + .take(SAMPLE_VALUES) + .map(|text| match text == &options.null_marker { + true => gettext("NULL"), + false => text.clone(), + }) + .collect(); + match values.is_empty() { + true => gettext("No values in the file"), + false => values.join(" · "), + } +} + +pub(super) fn qualified(schema: Option<&str>, table: &str) -> String { + match schema { + Some(schema) => format!("{schema}.{table}"), + None => table.to_owned(), + } +} diff --git a/linux/crates/app/src/ui/import_dialog/tests.rs b/linux/crates/app/src/ui/import_dialog/tests.rs new file mode 100644 index 0000000000..d7448f8a0f --- /dev/null +++ b/linux/crates/app/src/ui/import_dialog/tests.rs @@ -0,0 +1,433 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ColumnInfo; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::export::CsvDelimiter; +use tablepro_core::import::{CsvImportOptions, CsvSheet, ImportColumn, ImportError}; + +use super::preview_text::SAMPLE_VALUES; +use super::*; +use crate::runtime::AppRuntime; +use crate::test_support::{OwnedWindow, descendants, drain_main_context, drop_focus, test_runtime, wait_until}; + +const WAIT: Duration = Duration::from_secs(30); + +// Long enough for a replaced read's reply to come back after the current one. +const STRAGGLER: Duration = Duration::from_millis(200); + +const PEOPLE: &[u8] = b"name,id\nada,1\n"; + +fn sheet() -> CsvSheet { + CsvSheet { + headers: vec!["id".to_owned(), "name".to_owned()], + rows: vec![ + vec!["1".to_owned(), "ada".to_owned()], + vec!["2".to_owned(), String::new()], + vec!["3".to_owned(), "grace".to_owned()], + vec!["4".to_owned(), "alan".to_owned()], + ], + truncated: false, + } +} + +// The dialog reads only a column's name; the type matters once the import runs. +fn column(name: &str) -> ImportColumn { + ImportColumn { + info: ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::unknown(), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + partition_scoped: false, + default: ColumnDefault::None, + comment: None, + }, + server_filled: false, + } +} + +fn people() -> Vec { + vec![column("id"), column("name"), column("nickname")] +} + +struct Harness { + runtime: AppRuntime, +} + +impl Harness { + fn new() -> Self { + crate::register_test_resources(); + Self { + runtime: test_runtime(), + } + } + + fn open(&self, columns: Vec, csv: &[u8]) -> ImportCsvDialog { + self.open_bytes(columns, Arc::from(csv)) + } + + fn open_bytes(&self, columns: Vec, bytes: Arc<[u8]>) -> ImportCsvDialog { + ImportCsvDialog::new(ImportCsvDialogInit { + schema: Some("public".to_owned()), + table: "people".to_owned(), + columns, + bytes, + tasks: self.runtime.tasks(), + }) + } + + fn loaded(&self, columns: Vec, csv: &[u8]) -> ImportCsvDialog { + let dialog = self.open(columns, csv); + settle(&dialog); + dialog + } +} + +fn settle(dialog: &ImportCsvDialog) { + wait_until(WAIT, || !dialog.reading()).expect("the preview read to land"); +} + +fn summary(dialog: &ImportCsvDialog) -> String { + dialog.imp().summary.label().to_string() +} + +fn rows(dialog: &ImportCsvDialog) -> Vec { + dialog.imp().mapping_rows.borrow().clone() +} + +fn subtitles(dialog: &ImportCsvDialog) -> Vec { + rows(dialog) + .iter() + .map(|row| row.subtitle().unwrap_or_default().to_string()) + .collect() +} + +fn offered(row: &adw::ComboRow) -> Vec { + let Some(list) = row.model().and_downcast::() else { + return Vec::new(); + }; + (0..list.n_items()) + .filter_map(|index| list.string(index)) + .map(|text| text.to_string()) + .collect() +} + +#[test] +fn a_sample_shows_the_first_few_values_of_its_field() { + assert_eq!( + sample_text(&sheet(), 0, &CsvImportOptions::default()), + "1 · 2 · 3", + "a sample past {SAMPLE_VALUES} values would not fit a subtitle" + ); +} + +#[test] +fn a_sample_names_the_values_that_would_import_as_null() { + assert_eq!( + sample_text(&sheet(), 1, &CsvImportOptions::default()), + "ada · NULL · grace" + ); +} + +#[test] +fn a_field_the_file_never_fills_says_so_rather_than_showing_nothing() { + assert_eq!( + sample_text(&CsvSheet::default(), 0, &CsvImportOptions::default()), + "No values in the file" + ); +} + +#[test] +fn a_table_in_a_schema_is_named_with_it() { + assert_eq!(qualified(Some("public"), "orders"), "public.orders"); + assert_eq!(qualified(None, "orders"), "orders"); +} + +#[test] +fn a_file_the_csv_reader_refused_says_where_in_the_apps_words() { + assert_eq!( + import_error_text(&ImportError::Malformed { line: Some(12) }), + "The file couldn't be read as CSV at line 12." + ); + assert_eq!( + import_error_text(&ImportError::Malformed { line: None }), + "The file couldn't be read as CSV." + ); +} + +#[test] +fn a_field_with_no_name_is_offered_by_its_position() { + assert_eq!(field_label(0, ""), "Column 1"); + assert_eq!(field_label(2, " "), "Column 3"); + assert_eq!(field_label(1, "city"), "city"); +} + +#[test] +fn the_summary_counts_fields_and_rows_each_in_its_own_plural() { + assert_eq!(summary_text(1, 1, false), "1 field, 1 row."); + assert_eq!(summary_text(2, 2, false), "2 fields, 2 rows."); + assert_eq!(summary_text(1, 2, false), "1 field, 2 rows."); +} + +#[test] +fn a_truncated_preview_says_the_whole_file_is_imported() { + assert_eq!( + summary_text(2, 50, true), + "2 fields. Showing the first 50 rows; the whole file is imported." + ); +} + +#[gtk4::test] +fn the_file_is_read_off_the_gtk_thread_and_import_waits_for_it() { + let harness = Harness::new(); + let dialog = harness.open(people(), PEOPLE); + let imported = Rc::new(Cell::new(false)); + let flag = imported.clone(); + dialog.connect_import(move |_, _| flag.set(true)); + + // Nothing has run the main context yet, so a read on the GTK thread would already have landed. + assert_eq!(summary(&dialog), "Reading the file…"); + assert!(dialog.imp().summary.has_css_class("dim-label")); + assert!(!dialog.imp().import_button.is_sensitive()); + assert!(rows(&dialog).is_empty()); + dialog.imp().import_button.emit_clicked(); + assert!(!imported.get(), "an import went out before the file was read"); + + settle(&dialog); + + assert_eq!(dialog.mapping(), vec![Some(1), Some(0), None]); + assert!(dialog.imp().import_button.is_sensitive()); + assert_eq!(summary(&dialog), "2 fields, 1 row."); +} + +#[gtk4::test] +fn only_the_last_of_two_header_changes_is_shown() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + let shown = Rc::new(RefCell::new(Vec::new())); + let log = shown.clone(); + dialog + .imp() + .summary + .connect_label_notify(move |label| log.borrow_mut().push(label.label().to_string())); + + dialog.imp().header_row.set_active(false); + dialog.imp().header_row.set_active(true); + settle(&dialog); + std::thread::sleep(STRAGGLER); + drain_main_context(); + + assert_eq!(dialog.mapping(), vec![Some(1), Some(0), None]); + let shown = shown.borrow(); + assert_eq!(shown.last().map(String::as_str), Some("2 fields, 1 row.")); + assert!( + shown + .iter() + .all(|text| text == "Reading the file…" || text == "2 fields, 1 row."), + "the read the second change replaced was shown: {shown:?}" + ); +} + +#[gtk4::test] +fn a_dialog_closed_during_its_read_is_freed_and_fills_nothing() { + let harness = Harness::new(); + let window = OwnedWindow::presented(adw::Window::new()); + let dialog = harness.open(people(), PEOPLE); + dialog.present(Some(&*window)); + assert!(dialog.reading()); + let group = dialog.imp().mapping_group.get(); + let weak = dialog.downgrade(); + + drop_focus(&*window); + dialog.close(); + drop(dialog); + wait_until(WAIT, || weak.upgrade().is_none()).expect("the closed dialog to be freed"); + std::thread::sleep(STRAGGLER); + drain_main_context(); + + assert!(weak.upgrade().is_none(), "the read kept its dialog alive"); + assert!( + !descendants(&group).iter().any(|widget| widget.is::()), + "a read filled the mapping after its dialog closed" + ); +} + +#[gtk4::test] +fn import_hands_over_the_mapping_and_options_and_closes() { + let harness = Harness::new(); + let window = OwnedWindow::presented(adw::Window::new()); + let bytes: Arc<[u8]> = Arc::from(PEOPLE); + let dialog = harness.open_bytes(people(), bytes.clone()); + dialog.present(Some(&*window)); + settle(&dialog); + let received = Rc::new(RefCell::new(None)); + let slot = received.clone(); + dialog.connect_import(move |_, import| { + slot.replace(Some(import)); + }); + let closed = Rc::new(Cell::new(false)); + let flag = closed.clone(); + dialog.connect_closed(move |_| flag.set(true)); + + dialog.imp().null_row.set_text("-"); + dialog.imp().import_button.emit_clicked(); + + wait_until(WAIT, || closed.get()).expect("the dialog to close"); + let import = received.take().expect("the import"); + assert_eq!(import.schema.as_deref(), Some("public")); + assert_eq!(import.table, "people"); + assert_eq!(import.mapping, vec![Some(1), Some(0), None]); + assert_eq!( + import.options, + CsvImportOptions { + delimiter: CsvDelimiter::Comma, + has_header: true, + null_marker: "-".to_owned(), + } + ); + assert!(Arc::ptr_eq(&import.bytes, &bytes), "the file was copied"); +} + +#[gtk4::test] +fn a_null_marker_change_redraws_the_samples_without_reading_again() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + + dialog.imp().null_row.set_text("ada"); + + assert!(!dialog.reading(), "the file was read again for a NULL marker"); + assert_eq!(subtitles(&dialog)[1], "NULL"); +} + +#[gtk4::test] +fn names_and_values_with_markup_characters_show_as_written() { + let harness = Harness::new(); + let dialog = harness.loaded(vec![column("R&D ")], b"R&D \nAT&T \n"); + + let rows = rows(&dialog); + let row = rows.first().expect("a mapping row"); + + assert!(!row.uses_markup()); + assert_eq!(row.title(), "R&D "); + assert_eq!(row.subtitle().as_deref(), Some("AT&T ")); +} + +#[gtk4::test] +fn a_column_named_in_the_file_starts_mapped_to_its_field() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + + // id is the file's second field, name its first, and nothing in the file is called nickname. + assert_eq!(dialog.mapping(), vec![Some(1), Some(0), None]); +} + +#[gtk4::test] +fn an_unmapped_column_says_it_keeps_its_default() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + + let subtitles = subtitles(&dialog); + + assert_eq!(subtitles[0], "1"); + assert_eq!(subtitles[1], "ada"); + assert_eq!(subtitles[2], "Uses the column's own default"); +} + +#[gtk4::test] +fn turning_the_header_switch_off_remaps_against_positional_names() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + assert_eq!(dialog.mapping(), vec![Some(1), Some(0), None]); + + dialog.imp().header_row.set_active(false); + settle(&dialog); + + // Fields with no name match no table column, so every row falls back to Skip and the header line reads as data. + assert_eq!(dialog.mapping(), vec![None, None, None]); + assert_eq!(offered(&rows(&dialog)[0]), vec!["Skip", "Column 1", "Column 2"]); +} + +#[gtk4::test] +fn a_column_the_server_fills_cannot_take_a_field() { + let harness = Harness::new(); + let mut id = column("id"); + id.server_filled = true; + let dialog = harness.loaded(vec![id, column("name")], b"id,name\n1,ada\n"); + + let rows = rows(&dialog); + + assert_eq!(dialog.mapping(), vec![None, Some(1)]); + assert!( + !rows[0].is_sensitive(), + "a field could be picked for a column the server fills" + ); + assert_eq!( + rows[0].subtitle().unwrap_or_default().as_str(), + "The database fills this column" + ); +} + +#[gtk4::test] +fn a_file_that_is_not_csv_leaves_the_import_button_off_and_says_why_in_the_dialog() { + let harness = Harness::new(); + let dialog = harness.loaded(vec![column("id")], b"id\n\xff\n"); + + assert!(!dialog.imp().import_button.is_sensitive()); + assert_eq!( + summary(&dialog), + "The file isn't UTF-8 text from byte 3 on. Save it as UTF-8 and import it again." + ); + assert!(dialog.imp().summary.has_css_class("error")); +} + +#[gtk4::test] +fn every_column_set_to_skip_turns_the_import_button_off() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), PEOPLE); + assert!(dialog.imp().import_button.is_sensitive()); + + for row in rows(&dialog) { + row.set_selected(0); + } + + assert!(!dialog.imp().import_button.is_sensitive()); + assert_eq!( + summary(&dialog), + "Every column is set to Skip. Pick a field for at least one column to import." + ); +} + +#[gtk4::test] +fn a_file_with_only_a_header_has_nothing_to_import() { + let harness = Harness::new(); + let dialog = harness.loaded(people(), b"name,id\n"); + + assert!(!dialog.imp().import_button.is_sensitive()); + assert_eq!(summary(&dialog), "The file has no rows to import."); +} + +#[gtk4::test] +fn a_long_file_shows_its_first_rows_and_says_the_rest_import_too() { + let harness = Harness::new(); + let csv: String = std::iter::once("name,id\n".to_owned()) + .chain((0..=MAX_PREVIEW_ROWS).map(|index| format!("person {index},{index}\n"))) + .collect(); + + let dialog = harness.loaded(people(), csv.as_bytes()); + + assert_eq!( + summary(&dialog), + format!("2 fields. Showing the first {MAX_PREVIEW_ROWS} rows; the whole file is imported.") + ); + assert!(dialog.imp().import_button.is_sensitive()); +} diff --git a/linux/crates/app/src/ui/import_from_app/mod.rs b/linux/crates/app/src/ui/import_from_app/mod.rs index d302e1e640..1aeeced093 100644 --- a/linux/crates/app/src/ui/import_from_app/mod.rs +++ b/linux/crates/app/src/ui/import_from_app/mod.rs @@ -616,7 +616,7 @@ impl ImportFromAppDialog { let mut summary = ngettext_f( "{selected} of {n} connection selected", "{selected} of {n} connections selected", - u32::try_from(connections.len()).unwrap_or(u32::MAX), + connections.len(), &[ ("selected", &selected.to_string()), ("n", &connections.len().to_string()), @@ -632,7 +632,7 @@ impl ImportFromAppDialog { summary.push_str(&ngettext_f( "{included} of {n} saved query selected", "{included} of {n} saved queries selected", - u32::try_from(reviewing.queries.len()).unwrap_or(u32::MAX), + reviewing.queries.len(), &[ ("included", &included.to_string()), ("n", &reviewing.queries.len().to_string()), diff --git a/linux/crates/app/src/ui/import_from_app/wording.rs b/linux/crates/app/src/ui/import_from_app/wording.rs index 50a393725c..ceff5718bb 100644 --- a/linux/crates/app/src/ui/import_from_app/wording.rs +++ b/linux/crates/app/src/ui/import_from_app/wording.rs @@ -25,7 +25,7 @@ pub(super) fn source_subtitle(app: ForeignApp, available: bool, inventory: Inven let connections = ngettext_f( "{n} connection found", "{n} connections found", - count(inventory.connections), + inventory.connections, &[("n", &inventory.connections.to_string())], ); if inventory.saved_queries == 0 { @@ -34,7 +34,7 @@ pub(super) fn source_subtitle(app: ForeignApp, available: bool, inventory: Inven let queries = ngettext_f( "{n} saved query", "{n} saved queries", - count(inventory.saved_queries), + inventory.saved_queries, &[("n", &inventory.saved_queries.to_string())], ); format!("{connections} · {queries}") @@ -139,10 +139,6 @@ pub(super) fn resolution_title(resolution: Resolution) -> String { } } -fn count(n: usize) -> u32 { - u32::try_from(n).unwrap_or(u32::MAX) -} - // Where the connection goes, where it lands, what it matches and what it loses, one line each. pub(super) fn connection_subtitle(row: &ConnectionRow, drivers: &DriverRegistry) -> String { let mut lines = Vec::new(); diff --git a/linux/crates/app/src/ui/import_preview.rs b/linux/crates/app/src/ui/import_preview.rs index e7735e7160..74e46f2af8 100644 --- a/linux/crates/app/src/ui/import_preview.rs +++ b/linux/crates/app/src/ui/import_preview.rs @@ -24,7 +24,7 @@ pub(crate) fn import_preview_dialog(preview: &ImportPreview, drivers: &DriverReg Some(&crate::i18n::ngettext_f( "Import {n} Connection?", "Import {n} Connections?", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], )), Some(&crate::i18n::gettext( diff --git a/linux/crates/app/src/ui/key_tab/key_summary.rs b/linux/crates/app/src/ui/key_tab/key_summary.rs index 67d3d357a8..06c03cab85 100644 --- a/linux/crates/app/src/ui/key_tab/key_summary.rs +++ b/linux/crates/app/src/ui/key_tab/key_summary.rs @@ -4,7 +4,7 @@ use tablepro_core::keyspace::KeyTtl; use tablepro_core::meta::KeyKind; use crate::i18n::{ngettext_f, pgettext}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; /// The type of value a key holds, in Redis's documentation's words. pub(crate) fn key_kind_name(kind: KeyKind) -> String { @@ -22,15 +22,14 @@ pub(crate) fn key_kind_name(kind: KeyKind) -> String { /// How many entries the key holds, in the words its type uses: a /// string's bytes, a hash's fields, a list's elements. pub(super) fn length_text(kind: KeyKind, length: u64) -> Option { - let count = plural_count(length); let n = format_thousands(length); let placeholders = [("n", n.as_str())]; Some(match kind { - KeyKind::String => ngettext_f("{n} byte", "{n} bytes", count, &placeholders), - KeyKind::Hash => ngettext_f("{n} field", "{n} fields", count, &placeholders), - KeyKind::List => ngettext_f("{n} element", "{n} elements", count, &placeholders), - KeyKind::Set | KeyKind::SortedSet => ngettext_f("{n} member", "{n} members", count, &placeholders), - KeyKind::Stream => ngettext_f("{n} entry", "{n} entries", count, &placeholders), + KeyKind::String => ngettext_f("{n} byte", "{n} bytes", length, &placeholders), + KeyKind::Hash => ngettext_f("{n} field", "{n} fields", length, &placeholders), + KeyKind::List => ngettext_f("{n} element", "{n} elements", length, &placeholders), + KeyKind::Set | KeyKind::SortedSet => ngettext_f("{n} member", "{n} members", length, &placeholders), + KeyKind::Stream => ngettext_f("{n} entry", "{n} entries", length, &placeholders), KeyKind::Other => return None, }) } @@ -46,7 +45,6 @@ pub(super) fn ttl_text(ttl: KeyTtl) -> Option { let (count, unit) = largest_unit(left); let n = count.to_string(); let placeholders = [("n", n.as_str())]; - let count = plural_count(count); Some(match unit { Unit::Seconds => ngettext_f("Expires in {n} second", "Expires in {n} seconds", count, &placeholders), Unit::Minutes => ngettext_f("Expires in {n} minute", "Expires in {n} minutes", count, &placeholders), @@ -81,15 +79,10 @@ pub(super) fn shown_text(shown: u64, length: Option) -> String { Some(length) if length > shown => ngettext_f( "{shown} of {n} shown", "{shown} of {n} shown", - plural_count(length), + length, &[("shown", &format_thousands(shown)), ("n", &format_thousands(length))], ), - _ => ngettext_f( - "{n} shown", - "{n} shown", - plural_count(shown), - &[("n", &format_thousands(shown))], - ), + _ => ngettext_f("{n} shown", "{n} shown", shown, &[("n", &format_thousands(shown))]), } } diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 8140ea2bb8..957227b4c2 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -53,6 +53,7 @@ mod style_scheme_grid; mod style_scheme_resolver; mod tab_load; mod tags; +pub(crate) mod text_view_teardown; mod welcome_view; mod window_geometry; mod workspace; diff --git a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs index c54fa333d2..9fd59b7397 100644 --- a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs +++ b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs @@ -44,7 +44,7 @@ mod imp { self.confirm_row.set_subtitle(&crate::i18n::ngettext_f( "Ask before deleting {n} or more rows at once", "Ask before deleting {n} or more rows at once", - u32::try_from(threshold).unwrap_or(u32::MAX), + threshold, &[("n", &threshold.to_string())], )); } diff --git a/linux/crates/app/src/ui/preferences_dialog/history_group.rs b/linux/crates/app/src/ui/preferences_dialog/history_group.rs index 259b706380..e4e3c24583 100644 --- a/linux/crates/app/src/ui/preferences_dialog/history_group.rs +++ b/linux/crates/app/src/ui/preferences_dialog/history_group.rs @@ -213,7 +213,7 @@ impl HistoryGroup { Ok(Ok(removed)) => crate::i18n::ngettext_f( "{count} query cleared", "{count} queries cleared", - removed as u32, + removed, &[("count", &removed.to_string())], ), Ok(Err(error)) => { diff --git a/linux/crates/app/src/ui/result_pane/mod.rs b/linux/crates/app/src/ui/result_pane/mod.rs index 29f1890395..fce8ebd947 100644 --- a/linux/crates/app/src/ui/result_pane/mod.rs +++ b/linux/crates/app/src/ui/result_pane/mod.rs @@ -20,7 +20,7 @@ use crate::i18n::ngettext_f; use crate::services::grid::grid_columns; use crate::ui::data_grid::{DataGrid, DialectSource, GridConfig, GridSorting, RowData}; use crate::ui::export::ExportSource; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; /// What a result grid reads from the tab that shows it. #[derive(Clone)] @@ -81,7 +81,7 @@ fn row_count_footer(shown: u64) -> gtk::Label { .label(ngettext_f( "{n} row", "{n} rows", - plural_count(shown), + shown, &[("n", &format_thousands(shown))], )) .xalign(0.0) diff --git a/linux/crates/app/src/ui/result_pane/result_summary.rs b/linux/crates/app/src/ui/result_pane/result_summary.rs index 92b79cea66..e2cb90afc4 100644 --- a/linux/crates/app/src/ui/result_pane/result_summary.rs +++ b/linux/crates/app/src/ui/result_pane/result_summary.rs @@ -1,7 +1,7 @@ use tablepro_core::Truncation; use crate::i18n::{gettext_f, ngettext_f}; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; /// How much of a result set an editor run kept, as the driver reported /// it when the set ended. @@ -50,7 +50,7 @@ impl ResultSummary { retained => ngettext_f( "Showing the first {shown} row; a run keeps at most {limit} rows across its results", "Showing the first {shown} rows; a run keeps at most {limit} rows across its results", - plural_count(retained), + retained, &[("shown", &shown), ("limit", &limit)], ), }); @@ -60,13 +60,13 @@ impl ResultSummary { (Truncation::ReadPastLimit, Some(total)) => Some(ngettext_f( "Showing {shown} of {total} row", "Showing {shown} of {total} rows", - plural_count(total), + total, &[("shown", &shown), ("total", &format(total))], )), (Truncation::ReadPastLimit, None) => Some(ngettext_f( "Showing the first {shown} row", "Showing the first {shown} rows", - plural_count(self.retained), + self.retained, &[("shown", &shown)], )), // A SELECT that calls a function per row did that for these @@ -74,7 +74,7 @@ impl ResultSummary { (Truncation::ServerStopped, _) => Some(ngettext_f( "Stopped after the first {shown} row: the statement did not run past it", "Stopped after the first {shown} rows: the statement did not run past them", - plural_count(self.retained), + self.retained, &[("shown", &shown)], )), } diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs deleted file mode 100644 index bbf3e74ee6..0000000000 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ /dev/null @@ -1,847 +0,0 @@ -use relm4::adw::prelude::*; -use relm4::gtk::gio; -use relm4::prelude::*; -use relm4::{adw, gtk}; - -use tablepro_storage::SavedQueries; -use tablepro_storage::saved_queries::SavedQuery; -use uuid::Uuid; - -use crate::runtime::DeliverInput; - -/// The user's named queries, grouped by the connection each was saved -/// against. -/// -/// The active connection comes first, because that is the one whose -/// tables the user is looking at. The rest stay listed rather than -/// hidden: a query saved against a connection is still worth reading -/// when connected elsewhere. -pub struct SavedQueriesDialog { - store: Option, - active_connection: Option, - root: adw::Dialog, - /// The dialog's own, since it covers the window's. - toasts: adw::ToastOverlay, - search: gtk::SearchEntry, - groups: gtk::Box, - stack: gtk::Stack, - status_page: adw::StatusPage, - error_page: adw::StatusPage, - queries: Vec, - /// Whether a list was read once. A read that fails after that keeps - /// the list on screen. - loaded: bool, -} - -pub struct SavedQueriesDialogInit { - pub store: Option, - pub active_connection: Option, -} - -#[derive(Debug)] -pub enum SavedQueriesDialogInput { - Refresh, - SearchChanged, - Activate(i64), - ReplaceCurrent(i64), - CopySql(i64), - RenameRequested(i64), - Rename(i64, String), - DeleteRequested(i64), - Delete(i64), -} - -#[derive(Debug)] -pub enum SavedQueriesDialogOutput { - OpenInNewTab(String), - ReplaceCurrentTabQuery(String), -} - -#[derive(Debug)] -pub enum SavedQueriesDialogCmd { - Loaded(Vec), - /// The list could not be read, and why. - LoadFailed(String), - Failed(String), - Changed, -} - -impl Component for SavedQueriesDialog { - type Init = SavedQueriesDialogInit; - type Input = SavedQueriesDialogInput; - type Output = SavedQueriesDialogOutput; - type CommandOutput = SavedQueriesDialogCmd; - type Root = adw::Dialog; - type Widgets = (); - - fn init_root() -> Self::Root { - adw::Dialog::builder() - .title(crate::i18n::gettext("Saved Queries")) - .content_width(560) - .content_height(600) - .build() - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let toolbar = adw::ToolbarView::new(); - let header = adw::HeaderBar::builder().show_end_title_buttons(true).build(); - header.set_title_widget(Some(&adw::WindowTitle::new(&crate::i18n::gettext("Saved Queries"), ""))); - toolbar.add_top_bar(&header); - - let search_label = crate::i18n::gettext("Search saved queries"); - let search = gtk::SearchEntry::builder() - .placeholder_text(&search_label) - .hexpand(true) - .build(); - // A placeholder is gone once text is typed, so it names nothing. - search.update_property(&[gtk::accessible::Property::Label(&search_label)]); - let search_bar = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .margin_start(12) - .margin_end(12) - .margin_top(6) - .margin_bottom(6) - .build(); - search_bar.append(&search); - toolbar.add_top_bar(&search_bar); - let sender_for_search = sender.clone(); - search.connect_search_changed(move |_| sender_for_search.deliver(SavedQueriesDialogInput::SearchChanged)); - - let groups = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(18) - .margin_start(12) - .margin_end(12) - .margin_top(12) - .margin_bottom(12) - .build(); - let scroller = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .child(&groups) - .build(); - - let status_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::DOCUMENT_OPEN_RECENT) - .title(crate::i18n::gettext("No Saved Queries")) - .description(crate::i18n::gettext( - "Save a query from the editor and it will be listed here.", - )) - .build(); - - let retry = gtk::Button::builder() - .label(crate::i18n::gettext("Try Again")) - .halign(gtk::Align::Center) - .build(); - retry.add_css_class("pill"); - let sender_for_retry = sender.clone(); - retry.connect_clicked(move |_| sender_for_retry.deliver(SavedQueriesDialogInput::Refresh)); - let error_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::DIALOG_ERROR) - .title(crate::i18n::gettext("Could Not Read Saved Queries")) - .child(&retry) - .build(); - - let stack = gtk::Stack::new(); - stack.add_named(&scroller, Some("list")); - stack.add_named(&status_page, Some("empty")); - stack.add_named(&error_page, Some("error")); - stack.set_visible_child_name("empty"); - toolbar.set_content(Some(&stack)); - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&toolbar)); - root.set_child(Some(&toasts)); - - let model = SavedQueriesDialog { - store: init.store, - active_connection: init.active_connection, - root: root.clone(), - toasts, - search, - groups, - stack, - status_page, - error_page, - queries: Vec::new(), - loaded: false, - }; - sender.deliver(SavedQueriesDialogInput::Refresh); - - ComponentParts { model, widgets: () } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { - match msg { - SavedQueriesDialogInput::Refresh => self.load(&sender), - SavedQueriesDialogInput::SearchChanged => self.render(&sender), - SavedQueriesDialogInput::Activate(id) => { - if let Some(query) = self.query_for(id) { - let _ = sender.output(SavedQueriesDialogOutput::OpenInNewTab(query)); - self.root.close(); - } - } - SavedQueriesDialogInput::ReplaceCurrent(id) => { - if let Some(query) = self.query_for(id) { - let _ = sender.output(SavedQueriesDialogOutput::ReplaceCurrentTabQuery(query)); - self.root.close(); - } - } - SavedQueriesDialogInput::CopySql(id) => { - if let Some(query) = self.query_for(id) { - crate::ui::clipboard::copy_text(&self.root, query, |message| self.toast(message)); - } - } - SavedQueriesDialogInput::RenameRequested(id) => self.prompt_rename(id, &sender), - SavedQueriesDialogInput::Rename(id, name) => { - let Some(store) = self.store.clone() else { return }; - sender.oneshot_command(async move { - match store.rename(id, &name).await { - Ok(()) => SavedQueriesDialogCmd::Changed, - Err(error) => { - tracing::warn!(%error, "could not rename the saved query"); - SavedQueriesDialogCmd::Failed(rename_failure_text(&error)) - } - } - }); - } - SavedQueriesDialogInput::DeleteRequested(id) => self.confirm_delete(id, &sender), - SavedQueriesDialogInput::Delete(id) => { - let Some(store) = self.store.clone() else { return }; - sender.oneshot_command(async move { - match store.delete(id).await { - Ok(()) => SavedQueriesDialogCmd::Changed, - Err(error) => { - tracing::warn!(%error, "could not delete the saved query"); - SavedQueriesDialogCmd::Failed(crate::i18n::gettext("Could not delete that saved query.")) - } - } - }); - } - } - } - - fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender, _root: &Self::Root) { - match msg { - SavedQueriesDialogCmd::Loaded(queries) => { - self.queries = queries; - self.loaded = true; - self.render(&sender); - } - SavedQueriesDialogCmd::LoadFailed(reason) if self.loaded => self.toast(&reason), - SavedQueriesDialogCmd::LoadFailed(reason) => { - self.error_page.set_description(Some(&reason)); - self.stack.set_visible_child_name("error"); - } - SavedQueriesDialogCmd::Changed => sender.deliver(SavedQueriesDialogInput::Refresh), - SavedQueriesDialogCmd::Failed(message) => { - self.toast(&message); - sender.deliver(SavedQueriesDialogInput::Refresh); - } - } - } -} - -impl SavedQueriesDialog { - pub fn dialog(&self) -> &adw::Dialog { - &self.root - } - - fn toast(&self, message: &str) { - self.toasts.add_toast(crate::ui::plain_toast(message)); - } - - fn load(&self, sender: &ComponentSender) { - let Some(store) = self.store.clone() else { - return; - }; - sender.oneshot_command(async move { - match store.list(None).await { - Ok(queries) => SavedQueriesDialogCmd::Loaded(queries), - Err(error) => { - tracing::warn!(%error, "could not read the saved queries"); - SavedQueriesDialogCmd::LoadFailed(crate::i18n::gettext( - "The query history file could not be read. It may be damaged.", - )) - } - } - }); - } - - fn query_for(&self, id: i64) -> Option { - self.queries - .iter() - .find(|saved| saved.id == id) - .map(|saved| saved.query.clone()) - } - - fn name_for(&self, id: i64) -> Option { - self.queries - .iter() - .find(|saved| saved.id == id) - .map(|saved| saved.name.clone()) - } - - fn render(&self, sender: &ComponentSender) { - while let Some(child) = self.groups.first_child() { - self.groups.remove(&child); - } - - let groups = grouped(&self.queries, &self.search.text(), self.active_connection); - if groups.is_empty() { - let searching = !self.search.text().to_string().trim().is_empty(); - if searching { - self.status_page.set_title(&crate::i18n::gettext("No Matches")); - self.status_page - .set_description(Some(&crate::i18n::gettext("Try a different search term."))); - self.status_page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); - } else { - self.status_page.set_title(&crate::i18n::gettext("No Saved Queries")); - self.status_page.set_description(Some(&crate::i18n::gettext( - "Save a query from the editor and it will be listed here.", - ))); - self.status_page - .set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); - } - self.stack.set_visible_child_name("empty"); - return; - } - self.stack.set_visible_child_name("list"); - - for (connection_name, rows) in groups { - let group = adw::PreferencesGroup::builder().title(&connection_name).build(); - for saved in rows { - group.add(&self.build_row(saved, sender.clone())); - } - self.groups.append(&group); - } - } - - fn build_row(&self, saved: &SavedQuery, sender: ComponentSender) -> adw::ActionRow { - let row = adw::ActionRow::builder() - .title(glib::markup_escape_text(&saved.name)) - .subtitle(glib::markup_escape_text(saved.summary())) - .activatable(true) - .build(); - row.set_subtitle_lines(1); - - let id = saved.id; - let sender_for_activate = sender.clone(); - row.connect_activated(move |_| sender_for_activate.deliver(SavedQueriesDialogInput::Activate(id))); - - let menu = row_menu(); - - let actions = gio::SimpleActionGroup::new(); - let entry = |name: &str, message: fn(i64) -> SavedQueriesDialogInput, sender: ComponentSender| { - gio::ActionEntry::::builder(name) - .activate(move |_, _, _| sender.deliver(message(id))) - .build() - }; - actions.add_action_entries([ - entry("open", SavedQueriesDialogInput::Activate, sender.clone()), - entry("replace", SavedQueriesDialogInput::ReplaceCurrent, sender.clone()), - entry("copy", SavedQueriesDialogInput::CopySql, sender.clone()), - entry("rename", SavedQueriesDialogInput::RenameRequested, sender.clone()), - entry("delete", SavedQueriesDialogInput::DeleteRequested, sender), - ]); - - let button = gtk::MenuButton::builder() - .icon_name(crate::ui::icons::VIEW_MORE) - .valign(gtk::Align::Center) - .menu_model(&menu) - .build(); - button.add_css_class("flat"); - crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Saved Query Options"), None); - button.insert_action_group("saved", Some(&actions)); - row.add_suffix(&button); - - row - } - - fn prompt_rename(&self, id: i64, sender: &ComponentSender) { - let Some(current) = self.name_for(id) else { - return; - }; - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Rename Saved Query")), None); - let entry = adw::EntryRow::builder() - .title(crate::i18n::gettext("Name")) - .activates_default(true) - .build(); - entry.set_text(¤t); - let group = adw::PreferencesGroup::new(); - group.add(&entry); - dialog.set_extra_child(Some(&group)); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("rename", &crate::i18n::gettext("Rename")); - dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("rename")); - dialog.set_close_response("cancel"); - crate::ui::name_entry::require_name(&dialog, "rename", &entry); - - let sender = sender.clone(); - dialog.connect_response(None, move |_, response| { - if response != "rename" { - return; - } - let name = entry.text().to_string(); - if name.trim().is_empty() { - return; - } - sender.deliver(SavedQueriesDialogInput::Rename(id, name)); - }); - dialog.present(Some(&self.root)); - } - - fn confirm_delete(&self, id: i64, sender: &ComponentSender) { - let Some(name) = self.name_for(id) else { - return; - }; - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext_f("Delete “{name}”?", &[("name", &name)])), - Some(&crate::i18n::gettext( - "The saved query is removed. The SQL itself is not run or changed.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("delete", &crate::i18n::gettext("Delete")); - dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - - let sender = sender.clone(); - dialog.connect_response(None, move |_, response| { - if response == "delete" { - sender.deliver(SavedQueriesDialogInput::Delete(id)); - } - }); - dialog.present(Some(&self.root)); - } -} - -/// What a failed rename tells the user. A taken name is the one the -/// user can fix from the dialog. -fn rename_failure_text(error: &tablepro_storage::StorageError) -> String { - use tablepro_storage::StorageError; - match error { - StorageError::NameTaken => crate::i18n::gettext("That name is already used by another saved query."), - StorageError::TooLarge { .. } => crate::i18n::gettext("That name is too long."), - StorageError::NotFound => crate::i18n::gettext("That saved query was deleted elsewhere."), - _ => crate::i18n::gettext("The saved query could not be renamed."), - } -} - -/// Everything matching the search, grouped by connection, with the -/// active connection's group first and the rest in the order the store -/// returned them. -fn grouped<'a>(queries: &'a [SavedQuery], needle: &str, active: Option) -> Vec<(String, Vec<&'a SavedQuery>)> { - let needle = needle.to_lowercase(); - let mut groups: Vec<(String, Vec<&SavedQuery>)> = Vec::new(); - for saved in queries.iter().filter(|saved| matches(saved, &needle)) { - match groups.iter_mut().find(|(_, rows)| { - rows.first() - .is_some_and(|first| first.connection_id == saved.connection_id) - }) { - Some((_, rows)) => rows.push(saved), - None => groups.push((saved.connection_name.clone(), vec![saved])), - } - } - if let Some(active) = active { - // A stable sort, so everything but the active connection keeps - // the order the store gave it. - groups.sort_by_key(|(_, rows)| !rows.first().is_some_and(|first| first.connection_id == active)); - } - groups -} - -/// A saved query matches when the needle is in its name, its SQL or the -/// connection it was saved against, so searching for the connection -/// narrows to it without a filter control. -fn matches(saved: &SavedQuery, needle: &str) -> bool { - if needle.trim().is_empty() { - return true; - } - let needle = needle.trim(); - saved.name.to_lowercase().contains(needle) - || saved.query.to_lowercase().contains(needle) - || saved.connection_name.to_lowercase().contains(needle) -} - -fn row_menu() -> gio::Menu { - let menu = gio::Menu::new(); - let open_section = gio::Menu::new(); - open_section.append(Some(&crate::i18n::gettext("_Open in New Tab")), Some("saved.open")); - open_section.append( - Some(&crate::i18n::gettext("_Replace Current Tab")), - Some("saved.replace"), - ); - open_section.append(Some(&crate::i18n::gettext("_Copy SQL")), Some("saved.copy")); - menu.append_section(None, &open_section); - let edit_section = gio::Menu::new(); - edit_section.append(Some(&crate::i18n::gettext("Re_name…")), Some("saved.rename")); - menu.append_section(None, &edit_section); - let danger_section = gio::Menu::new(); - danger_section.append(Some(&crate::i18n::gettext("_Delete")), Some("saved.delete")); - menu.append_section(None, &danger_section); - menu -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::SystemTime; - - fn saved(name: &str, query: &str, connection: &str) -> SavedQuery { - on_connection(name, query, connection, Uuid::nil()) - } - - fn on_connection(name: &str, query: &str, connection: &str, connection_id: Uuid) -> SavedQuery { - SavedQuery { - id: 1, - name: name.to_owned(), - query: query.to_owned(), - connection_id, - connection_name: connection.to_owned(), - created_at: SystemTime::UNIX_EPOCH, - updated_at: SystemTime::UNIX_EPOCH, - } - } - - fn group_names(groups: &[(String, Vec<&SavedQuery>)]) -> Vec { - groups.iter().map(|(name, _)| name.clone()).collect() - } - - #[test] - fn queries_are_grouped_by_the_connection_they_were_saved_against() { - let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); - let queries = vec![ - on_connection("a", "SELECT 1", "local", local), - on_connection("b", "SELECT 2", "staging", staging), - on_connection("c", "SELECT 3", "local", local), - ]; - - let groups = grouped(&queries, "", None); - - assert_eq!(group_names(&groups), vec!["local", "staging"]); - assert_eq!(groups[0].1.len(), 2); - assert_eq!(groups[1].1.len(), 1); - } - - #[test] - fn the_connection_the_user_is_on_is_listed_first() { - let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); - let queries = vec![ - on_connection("a", "SELECT 1", "local", local), - on_connection("b", "SELECT 2", "staging", staging), - ]; - - let groups = grouped(&queries, "", Some(staging)); - - assert_eq!(group_names(&groups), vec!["staging", "local"]); - } - - #[test] - fn two_connections_sharing_a_name_stay_apart() { - // Duplicating a connection and renaming neither is enough for - // this: the id is what says they are different. - let (first, second) = (Uuid::new_v4(), Uuid::new_v4()); - let queries = vec![ - on_connection("a", "SELECT 1", "local", first), - on_connection("b", "SELECT 2", "local", second), - ]; - - let groups = grouped(&queries, "", None); - - assert_eq!(groups.len(), 2); - } - - #[test] - fn a_search_that_matches_nothing_leaves_no_groups() { - let queries = vec![saved("daily", "SELECT 1", "local")]; - - assert!(grouped(&queries, "invoices", None).is_empty()); - } - - #[test] - fn an_empty_search_matches_everything() { - assert!(matches(&saved("daily", "SELECT 1", "local"), "")); - assert!(matches(&saved("daily", "SELECT 1", "local"), " ")); - } - - #[test] - fn a_search_reads_the_name_the_sql_and_the_connection() { - let query = saved("daily report", "SELECT * FROM orders", "staging"); - - assert!(matches(&query, "report")); - assert!(matches(&query, "orders")); - assert!(matches(&query, "staging")); - assert!(!matches(&query, "invoices")); - } - - #[test] - fn every_row_menu_item_has_an_access_key_of_its_own() { - crate::test_support::assert_access_keys(&row_menu()); - } - - #[test] - fn a_search_ignores_case_and_surrounding_space() { - assert!(matches(&saved("Daily", "SELECT 1", "local"), " daily ")); - } -} - -#[cfg(test)] -mod dialog_tests { - use super::*; - use std::time::Duration; - - use tablepro_storage::QueryHistory; - use tablepro_storage::saved_queries::NewSavedQuery; - - const RENDER_WAIT: Duration = Duration::from_secs(5); - - /// A store on a temporary database, seeded with one query per - /// connection. The directory is returned so it outlives the store. - fn seeded() -> (tempfile::TempDir, SavedQueries, Uuid, Uuid) { - let root = tempfile::tempdir().expect("tempdir"); - let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); - let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); - let store = relm4::tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime") - .block_on(async { - let store = QueryHistory::open(&paths).await.expect("open").saved_queries(); - for (name, sql, id, connection) in [ - ("daily orders", "SELECT * FROM orders", local, "local"), - ("stale carts", "SELECT * FROM carts", staging, "staging"), - ] { - store - .save(NewSavedQuery { - name: name.to_owned(), - query: sql.to_owned(), - connection_id: id, - connection_name: connection.to_owned(), - }) - .await - .expect("save"); - } - store - }); - (root, store, local, staging) - } - - fn row_titles(dialog: &SavedQueriesDialog) -> Vec { - crate::test_support::descendants(&dialog.groups) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .map(|row| row.title().to_string()) - .collect() - } - - #[gtk4::test] - fn the_saved_queries_reach_the_list() { - let (_root, store, local, _staging) = seeded(); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - - crate::test_support::wait_until(RENDER_WAIT, || !row_titles(&dialog.model()).is_empty()) - .expect("the saved queries never rendered"); - - // The active connection's query comes first, whatever order the - // store returned them in. - assert_eq!(row_titles(&dialog.model()), vec!["daily orders", "stale carts"]); - } - - #[gtk4::test] - fn searching_narrows_the_list_to_what_matches() { - let (_root, store, local, _staging) = seeded(); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) - .expect("the saved queries never rendered"); - - dialog.model().search.set_text("carts"); - - crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()) == vec!["stale carts"]) - .expect("the search did not narrow the list"); - } - - /// No scenario opens the dialog, so its one field is checked here. - #[gtk4::test] - fn the_search_field_is_named() { - let (_root, store, local, _staging) = seeded(); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - - assert_eq!(crate::test_support::assert_labelled(&dialog.model().search), Ok(())); - } - - #[gtk4::test] - fn deleting_a_query_takes_it_out_of_the_list() { - let (_root, store, local, _staging) = seeded(); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) - .expect("the saved queries never rendered"); - let id = dialog - .model() - .queries - .iter() - .find(|saved| saved.name == "daily orders") - .expect("the seeded query") - .id; - - dialog.sender().send(SavedQueriesDialogInput::Delete(id)).expect("send"); - - crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()) == vec!["stale carts"]) - .expect("the deleted query stayed in the list"); - } - - /// Take away the table the store reads, as a damaged file does, - /// through a connection of its own. - fn drop_the_table(root: &tempfile::TempDir) { - use tablepro_core::statement::{GuardedStatement, StatementBuilder}; - use tablepro_core::{ - CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, DatabaseDriver, FileEndpoint, - ForeignKeyMode, TableRef, WorkspaceSettings, WriteBatch, WriteKind, - }; - use tokio_util::sync::CancellationToken; - - let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); - let endpoint = FileEndpoint::open_existing(paths.history_database()).expect("the history file"); - let options = ConnectOptions { - target: ConnectTarget::File(endpoint), - workspace_settings: WorkspaceSettings::workspace(false), - liveness: tablepro_core::LivenessPolicy::DESKTOP, - environment: ConnectEnvironment::new("test"), - }; - let mut drop = StatementBuilder::new(); - drop.push_sql("DROP TABLE saved_queries"); - let batch = WriteBatch::new( - WriteKind::SchemaChanges, - TableRef::unqualified("saved_queries"), - vec![GuardedStatement::unguarded(drop.finish())], - ForeignKeyMode::Enforced, - ); - relm4::tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime") - .block_on(async { - let connection = tablepro_driver_sqlite::SqliteDriver - .connect(options, CancellationToken::new()) - .await - .expect("connect"); - connection - .write_batch(&batch, CallOptions::new(None, CancellationToken::new())) - .await - .expect("the table dropped"); - connection.close().await.expect("close"); - }); - } - - fn visible_page(dialog: &SavedQueriesDialog) -> Option { - dialog.stack.visible_child_name().map(|name| name.to_string()) - } - - #[gtk4::test] - fn a_list_that_cannot_be_read_says_so_and_offers_try_again() { - let (root, store, local, _staging) = seeded(); - drop_the_table(&root); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - - crate::test_support::wait_until(RENDER_WAIT, || { - visible_page(&dialog.model()).as_deref() == Some("error") - }) - .expect("the error page"); - let model = dialog.model(); - assert_eq!(model.error_page.title(), "Could Not Read Saved Queries"); - assert_eq!( - model.error_page.description().as_deref(), - Some("The query history file could not be read. It may be damaged.") - ); - let retry = model - .error_page - .child() - .and_downcast::() - .expect("a Try Again button"); - assert_eq!(retry.label().as_deref(), Some("Try Again")); - drop(model); - - retry.emit_clicked(); - crate::test_support::drain_main_context(); - assert_eq!( - visible_page(&dialog.model()).as_deref(), - Some("error"), - "Try Again found the table back" - ); - } - - #[gtk4::test] - fn copying_a_query_says_so_inside_the_dialog() { - let (_root, store, local, _staging) = seeded(); - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: Some(store), - active_connection: Some(local), - }); - crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) - .expect("the saved queries never rendered"); - let id = dialog.model().queries[0].id; - - dialog - .sender() - .send(SavedQueriesDialogInput::CopySql(id)) - .expect("send"); - crate::test_support::drain_main_context(); - - let toasts = dialog.model().toasts.clone(); - assert!( - crate::test_support::descendants(&toasts) - .iter() - .filter_map(|widget| widget.downcast_ref::()) - .any(|label| label.label() == "Copied to clipboard"), - "the toast went somewhere the dialog covers" - ); - } - - #[test] - fn a_rename_failure_says_what_went_wrong() { - use tablepro_storage::StorageError; - - assert_eq!( - rename_failure_text(&StorageError::NameTaken), - "That name is already used by another saved query." - ); - assert_eq!( - rename_failure_text(&StorageError::NotFound), - "That saved query was deleted elsewhere." - ); - assert_eq!( - rename_failure_text(&StorageError::NotInitialised), - "The saved query could not be renamed." - ); - } - - #[gtk4::test] - fn a_dialog_with_no_database_shows_the_empty_state_rather_than_failing() { - let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { - store: None, - active_connection: None, - }); - - crate::test_support::drain_main_context(); - - assert_eq!(dialog.model().stack.visible_child_name().as_deref(), Some("empty")); - assert!(row_titles(&dialog.model()).is_empty()); - } -} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/imp.rs b/linux/crates/app/src/ui/saved_queries_dialog/imp.rs new file mode 100644 index 0000000000..dfc972cb81 --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/imp.rs @@ -0,0 +1,73 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::DialogParts; +use super::saved_query_listing::SearchSnapshot; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/saved-queries-dialog.ui")] +pub struct SavedQueriesDialog { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + + pub(super) parts: OnceCell, + pub(super) snapshot: RefCell, + pub(super) search_generation: Cell, + pub(super) load_generation: Cell, + // Once a read has landed, a failed one keeps the list on screen. + pub(super) listed: Cell, +} + +#[glib::object_subclass] +impl ObjectSubclass for SavedQueriesDialog { + const NAME: &'static str = "TableProSavedQueriesDialog"; + type Type = super::SavedQueriesDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for SavedQueriesDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("open-in-new-tab") + .param_types([String::static_type()]) + .build(), + Signal::builder("replace-current-tab") + .param_types([String::static_type()]) + .build(), + ] + }) + } +} + +impl WidgetImpl for SavedQueriesDialog {} +impl AdwDialogImpl for SavedQueriesDialog {} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/mod.rs b/linux/crates/app/src/ui/saved_queries_dialog/mod.rs new file mode 100644 index 0000000000..e22cf17a88 --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/mod.rs @@ -0,0 +1,586 @@ +mod imp; +mod saved_query_listing; +mod saved_query_object; +mod saved_query_object_imp; +mod saved_query_row; +#[cfg(test)] +mod tests; + +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_storage::{SavedQueries, StorageError}; +use uuid::Uuid; + +use crate::i18n::{gettext, gettext_f}; +use crate::runtime::spawn_and_deliver; +use crate::ui::history_dialog::QueryDestination; +use saved_query_listing::{Listing, matches}; +use saved_query_object::SavedQueryObject; +use saved_query_row::SavedQueryRow; + +glib::wrapper! { + pub struct SavedQueriesDialog(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) struct SavedQueriesDialogInit { + pub store: Option, + // Its connection's queries are listed first. + pub active_connection: Option, + pub tasks: Tasks, +} + +// What the last search matched. None shows every query. +type Matched = Rc>>>; + +struct DialogParts { + store: Option, + active_connection: Option, + tasks: Tasks, + items: gio::ListStore, + matched: Matched, + filter: gtk::CustomFilter, + sorted: gtk::SortListModel, +} + +#[derive(Debug, Clone, Copy)] +enum StoreChange { + Rename, + Delete, +} + +impl StoreChange { + fn failure_text(self, error: Option<&StorageError>) -> String { + match (self, error) { + (Self::Rename, Some(error)) => rename_failure_text(error), + (Self::Rename, None) => gettext("The saved query could not be renamed."), + (Self::Delete, _) => gettext("Could not delete that saved query."), + } + } +} + +impl SavedQueriesDialog { + pub(crate) fn new(init: SavedQueriesDialogInit) -> Self { + let dialog: Self = glib::Object::new(); + let items = gio::ListStore::new::(); + let matched = Matched::default(); + let filter = matching_filter(matched.clone()); + let filtered = gtk::FilterListModel::new(Some(items.clone()), Some(filter.clone())); + let sorted = gtk::SortListModel::new(Some(filtered), None::); + sorted.set_section_sorter(Some(&by_group())); + let _ = dialog.imp().parts.set(DialogParts { + store: init.store, + active_connection: init.active_connection, + tasks: init.tasks, + items, + matched, + filter, + sorted, + }); + dialog.setup_list(); + dialog.connect_controls(); + dialog.show_results(); + dialog.load(); + dialog + } + + pub(crate) fn connect_query_chosen(&self, f: F) { + let f = Rc::new(f); + for destination in [QueryDestination::NewTab, QueryDestination::CurrentTab] { + let f = f.clone(); + self.connect_closure( + destination.signal(), + false, + glib::closure_local!(move |dialog: &Self, query: String| f(dialog, destination, query)), + ); + } + } + + fn setup_list(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let list_view = imp.list_view.get(); + list_view.set_model(Some(>k::NoSelection::new(Some(parts.sorted.clone())))); + list_view.set_factory(Some(&self.row_factory())); + list_view.set_header_factory(Some(&connection_header_factory())); + let dialog = self.downgrade(); + list_view.connect_activate(move |list_view, position| { + let id = list_view + .model() + .and_then(|model| model.item(position)) + .and_downcast::() + .and_then(|object| object.id()); + if let (Some(dialog), Some(id)) = (dialog.upgrade(), id) { + dialog.send_query(id, QueryDestination::NewTab); + } + }); + } + + fn connect_controls(&self) { + let imp = self.imp(); + let dialog = self.downgrade(); + imp.search_entry.connect_search_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.search(); + } + }); + let dialog = self.downgrade(); + imp.retry_button.connect_clicked(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.load(); + } + }); + } + + fn row_factory(&self) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + let dialog = self.downgrade(); + factory.connect_setup(move |_, object| { + let (Some(item), Some(dialog)) = (object.downcast_ref::(), dialog.upgrade()) else { + return; + }; + let row = SavedQueryRow::default(); + row.insert_action_group("saved", Some(&dialog.row_actions(&row))); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + if let (Some(row), Some(saved)) = ( + item.child().and_downcast::(), + item.item().and_downcast::(), + ) { + row.bind(&saved); + } + }); + factory.connect_unbind(|_, object| { + if let Some(row) = object + .downcast_ref::() + .and_then(|item| item.child().and_downcast::()) + { + row.unbind(); + } + }); + factory + } + + fn row_actions(&self, row: &SavedQueryRow) -> gio::SimpleActionGroup { + let actions = gio::SimpleActionGroup::new(); + actions.add_action_entries([ + self.row_action(row, "open", |dialog, id| { + dialog.send_query(id, QueryDestination::NewTab) + }), + self.row_action(row, "replace", |dialog, id| { + dialog.send_query(id, QueryDestination::CurrentTab) + }), + self.row_action(row, "copy", Self::copy_query), + self.row_action(row, "rename", Self::prompt_rename), + self.row_action(row, "delete", Self::confirm_delete), + ]); + actions + } + + // Acts on whichever query the row shows when it is picked, since the + // list view binds its rows again as it scrolls. + fn row_action( + &self, + row: &SavedQueryRow, + name: &str, + act: fn(&Self, i64), + ) -> gio::ActionEntry { + let (dialog, row) = (self.downgrade(), row.downgrade()); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| { + if let (Some(dialog), Some(id)) = (dialog.upgrade(), row.upgrade().and_then(|row| row.id())) { + act(&dialog, id); + } + }) + .build() + } + + fn object_by_id(&self, id: i64) -> Option { + let parts = self.imp().parts.get()?; + parts + .items + .iter::() + .filter_map(Result::ok) + .find(|object| object.id() == Some(id)) + } + + fn send_query(&self, id: i64, destination: QueryDestination) { + let Some(query) = self + .object_by_id(id) + .and_then(|object| object.query().map(str::to_owned)) + else { + return; + }; + self.emit_by_name::<()>(destination.signal(), &[&query]); + self.close(); + } + + fn copy_query(&self, id: i64) { + if let Some(query) = self + .object_by_id(id) + .and_then(|object| object.query().map(str::to_owned)) + { + crate::ui::clipboard::copy_text(self, query, |message| self.add_toast(message)); + } + } + + fn prompt_rename(&self, id: i64) { + let Some(current) = self + .object_by_id(id) + .and_then(|object| object.name().map(str::to_owned)) + else { + return; + }; + let alert = adw::AlertDialog::new(Some(&gettext("Rename Saved Query")), None); + let entry = adw::EntryRow::builder() + .title(gettext("Name")) + .activates_default(true) + .build(); + entry.set_text(¤t); + let group = adw::PreferencesGroup::new(); + group.add(&entry); + alert.set_extra_child(Some(&group)); + alert.add_response("cancel", &gettext("Cancel")); + alert.add_response("rename", &gettext("Rename")); + alert.set_response_appearance("rename", adw::ResponseAppearance::Suggested); + alert.set_default_response(Some("rename")); + alert.set_close_response("cancel"); + crate::ui::name_entry::require_name(&alert, "rename", &entry); + + let dialog = self.downgrade(); + alert.connect_response(None, move |_, response| { + if response != "rename" { + return; + } + let name = entry.text().to_string(); + if name.trim().is_empty() { + return; + } + if let Some(dialog) = dialog.upgrade() { + dialog.rename(id, name); + } + }); + alert.present(Some(self)); + } + + fn confirm_delete(&self, id: i64) { + let Some(name) = self + .object_by_id(id) + .and_then(|object| object.name().map(str::to_owned)) + else { + return; + }; + let alert = adw::AlertDialog::new( + Some(&gettext_f("Delete “{name}”?", &[("name", &name)])), + Some(&gettext( + "The saved query is removed. The SQL itself is not run or changed.", + )), + ); + alert.add_response("cancel", &gettext("Cancel")); + alert.add_response("delete", &gettext("Delete")); + alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + + let dialog = self.downgrade(); + alert.connect_response(None, move |_, response| { + if response == "delete" + && let Some(dialog) = dialog.upgrade() + { + dialog.delete(id); + } + }); + alert.present(Some(self)); + } + + fn rename(&self, id: i64, name: String) { + self.change(StoreChange::Rename, move |store| async move { + store.rename(id, &name).await + }); + } + + fn delete(&self, id: i64) { + self.change(StoreChange::Delete, move |store| async move { store.delete(id).await }); + } + + // The list is read again whether the change landed or not, so it + // shows what the store holds now. + fn change(&self, change: StoreChange, work: impl FnOnce(SavedQueries) -> W + Send + 'static) + where + W: Future> + Send + 'static, + { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(store) = parts.store.clone() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &parts.tasks, + async move { work(store).await }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let failure = match done { + Ok(Ok(())) => None, + Ok(Err(error)) => { + tracing::warn!(%error, ?change, "a saved query change failed"); + Some(change.failure_text(Some(&error))) + } + Err(failure) => { + tracing::warn!(%failure, ?change, "a saved query change did not finish"); + Some(change.failure_text(None)) + } + }; + if let Some(message) = failure { + dialog.add_toast(&message); + } + dialog.load(); + }, + )); + } + + fn load(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let Some(store) = parts.store.clone() else { + return; + }; + let generation = imp.load_generation.get().wrapping_add(1); + imp.load_generation.set(generation); + let active = parts.active_connection; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &parts.tasks, + async move { store.list(None).await.map(|queries| Listing::new(queries, active)) }, + move |listed| { + if let Some(dialog) = dialog.upgrade() { + dialog.list_arrived(generation, listed); + } + }, + )); + } + + fn list_arrived(&self, generation: u64, listed: Result, TaskFailure>) { + let imp = self.imp(); + if imp.load_generation.get() != generation { + return; + } + let listing = match listed { + Ok(Ok(listing)) => listing, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the saved queries"); + self.load_failed(); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the saved queries read did not finish"); + self.load_failed(); + return; + } + }; + let Some(parts) = imp.parts.get() else { + return; + }; + let objects: Vec = listing + .queries + .into_iter() + .map(|(group, saved)| SavedQueryObject::new(saved, group)) + .collect(); + parts.items.splice(0, parts.items.n_items(), &objects); + imp.snapshot.replace(listing.snapshot); + imp.listed.set(true); + // The ids the search matched may have changed with the list. + self.search(); + self.show_results(); + } + + fn load_failed(&self) { + let imp = self.imp(); + let reason = gettext("The query history file could not be read. It may be damaged."); + if imp.listed.get() { + self.add_toast(&reason); + return; + } + imp.error_page.set_description(Some(&reason)); + imp.stack.set_visible_child_name("error"); + } + + // Matching runs on the blocking pool over the snapshot the last read + // built, and only the newest search's answer is applied. + fn search(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let generation = imp.search_generation.get().wrapping_add(1); + imp.search_generation.set(generation); + let needle = imp.search_entry.text().to_string(); + if needle.trim().is_empty() { + self.apply_matches(None); + return; + } + let snapshot = imp.snapshot.borrow().clone(); + let matching = parts.tasks.spawn_blocking_task(move || matches(&snapshot, &needle)); + let dialog = self.downgrade(); + glib::spawn_future_local(async move { + let found = matching.await; + if let Some(dialog) = dialog.upgrade() { + dialog.search_finished(generation, found); + } + }); + } + + fn search_finished(&self, generation: u64, found: Result, TaskFailure>) { + if self.imp().search_generation.get() != generation { + return; + } + match found { + Ok(found) => self.apply_matches(Some(found)), + Err(failure) => tracing::warn!(%failure, "the saved queries search did not finish"), + } + } + + fn apply_matches(&self, found: Option>) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + parts.matched.replace(found); + parts.filter.changed(gtk::FilterChange::Different); + self.show_results(); + } + + fn show_results(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + // Only a read that lands takes the list off the error page. + if !imp.listed.get() && imp.stack.visible_child_name().as_deref() == Some("error") { + return; + } + if parts.sorted.n_items() > 0 { + imp.stack.set_visible_child_name("list"); + return; + } + let page = &imp.status_page; + if imp.search_entry.text().trim().is_empty() { + page.set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); + page.set_title(&gettext("No Saved Queries")); + page.set_description(Some(&gettext( + "Save a query from the editor and it will be listed here.", + ))); + } else { + page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + page.set_title(&gettext("No Matches")); + page.set_description(Some(&gettext("Try a different search term."))); + } + imp.stack.set_visible_child_name("empty"); + } + + fn add_toast(&self, message: &str) { + self.imp().toast_overlay.add_toast(crate::ui::plain_toast(message)); + } +} + +fn matching_filter(matched: Matched) -> gtk::CustomFilter { + gtk::CustomFilter::new(move |object| { + let matched = matched.borrow(); + let Some(found) = matched.as_ref() else { + return true; + }; + object + .downcast_ref::() + .and_then(SavedQueryObject::id) + .is_some_and(|id| found.contains(&id)) + }) +} + +// Each connection is a section. Every query within one compares equal, +// so the store's order holds inside it. +fn by_group() -> gtk::CustomSorter { + gtk::CustomSorter::new(|a, b| { + let group = |object: &glib::Object| object.downcast_ref::().map(SavedQueryObject::group); + group(a).cmp(&group(b)).into() + }) +} + +fn connection_header_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder() + .xalign(0.0) + .margin_top(12) + .margin_bottom(6) + .margin_start(6) + .build(); + label.add_css_class("heading"); + header.set_child(Some(&label)); + }); + factory.connect_bind(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let Some(label) = header.child().and_downcast::() else { + return; + }; + let saved = header.item().and_downcast::(); + label.set_label( + saved + .as_ref() + .and_then(SavedQueryObject::connection_name) + .unwrap_or_default(), + ); + }); + factory +} + +// What a failed rename tells the user. A taken name is the one the user +// can fix from the dialog. +fn rename_failure_text(error: &StorageError) -> String { + match error { + StorageError::NameTaken => gettext("That name is already used by another saved query."), + StorageError::TooLarge { .. } => gettext("That name is too long."), + StorageError::NotFound => gettext("That saved query was deleted elsewhere."), + _ => gettext("The saved query could not be renamed."), + } +} + +fn row_menu() -> gio::Menu { + let menu = gio::Menu::new(); + let open_section = gio::Menu::new(); + open_section.append(Some(&gettext("_Open in New Tab")), Some("saved.open")); + open_section.append(Some(&gettext("_Replace Current Tab")), Some("saved.replace")); + open_section.append(Some(&gettext("_Copy SQL")), Some("saved.copy")); + menu.append_section(None, &open_section); + let edit_section = gio::Menu::new(); + edit_section.append(Some(&gettext("Re_name…")), Some("saved.rename")); + menu.append_section(None, &edit_section); + let danger_section = gio::Menu::new(); + danger_section.append(Some(&gettext("_Delete")), Some("saved.delete")); + menu.append_section(None, &danger_section); + menu +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/saved_query_listing.rs b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_listing.rs new file mode 100644 index 0000000000..05d48a4de6 --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_listing.rs @@ -0,0 +1,191 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use tablepro_storage::saved_queries::SavedQuery; +use uuid::Uuid; + +// One lowercase entry per field, so a search never matches across two fields. +pub(super) type SearchSnapshot = Arc<[(i64, String)]>; + +pub(super) struct Listing { + pub(super) queries: Vec<(u32, SavedQuery)>, + pub(super) snapshot: SearchSnapshot, +} + +impl Listing { + pub(super) fn new(queries: Vec, active: Option) -> Self { + let snapshot = snapshot(&queries); + Self { + queries: grouped(queries, active), + snapshot, + } + } +} + +// Each query with its group's rank. A group is a connection id, so two +// connections sharing a name stay apart; the active one comes first. +fn grouped(queries: Vec, active: Option) -> Vec<(u32, SavedQuery)> { + let mut groups: Vec<(Uuid, Vec)> = Vec::new(); + for saved in queries { + match groups + .iter_mut() + .find(|(connection, _)| *connection == saved.connection_id) + { + Some((_, members)) => members.push(saved), + None => groups.push((saved.connection_id, vec![saved])), + } + } + if let Some(active) = active { + // A stable sort, so the other groups keep the store's order. + groups.sort_by_key(|(connection, _)| *connection != active); + } + groups + .into_iter() + .zip(0u32..) + .flat_map(|((_, members), rank)| members.into_iter().map(move |saved| (rank, saved))) + .collect() +} + +fn snapshot(queries: &[SavedQuery]) -> SearchSnapshot { + queries + .iter() + .flat_map(|saved| { + [&saved.name, &saved.query, &saved.connection_name].map(|field| (saved.id, field.to_lowercase())) + }) + .collect() +} + +// The connection's name counts, so typing it narrows the list to that +// connection without a filter control. +pub(super) fn matches(snapshot: &[(i64, String)], needle: &str) -> HashSet { + let needle = needle.trim().to_lowercase(); + snapshot + .iter() + .filter(|(_, haystack)| haystack.contains(needle.as_str())) + .map(|(id, _)| *id) + .collect() +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use super::*; + + fn saved(name: &str, query: &str, connection: &str) -> SavedQuery { + on_connection(name, query, connection, Uuid::nil()) + } + + fn on_connection(name: &str, query: &str, connection: &str, connection_id: Uuid) -> SavedQuery { + SavedQuery { + id: 1, + name: name.to_owned(), + query: query.to_owned(), + connection_id, + connection_name: connection.to_owned(), + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + } + } + + // The connection named at the start of each group. + fn group_names(groups: &[(u32, SavedQuery)]) -> Vec { + let mut names = Vec::new(); + let mut last = None; + for (rank, saved) in groups { + if last != Some(*rank) { + names.push(saved.connection_name.clone()); + last = Some(*rank); + } + } + names + } + + fn found(query: &SavedQuery, needle: &str) -> bool { + matches(&snapshot(std::slice::from_ref(query)), needle).contains(&query.id) + } + + #[test] + fn queries_are_grouped_by_the_connection_they_were_saved_against() { + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", local), + on_connection("b", "SELECT 2", "staging", staging), + on_connection("c", "SELECT 3", "local", local), + ]; + + let groups = grouped(queries, None); + + assert_eq!(group_names(&groups), vec!["local", "staging"]); + let listed: Vec<(u32, &str)> = groups + .iter() + .map(|(rank, saved)| (*rank, saved.name.as_str())) + .collect(); + assert_eq!(listed, vec![(0_u32, "a"), (0, "c"), (1, "b")]); + } + + #[test] + fn the_connection_the_user_is_on_is_listed_first() { + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", local), + on_connection("b", "SELECT 2", "staging", staging), + ]; + + let groups = grouped(queries, Some(staging)); + + assert_eq!(group_names(&groups), vec!["staging", "local"]); + } + + #[test] + fn two_connections_sharing_a_name_stay_apart() { + // Duplicating a connection and renaming neither is enough for + // this: the id is what says they are different. + let (first, second) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", first), + on_connection("b", "SELECT 2", "local", second), + ]; + + let groups = grouped(queries, None); + + assert_eq!(group_names(&groups), vec!["local", "local"]); + } + + #[test] + fn a_search_that_matches_nothing_finds_no_query() { + let queries = vec![saved("daily", "SELECT 1", "local")]; + + assert!(matches(&snapshot(&queries), "invoices").is_empty()); + } + + #[test] + fn an_empty_search_matches_everything() { + assert!(found(&saved("daily", "SELECT 1", "local"), "")); + assert!(found(&saved("daily", "SELECT 1", "local"), " ")); + } + + #[test] + fn a_search_reads_the_name_the_sql_and_the_connection() { + let query = saved("daily report", "SELECT * FROM orders", "staging"); + + assert!(found(&query, "report")); + assert!(found(&query, "orders")); + assert!(found(&query, "staging")); + assert!(!found(&query, "invoices")); + } + + #[test] + fn a_search_ignores_case_and_surrounding_space() { + assert!(found(&saved("Daily", "SELECT 1", "local"), " daily ")); + assert!(found(&saved("daily", "SELECT 1", "local"), "DAILY")); + } + + #[test] + fn a_search_never_matches_across_two_fields() { + let query = saved("ab", "cd", "ef"); + + assert!(!found(&query, "bc")); + assert!(!found(&query, "de")); + } +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object.rs b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object.rs new file mode 100644 index 0000000000..bf24ee170a --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object.rs @@ -0,0 +1,48 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::saved_queries::SavedQuery; + +use super::saved_query_object_imp as imp; + +glib::wrapper! { + pub struct SavedQueryObject(ObjectSubclass); +} + +impl SavedQueryObject { + // `group` ranks the connection's section in the list. + pub(super) fn new(saved: SavedQuery, group: u32) -> Self { + let object: Self = glib::Object::new(); + let imp = object.imp(); + let _ = imp.saved.set(saved); + imp.group.set(group); + object + } + + fn saved(&self) -> Option<&SavedQuery> { + self.imp().saved.get() + } + + pub(super) fn id(&self) -> Option { + self.saved().map(|saved| saved.id) + } + + pub(super) fn name(&self) -> Option<&str> { + self.saved().map(|saved| saved.name.as_str()) + } + + pub(super) fn summary(&self) -> Option<&str> { + self.saved().map(SavedQuery::summary) + } + + pub(super) fn connection_name(&self) -> Option<&str> { + self.saved().map(|saved| saved.connection_name.as_str()) + } + + pub(super) fn query(&self) -> Option<&str> { + self.saved().map(|saved| saved.query.as_str()) + } + + pub(super) fn group(&self) -> u32 { + self.imp().group.get() + } +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object_imp.rs b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object_imp.rs new file mode 100644 index 0000000000..861953c67f --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_object_imp.rs @@ -0,0 +1,19 @@ +use std::cell::{Cell, OnceCell}; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::saved_queries::SavedQuery; + +#[derive(Default)] +pub struct SavedQueryObject { + pub(super) saved: OnceCell, + pub(super) group: Cell, +} + +#[glib::object_subclass] +impl ObjectSubclass for SavedQueryObject { + const NAME: &'static str = "TableProSavedQueryObject"; + type Type = super::SavedQueryObject; +} + +impl ObjectImpl for SavedQueryObject {} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/saved_query_row.rs b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_row.rs new file mode 100644 index 0000000000..74c70c89cc --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/saved_query_row.rs @@ -0,0 +1,87 @@ +use std::cell::RefCell; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::SavedQueryObject; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/saved-query-row.ui")] + pub struct SavedQueryRow { + #[template_child] + pub name_label: TemplateChild, + #[template_child] + pub summary_label: TemplateChild, + #[template_child] + pub menu_button: TemplateChild, + pub object: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for SavedQueryRow { + const NAME: &'static str = "TableProSavedQueryRow"; + type Type = super::SavedQueryRow; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for SavedQueryRow { + fn constructed(&self) { + self.parent_constructed(); + self.menu_button.set_menu_model(Some(&super::super::row_menu())); + } + } + impl WidgetImpl for SavedQueryRow {} + impl BoxImpl for SavedQueryRow {} +} + +glib::wrapper! { + // The list view fills a few of these again as it scrolls, so a row + // belongs to one saved query only between a bind and its unbind. + pub struct SavedQueryRow(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Default for SavedQueryRow { + fn default() -> Self { + glib::Object::new() + } +} + +impl SavedQueryRow { + pub(super) fn bind(&self, object: &SavedQueryObject) { + let imp = self.imp(); + imp.name_label.set_label(object.name().unwrap_or_default()); + let summary = object.summary().unwrap_or_default(); + imp.summary_label.set_label(summary); + imp.summary_label.set_visible(!summary.is_empty()); + imp.object.replace(Some(object.clone())); + } + + pub(super) fn unbind(&self) { + self.imp().object.replace(None); + } + + pub(super) fn id(&self) -> Option { + self.imp().object.borrow().as_ref().and_then(SavedQueryObject::id) + } + + #[cfg(test)] + pub(super) fn labels(&self) -> (gtk::Label, gtk::Label) { + let imp = self.imp(); + (imp.name_label.get(), imp.summary_label.get()) + } +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog/tests.rs b/linux/crates/app/src/ui/saved_queries_dialog/tests.rs new file mode 100644 index 0000000000..74c27ce3d7 --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog/tests.rs @@ -0,0 +1,490 @@ +use std::cell::RefCell; +use std::collections::HashSet; +use std::rc::Rc; +use std::time::{Duration, SystemTime}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::saved_queries::{NewSavedQuery, SavedQuery}; +use tablepro_storage::{QueryHistory, StoragePaths}; +use uuid::Uuid; + +use super::*; +use crate::runtime::AppRuntime; +use crate::test_support::{OwnedWindow, assert_access_keys, descendants, drain_main_context, test_runtime, wait_until}; + +// Long enough that a loaded machine cannot decide the outcome; a passing +// test returns as soon as its condition holds. +const WAIT: Duration = Duration::from_secs(30); + +struct Harness { + runtime: AppRuntime, + store: SavedQueries, + root: tempfile::TempDir, +} + +impl Harness { + // Each query is its name, its SQL, and the id and name of its connection. + fn with(queries: &[(&str, &str, Uuid, &str)]) -> Self { + init(); + let root = tempfile::tempdir().expect("tempdir"); + let paths = StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); + let store = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") + .block_on(async { + let store = QueryHistory::open(&paths).await.expect("open").saved_queries(); + for &(name, sql, connection_id, connection_name) in queries { + store + .save(NewSavedQuery { + name: name.to_owned(), + query: sql.to_owned(), + connection_id, + connection_name: connection_name.to_owned(), + }) + .await + .expect("save"); + } + store + }); + Self { + runtime: test_runtime(), + store, + root, + } + } + + fn dialog(&self, active_connection: Option) -> SavedQueriesDialog { + SavedQueriesDialog::new(SavedQueriesDialogInit { + store: Some(self.store.clone()), + active_connection, + tasks: self.runtime.tasks(), + }) + } +} + +fn init() { + crate::register_test_resources(); + adw::init().expect("libadwaita initialises under the test backend"); +} + +// One query on each of two connections, `local` and `staging`. +fn seeded() -> (Harness, Uuid, Uuid) { + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let harness = Harness::with(&[ + ("daily orders", "SELECT * FROM orders", local, "local"), + ("stale carts", "SELECT * FROM carts", staging, "staging"), + ]); + (harness, local, staging) +} + +// A window large enough that the list view lays out its rows. +fn window() -> OwnedWindow { + let window = adw::Window::new(); + window.set_default_size(640, 640); + OwnedWindow::presented(window) +} + +// What the list view shows, in its order. +fn objects(dialog: &SavedQueriesDialog) -> Vec { + let Some(model) = dialog.imp().list_view.model() else { + return Vec::new(); + }; + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .collect() +} + +fn row_titles(dialog: &SavedQueriesDialog) -> Vec { + objects(dialog) + .iter() + .filter_map(|object| object.name().map(str::to_owned)) + .collect() +} + +// Everything the last read listed, whatever the search shows. +fn stored(dialog: &SavedQueriesDialog) -> Vec { + dialog + .imp() + .parts + .get() + .map(|parts| parts.items.iter::().filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +fn named(dialog: &SavedQueriesDialog, name: &str) -> SavedQueryObject { + stored(dialog) + .into_iter() + .find(|object| object.name() == Some(name)) + .expect("the saved query") +} + +fn visible_page(dialog: &SavedQueriesDialog) -> Option { + dialog.imp().stack.visible_child_name().map(|name| name.to_string()) +} + +fn chosen(dialog: &SavedQueriesDialog) -> Rc>> { + let chosen: Rc>> = Rc::default(); + let sink = chosen.clone(); + dialog.connect_query_chosen(move |_, destination, query| sink.borrow_mut().push((destination, query))); + chosen +} + +fn row_named(dialog: &SavedQueriesDialog, name: &str) -> Option { + descendants(&dialog.imp().list_view.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.id().is_some() && row.labels().0.label() == name) +} + +#[gtk4::test] +fn the_saved_queries_reach_the_list() { + let (harness, local, staging) = seeded(); + let dialog = harness.dialog(Some(local)); + + wait_until(WAIT, || !row_titles(&dialog).is_empty()).expect("the saved queries never listed"); + + // The active connection's query comes first, whatever order the + // store returned them in. + assert_eq!(row_titles(&dialog), ["daily orders", "stale carts"]); + assert_eq!(visible_page(&dialog).as_deref(), Some("list")); + let on_staging = harness.dialog(Some(staging)); + wait_until(WAIT, || !row_titles(&on_staging).is_empty()).expect("the saved queries never listed"); + assert_eq!(row_titles(&on_staging), ["stale carts", "daily orders"]); +} + +#[gtk4::test] +fn searching_narrows_the_list_to_what_matches() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let imp = dialog.imp(); + + imp.search_entry.set_text("carts"); + wait_until(WAIT, || row_titles(&dialog) == ["stale carts"]).expect("the search did not narrow the list"); + + imp.search_entry.set_text("invoices"); + wait_until(WAIT, || visible_page(&dialog).as_deref() == Some("empty")).expect("the empty search page"); + assert_eq!(imp.status_page.title(), "No Matches"); + assert_eq!( + imp.status_page.description().as_deref(), + Some("Try a different search term.") + ); +} + +#[gtk4::test] +fn a_search_filters_the_items_it_has_rather_than_building_new_ones() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let before = stored(&dialog); + let carts = named(&dialog, "stale carts"); + let search = dialog.imp().search_entry.get(); + + search.set_text("carts"); + wait_until(WAIT, || row_titles(&dialog) == ["stale carts"]).expect("the search did not narrow the list"); + assert_eq!(objects(&dialog), [carts], "the search built the item again"); + assert_eq!(stored(&dialog), before, "the search replaced the list"); + + search.set_text(""); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the cleared search"); + assert_eq!(objects(&dialog), before, "clearing the search built the items again"); +} + +#[gtk4::test] +fn an_answer_to_a_replaced_search_is_dropped() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let carts = named(&dialog, "stale carts").id().expect("an id"); + let search = dialog.imp().search_entry.get(); + search.set_text("carts"); + wait_until(WAIT, || row_titles(&dialog) == ["stale carts"]).expect("the search for carts"); + let replaced = dialog.imp().search_generation.get(); + search.set_text("orders"); + wait_until(WAIT, || row_titles(&dialog) == ["daily orders"]).expect("the search for orders"); + + dialog.search_finished(replaced, Ok(HashSet::from([carts]))); + + assert_eq!( + row_titles(&dialog), + ["daily orders"], + "a replaced search's answer was applied" + ); +} + +#[gtk4::test] +fn two_connections_sharing_a_name_are_two_sections() { + let (first, second) = (Uuid::new_v4(), Uuid::new_v4()); + let harness = Harness::with(&[("a", "SELECT 1", first, "local"), ("b", "SELECT 2", second, "local")]); + let dialog = harness.dialog(None); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + + let sorted = dialog.imp().parts.get().expect("parts").sorted.clone(); + + assert_eq!(sorted.section(0), (0, 1)); + assert_eq!(sorted.section(1), (1, 2)); +} + +#[gtk4::test] +fn a_dialog_that_goes_before_its_list_lands_is_freed() { + let (harness, local, _staging) = seeded(); + + let dropped = harness.dialog(Some(local)); + let weak = dropped.downgrade(); + drop(dropped); + // Nothing has run on the main loop yet, so its read has not landed. + assert!(weak.upgrade().is_none(), "its read kept a dropped dialog alive"); + + let window = window(); + let closed = harness.dialog(Some(local)); + closed.present(Some(&*window)); + let weak = closed.downgrade(); + closed.force_close(); + drop(closed); + wait_until(WAIT, || weak.upgrade().is_none()).expect("the closed dialog to be freed"); + + // One opened after them lists once their reads have had their turn. + let open = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&open).len() == 2).expect("a later dialog's list"); + drain_main_context(); +} + +#[gtk4::test] +fn activating_a_row_opens_its_query_in_a_new_tab() { + let (harness, local, _staging) = seeded(); + let window = window(); + let dialog = harness.dialog(Some(local)); + dialog.present(Some(&*window)); + let chosen = chosen(&dialog); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + + dialog.imp().list_view.emit_by_name::<()>("activate", &[&1u32]); + + assert_eq!( + *chosen.borrow(), + [(QueryDestination::NewTab, "SELECT * FROM carts".to_owned())] + ); +} + +#[gtk4::test] +fn a_rows_menu_acts_on_the_query_that_row_shows() { + let (harness, local, _staging) = seeded(); + let window = window(); + let dialog = harness.dialog(Some(local)); + dialog.present(Some(&*window)); + let chosen = chosen(&dialog); + wait_until(WAIT, || row_named(&dialog, "stale carts").is_some()).expect("the rows"); + + row_named(&dialog, "stale carts") + .expect("the row") + .activate_action("saved.replace", None) + .expect("the action"); + + assert_eq!( + *chosen.borrow(), + [(QueryDestination::CurrentTab, "SELECT * FROM carts".to_owned())] + ); +} + +#[gtk4::test] +fn a_row_shows_its_name_and_summary_as_written() { + init(); + let row = SavedQueryRow::default(); + let saved = SavedQuery { + id: 7, + name: "R&D ".to_owned(), + query: "\n SELECT 1 & 2\nFROM t".to_owned(), + connection_id: Uuid::nil(), + connection_name: "local".to_owned(), + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + }; + + row.bind(&SavedQueryObject::new(saved, 0)); + + let (name, summary) = row.labels(); + assert_eq!(name.label(), "R&D "); + assert!(!name.uses_markup(), "the name was read as markup"); + assert_eq!(summary.label(), "SELECT 1 & 2"); + assert!(!summary.uses_markup(), "the summary was read as markup"); + assert_eq!(row.id(), Some(7)); +} + +// No scenario opens the dialog, so its one field is checked here. +#[gtk4::test] +fn the_search_field_is_named() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + + assert_eq!( + crate::test_support::assert_labelled(&dialog.imp().search_entry.get()), + Ok(()) + ); +} + +#[gtk4::test] +fn deleting_a_query_takes_it_out_of_the_list() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let id = named(&dialog, "daily orders").id().expect("an id"); + + dialog.delete(id); + + wait_until(WAIT, || row_titles(&dialog) == ["stale carts"]).expect("the deleted query stayed in the list"); +} + +#[gtk4::test] +fn renaming_a_query_lists_it_under_its_new_name() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let id = named(&dialog, "daily orders").id().expect("an id"); + + dialog.rename(id, "weekly orders".to_owned()); + + wait_until(WAIT, || row_titles(&dialog) == ["weekly orders", "stale carts"]).expect("the new name"); +} + +// Take away the table the store reads, as a damaged file does, through a +// connection of its own. +fn drop_the_table(root: &tempfile::TempDir) { + use tablepro_core::statement::{GuardedStatement, StatementBuilder}; + use tablepro_core::{ + CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, DatabaseDriver, FileEndpoint, ForeignKeyMode, + TableRef, WorkspaceSettings, WriteBatch, WriteKind, + }; + use tokio_util::sync::CancellationToken; + + let paths = StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); + let endpoint = FileEndpoint::open_existing(paths.history_database()).expect("the history file"); + let options = ConnectOptions { + target: ConnectTarget::File(endpoint), + workspace_settings: WorkspaceSettings::workspace(false), + liveness: tablepro_core::LivenessPolicy::DESKTOP, + environment: ConnectEnvironment::new("test"), + }; + let mut drop = StatementBuilder::new(); + drop.push_sql("DROP TABLE saved_queries"); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + TableRef::unqualified("saved_queries"), + vec![GuardedStatement::unguarded(drop.finish())], + ForeignKeyMode::Enforced, + ); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") + .block_on(async { + let connection = tablepro_driver_sqlite::SqliteDriver + .connect(options, CancellationToken::new()) + .await + .expect("connect"); + connection + .write_batch(&batch, CallOptions::new(None, CancellationToken::new())) + .await + .expect("the table dropped"); + connection.close().await.expect("close"); + }); +} + +#[gtk4::test] +fn a_list_that_cannot_be_read_says_so_and_offers_try_again() { + let (harness, local, _staging) = seeded(); + drop_the_table(&harness.root); + let dialog = harness.dialog(Some(local)); + + wait_until(WAIT, || visible_page(&dialog).as_deref() == Some("error")).expect("the error page"); + let imp = dialog.imp(); + assert_eq!(imp.error_page.title(), "Could Not Read Saved Queries"); + assert_eq!( + imp.error_page.description().as_deref(), + Some("The query history file could not be read. It may be damaged.") + ); + let retry = imp + .error_page + .child() + .and_downcast::() + .expect("a Try Again button"); + assert_eq!(retry.label().as_deref(), Some("Try Again")); + + imp.search_entry.set_text("orders"); + dialog.apply_matches(Some(HashSet::new())); + assert_eq!( + visible_page(&dialog).as_deref(), + Some("error"), + "a search took the list off its error page" + ); + + retry.emit_clicked(); + drain_main_context(); + assert_eq!( + visible_page(&dialog).as_deref(), + Some("error"), + "Try Again found the table back" + ); +} + +#[gtk4::test] +fn copying_a_query_says_so_inside_the_dialog() { + let (harness, local, _staging) = seeded(); + let dialog = harness.dialog(Some(local)); + wait_until(WAIT, || row_titles(&dialog).len() == 2).expect("the saved queries never listed"); + let id = named(&dialog, "daily orders").id().expect("an id"); + + dialog.copy_query(id); + drain_main_context(); + + let toasts = dialog.imp().toast_overlay.get(); + assert!( + descendants(&toasts) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Copied to clipboard"), + "the toast went somewhere the dialog covers" + ); +} + +#[gtk4::test] +fn a_dialog_with_no_database_shows_the_empty_state_rather_than_failing() { + init(); + let runtime = test_runtime(); + let dialog = SavedQueriesDialog::new(SavedQueriesDialogInit { + store: None, + active_connection: None, + tasks: runtime.tasks(), + }); + + drain_main_context(); + + assert_eq!(visible_page(&dialog).as_deref(), Some("empty")); + assert_eq!(dialog.imp().status_page.title(), "No Saved Queries"); + assert!(row_titles(&dialog).is_empty()); +} + +#[test] +fn a_rename_failure_says_what_went_wrong() { + assert_eq!( + rename_failure_text(&StorageError::NameTaken), + "That name is already used by another saved query." + ); + assert_eq!( + rename_failure_text(&StorageError::NotFound), + "That saved query was deleted elsewhere." + ); + assert_eq!( + rename_failure_text(&StorageError::NotInitialised), + "The saved query could not be renamed." + ); +} + +#[test] +fn every_row_menu_item_has_an_access_key_of_its_own() { + assert_access_keys(&row_menu()); +} diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index a75493bc83..d7f5bc58e8 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -851,7 +851,7 @@ impl SessionWindow { Ok(Ok(report)) => crate::i18n::ngettext_f( "Imported {n} connection.", "Imported {n} connections.", - u32::try_from(report.total()).unwrap_or(u32::MAX), + report.total(), &[("n", &report.total().to_string())], ), Ok(Err(tablepro_storage::StorageError::ImportOutdated)) => crate::i18n::gettext( diff --git a/linux/crates/app/src/ui/session_window/import_from_app.rs b/linux/crates/app/src/ui/session_window/import_from_app.rs index 5493d9d436..95d04e3d0d 100644 --- a/linux/crates/app/src/ui/session_window/import_from_app.rs +++ b/linux/crates/app/src/ui/session_window/import_from_app.rs @@ -76,7 +76,7 @@ pub(super) fn imported_message( parts.push(ngettext_f( "Imported {n} connection.", "Imported {n} connections.", - count(connections), + connections, &[("n", &connections.to_string())], )); } @@ -85,7 +85,7 @@ pub(super) fn imported_message( parts.push(ngettext_f( "Added {n} saved query.", "Added {n} saved queries.", - count(queries.added), + queries.added, &[("n", &queries.added.to_string())], )); } @@ -93,7 +93,7 @@ pub(super) fn imported_message( parts.push(ngettext_f( "{n} saved query could not be added.", "{n} saved queries could not be added.", - count(queries.failed), + queries.failed, &[("n", &queries.failed.to_string())], )); } @@ -106,7 +106,7 @@ pub(super) fn imported_message( parts.push(ngettext_f( "{n} password could not be kept in the keyring.", "{n} passwords could not be kept in the keyring.", - count(imported.secrets_not_kept), + imported.secrets_not_kept, &[("n", &imported.secrets_not_kept.to_string())], )); } @@ -115,7 +115,3 @@ pub(super) fn imported_message( } parts.join(" ") } - -fn count(n: usize) -> u32 { - u32::try_from(n).unwrap_or(u32::MAX) -} diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index f56ba8b03c..f704be5e4b 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -476,9 +476,9 @@ impl SessionWindow { SessionWindowInput::NewQueryIn(key) => self.deliver_to(&key, WorkspaceInput::NewEditorTab), SessionWindowInput::RefreshIn(key) => self.deliver_to(&key, WorkspaceInput::RefreshCatalog), SessionWindowInput::FrontChanged => self.sync_actions(), - SessionWindowInput::ReturnForeignTab(page) => { + SessionWindowInput::ReturnTab(page) => { if let Some(page) = page.upgrade() { - self.on_return_foreign_tab(&page); + self.on_return_tab(&page); } } SessionWindowInput::SetSafeMode { key, level } => self.on_set_safe_mode(&key, level), @@ -760,29 +760,38 @@ impl SessionWindow { } } - /// A tab belongs to its session, which only its own window holds: one - /// dropped here goes back to the end of that window's tab bar. - fn on_return_foreign_tab(&self, page: &adw::TabPage) { + /// A tab belongs to its session, and a session to one window. A tab + /// that landed here goes back to its session's view, where the drag + /// took it from; one whose session closed meanwhile closes, since that + /// close already asked about it. + fn on_return_tab(&self, page: &adw::TabPage) { let (Some(shell), Some(app)) = (self.shell(), self.application()) else { return; }; let view = shell.tab_view(); - if !(0..view.n_pages()).any(|position| &view.nth_page(position) == page) { + if crate::ui::workspace::page_position(&view, page).is_none() { return; } let owner = app .windows() .into_iter() .filter_map(|window| window.downcast::().ok()) - .filter(|window| window != self) .filter_map(|window| window.shell()) .find(|other| other.owner_of(page).is_some()); let Some(owner) = owner else { - tracing::debug!("a tab no other window's session owns was dropped here"); + view.close_page(page); return; }; let target = owner.tab_view(); - view.transfer_page(page, &target, target.n_pages()); + let left_at = owner.take_departure(page); + if target == view { + if let Some(position) = left_at { + view.reorder_page(page, position.min(view.n_pages() - 1)); + } + } else { + let position = left_at.unwrap_or(i32::MAX).min(target.n_pages()); + view.transfer_page(page, &target, position); + } } /// Start the session that connected and build its workspace in the diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index 0e23a1d9e9..a9494a006c 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -86,10 +86,11 @@ pub(crate) enum SessionWindowInput { RefreshIn(SessionKey), /// Another session came to the front of the shell. FrontChanged, - /// A tab of another window's session was dropped on this window's - /// tab bar. Weak and sendable, since the window's input also - /// crosses to the runtime. - ReturnForeignTab(glib::SendWeakRef), + /// A tab landed in this window's view where it does not belong: on + /// its bar from another window, or back from a drop outside every + /// window. Weak and sendable, since the window's input also crosses + /// to the runtime. + ReturnTab(glib::SendWeakRef), /// The main menu's Safe Mode level for the session that was in front /// when the user picked it. SetSafeMode { diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 1d970de0c6..9c94143675 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -610,8 +610,16 @@ fn a_safe_mode_reopen_keeps_a_dialog_connect_running() { assert!(toast_titled(&test.window, "The connection was deleted.").is_none()); } +/// Three editor tabs in `test`'s session, and the middle one. +fn three_tabs(test: &TestWindow) -> adw::TabPage { + let pages: Vec = (0..3) + .map(|_| crate::ui::workspace::tests::new_editor_page(&test.workspace()).expect("a tab")) + .collect(); + pages[1].clone() +} + #[gtk4::test] -fn a_tab_dropped_on_another_window_goes_back_to_its_own() { +fn a_tab_dropped_on_another_window_goes_back_where_it_left() { let test = TestWindow::connected("ForeignTab"); let other = second_connection(&test); let second = test @@ -620,13 +628,108 @@ fn a_tab_dropped_on_another_window_goes_back_to_its_own() { .and_then(|window| window.downcast::().ok()) .expect("a second window"); wait_until(WAIT, || second.page_name() == "workspace").expect("the second window's session"); - let page = crate::ui::workspace::tests::new_editor_page(&test.workspace()).expect("a tab"); + let page = three_tabs(&test); let home = test.window.shell().expect("the shell").tab_view(); let away = second.shell().expect("the second shell").tab_view(); + let left_at = crate::ui::workspace::page_position(&home, &page).expect("the tab in its own view"); home.transfer_page(&page, &away, away.n_pages()); - second.send(SessionWindowInput::ReturnForeignTab(page.downgrade().into())); wait_until(WAIT, || holds_page(&home, &page) && !holds_page(&away, &page)) .expect("the tab to go back to its session's window"); + assert_eq!(crate::ui::workspace::page_position(&home, &page), Some(left_at)); + assert_eq!(home.selected_page().as_ref(), Some(&page)); + assert_eq!( + test.window + .shell() + .and_then(|shell| shell.owner_of(&page)) + .and_then(|(workspace, _)| workspace.key()), + Some(test.key()) + ); +} + +/// libadwaita takes a dragged tab out of the view, asks for a window when +/// it is dropped outside every one, and attaches it to the view it gets +/// back at position 0. A view outside any window stands in for the drag. +#[gtk4::test] +fn a_tab_dropped_outside_every_window_stays_where_it_was() { + let test = TestWindow::connected("TearOut"); + let page = three_tabs(&test); + let home = test.window.shell().expect("the shell").tab_view(); + let left_at = crate::ui::workspace::page_position(&home, &page).expect("the tab in its view"); + let drag = adw::TabView::new(); + home.transfer_page(&page, &drag, 0); + + let new_window = home.emit_by_name::>("create-window", &[]); + drag.transfer_page(&page, &home, 0); + drain_main_context(); + + assert_eq!(new_window.as_ref(), Some(&home), "no window opens for the tab"); + assert_eq!(crate::ui::workspace::page_position(&home, &page), Some(left_at)); + assert_eq!(home.selected_page().as_ref(), Some(&page)); + assert_eq!(home.n_pages(), 3); +} + +#[gtk4::test] +fn a_tab_dropped_back_on_its_own_bar_stays_where_it_was_dropped() { + let test = TestWindow::connected("DropHome"); + let page = three_tabs(&test); + let home = test.window.shell().expect("the shell").tab_view(); + let drag = adw::TabView::new(); + home.transfer_page(&page, &drag, 0); + + drag.transfer_page(&page, &home, 0); + drain_main_context(); + + assert_eq!(crate::ui::workspace::page_position(&home, &page), Some(0)); +} + +#[gtk4::test] +fn a_tab_whose_session_is_gone_closes_rather_than_stay_in_another_window() { + let test = TestWindow::connected("StrayTab"); + let home = test.window.shell().expect("the shell").tab_view(); + let stray = home.append(>k4::Label::new(None)); + + test.window + .send(SessionWindowInput::ReturnTab(stray.downgrade().into())); + wait_until(WAIT, || !holds_page(&home, &stray)).expect("the stray tab to close"); +} + +#[gtk4::test] +fn a_session_closing_while_its_tab_is_out_leaves_that_tab_alone() { + let (test, other) = open_both("TabOut"); + let shell = test.window.shell().expect("the shell"); + let leaving = test + .window + .workspaces() + .into_iter() + .find(|workspace| workspace.key() == Some(SessionKey::Saved(other.id))) + .expect("the second session"); + let page = crate::ui::workspace::tests::new_editor_page(&leaving).expect("a tab"); + let drag = adw::TabView::new(); + shell.tab_view().transfer_page(&page, &drag, 0); + let criticals = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = criticals.clone(); + let handler = gtk4::glib::log_set_handler( + Some("Adwaita"), + gtk4::glib::LogLevels::LEVEL_CRITICAL, + false, + false, + move |_, _, _| { + counted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }, + ); + + test.window + .send(SessionWindowInput::DisconnectSession(SessionKey::Saved(other.id))); + wait_until(WAIT, || test.window.workspaces().len() == 1).expect("the session to go"); + gtk4::glib::log_remove_handler(Some("Adwaita"), handler); + + assert_eq!( + criticals.load(std::sync::atomic::Ordering::Relaxed), + 0, + "the view was asked to close a page it does not hold" + ); + assert!(holds_page(&drag, &page)); + assert!(shell.owner_of(&page).is_none()); } diff --git a/linux/crates/app/src/ui/source_dialog.rs b/linux/crates/app/src/ui/source_dialog.rs index 7c42334591..66e2d442ab 100644 --- a/linux/crates/app/src/ui/source_dialog.rs +++ b/linux/crates/app/src/ui/source_dialog.rs @@ -9,7 +9,7 @@ use sourceview5::prelude::*; use tablepro_linux_platform::AppSettings; use crate::ui::clipboard::ClipboardPayload; -use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::format::format_thousands; use crate::ui::handler_guard::HandlerGuard; const FALLBACK_SCHEME: &str = "Adwaita"; @@ -91,10 +91,8 @@ pub(crate) fn source_dialog(text: SourceText, settings: Option<&Rc> let shown_in = view.downgrade(); dialog.connect_closed(move |_| { guards.borrow_mut().clear(); - // GtkSourceView before 5.21 never frees a view that has drawn its - // line numbers, so the closed dialog takes the text back. if let Some(view) = shown_in.upgrade() { - view.set_buffer(Some(&sourceview5::Buffer::new(None))); + crate::ui::text_view_teardown::release(&view); } }); SourceDialog { dialog, header } @@ -143,7 +141,7 @@ pub(crate) fn more_bytes(more: u64) -> String { crate::i18n::ngettext_f( "… ({count} more byte)", "… ({count} more bytes)", - plural_count(more), + more, &[("count", &format_thousands(more))], ) } @@ -214,6 +212,20 @@ mod tests { adw::StyleManager::default().set_color_scheme(adw::ColorScheme::Default); } + #[gtk4::test] + fn a_closed_dialog_lets_go_of_its_text_and_input_method() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let shown = source_dialog(text("SELECT secret FROM vault"), None); + shown.dialog.present(Some(&*window)); + let view = first_descendant_of_type::(&shown.dialog).expect("the text"); + + shown.dialog.force_close(); + + assert_eq!(view.buffer().char_count(), 0); + assert_eq!(view.im_module().as_deref(), Some("gtk-im-context-none")); + } + #[test] fn a_long_text_is_cut_saying_how_much_is_left() { assert_eq!(cut("short", 10), None); diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index 5468ab9f7d..6cc0dd21d9 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -141,6 +141,7 @@ impl ObjectImpl for StructureTab { /// The model holds a handler on the app-wide style manager, which /// would otherwise outlive the tab. fn dispose(&self) { + crate::ui::text_view_teardown::release(&self.sql_view.get()); self.link_watch.take(); if let Ok(mut model) = self.model.try_borrow_mut() && let Some(mut model) = model.take() diff --git a/linux/crates/app/src/ui/structure_tab/review_dialog.rs b/linux/crates/app/src/ui/structure_tab/review_dialog.rs index 250f1896b6..0a99a3719e 100644 --- a/linux/crates/app/src/ui/structure_tab/review_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/review_dialog.rs @@ -49,7 +49,7 @@ pub(crate) fn build( let heading = crate::i18n::ngettext_f( "Apply {count} Change to “{table}”?", "Apply {count} Changes to “{table}”?", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("count", &count.to_string()), ("table", table)], ); let dialog = adw::AlertDialog::new(Some(&heading), Some(&body(review, engine, asks_first))); @@ -162,11 +162,14 @@ pub(crate) fn sql_view(sql: &str, dialog: &adw::AlertDialog) -> gtk::ScrolledWin } }), ))); + let view = sourceview5::View::with_buffer(&buffer); + let shown_in = view.downgrade(); dialog.connect_closed(move |_| { theme_handler.borrow_mut().take(); + if let Some(view) = shown_in.upgrade() { + crate::ui::text_view_teardown::release(&view); + } }); - - let view = sourceview5::View::with_buffer(&buffer); view.set_editable(false); view.set_cursor_visible(false); view.set_monospace(true); diff --git a/linux/crates/app/src/ui/structure_tab/save_handlers.rs b/linux/crates/app/src/ui/structure_tab/save_handlers.rs index a28efa38c0..3ecc867e68 100644 --- a/linux/crates/app/src/ui/structure_tab/save_handlers.rs +++ b/linux/crates/app/src/ui/structure_tab/save_handlers.rs @@ -209,7 +209,7 @@ impl StructureModel { let label = crate::i18n::ngettext_f( "{n} unsaved change", "{n} unsaved changes", - u32::try_from(pending_count).unwrap_or(u32::MAX), + pending_count, &[("n", &pending_count.to_string())], ); crate::ui::a11y::show_status(&self.widgets.pending_label, &label); diff --git a/linux/crates/app/src/ui/tags/manage_tags_dialog.rs b/linux/crates/app/src/ui/tags/manage_tags_dialog.rs index b0fdf0b575..3c70425d6f 100644 --- a/linux/crates/app/src/ui/tags/manage_tags_dialog.rs +++ b/linux/crates/app/src/ui/tags/manage_tags_dialog.rs @@ -214,12 +214,7 @@ fn used_by(count: usize) -> String { if count == 0 { return crate::i18n::gettext("No connections"); } - crate::i18n::ngettext_f( - "{n} connection", - "{n} connections", - u32::try_from(count).unwrap_or(u32::MAX), - &[("n", &count.to_string())], - ) + crate::i18n::ngettext_f("{n} connection", "{n} connections", count, &[("n", &count.to_string())]) } pub(super) fn delete_alert(tag: &ConnectionTag, count: usize) -> adw::AlertDialog { @@ -227,7 +222,7 @@ pub(super) fn delete_alert(tag: &ConnectionTag, count: usize) -> adw::AlertDialo crate::i18n::ngettext_f( "It comes off {n} connection, which stays saved.", "It comes off {n} connections, which stay saved.", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], ) }); diff --git a/linux/crates/app/src/ui/text_view_teardown.rs b/linux/crates/app/src/ui/text_view_teardown.rs new file mode 100644 index 0000000000..43635dd6d0 --- /dev/null +++ b/linux/crates/app/src/ui/text_view_teardown.rs @@ -0,0 +1,49 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// Two toolkit cycles keep a closed text view alive (docs/toolkit-workarounds.md): dropping the input method +/// breaks IBus's, and an empty buffer frees the text where GtkSourceView's still holds the view. +pub(crate) fn release(view: &impl IsA) { + let view = view.as_ref(); + view.set_im_module(Some(NO_INPUT_METHOD)); + let empty: gtk::TextBuffer = match view.downcast_ref::() { + Some(_) => sourceview5::Buffer::new(None).upcast(), + None => gtk::TextBuffer::new(None), + }; + view.set_buffer(Some(&empty)); +} + +const NO_INPUT_METHOD: &str = "gtk-im-context-none"; + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn a_released_view_holds_no_text_and_no_input_method() { + let view = sourceview5::View::new(); + view.buffer().set_text("SELECT secret FROM vault;"); + + release(&view); + + assert_eq!(view.im_module().as_deref(), Some(NO_INPUT_METHOD)); + assert_eq!(view.buffer().char_count(), 0); + assert!( + view.buffer().is::(), + "a source view lost its source buffer" + ); + } + + #[gtk4::test] + fn a_released_plain_view_is_finalized_once_dropped() { + let view = gtk::TextView::new(); + view.buffer().set_text("draft"); + let weak = view.downgrade(); + + release(&view); + drop(view); + crate::test_support::drain_main_context(); + + assert!(weak.upgrade().is_none(), "the text view outlived its last reference"); + } +} diff --git a/linux/crates/app/src/ui/workspace/import.rs b/linux/crates/app/src/ui/workspace/import.rs index f7b2d0bc1c..745fa69f6f 100644 --- a/linux/crates/app/src/ui/workspace/import.rs +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use gtk4 as gtk; use gtk4::{gio, glib}; use libadwaita::prelude::*; -use relm4::{Component, ComponentController}; use std::ops::Range; use std::time::Duration; @@ -117,24 +116,18 @@ impl WorkspaceModel { columns: Vec, bytes: Vec, ) { - use crate::ui::import_dialog::{ImportCsvDialog, ImportCsvDialogInit, ImportCsvDialogOutput}; - - let dialog = ImportCsvDialog::builder() - .launch(ImportCsvDialogInit { - schema, - table, - columns, - bytes, - }) - .connect_receiver({ - let input = self.input.clone(); - move |_, out| { - input.deliver(match out { - ImportCsvDialogOutput::Import(import) => WorkspaceInput::ImportCsvRun(import), - }); - } - }); - dialog.model().dialog().present(self.dialog_parent().as_ref()); + use crate::ui::import_dialog::{ImportCsvDialog, ImportCsvDialogInit}; + + let dialog = ImportCsvDialog::new(ImportCsvDialogInit { + schema, + table, + columns, + bytes: Arc::from(bytes), + tasks: self.context.tasks.clone(), + }); + let input = self.input.clone(); + dialog.connect_import(move |_, import| input.deliver(WorkspaceInput::ImportCsvRun(Box::new(import)))); + dialog.present(self.dialog_parent().as_ref()); self.import_dialog = Some(dialog); } @@ -179,7 +172,7 @@ impl WorkspaceModel { input.deliver(WorkspaceInput::ShowToast(crate::i18n::ngettext_f( "Imported {n} row.", "Imported {n} rows.", - u32::try_from(rows).unwrap_or(u32::MAX), + rows, &[("n", &rows.to_string())], ))); } @@ -416,12 +409,11 @@ fn import_failure_text(error: &OperationError, step_rows: &[Range], first .take(*statement_index) .map(ExactSizeIterator::len) .sum(); - let count = u32::try_from(written).unwrap_or(u32::MAX); match lines(*statement_index) { Some((first, last)) if first == last => crate::i18n::ngettext_f( "Line {line} could not be written: {error}. The {written} row before it stays in the table, which has no transaction to undo it.", "Line {line} could not be written: {error}. The {written} rows before it stay in the table, which has no transaction to undo them.", - count, + written, &[ ("line", &first.to_string()), ("error", &driver_message(source)), @@ -434,7 +426,7 @@ fn import_failure_text(error: &OperationError, step_rows: &[Range], first Some((first, last)) => crate::i18n::ngettext_f( "Lines {first} to {last} could not be written: {error}. The table has no transaction to undo the import, so the {written} row before them stays, and some of these lines may have been written too.", "Lines {first} to {last} could not be written: {error}. The table has no transaction to undo the import, so the {written} rows before them stay, and some of these lines may have been written too.", - count, + written, &[ ("first", &first.to_string()), ("last", &last.to_string()), @@ -573,7 +565,7 @@ mod tests { columns: vec![column("name"), column("city")], mapping: vec![Some(0), Some(1)], options: tablepro_core::import::CsvImportOptions::default(), - bytes: bytes.as_bytes().to_vec(), + bytes: Arc::from(bytes.as_bytes()), } } diff --git a/linux/crates/app/src/ui/workspace/import_ledger.rs b/linux/crates/app/src/ui/workspace/import_ledger.rs index 0c5fb8d009..4be05fd883 100644 --- a/linux/crates/app/src/ui/workspace/import_ledger.rs +++ b/linux/crates/app/src/ui/workspace/import_ledger.rs @@ -9,7 +9,6 @@ use tablepro_session::runtime::{HoldToken, ProcessHold}; use tokio_util::sync::CancellationToken; use crate::i18n::{gettext, ngettext_f}; -use crate::ui::format::plural_count; use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor}; /// The CSV imports a workspace has running. @@ -153,7 +152,7 @@ impl CloseBlocker for ImportLedger { body: ngettext_f( "{n} CSV import is still running. Stopping it rolls back the rows it wrote, unless the table has no transactions.", "{n} CSV imports are still running. Stopping them rolls back the rows they wrote, unless the table has no transactions.", - plural_count(running as u64), + running, &[("n", &running.to_string())], ), keep_label: gettext("_Keep Importing"), diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index d2d5419edb..2827809de5 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -53,7 +53,6 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; -use relm4::{ComponentController, Controller}; use tablepro_core::meta::SchemaInfo; use tablepro_core::{DataModel, EditorSession}; @@ -63,6 +62,7 @@ use uuid::Uuid; use self::session_watches::SessionWatches; use self::tab_title::ConnectionLabel; pub(crate) use self::workspace_shell::WorkspaceShell; +pub(crate) use self::workspace_tab_view::page_position; use super::browse_tab::BrowseTab; use super::editor::SqlEditor; use super::export::{ExportLedger, ExportLedgerInit, ExportOffer, ExportReporter}; @@ -173,8 +173,8 @@ pub(crate) struct WorkspaceModel { /// The CSV imports running into this workspace's tables. imports: Rc, history_dialog: Option, - saved_queries_dialog: Option>, - import_dialog: Option>, + saved_queries_dialog: Option, + import_dialog: Option, driver_id: String, /// The driver as the user knows it, for the header's subtitle. driver_name: String, @@ -1212,10 +1212,10 @@ impl WorkspaceModel { close_if_presented(dialog.upcast_ref()); } if let Some(dialog) = self.saved_queries_dialog.take() { - close_if_presented(dialog.model().dialog()); + close_if_presented(dialog.upcast_ref()); } if let Some(dialog) = self.import_dialog.take() { - close_if_presented(dialog.model().dialog()); + close_if_presented(dialog.upcast_ref()); } let pages = if close_pages { self.index.pages() } else { Vec::new() }; self.close_tabs(); diff --git a/linux/crates/app/src/ui/workspace/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs index d6dda920b8..ee669cee91 100644 --- a/linux/crates/app/src/ui/workspace/status_pages.rs +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -1,9 +1,8 @@ use libadwaita as adw; use libadwaita::prelude::*; -use relm4::{Component, ComponentController}; use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, QueryDestination}; -use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; +use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit}; use super::{WorkspaceInput, WorkspaceModel}; use crate::runtime::DeliverInput; @@ -69,23 +68,19 @@ impl WorkspaceModel { if self.alert_history_unavailable(&crate::i18n::gettext("Saved Queries Unavailable")) { return; } - let dialog = SavedQueriesDialog::builder() - .launch(SavedQueriesDialogInit { - store: self.history.store().map(|history| history.saved_queries()), - active_connection: self.context.active.metadata().map(|metadata| metadata.id), - }) - .connect_receiver({ - let input = self.input.clone(); - move |_, out| { - input.deliver(match out { - SavedQueriesDialogOutput::OpenInNewTab(text) => WorkspaceInput::OpenHistoryQuery(text), - SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => { - WorkspaceInput::ReplaceActiveTabQuery(text) - } - }); - } + let dialog = SavedQueriesDialog::new(SavedQueriesDialogInit { + store: self.history.store().map(|history| history.saved_queries()), + active_connection: self.context.active.metadata().map(|metadata| metadata.id), + tasks: self.tasks.clone(), + }); + let input = self.input.clone(); + dialog.connect_query_chosen(move |_, destination, text| { + input.deliver(match destination { + QueryDestination::NewTab => WorkspaceInput::OpenHistoryQuery(text), + QueryDestination::CurrentTab => WorkspaceInput::ReplaceActiveTabQuery(text), }); - dialog.model().dialog().present(self.dialog_parent().as_ref()); + }); + dialog.present(self.dialog_parent().as_ref()); self.saved_queries_dialog = Some(dialog); } diff --git a/linux/crates/app/src/ui/workspace/transaction_banner.rs b/linux/crates/app/src/ui/workspace/transaction_banner.rs index 3217aee57e..fe4d72af08 100644 --- a/linux/crates/app/src/ui/workspace/transaction_banner.rs +++ b/linux/crates/app/src/ui/workspace/transaction_banner.rs @@ -40,7 +40,7 @@ fn transaction_banner(held: &[HeldTransaction]) -> Option { several => crate::i18n::ngettext_f( "{n} editor tab has an open transaction", "{n} editor tabs have open transactions", - u32::try_from(several.len()).unwrap_or(u32::MAX), + several.len(), &[("n", &several.len().to_string())], ), }; diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index 7ca4992ec1..22fe745529 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -15,7 +15,7 @@ use uuid::Uuid; use super::health_banner::REVIEW_HOST_KEY; use super::sidebar::ConnectionTree; -use super::workspace_shell_imp::{self as imp, ClosedTab, HeldSession}; +use super::workspace_shell_imp::{self as imp, ClosedTab, Departure, HeldSession}; use super::{CLOSED_TABS_CAPACITY, ClosedTabDescriptor, Workspace, WorkspaceInput}; use crate::application::AppServices; use crate::i18n::{gettext, gettext_f}; @@ -248,33 +248,61 @@ impl WorkspaceShell { shell.follow_selected_page(); } }); - // libadwaita 1.9 has no tab groups: every tab bar of the process - // takes a dropped tab, and a drop outside one asks for a window. - // A tab never leaves its session, so it comes back to this view. - view.connect_create_window(|view| Some(view.clone())); + // A tab never leaves its session's window (ADR 0028), and NULL logs a critical in libadwaita 1.9, so the + // dropped tab comes back to this view: docs/toolkit-workarounds.md, "A tab dropped outside every window". + let shell = self.downgrade(); + view.connect_create_window(move |view| { + if let Some(shell) = shell.upgrade() { + shell.imp().declined_tear_out.set(true); + } + Some(view.clone()) + }); let shell = self.downgrade(); view.connect_page_attached(move |view, page, _| { let Some(shell) = shell.upgrade() else { return; }; shell.tell_sharing(); - // A page appended here is not in its session's index yet; one - // dragged in from another window is in none of them. - if view.is_transferring_page() - && shell.owner_of(page).is_none() - && let Some(sender) = shell.imp().sender.get() - { - sender.deliver(SessionWindowInput::ReturnForeignTab(page.downgrade().into())); + // A page appended here is not in its session's index yet, so + // only a transfer counts. + if !view.is_transferring_page() { + return; + } + let declined = shell.imp().declined_tear_out.replace(false); + if declined || shell.owner_of(page).is_none() { + // libadwaita is still attaching it; the window's queue + // moves it once that is done. + shell.tell_window(SessionWindowInput::ReturnTab(page.downgrade().into())); + } else { + shell.take_departure(page); } }); let shell = self.downgrade(); - view.connect_page_detached(move |_, _, _| { - if let Some(shell) = shell.upgrade() { - shell.tell_sharing(); + view.connect_page_detached(move |view, page, position| { + let Some(shell) = shell.upgrade() else { + return; + }; + shell.tell_sharing(); + if view.is_transferring_page() && shell.owner_of(page).is_some() { + shell.imp().departure.replace(Some(Departure { + page: page.downgrade(), + position, + })); } }); } + /// Where `page` stood before a drag took it out of this view, once. + pub(crate) fn take_departure(&self, page: &adw::TabPage) -> Option { + let mut departure = self.imp().departure.borrow_mut(); + let left = departure + .as_ref() + .filter(|left| left.page.upgrade().as_ref() == Some(page))?; + let position = left.position; + *departure = None; + Some(position) + } + /// The header bars, the window's title and the actions that act on /// one session follow the session in front. fn bind_front(&self) { diff --git a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs index da9d8cd4ec..0b88179f47 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -1,4 +1,4 @@ -use std::cell::{OnceCell, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::collections::VecDeque; use gtk4 as gtk; @@ -28,6 +28,12 @@ pub(super) struct ClosedTab { pub(super) tab: ClosedTabDescriptor, } +/// Where a tab stood when a drag took it out of the view. +pub(super) struct Departure { + pub(super) page: glib::WeakRef, + pub(super) position: i32, +} + #[derive(Default, gtk::CompositeTemplate, glib::Properties)] #[properties(wrapper_type = super::WorkspaceShell)] #[template(resource = "/app/tablepro/TablePro/ui/workspace-shell.ui")] @@ -127,6 +133,11 @@ pub struct WorkspaceShell { pub(super) watches: RefCell>, /// The overview's new tab, connected to the front's tab factory. pub(super) create_tab: RefCell>, + /// The tab of this window a drag has out of the view. + pub(super) departure: RefCell>, + /// Set from `create-window` until libadwaita attaches the tab it + /// declined to put in a new window. + pub(super) declined_tear_out: Cell, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs index 27881ba7f1..6672df29a6 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs @@ -12,6 +12,12 @@ pub(super) fn keep_child_navigation_keys(view: &adw::TabView) { view.remove_shortcuts(CHILD_NAVIGATION_KEYS); } +/// Where `page` is in `view`, if the view holds it. `page_position` +/// logs a critical for a page of another view. +pub(crate) fn page_position(view: &adw::TabView, page: &adw::TabPage) -> Option { + (0..view.n_pages()).find(|&position| &view.nth_page(position) == page) +} + #[cfg(test)] mod tests { use super::*; diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index ef22586280..2e2eca3ee2 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -77,11 +77,10 @@ impl WorkspaceModel { WorkspaceLoad::Blocked(_) => self.layout_banner.set_revealed(true), } if unreadable > 0 { - let count = u32::try_from(unreadable).unwrap_or(u32::MAX); self.show_toast(&crate::i18n::ngettext_f( "Saved text of {n} query tab could not be read. The file was kept.", "Saved text of {n} query tabs could not be read. The files were kept.", - count, + unreadable, &[("n", &unreadable.to_string())], )); } @@ -693,6 +692,12 @@ impl TabViewWatch { for handler in handlers { view.disconnect(handler); } + // A page out on another window's bar closes there once it is + // sent back and finds no session, and this view cannot close it. + let pages: Vec = pages + .into_iter() + .filter(|page| super::page_position(&view, page).is_some()) + .collect(); self.leaving.replace(pages.clone()); for page in &pages { view.close_page(page); diff --git a/linux/crates/app/src/ui/write_confirmation.rs b/linux/crates/app/src/ui/write_confirmation.rs index 50258e4937..f61e495c9b 100644 --- a/linux/crates/app/src/ui/write_confirmation.rs +++ b/linux/crates/app/src/ui/write_confirmation.rs @@ -25,7 +25,7 @@ pub(crate) struct WriteConfirmation { impl WriteConfirmation { /// Editor statements that need an answer before the run starts. pub(crate) fn statements(connection: &str, statements: &[String], destructive: bool) -> Self { - let count = u32::try_from(statements.len()).unwrap_or(u32::MAX); + let count = statements.len(); let args = [("connection", connection), ("n", &statements.len().to_string())]; let body = match destructive { true => crate::i18n::ngettext_f( @@ -114,7 +114,7 @@ impl WriteConfirmation { body.push_str(&crate::i18n::ngettext_f( "The import runs {n} statement; the first is shown.", "The import runs {n} statements; the first is shown.", - u32::try_from(count).unwrap_or(u32::MAX), + count, &[("n", &count.to_string())], )); } @@ -122,7 +122,7 @@ impl WriteConfirmation { heading: crate::i18n::ngettext_f( "Import {n} Row into “{table}”?", "Import {n} Rows into “{table}”?", - u32::try_from(rows).unwrap_or(u32::MAX), + rows, &[("n", &rows_text), ("table", table)], ), body, diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index ea62efdffd..d3aebee5a6 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -12,7 +12,8 @@ use libadwaita::prelude::*; /// forced shut, answers with its close response. /// /// `choose_future` is not used: its callback aborts the process when -/// the dialog goes away without a response. +/// the dialog goes away without a response (docs/toolkit-workarounds.md, +/// "AdwAlertDialog.choose_future"). pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> String { let fallback = dialog.close_response().to_string(); let (sender, receiver) = oneshot::channel(); diff --git a/linux/crates/platform/src/secrets/keyring_error.rs b/linux/crates/platform/src/secrets/keyring_error.rs index 25bcd9dc11..1704820646 100644 --- a/linux/crates/platform/src/secrets/keyring_error.rs +++ b/linux/crates/platform/src/secrets/keyring_error.rs @@ -178,6 +178,20 @@ mod tests { assert!(matches!(mapped, SecretError::PortalUnavailable { .. }), "{mapped:?}"); } + // oo7 0.6 returns this from Keyring::new in a sandbox with no Secret portal, before any Secret Service + // fallback, so it is what the store sees there (docs/storage.md, Passwords in the keyring). + #[test] + fn a_missing_secret_portal_is_portal_unavailable() { + let portal = oo7::zbus::names::OwnedInterfaceName::try_from("org.freedesktop.portal.Secret") + .expect("a valid interface name"); + let mapped = map_error( + oo7::file::Error::Portal(oo7::ashpd::Error::PortalNotFound(portal)).into(), + Stage::Open, + ); + + assert!(matches!(mapped, SecretError::PortalUnavailable { .. }), "{mapped:?}"); + } + #[test] fn only_a_wire_error_invalidates_the_handle() { assert!(!invalidates_handle(&oo7::dbus::Error::Dismissed.into())); diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index 3d59485ed5..0ca5bf804a 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -24,7 +24,6 @@ TablePro is licensed under the GNU Affero General Public License, version 3 or l Used by: - flagset 0.4.7 -- fragile 2.1.0 ~~~~text @@ -9622,8 +9621,6 @@ Used by: - quote 1.0.47 - ref-cast-impl 1.0.27 - ref-cast 1.0.27 -- relm4-macros 0.11.0 -- relm4 0.11.0 - rustix 1.1.4 - rustversion 1.0.23 - semver 1.0.28 diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index 245e0f3ea2..acdf1494f6 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -5,8 +5,7 @@ "name": "Apache License 2.0", "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n", "used_by": [ - { "crate": { "name": "flagset", "version": "0.4.7" } }, - { "crate": { "name": "fragile", "version": "2.1.0" } } + { "crate": { "name": "flagset", "version": "0.4.7" } } ] }, { @@ -1778,8 +1777,6 @@ { "crate": { "name": "quote", "version": "1.0.47" } }, { "crate": { "name": "ref-cast-impl", "version": "1.0.27" } }, { "crate": { "name": "ref-cast", "version": "1.0.27" } }, - { "crate": { "name": "relm4-macros", "version": "0.11.0" } }, - { "crate": { "name": "relm4", "version": "0.11.0" } }, { "crate": { "name": "rustix", "version": "1.1.4" } }, { "crate": { "name": "rustversion", "version": "1.0.23" } }, { "crate": { "name": "semver", "version": "1.0.28" } }, @@ -2609,7 +2606,6 @@ { "name": "foreign-types-macros", "version": "0.2.4", "license": "MIT OR Apache-2.0" }, { "name": "foreign-types-shared", "version": "0.3.1", "license": "MIT OR Apache-2.0" }, { "name": "form_urlencoded", "version": "1.2.2", "license": "MIT OR Apache-2.0" }, - { "name": "fragile", "version": "2.1.0", "license": "Apache-2.0" }, { "name": "funty", "version": "2.0.0", "license": "MIT" }, { "name": "futures", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, { "name": "futures-channel", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, @@ -2807,8 +2803,6 @@ { "name": "regex", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, { "name": "regex-automata", "version": "0.4.18", "license": "MIT OR Apache-2.0" }, { "name": "regex-syntax", "version": "0.8.11", "license": "MIT OR Apache-2.0" }, - { "name": "relm4", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, - { "name": "relm4-macros", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, { "name": "resolv-conf", "version": "0.7.6", "license": "MIT OR Apache-2.0" }, { "name": "rmcp", "version": "3.6.0", "license": "Apache-2.0 AND MIT" }, { "name": "rmp", "version": "0.8.15", "license": "MIT" }, diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 9eb7d6a843..4d2ba79ed3 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -23,6 +23,7 @@ ui/find-bar.ui ui/history-dialog.ui ui/history-row.ui + ui/import-csv-dialog.ui ui/import-from-app-dialog.ui ui/pager-bar.ui ui/pending-changes-bar.ui @@ -36,6 +37,8 @@ ui/preferences-mcp-page.ui ui/results-pane.ui ui/row-detail-pane.ui + ui/saved-queries-dialog.ui + ui/saved-query-row.ui ui/session-window.ui ui/sql-editor.ui ui/database-file-row.ui diff --git a/linux/data/resources/ui/import-csv-dialog.ui b/linux/data/resources/ui/import-csv-dialog.ui new file mode 100644 index 0000000000..c3364b06cd --- /dev/null +++ b/linux/data/resources/ui/import-csv-dialog.ui @@ -0,0 +1,88 @@ + + + + + + diff --git a/linux/data/resources/ui/saved-queries-dialog.ui b/linux/data/resources/ui/saved-queries-dialog.ui new file mode 100644 index 0000000000..5936dc8291 --- /dev/null +++ b/linux/data/resources/ui/saved-queries-dialog.ui @@ -0,0 +1,98 @@ + + + + + + diff --git a/linux/data/resources/ui/saved-query-row.ui b/linux/data/resources/ui/saved-query-row.ui new file mode 100644 index 0000000000..e55a8d8533 --- /dev/null +++ b/linux/data/resources/ui/saved-query-row.ui @@ -0,0 +1,51 @@ + + + + + diff --git a/linux/docs/README.md b/linux/docs/README.md index 078c09b8ba..c09095a042 100644 --- a/linux/docs/README.md +++ b/linux/docs/README.md @@ -11,6 +11,7 @@ The reference for working on the Linux app. Start with [ARCHITECTURE.md](../ARCH | [state-management.md](state-management.md) | Where the application, window, workspace and tab state lives, and the rules every view follows | | [storage.md](storage.md) | Which store holds which data: files, GSettings, the Secret Service and SQLite | | [testing.md](testing.md) | The test suites, the conformance and Docker tests, the manual passes and the CI workflows | +| [toolkit-workarounds.md](toolkit-workarounds.md) | Each place the app steps around a GTK, libadwaita, GtkSourceView, IBus or gtk-rs defect, and when it can go | | [troubleshooting.md](troubleshooting.md) | For users: saved passwords, SSH, Kerberos, logs, crash reports, bug reports and downgrades | | [regression-tests.md](regression-tests.md) | The tests that guard each critical audit finding | | [releasing.md](releasing.md) | The setup before the first release, a release, a packaging fix and a fix release | diff --git a/linux/docs/decisions/0003-relm4-architecture.md b/linux/docs/decisions/0003-relm4-architecture.md index 5830aa0557..9e1ad6dc79 100644 --- a/linux/docs/decisions/0003-relm4-architecture.md +++ b/linux/docs/decisions/0003-relm4-architecture.md @@ -1,6 +1,6 @@ # 0003: Relm4 for app architecture -- **Status**: Accepted; superseded by [0012](0012-window-per-session.md) except for the saved-queries and CSV import dialogs, which are still Relm4 components +- **Status**: Superseded by [0012](0012-window-per-session.md) - **Date**: 2026-04-26 ## Context diff --git a/linux/docs/decisions/0005-supply-chain-policy.md b/linux/docs/decisions/0005-supply-chain-policy.md index aa698cc5ed..281d03107e 100644 --- a/linux/docs/decisions/0005-supply-chain-policy.md +++ b/linux/docs/decisions/0005-supply-chain-policy.md @@ -1,6 +1,6 @@ # 0005: Supply-chain policy -- **Status**: Accepted +- **Status**: Accepted; the MySQL CA bundle's watch list amended by [0033](0033-mysql-ca-bundle-removal-condition.md) - **Date**: 2026-09-15 ## Context diff --git a/linux/docs/decisions/0033-mysql-ca-bundle-removal-condition.md b/linux/docs/decisions/0033-mysql-ca-bundle-removal-condition.md new file mode 100644 index 0000000000..3e86905183 --- /dev/null +++ b/linux/docs/decisions/0033-mysql-ca-bundle-removal-condition.md @@ -0,0 +1,17 @@ +# 0033: When the MySQL CA bundle goes + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +[0005](0005-supply-chain-policy.md) keeps `mysql_ca_bundle_pem` because sqlx takes MySQL CA certificates only as PEM bytes. Its watch list says sqlx [#4044](https://github.com/transact-rs/sqlx/issues/4044) and [#4045](https://github.com/transact-rs/sqlx/pull/4045) "let a caller pass a rustls `ClientConfig`". They do not. #4044 asks for rustls-platform-verifier support, and #4045 adds the `tls-rustls-aws-lc-rs-platform-verifier` and `tls-rustls-ring-platform-verifier` features and drops rustls-native-certs. No caller-supplied configuration or verifier comes with them. + +## Decision + +The bundle goes in the commit that switches MySQL to an sqlx release whose own TLS verifies the system trust store, accepts the connection's CA file as an extra root, and checks the server name, each proven by a test against a MySQL server. #4045 merging is a reason to run those tests, not a removal on its own. + +## Consequences + +- 0005's watch entry for the bundle reads as this one. +- If #4045 lands without extra roots beside the platform verifier, the bundle stays, and the watch moves to an sqlx API that takes a rustls `ClientConfig` or verifier. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 78e1f1cd69..4c4592718e 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -51,9 +51,9 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v |---|---|---|---| | [0001](0001-no-plugin-system.md) | No plugin system; drivers are statically linked | Superseded by core 0004 | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | | [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted; version floor superseded by 0015, the Relm4 consequence by 0012 | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | -| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded by 0012 except for the saved-queries and CSV import dialogs | Elm-style components scale to TablePro's view count. | +| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Superseded by 0012 | Elm-style components scale to TablePro's view count. | | [0004](0004-libsecret-secret-storage.md) | Secret storage through oo7 | Accepted; store API and Flatpak path amended 2026-09-15, the rules for edits 2026-10-15 | The Secret Service on the host, and oo7's own keyring file with a key from the Secret portal inside Flatpak; never a plain-text file. | -| [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | +| [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted; the MySQL CA bundle's watch list amended by 0033 | Maintained crates first; custom code needs recorded evidence and a removal trigger. | | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted; where the version lives amended by 0017, the release tarball and the place of the Cargo workspace amended by core 0001 | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted; the schema id and path per profile amended 2026-10-15 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | @@ -80,6 +80,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | | [0031](0031-duckdb-2.md) | DuckDB 2.0, writing files DuckDB 1.x reads | Accepted | duckdb 1.20000.1 compiles DuckDB 2.0.0, and every database opens with `storage_compatibility_version = 'v0.10.2'` so new and attached files stay readable by DuckDB 1.x. | | [0032](0032-connection-list-tree.md) | The connection list is the library's tree in a list view | Accepted | The picker and the window's tree show groups nested and in the library's order from one outline keyed by id, with Favourites and Recent sections in the picker, drag and drop, and a menu item for every move. | +| [0033](0033-mysql-ca-bundle-removal-condition.md) | When the MySQL CA bundle goes | Accepted | sqlx #4044/#4045 add rustls-platform-verifier features, not a caller's ClientConfig; the bundle goes once sqlx's own TLS passes system-trust, extra-CA and name tests against MySQL. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 1cb5674827..8385452783 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -138,7 +138,7 @@ The only text shown as it came is what another party wrote, inside a sentence th ## Logging -Logging goes through `tracing`. `crates/app/src/logging.rs` installs a `tracing_subscriber` fmt layer that writes to **stderr**, which the GNOME session journals for both Flatpak and system installs. Panics land in the same stream, so a panic and the lines around it stay in order. There is no journald writer. +Logging goes through `tracing`. `crates/app/src/logging.rs` installs a `tracing_subscriber` fmt layer that writes to **stderr**. Started from the desktop, a systemd user session connects stderr to the journal, for Flatpak and system installs alike; started from a terminal, it goes to the terminal. Panics land in the same stream, so a panic and the lines around it stay in order. There is no journald writer. - The filter comes from `RUST_LOG`. Without it, a development build logs at `debug` and an installed one at `info`. A `RUST_LOG` that does not parse falls back to that default and logs a warning saying so. - `TABLEPRO_LOG_FORMAT=json` writes one JSON object per line; `text`, the default, writes plain lines. @@ -159,7 +159,7 @@ The libraries under the drivers log what a database was sent or answered: tokio- A panic on the GTK thread ends the process: GTK calls the app back through C, which a panic cannot unwind, so it aborts. A panic on a runtime thread is caught there (`session/src/runtime/catch_panic.rs`, and tokio's own `JoinError`) and ends one task, which the UI shows as a failure. -- `crates/app/src/crash_report.rs` installs a panic hook in `run()`, right after logging. It runs the default hook first, so stderr and the journal keep the usual line. +- `crates/app/src/crash_report.rs` installs a panic hook in `run()`, right after logging. It runs the default hook first, so stderr keeps the usual line. It handles Rust panics only: a signal such as a segmentation fault in a C library ends the process without a report here, and systemd-coredump, where the system has it, keeps that one (`coredumpctl`). - For a panic on the GTK thread it then appends a report to `StoragePaths::crashes_dir()` (`$XDG_STATE_HOME//crashes`): the version, application ID, profile, debug or release build, the time, the message and place, and a backtrace captured whatever `RUST_BACKTRACE` says. The file is 0600 in a 0700 directory, written through `tablepro_storage::fs::append_private_blocking`. One file per process, named `crash--.txt`; the second panic Rust raises when the first cannot unwind goes in the same file. - At the next launch the primary instance looks for the newest report it has not announced, off the GTK thread, and records its name in `crashes/announced`. The first window shows a toast, "TablePro quit unexpectedly last time", whose Show Report button opens the folder with the report selected. The toast moves with the window from page to page until it is dismissed or times out. Only the 20 newest reports are kept. - Nothing leaves the machine: the user decides whether to attach the report to an issue. diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index 1e3642c33d..c31c8e4098 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -1,6 +1,6 @@ # State management -The `app` crate's application, windows, workspace and tabs are gtk-rs subclasses ([decisions/0012-window-per-session.md](decisions/0012-window-per-session.md)). A few views are still [Relm4](https://relm4.org) components ([decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. New views are subclasses. This page covers where state lives, the rules every view follows, and the patterns for each kind. +The `app` crate's application, windows, workspace, tabs and dialogs are gtk-rs subclasses ([decisions/0012-window-per-session.md](decisions/0012-window-per-session.md)). This page covers where state lives, the rules every view follows, and the patterns for each kind. ## Where state lives @@ -21,9 +21,9 @@ Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `cr ## Rules for every view -**Background work goes through `Tasks`.** Use `crate::runtime::spawn_and_deliver` to run work on the app runtime and get its result on the GTK thread, and `spawn_held` for work that has to finish even if its window goes, such as saving a named query or importing a connection list. A table or structure save goes through `WorkspaceHandle::save`, whose `SaveLedger` holds the process. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current`, `relm4::spawn` and their siblings, so nothing reaches for whichever runtime happens to be ambient. +**Background work goes through `Tasks`.** Use `crate::runtime::spawn_and_deliver` to run work on the app runtime and get its result on the GTK thread, and `spawn_held` for work that has to finish even if its window goes, such as saving a named query or importing a connection list. A table or structure save goes through `WorkspaceHandle::save`, whose `SaveLedger` holds the process. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current` and their siblings, so nothing reaches for whichever runtime happens to be ambient. -**Input goes through `DeliverInput::deliver`.** It works on an input queue's sender, a `relm4::Sender`, a `ComponentSender` and a `FactorySender`. A message to a view that has shut down is dropped and logged at debug under the `component` target, so a read or a save that comes back after its window closed changes nothing. `ComponentSender::input` and `FactorySender::input` are banned: relm4 0.11 calls `expect` on a closed channel, which would take every window down. Use `try_deliver` when the caller must act on a dropped message, as `QuitSequence` does when a window is already gone. +**Input goes through `DeliverInput::deliver`.** It works on an input queue's sender. A message to a view that has shut down is dropped and logged at debug under the `component` target, so a read or a save that comes back after its window closed changes nothing. Use `try_deliver` when the caller must act on a dropped message, as `QuitSequence` does when a window is already gone. **A watch loop is a `WatchForwarder`.** The session publishes its health (`Session::health`), its link generation (`link_changes`) and each scope's objects (`subscribe_objects`) on `tokio::sync::watch` channels. A view keeps a `WatchForwarder` in a field: it delivers the current value, then every change, on the GTK thread, and stops when the field drops. A value published after the view went away is never delivered. @@ -79,6 +79,14 @@ The closure holds only the sender, so a window that closed before the read ends A browse or structure tab is a subclass whose model sits in its `imp` struct. `send` hands the model one input and handles it at once; an input a widget signal sets off while another is being handled waits in the tab's queue until that one finishes. The model's update returns effects: more inputs for the queue, and outputs the tab emits as signals. The workspace connects those signals, tags them with the tab's id and queues them as `WorkspaceInput`, so the tabs never call each other. +A tab belongs to its session, and a session to one window ([decisions/0028-sessions-share-a-window.md](decisions/0028-sessions-share-a-window.md)). libadwaita 1.9 lets every tab bar of the process take a dragged tab, so the shell holds a tab to that while the drag moves it: + +- When a drag takes a tab out of the view, the shell of its session's window records the position it left (`Departure`, from `page-detached`). +- Dropped on its own bar, it stays where it was dropped. +- Dropped on another window's bar, that window sends it back (`SessionWindowInput::ReturnTab`), into its session's view at the position it left. +- Dropped outside every window, `create-window` declines a new window and the tab goes back to that position ([toolkit-workarounds.md](toolkit-workarounds.md#a-tab-dropped-outside-every-window)). +- `ReturnTab` runs from the window's queue, after libadwaita finishes attaching the page. A tab whose session closed while it was out closes where it is; the session's close already asked about it, and its view no longer holds the page to close it. + ## The SQL editor `SqlEditor` (`crates/app/src/ui/editor/`) runs SQL on a connection of its own and lists what each statement returned. @@ -141,31 +149,22 @@ The tab emits `transaction-changed` when that flips, and the workspace reads `Wo - Pinning moves the row to where the history now lists it without reading again. A row that now sorts past the pages read comes back with its page. - Delete happens at once and offers Undo ([storage.md](storage.md#deleting-and-restoring)). -## Relm4 components - -The components that remain follow these conventions until a rewrite turns them into subclasses: - -- Input reaches them through `deliver`. Their output reaches the subclass that hosts them through `connect_receiver`, whose closure delivers it to that subclass's queue. -- Their commands (`sender.command`, `oneshot_command`) run on Relm4's own runtime, not on `Tasks`. The saved-queries dialog still loads that way; new work goes through `spawn_and_deliver`. -- They present their dialogs on the window they belong to. `ComponentBuilder::transient_for` is banned, because Relm4 applies it only from `relm4::RelmApp`, which this app does not use. - ## Naming - `Input`: every message a view handles, as an enum (`SessionWindowInput`, `WorkspaceInput`, `BrowseTabInput`). - A view with many inputs groups them by the state each one changes, one enum per group (`BrowseTabInput::Paging(PagingInput)`, `StructureTabInput::Save(SaveInput)`), and handles each group in its own `*_handlers` module. Senders pass the group's enum; `send` takes anything that converts into the view's input. - `Output`: only the messages a parent should react to. -- `Cmd`: a Relm4 command's result. - `Init`: what the parent hands a view when it builds it (`SessionWindowInit`, `WorkspaceInit`). ## Alert dialogs - `AdwAlertDialog` closes itself before it emits `response`. A response handler that calls `close()` again finds the dialog gone, and libadwaita logs a critical when animations are off. - That close emits `closed` before the `response` the click chose. A `closed` handler that answers at once turns every click into the close response. `workspace::respond` defers its close response to an idle callback, so the click's response comes first. -- To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response. `choose_future` is not used: its callback aborts the process in that case. +- To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response. `choose_future` is not used: its callback aborts the process in that case ([toolkit-workarounds.md](toolkit-workarounds.md#adwalertdialogchoose_future)). ## Anti-patterns to flag in review -- `tokio::spawn`, `relm4::spawn` or `Handle::current` in app code. Use `Tasks`. +- `tokio::spawn` or `Handle::current` in app code. Use `Tasks`. - `ComponentSender::input` or `FactorySender::input`. Use `deliver`. - A hand-written loop over a watch channel. Use `WatchForwarder`. - A view that keeps a `Connection`, or a dialect past a link change. diff --git a/linux/docs/storage.md b/linux/docs/storage.md index ecc8a25fab..537228079a 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -56,7 +56,7 @@ SSH passwords and key passphrases sit in the keyring next to database passwords. `SecretStore` (`tablepro_linux_platform`) is the app's `SecretVault`: `store`, `load`, `relabel`, `delete_kind` and `delete_connection`, keyed by connection id and `SecretKind`, with the secret as a `SecretString`. It is also the app's `SecretItems`, which holds the MCP access token and the AI providers' API keys and labels them through the app's `SecretItemLabeler`. -Backed by the [`oo7`](https://crates.io/crates/oo7) crate ([ADR 0004](decisions/0004-libsecret-secret-storage.md)). Outside Flatpak it talks to the host's Secret Service over D-Bus, which GNOME Keyring and KWallet both implement. Inside Flatpak it keeps the items in an encrypted file in the sandbox, unlocked with a key from the Secret portal, and uses the Secret Service only when there is no portal. +Backed by the [`oo7`](https://crates.io/crates/oo7) crate ([ADR 0004](decisions/0004-libsecret-secret-storage.md)). Outside Flatpak it talks to the host's Secret Service over D-Bus, which GNOME Keyring and KWallet both implement. Inside Flatpak it keeps the items in an encrypted file in the sandbox, unlocked with a key from the Secret portal. Without the portal, oo7 0.6 reports the portal's error rather than trying the Secret Service, which the sandbox cannot reach anyway, so the store reports `PortalUnavailable`. Notes: @@ -66,7 +66,7 @@ Notes: - Labels name the kind and the target, for example `TablePro database password for app@db.example.com:5432/shop`, so the item is identifiable in Seahorse on a host install. Under Flatpak the items sit in the app's own file keyring. - The store unlocks the keyring and the item before reading. A locked keyring otherwise looks exactly like a connection with no saved password. - Failures are typed (`SecretError`): a dismissed prompt, a locked keyring, a missing service, a missing portal and a bad encoding each get their own message and their own next step. -- With no Secret Service at all the store reports `ServiceUnavailable` and the UI prompts at connect time. The app never writes passwords to a plain file as a fallback. +- With no Secret Service on the host, or no Secret portal inside Flatpak, the store reports `ServiceUnavailable` or `PortalUnavailable` and the UI prompts at connect time. The app never writes passwords to a plain file as a fallback. - Never log a password, ever. Wrap them in `secrecy::SecretString` from the `secrecy` crate before they leave the storage layer. ### When the keyring cannot supply a secret diff --git a/linux/docs/toolkit-workarounds.md b/linux/docs/toolkit-workarounds.md new file mode 100644 index 0000000000..fa6cfc4226 --- /dev/null +++ b/linux/docs/toolkit-workarounds.md @@ -0,0 +1,43 @@ +# Toolkit workarounds + +The places the app steps around a GTK, libadwaita, GtkSourceView, IBus or gtk-rs defect. Each entry says what the defect is, where the code is, the versions it was seen in, the test that holds the workaround, and when it can go. Workarounds for a database client library are in [decisions/0005-supply-chain-policy.md](decisions/0005-supply-chain-policy.md) and the [driver pages](drivers/); the platform floor is in [decisions/0015-gnome-50-platform-floor.md](decisions/0015-gnome-50-platform-floor.md). + +## A tab dropped outside every window + +- **Defect.** libadwaita documents the return of `AdwTabView::create-window` as nullable, but `adw_tab_view_create_window` logs `g_critical ("AdwTabView::create-window handler must not return NULL")` for NULL (`src/adw-tab-view.c`). So a handler cannot decline a new window without a critical. libadwaita then attaches the page to whatever view the handler returns, at position 0 (`detach_into_new_window` in `src/adw-tab-box.c`). Tab groups, which would keep a tab in its window, are a TODO there (`is_view_in_the_same_group`). No upstream report yet. +- **Seen in.** libadwaita 1.9.0 and main on 2026-11-05. +- **Workaround.** A tab belongs to its session's window ([state-management.md](state-management.md#tabs)), so `create-window` returns the tab's own view and the shell moves the page back to where the drag took it from (`crates/app/src/ui/workspace/workspace_shell.rs`, `SessionWindowInput::ReturnTab`). +- **Test.** `a_tab_dropped_outside_every_window_stays_where_it_was` in `crates/app/src/ui/session_window/tests/sessions.rs`. +- **Remove when** the floor's libadwaita takes NULL from `create-window` as a decline without a critical, or has tab groups: return `None`, or put each window's view in its own group, and drop the position restore for this case. + +## IBus keeps every text view + +- **Defect.** From GTK 4.24 a `GtkTextView` or `GtkText` makes itself its input method's client widget at construction and clears it only in its own dispose (GTK commit 3df992599b). ibus-gtk4 holds the client with a strong reference (`ibus_im_context_set_client_widget`), so the view is never disposed, nor anything it holds. [ibus/ibus#2968](https://github.com/ibus/ibus/issues/2968), open. +- **Seen in.** GTK 4.24.1 with ibus-gtk4 1.5.34 (Ubuntu 26.10) and 1.5.35-beta1 (the GNOME 51 runtime). Not on GTK 4.22, the floor, which sets the client on realize and clears it on unrealize. Measured on 2026-11-05 with the issue's C reproducer extended to GtkSourceView: on GTK 4.24.1 with IBus every text view, shown or not, stays alive, and each one is freed when its `im-module` is set to `gtk-im-context-none` first; with `gtk-im-context-simple` every one is freed. +- **Workaround.** `crate::ui::text_view_teardown::release` sets `gtk-im-context-none` on the views that hold the user's text when they close: the SQL editor, the source dialog (definitions and row JSON), the structure tab's SQL, the change review, the row detail value and the assistant's composer. The text in an entry (`GtkText`) leaks the same way; entries have no workaround here. +- **Test.** `text_view_teardown` tests, `a_closed_dialog_lets_go_of_its_text_and_input_method` in `crates/app/src/ui/source_dialog.rs`. +- **Remove when** an ibus-gtk4 release holds the client widget weakly and the distributions and runtimes TablePro ships for carry it. + +## GtkSourceView keeps a view that drew line numbers + +- **Defect.** The gutter's lines object holds its view strongly, so a view that showed line numbers is never disposed. Fixed by [GNOME/gtksourceview!438](https://gitlab.gnome.org/GNOME/gtksourceview/-/merge_requests/438) (9cb5802f "gutterlines: use a weak reference to the view", with ea1eff56 and b17426c2). +- **Seen in.** GtkSourceView 5.18.0 (Ubuntu 26.04, the floor), with or without IBus. Fixed in 5.21.0 and 5.22.0. +- **Workaround.** The same `release` gives the view an empty buffer, so the text and the language state are freed even while the view is kept. +- **Test.** As above: the buffer is empty after each close path. +- **Remove when** the floor reaches GtkSourceView 5.22, together with the IBus entry or after it. + +## AdwAlertDialog.choose_future + +- **Defect.** `adw_alert_dialog_choose_finish` returns `g_quark_to_string (g_task_propagate_int (...))`. Once the call's cancellable fires, the task's error makes `g_task_propagate_int` return -1 and the result is NULL (`src/adw-alert-dialog.c`), though its documentation promises the close response. libadwaita-rs reads that result with `from_glib_none` into a `GString` inside an `extern "C"` callback (`src/alert_dialog.rs`), where the panic aborts the process. `choose_future` cancels when its future is dropped, such as when the window that awaits it closes. A dialog that goes without emitting `response` leaves `choose` waiting for good. No upstream report yet. +- **Seen in.** libadwaita 1.9.0 (`choose` since 1.5) with libadwaita-rs 0.9.2. +- **Workaround.** `crate::workspace::respond` (`crates/app/src/workspace/dialog_response.rs`) answers from `response`, and from `closed` with the close response once a click's `response` has had its turn ([state-management.md](state-management.md#alert-dialogs)). +- **Test.** `a_clicked_response_answers_and_closes_the_alert` and `an_alert_forced_shut_answers_with_its_close_response` in `dialog_response.rs`. +- **Remove when** libadwaita returns the close response on cancel and on a forced close, and libadwaita-rs maps a NULL result without panicking. + +## ColumnViewCell.set_focusable in gtk-rs + +- **Defect.** gtk4-rs generates `ColumnViewCell::set_focusable` only under `cfg(not(feature = "v4_12"))` (`src/auto/column_view_cell.rs`). With v4_12 on, `cell.set_focusable` resolves to `ListItemExt::set_focusable`, which sets GtkListItem's own `focusable`, and GtkColumnViewCell reads its own property of that name. No upstream report yet. +- **Seen in.** gtk4-rs 0.11.4 and main on 2026-11-05. +- **Workaround.** `cell.set_property("focusable", true)` in `crates/app/src/ui/data_grid/columns.rs`, and `false` for the row number cells in `row_number_column.rs`. +- **Test.** The grid's keyboard tests in `crates/app/src/ui/data_grid/tests/gutter.rs` read `is_focusable` on the cells. +- **Remove when** a gtk4-rs release generates `ColumnViewCell::set_focusable` under v4_12: call it. diff --git a/linux/docs/troubleshooting.md b/linux/docs/troubleshooting.md index ff441f1272..cf5538f4d4 100644 --- a/linux/docs/troubleshooting.md +++ b/linux/docs/troubleshooting.md @@ -71,7 +71,13 @@ Read the log before you attach it. It names your hosts, databases and tables. It ## Crash reports -TablePro writes a report of each crash to `~/.local/state/tablepro/crashes/`. Attach the newest one to the bug report, with the log from the same time. +When TablePro stops on an error of its own, it writes a report to `~/.local/state/tablepro/crashes/`. Attach the newest one to the bug report, with the log from the same time. + +A crash in a system library leaves no report there. On a system with systemd-coredump, list it with: + +```sh +coredumpctl list tablepro +``` ## Reporting a bug diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 3628fbd60a..bafe6648b4 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -437,7 +437,10 @@ crates/app/src/ui/history_dialog/tests.rs crates/app/src/ui/host_key_failure.rs crates/app/src/ui/host_key_review.rs crates/app/src/ui/icons.rs -crates/app/src/ui/import_dialog.rs +crates/app/src/ui/import_dialog/imp.rs +crates/app/src/ui/import_dialog/mod.rs +crates/app/src/ui/import_dialog/preview_text.rs +crates/app/src/ui/import_dialog/tests.rs crates/app/src/ui/import_from_app/imp.rs crates/app/src/ui/import_from_app/mod.rs crates/app/src/ui/import_from_app/tests.rs @@ -474,7 +477,13 @@ crates/app/src/ui/result_pane/result_limit_banner.rs crates/app/src/ui/result_pane/result_set_view.rs crates/app/src/ui/result_pane/result_summary.rs crates/app/src/ui/safe_mode.rs -crates/app/src/ui/saved_queries_dialog.rs +crates/app/src/ui/saved_queries_dialog/imp.rs +crates/app/src/ui/saved_queries_dialog/mod.rs +crates/app/src/ui/saved_queries_dialog/saved_query_listing.rs +crates/app/src/ui/saved_queries_dialog/saved_query_object.rs +crates/app/src/ui/saved_queries_dialog/saved_query_object_imp.rs +crates/app/src/ui/saved_queries_dialog/saved_query_row.rs +crates/app/src/ui/saved_queries_dialog/tests.rs crates/app/src/ui/session_target.rs crates/app/src/ui/session_window/application_notification_sink.rs crates/app/src/ui/session_window/connect_failed_page.rs @@ -567,6 +576,7 @@ crates/app/src/ui/tab_load/stale_banner.rs crates/app/src/ui/tags/manage_tags_dialog.rs crates/app/src/ui/tags/mod.rs crates/app/src/ui/tags/tag_dialog.rs +crates/app/src/ui/text_view_teardown.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/ui/workspace/assistant.rs @@ -719,6 +729,7 @@ data/resources/ui/filter-strip.ui data/resources/ui/find-bar.ui data/resources/ui/history-dialog.ui data/resources/ui/history-row.ui +data/resources/ui/import-csv-dialog.ui data/resources/ui/import-from-app-dialog.ui data/resources/ui/pager-bar.ui data/resources/ui/pending-changes-bar.ui @@ -732,6 +743,8 @@ data/resources/ui/preferences-history-group.ui data/resources/ui/preferences-mcp-page.ui data/resources/ui/results-pane.ui data/resources/ui/row-detail-pane.ui +data/resources/ui/saved-queries-dialog.ui +data/resources/ui/saved-query-row.ui data/resources/ui/session-window.ui data/resources/ui/sql-editor.ui data/resources/ui/ssh-section.ui From 9766da4b2ba71ce8281f277c721c07b657fa36b5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:48:57 +0700 Subject: [PATCH 2/6] fix(linux): let the CSV import dialog drop the file once it is handed over --- linux/changelog.d/4056.fixed.1.md | 1 + linux/changelog.d/4056.fixed.2.md | 1 + linux/changelog.d/4056.fixed.3.md | 1 + linux/changelog.d/4056.fixed.md | 1 + linux/crates/app/src/ui/import_dialog/imp.rs | 5 ++++- linux/crates/app/src/ui/import_dialog/mod.rs | 13 +++++++++---- linux/crates/app/src/ui/import_dialog/tests.rs | 2 ++ 7 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 linux/changelog.d/4056.fixed.1.md create mode 100644 linux/changelog.d/4056.fixed.2.md create mode 100644 linux/changelog.d/4056.fixed.3.md create mode 100644 linux/changelog.d/4056.fixed.md diff --git a/linux/changelog.d/4056.fixed.1.md b/linux/changelog.d/4056.fixed.1.md new file mode 100644 index 0000000000..83a7ccb6c1 --- /dev/null +++ b/linux/changelog.d/4056.fixed.1.md @@ -0,0 +1 @@ +Opening a large CSV file for import froze the window while its preview was read. diff --git a/linux/changelog.d/4056.fixed.2.md b/linux/changelog.d/4056.fixed.2.md new file mode 100644 index 0000000000..47066ca08b --- /dev/null +++ b/linux/changelog.d/4056.fixed.2.md @@ -0,0 +1 @@ +The CSV import summary said "1 fields" for a file with one field. diff --git a/linux/changelog.d/4056.fixed.3.md b/linux/changelog.d/4056.fixed.3.md new file mode 100644 index 0000000000..2a636da97a --- /dev/null +++ b/linux/changelog.d/4056.fixed.3.md @@ -0,0 +1 @@ +Searching Saved Queries slowed the window down as the list grew. diff --git a/linux/changelog.d/4056.fixed.md b/linux/changelog.d/4056.fixed.md new file mode 100644 index 0000000000..6072937df7 --- /dev/null +++ b/linux/changelog.d/4056.fixed.md @@ -0,0 +1 @@ +A tab dropped outside the window, or on another window, did not go back to where it was in the tab bar. diff --git a/linux/crates/app/src/ui/import_dialog/imp.rs b/linux/crates/app/src/ui/import_dialog/imp.rs index 4485c39660..b744ae4db7 100644 --- a/linux/crates/app/src/ui/import_dialog/imp.rs +++ b/linux/crates/app/src/ui/import_dialog/imp.rs @@ -1,5 +1,5 @@ use std::cell::{OnceCell, RefCell}; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use glib::subclass::Signal; use gtk4 as gtk; @@ -32,6 +32,8 @@ pub struct ImportCsvDialog { pub summary: TemplateChild, pub(super) parts: OnceCell, + // Until the import takes it or the dialog closes. + pub(super) bytes: RefCell>>, pub(super) sheet: RefCell, pub(super) read_error: RefCell>, // One per table column, in column order. Position 0 is Skip, position n + 1 is field n. @@ -77,6 +79,7 @@ impl AdwDialogImpl for ImportCsvDialog { // The dialog lives on through its close animation, which a read landing then must not fill. fn closed(&self) { self.sequencer.borrow_mut().cancel_all(); + self.bytes.take(); self.parent_closed(); } } diff --git a/linux/crates/app/src/ui/import_dialog/mod.rs b/linux/crates/app/src/ui/import_dialog/mod.rs index e79ff0097d..1e1a15adce 100644 --- a/linux/crates/app/src/ui/import_dialog/mod.rs +++ b/linux/crates/app/src/ui/import_dialog/mod.rs @@ -62,7 +62,6 @@ pub(super) struct DialogParts { schema: Option, table: String, columns: Vec, - bytes: Arc<[u8]>, tasks: Tasks, } @@ -76,9 +75,9 @@ impl ImportCsvDialog { schema: init.schema, table: init.table, columns: init.columns, - bytes: init.bytes, tasks: init.tasks, }); + imp.bytes.replace(Some(init.bytes)); dialog.connect_inputs(); dialog.read_preview(); dialog @@ -145,8 +144,10 @@ impl ImportCsvDialog { let Some(parts) = imp.parts.get() else { return; }; + let Some(bytes) = imp.bytes.borrow().clone() else { + return; + }; let RequestTicket { stamp, cancel } = imp.sequencer.borrow_mut().issue(CsvRequest::PreviewRead); - let bytes = parts.bytes.clone(); let options = self.read_options(); let reading = parts.tasks.spawn_blocking_task(move || { if cancel.is_cancelled() { @@ -335,13 +336,17 @@ impl ImportCsvDialog { if self.reading() || self.blocker().is_some() { return; } + // The workspace keeps the closed dialog until the next import, so the file goes with the import. + let Some(bytes) = imp.bytes.take() else { + return; + }; let import = CsvImport { schema: parts.schema.clone(), table: parts.table.clone(), columns: parts.columns.clone(), mapping: self.mapping(), options: self.read_options(), - bytes: parts.bytes.clone(), + bytes, }; self.emit_by_name::<()>("import", &[&import]); self.close(); diff --git a/linux/crates/app/src/ui/import_dialog/tests.rs b/linux/crates/app/src/ui/import_dialog/tests.rs index d7448f8a0f..f249d2f9dc 100644 --- a/linux/crates/app/src/ui/import_dialog/tests.rs +++ b/linux/crates/app/src/ui/import_dialog/tests.rs @@ -296,6 +296,8 @@ fn import_hands_over_the_mapping_and_options_and_closes() { } ); assert!(Arc::ptr_eq(&import.bytes, &bytes), "the file was copied"); + drop(import); + assert_eq!(Arc::strong_count(&bytes), 1, "the closed dialog kept the file"); } #[gtk4::test] From ea342cac4eb4e4e0dae4e85d49f92753d5b257ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:57:56 +0700 Subject: [PATCH 3/6] test(linux): give the catalogue test's plural counts a type --- linux/crates/app/tests/i18n_catalog.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/tests/i18n_catalog.rs b/linux/crates/app/tests/i18n_catalog.rs index 01bd295eeb..24d445d17f 100644 --- a/linux/crates/app/tests/i18n_catalog.rs +++ b/linux/crates/app/tests/i18n_catalog.rs @@ -113,12 +113,12 @@ fn child() -> ExitCode { ); check( "ngettext_f singular", - tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 1, &[("n", "1")]), + tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 1_u64, &[("n", "1")]), "exactly 1 row", ); check( "ngettext_f plural", - tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 7, &[("n", "7")]), + tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 7_u64, &[("n", "7")]), "all 7 rows", ); check( From beb71eff571ae0b00a6ee43364ae8a5efdfef2f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 16:43:13 +0700 Subject: [PATCH 4/6] test(linux): walk the new dialog templates and balance the create-window emission --- linux/crates/app/src/ui/a11y/tests.rs | 7 +++++-- linux/crates/app/src/ui/saved_queries_dialog/mod.rs | 2 +- linux/crates/app/src/ui/session_window/tests/sessions.rs | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs index 99da0cf5a6..70f3cc15f7 100644 --- a/linux/crates/app/src/ui/a11y/tests.rs +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -8,8 +8,8 @@ use crate::test_support::{ }; use crate::ui::{ PreferencesDialog, SessionWindow, browse_tab, connect_dialog, data_grid, diagram, editor, export, filter_strip, - history_dialog, import_from_app, preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, - workspace, + history_dialog, import_dialog, import_from_app, preferences_dialog, saved_queries_dialog, session_window, + ssh_section, structure_tab, welcome_view, workspace, }; /// Every class the app's templates build. A template whose class is @@ -31,6 +31,7 @@ fn template_classes() -> Vec { filter_strip::FilterStrip::static_type(), history_dialog::HistoryDialog::static_type(), history_dialog::HistoryRow::static_type(), + import_dialog::ImportCsvDialog::static_type(), import_from_app::ImportFromAppDialog::static_type(), session_window::ConnectFailedPage::static_type(), session_window::ConnectingPage::static_type(), @@ -40,6 +41,8 @@ fn template_classes() -> Vec { preferences_dialog::EditorPage::static_type(), preferences_dialog::GeneralPage::static_type(), preferences_dialog::HistoryGroup::static_type(), + saved_queries_dialog::SavedQueriesDialog::static_type(), + saved_queries_dialog::SavedQueryRow::static_type(), SessionWindow::static_type(), ssh_section::SshSection::static_type(), structure_tab::StructureTab::static_type(), diff --git a/linux/crates/app/src/ui/saved_queries_dialog/mod.rs b/linux/crates/app/src/ui/saved_queries_dialog/mod.rs index e22cf17a88..e1b6a6eff8 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog/mod.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog/mod.rs @@ -25,7 +25,7 @@ use crate::runtime::spawn_and_deliver; use crate::ui::history_dialog::QueryDestination; use saved_query_listing::{Listing, matches}; use saved_query_object::SavedQueryObject; -use saved_query_row::SavedQueryRow; +pub(super) use saved_query_row::SavedQueryRow; glib::wrapper! { pub struct SavedQueriesDialog(ObjectSubclass) diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 9c94143675..d2505251d0 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -661,10 +661,14 @@ fn a_tab_dropped_outside_every_window_stays_where_it_was() { home.transfer_page(&page, &drag, 0); let new_window = home.emit_by_name::>("create-window", &[]); + let declined_to_home = new_window.as_ref() == Some(&home); + // The signal's marshaller takes the reference its handler returns, which g_signal_emitv then hands + // back: libadwaita's own caller keeps it, so this one must too, or the view is freed twice. + std::mem::forget(new_window); drag.transfer_page(&page, &home, 0); drain_main_context(); - assert_eq!(new_window.as_ref(), Some(&home), "no window opens for the tab"); + assert!(declined_to_home, "no window opens for the tab"); assert_eq!(crate::ui::workspace::page_position(&home, &page), Some(left_at)); assert_eq!(home.selected_page().as_ref(), Some(&page)); assert_eq!(home.n_pages(), 3); From 869a2d6359ec3ec40ea87df2c63a25d397e03cf5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 23:21:46 +0700 Subject: [PATCH 5/6] fix(linux): answer an alert whose window is destroyed under it --- .../app/src/ui/workspace/workspace_shell.rs | 5 +- .../app/src/workspace/dialog_response.rs | 62 +++++++++++++------ linux/docs/state-management.md | 2 +- linux/docs/toolkit-workarounds.md | 22 ++++--- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index 22fe745529..25eb2ae449 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -248,8 +248,9 @@ impl WorkspaceShell { shell.follow_selected_page(); } }); - // A tab never leaves its session's window (ADR 0028), and NULL logs a critical in libadwaita 1.9, so the - // dropped tab comes back to this view: docs/toolkit-workarounds.md, "A tab dropped outside every window". + // A tab never leaves its session's window (ADR 0028), and libadwaita has no way to decline the new window + // it asks for here, so the tab comes back to this view: docs/toolkit-workarounds.md, "A tab dropped outside + // every window". let shell = self.downgrade(); view.connect_create_window(move |view| { if let Some(shell) = shell.upgrade() { diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index d3aebee5a6..144e1c7b3d 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use futures::channel::oneshot; @@ -11,33 +11,46 @@ use libadwaita::prelude::*; /// with. A dialog closed without one, torn down with its window or /// forced shut, answers with its close response. /// -/// `choose_future` is not used: its callback aborts the process when -/// the dialog goes away without a response (docs/toolkit-workarounds.md, -/// "AdwAlertDialog.choose_future"). +/// `choose_future` is not used: it never resolves when the window goes +/// with the dialog open, and its callback aborts the process when the +/// call is cancelled (docs/toolkit-workarounds.md, "AdwAlertDialog.choose"). pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> String { let fallback = dialog.close_response().to_string(); let (sender, receiver) = oneshot::channel(); let sender = Rc::new(RefCell::new(Some(sender))); - let on_response = sender.clone(); - dialog.connect_response(None, move |_, response| { - if let Some(sender) = on_response.borrow_mut().take() { - let _ = sender.send(response.to_owned()); + let reply = move |response: String| { + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(response); } - }); - let closed_fallback = fallback.clone(); + }; + let on_response = reply.clone(); + dialog.connect_response(None, move |_, response| on_response(response.to_owned())); + let (on_closed, closed_fallback) = (reply.clone(), fallback.clone()); dialog.connect_closed(move |_| { // AdwAlertDialog emits `closed` before the response a click // chose, so the close response waits until that has had its turn. - let sender = sender.clone(); - let fallback = closed_fallback.clone(); - glib::idle_add_local_once(move || { - if let Some(sender) = sender.borrow_mut().take() { - let _ = sender.send(fallback); - } - }); + let (on_closed, fallback) = (on_closed.clone(), closed_fallback.clone()); + glib::idle_add_local_once(move || on_closed(fallback)); + }); + // A window destroyed with the alert open takes it down with neither `response` nor `closed`. + let window = parent.root().map(|root| root.upcast::()); + let destroyed = Rc::new(Cell::new(false)); + let on_destroy = window.as_ref().map(|window| { + let (reply, fallback, destroyed) = (reply.clone(), fallback.clone(), destroyed.clone()); + window.connect_destroy(move |_| { + destroyed.set(true); + reply(fallback.clone()); + }) }); + let window = window.map(|window| window.downgrade()); dialog.present(Some(parent)); - receiver.await.unwrap_or(fallback) + let answered = receiver.await.unwrap_or(fallback); + if !destroyed.get() + && let (Some(window), Some(handler)) = (window.and_then(|window| window.upgrade()), on_destroy) + { + window.disconnect(handler); + } + answered } /// Answer the alert `window` shows with `response` by clicking its @@ -96,6 +109,19 @@ mod tests { wait_until(WAIT, || visible_alert(&window).is_none()).expect("the alert to close itself"); } + #[gtk4::test] + fn an_alert_whose_window_is_destroyed_answers_with_its_close_response() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (_dialog, answer) = alert(&window); + + window.destroy(); + + wait_until(WAIT, || answer.borrow().is_some()).expect("an answer"); + assert_eq!(answer.borrow().as_deref(), Some("keep")); + } + #[gtk4::test] fn an_alert_forced_shut_answers_with_its_close_response() { adw::init().expect("libadwaita initialises under the test backend"); diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index c31c8e4098..fd5cae4ec8 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -160,7 +160,7 @@ The tab emits `transaction-changed` when that flips, and the workspace reads `Wo - `AdwAlertDialog` closes itself before it emits `response`. A response handler that calls `close()` again finds the dialog gone, and libadwaita logs a critical when animations are off. - That close emits `closed` before the `response` the click chose. A `closed` handler that answers at once turns every click into the close response. `workspace::respond` defers its close response to an idle callback, so the click's response comes first. -- To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response. `choose_future` is not used: its callback aborts the process in that case ([toolkit-workarounds.md](toolkit-workarounds.md#adwalertdialogchoose_future)). +- To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response, its window destroyed under it included. `choose_future` is not used: it never resolves in that case, and its callback aborts the process when the call is cancelled ([toolkit-workarounds.md](toolkit-workarounds.md#adwalertdialogchoose)). ## Anti-patterns to flag in review diff --git a/linux/docs/toolkit-workarounds.md b/linux/docs/toolkit-workarounds.md index fa6cfc4226..3c965c7a22 100644 --- a/linux/docs/toolkit-workarounds.md +++ b/linux/docs/toolkit-workarounds.md @@ -4,11 +4,11 @@ The places the app steps around a GTK, libadwaita, GtkSourceView, IBus or gtk-rs ## A tab dropped outside every window -- **Defect.** libadwaita documents the return of `AdwTabView::create-window` as nullable, but `adw_tab_view_create_window` logs `g_critical ("AdwTabView::create-window handler must not return NULL")` for NULL (`src/adw-tab-view.c`). So a handler cannot decline a new window without a critical. libadwaita then attaches the page to whatever view the handler returns, at position 0 (`detach_into_new_window` in `src/adw-tab-box.c`). Tab groups, which would keep a tab in its window, are a TODO there (`is_view_in_the_same_group`). No upstream report yet. +- **What libadwaita does.** A tab dropped outside every tab bar emits `AdwTabView::create-window`, whose handler "is expected to create a new window, position it as needed and return its `AdwTabView`" (`src/adw-tab-view.c`). The signal stops at the first handler that returns a view; a handler that returns NULL leaves it to the next one. When none returns a view, `adw_tab_view_create_window` logs a critical and the tab bar puts the tab back where it was (`detach_into_new_window` in `src/adw-tab-box.c` and `src/adw-tab-grid.c`). There is no way to decline the new window and no tab groups yet (`is_view_in_the_same_group` is a TODO). This is the documented contract, not a defect. - **Seen in.** libadwaita 1.9.0 and main on 2026-11-05. -- **Workaround.** A tab belongs to its session's window ([state-management.md](state-management.md#tabs)), so `create-window` returns the tab's own view and the shell moves the page back to where the drag took it from (`crates/app/src/ui/workspace/workspace_shell.rs`, `SessionWindowInput::ReturnTab`). +- **Workaround.** A tab belongs to its session's window ([state-management.md](state-management.md#tabs)), so the handler returns the tab's own view, which libadwaita attaches the tab to at position 0, and the shell moves it back to where the drag took it from (`crates/app/src/ui/workspace/workspace_shell.rs`, `SessionWindowInput::ReturnTab`). - **Test.** `a_tab_dropped_outside_every_window_stays_where_it_was` in `crates/app/src/ui/session_window/tests/sessions.rs`. -- **Remove when** the floor's libadwaita takes NULL from `create-window` as a decline without a critical, or has tab groups: return `None`, or put each window's view in its own group, and drop the position restore for this case. +- **Remove when** the floor's libadwaita offers a way to decline a detach, or tab groups: use it, and drop the position restore for this case. ## IBus keeps every text view @@ -26,17 +26,19 @@ The places the app steps around a GTK, libadwaita, GtkSourceView, IBus or gtk-rs - **Test.** As above: the buffer is empty after each close path. - **Remove when** the floor reaches GtkSourceView 5.22, together with the IBus entry or after it. -## AdwAlertDialog.choose_future +## AdwAlertDialog.choose -- **Defect.** `adw_alert_dialog_choose_finish` returns `g_quark_to_string (g_task_propagate_int (...))`. Once the call's cancellable fires, the task's error makes `g_task_propagate_int` return -1 and the result is NULL (`src/adw-alert-dialog.c`), though its documentation promises the close response. libadwaita-rs reads that result with `from_glib_none` into a `GString` inside an `extern "C"` callback (`src/alert_dialog.rs`), where the panic aborts the process. `choose_future` cancels when its future is dropped, such as when the window that awaits it closes. A dialog that goes without emitting `response` leaves `choose` waiting for good. No upstream report yet. -- **Seen in.** libadwaita 1.9.0 (`choose` since 1.5) with libadwaita-rs 0.9.2. -- **Workaround.** `crate::workspace::respond` (`crates/app/src/workspace/dialog_response.rs`) answers from `response`, and from `closed` with the close response once a click's `response` has had its turn ([state-management.md](state-management.md#alert-dialogs)). -- **Test.** `a_clicked_response_answers_and_closes_the_alert` and `an_alert_forced_shut_answers_with_its_close_response` in `dialog_response.rs`. -- **Remove when** libadwaita returns the close response on cancel and on a forced close, and libadwaita-rs maps a NULL result without panicking. +- **Defect.** Two, both in libadwaita (`src/adw-alert-dialog.c`, `src/adw-dialog-host.c`). Upstream report pending, filed by the maintainer. + - A window destroyed with an alert open takes the alert down with neither `response` nor `closed`: `adw_dialog_host_dispose` unparents its dialogs after clearing their callbacks. `choose` then never finishes, and `choose_future` never resolves. + - A cancelled `choose` finishes with NULL, though `adw_alert_dialog_choose_finish` promises the close response: the task keeps GTask's check-cancellable, so `g_task_propagate_int` returns -1 and `g_quark_to_string` gives NULL. libadwaita-rs follows the non-nullable annotation and reads it with `from_glib_none` inside an `extern "C"` callback, where the panic aborts the process. `choose_future` cancels when its future is dropped. +- **Seen in.** libadwaita 1.9.0, and 1.10.0, whose dialog code matches main 295aa0e8. +- **Workaround.** `crate::workspace::respond` (`crates/app/src/workspace/dialog_response.rs`) answers from `response`, from `closed` with the close response once a click's `response` has had its turn ([state-management.md](state-management.md#alert-dialogs)), and from the window's `destroy` with the close response. +- **Test.** `a_clicked_response_answers_and_closes_the_alert`, `an_alert_forced_shut_answers_with_its_close_response` and `an_alert_whose_window_is_destroyed_answers_with_its_close_response` in `dialog_response.rs`. +- **Remove when** the floor's libadwaita finishes `choose` with the close response on cancel and when the window goes: use `choose_future` with a cancellable. ## ColumnViewCell.set_focusable in gtk-rs -- **Defect.** gtk4-rs generates `ColumnViewCell::set_focusable` only under `cfg(not(feature = "v4_12"))` (`src/auto/column_view_cell.rs`). With v4_12 on, `cell.set_focusable` resolves to `ListItemExt::set_focusable`, which sets GtkListItem's own `focusable`, and GtkColumnViewCell reads its own property of that name. No upstream report yet. +- **Defect.** gtk4-rs generates `ColumnViewCell::set_focusable` only under `cfg(not(feature = "v4_12"))` (`src/auto/column_view_cell.rs`). With v4_12 on, `cell.set_focusable` resolves to `ListItemExt::set_focusable`, which sets GtkListItem's own `focusable`, and GtkColumnViewCell reads its own property of that name. [gtk-rs/gtk4-rs#2393](https://github.com/gtk-rs/gtk4-rs/issues/2393), with the generator fix in [gtk-rs/gir#1759](https://github.com/gtk-rs/gir/pull/1759), both open. - **Seen in.** gtk4-rs 0.11.4 and main on 2026-11-05. - **Workaround.** `cell.set_property("focusable", true)` in `crates/app/src/ui/data_grid/columns.rs`, and `false` for the row number cells in `row_number_column.rs`. - **Test.** The grid's keyboard tests in `crates/app/src/ui/data_grid/tests/gutter.rs` read `is_focusable` on the cells. From 767589ecf73677291aaf2f8d22026e4d5747f79b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 6 Nov 2026 03:47:01 +0700 Subject: [PATCH 6/6] fix(linux): answer a destroyed window's alert on unrealize, which comes at once --- .../crates/app/src/workspace/dialog_response.rs | 17 ++++++++++------- linux/docs/toolkit-workarounds.md | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index 144e1c7b3d..4823271420 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -33,20 +33,22 @@ pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> glib::idle_add_local_once(move || on_closed(fallback)); }); // A window destroyed with the alert open takes it down with neither `response` nor `closed`. + // `gtk_window_destroy` unrealizes it at once; its `destroy` waits for the last reference, which + // the caller awaiting this holds. let window = parent.root().map(|root| root.upcast::()); - let destroyed = Rc::new(Cell::new(false)); - let on_destroy = window.as_ref().map(|window| { - let (reply, fallback, destroyed) = (reply.clone(), fallback.clone(), destroyed.clone()); - window.connect_destroy(move |_| { - destroyed.set(true); + let gone = Rc::new(Cell::new(false)); + let on_gone = window.as_ref().map(|window| { + let (reply, fallback, gone) = (reply.clone(), fallback.clone(), gone.clone()); + window.connect_unrealize(move |_| { + gone.set(true); reply(fallback.clone()); }) }); let window = window.map(|window| window.downgrade()); dialog.present(Some(parent)); let answered = receiver.await.unwrap_or(fallback); - if !destroyed.get() - && let (Some(window), Some(handler)) = (window.and_then(|window| window.upgrade()), on_destroy) + if !gone.get() + && let (Some(window), Some(handler)) = (window.and_then(|window| window.upgrade()), on_gone) { window.disconnect(handler); } @@ -109,6 +111,7 @@ mod tests { wait_until(WAIT, || visible_alert(&window).is_none()).expect("the alert to close itself"); } + // The waiting future holds the window, as every caller's does, so its `destroy` cannot come first. #[gtk4::test] fn an_alert_whose_window_is_destroyed_answers_with_its_close_response() { adw::init().expect("libadwaita initialises under the test backend"); diff --git a/linux/docs/toolkit-workarounds.md b/linux/docs/toolkit-workarounds.md index 3c965c7a22..2bbbf0685a 100644 --- a/linux/docs/toolkit-workarounds.md +++ b/linux/docs/toolkit-workarounds.md @@ -32,7 +32,7 @@ The places the app steps around a GTK, libadwaita, GtkSourceView, IBus or gtk-rs - A window destroyed with an alert open takes the alert down with neither `response` nor `closed`: `adw_dialog_host_dispose` unparents its dialogs after clearing their callbacks. `choose` then never finishes, and `choose_future` never resolves. - A cancelled `choose` finishes with NULL, though `adw_alert_dialog_choose_finish` promises the close response: the task keeps GTask's check-cancellable, so `g_task_propagate_int` returns -1 and `g_quark_to_string` gives NULL. libadwaita-rs follows the non-nullable annotation and reads it with `from_glib_none` inside an `extern "C"` callback, where the panic aborts the process. `choose_future` cancels when its future is dropped. - **Seen in.** libadwaita 1.9.0, and 1.10.0, whose dialog code matches main 295aa0e8. -- **Workaround.** `crate::workspace::respond` (`crates/app/src/workspace/dialog_response.rs`) answers from `response`, from `closed` with the close response once a click's `response` has had its turn ([state-management.md](state-management.md#alert-dialogs)), and from the window's `destroy` with the close response. +- **Workaround.** `crate::workspace::respond` (`crates/app/src/workspace/dialog_response.rs`) answers from `response`, from `closed` with the close response once a click's `response` has had its turn ([state-management.md](state-management.md#alert-dialogs)), and from the window's `unrealize` with the close response. `gtk_window_destroy` unrealizes the window at once, while its `destroy` signal waits for the last reference, which the caller awaiting the answer holds. - **Test.** `a_clicked_response_answers_and_closes_the_alert`, `an_alert_forced_shut_answers_with_its_close_response` and `an_alert_whose_window_is_destroyed_answers_with_its_close_response` in `dialog_response.rs`. - **Remove when** the floor's libadwaita finishes `choose` with the close response on cancel and when the window goes: use `choose_future` with a cancellable.