Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/fix-issue/references/research-linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
42 changes: 0 additions & 42 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,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"
Expand Down
6 changes: 1 addition & 5 deletions linux/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,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)) |
Expand Down
13 changes: 5 additions & 8 deletions linux/build-aux/debian/copyright

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions linux/changelog.d/4056.fixed.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Opening a large CSV file for import froze the window while its preview was read.
1 change: 1 addition & 0 deletions linux/changelog.d/4056.fixed.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The CSV import summary said "1 fields" for a file with one field.
1 change: 1 addition & 0 deletions linux/changelog.d/4056.fixed.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Searching Saved Queries slowed the window down as the list grew.
1 change: 1 addition & 0 deletions linux/changelog.d/4056.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion linux/crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 0 additions & 18 deletions linux/crates/app/clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
9 changes: 5 additions & 4 deletions linux/crates/app/src/crash_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
61 changes: 57 additions & 4 deletions linux/crates/app/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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]
Expand Down
40 changes: 3 additions & 37 deletions linux/crates/app/src/runtime/deliver_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<M> {
fn try_deliver(&self, message: M) -> Delivery;

Expand All @@ -22,27 +18,6 @@ pub trait DeliverInput<M> {
}
}

impl<M> DeliverInput<M> for relm4::Sender<M> {
fn try_deliver(&self, message: M) -> Delivery {
match self.send(message) {
Ok(()) => Delivery::Delivered,
Err(_) => Delivery::ReceiverGone,
}
}
}

impl<C: relm4::Component> DeliverInput<C::Input> for relm4::ComponentSender<C> {
fn try_deliver(&self, message: C::Input) -> Delivery {
self.input_sender().try_deliver(message)
}
}

impl<C: relm4::factory::FactoryComponent> DeliverInput<C::Input> for relm4::FactorySender<C> {
fn try_deliver(&self, message: C::Input) -> Delivery {
self.input_sender().try_deliver(message)
}
}

impl<M> DeliverInput<M> for tokio::sync::mpsc::UnboundedSender<M> {
fn try_deliver(&self, message: M) -> Delivery {
match self.send(message) {
Expand All @@ -56,18 +31,9 @@ impl<M> DeliverInput<M> for tokio::sync::mpsc::UnboundedSender<M> {
mod tests {
use super::*;

#[test]
fn a_message_to_a_live_receiver_arrives() {
let (sender, receiver) = relm4::channel::<u8>();

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::<u8>();
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<u8>();
drop(receiver);

sender.deliver(7);
Expand Down
Loading
Loading