diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index fafc058e7b..32bca21ca1 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -106,21 +106,41 @@ jobs: echo "::error::crates/app/src/services must not hold thread_local! state" exit 1 fi - - name: No blocking file I/O in the window and editor code + - name: No blocking file I/O in the UI code # This code runs on the GTK thread, where a slow disk or a # network mount freezes the window. A storage call ending in # _blocking is handed to spawn_blocking_task on the same line, - # which is what this check can see. Tests may do either. + # which is what this check can see. A Path method that stats the + # disk blocks the same way. Tests may do either. run: | # The $0 in the awk program is awk's record, not a shell variable. # shellcheck disable=SC2016 - found=$(find crates/app/src/ui/app crates/app/src/ui/editor -name '*.rs' ! -name tests.rs -print0 \ - | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') + found=$(find crates/app/src/ui -name '*.rs' ! -name tests.rs -print0 \ + | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || /\.(exists|try_exists|is_dir|is_file|is_symlink|canonicalize|read_dir|read_link|symlink_metadata)\(/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') if [ -n "$found" ]; then echo "$found" - echo "::error::crates/app/src/ui/app and crates/app/src/ui/editor must not call std::fs, or a _blocking function outside spawn_blocking_task" + echo "::error::crates/app/src/ui must not call std::fs or a Path method that reads the disk, or a _blocking function outside spawn_blocking_task" exit 1 fi + - name: Window actions on the window's own map + # GTK exports a window's own action map as win. A group inserted + # under that name hides it from the application's accelerators + # and from D-Bus. + run: | + if grep -rn 'insert_action_group("win"' crates/app/src; then + echo "::error::add window actions with add_action_entries on the window, not a group inserted as win" + exit 1 + fi + - name: Docs name paths and types that exist + # A renamed type or a moved file stays named in prose until + # something asks. GTK and GLib names resolve against the GIR + # files the -dev packages install. + run: | + ../scripts/check-doc-symbols.sh \ + linux/ARCHITECTURE.md \ + linux/docs/state-management.md \ + linux/docs/testing.md \ + linux/docs/decisions/0012-window-per-session.md - name: Clippy run: meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings - name: Clippy without default features diff --git a/linux/AGENTS.md b/linux/AGENTS.md index 2dd063cec0..faf7b32efd 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -1,6 +1,6 @@ # TablePro for Linux -The native Linux app: Rust, GTK 4 and libadwaita through Relm4, built with Meson and Cargo. The platform floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88). It shares no code with the macOS app. +The native Linux app: Rust, GTK 4 and libadwaita through gtk-rs, built with Meson and Cargo. The platform floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88). It shares no code with the macOS app. This is the guide for work under `linux/`. The root `AGENTS.md` describes the macOS and iOS apps; of it, only the principles named below, "Writing style" and the commit rules apply here. Claude Code loads this file through `linux/CLAUDE.md` once a file under `linux/` is read; Codex reads it when started in `linux/`, so otherwise read it yourself. @@ -18,7 +18,7 @@ This is the guide for work under `linux/`. The root `AGENTS.md` describes the ma ## Map -- `crates/app`: the binary and every Relm4 component. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. +- `crates/app`: the binary, the GTK application, its windows and views. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. - `data/`: desktop entry, D-Bus service, metainfo, GSettings schema, icons and GResource. `po/`: translations. `build-aux/flatpak/`: the Flatpak manifest. - `ARCHITECTURE.md`, `CONTRIBUTING.md` and `docs/` (testing, storage, error handling, state management, adding a driver, decisions) are the reference. Read the one for the area you touch. diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index c9677a9dfe..4d0d9d520f 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -12,7 +12,7 @@ linux/ ├── build-aux/flatpak/ Devel Flatpak manifest and krb5.conf ├── po/ translation catalogue └── crates/ - ├── app/ binary, GTK4 entry point, Relm4 components + ├── app/ binary, the GTK application, its windows and views ├── core/ domain types and the driver contract, no GUI deps ├── net/ direct TCP transport and rustls client configs ├── ssh/ system OpenSSH transport and the askpass helper @@ -74,27 +74,57 @@ Adding a new driver = adding one workspace member + one `register` call. There i ## Async architecture -Two runtimes coexist: +The GTK main loop owns every widget and runs on the main thread. Everything else runs on one tokio runtime, `AppRuntime`, which `run()` builds before GTK starts: multi-threaded, two to four workers named `tablepro-rt`. Code reaches it only through `Tasks` (`tablepro_session::runtime`), which `AppServices` hands to every window: -- **glib's main context** runs the UI. Single-threaded. Owns all GTK widgets. -- **tokio runtime** owned by Relm4 runs all DB driver work and other async tasks. +- `spawn_task` for async work, `spawn_blocking_task` for a blocking call such as a durable write, and `spawn_owned` for a loop that stops when its handle drops. +- Once the main loop returns, `AppRuntime::shutdown` gives the work still in flight two seconds. -Bridging uses Relm4's built-in primitives instead of hand-rolled channels: +Work crosses between the two through `crate::runtime`: -- `sender.command(move |out, shutdown| shutdown.register(async move { ... out.send(...) }).drop_on_shutdown())`: a component-scoped tokio task that cancels when the component drops. The `out` sender feeds `CmdOutput` back into the component's update loop on the GTK thread. Used for every per-tab fetch (browse rows, schema introspection, save transaction). -- `relm4::spawn(async move { ... })`: a fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. -- `sender.input(AppMsg::...)` from inside an async block routes back into the component's `update` on the GTK thread. Combined with `sender.command`, this is how a "fetch-then-render" round trip lands its result on the right widget. +- `spawn_and_deliver` runs work on `Tasks` and hands its result to a closure on the GTK thread. Aborting the returned handle drops the delivery, never the work. +- `spawn_held` runs work the user expects to finish, such as a save or a write to the connection list, with the process held until it ends. +- A window's and a workspace's input queue (`input_queue`) takes the messages their actions, signal handlers and background work send. `pump_inputs` hands them over one at a time on the GTK thread, never inside the signal or action that sent one. `DeliverInput::deliver` drops a message once the widget has shut down and logs that at debug, so a reply that lands after a disconnect is never shown. +- A value the session publishes on a `tokio::sync::watch` (its health, its link generation, a schema's objects) reaches a view through a `WatchForwarder`, which delivers on the GTK thread and stops when the view drops it. -`main.rs` builds a tiny `tokio::runtime::Builder::new_multi_thread().worker_threads(1)` runtime exclusively to `block_on` the history-DB init / prune at startup, then `shutdown_timeout`s it before `RelmApp::run` takes over. The query-history sqlx pool is stored in a `OnceLock` and reused from Relm4's runtime afterward. +`crates/app/clippy.toml` bans the ways around this: `tokio::spawn`, `Handle::current`, `relm4::spawn` and its siblings, `MainContext::block_on`, `ComponentSender::input`, `RelmApp::run` and `gio::Application::quit`. The fast CI job also fails on `std::fs`, a `Path` method that reads the disk, or a `_blocking` call outside `spawn_blocking_task` under `crates/app/src/ui`. -## UI architecture: Relm4 +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 history and saved-queries dialogs still load through commands, so that runtime starts when either opens. -The `app` crate uses [Relm4](https://relm4.org) for component-based UI structure. +## The application -- **Component**: a unit of UI with explicit `Init`, `Input`, `Output`, `CmdOutput` types. State is private. All transitions go through `update`. -- **AsyncComponent**: same shape, but `init` and `update` may be `async`. Used for components that load data on creation. -- **Factory**: drives a list / grid of homogeneous child components from a model. Used for the table sidebar and similar lists. -- **CmdOutput**: how a component receives async results. The tokio bridge sends `CmdOutput` messages back into the component's update loop. +`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 `SessionRegistry`, the settings, the storage, `Tasks`, the process hold, the open-sessions record and the query history. + +- **One process per profile.** GApplication owns the application id on the session bus. A second launch parses its own options, sends the running process an activate, or an open with the files it was given, and exits. With `--new-window` (`-n`), `handle_local_options` sends `app.new-window` instead. The development profile has an id of its own and runs beside the stable app. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`) and their accelerators, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. +- **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. +- **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. +- **Quit.** `request_quit` runs `QuitSequence`, which asks each window in turn through its close prompts and never calls `g_application_quit`. A second Quit while a window asks brings that window forward. At logout, `query-end` brings forward the first window that holds the session back. + +### Restoring sessions and opening files + +`OpenSessions` (`crates/app/src/workspace/open_sessions.rs`) records the session each window adopts, a file or a saved connection, and drops it on a disconnect or a closed window. With the `restore-session` key on (the default, under Preferences), the first activate of a launch reads the record under a hold, opens a window for each session in it with `WindowLaunch::Restore`, then opens the files handed over while it read, or the picker when that opens nothing. A restored saved connection is looked up in the list again, and one deleted since says so in a toast. With the key off, the first activate opens the picker. + +A quit freezes the record before it asks the first window, so each window it closes stays on it, and writes it before the process may end. An approved quit then empties the list in memory; a cancelled one keeps only the windows still showing or connecting their session. Shutdown freezes it too: windows that go with the process are not the user closing them. An approved quit can leave the process running while a session closes, and an activate that reaches it then restores the record as a new launch would. + +Files come from Open With in a file manager or from the command line (`HANDLES_OPEN`). They wait until the restore has read the record, since a file's window adds to it. Each file is resolved off the GTK thread, its path made canonical and matched against the saved connections, then it opens in the window that has it open, in the active window while that shows only the picker, or in a new window. + +## Windows and sessions + +Each window is a `SessionWindow` (`crates/app/src/ui/session_window/`) for one connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)). Its `SessionPage` is the picker of saved connections, the connect while it runs, the failure when it fails, or the session's `Workspace`. Every page past the picker holds the `SessionClaim` on the session's key, so leaving the page releases it. + +- A connect claims the key in the `SessionRegistry` before it starts, so a second window asking for the same database brings the first forward. The connect runs on `Tasks` under a cancel token, and a result for an attempt the user cancelled or replaced is closed instead of adopted. +- Adopting a connected session starts its `Session`, attaches it to the claim, which `SessionSignals` hears through the registry, records the key in `OpenSessions` and builds the `Workspace`. +- A workspace never moves to another session. Disconnect asks the close prompts, then `tear_down` writes what the workspace shows, takes its editor sessions, shuts the workspace down, drops the claim and the record's key, and closes the session and those editor sessions under one hold before the window returns to the picker. An approved close runs the same teardown. +- The close prompts (`CloseGuards`) belong to the window, so a tab's close, the close button and Ctrl+W ask on every page, one prompt at a time. While the workspace has something a logout would lose, a `WindowInhibitor` holds logout back, and suspend too while a save runs. +- The window's actions (`disconnect`, `reconnect`, `close-current` and the rest) go on its own action map, which GTK exports as `win`. + +The `Workspace` (`crates/app/src/ui/workspace/`) shows one session: the sidebar of its objects, the tab view and the banners. It adds its own actions to the window and removes them when it shuts down, follows the session's health and object list through `WatchForwarder`s, and tells the window what it shows through its `title-changed`, `dirty-changed` and `saves-in-flight-changed` signals. Its `WorkspaceHandle` runs the saves and tells the close prompts what closing any part of it would lose. [Workspace tab system](#workspace-tab-system) covers its tabs, and [Sessions and health](#sessions-and-health) the `Session` itself. + +## 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 results pane, 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 picker's `WelcomeView` with its `ConnectionRow` factory, the sidebar's `SidebarRow` factory, and the history, saved-queries and CSV import dialogs. Their output reaches the subclass that hosts them through `connect_receiver` or `relm4_bridge`, which put it on that subclass's queue. The two patterns coexist until a rewrite turns each component into a subclass. See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. @@ -193,11 +223,13 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s and calls the connection lost after three failed probes in a row, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. +The application tells each session what its connection cannot see. When `GNetworkMonitor` reports a change, and when a window adopts a session, it asks whether that session's server, or its first SSH hop, is reachable; a file, a socket and a loopback server are never asked about. A session that loses a server the network cannot reach waits offline, checking every 60 s, and reconnects as soon as the server is reachable again. While a session is open, a 5 s timer compares `CLOCK_BOOTTIME` with `CLOCK_MONOTONIC`, and after a suspend each server is checked again and a session that slept more than 30 s probes its connection. A session parked on an SSH host key keeps the error that parked it, and its banner's Review Key shows the fingerprint. Trusting it retries with ssh's question about that one key answered yes, so ssh still checks the key and writes known_hosts; a changed key is first removed with `ssh-keygen -R`, since ssh never accepts one. When the review cannot accept the key, because ssh's configuration takes keys only from known_hosts or the key is revoked, its dialog offers Retry for after the user fixes the key outside TablePro. + The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar follows the default schema's list through a watch the window drops before the session closes. The editor's completion reads the catalog when it opens: the default schema's objects, a named schema's objects after `schema.`, and the columns of the tables the statement names. ## Workspace tab system -The active connection drives a single `AdwTabView` hosting heterogeneous tabs. The `App` component owns the strip; tabs are typed via the `WorkspaceTab` enum: +The session drives a single `AdwTabView` hosting heterogeneous tabs. The `Workspace` owns the strip; tabs are typed via the `WorkspaceTab` enum: ```rust pub enum WorkspaceTab { @@ -208,24 +240,24 @@ pub enum WorkspaceTab { } ``` -Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. `App` keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. +Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. The workspace keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. -App routing is hub-and-spoke: a tab emits signals, and the closures App connects tag them with the tab's UUID and re-emit them as `AppMsg` inputs. App's `update` looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives App one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). +Routing is hub-and-spoke: a tab emits signals, and the closures the workspace connects tag them with the tab's UUID and queue them as `WorkspaceInput`. The workspace looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives the workspace one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). A reply a tab's request brings back after a disconnect reaches a closed queue and is dropped. A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and a count wait for the table's columns from the catalog. A read that failed is asked again once the session reconnects, while a tab whose reads all landed keeps its rows. Closing a tab stops its reads, so the server drops the statement. -Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack clears on disconnect because descriptors reference tables in the active connection. +Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack goes with the workspace on disconnect because descriptors reference tables in its session. ## Per-tab pending-change registries -Each window owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. App creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: +Each workspace owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. The workspace creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: | Registry | Handle | What it tracks | Materialised by | |---|---|---|---| | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `WorkspaceModel::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). @@ -236,15 +268,17 @@ The Structure tab is **definition + draft**, not a per-change log. The `TableDef | Saved connections | JSON, `g_file_set_contents_full` CONSISTENT + DURABLE, 0600 | `$XDG_CONFIG_HOME/tablepro/connections.json` | | Connection passwords + SSH secrets | libsecret via `oo7` (Secret Service / KWallet) | keyring item per connection UUID | | Workspace tabs, one file per session | JSON, durable write, debounced 500 ms, off the GTK thread | `$XDG_STATE_HOME/tablepro/workspaces/.json`, `file-.json` for an unsaved file | +| Sessions open at quit, reopened at launch | JSON, durable write, off the GTK thread | `$XDG_STATE_HOME/tablepro/workspaces/open-sessions.json` | | Editor tab text | one file per tab, debounced 500 ms | `$XDG_DATA_HOME/tablepro/drafts//` | | Query history | SQLite + FTS5 virtual table, 0600 | `$XDG_STATE_HOME/tablepro/history.db` | | Application preferences | GSettings | schema `app.tablepro.TablePro` | | Window size / maximized | GSettings | schema `app.tablepro.TablePro` | | Per-table column widths | JSON, durable write | `$XDG_CONFIG_HOME/tablepro/column_widths.json` | -| Single-instance lock | `flock` on an empty file | `$XDG_RUNTIME_DIR/app//tablepro.lock` | `WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. +`OpenSessions` writes the record of open sessions off the GTK thread, holding the process until each write lands; [Restoring sessions and opening files](#restoring-sessions-and-opening-files) says when it changes. A record that cannot be read is moved aside as `open-sessions.json.corrupt-`. + A restore deletes the drafts no saved or open tab names, except while a moved-aside copy of the session's file is there, since that copy may name them. A draft that cannot be read is moved aside as `.sql.corrupt-`, which the cleanup never deletes, and its tab opens empty with a toast. A disconnect writes the open tabs' text before their buffers go. `SavedConnection::last_opened_at: Option>` is stamped on each successful connect via `touch_last_opened`. The welcome view sorts by recency-first with alphabetical tiebreaker; never-opened entries fall to the bottom. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71337c5a22..9c6ad6e5d3 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Password prompt on connect when the keyring holds no password, with a choice to remember it. +- Password prompt on connect when the keyring holds no password or the server refused it, with a choice to remember it. - SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. - SQLite file chooser in the connect dialog, with New Database to create a file. @@ -19,6 +19,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. - Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. +- SQLite column type, nullability, default and primary key changes, and foreign keys added or removed, in the Structure tab. +- Delete button in the table grid, with the reason in its tooltip when the selected rows can't be deleted. +- Set to DEFAULT in a new row's cell menu, for columns the server fills. +- New Window (Ctrl+Shift+N, or `tablepro --new-window`) opens another window in the running TablePro. +- Connections wait offline while the network can't reach their server, and reconnect once it can or after a suspend. +- Review Key in a connection's banner when its SSH host key is refused, to trust a new key, replace a changed one, or retry once the key is fixed outside TablePro. +- Read-only or read-write choice before opening a SQLite file on a network share or FUSE filesystem. +- Locate… for a saved SQLite connection whose file was moved or renamed. +- Open SQLite files from the file manager's Open With, or with `tablepro file.sqlite3`. +- Review before a structure save that drops data, rebuilds the table or can stop halfway, with the SQL it runs. +- Arrow keys move between grid cells; F2 or Return edits one, Tab carries an edit to the next cell, and Shift+F10 opens the cell menu. +- Screen readers say when a grid cell is modified and when a row is new or marked for deletion. +- Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. +- Save query history in Preferences turns the query history off and keeps the entries already saved. ### Changed @@ -27,17 +41,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. +- SQLite foreign keys show the name their CREATE TABLE gives them. +- Delete, Duplicate and Copy as in a grid cell's menu act on every selected row when the clicked row is selected. +- Grid cells edit in place with the keyboard each type needs; dates open a calendar, and JSON, XML and long text a larger editor. +- Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. +- Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. +- Quit asks each window about its unsaved changes in turn, and waits for open connections to close. +- Each window holds one connection: opening one already open brings its window forward, and Disconnect returns to the list. +- Connecting shows in the window with Cancel, and a failed connect says why, with Retry, Edit Connection and Choose Connection. +- Deleting a connection asks once, offers its window when it is open, and can delete its query history too. +- SQLite files shared through the document portal show their own path instead of the portal's. +- Add Index and Add Foreign Key check the form as you type, and pick the referenced table and columns from the database. +- Denser grid rows, with numbers aligned right in even-width digits at the precision their column declares. +- A grid value too long to draw says how many bytes it leaves out, and its tooltip points to Show Row as JSON. + +### Removed + +- Connection buttons in a connected window's header bar; New Window opens another connection. ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. - Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. +- Exporting results or query history, or opening the connect dialog, froze the window on a slow disk or network folder. +- A query saved just before quitting could be lost. +- The sidebar's table search button had no name for screen readers. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. - MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. +- Launching TablePro while it was running did nothing instead of showing the open window. +- Deleting a connection left its saved tabs and editor drafts on disk. +- A change to the connection list made just before quitting could be lost. - A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. - A SQLite script ending in an unclosed comment showed later results under the first statement's columns. - SQLite text containing a NUL character exported as SQL that SQLite cannot read. @@ -47,11 +84,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. +- Structure tab collapsed open columns on each add or remove, and lost an edit typed right after one. +- Structure tab column summary and the Default field's error did not update as the column was edited. +- SQLite `INTEGER PRIMARY KEY DESC` columns showed as auto-increment in the Structure tab. +- Undoing a delete of many rows took one Ctrl+Z per row and could not bring back more than 50. +- Editing a number in the grid could round it: integers past 2^53 and floats past six decimal places. +- Setting a new row's cell to NULL saved the column's default instead. +- Setting a NOT NULL cell to NULL was accepted and failed at save. +- The Menu key on a read-only grid cell opened a text selection menu instead of the cell's menu. +- Text with a NUL character showed cut short in the grid, copied cut short, and editing it saved the cut value. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. - Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. - Duplicate left out the row's unsaved edits. - Cells of a table without a primary key opened for editing, then dropped what was typed. +- A row whose primary key the driver could not read opened for editing, then failed to save. - A save that failed on a new row did not flash that row. - Undo after editing a cell twice restored the fetched value instead of the first edit. - An edit made while a save ran was dropped when it set the cell back to the value it was read with. @@ -76,7 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Turning a column's primary key on or off in the Structure tab saved nothing. - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. -- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK or other attributes it had. +- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK, storage, compression or other attributes it had. - Saving a MySQL column whose default or generated expression held a quoted string failed. - Save in a Structure tab's close prompt skipped the tab's checks and dropped a default that did not parse. - The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. @@ -96,6 +143,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ctrl+S did nothing in a Structure tab. - Structure tab could not save two columns swapping names, or a column taking the name of one being removed. - Changing the type of a SQL Server column with a default failed. +- A bulk delete confirmed after the table's primary key changed marked rows by their old key. +- Ctrl+Home and Ctrl+End jumped to the first or last tab instead of the start or end of the SQL editor or table. ### Security @@ -103,3 +152,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. - Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. - Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. +- A jump host whose ssh_config sets StrictHostKeyChecking to accept-new or no was trusted without the host key dialog. +- Editor SQL saved by an earlier build stayed on disk in a tab file nothing reads any more. +- Statements that set a password, an encryption key or a connection secret were saved in the query history. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index d2840b74cb..2b44d6cf97 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -1592,6 +1592,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "gio-unix" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8d768735e229742c135afa3d3326604088d2df0679901cdb05e791fe2fbe0a" +dependencies = [ + "gio", + "gio-unix-sys", + "glib", + "libc", +] + +[[package]] +name = "gio-unix-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c01dbb58f92f67548d85e2209e073acc7ee04e107c7e04ee69b49e38b4933b4" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + [[package]] name = "glib" version = "0.22.9" @@ -4314,6 +4339,7 @@ dependencies = [ "futures", "gettext-rs", "gio", + "gio-unix", "glib", "glib-build-tools", "gtk4", @@ -4321,6 +4347,7 @@ dependencies = [ "log", "proptest", "relm4", + "rustix", "secrecy", "serde", "serde_json", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 8ec0fc0cb7..ce5676e5fb 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -76,6 +76,7 @@ gtk4 = { version = "0.11", features = ["gnome_50"] } libadwaita = { version = "0.9", features = ["v1_9"] } sourceview5 = { version = "0.11", features = ["v5_18"] } gio = { version = "0.22", features = ["v2_88"] } +gio-unix = { version = "0.22", features = ["v2_84"] } glib = { version = "0.22", features = ["v2_88"] } glib-build-tools = "0.22" uuid = { version = "1", features = ["v4", "serde"] } diff --git a/linux/README.md b/linux/README.md index ca8f1aacb8..c2a20b7c6d 100644 --- a/linux/README.md +++ b/linux/README.md @@ -12,8 +12,8 @@ Phases 2 and 3 in progress (see [ROADMAP.md](ROADMAP.md)). The stack (Rust + GTK |---|---| | Language | Rust 1.98+ | | GUI toolkit | GTK4 4.22+ + libadwaita 1.9+ + GtkSourceView 5.18+ (GNOME 50, [ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | -| App architecture | [Relm4](https://relm4.org) — Elm-style components on gtk4-rs | -| Async | tokio (DB drivers) bridged to glib main loop (UI) | +| App architecture | gtk-rs subclasses with composite templates, one window per connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)); a few views still on [Relm4](https://relm4.org) | +| Async | the app's tokio runtime (`AppRuntime`) for background work, results delivered to the GLib main loop | | DB drivers | sqlx (PG / MySQL / SQLite), tiberius (MSSQL), official `clickhouse` crate; planned: fred (Redis), official mongodb / duckdb crates, etc. | | Persistence | libsecret (passwords), gio::Settings (prefs), JSON files (connection metadata) | | Distribution | Flathub primary, .deb / .rpm / AppImage secondary | @@ -82,6 +82,20 @@ feature: SQL Server Windows integrated auth links MIT Kerberos and runs bindgen at build time. `cargo build -p tablepro --no-default-features` leaves it out, and the connect dialog then hides Windows (Kerberos). +## Opening SQLite files + +Once installed, TablePro is in the file manager's **Open With** for +SQLite databases (`application/vnd.sqlite3`). From a terminal: + +```bash +tablepro app.sqlite3 +``` + +A running TablePro opens the file in its own window and the new launch +exits. A file already open brings its window forward. A database on a +network or FUSE filesystem asks whether to open read-only first, because +SQLite's locking cannot be trusted there. + ## SQL Server with Windows integrated auth Pick **Method → Windows (Kerberos)** in the connect dialog. There is no @@ -119,7 +133,7 @@ worth knowing: | Roadmap and current phase | [ROADMAP.md](ROADMAP.md) | | Contributing: dev workflow, lint, commits, PRs | [CONTRIBUTING.md](CONTRIBUTING.md) | | **Adding a database driver** | [docs/adding-drivers.md](docs/adding-drivers.md) | -| State management with Relm4 | [docs/state-management.md](docs/state-management.md) | +| State management: windows, sessions, background work | [docs/state-management.md](docs/state-management.md) | | Persistence: secrets, settings, files | [docs/storage.md](docs/storage.md) | | Error handling conventions | [docs/error-handling.md](docs/error-handling.md) | | Testing conventions | [docs/testing.md](docs/testing.md) | diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 1474b0f361..e014139905 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -12,7 +12,7 @@ This is **past demo-grade**, but still **not beta-shippable**. The gap between " |---|---| | Type system | Done. `Value` covers Null/Bool/Int/Float/Text/Bytes/Date/Time/DateTime/TimestampTz/Decimal/Uuid/Json | | Result scaling | Streams via sqlx `fetch` with `MAX_QUERY_ROWS` cap; full result still held in the grid model | -| Connection management | `DatabaseService` + `AdwTabView` workspace tabs done; one active connection at a time, multi-window still open | +| Connection management | Done. One window per connection session, each with its own `AdwTabView` workspace; the sessions open at quit open again at launch | | Network security | SSH tunnelling + TLS toggle present; cert-path / verify-mode UI still thin | | Distribution | Flatpak manifest + metainfo + desktop + icon present; never built end-to-end on CI | | Internationalization | gettext through `crates/app/src/i18n.rs` + `po/` template; extraction reaches every call site and `POTFILES.in` matches the tree. The committed template still predates that fix and needs regenerating on gettext 0.24 or newer | @@ -86,13 +86,14 @@ Exit criterion: a developer can demo the basic flows (connect, browse, edit, que - [ ] Memory-bounded grid: drop rows outside viewport (GTK virtualizes paint; model still holds all loaded rows) - [ ] Cancellation: drop the stream when user navigates away -### Multi-connection / multi-tab architecture (partial) +### Multi-connection / multi-tab architecture ✅ -- [x] `DatabaseService` owning active connection(s) +- [x] A `Session` per window, claimed in the `SessionRegistry` so one database opens in one window - [x] `AdwTabView` for multiple open tables / queries within one connection -- [x] Workspace tab persistence (`workspace_state.json`) -- [ ] Switch the active connection without reconnecting (`DatabaseService` has no `set_active`) -- [ ] Multi-window: each window holds its own active connection via `gtk::Application::add_window` +- [x] Workspace tab persistence, one file per session under `$XDG_STATE_HOME/tablepro/workspaces/` +- [x] Multi-window: each `SessionWindow` holds its own session, and another connection opens in another window rather than replacing it ([ADR 0012](docs/decisions/0012-window-per-session.md)) +- [x] Session restore: the windows open at quit open again at the next launch, behind the `restore-session` preference +- [x] Open With: a database file from the file manager opens in a window of its own, or brings forward the window that has it open ### Security baseline (partial) @@ -363,7 +364,7 @@ This is a separate undertaking and is not blocking any phase. It moves only afte | Stage | Effort | Calendar | |---|---|---| | Phase 0 + 1 | done | done | -| Phase 2 remainder (streaming backpressure, driver-side query cancel, multi-window, TLS UI polish) | ~2 weeks FT | next | +| Phase 2 remainder (streaming backpressure, driver-side query cancel, TLS UI polish) | ~2 weeks FT | next | | Phase 3 remainder — Beta release | ~3 weeks FT | following | | Phase 4 — Beta polish | 3 weeks FT | after Beta | | **Beta on Flathub** | **~8 weeks FT from this revision** | **after Phase 4** | diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 06e852c9cd..aadf337c07 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -28,6 +28,29 @@ name = "logging" path = "tests/logging.rs" harness = false +# These start the application, which GTK allows only on the process's +# main thread, so they run without the test harness. +[[test]] +name = "application_uniqueness" +path = "tests/application_uniqueness.rs" +harness = false + +[[test]] +name = "application_lifecycle" +path = "tests/application_lifecycle.rs" +harness = false + +# D-Bus starts the binary, which opens its windows on GTK's main thread. +[[test]] +name = "open_files" +path = "tests/open_files.rs" +harness = false + +[[test]] +name = "application_session_restore" +path = "tests/application_session_restore.rs" +harness = false + [features] default = ["kerberos"] kerberos = ["tablepro-driver-mssql/kerberos"] @@ -49,11 +72,14 @@ tablepro-ssh = { path = "../ssh" } tablepro-session = { path = "../session" } tablepro-storage = { path = "../storage" } gio.workspace = true +gio-unix.workspace = true gtk4.workspace = true libadwaita.workspace = true sourceview5.workspace = true glib.workspace = true relm4.workspace = true +# CLOCK_BOOTTIME, which std does not expose, tells a suspend apart. +rustix = { version = "1.1", default-features = false, features = ["std", "time"] } secrecy.workspace = true tokio.workspace = true tokio-util = { workspace = true, features = ["rt"] } diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index 84a393bc06..be994fc95e 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -34,5 +34,20 @@ disallowed-methods = [ # 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 = "close the window so shutdown runs; run() shuts the runtime down after" }, + { 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/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs new file mode 100644 index 0000000000..eeb3bfc9a9 --- /dev/null +++ b/linux/crates/app/src/application/app_actions.rs @@ -0,0 +1,171 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::{TableProApplication, WindowLaunch}; +use crate::ui::PreferencesDialog; + +/// The application-wide shortcuts. No window or tab binds these keys: +/// GTK runs application accelerators before a focused widget sees the +/// key. AdwApplication adds `app.shortcuts` on `question` from +/// `shortcuts-dialog.ui`. +pub const APP_ACCELS: &[(&str, &[&str])] = &[ + ("app.new-window", &["n"]), + ("app.quit", &["q"]), + ("app.preferences", &["comma"]), +]; + +pub fn install_app_actions(app: &TableProApplication) { + let new_window = gio::ActionEntry::builder("new-window") + .activate(|app: &TableProApplication, _, _| { + app.open_window(WindowLaunch::Picker); + }) + .build(); + let quit = gio::ActionEntry::builder("quit") + .activate(|app: &TableProApplication, _, _| app.request_quit()) + .build(); + let preferences = gio::ActionEntry::builder("preferences") + .activate(|app: &TableProApplication, _, _| present_preferences(app)) + .build(); + let about = gio::ActionEntry::builder("about") + .activate(|app: &TableProApplication, _, _| about_dialog().present(app.active_window().as_ref())) + .build(); + app.add_action_entries([new_window, quit, preferences, about]); + for (action, accels) in APP_ACCELS { + app.set_accels_for_action(action, accels); + } +} + +fn present_preferences(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + PreferencesDialog::new( + &services.settings, + &services.storage, + services.history().store(), + &services.tasks, + ) + .present(app.active_window().as_ref()); +} + +fn about_dialog() -> adw::AboutDialog { + let dialog = adw::AboutDialog::builder() + .application_name(crate::i18n::gettext("TablePro")) + .application_icon(crate::config::APP_ID) + .developer_name(crate::i18n::gettext("TablePro Authors")) + .version(crate::config::VERSION) + .website("https://github.com/TableProApp/TablePro") + .issue_url("https://github.com/TableProApp/TablePro/issues") + .support_url("https://github.com/TableProApp/TablePro/discussions") + .copyright(crate::i18n::gettext("© 2025–2026 TablePro Authors")) + .license_type(gtk::License::Agpl30) + .comments(crate::i18n::gettext( + "A native Linux database client built with GTK4 + libadwaita.", + )) + .build(); + dialog.set_developers(&["TablePro Authors https://github.com/TableProApp/TablePro"]); + dialog.set_translator_credits(&crate::i18n::gettext("translator-credits")); + dialog +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + use std::time::Duration; + + use gtk::gdk; + use tablepro_session::runtime::CountingHold; + + use super::*; + use crate::test_support::MemorySettings; + use crate::ui::{BrowseTab, SqlEditor, SqlEditorInit, install_window_shortcuts}; + + fn parse(accel: &str) -> (gdk::Key, gdk::ModifierType) { + let (key, modifiers) = gtk::accelerator_parse(accel).expect("an accelerator GTK can parse"); + (key.to_lower(), modifiers) + } + + fn keyval_triggers(trigger: >k::ShortcutTrigger, into: &mut Vec<(gdk::Key, gdk::ModifierType)>) { + if let Some(keyval) = trigger.downcast_ref::() { + into.push((keyval.keyval().to_lower(), keyval.modifiers())); + } else if let Some(pair) = trigger.downcast_ref::() { + keyval_triggers(&pair.first(), into); + keyval_triggers(&pair.second(), into); + } + } + + /// Every key a shortcut controller on `widget` or below it binds. + fn bound_keys(widget: >k::Widget) -> Vec<(gdk::Key, gdk::ModifierType)> { + let mut keys = Vec::new(); + let mut widgets = crate::test_support::descendants(widget); + widgets.push(widget.clone()); + for widget in widgets { + let controllers = widget.observe_controllers(); + for index in 0..controllers.n_items() { + let Some(controller) = controllers.item(index).and_downcast::() else { + continue; + }; + for position in 0..controller.n_items() { + if let Some(trigger) = controller + .item(position) + .and_downcast::() + .and_then(|shortcut| shortcut.trigger()) + { + keyval_triggers(&trigger, &mut keys); + } + } + } + } + keys + } + + #[gtk4::test] + fn app_accels_do_not_collide_with_window_accels() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let window = adw::ApplicationWindow::builder().build(); + install_window_shortcuts(&window); + let browse: BrowseTab = glib::Object::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(CountingHold::new()), + close_timeout: Duration::from_secs(1), + }); + let mut bound = bound_keys(window.upcast_ref()); + bound.extend(bound_keys(browse.upcast_ref())); + bound.extend(bound_keys(editor.upcast_ref())); + assert!( + bound.contains(&parse("n")), + "the tab's Insert Row key was not found, so the check reads nothing" + ); + + let reserved = APP_ACCELS + .iter() + .flat_map(|(_, accels)| accels.iter()) + .chain(std::iter::once(&"question")); + for accel in reserved { + assert!( + !bound.contains(&parse(accel)), + "{accel} is bound in a window or a tab as well as by the application" + ); + } + drop(editor); + runtime.shutdown(); + } + + #[test] + fn app_accels_name_app_actions() { + for (action, accels) in APP_ACCELS { + assert!(action.starts_with("app."), "{action}"); + assert!(!accels.is_empty(), "{action} has no accelerator"); + } + } +} diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs new file mode 100644 index 0000000000..d57252d11d --- /dev/null +++ b/linux/crates/app/src/application/app_services.rs @@ -0,0 +1,240 @@ +use std::cell::OnceCell; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::glib::prelude::IsA; +use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::operation::{DeadlinePolicy, DefaultDeadlines}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{ConnectServices, Connector, DriverConnector, SessionRegistry}; +use tablepro_ssh::SshServices; +use tablepro_storage::{AppSettings, QueryHistory, SecretStore, StoragePaths}; + +use crate::config; +use crate::runtime::GioProcessHold; +use crate::services::active_session::ActiveSession; +use crate::services::connection_deletion::ConnectionDeletion; +use crate::services::history_preferences::HistoryPreferences; +use crate::services::history_service::HistoryService; +use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; +use crate::storage::{AppStorage, SharedStorage}; +use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; +use crate::workspace::OpenSessions; + +/// What every window of the application shares. GTK-thread only. +pub struct AppServices { + pub drivers: Arc, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub sessions: Rc, + pub settings: Rc, + pub storage: SharedStorage, + pub tasks: Tasks, + /// Where each session's secrets come from. + credentials: Rc, + /// Bound to the application once it exists, which is after this. + application_hold: Rc, + /// What keeps the process alive while a close or a save finishes. + hold: Rc, + /// Which sessions the windows show, for the next launch to reopen. + open_sessions: Rc, + history: OnceCell, + ssh: Option, +} + +/// What `AppServices::new` builds from, for a caller that brings its own +/// connector and storage. +pub struct AppServicesParts { + pub drivers: DriverRegistry, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub settings: Rc, + pub storage: AppStorage, + pub tasks: Tasks, +} + +impl AppServices { + pub fn new(parts: AppServicesParts) -> Self { + let AppServicesParts { + drivers, + connector, + deadlines, + liveness, + settings, + storage, + tasks, + } = parts; + let application_hold = Rc::new(GioProcessHold::default()); + let open_sessions = OpenSessions::new(storage.workspaces().clone(), application_hold.clone()); + Self { + drivers: Arc::new(drivers), + connector, + deadlines, + liveness, + sessions: SessionRegistry::new(), + settings, + credentials: Rc::new(KeyringSources::new(storage.secrets().clone())), + storage: Rc::new(storage), + tasks, + hold: application_hold.clone(), + application_hold, + open_sessions, + history: OnceCell::new(), + ssh: None, + } + } + + /// A second launch builds this too before it hands its command line + /// to the running instance, so what only the primary may do waits + /// for `start`. + pub fn production(drivers: DriverRegistry, settings: Rc, paths: StoragePaths, tasks: Tasks) -> Self { + let secrets = Arc::new(SecretStore::new(config::secret_schema())); + let storage = AppStorage::new(paths, secrets, &tasks); + let ssh = SshServices::system(storage.paths().runtime.clone(), Path::new(config::LIBEXECDIR)); + let liveness = LivenessPolicy::DESKTOP; + let deadlines: Arc = Arc::new(DefaultDeadlines::new(liveness)); + let connector = DriverConnector::new( + ConnectServices { + environment: ConnectEnvironment::new(config::VERSION), + ssh: ssh.clone(), + liveness, + }, + deadlines.clone(), + ); + let mut services = Self::new(AppServicesParts { + drivers, + connector: Arc::new(connector), + deadlines, + liveness, + settings, + storage, + tasks, + }); + services.ssh = Some(ssh); + services + } + + /// The work only the primary instance does, from its startup. + /// + /// A missing askpass helper is logged rather than fatal: only SSH + /// needs it, and the connect that tries says so in its own error. The + /// sweep ends masters a crashed instance left behind, which would + /// otherwise keep their tunnels open until the next reboot. + pub(crate) fn start(&self) { + drop(self.history()); + let Some(ssh) = self.ssh.clone() else { + return; + }; + if let Err(error) = ssh.verify_askpass_helper() { + tracing::warn!(%error, "SSH connections will fail until the askpass helper is installed"); + } + drop(self.tasks.spawn_task(async move { + let ended = tablepro_ssh::sweep_stale_masters(&ssh).await; + if ended > 0 { + tracing::info!(ended, "ended SSH masters a previous instance left running"); + } + })); + } + + /// The database migrates and prunes on open, which is too slow to + /// hold up the first window. The menu items that need it stay + /// disabled until the service reports Ready. + pub(crate) fn history(&self) -> HistoryService { + self.history + .get_or_init(|| { + let preferences = HistoryPreferences::follow(&self.settings); + HistoryService::start(self.storage.paths().clone(), preferences, &self.tasks) + }) + .clone() + } + + /// The OpenSSH client and its helpers. `None` in a test that brings + /// none. + pub(crate) fn ssh(&self) -> Option { + self.ssh.clone() + } + + pub(crate) fn hold(&self) -> Rc { + self.hold.clone() + } + + /// What deletes a saved connection and everything kept for it, with + /// `history` as the history database, or `None` when it did not + /// open. + pub(crate) fn connection_deletion(&self, history: Option) -> ConnectionDeletion { + ConnectionDeletion { + secrets: self.storage.secrets().clone(), + connections: Arc::new(StoreConnectionWrites::new( + self.storage.connections().clone(), + self.tasks.clone(), + )), + column_widths: self.storage.column_widths().clone(), + filters: self.storage.filter_settings().clone(), + workspaces: self.storage.workspaces().clone(), + history, + spawner: self.tasks.clone(), + } + } + + pub(crate) fn open_sessions(&self) -> &Rc { + &self.open_sessions + } + + pub(crate) fn bind_application(&self, application: &impl IsA) { + self.application_hold.bind(application); + } + + /// A test reads the holds, or stands in for the keyring. + #[cfg(test)] + pub(crate) fn with_hold(mut self, hold: Rc) -> Self { + self.open_sessions = OpenSessions::new(self.storage.workspaces().clone(), hold.clone()); + self.hold = hold; + self + } + + #[cfg(test)] + pub(crate) fn with_ssh(mut self, ssh: SshServices) -> Self { + self.ssh = Some(ssh); + self + } + + #[cfg(test)] + pub(crate) fn with_credentials(mut self, credentials: Rc) -> Self { + self.credentials = credentials; + self + } + + /// One window's view of the shared services: its own session slot, + /// and its own credential prompts, which it shows over itself. + pub(crate) fn window_context(&self) -> (Rc, PromptQueue) { + let (prompter, prompts) = GtkCredentialPrompter::new(); + let context = SessionContext { + drivers: self.drivers.clone(), + connector: self.connector.clone(), + deadlines: self.deadlines.clone(), + prompter: Arc::new(prompter), + secrets: self.storage.secrets().clone(), + sessions: self.sessions.clone(), + hold: self.hold(), + tasks: self.tasks.clone(), + liveness: self.liveness, + credentials: self.credentials.clone(), + active: ActiveSession::new(), + }; + (Rc::new(context), prompts) + } +} + +impl std::fmt::Debug for AppServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppServices") + .field("drivers", &self.drivers.len()) + .field("sessions", &self.sessions) + .field("history", &self.history.get().is_some()) + .finish_non_exhaustive() + } +} diff --git a/linux/crates/app/src/application/app_window_host.rs b/linux/crates/app/src/application/app_window_host.rs new file mode 100644 index 0000000000..0d11383211 --- /dev/null +++ b/linux/crates/app/src/application/app_window_host.rs @@ -0,0 +1,34 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::{TableProApplication, WindowHost, WindowLaunch}; + +/// The application keeps every window's controller, so a window that +/// held it strongly would keep it alive past its own shutdown. +#[derive(Debug)] +pub struct AppWindowHost { + app: glib::WeakRef, +} + +impl AppWindowHost { + pub fn new(app: &TableProApplication) -> Self { + Self { app: app.downgrade() } + } +} + +impl WindowHost for AppWindowHost { + fn present_window(&self, window_id: u32) -> bool { + self.app.upgrade().is_some_and(|app| app.present_window(window_id)) + } + + fn open_window(&self, launch: WindowLaunch) { + if let Some(app) = self.app.upgrade() { + app.open_window(launch); + } + } + + fn application(&self) -> Option { + self.app.upgrade().map(|app| app.upcast()) + } +} diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs new file mode 100644 index 0000000000..c3461360bf --- /dev/null +++ b/linux/crates/app/src/application/imp.rs @@ -0,0 +1,158 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::ops::ControlFlow; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::session_signals::SessionSignals; +use super::{AppServices, WindowLaunch, WindowRegistry, install_app_actions}; +use crate::workspace::CloseDecision; + +pub(super) const NEW_WINDOW_OPTION: &str = "new-window"; + +#[derive(Default)] +pub struct TableProApplication { + pub(super) services: OnceCell>, + pub(super) windows: WindowRegistry, + /// The quit in progress, if any. + pub(super) quit: RefCell>>, + /// The window the quit is waiting on. + pub(super) quit_asking: Cell>, + /// Only the primary instance has sessions to tell. + pub(super) session_signals: RefCell>, + /// Only the first activate restores the last session. + pub(super) restore: Cell, + /// Files handed over while the record is read, opened after the + /// windows it restores. + pub(super) pending_files: RefCell>, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum RestoreState { + #[default] + NotAttempted, + /// The record is being read, and the windows open once it is. + Loading, + Done, +} + +#[glib::object_subclass] +impl ObjectSubclass for TableProApplication { + const NAME: &'static str = "TpApplication"; + type Type = super::TableProApplication; + type ParentType = adw::Application; +} + +impl ObjectImpl for TableProApplication { + fn constructed(&self) { + self.parent_constructed(); + // GApplication parses the command line before it emits + // handle-local-options, so an option added there is unknown. + self.obj().add_main_option( + NEW_WINDOW_OPTION, + glib::Char::from(b'n'), + glib::OptionFlags::NONE, + glib::OptionArg::None, + &crate::i18n::gettext("Open a new window"), + None, + ); + } +} + +impl ApplicationImpl for TableProApplication { + fn startup(&self) { + self.parent_startup(); + let app = self.obj(); + install_app_actions(&app); + if let Some(services) = self.services.get() { + services.start(); + self.session_signals + .replace(Some(SessionSignals::start(&services.sessions))); + } + app.connect_query_end(|app| { + app.present_inhibiting_window(); + }); + } + + fn activate(&self) { + self.parent_activate(); + let app = self.obj(); + if let Some(window) = app.active_window() { + window.present(); + return; + } + match self.restore.get() { + RestoreState::NotAttempted => app.restore_session(), + RestoreState::Loading => {} + RestoreState::Done => { + app.open_window(WindowLaunch::Picker); + } + } + } + + /// The files from Open With in the file manager, or from the command + /// line. GApplication opens them instead of activating, so no picker + /// window comes with them. + fn open(&self, files: &[gio::File], _hint: &str) { + match self.restore.get() { + RestoreState::Done => self.obj().open_files(files), + // The record is read before a file's window adds to it, or + // that write would replace the sessions still to restore. + RestoreState::NotAttempted => { + self.pending_files.borrow_mut().extend_from_slice(files); + self.obj().restore_session(); + } + RestoreState::Loading => self.pending_files.borrow_mut().extend_from_slice(files), + } + } + + /// `--new-window` on a second launch opens the window in the running + /// instance and exits. On the first launch it falls through to + /// activate, which opens the one window either way. + fn handle_local_options(&self, options: &glib::VariantDict) -> ControlFlow { + if !options.contains(NEW_WINDOW_OPTION) { + return ControlFlow::Continue(()); + } + let app = self.obj(); + if let Err(error) = app.register(gio::Cancellable::NONE) { + tracing::error!(%error, "could not register with the session bus"); + return ControlFlow::Break(glib::ExitCode::FAILURE); + } + if app.is_remote() { + app.activate_action("new-window", None); + return ControlFlow::Break(glib::ExitCode::SUCCESS); + } + ControlFlow::Continue(()) + } + + fn shutdown(&self) { + // The windows go with the process, which is not the user closing + // them, so the next launch still reopens their sessions. + if let Some(services) = self.services.get() { + services.open_sessions().freeze_for_quit(); + } + self.windows.clear(); + drop(self.session_signals.take()); + drop(self.quit.take()); + self.parent_shutdown(); + } +} + +impl GtkApplicationImpl for TableProApplication { + fn window_removed(&self, window: >k::Window) { + let id = window + .downcast_ref::() + .map(|window| window.id()); + self.parent_window_removed(window); + if let Some(id) = id { + self.windows.remove(id); + } + } +} + +impl AdwApplicationImpl for TableProApplication {} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs new file mode 100644 index 0000000000..033c60dabf --- /dev/null +++ b/linux/crates/app/src/application/mod.rs @@ -0,0 +1,167 @@ +//! The GApplication: one process per profile, the actions every window +//! shares, and the windows themselves. + +mod app_actions; +mod app_services; +mod app_window_host; +mod imp; +mod open_files; +mod quit_sequence; +mod session_signals; +mod window_host; +mod window_launch; +mod window_registry; + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_session::SessionKey; + +pub use app_actions::{APP_ACCELS, install_app_actions}; +pub use app_services::{AppServices, AppServicesParts}; +pub use app_window_host::AppWindowHost; +pub use quit_sequence::QuitSequence; +pub use window_host::WindowHost; +pub use window_launch::WindowLaunch; +pub(crate) use window_registry::WindowRegistry; + +use crate::config; +use crate::ui::{SessionWindow, SessionWindowInit}; +use crate::workspace::OpenSessionsLoad; + +glib::wrapper! { + pub struct TableProApplication(ObjectSubclass) + @extends adw::Application, gtk::Application, gio::Application, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl TableProApplication { + pub fn new(services: AppServices) -> Self { + let app: Self = glib::Object::builder() + .property("application-id", config::APP_ID) + .property("resource-base-path", config::RESOURCE_BASE_PATH) + .property("flags", gio::ApplicationFlags::HANDLES_OPEN) + .build(); + services.bind_application(&app); + // Only `new` sets it, and it has just built the object. + let _ = app.imp().services.set(Rc::new(services)); + app + } + + /// `None` only for an application built without `new`. + pub fn services(&self) -> Option> { + self.imp().services.get().cloned() + } + + /// Open a window on `launch` and show it. `None` when the + /// application has not started yet, since GTK takes windows only + /// after startup. + pub fn open_window(&self, launch: WindowLaunch) -> Option { + let Some(services) = self.services() else { + tracing::warn!("no window opened: the application has no services"); + return None; + }; + let window = SessionWindow::new(); + // The window gets its id when it is added, and it claims its + // session under that id from start on. + self.add_window(&window); + if window.application().is_none() { + tracing::warn!("no window opened: the application has not started"); + window.destroy(); + return None; + } + let host: Rc = Rc::new(AppWindowHost::new(self)); + self.imp().windows.insert(window.id(), window.clone()); + window.start(SessionWindowInit { services, host, launch }); + window.present(); + Some(window.upcast()) + } + + /// False when no window has that id. + pub fn present_window(&self, window_id: u32) -> bool { + let Some(window) = self.window_by_id(window_id) else { + return false; + }; + window.present(); + true + } + + /// Open the windows whose sessions were open when the application + /// last quit, then any files handed over meanwhile, or the picker + /// when that opens nothing. + fn restore_session(&self) { + let imp = self.imp(); + let services = match self.services() { + Some(services) if services.settings.restore_session() => services, + _ => { + imp.restore.set(imp::RestoreState::Done); + self.finish_restore(Vec::new()); + return; + } + }; + imp.restore.set(imp::RestoreState::Loading); + // No window holds the application while the record is read. + let hold = services.hold().acquire(); + let loading = services.open_sessions().load(); + let app = self.clone(); + glib::spawn_future_local(async move { + let keys = match loading.await { + OpenSessionsLoad::Restored(record) => services.open_sessions().restore(record), + OpenSessionsLoad::Empty => Vec::new(), + }; + app.imp().restore.set(imp::RestoreState::Done); + app.finish_restore(keys); + drop(hold); + }); + } + + fn finish_restore(&self, keys: Vec) { + let files = self.imp().pending_files.take(); + if keys.is_empty() && files.is_empty() { + self.open_window(WindowLaunch::Picker); + } + for key in keys { + self.open_window(WindowLaunch::Restore(key)); + } + self.open_files(&files); + } + + /// Ask every window to close, in turn. While one is asking, another + /// request brings that window forward instead of starting over. + pub fn request_quit(&self) { + let imp = self.imp(); + if imp.quit.borrow().is_some() { + if let Some(asking) = imp.quit_asking.get() { + self.present_window(asking); + } + return; + } + let app = self.clone(); + let running = glib::spawn_future_local(async move { + let decision = QuitSequence::run(app.clone()).await; + app.imp().quit.take(); + decision + }); + imp.quit.replace(Some(running)); + } + + /// Bring forward the first window that holds the session back, so + /// the user sees what a logout would lose. Returns its id. + pub(crate) fn present_inhibiting_window(&self) -> Option { + let imp = self.imp(); + let window = self + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .find(|window| imp.windows.holds_inhibit(window.id()))?; + window.present(); + Some(window.id()) + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/application/open_files.rs b/linux/crates/app/src/application/open_files.rs new file mode 100644 index 0000000000..783f7b3901 --- /dev/null +++ b/linux/crates/app/src/application/open_files.rs @@ -0,0 +1,73 @@ +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_session::SessionKey; + +use super::{TableProApplication, WindowLaunch}; +use crate::services::file_resolution::{FileTarget, ResolvedFile, resolve_file_blocking}; +use crate::ui::{SessionWindow, SessionWindowInput}; + +impl TableProApplication { + /// Open each file the file manager or the command line hands over: + /// in the window that has it open already, in the active window + /// while it only shows the picker, or in a new window. + pub fn open_files(&self, files: &[gio::File]) { + for file in files { + self.open_file(file.clone()); + } + } + + /// Finding the window that has the file open means canonicalizing + /// its path and matching it against the saved connections, which + /// read the disk, so the file is resolved on the spawner first. + fn open_file(&self, file: gio::File) { + let Some(services) = self.services() else { + tracing::warn!("no file opened: the application has no services"); + return; + }; + let (path, uri) = (file.path(), file.uri().to_string()); + let connections = services.storage.connections().clone(); + let resolving = services + .tasks + .spawn_blocking_task(move || resolve_file_blocking(path, uri, &connections)); + // An instance D-Bus started for this file would otherwise exit + // before its window opens. + let hold = self.hold(); + let app = self.downgrade(); + glib::spawn_future_local(async move { + let key = resolving.await.ok().and_then(|resolved| session_key(&resolved)); + if let Some(app) = app.upgrade() { + app.place_file(file, key); + } + drop(hold); + }); + } + + fn place_file(&self, file: gio::File, key: Option) { + let owner = key.and_then(|key| self.services()?.sessions.window_for(&key)); + if owner.is_some_and(|owner| self.present_window(owner)) { + return; + } + let idle = self + .active_window() + .and_downcast::() + .filter(SessionWindow::is_idle); + match idle { + Some(window) => { + window.send(SessionWindowInput::OpenFile(file)); + window.present(); + } + None => { + self.open_window(WindowLaunch::OpenFile(file)); + } + } + } +} + +/// The session a resolved file opens, so its window can be found. +fn session_key(resolved: &ResolvedFile) -> Option { + match &resolved.target { + Ok(FileTarget::Saved(saved)) => Some(SessionKey::Saved(saved.id)), + Ok(FileTarget::File(identity)) => Some(SessionKey::File(identity.uri.clone())), + Err(_) => None, + } +} diff --git a/linux/crates/app/src/application/quit_sequence.rs b/linux/crates/app/src/application/quit_sequence.rs new file mode 100644 index 0000000000..9d9fe83f38 --- /dev/null +++ b/linux/crates/app/src/application/quit_sequence.rs @@ -0,0 +1,91 @@ +use futures::channel::oneshot; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::TableProApplication; +use super::imp::RestoreState; +use crate::runtime::{DeliverInput, Delivery}; +use crate::ui::SessionWindowInput; +use crate::workspace::{CloseDecision, CloseReason}; + +/// Asks each window in turn whether it may close, the way closing it by +/// hand would. +/// +/// It never calls `g_application_quit`, which ignores the hold count: +/// the process ends once the last window is gone and the last save, +/// close and teardown hold has dropped. +/// +/// The open sessions stay recorded through the quit, so the next launch +/// reopens the windows it closes. The record is written before the +/// process may end, and a cancelled quit keeps only the windows still +/// showing or connecting their session. +/// +/// An approved quit can leave the process running with no window while +/// a session closes, and a second launch then activates it. That +/// activate restores the record as a new process would. +pub struct QuitSequence; + +impl QuitSequence { + pub async fn run(app: TableProApplication) -> CloseDecision { + let services = app.services(); + // Taken before the first window closes: the last one going would + // end the process before the record is written. + let hold = services.as_ref().map(|services| services.hold().acquire()); + if let Some(services) = &services { + services.open_sessions().freeze_for_quit(); + } + let mut asked: Vec = Vec::new(); + let decision = loop { + let Some(window) = app.windows().into_iter().find(|window| !asked.contains(window)) else { + break CloseDecision::Approved; + }; + asked.push(window.clone()); + if ask(&app, &window).await == CloseDecision::Cancelled { + break CloseDecision::Cancelled; + } + }; + app.imp().quit_asking.set(None); + if let Some(services) = &services { + match decision { + CloseDecision::Approved => { + services.open_sessions().flush().await; + services.open_sessions().end_quit(); + let restore = &app.imp().restore; + if restore.get() == RestoreState::Done { + restore.set(RestoreState::NotAttempted); + } + } + CloseDecision::Cancelled => services.open_sessions().thaw(|key| { + services + .sessions + .window_for(key) + .is_some_and(|window_id| app.imp().windows.keeps_session(window_id)) + }), + } + } + drop(hold); + decision + } +} + +async fn ask(app: &TableProApplication, window: >k::Window) -> CloseDecision { + let sender = window + .downcast_ref::() + .and_then(|window| app.imp().windows.sender(window.id())); + let Some(sender) = sender else { + // Not one of the session windows, so it has nothing to ask. + window.close(); + return CloseDecision::Approved; + }; + if let Some(window) = window.downcast_ref::() { + app.imp().quit_asking.set(Some(window.id())); + } + window.present(); + let (reply, answer) = oneshot::channel(); + if sender.try_deliver(SessionWindowInput::CloseRequested(CloseReason::Quit(reply))) == Delivery::ReceiverGone { + return CloseDecision::Approved; + } + // A window torn down before it answered has nothing left to keep. + answer.await.unwrap_or(CloseDecision::Approved) +} diff --git a/linux/crates/app/src/application/session_signals.rs b/linux/crates/app/src/application/session_signals.rs new file mode 100644 index 0000000000..c499295244 --- /dev/null +++ b/linux/crates/app/src/application/session_signals.rs @@ -0,0 +1,100 @@ +use std::rc::Rc; +use std::time::Duration; + +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{MonitorSignal, SessionRegistry}; + +use crate::runtime::{ + ClockSource, GioReachability, NetworkWatch, ReachabilityFanout, ReachabilityProbe, ResumeDetector, SystemClocks, +}; + +/// What tells the open sessions about the world outside their +/// connections: whether the network reaches each server, and that the +/// computer woke from suspend. It lives from startup to shutdown. +#[derive(Debug)] +pub(super) struct SessionSignals { + _network: NetworkWatch, + _live_count: RegistrySubscription, + _fanout: Rc, + _resume: Rc, +} + +impl SessionSignals { + pub(super) fn start(sessions: &Rc) -> Self { + let monitor = gio::NetworkMonitor::default(); + Self::start_with( + sessions, + monitor.clone(), + Rc::new(GioReachability::new(monitor)), + Box::new(SystemClocks), + ) + } + + fn start_with( + sessions: &Rc, + monitor: gio::NetworkMonitor, + probe: Rc, + clocks: Box, + ) -> Self { + let fanout = ReachabilityFanout::new(sessions.clone(), probe); + let network = NetworkWatch::start(monitor, fanout.clone(), sessions); + let resume = ResumeDetector::new(clocks); + // Held weakly: the registry keeps this closure among its + // observers, and the fanout keeps the registry. + let registry = Rc::downgrade(sessions); + let rechecks = Rc::downgrade(&fanout); + let on_resume: Rc = Rc::new(move |slept| { + if let Some(sessions) = registry.upgrade() { + for (_, session) in sessions.attached_sessions() { + session.signal(MonitorSignal::Resumed { slept }); + } + } + if let Some(fanout) = rechecks.upgrade() { + fanout.reevaluate_all(); + } + }); + let live_count = resume.follow(sessions, on_resume); + Self { + _network: network, + _live_count: live_count, + _fanout: fanout, + _resume: resume, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_test_support::CallCounters; + + use super::*; + use crate::test_support::{FakeClocks, FakeProbe, attach_remote, wait_until}; + + #[gtk4::test] + fn a_resume_probes_every_session_and_rechecks_its_server() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let clocks = FakeClocks::default(); + let signals = SessionSignals::start_with( + ®istry, + gio::NetworkMonitor::default(), + probe.clone(), + Box::new(clocks.clone()), + ); + let attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + let checked = probe.asked().len(); + let probes = || CallCounters::get(&attached.fake.connection.counters().probe); + let probed = probes(); + + clocks.advance(600, 0); + + wait_until(Duration::from_secs(10), || probes() > probed) + .expect("the session to probe its connection after the sleep"); + wait_until(Duration::from_secs(1), || probe.asked().len() > checked) + .expect("the server to be checked again after the sleep"); + drop(signals); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs new file mode 100644 index 0000000000..518ae82df7 --- /dev/null +++ b/linux/crates/app/src/application/tests.rs @@ -0,0 +1,656 @@ +use std::cell::Cell; +use std::ops::ControlFlow; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{DriverRegistry, LivenessPolicy}; +use tablepro_session::SessionKey; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::StoragePaths; +use tablepro_storage::document::{decode_document, encode_document}; +use tablepro_test_support::{ConnectionScript, FakeDriver, FakeReply, FakeSecretVault}; + +use super::*; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, finish_on_main, wait_until}; +use crate::workspace::{CloseDecision, InhibitReason, InhibitTarget, OpenSessionsRecord, WindowInhibitor}; + +const WAIT: Duration = Duration::from_secs(10); + +/// What fake services need kept alive for as long as the test runs. +struct Backing { + runtime: Option, + _settings: MemorySettings, + dir: tempfile::TempDir, +} + +impl Drop for Backing { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn fake_services() -> (AppServices, Backing) { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let liveness = LivenessPolicy::DESKTOP; + // Files open through a fake driver, so a restored file connects. + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + script.clone(), + ))); + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(FakeConnector::new([]).always(FakeEstablish::Connect(script))), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }); + let backing = Backing { + runtime: Some(runtime), + _settings: settings, + dir, + }; + (services, backing) +} + +/// An application over fakes, under an id of its own so it never meets +/// another test's application, or a TablePro the developer has open, on +/// the session bus. +struct TestApp { + app: TableProApplication, + _backing: Backing, +} + +impl TestApp { + fn new(name: &str) -> Self { + let (services, backing) = fake_services(); + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.{name}", crate::config::SCHEMA_ID))); + Self { app, _backing: backing } + } + + fn started(name: &str) -> Self { + let test = Self::new(name); + test.app + .register(gio::Cancellable::NONE) + .expect("register the application"); + test + } + + fn window(&self) -> adw::ApplicationWindow { + self.app.open_window(WindowLaunch::Picker).expect("a window") + } + + fn services(&self) -> Rc { + self.app.services().expect("the application's services") + } + + /// A database file in the test's directory, by the key it opens as. + fn database(&self, name: &str) -> SessionKey { + std::fs::write(self._backing.dir.path().join(name), b"").expect("create the database"); + self.file_key(name) + } + + /// The key a file in the test's directory opens as, whether or not + /// it is there. + fn file_key(&self, name: &str) -> SessionKey { + let dir = std::fs::canonicalize(self._backing.dir.path()).expect("the canonical directory"); + let uri = glib::filename_to_uri(dir.join(name), None).expect("a file URI"); + SessionKey::File(uri.to_string()) + } + + fn record_path(&self) -> std::path::PathBuf { + self.services() + .storage + .paths() + .workspaces_dir() + .join(OpenSessionsRecord::FILE_NAME) + } + + /// Write `keys` as the sessions open at the last quit. + fn record(&self, keys: &[SessionKey]) { + let path = self.record_path(); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the workspaces directory"); + let bytes = encode_document(&OpenSessionsRecord::from_keys(keys)).expect("encode"); + std::fs::write(&path, bytes).expect("write the record"); + } + + /// The sessions recorded on disk, once every write has landed. + fn recorded(&self) -> Vec { + finish_on_main(WAIT, self.services().open_sessions().settled()).expect("the writes to land"); + let path = self.record_path(); + let bytes = std::fs::read(&path).expect("the record"); + decode_document::(&path, &bytes) + .expect("a record") + .keys() + } + + /// The window that claims `key`. + fn window_for(&self, key: &SessionKey) -> gtk4::ApplicationWindow { + let id = self + .services() + .sessions + .window_for(key) + .expect("a window claims the key"); + self.app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .find(|window| window.id() == id) + .expect("the window") + } + + /// Wait for every key to show its workspace in a window of its own. + fn wait_for_sessions(&self, keys: &[SessionKey]) { + let services = self.services(); + wait_until(WAIT, || { + services.sessions.live_count() == keys.len() + && keys.iter().all(|key| services.sessions.window_for(key).is_some()) + }) + .expect("the restored sessions to connect"); + } +} + +impl Drop for TestApp { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + crate::test_support::drain_main_context(); + } +} + +/// Grants every inhibition, as a session manager would. +struct GrantingSession; + +impl InhibitTarget for GrantingSession { + fn inhibit(&self, _flags: gtk4::ApplicationInhibitFlags, _reason: &str) -> u32 { + 1 + } + + fn uninhibit(&self, _cookie: u32) {} +} + +#[gtk4::test] +fn new_builds_on_the_configured_id_and_resources() { + let (services, _backing) = fake_services(); + + let app = TableProApplication::new(services); + + assert_eq!(app.application_id().as_deref(), Some(crate::config::APP_ID)); + assert_eq!( + app.resource_base_path().as_deref(), + Some(crate::config::RESOURCE_BASE_PATH) + ); + assert!(app.services().is_some()); + assert!(glib::Object::new::().services().is_none()); +} + +#[gtk4::test] +fn new_window_option_registered_before_parse() { + let test = TestApp::new("Parse"); + let seen = Rc::new(Cell::new(false)); + let sink = seen.clone(); + test.app.connect_handle_local_options(move |_, options| { + sink.set(options.contains("new-window")); + ControlFlow::Break(glib::ExitCode::SUCCESS) + }); + + let code = test.app.run_with_args(&["tablepro", "--new-window"]); + + assert_eq!(code, glib::ExitCode::SUCCESS, "GApplication refused --new-window"); + assert!(seen.get(), "handle-local-options did not see --new-window"); +} + +#[gtk4::test] +fn handle_local_options_new_window_on_primary_continues() { + let test = TestApp::new("Primary"); + let options = glib::VariantDict::new(None); + options.insert_value("new-window", &true.to_variant()); + + let flow = test.app.imp().handle_local_options(&options); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(test.app.is_registered() && !test.app.is_remote()); +} + +#[gtk4::test] +fn handle_local_options_without_new_window_does_not_register() { + let test = TestApp::new("Plain"); + + let flow = test.app.imp().handle_local_options(&glib::VariantDict::new(None)); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(!test.app.is_registered()); +} + +#[gtk4::test] +fn startup_installs_the_app_actions_and_their_accels() { + let test = TestApp::started("Actions"); + + for action in ["new-window", "quit", "preferences", "about"] { + assert!(test.app.lookup_action(action).is_some(), "app.{action} is missing"); + } + for (action, _) in APP_ACCELS { + assert!(!test.app.accels_for_action(action).is_empty(), "{action} has no accel"); + } +} + +#[gtk4::test] +fn activate_opens_one_picker_and_then_presents_it() { + let test = TestApp::started("Activate"); + + test.app.activate(); + test.app.activate(); + wait_until(WAIT, || test.app.windows().len() == 1).expect("the picker"); + test.app.activate(); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + + assert_eq!(test.app.windows().len(), 1); + assert_eq!(test.app.imp().windows.len(), 1); +} + +#[gtk4::test] +fn activate_restores_the_recorded_sessions_in_order() { + let test = TestApp::started("Restore"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + let services = test.services(); + let ids = [&first, &second].map(|key| services.sessions.window_for(key).expect("a window")); + assert!(ids[0] < ids[1], "the windows opened out of the recorded order: {ids:?}"); + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + assert_eq!(services.open_sessions().keys(), [first, second]); +} + +#[gtk4::test] +fn activate_restores_nothing_when_restore_session_false() { + let test = TestApp::started("NoRestore"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + test.services() + .settings + .set_restore_session(false) + .expect("turn restoring off"); + + test.app.activate(); + + assert_eq!(test.app.windows().len(), 1, "the picker did not open at once"); + let _ = wait_until(Duration::from_millis(500), || { + test.services().sessions.window_for(&first).is_some() + }); + assert_eq!( + test.services().sessions.window_for(&first), + None, + "a session was restored" + ); + assert_eq!(test.app.windows().len(), 1); +} + +#[gtk4::test] +fn second_activate_does_not_restore_again() { + let test = TestApp::started("RestoreOnce"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + test.app.activate(); + test.wait_for_sessions(std::slice::from_ref(&first)); + for window in test.app.windows() { + window.destroy(); + } + assert!(test.recorded().is_empty(), "a closed window's session stayed recorded"); + test.record(std::slice::from_ref(&first)); + + test.app.activate(); + + assert_eq!(test.app.windows().len(), 1, "the picker did not open at once"); + let _ = wait_until(Duration::from_millis(500), || { + test.services().sessions.window_for(&first).is_some() + }); + assert_eq!( + test.services().sessions.window_for(&first), + None, + "the session was restored twice" + ); +} + +/// A launch for a file skips activate, and the file's window must not +/// write over the sessions recorded at the last quit. +#[gtk4::test] +fn an_open_before_any_activate_restores_the_record_and_keeps_it() { + let test = TestApp::started("OpenRestores"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(std::slice::from_ref(&first)); + let SessionKey::File(uri) = &second else { + unreachable!("a database file opens as a file key"); + }; + + test.app.open(&[gio::File::for_uri(uri)], ""); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored and opened windows" + ); + let mut recorded = test.recorded(); + recorded.sort_by_key(|key| key == &second); + assert_eq!(recorded, [first, second]); +} + +#[gtk4::test] +fn an_activate_while_the_record_loads_opens_nothing_more() { + let test = TestApp::started("RestoreLoading"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + + test.app.activate(); + test.app.activate(); + test.wait_for_sessions(std::slice::from_ref(&first)); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + + assert_eq!( + test.app.windows().len(), + 1, + "a picker opened beside the restored window" + ); +} + +#[gtk4::test] +fn quit_keeps_keys_window_close_removes() { + let test = TestApp::started("QuitRecord"); + let (closed, kept) = (test.database("closed.db"), test.database("kept.db")); + test.record(&[closed.clone(), kept.clone()]); + test.app.activate(); + test.wait_for_sessions(&[closed.clone(), kept.clone()]); + let services = test.services(); + + test.window_for(&closed).close(); + wait_until(WAIT, || services.sessions.window_for(&closed).is_none()).expect("the window to close"); + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert!(test.app.windows().is_empty()); + assert_eq!(test.recorded(), [kept]); +} + +#[gtk4::test] +fn cancelled_quit_thaws_to_live_keys() { + let test = TestApp::started("QuitCancelled"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + // Whichever window the quit asks first closes and raises a dialog + // over the other, which takes that window's close request and so + // cancels the quit. + let windows: Vec = test + .app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .collect(); + let [one, other] = windows.as_slice() else { + panic!("{} windows, not two", windows.len()); + }; + let raised = Rc::new(Cell::new(false)); + for (closing, kept) in [(one, other), (other, one)] { + let (kept, raised) = (kept.downgrade(), raised.clone()); + closing.connect_close_request(move |_| { + if let Some(kept) = kept.upgrade() + && !raised.replace(true) + { + adw::AlertDialog::new(Some("Busy"), None).present(Some(&kept)); + } + glib::Propagation::Proceed + }); + } + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Cancelled); + assert_eq!(test.app.windows().len(), 1); + let services = test.services(); + let open: Vec = [first, second] + .into_iter() + .filter(|key| services.sessions.window_for(key).is_some()) + .collect(); + assert_eq!(open.len(), 1, "{open:?}"); + assert_eq!(services.open_sessions().keys(), open); + assert_eq!(test.recorded(), open); +} + +#[gtk4::test] +fn an_activate_after_an_approved_quit_restores_its_record() { + let test = TestApp::started("QuitThenActivate"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + let services = test.services(); + // A session still closing keeps the process up once its window is gone. + let closing = services.hold().acquire(); + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + assert_eq!(decision, CloseDecision::Approved); + assert!(test.app.windows().is_empty()); + + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + test.window_for(&first).close(); + wait_until(WAIT, || services.sessions.window_for(&first).is_none()).expect("the window to close"); + assert_eq!( + test.recorded(), + [second], + "a window closed after the restore stayed recorded" + ); + drop(closing); +} + +#[gtk4::test] +fn a_cancelled_quit_forgets_a_restore_that_failed_during_it() { + let test = TestApp::started("QuitFailedRestore"); + let gone = test.file_key("gone.db"); + test.record(std::slice::from_ref(&gone)); + let services = test.services(); + // The restore fails while the quit waits on another window. + services.open_sessions().freeze_for_quit(); + test.app.activate(); + wait_until(WAIT, || services.sessions.window_for(&gone).is_some()).expect("the failed connect's claim"); + // The window's close request dismisses the dialog instead, which + // cancels the quit. + adw::AlertDialog::new(Some("Busy"), None).present(Some(&test.window_for(&gone))); + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Cancelled); + assert!( + services.sessions.window_for(&gone).is_some(), + "the failed connect's window closed" + ); + assert!(test.recorded().is_empty(), "the failed restore stayed recorded"); +} + +#[gtk4::test] +fn a_quit_without_close_requests_keeps_the_sessions_recorded() { + let test = TestApp::new("ShutdownRecord"); + let open = test.database("open.db"); + test.record(std::slice::from_ref(&open)); + let services = test.services(); + let connected = Rc::new(Cell::new(false)); + let (app, seen, key) = (test.app.downgrade(), connected.clone(), open.clone()); + let deadline = std::time::Instant::now() + WAIT; + glib::timeout_add_local(Duration::from_millis(20), move || { + let Some(app) = app.upgrade() else { + return glib::ControlFlow::Break; + }; + seen.set(services.sessions.live_count() == 1 && services.sessions.window_for(&key).is_some()); + if seen.get() || std::time::Instant::now() > deadline { + #[expect( + clippy::disallowed_methods, + reason = "GTK quits this way when the session ends, without asking the windows" + )] + app.quit(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); + + let code = test.app.run_with_args(&["tablepro"]); + + assert_eq!(code, glib::ExitCode::SUCCESS); + assert!(connected.get(), "the recorded session never opened"); + assert_eq!(test.recorded(), [open]); +} + +#[gtk4::test] +fn new_window_action_opens_another_window() { + let test = TestApp::started("NewWindow"); + test.window(); + + test.app.activate_action("new-window", None); + + assert_eq!(test.app.windows().len(), 2); + assert_eq!(test.app.imp().windows.len(), 2); +} + +#[gtk4::test] +fn a_window_before_startup_is_refused() { + let test = TestApp::new("Early"); + + assert!(test.app.open_window(WindowLaunch::Picker).is_none()); + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn a_removed_window_takes_its_controller_with_it() { + let test = TestApp::started("Removed"); + let window = test.window(); + assert_eq!(test.app.imp().windows.len(), 1); + + window.destroy(); + + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn present_window_finds_the_window_by_id() { + let test = TestApp::started("Present"); + let window = test.window(); + window.set_visible(false); + + assert!(test.app.present_window(window.id())); + assert!(window.is_visible()); + assert!(!test.app.present_window(window.id() + 100)); +} + +#[gtk4::test] +fn query_end_presents_inhibiting_window() { + let test = TestApp::started("QueryEnd"); + let idle = test.window(); + let busy = test.window(); + for window in [&idle, &busy] { + window.set_visible(false); + } + let mut inhibitor = WindowInhibitor::new(GrantingSession); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + test.app + .imp() + .windows + .get(busy.id()) + .expect("the busy window") + .set_inhibitor(inhibitor); + + test.app.emit_by_name::<()>("query-end", &[]); + + assert!(busy.is_visible(), "the window holding logout back stayed hidden"); + assert!(!idle.is_visible(), "a window with nothing to lose was brought forward"); + assert_eq!(test.app.present_inhibiting_window(), Some(busy.id())); +} + +#[gtk4::test] +fn quit_closes_every_window_through_its_close_flow() { + let test = TestApp::started("Quit"); + let first = test.window(); + let second = test.window(); + let requests = Rc::new(Cell::new(0)); + for window in [&first, &second] { + let requests = requests.clone(); + window.connect_close_request(move |_| { + requests.set(requests.get() + 1); + glib::Propagation::Proceed + }); + } + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert_eq!(requests.get(), 2, "a window closed without a close request"); + assert!(!first.is_visible() && !second.is_visible()); + assert!(test.app.windows().is_empty()); + assert_eq!(test.app.imp().windows.len(), 0); + assert_eq!(test.app.imp().quit_asking.get(), None); +} + +#[gtk4::test] +fn request_quit_runs_once_and_clears_when_done() { + let test = TestApp::started("RequestQuit"); + test.window(); + + test.app.request_quit(); + assert!(test.app.imp().quit.borrow().is_some()); + wait_until(WAIT, || test.app.imp().quit.borrow().is_none()).expect("the quit to finish"); + + assert!(test.app.windows().is_empty()); +} + +#[gtk4::test] +fn a_second_quit_request_brings_the_asking_window_forward() { + let test = TestApp::started("QuitAgain"); + let asking = test.window(); + asking.set_visible(false); + let pending = glib::spawn_future_local(std::future::pending::()); + test.app.imp().quit.replace(Some(pending)); + test.app.imp().quit_asking.set(Some(asking.id())); + + test.app.request_quit(); + + assert!(asking.is_visible(), "the window the quit waits on stayed hidden"); + assert_eq!(test.app.windows().len(), 1, "a second quit started over"); + if let Some(pending) = test.app.imp().quit.take() { + pending.abort(); + } +} diff --git a/linux/crates/app/src/application/window_host.rs b/linux/crates/app/src/application/window_host.rs new file mode 100644 index 0000000000..e4a0876b66 --- /dev/null +++ b/linux/crates/app/src/application/window_host.rs @@ -0,0 +1,13 @@ +use gtk4 as gtk; + +use super::WindowLaunch; + +/// What a window may ask of the application that hosts it, so a window +/// reaches the others without holding the application. +pub trait WindowHost { + /// False when no window has that id any more. + fn present_window(&self, window_id: u32) -> bool; + fn open_window(&self, launch: WindowLaunch); + /// `None` once the application is gone. + fn application(&self) -> Option; +} diff --git a/linux/crates/app/src/application/window_launch.rs b/linux/crates/app/src/application/window_launch.rs new file mode 100644 index 0000000000..de1311351a --- /dev/null +++ b/linux/crates/app/src/application/window_launch.rs @@ -0,0 +1,13 @@ +use tablepro_session::SessionKey; +use tablepro_storage::SavedConnection; + +/// What a new window opens on. +#[derive(Debug, Clone)] +pub enum WindowLaunch { + Picker, + Connect(Box), + /// A database file, from the file manager or the command line. + OpenFile(gio::File), + /// A session that was open when the application last quit. + Restore(SessionKey), +} diff --git a/linux/crates/app/src/application/window_registry.rs b/linux/crates/app/src/application/window_registry.rs new file mode 100644 index 0000000000..c094d1d328 --- /dev/null +++ b/linux/crates/app/src/application/window_registry.rs @@ -0,0 +1,64 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::runtime::InputSender; +use crate::ui::{SessionWindow, SessionWindowInput}; + +/// Each window the application opened, by window id. GTK-thread only. +#[derive(Default)] +pub(crate) struct WindowRegistry { + windows: RefCell>, +} + +impl WindowRegistry { + pub(crate) fn insert(&self, id: u32, window: SessionWindow) { + self.windows.borrow_mut().insert(id, window); + } + + /// The window is shut down after the map is released: its teardown + /// may present another window, which reads the map. + pub(crate) fn remove(&self, id: u32) { + let removed = self.windows.borrow_mut().remove(&id); + if let Some(window) = removed { + window.shutdown(); + } + } + + pub(crate) fn sender(&self, id: u32) -> Option> { + self.windows.borrow().get(&id).and_then(SessionWindow::input) + } + + /// Whether the window holds logout back. + pub(crate) fn holds_inhibit(&self, id: u32) -> bool { + self.windows.borrow().get(&id).is_some_and(SessionWindow::is_inhibiting) + } + + pub(crate) fn keeps_session(&self, id: u32) -> bool { + self.windows.borrow().get(&id).is_some_and(SessionWindow::keeps_session) + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.windows.borrow().len() + } + + #[cfg(test)] + pub(crate) fn get(&self, id: u32) -> Option { + self.windows.borrow().get(&id).cloned() + } + + pub(crate) fn clear(&self) { + let windows: Vec = self.windows.borrow_mut().drain().map(|(_, window)| window).collect(); + for window in windows { + window.shutdown(); + } + } +} + +impl std::fmt::Debug for WindowRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowRegistry") + .field("windows", &self.windows.borrow().keys().collect::>()) + .finish() + } +} diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index 19ecb0945d..36aadccb26 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -1,17 +1,12 @@ -use std::path::Path; use std::rc::Rc; use std::sync::Arc; -use libadwaita as adw; -use relm4::RelmApp; use thiserror::Error; -use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; -use tablepro_session::operation::{DeadlinePolicy, DefaultDeadlines}; -use tablepro_session::runtime::Tasks; -use tablepro_session::{ConnectServices, DriverConnector, SessionRegistry}; -use tablepro_ssh::SshServices; +use tablepro_core::DriverRegistry; +use tablepro_storage::{AppSettings, StoragePaths}; +pub mod application; pub mod config; pub mod i18n; pub mod logging; @@ -26,148 +21,46 @@ mod test_support; mod ui; mod workspace; +use application::{AppServices, TableProApplication}; + #[derive(Debug, Error)] -enum StartupError { +pub enum StartupError { + #[error("could not start logging: {0}")] + Logging(#[from] logging::LoggingError), #[error("could not start the background runtime: {0}")] Runtime(#[source] std::io::Error), #[error("could not register the embedded resources: {0}")] Resources(#[source] glib::Error), #[error("could not open the settings schema: {0}")] Settings(#[from] tablepro_storage::SettingsError), - #[error("could not start logging: {0}")] - Logging(#[from] logging::LoggingError), } -pub fn run() -> glib::ExitCode { +/// Start the application and return its exit code once the last window +/// and the last background close are gone. +pub fn run() -> Result { // SAFETY: nothing above spawns a thread; the tokio runtime and GTK start later. let translations = unsafe { i18n::init() }; - - if let Err(error) = logging::init(config::profile()) { - // Nothing is journalling yet, so this is the one place that has - // to reach the session log by another route. - glib::g_critical!("tablepro", "{error}"); - return glib::ExitCode::FAILURE; - } + logging::init(config::profile())?; if let Err(error) = translations { tracing::warn!(%error, "translations unavailable; falling back to the source strings"); } - match start() { - Ok(()) => glib::ExitCode::SUCCESS, - Err(error) => { - tracing::error!(%error, "TablePro could not start"); - glib::ExitCode::FAILURE - } - } -} - -fn start() -> Result<(), StartupError> { - // Single-instance gate: belt-and-suspenders flock on top of - // gtk::Application's DBus-based uniqueness, since the latter - // silently lets two processes through when DBus is unavailable. - // A second instance would write the same state files beside this - // one. Hold the lock through the entire `start`. - let paths = tablepro_storage::StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); - - let _instance_lock = match services::single_instance::acquire(&paths) { - Ok(lock) => Some(lock), - Err(services::single_instance::LockError::AlreadyRunning) => { - tracing::info!("another TablePro instance is running; exiting"); - return Ok(()); - } - Err(e) => { - // No XDG runtime, cache or HOME: proceed without the - // lock. gtk::Application's uniqueness still applies. - tracing::warn!(error = %e, "single-instance lock unavailable; relying on DBus uniqueness"); - None - } - }; - - let settings = std::rc::Rc::new(tablepro_storage::AppSettings::open(config::SCHEMA_ID)?); let runtime = runtime::AppRuntime::build().map_err(StartupError::Runtime)?; - let tasks = runtime.tasks(); - // The database migrates and prunes on open, which is too slow to - // hold up the first window. The menu item that needs it stays - // disabled until the service reports Ready. - let retention = services::history_service::publish_retention(&settings); - let history = services::history_service::HistoryService::start(paths.clone(), retention, &tasks); - let storage = std::rc::Rc::new(storage::AppStorage::new( - paths, - std::sync::Arc::new(tablepro_storage::SecretStore::new(config::secret_schema())), - &tasks, - )); - - let drivers = Arc::new(build_registry()); - tracing::info!(drivers = drivers.len(), "starting tablepro"); - gio::resources_register_include!("tablepro.gresource").map_err(StartupError::Resources)?; + let settings = Rc::new(AppSettings::open(config::SCHEMA_ID)?); + let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); + let drivers = build_registry(); + tracing::info!(drivers = drivers.len(), "starting tablepro"); - let application = adw::Application::builder() - .application_id(config::APP_ID) - .resource_base_path(config::RESOURCE_BASE_PATH) - .build(); - let ssh = ssh_services(&storage.paths().runtime, &tasks); - let liveness = LivenessPolicy::DESKTOP; - let deadlines: Arc = Arc::new(DefaultDeadlines::new(liveness)); - let connector = DriverConnector::new( - ConnectServices { - environment: ConnectEnvironment::new(config::VERSION), - ssh, - liveness, - }, - deadlines.clone(), - ); - let (prompter, prompts) = ui::credential_prompt::GtkCredentialPrompter::new(); - let context = Rc::new(services::session_context::SessionContext { - drivers, - connector: Arc::new(connector), - deadlines, - prompter: Arc::new(prompter), - secrets: storage.secrets().clone(), - sessions: SessionRegistry::new(), - hold: Rc::new(runtime::GioProcessHold::new(&application)), - tasks: tasks.clone(), - liveness, - active: services::active_session::ActiveSession::new(), - }); - - let app = RelmApp::from_app(application); - app.run::(ui::AppInit { - context, - prompts, - settings, - storage, - tasks, - history, - }); + let application = TableProApplication::new(AppServices::production(drivers, settings, paths, runtime.tasks())); + let code = gio::prelude::ApplicationExtManual::run(&application); + drop(application); // The main loop has returned, so nothing is producing work any // more. Give what is in flight a moment to finish rather than // dropping it mid-write. runtime.shutdown(); - Ok(()) -} - -/// The system OpenSSH client, with its control sockets under the app's -/// runtime directory. -/// -/// A missing askpass helper is logged rather than fatal: only SSH needs -/// it, and the connect that tries says so in its own error. The sweep -/// ends masters a crashed instance left behind, which would otherwise -/// keep their tunnels open until the next reboot. -fn ssh_services(runtime_dir: &Path, tasks: &Tasks) -> SshServices { - let services = SshServices::system(runtime_dir.to_path_buf(), Path::new(config::LIBEXECDIR)); - if let Err(error) = services.verify_askpass_helper() { - tracing::warn!(%error, "SSH connections will fail until the askpass helper is installed"); - } - let sweeping = services.clone(); - drop(tasks.spawn_task(async move { - let ended = tablepro_ssh::sweep_stale_masters(&sweeping).await; - if ended > 0 { - tracing::info!(ended, "ended SSH masters a previous instance left running"); - } - })); - services + Ok(code) } fn build_registry() -> DriverRegistry { @@ -190,11 +83,10 @@ pub(crate) fn register_test_resources() { #[cfg(test)] mod tests { - use gio::prelude::ApplicationExt; - - use super::register_test_resources; use glib::prelude::ObjectExt; + use libadwaita as adw; + use super::register_test_resources; use super::*; #[test] @@ -222,14 +114,4 @@ mod tests { assert_eq!(dialog.type_().name(), "AdwShortcutsDialog"); } - - #[gtk4::test] - fn application_uses_explicit_resource_base_path() { - let app = adw::Application::builder() - .application_id(config::APP_ID) - .resource_base_path(config::RESOURCE_BASE_PATH) - .build(); - - assert_eq!(app.resource_base_path().as_deref(), Some(config::RESOURCE_BASE_PATH)); - } } diff --git a/linux/crates/app/src/main.rs b/linux/crates/app/src/main.rs index bcd9e1d404..9e7afed97e 100644 --- a/linux/crates/app/src/main.rs +++ b/linux/crates/app/src/main.rs @@ -1,3 +1,17 @@ +use tablepro_app::StartupError; + fn main() -> glib::ExitCode { - tablepro_app::run() + match tablepro_app::run() { + Ok(code) => code, + Err(StartupError::Logging(error)) => { + // Nothing is journalling yet, so this is the one place that + // has to reach the session log by another route. + glib::g_critical!("tablepro", "{error}"); + glib::ExitCode::FAILURE + } + Err(error) => { + tracing::error!(%error, "TablePro could not start"); + glib::ExitCode::FAILURE + } + } } diff --git a/linux/crates/app/src/runtime/clock_sample.rs b/linux/crates/app/src/runtime/clock_sample.rs new file mode 100644 index 0000000000..ca2d585165 --- /dev/null +++ b/linux/crates/app/src/runtime/clock_sample.rs @@ -0,0 +1,10 @@ +use std::time::Duration; + +/// Both clocks read at one moment. Monotonic time stops while the +/// machine is suspended and boot time does not, so the gap between them +/// grows by exactly the time spent asleep. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ClockSample { + pub boottime: Duration, + pub monotonic: Duration, +} diff --git a/linux/crates/app/src/runtime/clock_source.rs b/linux/crates/app/src/runtime/clock_source.rs new file mode 100644 index 0000000000..4cfe0592ab --- /dev/null +++ b/linux/crates/app/src/runtime/clock_source.rs @@ -0,0 +1,5 @@ +use super::ClockSample; + +pub trait ClockSource { + fn sample(&self) -> ClockSample; +} diff --git a/linux/crates/app/src/runtime/deliver.rs b/linux/crates/app/src/runtime/deliver.rs index de5093cd2f..80e5584e55 100644 --- a/linux/crates/app/src/runtime/deliver.rs +++ b/linux/crates/app/src/runtime/deliver.rs @@ -1,6 +1,6 @@ use std::future::Future; -use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_session::runtime::{ProcessHold, TaskFailure, Tasks}; /// Run `work` on the app runtime and hand its result to `deliver` on the /// GTK thread. @@ -21,13 +21,50 @@ where }) } +/// Run `work` on the app runtime with the process held until it ends, +/// so a quit right after a write started waits for it instead of +/// cutting it off. +pub fn spawn_held(tasks: &Tasks, hold: &dyn ProcessHold, work: W) +where + W: Future + Send + 'static, +{ + let token = hold.acquire(); + let running = tasks.spawn_task(work); + glib::spawn_future_local(async move { + if let Err(failure) = running.await { + tracing::warn!(%failure, "a held background task failed"); + } + drop(token); + }); +} + #[cfg(test)] mod tests { use std::cell::RefCell; use std::rc::Rc; + use tablepro_session::runtime::CountingHold; + use super::*; + #[gtk4::test] + fn held_work_keeps_the_process_until_it_ends() { + let runtime = crate::test_support::test_runtime(); + let hold = CountingHold::new(); + let (finish, finished) = tokio::sync::oneshot::channel::<()>(); + + spawn_held(&runtime.tasks(), &hold, async move { + let _ = finished.await; + }); + crate::test_support::drain_main_context(); + assert_eq!(hold.live(), 1, "the work was not held"); + + let _ = finish.send(()); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || hold.live() == 0) + .expect("the hold to end with the work"); + runtime.shutdown(); + } + #[gtk4::test] fn the_result_arrives_on_the_main_thread() { let runtime = crate::test_support::test_runtime(); diff --git a/linux/crates/app/src/runtime/deliver_input.rs b/linux/crates/app/src/runtime/deliver_input.rs new file mode 100644 index 0000000000..9f18633d3c --- /dev/null +++ b/linux/crates/app/src/runtime/deliver_input.rs @@ -0,0 +1,85 @@ +/// Whether a message reached the component it was sent to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delivery { + Delivered, + /// The component has shut down, so the message was dropped. + 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. +pub trait DeliverInput { + fn try_deliver(&self, message: M) -> Delivery; + + fn deliver(&self, message: M) { + if self.try_deliver(message) == Delivery::ReceiverGone { + tracing::debug!(target: "component", "message dropped after component shutdown"); + } + } +} + +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) { + Ok(()) => Delivery::Delivered, + Err(_) => Delivery::ReceiverGone, + } + } +} + +#[cfg(test)] +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::(); + drop(receiver); + + sender.deliver(7); + } + + #[test] + fn deliver_to_dropped_receiver_reports_receiver_gone() { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + assert_eq!(sender.try_deliver(1), Delivery::Delivered); + + drop(receiver); + + assert_eq!(sender.try_deliver(2), Delivery::ReceiverGone); + } +} diff --git a/linux/crates/app/src/runtime/detect_resume.rs b/linux/crates/app/src/runtime/detect_resume.rs new file mode 100644 index 0000000000..4464c79929 --- /dev/null +++ b/linux/crates/app/src/runtime/detect_resume.rs @@ -0,0 +1,48 @@ +use std::time::Duration; + +use super::ClockSample; + +/// Below this the gap is scheduling noise, not a suspend. +pub const RESUME_THRESHOLD: Duration = Duration::from_secs(2); + +/// How long the machine slept between two samples, when it slept longer +/// than `threshold`. +pub fn detect_resume(prev: ClockSample, now: ClockSample, threshold: Duration) -> Option { + let awake = now.monotonic.saturating_sub(prev.monotonic); + let slept = now.boottime.saturating_sub(prev.boottime).saturating_sub(awake); + (slept > threshold).then_some(slept) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(boottime: u64, monotonic: u64) -> ClockSample { + ClockSample { + boottime: Duration::from_secs(boottime), + monotonic: Duration::from_secs(monotonic), + } + } + + #[test] + fn drift_over_threshold_reports_sleep() { + let slept = detect_resume(sample(100, 90), sample(700, 95), RESUME_THRESHOLD); + + assert_eq!(slept, Some(Duration::from_secs(595))); + } + + #[test] + fn equal_clocks_report_none() { + assert_eq!(detect_resume(sample(100, 90), sample(105, 95), RESUME_THRESHOLD), None); + assert_eq!( + detect_resume(sample(100, 90), sample(107, 95), RESUME_THRESHOLD), + None, + "two seconds is not a sleep" + ); + assert_eq!( + detect_resume(sample(100, 90), sample(90, 80), RESUME_THRESHOLD), + None, + "clocks read out of order are not a sleep" + ); + } +} diff --git a/linux/crates/app/src/runtime/gio_process_hold.rs b/linux/crates/app/src/runtime/gio_process_hold.rs index d9a0ce38b5..fc60a0c6cf 100644 --- a/linux/crates/app/src/runtime/gio_process_hold.rs +++ b/linux/crates/app/src/runtime/gio_process_hold.rs @@ -1,39 +1,40 @@ +use glib::prelude::*; use tablepro_session::runtime::{HoldToken, ProcessHold}; /// Keeps the GApplication running while a token is alive, so closing /// the last window does not end the process with a connection still /// closing behind it. -#[derive(Debug, Clone)] +/// +/// It holds the application weakly: the application owns the services +/// this lives in, and a token taken once the application is gone has +/// nothing left to keep alive. +#[derive(Debug, Clone, Default)] pub struct GioProcessHold { - application: gio::Application, + application: glib::WeakRef, } impl GioProcessHold { - pub fn new(application: &impl glib::prelude::IsA) -> Self { - Self { - application: application.as_ref().clone(), - } + pub fn new(application: &impl IsA) -> Self { + let hold = Self::default(); + hold.bind(application); + hold } - /// The hold for whichever application is running, or none in a test - /// that never started one. - pub fn for_default_application() -> Option { - gio::Application::default().map(|application| Self { application }) + /// Point the hold at `application`. Tokens taken before this hold + /// nothing. + pub fn bind(&self, application: &impl IsA) { + self.application.set(Some(application.upcast_ref())); } } impl ProcessHold for GioProcessHold { fn acquire(&self) -> HoldToken { - HoldToken::new(gio::prelude::ApplicationExtManual::hold(&self.application)) + self.application.upgrade().map_or_else(HoldToken::inert, |application| { + HoldToken::new(gio::prelude::ApplicationExtManual::hold(&application)) + }) } } -/// A hold on the running application when there is one, and an inert -/// token otherwise. GTK-thread only, like the application itself. -pub fn hold_default_application() -> HoldToken { - GioProcessHold::for_default_application().map_or_else(HoldToken::inert, |hold| hold.acquire()) -} - #[cfg(test)] mod tests { use super::*; @@ -48,4 +49,20 @@ mod tests { let again = hold.acquire(); drop(again); } + + #[gtk4::test] + fn a_hold_without_an_application_gives_inert_tokens() { + let unbound = GioProcessHold::default(); + drop(unbound.acquire()); + + let application = gio::Application::new(None, gio::ApplicationFlags::NON_UNIQUE); + let hold = GioProcessHold::new(&application); + drop(application); + + assert!( + hold.application.upgrade().is_none(), + "the hold kept the application alive" + ); + drop(hold.acquire()); + } } diff --git a/linux/crates/app/src/runtime/gio_reachability.rs b/linux/crates/app/src/runtime/gio_reachability.rs new file mode 100644 index 0000000000..d592b1a849 --- /dev/null +++ b/linux/crates/app/src/runtime/gio_reachability.rs @@ -0,0 +1,38 @@ +use futures::FutureExt; +use futures::future::LocalBoxFuture; +use gio::prelude::*; +use tablepro_core::NetworkEndpoint; +use tablepro_session::health::Reachability; + +use super::ReachabilityProbe; + +/// Asks GLib's network monitor, which checks the endpoint's addresses +/// against the routes it knows, through the network portal under +/// Flatpak. It resolves the name but never connects. +#[derive(Debug, Clone)] +pub struct GioReachability { + monitor: gio::NetworkMonitor, +} + +impl GioReachability { + pub fn new(monitor: gio::NetworkMonitor) -> Self { + Self { monitor } + } +} + +impl ReachabilityProbe for GioReachability { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability> { + let address = gio::NetworkAddress::new(endpoint.host(), endpoint.port()); + let host = endpoint.host().to_owned(); + self.monitor + .can_reach_future(&address) + .map(move |reached| match reached { + Ok(()) => Reachability::Reachable, + Err(error) => { + tracing::debug!(%host, %error, "the network cannot reach a session's server"); + Reachability::Unreachable + } + }) + .boxed_local() + } +} diff --git a/linux/crates/app/src/runtime/input_queue.rs b/linux/crates/app/src/runtime/input_queue.rs new file mode 100644 index 0000000000..45c4be4477 --- /dev/null +++ b/linux/crates/app/src/runtime/input_queue.rs @@ -0,0 +1,177 @@ +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; + +pub fn input_queue() -> (InputSender, UnboundedReceiver) { + tokio::sync::mpsc::unbounded_channel() +} + +/// Hand each message `object` receives to `handle`, one at a time and in +/// order, on the GTK thread, until the returned handle is aborted or +/// `object` is gone. +/// +/// Handling waits for the main loop rather than running inside whatever +/// sent the message, so a signal handler or an action never re-enters +/// the widget while it is in the middle of handling another message. +pub fn pump_inputs( + object: &T, + receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, +) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + pump_inputs_with_leftovers(object, receiver, handle, drop) +} + +/// As [`pump_inputs`], and once the pump stops, aborted or with `object` +/// gone, every message still queued goes to `leftover` rather than being +/// dropped, for one that owns something that has to be closed. +pub fn pump_inputs_with_leftovers( + object: &T, + receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, + leftover: impl FnMut(M) + 'static, +) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + let weak = object.downgrade(); + let mut queue = Leftovers { receiver, leftover }; + glib::spawn_future_local(async move { + while let Some(message) = queue.receiver.recv().await { + let Some(object) = weak.upgrade() else { + (queue.leftover)(message); + return; + }; + handle(&object, message); + } + }) +} + +/// The pump's end of the queue. It drains when the pump's future drops. +struct Leftovers { + receiver: UnboundedReceiver, + leftover: F, +} + +impl Drop for Leftovers { + fn drop(&mut self) { + // Closed first, so a sender gets its message back instead of + // adding to a queue nobody reads. + self.receiver.close(); + while let Ok(message) = self.receiver.try_recv() { + (self.leftover)(message); + } + } +} + +/// 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::test_support::drain_main_context; + + #[gtk4::test] + fn messages_are_handled_in_order_after_the_sender_returns() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + let pump = pump_inputs(&object, receiver, move |_, message| sink.borrow_mut().push(message)); + + sender.deliver(1); + sender.deliver(2); + assert!(seen.borrow().is_empty(), "handled inside the send"); + drain_main_context(); + + assert_eq!(*seen.borrow(), [1, 2]); + pump.abort(); + } + + #[gtk4::test] + fn an_aborted_pump_leaves_the_sender_with_no_receiver() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let pump = pump_inputs(&object, receiver, |_, _| {}); + + // The handle holds the task's source, and with it the receiver, + // until it is dropped too. + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(sender.try_deliver(1), Delivery::ReceiverGone); + } + + #[gtk4::test] + fn messages_still_queued_when_the_pump_stops_go_to_leftover() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let (handled, left) = (Rc::new(RefCell::new(Vec::new())), Rc::new(RefCell::new(Vec::new()))); + let (handled_sink, left_sink) = (handled.clone(), left.clone()); + let pump = pump_inputs_with_leftovers( + &object, + receiver, + move |_, message| handled_sink.borrow_mut().push(message), + move |message| left_sink.borrow_mut().push(message), + ); + sender.deliver(1); + drain_main_context(); + + sender.deliver(2); + sender.deliver(3); + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(*handled.borrow(), [1]); + assert_eq!(*left.borrow(), [2, 3]); + assert_eq!(sender.try_deliver(4), Delivery::ReceiverGone); + assert_eq!( + *left.borrow(), + [2, 3], + "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 5763e2b4ea..3c304fb8c6 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -1,17 +1,39 @@ mod app_runtime; +mod clock_sample; +mod clock_source; mod deliver; +mod deliver_input; +mod detect_resume; mod failed_requests; mod gio_process_hold; +mod gio_reachability; +mod input_queue; +mod network_watch; +mod reachability_fanout; +mod reachability_probe; mod request_sequencer; mod request_stamp; mod request_ticket; +mod resume_detector; mod stamped; +mod system_clocks; pub use app_runtime::AppRuntime; -pub use deliver::spawn_and_deliver; +pub use clock_sample::ClockSample; +pub use clock_source::ClockSource; +pub use deliver::{spawn_and_deliver, spawn_held}; +pub use deliver_input::{DeliverInput, Delivery}; +pub use detect_resume::{RESUME_THRESHOLD, detect_resume}; pub use failed_requests::FailedRequests; -pub use gio_process_hold::{GioProcessHold, hold_default_application}; +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 network_watch::NetworkWatch; +pub use reachability_fanout::ReachabilityFanout; +pub use reachability_probe::ReachabilityProbe; pub use request_sequencer::RequestSequencer; pub use request_stamp::RequestStamp; pub use request_ticket::RequestTicket; +pub use resume_detector::ResumeDetector; pub use stamped::Stamped; +pub use system_clocks::SystemClocks; diff --git a/linux/crates/app/src/runtime/network_watch.rs b/linux/crates/app/src/runtime/network_watch.rs new file mode 100644 index 0000000000..873bb5ff79 --- /dev/null +++ b/linux/crates/app/src/runtime/network_watch.rs @@ -0,0 +1,116 @@ +use std::rc::Rc; + +use gio::prelude::*; +use gtk4::glib; +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{RegistryEvent, SessionRegistry}; + +use super::ReachabilityFanout; + +/// Rechecks every session's server when the network changes, and a new +/// session's as soon as it is adopted. +/// +/// `network-changed` carries a global "network available" flag that only +/// says whether there is a default route. It is not used: each session +/// asks about its own server instead. +#[derive(Debug)] +pub struct NetworkWatch { + monitor: gio::NetworkMonitor, + handlers: Vec, + _registry: RegistrySubscription, +} + +impl NetworkWatch { + pub fn start(monitor: gio::NetworkMonitor, fanout: Rc, sessions: &Rc) -> Self { + let changed = { + let fanout = fanout.clone(); + monitor.connect_network_changed(move |_, _| fanout.reevaluate_all()) + }; + let connectivity = { + let fanout = fanout.clone(); + monitor.connect_connectivity_notify(move |_| fanout.reevaluate_all()) + }; + // The registry holds its observers, so this one holds the fanout + // weakly: the fanout holds the registry. + let fanout = Rc::downgrade(&fanout); + let registry = sessions.subscribe(move |event| { + let Some(fanout) = fanout.upgrade() else { + return; + }; + match event { + RegistryEvent::Attached { key, .. } => fanout.reevaluate(key), + RegistryEvent::Released { key } => fanout.forget(key), + RegistryEvent::LiveCountChanged(_) => {} + } + }); + Self { + monitor, + handlers: vec![changed, connectivity], + _registry: registry, + } + } +} + +impl Drop for NetworkWatch { + fn drop(&mut self) { + for handler in self.handlers.drain(..) { + self.monitor.disconnect(handler); + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::NetworkEndpoint; + + use super::*; + use crate::test_support::{FakeProbe, attach_remote}; + + fn emit_network_changed(monitor: &gio::NetworkMonitor) { + monitor.emit_by_name::<()>("network-changed", &[&true]); + crate::test_support::drain_main_context(); + } + + #[gtk4::test] + fn attach_triggers_reevaluate() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = std::rc::Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let watch = NetworkWatch::start(gio::NetworkMonitor::default(), fanout, ®istry); + let endpoint = NetworkEndpoint::new("db.example.com", 5432).unwrap(); + + let attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + + assert!( + probe.asked().contains(&endpoint), + "the new session's server was not checked" + ); + drop(attached); + drop(watch); + runtime.shutdown(); + } + + #[gtk4::test] + fn a_network_change_rechecks_until_the_watch_is_dropped() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = std::rc::Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let monitor = gio::NetworkMonitor::default(); + let watch = NetworkWatch::start(monitor.clone(), fanout, ®istry); + let _attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + + let before = probe.asked().len(); + emit_network_changed(&monitor); + assert!(probe.asked().len() > before, "a network change rechecked nothing"); + + drop(watch); + let after_drop = probe.asked().len(); + emit_network_changed(&monitor); + assert_eq!(probe.asked().len(), after_drop, "a dropped watch still rechecked"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/reachability_fanout.rs b/linux/crates/app/src/runtime/reachability_fanout.rs new file mode 100644 index 0000000000..1e7cf3d7df --- /dev/null +++ b/linux/crates/app/src/runtime/reachability_fanout.rs @@ -0,0 +1,222 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Weak}; + +use gtk4::glib; +use tablepro_session::health::ReachabilityTarget; +use tablepro_session::{MonitorSignal, Session, SessionKey, SessionRegistry}; + +use super::ReachabilityProbe; + +/// Checks each attached session's server and tells the session what it +/// found. GTK thread only. +/// +/// Checks overlap when the network changes several times in a row, and +/// can finish in any order, so each is stamped and only the newest +/// answer for a session reaches it. +pub struct ReachabilityFanout { + sessions: Rc, + probe: Rc, + stamps: RefCell>, + last_stamp: Cell, +} + +impl ReachabilityFanout { + pub fn new(sessions: Rc, probe: Rc) -> Rc { + Rc::new(Self { + sessions, + probe, + stamps: RefCell::new(HashMap::new()), + last_stamp: Cell::new(0), + }) + } + + pub fn reevaluate_all(self: &Rc) { + for (key, session) in self.sessions.attached_sessions() { + self.check(key, &session); + } + } + + pub fn reevaluate(self: &Rc, key: &SessionKey) { + if let Some(session) = self.sessions.session(key) { + self.check(key.clone(), &session); + } + } + + /// The session went away, so an answer still on its way has nowhere + /// to go. + pub fn forget(&self, key: &SessionKey) { + self.stamps.borrow_mut().remove(key); + } + + /// A file, a socket or a loopback server needs no network, so it is + /// never checked and never told it is unreachable. + fn check(self: &Rc, key: SessionKey, session: &Arc) { + let ReachabilityTarget::Remote(endpoint) = session.reachability() else { + return; + }; + let stamp = self.last_stamp.get().wrapping_add(1); + self.last_stamp.set(stamp); + self.stamps.borrow_mut().insert(key.clone(), stamp); + let probing = self.probe.can_reach(&endpoint); + let fanout = Rc::downgrade(self); + let session: Weak = Arc::downgrade(session); + glib::spawn_future_local(async move { + let reachability = probing.await; + let Some(fanout) = fanout.upgrade() else { + return; + }; + if !fanout.accept(&key, stamp) { + return; + } + if let Some(session) = session.upgrade() { + session.signal(MonitorSignal::Reachability(reachability)); + } + }); + } + + fn accept(&self, key: &SessionKey, stamp: u64) -> bool { + let mut stamps = self.stamps.borrow_mut(); + if stamps.get(key) != Some(&stamp) { + return false; + } + stamps.remove(key); + true + } +} + +impl std::fmt::Debug for ReachabilityFanout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReachabilityFanout") + .field("checking", &self.stamps.borrow().len()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::NetworkEndpoint; + use tablepro_session::SessionHealth; + use tablepro_session::fakes::FakeEstablish; + use tablepro_session::health::Reachability; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{Attached, FakeProbe, FakeSession, attach_remote, wait_until}; + + const WAIT: Duration = Duration::from_secs(10); + + fn endpoint(host: &str) -> NetworkEndpoint { + NetworkEndpoint::new(host, 5432).expect("an endpoint") + } + + fn health(attached: &Attached) -> SessionHealth { + attached.fake.session.health().borrow().clone() + } + + #[gtk4::test] + fn loopback_never_probed() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let loopback = attach_remote(®istry, "127.0.0.1", Vec::new(), runtime.tasks()); + let _localhost = attach_remote(®istry, "localhost", Vec::new(), runtime.tasks()); + let _file = { + let fake = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let claim = registry + .claim(SessionKey::File("file:///srv/app.db".to_owned()), 1) + .expect("a claim"); + claim.attach(&fake.session); + (fake, claim) + }; + let remote = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + + fanout.reevaluate_all(); + fanout.reevaluate(&loopback.key); + crate::test_support::drain_main_context(); + + assert_eq!(probe.asked(), [endpoint("db.example.com")]); + drop(remote); + runtime.shutdown(); + } + + /// The first answer parks the session offline, so only the fanout + /// delivering the second one can bring it back before the offline + /// recheck. + #[gtk4::test] + fn lan_host_reachable_without_default_route_stays_online() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = FakeProbe::answering(Reachability::Unreachable); + probe.then(Reachability::Reachable); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let mut healthy = ConnectionScript::default(); + healthy.probe.always(FakeReply::ok(())); + let lan = attach_remote( + ®istry, + "192.168.1.20", + vec![FakeEstablish::Connect(healthy)], + runtime.tasks(), + ); + + fanout.reevaluate_all(); + crate::test_support::drain_main_context(); + lan.fake.connection.mark_lost(); + wait_until(WAIT, || health(&lan) == SessionHealth::Offline).expect("the session offline"); + assert!( + lan.fake.connector.calls().is_empty(), + "an unreachable server was redialled" + ); + + fanout.reevaluate_all(); + + wait_until(WAIT, || health(&lan) == SessionHealth::Online).expect("the session back online"); + assert_eq!(lan.fake.connector.calls().len(), 1); + assert_eq!(probe.asked(), [endpoint("192.168.1.20"), endpoint("192.168.1.20")]); + runtime.shutdown(); + } + + #[gtk4::test] + fn stale_stamp_dropped() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let refused = FakeEstablish::Fail(tablepro_session::ConnectError::Driver( + tablepro_core::DriverError::Transport(tablepro_core::TransportError::Refused { + endpoint: endpoint("db.example.com"), + }), + )); + let remote = attach_remote( + ®istry, + "db.example.com", + vec![refused.clone(), refused], + runtime.tasks(), + ); + let first = probe.then_held(); + let second = probe.then_held(); + + fanout.reevaluate_all(); + fanout.reevaluate(&remote.key); + crate::test_support::drain_main_context(); + assert_eq!(probe.asked().len(), 2); + let _ = second.send(Reachability::Reachable); + crate::test_support::drain_main_context(); + let _ = first.send(Reachability::Unreachable); + crate::test_support::drain_main_context(); + remote.fake.connection.mark_lost(); + + wait_until(WAIT, || !remote.fake.connector.calls().is_empty()) + .expect("the session to reconnect, as its server is reachable"); + assert_ne!( + health(&remote), + SessionHealth::Offline, + "the older answer overrode the newer one" + ); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/reachability_probe.rs b/linux/crates/app/src/runtime/reachability_probe.rs new file mode 100644 index 0000000000..b2a93bcd85 --- /dev/null +++ b/linux/crates/app/src/runtime/reachability_probe.rs @@ -0,0 +1,12 @@ +use futures::future::LocalBoxFuture; +use tablepro_core::NetworkEndpoint; +use tablepro_session::health::Reachability; + +/// Whether the network can reach one endpoint right now. +/// +/// Answered per endpoint, not from the default route: a LAN server with +/// no internet behind it is reachable, and a host behind a VPN that is +/// down is not, whatever the rest of the network looks like. +pub trait ReachabilityProbe { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability>; +} diff --git a/linux/crates/app/src/runtime/resume_detector.rs b/linux/crates/app/src/runtime/resume_detector.rs new file mode 100644 index 0000000000..39ad72e9ff --- /dev/null +++ b/linux/crates/app/src/runtime/resume_detector.rs @@ -0,0 +1,204 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{RegistryEvent, SessionRegistry}; + +use super::{ClockSample, ClockSource, RESUME_THRESHOLD, detect_resume}; + +/// How often the clocks are compared. A whole-second GLib timer is +/// batched with the system's other wakeups, and the check reads two +/// clocks and nothing else. +const TICK_SECONDS: u32 = 5; + +/// Notices that the machine slept, by the gap that opens between boot +/// time and monotonic time. GTK thread only. +/// +/// logind's PrepareForSleep needs the system bus, which a Flatpak +/// sandbox reaches only with a permission the app does not ask for. The +/// clocks need nothing. +pub struct ResumeDetector { + clocks: Box, + last: Cell, + source: RefCell>, +} + +impl ResumeDetector { + pub fn new(clocks: Box) -> Rc { + let last = clocks.sample(); + Rc::new(Self { + clocks, + last: Cell::new(last), + source: RefCell::new(None), + }) + } + + /// How long the machine slept since the last tick, if it did. + pub fn tick(&self) -> Option { + let now = self.clocks.sample(); + let prev = self.last.replace(now); + detect_resume(prev, now, RESUME_THRESHOLD) + } + + pub fn is_running(&self) -> bool { + self.source.borrow().is_some() + } + + /// Check every few seconds until `stop`. A sleep while stopped is not + /// reported: nothing was open to be told. + pub fn start(self: &Rc, on_resume: Rc) { + if self.is_running() { + return; + } + self.last.set(self.clocks.sample()); + let detector = Rc::downgrade(self); + let source = glib::timeout_add_seconds_local(TICK_SECONDS, move || { + // Never Break: stop() removes the source, and removing one + // that already ended is a GLib critical. + if let Some(slept) = detector.upgrade().and_then(|detector| detector.tick()) { + tracing::info!(slept_secs = slept.as_secs(), "the computer woke from suspend"); + on_resume(slept); + } + glib::ControlFlow::Continue + }); + self.source.replace(Some(source)); + } + + pub fn stop(&self) { + if let Some(source) = self.source.take() { + source.remove(); + } + } + + /// Run while at least one session is open, calling `on_resume` after + /// each sleep. Dropping the subscription stops following, not the + /// detector. + pub fn follow( + self: &Rc, + sessions: &Rc, + on_resume: Rc, + ) -> RegistrySubscription { + if sessions.live_count() > 0 { + self.start(on_resume.clone()); + } + let detector = Rc::downgrade(self); + sessions.subscribe(move |event| { + let RegistryEvent::LiveCountChanged(live) = event else { + return; + }; + let Some(detector) = detector.upgrade() else { + return; + }; + match live { + 0 => detector.stop(), + _ => detector.start(on_resume.clone()), + } + }) + } +} + +impl Drop for ResumeDetector { + fn drop(&mut self) { + self.stop(); + } +} + +impl std::fmt::Debug for ResumeDetector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResumeDetector") + .field("last", &self.last.get()) + .field("running", &self.is_running()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use tablepro_session::SessionKey; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::test_support::{FakeClocks, FakeSession}; + + fn detector() -> (Rc, FakeClocks) { + let clocks = FakeClocks::default(); + clocks.advance(1_000, 1_000); + (ResumeDetector::new(Box::new(clocks.clone())), clocks) + } + + fn ignore_resume() -> Rc { + Rc::new(|_| {}) + } + + #[test] + fn tick_reports_fake_clock_jump() { + let (detector, clocks) = detector(); + + clocks.advance(60, 5); + + assert_eq!(detector.tick(), Some(Duration::from_secs(55))); + assert_eq!(detector.tick(), None, "the same sleep was reported twice"); + } + + #[test] + fn tick_without_jump_reports_none() { + let (detector, clocks) = detector(); + + clocks.advance(5, 5); + + assert_eq!(detector.tick(), None); + } + + #[gtk4::test] + fn start_then_stop_removes_source_without_critical() { + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let (detector, _clocks) = detector(); + + detector.start(ignore_resume()); + assert!(detector.is_running()); + detector.stop(); + detector.stop(); + detector.start(ignore_resume()); + drop(detector); + crate::test_support::drain_main_context(); + + glib::log_set_always_fatal(previous); + } + + #[gtk4::test] + fn a_sleep_while_stopped_is_not_reported() { + let (detector, clocks) = detector(); + detector.start(ignore_resume()); + detector.stop(); + + clocks.advance(3_600, 0); + detector.start(ignore_resume()); + + assert_eq!(detector.tick(), None); + } + + #[gtk4::test] + fn stops_when_live_count_reaches_zero() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let (detector, _clocks) = detector(); + let _following = detector.follow(®istry, ignore_resume()); + assert!(!detector.is_running(), "running with no session open"); + + let first = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let second = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let first_claim = registry.claim(SessionKey::File("file:///a.db".to_owned()), 1).unwrap(); + first_claim.attach(&first.session); + assert!(detector.is_running(), "not running with a session open"); + let second_claim = registry.claim(SessionKey::File("file:///b.db".to_owned()), 2).unwrap(); + second_claim.attach(&second.session); + + drop(first_claim); + assert!(detector.is_running(), "stopped with a session still open"); + drop(second_claim); + assert!(!detector.is_running(), "still running with every session closed"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/system_clocks.rs b/linux/crates/app/src/runtime/system_clocks.rs new file mode 100644 index 0000000000..d83f342eb2 --- /dev/null +++ b/linux/crates/app/src/runtime/system_clocks.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +use rustix::time::{ClockId, Timespec, clock_gettime}; + +use super::{ClockSample, ClockSource}; + +/// `CLOCK_BOOTTIME` and `CLOCK_MONOTONIC`, read straight from the kernel. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClocks; + +impl ClockSource for SystemClocks { + fn sample(&self) -> ClockSample { + ClockSample { + boottime: duration(clock_gettime(ClockId::Boottime)), + monotonic: duration(clock_gettime(ClockId::Monotonic)), + } + } +} + +fn duration(time: Timespec) -> Duration { + let seconds = Duration::from_secs(u64::try_from(time.tv_sec).unwrap_or(0)); + seconds.saturating_add(Duration::from_nanos(u64::try_from(time.tv_nsec).unwrap_or(0))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn back_to_back_samples_move_forward_and_find_no_sleep() { + let first = SystemClocks.sample(); + let second = SystemClocks.sample(); + + assert!(second.monotonic >= first.monotonic, "{first:?} then {second:?}"); + assert!(second.boottime >= first.boottime, "{first:?} then {second:?}"); + assert_eq!( + crate::runtime::detect_resume(first, second, crate::runtime::RESUME_THRESHOLD), + None + ); + } +} diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs index 0bc7a4def8..ee2ec871a9 100644 --- a/linux/crates/app/src/services/cell_overlay.rs +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -1,14 +1,31 @@ +#[cfg(test)] use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::CellState; -/// One cell as the grid draws it, copied out of the tracker so a bind -/// holds no borrow while it updates widgets. +/// One cell's pending state, copied out of the tracker so a draw holds +/// no borrow while it updates widgets. Only a pending value is copied: a +/// clean cell is drawn from the row itself. #[derive(Debug, Clone, PartialEq)] pub struct CellOverlay { - /// The pending edit when there is one, else the fetched value. - pub value: Value, + /// The cell's pending input, `None` when it shows what was read. A + /// draft's cell is always the tracker's. + pub pending: Option, pub state: CellState, /// The row produced the statement a save just failed on. pub error_flash: bool, } + +#[cfg(test)] +impl CellOverlay { + /// The value the cell shows over `fetched`, a cell left to the + /// server reading as NULL. + pub fn shown(self, fetched: Value) -> Value { + match self.pending { + Some(CellInput::Value(value)) => value, + Some(CellInput::Default) => Value::Null, + None => fetched, + } + } +} diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 60009957ad..a44bb5caa4 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -3,6 +3,7 @@ //! only through `TabTrackerHandle`. use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; use tablepro_core::dml::BuildSqlError; use tablepro_core::edit::CellInput; @@ -14,9 +15,9 @@ use super::cell_overlay::CellOverlay; use super::column_relayout::{ColumnRelayout, Misfit}; use super::save_batch::{SaveBatch, SaveTarget}; use super::touch::Touch; -use super::tracker_delta::{TrackerDelta, VisualChange}; +use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; -use super::undo_op::UndoOp; +use super::undo_op::{DeleteStep, UndoOp}; use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot, UnknownCause}; const UNDO_LIMIT: usize = 50; @@ -54,13 +55,12 @@ impl RowKey { /// the one the row was read with. pub type KeyValue = tablepro_core::value::ValueIdentity; -/// A draft row collected by the tracker. `values` is the full column -/// vector (length = `columns.len()` at the time of creation). Empty -/// cells start as `Value::Null`. +/// A draft row collected by the tracker, one cell per column. A cell +/// left to the server is `CellInput::Default`, which is not a NULL. #[derive(Debug, Clone, PartialEq)] pub struct DraftRow { pub draft_id: u64, - pub values: Vec, + pub cells: Vec, /// The insert or the latest edit to one of its cells. pub touched: Touch, } @@ -158,61 +158,61 @@ impl TabChangeTracker { /// An edit the running save holds back comes back as a redraw of its /// row, so the grid stops showing what was typed into it. pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { - if self.edit_is_in_flight(&edit) { - return self.delta(edit.row_key().into_iter().collect(), Vec::new()); - } match edit { + TrackerEdit::DeleteRows { persisted, drafts } => self.delete_rows(persisted, drafts), + edit if self.edit_is_in_flight(&edit) => self.delta(edit.row_keys()), TrackerEdit::Cell { row_key, col, original, value, } => self.track_cell_edit(row_key, col, original, value), - TrackerEdit::DraftCell { draft_id, col, value } => self.track_draft_cell_edit(draft_id, col, value), - TrackerEdit::Insert { values } => self.track_insert(values), - TrackerEdit::Delete { - row_key, - original_values, - } => self.track_delete(row_key, original_values), - TrackerEdit::DiscardDraft { draft_id } => self.discard_draft(draft_id), + TrackerEdit::DraftCell { draft_id, col, input } => self.track_draft_cell_edit(draft_id, col, input), + TrackerEdit::InsertDrafts { rows } => self.track_insert(rows), } } /// An edit that would change what the running save already sent. /// An insert or a delete cannot be sent twice, so a draft or a /// delete in the save stays as it is until the save settles; a cell - /// edit is a new value, which the next save writes. + /// edit is a new value, which the next save writes. A Delete is held + /// back row by row, so this says whether any of its rows is. pub fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { - let Some(snapshot) = self.saving.as_ref().map(|save| save.snapshot) else { - return false; - }; - let draft_in_flight = |draft_id: u64| { - self.drafts - .iter() - .any(|draft| draft.draft_id == draft_id && snapshot.includes(draft.touched)) - }; - let delete_in_flight = |row_key: &RowKey| { - self.deletes - .get(row_key) - .is_some_and(|mark| snapshot.includes(mark.touched)) - }; match edit { - TrackerEdit::DraftCell { draft_id, .. } | TrackerEdit::DiscardDraft { draft_id } => { - draft_in_flight(*draft_id) + TrackerEdit::DraftCell { draft_id, .. } => self.draft_in_flight(*draft_id), + TrackerEdit::Cell { row_key, .. } => self.row_in_flight(row_key), + TrackerEdit::DeleteRows { persisted, drafts } => { + persisted.iter().any(|(row_key, _)| self.row_in_flight(row_key)) + || drafts.iter().any(|draft_id| self.draft_in_flight(*draft_id)) } - TrackerEdit::Cell { row_key, .. } | TrackerEdit::Delete { row_key, .. } => match row_key { - RowKey::Draft(draft_id) => draft_in_flight(*draft_id), - RowKey::Persisted(_) => delete_in_flight(row_key), - }, - TrackerEdit::Insert { .. } => false, + TrackerEdit::InsertDrafts { .. } => false, + } + } + + fn draft_in_flight(&self, draft_id: u64) -> bool { + self.saving.as_ref().is_some_and(|save| { + self.drafts + .iter() + .any(|draft| draft.draft_id == draft_id && save.snapshot.includes(draft.touched)) + }) + } + + fn row_in_flight(&self, row_key: &RowKey) -> bool { + match row_key { + RowKey::Draft(draft_id) => self.draft_in_flight(*draft_id), + RowKey::Persisted(_) => self.saving.as_ref().is_some_and(|save| { + self.deletes + .get(row_key) + .is_some_and(|mark| save.snapshot.includes(mark.touched)) + }), } } - fn delta(&self, rows: Vec, visual: Vec) -> TrackerDelta { + fn delta(&self, changed: Vec) -> TrackerDelta { TrackerDelta { - rows, - visual, + changed, pending_count: self.pending_count(), + ..TrackerDelta::default() } } @@ -249,69 +249,97 @@ impl TabChangeTracker { self.put_cell_edit(&row_key, col, after) } - fn track_insert(&mut self, values: Vec) -> TrackerDelta { - let draft = DraftRow { - draft_id: self.next_draft_id, - values, - touched: self.touch(), - }; - self.next_draft_id += 1; - self.push_undo(UndoOp::Insert { draft: draft.clone() }); - self.put_draft(draft) - } - - fn track_delete(&mut self, row_key: RowKey, original_values: Vec) -> TrackerDelta { - let after = DeleteMark { - original_values, - touched: self.touch(), - }; - self.push_undo(UndoOp::Delete { - row_key: row_key.clone(), - before: self.deletes.get(&row_key).cloned(), - after: after.clone(), - }); - self.put_delete_mark(&row_key, Some(after)) + fn track_insert(&mut self, rows: Vec>) -> TrackerDelta { + if rows.is_empty() { + return self.delta(Vec::new()); + } + let drafts: Vec = rows + .into_iter() + .map(|cells| { + let draft_id = self.next_draft_id; + self.next_draft_id += 1; + DraftRow { + draft_id, + cells, + touched: self.touch(), + } + }) + .collect(); + self.push_undo(UndoOp::Insert { drafts: drafts.clone() }); + self.put_drafts(drafts) + } + + /// Marks the saved rows and drops the drafts. The marks are one undo + /// step; a row the running save is deleting already is held back and + /// only drawn again. + fn delete_rows(&mut self, persisted: Vec<(RowKey, Arc<[Value]>)>, drafts: Vec) -> TrackerDelta { + let mut changed = Vec::new(); + let mut removed_drafts = Vec::new(); + for draft_id in drafts { + if !self.draft_in_flight(draft_id) && self.drop_draft(draft_id) { + removed_drafts.push(draft_id); + } + changed.push(RowKey::Draft(draft_id)); + } + let mut steps = Vec::new(); + for (row_key, original) in persisted { + changed.push(row_key.clone()); + if self.row_in_flight(&row_key) { + continue; + } + let after = DeleteMark { + original_values: original.to_vec(), + touched: self.touch(), + }; + let before = self.deletes.insert(row_key.clone(), after.clone()); + steps.push(DeleteStep { row_key, before, after }); + } + if !steps.is_empty() { + self.push_undo(UndoOp::Delete { steps }); + } + dedup(&mut changed); + TrackerDelta { + removed_drafts, + ..self.delta(changed) + } } // The draft's undo and redo entries go with it, so Ctrl+Z cannot // bring it back half-edited. - fn discard_draft(&mut self, draft_id: u64) -> TrackerDelta { + fn drop_draft(&mut self, draft_id: u64) -> bool { let before = self.drafts.len(); self.drafts.retain(|d| d.draft_id != draft_id); if self.drafts.len() == before { - return self.delta(Vec::new(), Vec::new()); + return false; } self.updates .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.deletes .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); - self.undo.retain(|op| !op.is_about_draft(draft_id)); - self.redo.retain(|op| !op.is_about_draft(draft_id)); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::RemoveDraft { draft_id }], - ) + self.undo.retain_mut(|op| op.forget_draft(draft_id)); + self.redo.retain_mut(|op| op.forget_draft(draft_id)); + true } - fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, value: Value) -> TrackerDelta { + fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, input: CellInput) -> TrackerDelta { let Some((before, before_touch)) = self.draft_cell(draft_id, col) else { - return self.delta(Vec::new(), Vec::new()); + return self.delta(Vec::new()); }; let after_touch = self.touch(); self.push_undo(UndoOp::DraftCell { draft_id, col, before, - after: value.clone(), + after: input.clone(), before_touch, after_touch, }); - self.put_draft_cell(draft_id, col, value, after_touch) + self.put_draft_cell(draft_id, col, input, after_touch) } - fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(Value, Touch)> { + fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(CellInput, Touch)> { let draft = self.drafts.iter().find(|d| d.draft_id == draft_id)?; - Some((draft.values.get(col)?.clone(), draft.touched)) + Some((draft.cells.get(col)?.clone(), draft.touched)) } fn is_being_written(&self, cell: &(RowKey, usize)) -> bool { @@ -328,44 +356,56 @@ impl TabChangeTracker { Some(edit) => self.updates.insert(key, edit), None => self.updates.remove(&key), }; - self.delta(vec![row_key.clone()], Vec::new()) + self.delta(vec![row_key.clone()]) } - fn put_delete_mark(&mut self, row_key: &RowKey, mark: Option) -> TrackerDelta { - match mark { + /// Undo puts each step's `before` back, last step first; redo puts + /// each `after` back in order. + fn put_delete_marks(&mut self, steps: &[DeleteStep], undo: bool) -> TrackerDelta { + let mut put = |row_key: &RowKey, mark: Option| match mark { Some(mark) => self.deletes.insert(row_key.clone(), mark), None => self.deletes.remove(row_key), }; - self.delta(vec![row_key.clone()], Vec::new()) + if undo { + for step in steps.iter().rev() { + put(&step.row_key, step.before.clone()); + } + } else { + for step in steps { + put(&step.row_key, Some(step.after.clone())); + } + } + self.delta(steps.iter().map(|step| step.row_key.clone()).collect()) } - fn put_draft(&mut self, draft: DraftRow) -> TrackerDelta { - let draft_id = draft.draft_id; - self.drafts.push(draft); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::InsertDraft { draft_id }], - ) + fn put_drafts(&mut self, drafts: Vec) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.extend(drafts); + TrackerDelta { + inserted_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) + } } - fn remove_draft(&mut self, draft_id: u64) -> TrackerDelta { - self.drafts.retain(|d| d.draft_id != draft_id); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::RemoveDraft { draft_id }], - ) + fn remove_drafts(&mut self, drafts: &[DraftRow]) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.retain(|d| !draft_ids.contains(&d.draft_id)); + TrackerDelta { + removed_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) + } } - fn put_draft_cell(&mut self, draft_id: u64, col: usize, value: Value, touched: Touch) -> TrackerDelta { + fn put_draft_cell(&mut self, draft_id: u64, col: usize, input: CellInput, touched: Touch) -> TrackerDelta { let Some(draft) = self.drafts.iter_mut().find(|d| d.draft_id == draft_id) else { - return self.delta(Vec::new(), Vec::new()); + return self.delta(Vec::new()); }; - let Some(cell) = draft.values.get_mut(col) else { - return self.delta(Vec::new(), Vec::new()); + let Some(cell) = draft.cells.get_mut(col) else { + return self.delta(Vec::new()); }; - *cell = value; + *cell = input; draft.touched = touched; - self.delta(vec![RowKey::Draft(draft_id)], Vec::new()) + self.delta(vec![RowKey::Draft(draft_id)]) } /// Nothing while a save runs: stepping back through edits the save @@ -386,8 +426,8 @@ impl TabChangeTracker { before_touch, .. } => self.put_draft_cell(*draft_id, *col, before.clone(), *before_touch), - UndoOp::Insert { draft } => self.remove_draft(draft.draft_id), - UndoOp::Delete { row_key, before, .. } => self.put_delete_mark(row_key, before.clone()), + UndoOp::Insert { drafts } => self.remove_drafts(drafts), + UndoOp::Delete { steps } => self.put_delete_marks(steps, true), }; self.redo.push_back(op); Some(delta) @@ -409,8 +449,8 @@ impl TabChangeTracker { after_touch, .. } => self.put_draft_cell(*draft_id, *col, after.clone(), *after_touch), - UndoOp::Insert { draft } => self.put_draft(draft.clone()), - UndoOp::Delete { row_key, after, .. } => self.put_delete_mark(row_key, Some(after.clone())), + UndoOp::Insert { drafts } => self.put_drafts(drafts.clone()), + UndoOp::Delete { steps } => self.put_delete_marks(steps, false), }; self.undo.push_back(op); Some(delta) @@ -440,25 +480,22 @@ impl TabChangeTracker { .chain(self.drafts.iter().map(|draft| RowKey::Draft(draft.draft_id))) .filter(|row_key| seen.insert(row_key.clone())) .collect(); - let visual = self - .drafts - .iter() - .map(|draft| VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }) - .collect(); + let removed_drafts = self.drafts.iter().map(|draft| draft.draft_id).collect(); self.drafts.clear(); self.deletes.clear(); self.updates.clear(); self.set_aside.clear(); self.undo.clear(); self.redo.clear(); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } - pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { CellOverlay { - value: self.current_cell_value(row_key, col, original).clone(), + pending: self.pending_input(row_key, col), state: self.cell_state(row_key, col), error_flash: self.error_row.as_ref() == Some(row_key), } @@ -474,6 +511,31 @@ impl TabChangeTracker { CellState::Clean } + fn pending_input(&self, row_key: &RowKey, col: usize) -> Option { + match row_key { + RowKey::Draft(draft_id) => self.draft_input(*draft_id, col), + RowKey::Persisted(_) => self + .updates + .get(&(row_key.clone(), col)) + .map(|edit| CellInput::Value(edit.pending.clone())), + } + } + + /// A saved row's pending values among its first `width` columns, in + /// column order. + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + if self.updates.is_empty() { + return Vec::new(); + } + let mut cell = (row_key.clone(), 0); + (0..width) + .filter_map(|col| { + cell.1 = col; + self.updates.get(&cell).map(|edit| (col, edit.pending.clone())) + }) + .collect() + } + /// Coarse row state. PendingDelete wins over Modified. pub fn row_state(&self, row_key: &RowKey) -> RowState { if matches!(row_key, RowKey::Draft(_)) { @@ -512,28 +574,33 @@ impl TabChangeTracker { true } - /// A draft's cells live only here, so for a draft `original` is - /// what an out-of-range column reads as. - fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { - if let RowKey::Draft(draft_id) = row_key { - return self - .drafts - .iter() - .find(|draft| draft.draft_id == *draft_id) - .and_then(|draft| draft.values.get(col)) - .unwrap_or(original); - } - match self.updates.get(&(row_key.clone(), col)) { - Some(edit) => &edit.pending, - None => original, - } + /// A draft's values, with a cell left to the server as NULL. + pub fn draft_values(&self, draft_id: u64) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| { + draft + .cells + .iter() + .map(|input| input.value().cloned().unwrap_or(Value::Null)) + .collect() + }) } - pub fn draft_values(&self, draft_id: u64) -> Option<&[Value]> { + pub fn draft_inputs(&self, draft_id: u64) -> Option> { self.drafts .iter() .find(|draft| draft.draft_id == draft_id) - .map(|draft| draft.values.as_slice()) + .map(|draft| draft.cells.clone()) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .and_then(|draft| draft.cells.get(col)) + .cloned() } /// A draft's cells as its INSERT carries them. @@ -631,13 +698,13 @@ impl TabChangeTracker { Some(previous) if previous.columns != columns => { self.relayout(&ColumnRelayout::new(&previous.columns, &columns)) } - _ => self.delta(Vec::new(), Vec::new()), + _ => self.delta(Vec::new()), } } fn relayout(&mut self, layout: &ColumnRelayout<'_>) -> TrackerDelta { let mut rows = Vec::new(); - let mut visual = Vec::new(); + let mut removed_drafts = Vec::new(); let set_aside = self.set_aside.len(); for ((row_key, col), edit) in std::mem::take(&mut self.updates) { match layout.key(&row_key).and_then(|()| layout.cell(col, &edit.pending)) { @@ -665,15 +732,13 @@ impl TabChangeTracker { rows.push(row_key); } for mut draft in std::mem::take(&mut self.drafts) { - match layout.row(&draft.values) { - Ok(values) => { - draft.values = values; + match layout.draft(&draft.cells) { + Ok(cells) => { + draft.cells = cells; self.drafts.push(draft); } Err(misfit) => { - visual.push(VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }); + removed_drafts.push(draft.draft_id); self.set_aside.push(SetAside { touched: draft.touched, misfit, @@ -704,7 +769,10 @@ impl TabChangeTracker { } } dedup(&mut rows); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } /// Why the changes set aside cannot be saved, the key first since @@ -794,18 +862,18 @@ impl TabChangeTracker { match outcome { SaveOutcome::Committed { .. } => { let mut delta = self.drop_saved(snapshot); - delta.rows.extend(self.rebase(written, true)); - dedup(&mut delta.rows); + delta.changed.extend(self.rebase(written, true)); + dedup(&mut delta.changed); delta.pending_count = self.pending_count(); delta } SaveOutcome::RolledBack(_) => { let rows = self.rebase(written, false); - self.delta(rows, Vec::new()) + self.delta(rows) } SaveOutcome::Unknown(cause) => { self.mark_outcome_unknown(cause.clone()); - self.delta(Vec::new(), Vec::new()) + self.delta(Vec::new()) } } } @@ -838,7 +906,7 @@ impl TabChangeTracker { fn drop_saved(&mut self, snapshot: SaveSnapshot) -> TrackerDelta { let mut rows = Vec::new(); - let mut visual = Vec::new(); + let mut removed_drafts = Vec::new(); self.updates.retain(|(row_key, _), edit| { let saved = snapshot.includes(edit.touched); if saved { @@ -858,9 +926,7 @@ impl TabChangeTracker { let saved = snapshot.includes(draft.touched); if saved { rows.push(RowKey::Draft(draft.draft_id)); - visual.push(VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }); + removed_drafts.push(draft.draft_id); } !saved }); @@ -869,7 +935,10 @@ impl TabChangeTracker { self.undo.clear(); self.redo.clear(); dedup(&mut rows); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } } @@ -916,25 +985,29 @@ fn key_values(key: &[KeyValue]) -> Vec { key.iter().map(|part| part.value().clone()).collect() } +/// A column the server always fills is left to it whatever the draft +/// holds: SQL Server IDENTITY and PostgreSQL GENERATED ALWAYS refuse a +/// value. fn insert_cells(columns: &[ColumnInfo], draft: &DraftRow) -> Vec { columns .iter() - .zip(&draft.values) - .map(|(column, value)| draft_cell(column, value)) + .zip(&draft.cells) + .map( + |(column, input)| match column.is_auto_increment || column.is_generated { + true => CellInput::Default, + false => input.clone(), + }, + ) .collect() } -/// A NULL the user left in a column with a default means "use the -/// default" (CURRENT_TIMESTAMP, gen_random_uuid()), not an explicit -/// NULL over it. -fn draft_cell(column: &ColumnInfo, value: &Value) -> CellInput { - if column.is_auto_increment || column.is_generated { - return CellInput::Default; - } - if matches!(value, Value::Null) && !column.default.is_none() { - return CellInput::Default; +/// What a new row holds in `column` until the user types: the server's +/// value where it has one, else NULL. +pub fn new_draft_cell(column: &ColumnInfo) -> CellInput { + match column.is_auto_increment || column.is_generated || !column.default.is_none() { + true => CellInput::Default, + false => CellInput::Value(Value::Null), } - CellInput::Value(value.clone()) } #[cfg(test)] @@ -1002,12 +1075,30 @@ mod tests { } fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { - match t.track_insert(values).visual.as_slice() { - [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + match t + .track_insert(vec![values.into_iter().map(CellInput::Value).collect()]) + .inserted_drafts + .as_slice() + { + [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), } } + fn delete(t: &mut TabChangeTracker, row_key: RowKey, original: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: vec![(row_key, original.into())], + drafts: Vec::new(), + }) + } + + fn drop_drafts(t: &mut TabChangeTracker, drafts: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts, + }) + } + #[test] fn cell_edit_creates_modified_state() { let mut t = TabChangeTracker::default(); @@ -1045,7 +1136,7 @@ mod tests { fn delete_marks_row_pending() { let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(7)]); - t.track_delete(key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); + delete(&mut t, key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); assert_eq!(t.row_state(&key), RowState::PendingDelete); assert_eq!(t.pending_count(), 1); } @@ -1056,7 +1147,7 @@ mod tests { let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![key.clone()])); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![key.clone()])); assert_eq!(t.cell_state(&key, 1), CellState::Clean); assert!(t.can_redo()); } @@ -1086,8 +1177,8 @@ mod tests { let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); - t.track_insert(vec![Value::Null]); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2)]); + t.track_insert(vec![vec![CellInput::Value(Value::Null)]]); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); t.discard(); assert_eq!(t.pending_count(), 0); assert!(!t.can_undo()); @@ -1116,7 +1207,7 @@ mod tests { /// The cell as the grid draws it, over a fetched "a". fn pending(t: &TabChangeTracker, id: i64) -> Value { - t.cell_overlay(&rk(&[Value::Int(id)]), 1, &text("a")).value + t.cell_overlay(&rk(&[Value::Int(id)]), 1).shown(text("a")) } /// Each change as a verb and the key or draft it names, which is @@ -1145,7 +1236,7 @@ mod tests { let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![rk(&[Value::Int(1)])])); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![rk(&[Value::Int(1)])])); assert_eq!(pending(&t, 1), text("b")); assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); assert_eq!(t.pending_count(), 1); @@ -1202,9 +1293,9 @@ mod tests { let mut t = TabChangeTracker::default(); let marked_twice = rk(&[Value::Int(1)]); let marked_once = rk(&[Value::Int(2)]); - t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); - t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); - t.track_delete(marked_once.clone(), vec![Value::Int(2)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_once.clone(), vec![Value::Int(2)]); let _ = t.undo(); assert_eq!(t.row_state(&marked_once), RowState::Clean); @@ -1222,21 +1313,21 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("b"), + input: CellInput::Value(text("b")), }); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("c"), + input: CellInput::Value(text("c")), }); let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], text("b")); - let redone = t.redo().map(|delta| delta.rows); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("b"))); + let redone = t.redo().map(|delta| delta.changed); assert_eq!(redone, Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], text("c")); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("c"))); } #[test] @@ -1245,7 +1336,7 @@ mod tests { let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); let draft_id = insert(&mut t, vec![Value::Null, text("draft")]); - t.track_delete(rk(&[Value::Int(3)]), vec![Value::Int(3)]); + delete(&mut t, rk(&[Value::Int(3)]), vec![Value::Int(3)]); edit(&mut t, 2, "a", "b"); // Row 1 moves behind everything it was ahead of. edit(&mut t, 1, "a", "c"); @@ -1269,7 +1360,7 @@ mod tests { fn change_set_places_delete_before_later_insert() { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let draft_id = insert(&mut t, vec![Value::Null, text("a")]); let (tracked, plan) = plan(&t, &columns); @@ -1288,11 +1379,11 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("a"), + input: CellInput::Value(text("a")), }); assert_eq!( @@ -1306,11 +1397,11 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("a"), + input: CellInput::Value(text("a")), }); let edited = vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")]; assert_eq!(script(&t, &columns), edited); @@ -1330,7 +1421,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); edit(&mut t, 1, "a", "c"); let edited = vec!["delete [Int(2)]".to_owned(), "update [Int(1)]".to_owned()]; assert_eq!(script(&t, &columns), edited); @@ -1354,11 +1445,14 @@ mod tests { let edit_script = |t: &mut TabChangeTracker| { for id in (0..40).rev() { match id % 3 { - 0 => t.track_delete(rk(&[Value::Int(id)]), vec![Value::Int(id)]), + 0 => delete(t, rk(&[Value::Int(id)]), vec![Value::Int(id)]), _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), }; } - let _ = t.track_insert(vec![Value::Null, text("draft")]); + let _ = t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(text("draft")), + ]]); }; let mut first = TabChangeTracker::default(); let mut second = TabChangeTracker::default(); @@ -1383,7 +1477,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); assert_eq!(script(&t, &columns), vec!["delete [Int(1)]".to_owned()]); @@ -1397,8 +1491,15 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; let doomed = rk(&[Value::Int(9)]); - t.track_insert(vec![Value::Null, Value::Text("draft".into())]); - t.track_delete(doomed.clone(), vec![Value::Int(9), Value::Text("doomed".into())]); + t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("draft".into())), + ]]); + delete( + &mut t, + doomed.clone(), + vec![Value::Int(9), Value::Text("doomed".into())], + ); let (tracked, plan) = plan(&t, &columns); @@ -1443,7 +1544,12 @@ mod tests { tablepro_core::column::SqlExpression::from_catalog_text("now()"), ); let columns = vec![pk_col("id"), data_col("name"), stamped]; - t.track_insert(vec![Value::Null, Value::Text("draft".into()), Value::Null]); + let mut cells: Vec = columns.iter().map(new_draft_cell).collect(); + cells[1] = CellInput::Value(Value::Text("draft".into())); + let draft_id = match t.track_insert(vec![cells]).inserted_drafts.as_slice() { + [draft_id] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + }; let tracked = t.change_set(&columns).unwrap(); @@ -1457,6 +1563,17 @@ mod tests { ] }] ); + + // NULL set on purpose is not the default. + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 2, + input: CellInput::Value(Value::Null), + }); + assert_eq!( + t.draft_cells(draft_id, &columns).map(|cells| cells[2].clone()), + Some(CellInput::Value(Value::Null)) + ); } #[test] @@ -1496,13 +1613,13 @@ mod tests { let mut t = TabChangeTracker::default(); let draft_id = insert(&mut t, vec![Value::Null, Value::Text("hi".into())]); // Edit the draft so undo stack has a CellEdit on top of Insert. - t.track_draft_cell_edit(draft_id, 1, Value::Text("edited".into())); + t.track_draft_cell_edit(draft_id, 1, CellInput::Value(Value::Text("edited".into()))); assert_eq!(t.drafts().len(), 1); assert!(t.can_undo()); - let removed = t.discard_draft(draft_id); + let removed = drop_drafts(&mut t, vec![draft_id]); - assert_eq!(removed.visual, vec![VisualChange::RemoveDraft { draft_id }]); + assert_eq!(removed.removed_drafts, vec![draft_id]); assert!(t.drafts().is_empty(), "draft must be gone"); assert!(!t.can_undo(), "undo entries scoped to the draft must be cleared"); assert_eq!(t.pending_count(), 0, "no pending state after discard"); @@ -1511,8 +1628,11 @@ mod tests { #[test] fn discard_draft_no_op_for_unknown_id() { let mut t = TabChangeTracker::default(); - assert_eq!(t.discard_draft(99_999), TrackerDelta::default()); - assert_eq!(t.pending_count(), 0); + let delta = drop_drafts(&mut t, vec![99_999]); + + assert!(delta.removed_drafts.is_empty()); + assert_eq!(delta.pending_count, 0); + assert!(!t.can_undo()); } #[test] @@ -1521,7 +1641,7 @@ mod tests { let a = insert(&mut t, vec![Value::Int(1)]); let b = insert(&mut t, vec![Value::Int(2)]); assert_eq!(t.drafts().len(), 2); - assert_eq!(t.discard_draft(a).pending_count, 1); + assert_eq!(drop_drafts(&mut t, vec![a]).pending_count, 1); assert_eq!(t.drafts().len(), 1); assert_eq!(t.drafts()[0].draft_id, b); // The remaining draft is still undoable on its own. @@ -1529,26 +1649,54 @@ mod tests { } #[test] - fn bulk_delete_via_track_delete_is_per_row_undoable() { - // Multi-row delete sequence (what the UI does on Ctrl+A → Delete - // for persisted rows): track_delete N times. Each push is its - // own undoable op so Ctrl+Z restores rows one at a time — - // matches the per-op undo contract elsewhere. + fn one_delete_over_many_rows_is_one_undo_step() { let mut t = TabChangeTracker::default(); - let k1 = rk(&[Value::Int(1)]); - let k2 = rk(&[Value::Int(2)]); - let k3 = rk(&[Value::Int(3)]); - t.track_delete(k1.clone(), vec![Value::Int(1)]); - t.track_delete(k2.clone(), vec![Value::Int(2)]); - t.track_delete(k3.clone(), vec![Value::Int(3)]); - assert_eq!(t.pending_count(), 3); - - // First undo restores the most-recent delete (k3). - let _ = t.undo(); - assert_eq!(t.pending_count(), 2); - assert!(matches!(t.row_state(&k3), RowState::Clean)); - assert!(matches!(t.row_state(&k1), RowState::PendingDelete)); - assert!(matches!(t.row_state(&k2), RowState::PendingDelete)); + let earlier = rk(&[Value::Int(9)]); + delete(&mut t, earlier.clone(), vec![Value::Int(9)]); + let keys: Vec = (10..70).map(|id| rk(&[Value::Int(id)])).collect(); + let draft_id = insert(&mut t, vec![Value::Null]); + + let deleted = t.apply(TrackerEdit::DeleteRows { + persisted: keys + .iter() + .map(|key| (key.clone(), vec![Value::Int(0)].into())) + .collect(), + drafts: vec![draft_id], + }); + assert_eq!(deleted.removed_drafts, vec![draft_id]); + assert_eq!(deleted.changed.len(), 61); + assert_eq!(t.pending_count(), 61); + + let undone = t.undo().expect("the delete to undo"); + assert_eq!(undone.changed, keys, "the undo left some of the rows marked"); + assert_eq!(t.pending_count(), 1); + assert_eq!(t.row_state(&earlier), RowState::PendingDelete); + let _ = t.redo(); + assert!(keys.iter().all(|key| t.row_state(key) == RowState::PendingDelete)); + } + + #[test] + fn a_delete_during_a_save_holds_back_only_the_rows_in_it() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns); + let saved_draft = insert(&mut t, vec![Value::Null, text("a")]); + let _batch = t.begin_save().unwrap().unwrap(); + let new_draft = insert(&mut t, vec![Value::Null, text("b")]); + let edit = TrackerEdit::DeleteRows { + persisted: vec![(rk(&[Value::Int(1)]), vec![Value::Int(1)].into())], + drafts: vec![saved_draft, new_draft], + }; + assert!(t.edit_is_in_flight(&edit)); + + let delta = t.apply(edit); + + assert_eq!(delta.removed_drafts, vec![new_draft]); + assert!( + delta.changed.contains(&RowKey::Draft(saved_draft)), + "the held draft is not drawn again" + ); + assert_eq!(t.drafts().len(), 1); + assert_eq!(t.row_state(&rk(&[Value::Int(1)])), RowState::PendingDelete); } #[test] @@ -1562,15 +1710,12 @@ mod tests { original: Value::Text("a".into()), value: Value::Text("b".into()), }); - let deleted = t.apply(TrackerEdit::Delete { - row_key: rk(&[Value::Int(2)]), - original_values: vec![Value::Int(2)], - }); + let deleted = delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); - assert_eq!(edited.rows, vec![key]); - assert!(edited.visual.is_empty()); + assert_eq!(edited.changed, vec![key]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); assert_eq!(edited.pending_count, 1); - assert_eq!(deleted.rows, vec![rk(&[Value::Int(2)])]); + assert_eq!(deleted.changed, vec![rk(&[Value::Int(2)])]); assert_eq!(deleted.pending_count, 2); } @@ -1582,16 +1727,14 @@ mod tests { let edited = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Text("b".into()), + input: CellInput::Value(Value::Text("b".into())), }); let undone = t.undo(); - assert_eq!( - (edited.rows, edited.visual), - (vec![RowKey::Draft(draft_id)], Vec::new()) - ); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); + assert_eq!(edited.changed, vec![RowKey::Draft(draft_id)]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(Value::Text("a".into()))); } #[test] @@ -1601,17 +1744,14 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Text("b".into()), + input: CellInput::Value(Value::Text("b".into())), }); - let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1, &Value::Null); + let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1); - assert_eq!(overlay.value, Value::Text("b".into())); + assert_eq!(overlay.pending, Some(CellInput::Value(Value::Text("b".into())))); assert_eq!(overlay.state, CellState::InsertDraft); - assert_eq!( - t.cell_overlay(&RowKey::Draft(draft_id), 9, &Value::Null).value, - Value::Null - ); + assert_eq!(t.cell_overlay(&RowKey::Draft(draft_id), 9).pending, None); } #[test] @@ -1622,10 +1762,16 @@ mod tests { let delta = t.apply(TrackerEdit::DraftCell { draft_id, col: 4, - value: Value::Int(1), + input: CellInput::Value(Value::Int(1)), }); - assert!(delta.rows.is_empty() && delta.visual.is_empty()); + assert_eq!( + delta, + TrackerDelta { + pending_count: 1, + ..TrackerDelta::default() + } + ); assert_eq!(delta.pending_count, 1); } @@ -1634,11 +1780,52 @@ mod tests { let mut t = TabChangeTracker::default(); let draft_id = insert(&mut t, vec![Value::Int(7)]); - let undone = t.undo().map(|delta| delta.visual); - let redone = t.redo().map(|delta| delta.visual); + let undone = t.undo().expect("the insert to undo"); + let redone = t.redo().expect("the insert to redo"); - assert_eq!(undone, Some(vec![VisualChange::RemoveDraft { draft_id }])); - assert_eq!(redone, Some(vec![VisualChange::InsertDraft { draft_id }])); + assert_eq!( + (undone.inserted_drafts, undone.removed_drafts), + (Vec::new(), vec![draft_id]) + ); + assert_eq!( + (redone.inserted_drafts, redone.removed_drafts), + (vec![draft_id], Vec::new()) + ); + } + + #[test] + fn a_duplicate_of_more_rows_than_the_history_holds_is_one_undo_step() { + let mut t = TabChangeTracker::default(); + let rows: Vec> = (0..UNDO_LIMIT + 10) + .map(|id| vec![CellInput::Value(Value::Int(i64::try_from(id).unwrap()))]) + .collect(); + + let inserted = t.apply(TrackerEdit::InsertDrafts { rows }); + let undone = t.undo().expect("the duplicate to undo"); + + assert_eq!(inserted.inserted_drafts.len(), UNDO_LIMIT + 10); + assert_eq!(undone.removed_drafts, inserted.inserted_drafts); + assert!(t.drafts().is_empty(), "the undo left some copies behind"); + assert!(!t.can_undo()); + let redone = t.redo().expect("the duplicate to redo"); + assert_eq!(redone.inserted_drafts, inserted.inserted_drafts); + } + + #[test] + fn deleting_one_duplicated_draft_leaves_the_others_to_undo() { + let mut t = TabChangeTracker::default(); + let inserted = t.apply(TrackerEdit::InsertDrafts { + rows: (1..=3).map(|id| vec![CellInput::Value(Value::Int(id))]).collect(), + }); + let [first, middle, last] = inserted.inserted_drafts[..] else { + panic!("three drafts: {:?}", inserted.inserted_drafts); + }; + + let _ = drop_drafts(&mut t, vec![middle]); + let undone = t.undo().expect("the rest of the duplicate to undo"); + + assert_eq!(undone.removed_drafts, vec![first, last]); + assert!(t.drafts().is_empty()); } #[test] @@ -1651,8 +1838,8 @@ mod tests { let redone = t.redo(); assert_eq!( - redone.map(|delta| (delta.rows, delta.visual)), - Some((vec![key], Vec::new())) + redone.map(|delta| (delta.changed, delta.inserted_drafts, delta.removed_drafts)), + Some((vec![key], Vec::new(), Vec::new())) ); } @@ -1667,9 +1854,9 @@ mod tests { let delta = t.discard(); assert_eq!(delta.pending_count, 0); - assert_eq!(delta.visual, vec![VisualChange::RemoveDraft { draft_id }]); - assert_eq!(delta.rows.len(), 2, "{:?}", delta.rows); - assert!(delta.rows.contains(&key) && delta.rows.contains(&RowKey::Draft(draft_id))); + assert_eq!(delta.removed_drafts, vec![draft_id]); + assert_eq!(delta.changed.len(), 2, "{:?}", delta.changed); + assert!(delta.changed.contains(&key) && delta.changed.contains(&RowKey::Draft(draft_id))); } #[test] @@ -1679,12 +1866,12 @@ mod tests { let fetched = Value::Text("a".into()); t.track_cell_edit(key.clone(), 1, fetched.clone(), Value::Text("b".into())); - let edited = t.cell_overlay(&key, 1, &fetched); - let untouched = t.cell_overlay(&key, 0, &Value::Int(1)); + let edited = t.cell_overlay(&key, 1); + let untouched = t.cell_overlay(&key, 0); - assert_eq!(edited.value, Value::Text("b".into())); + assert_eq!(edited.pending, Some(CellInput::Value(Value::Text("b".into())))); assert_eq!(edited.state, CellState::Modified); - assert_eq!(untouched.value, Value::Int(1)); + assert_eq!(untouched.pending, None, "a clean cell is drawn from its row"); assert_eq!(untouched.state, CellState::Clean); } @@ -1752,7 +1939,7 @@ mod tests { assert_eq!(delta.pending_count, 0); assert_eq!(t.pending_count(), 0); - assert_eq!(delta.rows, vec![rk(&[Value::Int(1)])]); + assert_eq!(delta.changed, vec![rk(&[Value::Int(1)])]); } #[test] @@ -1811,7 +1998,7 @@ mod tests { assert_eq!( t.draft_values(draft_id), - Some(&[Value::Null, text("Ada"), Value::Null][..]) + Some(vec![Value::Null, text("Ada"), Value::Null]) ); } @@ -1878,7 +2065,11 @@ mod tests { let after = vec![plain_id, legacy_key, data_col("name")]; let mut t = saving_on(before); t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), Value::Int(9), text("Bob")]); + delete( + &mut t, + rk(&[Value::Int(2)]), + vec![Value::Int(2), Value::Int(9), text("Bob")], + ); retarget(&mut t, &after); @@ -1918,7 +2109,7 @@ mod tests { /// The cell as the grid draws it at `col`, over a fetched "Ada". fn pending_at(t: &TabChangeTracker, id: i64, col: usize) -> Value { - t.cell_overlay(&rk(&[Value::Int(id)]), col, &text("Ada")).value + t.cell_overlay(&rk(&[Value::Int(id)]), col).shown(text("Ada")) } #[test] @@ -1930,9 +2121,9 @@ mod tests { let new = t.flash_error_row(second.clone()); assert!(!t.clear_error_row_if(old)); - assert!(t.cell_overlay(&second, 0, &Value::Null).error_flash); - assert!(!t.cell_overlay(&first, 0, &Value::Null).error_flash); + assert!(t.cell_overlay(&second, 0).error_flash); + assert!(!t.cell_overlay(&first, 0).error_flash); assert!(t.clear_error_row_if(new)); - assert!(!t.cell_overlay(&second, 0, &Value::Null).error_flash); + assert!(!t.cell_overlay(&second, 0).error_flash); } } diff --git a/linux/crates/app/src/services/column_relayout.rs b/linux/crates/app/src/services/column_relayout.rs index 27dcc9777a..daa4c34870 100644 --- a/linux/crates/app/src/services/column_relayout.rs +++ b/linux/crates/app/src/services/column_relayout.rs @@ -1,10 +1,10 @@ use tablepro_core::column::ColumnType; -use tablepro_core::edit::parse_literal_text; +use tablepro_core::edit::{CellInput, parse_literal_text}; use tablepro_core::export::value_text; use tablepro_core::value::same_value; use tablepro_core::{ColumnInfo, Value}; -use super::change_tracker::RowKey; +use super::change_tracker::{RowKey, new_draft_cell}; /// How the columns a tab's pending changes were made against line up /// with the columns it read since. Columns are matched by name: once a @@ -87,23 +87,38 @@ impl<'a> ColumnRelayout<'a> { } } - /// A new row's values under the new columns. A value the user left + /// Where a draft cell's input in old column `col` belongs now. A + /// cell left to the server fits anywhere, as NULL does. + pub(crate) fn input_cell(&self, col: usize, input: &CellInput) -> Result { + self.cell(col, input.value().unwrap_or(&Value::Null)) + } + + /// A new row's cells under the new columns. A value the user left /// in a column that is gone, or that no longer takes it, has no - /// place; an empty cell there is dropped. - pub(crate) fn row(&self, values: &[Value]) -> Result, Misfit> { + /// place; an empty cell there is dropped. A new column starts as a + /// new row's would. + pub(crate) fn draft(&self, cells: &[CellInput]) -> Result, Misfit> { let mut lost = Vec::new(); - for (col, value) in values.iter().enumerate() { - if matches!(value, Value::Null) { + for (col, input) in cells.iter().enumerate() { + let Some(value) = input.value().filter(|value| !matches!(value, Value::Null)) else { continue; - } + }; if let Err(Misfit::Columns(columns)) = self.cell(col, value) { lost.extend(columns); } } - match lost.is_empty() { - true => Ok(self.fetched(values)), - false => Err(Misfit::Columns(lost)), + if !lost.is_empty() { + return Err(Misfit::Columns(lost)); } + Ok(self + .sources + .iter() + .zip(self.new) + .map(|(source, column)| match source.and_then(|col| cells.get(col)) { + Some(input) => input.clone(), + None => new_draft_cell(column), + }) + .collect()) } /// A row as read under the old columns, under the new ones. A new diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs new file mode 100644 index 0000000000..2a5c3cacda --- /dev/null +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -0,0 +1,537 @@ +use std::sync::Arc; + +use tablepro_core::credentials::SecretVault; +use tablepro_session::SessionKey; +use tablepro_session::connect::ConnectionWrites; +use tablepro_session::runtime::Tasks; +use tablepro_storage::QueryHistory; +use uuid::Uuid; + +use super::deletion_error::DeletionError; +use super::deletion_report::{DeletionReport, PurgeFailure}; +use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; +use crate::workspace::WorkspaceStore; + +/// What the user chose in the delete dialog. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DeletionOptions { + pub delete_history: bool, +} + +/// Deletes a saved connection, then everything stored for it. +/// +/// The order is what keeps a failure recoverable: the secrets go first, +/// because a secret with no entry is orphaned in the keyring while an +/// entry with no secrets only asks for them again. What is kept for the +/// connection goes last, once nothing can reach it. +/// +/// GTK-thread only, like the width, filter and workspace stores. +pub struct ConnectionDeletion { + pub secrets: Arc, + pub connections: Arc, + pub column_widths: ColumnWidthStore, + pub filters: FilterSettingsStore, + pub workspaces: WorkspaceStore, + /// `None` when the history database did not open. + pub history: Option, + pub spawner: Tasks, +} + +impl ConnectionDeletion { + pub async fn run(&self, id: Uuid, options: DeletionOptions) -> Result { + // The Secret Service client runs on the runtime. + let secrets = self.secrets.clone(); + self.spawner + .spawn_task(async move { secrets.delete_connection(id).await }) + .await + .map_err(DeletionError::Interrupted)? + .map_err(DeletionError::Secrets)?; + self.connections.remove(id).await.map_err(DeletionError::Entry)?; + Ok(self.purge(id, options).await) + } + + async fn purge(&self, id: Uuid, options: DeletionOptions) -> DeletionReport { + let mut report = DeletionReport::default(); + self.column_widths.forget_connection(id); + self.filters.forget_connection(id); + // Awaited inside the delete's hold, so a quit right after it + // cannot leave them on disk. + self.column_widths.flush().await; + self.filters.flush().await; + if let Err(error) = self.workspaces.purge(&SessionKey::Saved(id)).await { + report.purge_failures.push(PurgeFailure::Workspace(error)); + } + let Some(history) = self.history.clone() else { + let unavailable = || "the query history did not open".to_owned(); + report.purge_failures.push(PurgeFailure::SavedQueries(unavailable())); + if options.delete_history { + report.purge_failures.push(PurgeFailure::History(unavailable())); + } + return report; + }; + // The queries saved under the connection name tables only its + // database has, so they go with it whatever the history switch + // says. + let saved_queries = history.saved_queries(); + let removed = self + .spawner + .spawn_task(async move { saved_queries.delete_for_connection(id).await }) + .await; + if let Some(error) = failure_text(removed) { + report.purge_failures.push(PurgeFailure::SavedQueries(error)); + } + if options.delete_history { + let removed = self + .spawner + .spawn_task(async move { history.delete_for_connection(id).await }) + .await; + if let Some(error) = failure_text(removed) { + report.purge_failures.push(PurgeFailure::History(error)); + } + } + report + } +} + +fn failure_text( + outcome: Result, tablepro_session::runtime::TaskFailure>, +) -> Option { + match outcome { + Ok(Ok(_)) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(failure) => Some(failure.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Mutex; + + use futures::future::BoxFuture; + use secrecy::SecretString; + use tablepro_core::credentials::{SecretError, SecretKind}; + use tablepro_core::{AuthMode, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, TlsConfig}; + use tablepro_session::connect::StoreConnectionWrites; + use tablepro_storage::query_history::{NewEntry, Outcome, SearchFilter}; + use tablepro_storage::saved_queries::NewSavedQuery; + use tablepro_storage::{ConnectionStore, DraftId, DraftStore, SavedConnection, StorageError, StoragePaths}; + use tablepro_test_support::{Call, FakeSecretVault}; + + use super::*; + use crate::test_support::paused_tasks; + use crate::workspace::{TabRecord, WorkspaceFileName, WorkspaceRecord}; + + type Log = Arc>>; + + /// The list on disk, noting at each removal whether the secrets were + /// already deleted and the saved tabs were still there. + struct LoggedWrites { + writes: StoreConnectionWrites, + vault: Arc, + log: Log, + tabs: PathBuf, + } + + impl ConnectionWrites for LoggedWrites { + fn upsert(&self, connection: SavedConnection) -> BoxFuture<'_, Result<(), StorageError>> { + self.writes.upsert(connection) + } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + let secrets_gone = self.vault.calls().contains(&Call::DeleteConnection(id)); + self.log.lock().unwrap().push(format!( + "list, secrets deleted: {secrets_gone}, tabs still saved: {}", + self.tabs.exists() + )); + self.writes.remove(id) + } + } + + struct Fixture { + _root: tempfile::TempDir, + paths: StoragePaths, + store: ConnectionStore, + vault: Arc, + widths: ColumnWidthStore, + filters: FilterSettingsStore, + workspaces: WorkspaceStore, + drafts: DraftStore, + history: QueryHistory, + id: Uuid, + other: Uuid, + log: Log, + } + + fn connection(id: Uuid) -> SavedConnection { + SavedConnection { + id, + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn filter() -> FilterSet { + FilterSet { + combinator: Combinator::And, + rules: vec![FilterRule { + column: "name".into(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("alice".into())), + }], + extra_sql: None, + } + } + + fn ran(query: &str, connection: Uuid) -> NewEntry { + NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: connection, + connection_name: "Prod".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: Some(0), + outcome: Outcome::Success, + } + } + + /// Two connections with something of every kind stored for each. + async fn fixture() -> Fixture { + let root = tempfile::tempdir().expect("tempdir"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test"); + let tasks = paused_tasks(); + let store = ConnectionStore::new(&paths); + let vault = Arc::new(FakeSecretVault::new()); + let widths = ColumnWidthStore::load(paths.column_widths_file(), &tasks); + let filters = FilterSettingsStore::load(paths.filter_settings_file(), &tasks); + let drafts = DraftStore::new(paths.drafts_dir()); + let workspaces = WorkspaceStore::start(paths.workspaces_dir(), drafts.clone(), &tasks); + let history = QueryHistory::open(&paths).await.expect("the history"); + let (id, other) = (Uuid::new_v4(), Uuid::new_v4()); + for connection_id in [id, other] { + store.upsert_blocking(connection(connection_id)).expect("the entry"); + vault + .store( + connection_id, + SecretKind::DatabasePassword, + &SecretString::from("s3cret"), + "label", + ) + .await + .expect("the password"); + widths.record(connection_id, Some("public"), "users", "name", 120); + filters.set_filters(connection_id, Some("public"), "users", filter()); + let key = SessionKey::Saved(connection_id); + let draft = DraftId::new(); + drafts + .write_blocking(&WorkspaceFileName::for_key(&key).draft_scope(), draft, "SELECT 1") + .expect("a draft"); + workspaces + .schedule( + &key, + WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { draft }], + }, + ) + .expect("scheduled"); + workspaces.flush(&key).await.expect("the saved tabs"); + history + .record(ran("SELECT * FROM users", connection_id)) + .await + .expect("a history entry"); + history + .saved_queries() + .save(NewSavedQuery { + name: "users".to_owned(), + query: "SELECT * FROM users".to_owned(), + connection_id, + connection_name: "Prod".to_owned(), + }) + .await + .expect("a saved query"); + } + Fixture { + _root: root, + paths, + store, + vault, + widths, + filters, + workspaces, + drafts, + history, + id, + other, + log: Log::default(), + } + } + + impl Fixture { + fn deletion(&self) -> ConnectionDeletion { + ConnectionDeletion { + secrets: self.vault.clone(), + connections: Arc::new(LoggedWrites { + writes: StoreConnectionWrites::new(self.store.clone(), paused_tasks()), + vault: self.vault.clone(), + log: self.log.clone(), + tabs: self.tabs(self.id), + }), + column_widths: self.widths.clone(), + filters: self.filters.clone(), + workspaces: self.workspaces.clone(), + history: Some(self.history.clone()), + spawner: paused_tasks(), + } + } + + fn tabs(&self, id: Uuid) -> PathBuf { + self.paths + .workspaces_dir() + .join(WorkspaceFileName::for_key(&SessionKey::Saved(id)).file_name()) + } + + fn drafts_of(&self, id: Uuid) -> PathBuf { + self.drafts.root().join( + WorkspaceFileName::for_key(&SessionKey::Saved(id)) + .draft_scope() + .as_str(), + ) + } + + fn listed(&self, id: Uuid) -> bool { + self.store + .load_blocking() + .expect("the list") + .iter() + .any(|saved| saved.id == id) + } + + fn has_password(&self, id: Uuid) -> bool { + self.vault.stored_secret(id, SecretKind::DatabasePassword).is_some() + } + + async fn history_count(&self, id: Uuid) -> u64 { + self.history.count_for_connection(id).await.expect("the count") + } + + async fn saved_query_count(&self, id: Uuid) -> usize { + self.history + .saved_queries() + .list(Some(id)) + .await + .expect("the saved queries") + .len() + } + + /// Everything stored for `id` other than its entry and secrets + /// is still there. + async fn kept_everything_for(&self, id: Uuid) { + assert!(self.widths.width(id, Some("public"), "users", "name").is_some()); + assert_eq!(self.filters.filters(id, Some("public"), "users"), filter()); + assert!(self.tabs(id).exists(), "the saved tabs went"); + assert!(self.drafts_of(id).exists(), "the drafts went"); + assert_eq!(self.history_count(id).await, 1); + assert_eq!(self.saved_query_count(id).await, 1); + } + } + + #[tokio::test] + async fn secret_failure_keeps_entry_and_skips_purge() { + let fixture = fixture().await; + fixture.vault.fail_delete_connection(SecretError::Locked); + + let refused = fixture + .deletion() + .run(fixture.id, DeletionOptions { delete_history: true }) + .await; + + assert!( + matches!(refused, Err(DeletionError::Secrets(SecretError::Locked))), + "{refused:?}" + ); + assert!(fixture.listed(fixture.id), "the entry went while its secrets stayed"); + assert!(fixture.log.lock().unwrap().is_empty(), "the list was written"); + assert!(fixture.has_password(fixture.id)); + fixture.kept_everything_for(fixture.id).await; + } + + #[tokio::test] + async fn order_is_secrets_then_list_then_purge() { + let fixture = fixture().await; + + fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert_eq!( + *fixture.log.lock().unwrap(), + ["list, secrets deleted: true, tabs still saved: true"], + "the steps ran out of order" + ); + assert!(!fixture.tabs(fixture.id).exists(), "the purge did not follow the list"); + } + + #[tokio::test] + async fn success_forgets_widths_filters_workspace_and_drafts() { + let fixture = fixture().await; + + let report = fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert!(report.is_complete(), "{report:?}"); + assert!(!fixture.listed(fixture.id)); + assert!(!fixture.has_password(fixture.id)); + assert!( + fixture + .widths + .width(fixture.id, Some("public"), "users", "name") + .is_none() + ); + assert!(fixture.filters.filters(fixture.id, Some("public"), "users").is_empty()); + for file in [fixture.paths.column_widths_file(), fixture.paths.filter_settings_file()] { + let on_disk = std::fs::read_to_string(&file).expect("the file"); + assert!( + !on_disk.contains(&fixture.id.to_string()), + "{} still names the deleted connection once the delete ended", + file.display() + ); + assert!(on_disk.contains(&fixture.other.to_string())); + } + assert!( + !fixture.tabs(fixture.id).exists(), + "the saved tabs outlived the connection" + ); + assert!( + !fixture.drafts_of(fixture.id).exists(), + "the drafts outlived the connection" + ); + assert_eq!(fixture.saved_query_count(fixture.id).await, 0); + assert!(fixture.listed(fixture.other)); + assert!(fixture.has_password(fixture.other)); + fixture.kept_everything_for(fixture.other).await; + } + + #[tokio::test] + async fn history_kept_unless_requested() { + let fixture = fixture().await; + + fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert_eq!(fixture.history_count(fixture.id).await, 1); + } + + #[tokio::test] + async fn history_deleted_when_requested() { + let fixture = fixture().await; + + let report = fixture + .deletion() + .run(fixture.id, DeletionOptions { delete_history: true }) + .await + .expect("the delete"); + + assert!(report.is_complete(), "{report:?}"); + assert_eq!(fixture.history_count(fixture.id).await, 0); + assert_eq!(fixture.history_count(fixture.other).await, 1); + let left = fixture + .history + .search(SearchFilter { + needle: Some("users".to_owned()), + limit: 10, + ..SearchFilter::default() + }) + .await + .expect("search"); + assert!(left.iter().all(|entry| entry.connection_id == fixture.other)); + } + + #[tokio::test] + async fn without_the_history_database_only_the_saved_queries_are_left_unless_history_was_asked_for() { + let fixture = fixture().await; + let mut deletion = fixture.deletion(); + deletion.history = None; + + let report = deletion + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert!( + matches!(report.purge_failures.as_slice(), [PurgeFailure::SavedQueries(_)]), + "{report:?}" + ); + } + + #[tokio::test] + async fn purge_failure_reported_but_entry_removed() { + let fixture = fixture().await; + let drafts = fixture.drafts_of(fixture.id); + std::fs::remove_dir_all(&drafts).expect("clear the drafts"); + // A file where the drafts directory goes cannot be removed as + // one. + std::fs::write(&drafts, b"not a directory").expect("seed"); + let mut deletion = fixture.deletion(); + deletion.history = None; + + let report = deletion + .run(fixture.id, DeletionOptions { delete_history: true }) + .await + .expect("the delete"); + + assert!(!fixture.listed(fixture.id), "a failed purge kept the entry"); + assert!(!fixture.has_password(fixture.id)); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::Workspace(_))), + "{report:?}" + ); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::SavedQueries(_))), + "{report:?}" + ); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::History(_))), + "{report:?}" + ); + assert!( + fixture + .widths + .width(fixture.id, Some("public"), "users", "name") + .is_none() + ); + assert!( + !fixture.tabs(fixture.id).exists(), + "the saved tabs stayed with the drafts" + ); + } +} diff --git a/linux/crates/app/src/services/deletion_error.rs b/linux/crates/app/src/services/deletion_error.rs new file mode 100644 index 0000000000..424f7253e3 --- /dev/null +++ b/linux/crates/app/src/services/deletion_error.rs @@ -0,0 +1,57 @@ +use tablepro_core::credentials::SecretError; +use tablepro_session::runtime::TaskFailure; +use tablepro_storage::StorageError; +use thiserror::Error; + +/// Why a saved connection is still there after a delete. +#[derive(Debug, Error)] +pub enum DeletionError { + /// The keyring kept the connection's secrets, so the entry was kept + /// too: a secret with no entry would be orphaned in the keyring. + #[error("the stored passwords could not be deleted: {0}")] + Secrets(SecretError), + /// The secrets are gone but the entry is not. It still connects + /// once the user types its passwords. + #[error("the connection could not be removed from the list: {0}")] + Entry(StorageError), + #[error("the delete did not finish: {0}")] + Interrupted(TaskFailure), +} + +impl DeletionError { + /// What the user is told about the delete of `name`. + pub fn summary(&self, name: &str) -> String { + match self { + Self::Secrets(_) => { + crate::i18n::gettext("The stored passwords could not be deleted, so the connection was kept.") + } + Self::Entry(_) => crate::i18n::gettext_f( + "“{name}” is still listed, but its stored passwords were deleted. Connecting will ask for them.", + &[("name", name)], + ), + Self::Interrupted(_) => crate::i18n::gettext_f("“{name}” could not be deleted", &[("name", name)]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_kept_entry_says_its_passwords_are_gone() { + let kept = DeletionError::Entry(StorageError::Schema("read-only".to_owned())); + + let said = kept.summary("Prod"); + + assert!(said.contains("“Prod” is still listed"), "{said}"); + assert!(said.contains("passwords were deleted"), "{said}"); + } + + #[test] + fn an_interrupted_delete_says_nothing_was_deleted() { + let said = DeletionError::Interrupted(TaskFailure::Aborted).summary("Prod"); + + assert_eq!(said, "“Prod” could not be deleted"); + } +} diff --git a/linux/crates/app/src/services/deletion_report.rs b/linux/crates/app/src/services/deletion_report.rs new file mode 100644 index 0000000000..ba666cb0c0 --- /dev/null +++ b/linux/crates/app/src/services/deletion_report.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +use crate::workspace::WorkspaceSaveError; + +/// What was stored for a deleted connection and could not be removed. +/// None of it undoes the delete: nothing can reach it any more. +#[derive(Debug, Default)] +pub struct DeletionReport { + pub purge_failures: Vec, +} + +impl DeletionReport { + pub fn is_complete(&self) -> bool { + self.purge_failures.is_empty() + } + + /// The toast that tells the user what the delete of `name` left + /// behind, or `None` when it left nothing. + pub fn summary(&self, name: &str) -> Option { + let left = |wanted: fn(&PurgeFailure) -> bool| self.purge_failures.iter().any(wanted); + let tabs = left(|failure| matches!(failure, PurgeFailure::Workspace(_))); + let queries = left(|failure| matches!(failure, PurgeFailure::SavedQueries(_))); + let history = left(|failure| matches!(failure, PurgeFailure::History(_))); + let named = &[("name", name)]; + let text = match (tabs, queries, history) { + (false, false, false) => return None, + (true, false, false) => crate::i18n::gettext_f( + "“{name}” was deleted, but some saved tabs or settings could not be removed", + named, + ), + (true, ..) => crate::i18n::gettext_f( + "“{name}” was deleted, but some of what was stored for it could not be removed", + named, + ), + (false, true, true) => crate::i18n::gettext_f( + "“{name}” was deleted, but its saved queries and query history could not be removed", + named, + ), + (false, true, false) => crate::i18n::gettext_f( + "“{name}” was deleted, but its saved queries could not be removed", + named, + ), + (false, false, true) => crate::i18n::gettext_f( + "“{name}” was deleted, but its query history could not be removed", + named, + ), + }; + Some(text) + } +} + +#[derive(Debug, Error)] +pub enum PurgeFailure { + #[error(transparent)] + Workspace(WorkspaceSaveError), + #[error("could not delete the saved queries: {0}")] + SavedQueries(String), + #[error("could not delete the query history: {0}")] + History(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(failures: Vec) -> DeletionReport { + DeletionReport { + purge_failures: failures, + } + } + + fn queries() -> PurgeFailure { + PurgeFailure::SavedQueries("the query history did not open".to_owned()) + } + + #[test] + fn a_complete_delete_has_nothing_to_say() { + assert_eq!(report(Vec::new()).summary("Prod"), None); + } + + #[test] + fn the_summary_names_what_was_left_behind() { + let tabs = report(vec![PurgeFailure::Workspace(WorkspaceSaveError::WriterStopped)]); + let saved = report(vec![queries()]); + let history = report(vec![PurgeFailure::History("locked".to_owned())]); + let both = report(vec![queries(), PurgeFailure::History("locked".to_owned())]); + + assert_eq!( + tabs.summary("Prod").as_deref(), + Some("“Prod” was deleted, but some saved tabs or settings could not be removed") + ); + assert_eq!( + saved.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its saved queries could not be removed") + ); + assert_eq!( + history.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its query history could not be removed") + ); + assert_eq!( + both.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its saved queries and query history could not be removed") + ); + } +} diff --git a/linux/crates/app/src/services/file_location.rs b/linux/crates/app/src/services/file_location.rs new file mode 100644 index 0000000000..8f8c7a7930 --- /dev/null +++ b/linux/crates/app/src/services/file_location.rs @@ -0,0 +1,340 @@ +//! Which filesystem a database file is on, and the file a document +//! portal path stands for. Each `_blocking` call reads the disk through +//! synchronous GIO, with no main context, and runs on the spawner. + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use gio::prelude::*; + +pub use super::file_location_kind::FileLocationKind; + +pub type LocationClassifier = fn(&Path) -> FileLocationKind; + +/// GIO's name for the portal's `user.document-portal.host-path`. +const HOST_PATH_ATTRIBUTE: &str = "xattr::document-portal.host-path"; + +/// Filesystems whose locks SQLite cannot rely on across machines. +const REMOTE_FILESYSTEMS: &[&str] = &[ + "nfs", + "nfs4", + "cifs", + "smb", + "smb2", + "smb3", + "ceph", + "9p", + "afs", + "glusterfs", + "lustre", +]; + +/// `fuseblk` is a block device behind FUSE, such as NTFS through +/// ntfs-3g, so its locks are this machine's own. +pub fn kind_for_fs_type(fs_type: &str) -> FileLocationKind { + match fs_type { + "fuseblk" => FileLocationKind::Local, + "fuse.portal" => FileLocationKind::Portal { + host_path: None, + host_is_local: false, + }, + remote if REMOTE_FILESYSTEMS.contains(&remote) => FileLocationKind::Remote { + fs_type: remote.to_owned(), + }, + fuse if fuse == "fuse" || fuse.starts_with("fuse.") => FileLocationKind::Unknown { + fs_type: fuse.to_owned(), + }, + _ => FileLocationKind::Local, + } +} + +/// The filesystem `path` is on. A file that does not exist yet is +/// classified by its folder's mount. +pub fn classify_blocking(path: &Path) -> FileLocationKind { + classify_with(path, mount_kind_blocking) +} + +fn classify_with(path: &Path, mount_kind: impl Fn(&Path) -> FileLocationKind) -> FileLocationKind { + let path = follow_links(path); + match mount_kind(&path) { + FileLocationKind::Portal { .. } => FileLocationKind::portal( + portal_host_path_blocking(&path), + |host| std::fs::symlink_metadata(host).is_ok(), + |host| mount_kind(&follow_links(host)), + ), + kind => kind, + } +} + +/// SQLite opens the file a link points to, while GIO finds the mount +/// of the link itself. A path that does not resolve, such as a file +/// not made yet, stays as given, and GIO resolves its folder. +fn follow_links(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// The filesystem's kind, without asking a portal for the file behind +/// it, so a portal path inside a portal is never followed. +fn mount_kind_blocking(path: &Path) -> FileLocationKind { + if let (Some(entry), _) = gio_unix::MountEntry::for_file_path(path) { + return kind_for_fs_type(&entry.fs_type()); + } + let attributes = format!( + "{},{}", + gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE, + gio::FILE_ATTRIBUTE_FILESYSTEM_TYPE + ); + match gio::File::for_path(path).query_filesystem_info(&attributes, gio::Cancellable::NONE) { + Ok(info) => { + let fs_type = info + .attribute_string(gio::FILE_ATTRIBUTE_FILESYSTEM_TYPE) + .map(String::from) + .unwrap_or_default(); + if info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE) { + FileLocationKind::Remote { fs_type } + } else { + kind_for_fs_type(&fs_type) + } + } + // The open that follows reports a file it cannot reach in its + // own words. + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not read the file's filesystem"); + FileLocationKind::Local + } + } +} + +/// The file a document portal path stands for, when the portal says. +pub fn portal_host_path_blocking(path: &Path) -> Option { + let info = gio::File::for_path(path) + .query_info( + HOST_PATH_ATTRIBUTE, + gio::FileQueryInfoFlags::NONE, + gio::Cancellable::NONE, + ) + .ok()?; + host_path_from_xattr(&info.attribute_string(HOST_PATH_ATTRIBUTE)?) +} + +/// GIO escapes every xattr byte outside printable ASCII, and the +/// backslash, as `\xNN`. +fn host_path_from_xattr(escaped: &str) -> Option { + let mut bytes = Vec::with_capacity(escaped.len()); + let mut rest = escaped.as_bytes(); + while let Some((&byte, tail)) = rest.split_first() { + match (byte, tail) { + (b'\\', [b'x', high, low, after @ ..]) => { + let hex = [*high, *low]; + let hex = std::str::from_utf8(&hex).ok()?; + bytes.push(u8::from_str_radix(hex, 16).ok()?); + rest = after; + } + _ => { + bytes.push(byte); + rest = tail; + } + } + } + while bytes.last() == Some(&0) { + bytes.pop(); + } + let path = PathBuf::from(OsString::from_vec(bytes)); + path.is_absolute().then_some(path) +} + +/// A path under the document portal's mount, whose folder is an id +/// that means nothing to the user. +pub fn is_portal_path(path: &Path) -> bool { + if let Ok(rest) = path.strip_prefix("/run/user") { + let mut parts = rest.iter(); + let uid = parts.next().and_then(|uid| uid.to_str()); + return uid.is_some_and(|uid| !uid.is_empty() && uid.bytes().all(|byte| byte.is_ascii_digit())) + && parts.next().is_some_and(|doc| doc == "doc") + && parts.next().is_some(); + } + path.strip_prefix("/run/flatpak/doc") + .is_ok_and(|rest| rest.iter().next().is_some()) +} + +/// How a database path reads to the user: the file a portal stands for +/// when it is known, the name alone for any other portal path, and the +/// path itself otherwise. +pub fn display_path(path: &Path, host_path: Option<&Path>) -> String { + if let Some(host) = host_path { + return host.display().to_string(); + } + match path.file_name() { + Some(name) if is_portal_path(path) => name.to_string_lossy().into_owned(), + _ => path.display().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote(fs_type: &str) -> FileLocationKind { + FileLocationKind::Remote { + fs_type: fs_type.to_owned(), + } + } + + fn unknown(fs_type: &str) -> FileLocationKind { + FileLocationKind::Unknown { + fs_type: fs_type.to_owned(), + } + } + + #[test] + fn fs_type_table() { + let portal = FileLocationKind::Portal { + host_path: None, + host_is_local: false, + }; + let cases = [ + ("nfs4", remote("nfs4")), + ("smb3", remote("smb3")), + ("cifs", remote("cifs")), + ("9p", remote("9p")), + ("fuse.sshfs", unknown("fuse.sshfs")), + ("fuse.gvfsd-fuse", unknown("fuse.gvfsd-fuse")), + ("fuse", unknown("fuse")), + ("fuseblk", FileLocationKind::Local), + ("fuse.portal", portal), + ("ext4", FileLocationKind::Local), + ("btrfs", FileLocationKind::Local), + ]; + + for (fs_type, expected) in cases { + assert_eq!(kind_for_fs_type(fs_type), expected, "{fs_type}"); + } + } + + #[test] + fn portal_without_host_path_requires_prompt() { + let kind = FileLocationKind::portal(None, |_| true, |_| FileLocationKind::Local); + + assert!(kind.requires_read_only_prompt()); + } + + #[test] + fn portal_with_invisible_host_path_requires_prompt() { + let kind = FileLocationKind::portal( + Some(PathBuf::from("/srv/data/app.db")), + |_| false, + |_| FileLocationKind::Local, + ); + + assert!(kind.requires_read_only_prompt()); + assert!(matches!( + kind, + FileLocationKind::Portal { host_path: Some(host), .. } if host == Path::new("/srv/data/app.db") + )); + } + + #[test] + fn portal_with_local_host_path_skips_prompt() { + let host = PathBuf::from("/home/me/app.db"); + let seen = host.clone(); + + let kind = FileLocationKind::portal(Some(host), |path| path == seen, |_| FileLocationKind::Local); + + assert!(!kind.requires_read_only_prompt()); + } + + #[test] + fn portal_with_host_on_a_share_or_another_portal_requires_prompt() { + let on_share = FileLocationKind::portal(Some(PathBuf::from("/mnt/share/app.db")), |_| true, |_| remote("nfs")); + let nested = FileLocationKind::portal( + Some(PathBuf::from("/run/user/1000/doc/1a2b/app.db")), + |_| true, + |_| kind_for_fs_type("fuse.portal"), + ); + + assert!(on_share.requires_read_only_prompt()); + assert!(nested.requires_read_only_prompt()); + } + + #[test] + fn only_a_local_file_opens_without_asking() { + assert!(!FileLocationKind::Local.requires_read_only_prompt()); + assert!(remote("nfs").requires_read_only_prompt()); + assert!(unknown("fuse.sshfs").requires_read_only_prompt()); + } + + #[test] + fn tempdir_is_local() { + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + assert_eq!(classify_blocking(&database), FileLocationKind::Local); + assert_eq!( + classify_blocking(&root.path().join("not-created-yet.db")), + FileLocationKind::Local + ); + assert_eq!(portal_host_path_blocking(&database), None); + } + + #[test] + fn a_link_is_classified_by_the_mount_of_the_file_it_points_to() { + let share = tempfile::tempdir().expect("share"); + let home = tempfile::tempdir().expect("home"); + let share_root = std::fs::canonicalize(share.path()).expect("canonical share"); + let target = share_root.join("app.db"); + std::fs::write(&target, b"").expect("seed"); + let link = home.path().join("app.db"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + let on_share = |path: &Path| { + if path.starts_with(&share_root) { + remote("nfs4") + } else { + FileLocationKind::Local + } + }; + + assert_eq!(classify_with(&link, on_share), remote("nfs4")); + assert_eq!(classify_with(&share_root.join("new.db"), on_share), remote("nfs4")); + assert_eq!( + classify_with(&home.path().join("new.db"), on_share), + FileLocationKind::Local + ); + } + + #[test] + fn the_host_path_comes_back_unescaped() { + assert_eq!( + host_path_from_xattr("/home/me/T\\xc3\\xa0i li\\xe1\\xbb\\x87u/app.db\\x00"), + Some(PathBuf::from("/home/me/Tài liệu/app.db")) + ); + assert_eq!( + host_path_from_xattr("/srv/a\\x5cb.db"), + Some(PathBuf::from("/srv/a\\b.db")) + ); + assert_eq!(host_path_from_xattr("relative.db"), None); + assert_eq!(host_path_from_xattr("/srv/\\xzz.db"), None); + } + + #[test] + fn portal_paths_are_told_apart() { + assert!(is_portal_path(Path::new("/run/user/1000/doc/1a2b3c/app.db"))); + assert!(is_portal_path(Path::new("/run/flatpak/doc/1a2b3c/app.db"))); + assert!(!is_portal_path(Path::new("/run/user/1000/doc"))); + assert!(!is_portal_path(Path::new("/run/user/me/doc/1a2b3c/app.db"))); + assert!(!is_portal_path(Path::new("/home/me/doc/app.db"))); + } + + #[test] + fn a_portal_path_reads_as_its_host_path_or_its_name() { + let portal = Path::new("/run/user/1000/doc/1a2b3c/app.db"); + + assert_eq!( + display_path(portal, Some(Path::new("/srv/data/app.db"))), + "/srv/data/app.db" + ); + assert_eq!(display_path(portal, None), "app.db"); + assert_eq!(display_path(Path::new("/home/me/app.db"), None), "/home/me/app.db"); + } +} diff --git a/linux/crates/app/src/services/file_location_kind.rs b/linux/crates/app/src/services/file_location_kind.rs new file mode 100644 index 0000000000..e4127f7c0d --- /dev/null +++ b/linux/crates/app/src/services/file_location_kind.rs @@ -0,0 +1,52 @@ +use std::path::{Path, PathBuf}; + +/// The filesystem a database file is on, as far as SQLite's locking +/// can trust it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileLocationKind { + Local, + /// A network filesystem, whose locks SQLite cannot rely on. + Remote { + fs_type: String, + }, + /// A file the document portal hands over. `host_is_local` is true + /// only when the file behind it is visible here and on a local + /// filesystem. + Portal { + host_path: Option, + host_is_local: bool, + }, + /// A FUSE filesystem whose locking is not known. + Unknown { + fs_type: String, + }, +} + +impl FileLocationKind { + /// A portal file, checked through `visible` and `classify` for the + /// file it stands for. `classify` looks one level deep: a host path + /// that is itself behind a portal never counts as local. + pub fn portal( + host_path: Option, + visible: impl FnOnce(&Path) -> bool, + classify: impl FnOnce(&Path) -> FileLocationKind, + ) -> Self { + let host_is_local = host_path + .as_deref() + .is_some_and(|host| visible(host) && classify(host) == Self::Local); + Self::Portal { + host_path, + host_is_local, + } + } + + /// SQLite locking through the portal's FUSE layer is not + /// established, and the host file may be on a network share. + pub fn requires_read_only_prompt(&self) -> bool { + match self { + Self::Local => false, + Self::Remote { .. } | Self::Unknown { .. } => true, + Self::Portal { host_is_local, .. } => !host_is_local, + } + } +} diff --git a/linux/crates/app/src/services/file_resolution.rs b/linux/crates/app/src/services/file_resolution.rs new file mode 100644 index 0000000000..27cf46f999 --- /dev/null +++ b/linux/crates/app/src/services/file_resolution.rs @@ -0,0 +1,127 @@ +//! A database file handed to TablePro, resolved to the session it +//! opens. Resolving reads the disk, so it runs on the blocking pool. + +use std::path::{Path, PathBuf}; + +use tablepro_session::SessionKey; +use tablepro_session::registry::FileIdentity; +use tablepro_storage::{ConnectionStore, SavedConnection}; +use thiserror::Error; + +/// Why a file could not be opened before any connect was tried. +#[derive(Debug, Error)] +pub enum FileResolveError { + /// A file with no local path, such as one on a remote share GIO + /// reaches without mounting it. + #[error("the file has no local path")] + NotLocal, + #[error("the file could not be found: {0}")] + NotFound(#[source] std::io::Error), + /// The background work that resolves it did not finish. + #[error("the file could not be resolved: {0}")] + Interrupted(#[source] tablepro_session::runtime::TaskFailure), +} + +/// What a file turned out to open. +#[derive(Debug)] +pub enum FileTarget { + /// The user saved this file as a connection, so that connection + /// opens, with its name, colour and saved tabs. + Saved(Box), + File(FileIdentity), +} + +/// A file the user opened and what it resolved to. +#[derive(Debug)] +pub struct ResolvedFile { + /// The URI it arrived as, for a Retry to resolve again. + pub uri: String, + pub target: Result, +} + +/// The file's canonical path, so a symlink and its target, or two +/// spellings of one path, open one session. The URI is the canonical +/// path's, which is what the session's key and saved tabs go by. +pub fn resolve_file_identity_blocking(path: Option<&Path>, uri: &str) -> Result { + let path = path.ok_or(FileResolveError::NotLocal)?; + let canonical = std::fs::canonicalize(path).map_err(FileResolveError::NotFound)?; + let uri = glib::filename_to_uri(&canonical, None).map_or_else(|_| uri.to_owned(), |uri| uri.to_string()); + Ok(FileIdentity::new(Some(canonical), uri)) +} + +/// Resolve the file, then find whether a saved connection opens the +/// same one. +pub fn resolve_file_blocking(path: Option, uri: String, connections: &ConnectionStore) -> ResolvedFile { + let target = resolve_file_identity_blocking(path.as_deref(), &uri).map(|identity| { + let saved = match connections.load_blocking() { + Ok(saved) => saved.to_vec(), + Err(error) => { + tracing::warn!(%error, "the saved connections could not be read to match a file against"); + Vec::new() + } + }; + match SessionKey::for_file(&identity, &saved) { + SessionKey::Saved(id) => match saved.into_iter().find(|connection| connection.id == id) { + Some(connection) => FileTarget::Saved(Box::new(connection)), + None => FileTarget::File(identity), + }, + SessionKey::File(_) => FileTarget::File(identity), + } + }); + ResolvedFile { uri, target } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_file_is_not_found() { + let root = tempfile::tempdir().expect("tempdir"); + let missing = root.path().join("gone.db"); + + let resolved = resolve_file_identity_blocking(Some(&missing), "file:///gone.db"); + + assert!(matches!(resolved, Err(FileResolveError::NotFound(_))), "{resolved:?}"); + assert!(matches!( + resolve_file_identity_blocking(None, "sftp://host/app.db"), + Err(FileResolveError::NotLocal) + )); + } + + #[test] + fn a_saved_file_opens_its_saved_connection() { + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + let store = ConnectionStore::new(&tablepro_storage::StoragePaths::under( + root.path(), + "tablepro-test", + "app.tablepro.Test", + )); + let saved = SavedConnection { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "sqlite".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: tablepro_core::TlsConfig::disabled(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }; + store.upsert_blocking(saved.clone()).expect("saved"); + + let resolved = resolve_file_blocking(Some(database.clone()), format!("file://{}", database.display()), &store); + + match resolved.target { + Ok(FileTarget::Saved(connection)) => assert_eq!(connection.id, saved.id), + other => panic!("the saved connection was not found: {other:?}"), + } + } +} diff --git a/linux/crates/app/src/services/from_session.rs b/linux/crates/app/src/services/from_session.rs deleted file mode 100644 index 79d775d576..0000000000 --- a/linux/crates/app/src/services/from_session.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::sync::{Arc, Weak}; - -use tablepro_session::Session; - -use super::active_session::ActiveSession; - -/// A message a session's watcher queued for the window, and the session -/// it came from. -/// -/// The window's queue can hold one behind the switch to another -/// session, and by the time it is handled it describes a session the -/// window has let go of. -#[derive(Debug)] -pub struct FromSession { - origin: Weak, - value: T, -} - -impl FromSession { - pub fn new(origin: Weak, value: T) -> Self { - Self { origin, value } - } - - /// The value, while the session it came from is still the window's. - pub fn if_current(self, active: &ActiveSession) -> Option { - let current = active.get()?; - Weak::ptr_eq(&Arc::downgrade(¤t), &self.origin).then_some(self.value) - } -} - -#[cfg(test)] -mod tests { - use tablepro_test_support::ConnectionScript; - - use super::*; - use crate::test_support::FakeSession; - - #[test] - fn only_the_current_session_is_heard() { - let runtime = crate::test_support::test_runtime(); - let a = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let active = ActiveSession::new(); - let from_a = || FromSession::new(Arc::downgrade(&a.session), "a"); - - assert_eq!(from_a().if_current(&active), None, "heard with no session open"); - active.replace(a.session.clone()); - assert_eq!(from_a().if_current(&active), Some("a")); - active.replace(b.session.clone()); - assert_eq!( - from_a().if_current(&active), - None, - "heard after the switch to another session" - ); - assert_eq!( - FromSession::new(Arc::downgrade(&b.session), "b").if_current(&active), - Some("b") - ); - } -} diff --git a/linux/crates/app/src/services/history_preferences.rs b/linux/crates/app/src/services/history_preferences.rs new file mode 100644 index 0000000000..77fd4cd8df --- /dev/null +++ b/linux/crates/app/src/services/history_preferences.rs @@ -0,0 +1,65 @@ +use std::rc::Rc; + +use gio::prelude::SettingsExt; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; +use tokio::sync::watch; + +/// The history preferences, mirrored where the history's worker and the +/// tasks that record can read them. +/// +/// `gio::Settings` lives on the GTK thread, so neither can hold it. Each +/// channel keeps the newest value, which is the one a prune or a record +/// must go by. +#[derive(Debug, Clone)] +pub struct HistoryPreferences { + pub retention_days: watch::Receiver, + pub save: watch::Receiver, +} + +impl HistoryPreferences { + pub fn follow(settings: &Rc) -> Self { + Self { + retention_days: mirror( + settings, + keys::HISTORY_RETENTION_DAYS, + AppSettings::history_retention_days, + ), + save: mirror(settings, keys::SAVE_QUERY_HISTORY, AppSettings::save_query_history), + } + } +} + +fn mirror( + settings: &Rc, + key: &str, + read: fn(&AppSettings) -> T, +) -> watch::Receiver { + let (sender, receiver) = watch::channel(read(settings)); + let settings_for_change = settings.clone(); + settings.gio().connect_changed(Some(key), move |_, _| { + sender.send_replace(read(&settings_for_change)); + }); + receiver +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::MemorySettings; + + /// On the GTK thread, which owns the main context GSettings sends + /// its change signals through. + #[gtk4::test] + fn a_changed_preference_reaches_the_channel() { + let settings = MemorySettings::new(); + let preferences = HistoryPreferences::follow(settings.get()); + assert!(*preferences.save.borrow()); + + settings.get().set_save_query_history(false).expect("store the flag"); + settings.get().set_history_retention_days(7).expect("store the days"); + + assert!(!*preferences.save.borrow()); + assert_eq!(*preferences.retention_days.borrow(), 7); + } +} diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs index 09daf1e64e..ed7fd0ca27 100644 --- a/linux/crates/app/src/services/history_service.rs +++ b/linux/crates/app/src/services/history_service.rs @@ -1,18 +1,22 @@ -use gio::prelude::SettingsExt; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::HistoryRecording; use tablepro_session::runtime::Tasks; +use tablepro_storage::query_history::NewEntry; use tablepro_storage::{QueryHistory, StoragePaths}; use tokio::sync::watch; use super::history_availability::HistoryAvailability; +use super::history_preferences::HistoryPreferences; /// How often unpinned history is pruned while the app runs. Startup does /// one pass, and this keeps a long session from growing without bound. const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); -/// Opens the query history off the GTK thread and prunes it on a timer. +/// Opens the query history off the GTK thread, prunes it on a timer and +/// decides what goes into it. /// /// Opening runs a migration and a prune, which is too slow to block /// startup on, so the app starts and this reports when the database is @@ -21,31 +25,19 @@ const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); pub struct HistoryService { availability: watch::Receiver, store: Arc>>, + save: watch::Receiver, _worker: Arc>, } -/// Mirror the retention preference onto a channel the prune worker can -/// read. -/// -/// `gio::Settings` lives on the GTK thread, so the worker cannot hold -/// it. This keeps the newest value where the worker can see it. -pub fn publish_retention(settings: &std::rc::Rc) -> watch::Receiver { - let (sender, receiver) = watch::channel(settings.history_retention_days()); - let settings_for_change = settings.clone(); - settings.gio().connect_changed( - Some(tablepro_storage::settings::keys::HISTORY_RETENTION_DAYS), - move |_, _| { - sender.send_replace(settings_for_change.history_retention_days()); - }, - ); - receiver -} - impl HistoryService { /// `retention` is read at every tick rather than captured, so a /// value the user typed through on the way to another one never /// decides what is deleted an hour later. - pub fn start(paths: StoragePaths, retention: watch::Receiver, tasks: &Tasks) -> Self { + pub fn start(paths: StoragePaths, preferences: HistoryPreferences, tasks: &Tasks) -> Self { + let HistoryPreferences { + retention_days: retention, + save, + } = preferences; let (sender, availability) = watch::channel(HistoryAvailability::Starting); let store: Arc>> = Arc::new(Mutex::new(None)); let store_for_worker = store.clone(); @@ -78,10 +70,46 @@ impl HistoryService { Self { availability, store, + save, _worker: Arc::new(worker), } } + /// Record one run once the database is open, unless the user turned + /// the history off or the SQL can carry a credential. + /// + /// `dialect` is the connection's: without it nobody can say which + /// statements hold a secret, so nothing is recorded. The setting is + /// read now, so a run that ends after the history was turned off is + /// not recorded either. + pub fn record( + &self, + entry: NewEntry, + dialect: Option>, + ) -> impl Future + Send + use<> { + let saving = *self.save.borrow(); + let store = self.ready_store(); + async move { + if !saving { + return; + } + let Some(dialect) = dialect else { + tracing::debug!(connection = %entry.connection_id, "no dialect, so the run is not recorded"); + return; + }; + if let HistoryRecording::Withhold(reason) = dialect.history_recording(&entry.query) { + tracing::debug!(connection = %entry.connection_id, ?reason, "a run was kept out of the history"); + return; + } + let Some(history) = store.await else { + return; + }; + if let Err(error) = history.record(entry).await { + tracing::warn!(%error, "history record failed"); + } + } + } + pub fn availability(&self) -> watch::Receiver { self.availability.clone() } @@ -91,6 +119,22 @@ impl HistoryService { pub fn store(&self) -> Option { self.store.lock().ok().and_then(|guard| guard.clone()) } + + /// The database once the open at startup has finished, or `None` + /// when it failed, for work that must not skip the history just + /// because it ran early. + pub fn ready_store(&self) -> impl Future> + use<> { + let mut availability = self.availability.clone(); + let store = self.store.clone(); + async move { + // A sender gone means the worker ended, which it does only + // after it has said how the open went. + let _ = availability + .wait_for(|state| !matches!(state, HistoryAvailability::Starting)) + .await; + store.lock().ok().and_then(|guard| guard.clone()) + } + } } /// One prune at the retention the user has set right now. @@ -123,8 +167,13 @@ mod tests { use super::*; use crate::test_support::paused_tasks; - fn retention(days: u32) -> watch::Receiver { - watch::channel(days).1 + /// Preferences that stay as given: a dropped sender leaves the + /// receiver on its last value. + fn preferences(days: u32, save: bool) -> HistoryPreferences { + HistoryPreferences { + retention_days: watch::channel(days).1, + save: watch::channel(save).1, + } } fn paths(root: &tempfile::TempDir) -> StoragePaths { @@ -146,19 +195,29 @@ mod tests { let root = tempfile::tempdir().expect("tempdir"); let tasks = paused_tasks(); - let service = HistoryService::start(paths(&root), retention(30), &tasks); + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); let state = wait_for_ready(&service).await; assert_eq!(state, HistoryAvailability::Ready); assert!(service.store().is_some()); } + #[tokio::test] + async fn ready_store_waits_for_the_open() { + let root = tempfile::tempdir().expect("tempdir"); + + let service = HistoryService::start(paths(&root), preferences(30, true), &paused_tasks()); + assert!(service.store().is_none()); + + assert!(service.ready_store().await.is_some()); + } + #[tokio::test] async fn store_is_none_until_ready() { let root = tempfile::tempdir().expect("tempdir"); let tasks = paused_tasks(); - let service = HistoryService::start(paths(&root), retention(30), &tasks); + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); // Nothing has been polled yet, so the worker cannot have run. assert!(service.store().is_none()); @@ -202,7 +261,11 @@ mod tests { /// A service whose store is open, with its retention channel. async fn ready_service(root: &tempfile::TempDir, days: u32) -> (QueryHistory, watch::Sender) { let (retention, receiver) = watch::channel(days); - let service = HistoryService::start(paths(root), receiver.clone(), &paused_tasks()); + let preferences = HistoryPreferences { + retention_days: receiver, + save: watch::channel(true).1, + }; + let service = HistoryService::start(paths(root), preferences, &paused_tasks()); let state = wait_for_ready(&service).await; assert_eq!(state, HistoryAvailability::Ready, "the history did not open"); (service.store().expect("the store"), retention) @@ -260,10 +323,82 @@ mod tests { std::fs::write(&paths.state, b"not a directory").expect("seed"); let tasks = paused_tasks(); - let service = HistoryService::start(paths, retention(30), &tasks); + let service = HistoryService::start(paths, preferences(30, true), &tasks); let state = wait_for_ready(&service).await; assert!(state.failure().is_some(), "{state:?}"); assert!(service.store().is_none()); } + + fn run_of(query: &str) -> NewEntry { + NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: uuid::Uuid::new_v4(), + connection_name: "local".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: None, + outcome: tablepro_storage::query_history::Outcome::Success, + } + } + + fn fake_dialect() -> Option> { + Some(Arc::new(tablepro_test_support::FakeDialect)) + } + + /// Record `query` through a service built with `save`, and read back + /// what the store holds. + async fn recorded(save: bool, query: &str, dialect: Option>) -> Vec { + let root = tempfile::tempdir().expect("tempdir"); + let service = HistoryService::start(paths(&root), preferences(30, save), &paused_tasks()); + + service.record(run_of(query), dialect).await; + + let history = service.ready_store().await.expect("the store"); + queries(&history).await + } + + #[tokio::test] + async fn history_service_skips_when_setting_off() { + assert_eq!(recorded(false, "SELECT 1", fake_dialect()).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_without_dialect() { + assert_eq!(recorded(true, "SELECT 1", None).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_credential_statement() { + assert_eq!( + recorded(true, "SELECT 1; CREATE USER u PASSWORD 'p'", fake_dialect()).await, + Vec::::new() + ); + } + + #[tokio::test] + async fn history_service_records_plain_select_unchanged() { + let query = "SELECT id FROM users WHERE password IS NULL AND email = 'a@b' -- kept as typed\n"; + + assert_eq!(recorded(true, query, fake_dialect()).await, vec![query.to_owned()]); + } + + #[tokio::test] + async fn turning_the_history_off_stops_the_next_record() { + let root = tempfile::tempdir().expect("tempdir"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let service = HistoryService::start(paths(&root), preferences, &paused_tasks()); + service.record(run_of("SELECT 'kept'"), fake_dialect()).await; + + save.send_replace(false); + service.record(run_of("SELECT 'dropped'"), fake_dialect()).await; + + let history = service.ready_store().await.expect("the store"); + assert_eq!(queries(&history).await, vec!["SELECT 'kept'".to_owned()]); + } } diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 757bb5afca..9abb70cc2a 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -3,15 +3,21 @@ pub mod cell_overlay; pub mod change_tracker; pub mod change_tracker_registry; pub mod column_relayout; -pub mod from_session; +pub mod connection_deletion; +pub mod deletion_error; +pub mod deletion_report; +pub mod file_location; +mod file_location_kind; +pub mod file_resolution; pub mod history_availability; +pub mod history_preferences; pub mod history_service; pub mod pending_structure; pub mod save_batch; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; -pub mod single_instance; +pub mod ssh_environment; pub mod structure_tracker; pub mod structure_tracker_handle; pub mod structure_tracker_registry; diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs index ef5b5ccc33..bdc8be2d9c 100644 --- a/linux/crates/app/src/services/pending_structure.rs +++ b/linux/crates/app/src/services/pending_structure.rs @@ -1,5 +1,6 @@ use tablepro_core::ddl::{ - BuildDdlError, DdlDialect, DdlOperation, DdlPlan, DraftProblem, TableChanges, TableDefinition, TableDraft, + BuildDdlError, ChangeReview, DdlDialect, DdlOperation, DdlPlan, DraftProblem, TableChanges, TableDefinition, + TableDraft, }; use tablepro_core::meta::TableRef; @@ -66,6 +67,15 @@ impl PendingStructure { } } + /// What the save would run and lose, planned as `plan` plans it. + pub fn review(&self, ddl: &dyn DdlDialect) -> Result { + let plan = self.plan(ddl)?; + Ok(match self.original { + Some(_) => ChangeReview::review(&self.changes, &plan), + None => ChangeReview::create(&plan), + }) + } + /// What a connection without structure editing refuses. pub fn operation(&self) -> DdlOperation { match self.original { diff --git a/linux/crates/app/src/services/session_context.rs b/linux/crates/app/src/services/session_context.rs index ef67c37437..6bc192e36a 100644 --- a/linux/crates/app/src/services/session_context.rs +++ b/linux/crates/app/src/services/session_context.rs @@ -6,13 +6,14 @@ use tablepro_core::credentials::{CredentialInteraction, CredentialPrompter, Secr use tablepro_core::{DriverRegistry, LivenessPolicy}; use tablepro_session::operation::{DeadlinePolicy, OperationKind}; use tablepro_session::runtime::{ProcessHold, Tasks}; -use tablepro_session::{Connector, CredentialSource, SessionDeps, SessionRegistry}; +use tablepro_session::{Connector, CredentialSource, NoStoredCredentials, SessionDeps, SessionRegistry}; use tablepro_storage::{KeyringCredentials, SavedConnection}; use super::active_session::ActiveSession; /// Everything a window needs to connect, run a session and close it, /// built once in `run()` and shared on the GTK thread. +#[derive(Clone)] pub struct SessionContext { pub drivers: Arc, pub connector: Arc, @@ -23,19 +24,67 @@ pub struct SessionContext { pub hold: Rc, pub tasks: Tasks, pub liveness: LivenessPolicy, + pub credentials: Rc, pub active: ActiveSession, } +/// Where a session's secrets come from at each connect and reconnect. +pub trait CredentialSourceFactory { + /// A saved connection's keyring entries, or for a connection with no + /// saved entry, only what its request carries. `session_only` names + /// the secrets typed for the session that the keyring does not hold. + fn for_connection(&self, saved: Option<&SavedConnection>, session_only: &[SecretKind]) + -> Arc; +} + +/// The keyring, labelling what it remembers in the user's language. +pub struct KeyringSources { + secrets: Arc, +} + +impl KeyringSources { + pub fn new(secrets: Arc) -> Self { + Self { secrets } + } +} + +impl CredentialSourceFactory for KeyringSources { + fn for_connection( + &self, + saved: Option<&SavedConnection>, + session_only: &[SecretKind], + ) -> Arc { + match saved { + Some(saved) => Arc::new( + KeyringCredentials::for_saved(self.secrets.clone(), saved) + .with_labeler(translated_secret_label) + .keeping_session_secrets(session_only), + ), + None => Arc::new(NoStoredCredentials), + } + } +} + impl SessionContext { /// A connect the user is waiting on may ask them for what is missing. pub fn attended(&self) -> CredentialInteraction { CredentialInteraction::Attended(self.prompter.clone()) } - /// A saved connection's secrets, read from the keyring at every - /// connect and labelled in the user's language when remembered. - pub fn keyring_credentials(&self, saved: &SavedConnection) -> KeyringCredentials { - KeyringCredentials::for_saved(self.secrets.clone(), saved).with_labeler(translated_secret_label) + /// What a connect to `saved` reads its secrets from, or one with no + /// saved entry. + pub fn credentials_for(&self, saved: Option<&SavedConnection>) -> Arc { + self.credentials.for_connection(saved, &[]) + } + + /// What a session opened from `saved` reconnects with, keeping the + /// `session_only` secrets it logged in with over the keyring's. + pub fn session_credentials( + &self, + saved: Option<&SavedConnection>, + session_only: &[SecretKind], + ) -> Arc { + self.credentials.for_connection(saved, session_only) } /// What a started session reconnects with. A Retry the user presses diff --git a/linux/crates/app/src/services/single_instance.rs b/linux/crates/app/src/services/single_instance.rs deleted file mode 100644 index 00835a2d30..0000000000 --- a/linux/crates/app/src/services/single_instance.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Process-level single-instance gate. -//! -//! `gtk::Application::register()` already handles single-instance through -//! the D-Bus session bus on a healthy GNOME session: a second launch -//! sends `activate` to the primary and exits. That mechanism breaks when -//! D-Bus is unavailable (sandboxed, headless, minimal session) and -//! silently lets two processes through. Two TablePro processes racing on -//! the workspace state corrupt each other's tabs. -//! -//! This adds a belt-and-suspenders exclusive lock on -//! `StoragePaths::instance_lock`, under the per-application runtime -//! directory. It is held for the lifetime of the returned `Lock`. The -//! kernel releases it when the process exits, so a crash does not leak -//! it. - -use std::fs::TryLockError; -use std::fs::{File, OpenOptions}; -use std::os::unix::fs::OpenOptionsExt; -use std::path::PathBuf; - -use tablepro_storage::StoragePaths; - -#[derive(Debug)] -pub struct Lock { - // Held only for its drop side effect: closing the fd, which the - // kernel turns into a lock release. - _file: File, -} - -#[derive(Debug)] -pub enum LockError { - AlreadyRunning, - Io { path: PathBuf, source: std::io::Error }, -} - -impl std::fmt::Display for LockError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LockError::AlreadyRunning => write!(f, "another TablePro instance is already running"), - LockError::Io { path, source } => { - write!(f, "single-instance lock {}: {source}", path.display()) - } - } - } -} - -impl std::error::Error for LockError {} - -/// Take the process-wide single-instance lock. `Err(AlreadyRunning)` -/// means another process holds it. The guard must outlive every code -/// path that writes user state. -pub fn acquire(paths: &StoragePaths) -> Result { - let path = paths.instance_lock(); - tablepro_storage::fs::ensure_private_dir(&paths.runtime).map_err(|error| LockError::Io { - path: paths.runtime.clone(), - source: std::io::Error::other(error), - })?; - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(&path) - .map_err(|source| LockError::Io { - path: path.clone(), - source, - })?; - match file.try_lock() { - Ok(()) => Ok(Lock { _file: file }), - Err(TryLockError::WouldBlock) => Err(LockError::AlreadyRunning), - Err(TryLockError::Error(source)) => Err(LockError::Io { path, source }), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn paths(root: &tempfile::TempDir) -> StoragePaths { - StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro") - } - - #[test] - fn instance_lock_lives_under_runtime_dir() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - - let _lock = acquire(&paths).expect("acquire"); - - assert_eq!(paths.instance_lock(), paths.runtime.join("tablepro.lock")); - assert!(paths.instance_lock().exists()); - } - - #[test] - fn a_second_acquire_reports_already_running() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - let _held = acquire(&paths).expect("first acquire"); - - let second = acquire(&paths); - - assert!(matches!(second, Err(LockError::AlreadyRunning)), "{second:?}"); - } - - #[test] - fn the_lock_releases_when_the_guard_drops() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - - drop(acquire(&paths).expect("first acquire")); - - assert!(acquire(&paths).is_ok()); - } - - #[test] - fn two_profiles_do_not_block_each_other() { - let root = tempfile::tempdir().expect("tempdir"); - let installed = StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); - let devel = StoragePaths::under(root.path(), "tablepro-devel", "app.tablepro.TablePro.Devel"); - - let _held = acquire(&installed).expect("installed"); - - assert!(acquire(&devel).is_ok()); - } -} diff --git a/linux/crates/app/src/services/ssh_environment.rs b/linux/crates/app/src/services/ssh_environment.rs new file mode 100644 index 0000000000..ca8b1fafae --- /dev/null +++ b/linux/crates/app/src/services/ssh_environment.rs @@ -0,0 +1,25 @@ +//! What the machine offers for SSH logins. + +use std::path::PathBuf; + +/// What the machine offers for SSH logins, read each time the connect +/// dialog opens so a test can say what it has instead. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshEnvironment { + pub agent: bool, + pub default_key: Option, +} + +impl SshEnvironment { + /// Looking for a key file reads the disk, so this runs on the + /// blocking pool, never on the GTK thread. + pub fn detect_blocking() -> Self { + let agent = std::env::var_os("SSH_AUTH_SOCK").is_some_and(|socket| !socket.is_empty()); + let ssh_dir = glib::home_dir().join(".ssh"); + let default_key = ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|candidate| ssh_dir.join(candidate)) + .find(|path| path.exists()); + Self { agent, default_key } + } +} diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index 19b6cf834f..5e75d0d394 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -41,10 +41,10 @@ impl StructureChangeTracker { } } - /// What Save writes, held as in flight until it settles, or `None` - /// when there is nothing to. The tab's own checks run here too, so a - /// save from a close prompt cannot skip them. - pub fn begin_save(&mut self) -> Result, SaveRejection> { + /// What Save would write, or `None` when there is nothing to. The + /// tab's own checks run here too, so a save from a close prompt + /// cannot skip them. + pub fn pending_save(&self) -> Result, SaveRejection> { if self.outcome_unknown.is_some() { return Err(SaveRejection::OutcomeUnknown); } @@ -55,10 +55,16 @@ impl StructureChangeTracker { return Ok(None); }; pending.check().map_err(SaveRejection::Blocked)?; - self.saving = true; Ok(Some(pending)) } + /// What Save writes, held as in flight until it settles. + pub fn begin_save(&mut self) -> Result, SaveRejection> { + let pending = self.pending_save()?; + self.saving = pending.is_some(); + Ok(pending) + } + /// A commit leaves nothing pending: the tab reads the table again and /// diffs against what is there now. pub fn settle_save(&mut self, outcome: &SaveOutcome) { @@ -140,6 +146,17 @@ mod tests { assert_eq!(tracker.pending_state(), PendingState::Clean); } + #[test] + fn reading_what_a_save_would_write_takes_nothing() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(1))); + + assert_eq!(tracker.pending_save(), Ok(Some(new_table(1)))); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + assert!(matches!(tracker.begin_save(), Ok(Some(_)))); + assert_eq!(tracker.pending_save(), Err(SaveRejection::AlreadySaving)); + } + #[test] fn a_draft_the_tab_would_refuse_is_refused_here_too() { let mut tracker = StructureChangeTracker::default(); diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs index 1bdf5fe3dc..b48c94e3c9 100644 --- a/linux/crates/app/src/services/structure_tracker_handle.rs +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -15,6 +15,10 @@ impl StructureTrackerHandle { self.0.borrow_mut().set_pending(pending); } + pub fn pending_save(&self) -> Result, SaveRejection> { + self.0.borrow().pending_save() + } + pub fn begin_save(&self) -> Result, SaveRejection> { self.0.borrow_mut().begin_save() } diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 14d73edd59..55c73630a2 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -19,14 +19,18 @@ use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot}; pub struct TabTrackerHandle(Rc>); impl TabTrackerHandle { - pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { - self.0.borrow().cell_overlay(row_key, col, original) + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { + self.0.borrow().cell_overlay(row_key, col) } pub fn row_state(&self, row_key: &RowKey) -> RowState { self.0.borrow().row_state(row_key) } + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + self.0.borrow().pending_values(row_key, width) + } + #[must_use = "the grid shows stale rows until the delta is applied"] pub fn apply(&self, edit: TrackerEdit) -> TrackerDelta { self.0.borrow_mut().apply(edit) @@ -56,7 +60,15 @@ impl TabTrackerHandle { } pub fn draft_values(&self, draft_id: u64) -> Option> { - self.0.borrow().draft_values(draft_id).map(<[Value]>::to_vec) + self.0.borrow().draft_values(draft_id) + } + + pub fn draft_inputs(&self, draft_id: u64) -> Option> { + self.0.borrow().draft_inputs(draft_id) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.0.borrow().draft_input(draft_id, col) } pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { @@ -108,7 +120,6 @@ mod tests { use tablepro_core::meta::TableRef; use super::*; - use crate::services::tracker_delta::VisualChange; use crate::workspace::{RollbackCause, UnknownCause}; fn row(id: i64) -> RowKey { @@ -192,8 +203,13 @@ mod tests { #[test] fn committed_clears_only_snapshot_edits() { let tracker = edited(); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(9), Value::Int(9)], + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let batch = tracker.begin_save().expect("a save").expect("changes to save"); assert_eq!(batch.changes.changes.changes.len(), 2); @@ -205,21 +221,26 @@ mod tests { let delta = tracker.settle_save(&SaveOutcome::Committed { rows: Some(2) }, batch.snapshot); assert_eq!(delta.pending_count, 2); - assert!(matches!(delta.visual.as_slice(), [VisualChange::RemoveDraft { .. }])); + assert_eq!(delta.removed_drafts.len(), 1); assert!(tracker.drafts().is_empty(), "the inserted draft stayed pending"); assert_eq!(tracker.pending_state(), PendingState::Unsaved); - assert_eq!(tracker.cell_overlay(&row(1), 1, &Value::Int(0)).value, Value::Int(3)); - assert_eq!(tracker.cell_overlay(&row(4), 1, &Value::Int(0)).value, Value::Int(5)); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(Value::Int(0)), Value::Int(3)); + assert_eq!(tracker.cell_overlay(&row(4), 1).shown(Value::Int(0)), Value::Int(5)); } #[test] fn an_insert_or_delete_in_flight_is_not_changed_until_it_settles() { let tracker = edited(); - let inserted = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(9), Value::Int(9)], + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); - let draft_id = match inserted.visual.as_slice() { - [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + let draft_id = match inserted.inserted_drafts.as_slice() { + [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), }; let batch = tracker.begin_save().expect("a save").expect("changes to save"); @@ -227,17 +248,24 @@ mod tests { let typed = TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Int(10), + input: CellInput::Value(Value::Int(10)), }; assert!(tracker.refuses(&typed)); let held = tracker.apply(typed); - let _ = tracker.apply(TrackerEdit::DiscardDraft { draft_id }); + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts: vec![draft_id], + }); assert!(tracker.undo().is_none(), "undo ran while a save was in flight"); - assert_eq!(held.rows, [RowKey::Draft(draft_id)], "the typed value stayed on screen"); - assert!(held.visual.is_empty()); + assert_eq!( + held.changed, + [RowKey::Draft(draft_id)], + "the typed value stayed on screen" + ); + assert!(held.inserted_drafts.is_empty() && held.removed_drafts.is_empty()); - assert_eq!(tracker.drafts()[0].values[1], Value::Int(9)); + assert_eq!(tracker.drafts()[0].cells[1], CellInput::Value(Value::Int(9))); let _ = tracker.settle_save(&rolled_back(), batch.snapshot); assert!(tracker.undo().is_some()); } @@ -252,15 +280,20 @@ mod tests { original: Value::Text("a".into()), value: Value::Text("b".into()), }); - let inserted = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Null, Value::Text("c".into())], + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Null, Value::Text("c".into())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); - assert_eq!(edited.rows, vec![row(1)]); + assert_eq!(edited.changed, vec![row(1)]); assert_eq!(edited.pending_count, 1); assert_eq!(inserted.pending_count, 2); - let [VisualChange::InsertDraft { draft_id }] = inserted.visual.as_slice() else { - panic!("an insert adds one draft row: {:?}", inserted.visual); + let [draft_id] = inserted.inserted_drafts.as_slice() else { + panic!("an insert adds one draft row: {:?}", inserted.inserted_drafts); }; assert_eq!( tracker.draft_values(*draft_id), @@ -284,12 +317,9 @@ mod tests { let undone = tracker.undo().expect("an edit to undo"); - assert_eq!(undone.rows, vec![row(1)]); + assert_eq!(undone.changed, vec![row(1)]); assert_eq!(undone.pending_count, 1); - assert_eq!( - tracker.cell_overlay(&row(1), 1, &fetched).value, - Value::Text("b".into()) - ); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(fetched), Value::Text("b".into())); } #[test] @@ -297,9 +327,9 @@ mod tests { let tracker = TabTrackerHandle::default(); let grid_copy = tracker.clone(); - let _ = tracker.apply(TrackerEdit::Delete { - row_key: row(4), - original_values: vec![Value::Int(4)], + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(row(4), vec![Value::Int(4)].into())], + drafts: Vec::new(), }); assert_eq!(grid_copy.row_state(&row(4)), RowState::PendingDelete); @@ -315,12 +345,12 @@ mod tests { value: Value::Int(2), }); - for key in &delta.rows { - let _ = tracker.apply(TrackerEdit::Delete { - row_key: key.clone(), - original_values: vec![Value::Int(1)], + for key in &delta.changed { + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key.clone(), vec![Value::Int(1)].into())], + drafts: Vec::new(), }); - assert_eq!(tracker.cell_overlay(key, 0, &Value::Int(1)).value, Value::Int(2)); + assert_eq!(tracker.cell_overlay(key, 0).shown(Value::Int(1)), Value::Int(2)); } } } diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs index 13fd2c8686..5db29b8fff 100644 --- a/linux/crates/app/src/services/tracker_delta.rs +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -5,14 +5,10 @@ use super::change_tracker::RowKey; #[derive(Debug, Clone, Default, PartialEq)] pub struct TrackerDelta { /// Rows whose pending state changed and need a redraw. - pub rows: Vec, - /// Draft rows the list store has to gain or lose. - pub visual: Vec, + pub changed: Vec, + /// Draft rows the list store gains, oldest first. + pub inserted_drafts: Vec, + /// Draft rows the list store loses. + pub removed_drafts: Vec, pub pending_count: usize, } - -#[derive(Debug, Clone, PartialEq)] -pub enum VisualChange { - RemoveDraft { draft_id: u64 }, - InsertDraft { draft_id: u64 }, -} diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs index 4de95bd657..14bf499224 100644 --- a/linux/crates/app/src/services/tracker_edit.rs +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -1,4 +1,7 @@ +use std::sync::Arc; + use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::RowKey; @@ -14,27 +17,30 @@ pub enum TrackerEdit { DraftCell { draft_id: u64, col: usize, - value: Value, - }, - Insert { - values: Vec, + input: CellInput, }, - Delete { - row_key: RowKey, - original_values: Vec, - }, - DiscardDraft { - draft_id: u64, + /// One undo step however many rows it holds. + InsertDrafts { rows: Vec> }, + /// One Delete: the saved rows are marked, each with the row as it + /// was read, and the drafts are dropped. + DeleteRows { + persisted: Vec<(RowKey, Arc<[Value]>)>, + drafts: Vec, }, } impl TrackerEdit { - /// The row the edit changes. An insert makes a new one. - pub fn row_key(&self) -> Option { + /// The rows the edit changes. An insert makes a new one. + pub fn row_keys(&self) -> Vec { match self { - Self::Cell { row_key, .. } | Self::Delete { row_key, .. } => Some(row_key.clone()), - Self::DraftCell { draft_id, .. } | Self::DiscardDraft { draft_id } => Some(RowKey::Draft(*draft_id)), - Self::Insert { .. } => None, + Self::Cell { row_key, .. } => vec![row_key.clone()], + Self::DraftCell { draft_id, .. } => vec![RowKey::Draft(*draft_id)], + Self::DeleteRows { persisted, drafts } => persisted + .iter() + .map(|(row_key, _)| row_key.clone()) + .chain(drafts.iter().map(|draft_id| RowKey::Draft(*draft_id))) + .collect(), + Self::InsertDrafts { .. } => Vec::new(), } } } diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs index e8e65cda0a..757ca26051 100644 --- a/linux/crates/app/src/services/undo_op.rs +++ b/linux/crates/app/src/services/undo_op.rs @@ -1,4 +1,4 @@ -use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::{CellEdit, DeleteMark, DraftRow, RowKey}; use super::column_relayout::ColumnRelayout; @@ -21,21 +21,27 @@ pub enum UndoOp { DraftCell { draft_id: u64, col: usize, - before: Value, - after: Value, + before: CellInput, + after: CellInput, before_touch: Touch, after_touch: Touch, }, + /// One undo removes every draft the insert made. Insert { - draft: DraftRow, + drafts: Vec, }, Delete { - row_key: RowKey, - before: Option, - after: DeleteMark, + steps: Vec, }, } +#[derive(Debug, Clone, PartialEq)] +pub struct DeleteStep { + pub row_key: RowKey, + pub before: Option, + pub after: DeleteMark, +} + impl UndoOp { /// The step under the columns `layout` moves to, or `None` when a /// value in it has no place there. @@ -71,38 +77,58 @@ impl UndoOp { before_touch, after_touch, } => { - layout.cell(*col, before).ok()?; + layout.input_cell(*col, before).ok()?; Some(UndoOp::DraftCell { draft_id: *draft_id, - col: layout.cell(*col, after).ok()?, + col: layout.input_cell(*col, after).ok()?, before: before.clone(), after: after.clone(), before_touch: *before_touch, after_touch: *after_touch, }) } - UndoOp::Insert { draft } => Some(UndoOp::Insert { - draft: DraftRow { - values: layout.row(&draft.values).ok()?, - ..draft.clone() - }, + UndoOp::Insert { drafts } => Some(UndoOp::Insert { + drafts: drafts + .iter() + .map(|draft| { + Some(DraftRow { + cells: layout.draft(&draft.cells).ok()?, + ..draft.clone() + }) + }) + .collect::>()?, + }), + UndoOp::Delete { steps } => Some(UndoOp::Delete { + steps: steps + .iter() + .map(|step| { + layout.key(&step.row_key).ok()?; + Some(DeleteStep { + row_key: step.row_key.clone(), + before: step.before.as_ref().map(delete_mark), + after: delete_mark(&step.after), + }) + }) + .collect::>()?, }), - UndoOp::Delete { row_key, before, after } => { - layout.key(row_key).ok()?; - Some(UndoOp::Delete { - row_key: row_key.clone(), - before: before.as_ref().map(delete_mark), - after: delete_mark(after), - }) - } } } - pub fn is_about_draft(&self, draft_id: u64) -> bool { + /// Drops what the step says about a draft that is gone, and returns + /// whether anything is left of the step. + pub fn forget_draft(&mut self, draft_id: u64) -> bool { + let gone = RowKey::Draft(draft_id); match self { - UndoOp::Insert { draft } => draft.draft_id == draft_id, - UndoOp::DraftCell { draft_id: id, .. } => *id == draft_id, - UndoOp::CellEdit { row_key, .. } | UndoOp::Delete { row_key, .. } => *row_key == RowKey::Draft(draft_id), + UndoOp::Insert { drafts } => { + drafts.retain(|draft| draft.draft_id != draft_id); + !drafts.is_empty() + } + UndoOp::DraftCell { draft_id: id, .. } => *id != draft_id, + UndoOp::CellEdit { row_key, .. } => *row_key != gone, + UndoOp::Delete { steps } => { + steps.retain(|step| step.row_key != gone); + !steps.is_empty() + } } } } diff --git a/linux/crates/app/src/storage.rs b/linux/crates/app/src/storage.rs index 3c9ec10b98..2c69dd7254 100644 --- a/linux/crates/app/src/storage.rs +++ b/linux/crates/app/src/storage.rs @@ -5,7 +5,7 @@ use tablepro_session::runtime::Tasks; use tablepro_storage::{ConnectionStore, StoragePaths}; use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; -use crate::workspace::WorkspaceStore; +use crate::workspace::{WorkspaceStore, remove_legacy_tab_file}; /// Everything the app persists, resolved once at startup and handed to /// the root component. Nothing reaches for a global, so a test can point @@ -27,7 +27,17 @@ impl AppStorage { let connections = ConnectionStore::new(&paths); let column_widths = ColumnWidthStore::load(paths.column_widths_file(), tasks); let filter_settings = FilterSettingsStore::load(paths.filter_settings_file(), tasks); - let workspaces = WorkspaceStore::start(paths.workspaces_dir(), tasks); + let workspaces = WorkspaceStore::start( + paths.workspaces_dir(), + tablepro_storage::DraftStore::new(paths.drafts_dir()), + tasks, + ); + let state = paths.state.clone(); + drop(tasks.spawn_blocking_task(move || match remove_legacy_tab_file(&state) { + Ok(true) => tracing::info!("removed the tab file an older build left"), + Ok(false) => {} + Err(error) => tracing::warn!(%error, "could not remove the tab file an older build left"), + })); Self { paths, connections, @@ -64,3 +74,28 @@ impl AppStorage { } pub type SharedStorage = Rc; + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_runtime, wait_until}; + + #[gtk4::test] + fn opening_the_storage_removes_the_tab_file_an_older_build_left() { + let root = tempfile::tempdir().expect("a root"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + std::fs::create_dir_all(&paths.state).expect("the state directory"); + let legacy = paths.state.join("workspace_state.json"); + std::fs::write(&legacy, b"{}").expect("the legacy file"); + let runtime = test_runtime(); + + let _storage = AppStorage::new( + paths, + std::sync::Arc::new(tablepro_storage::SecretStore::new(crate::config::secret_schema())), + &runtime.tasks(), + ); + + wait_until(std::time::Duration::from_secs(10), || !legacy.exists()).expect("the legacy file is removed"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index 513597496d..0de2ec87de 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -1,4 +1,7 @@ mod accessible_label; +mod alert_response; +mod fake_clocks; +mod fake_reachability; mod fake_session; mod main_loop; mod memory_settings; @@ -9,6 +12,9 @@ mod wait_timed_out; mod widget_lookup; pub(crate) use accessible_label::assert_labelled; +pub(crate) use alert_response::{click_response, respond}; +pub(crate) use fake_clocks::FakeClocks; +pub(crate) use fake_reachability::{Attached, FakeProbe, attach_remote}; pub(crate) use fake_session::FakeSession; pub(crate) use main_loop::{drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; diff --git a/linux/crates/app/src/test_support/alert_response.rs b/linux/crates/app/src/test_support/alert_response.rs new file mode 100644 index 0000000000..791c491bf7 --- /dev/null +++ b/linux/crates/app/src/test_support/alert_response.rs @@ -0,0 +1,30 @@ +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Answer the presented `dialog` with `response` by clicking its button. +/// Emitting `response` by hand would skip the close a click makes first, +/// and with it the `closed` that reaches handlers before the response. +pub(crate) fn respond(dialog: &adw::AlertDialog, response: &str) { + assert!( + click_response(dialog, response), + "the dialog has no “{response}” button" + ); +} + +/// Click the button for `response` in `dialog`. False when it has none. +pub(crate) fn click_response(dialog: &adw::AlertDialog, response: &str) -> bool { + if !dialog.has_response(response) { + return false; + } + let label = dialog.response_label(response); + let Some(button) = super::descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + else { + return false; + }; + button.emit_clicked(); + true +} diff --git a/linux/crates/app/src/test_support/fake_clocks.rs b/linux/crates/app/src/test_support/fake_clocks.rs new file mode 100644 index 0000000000..bcafafdc4c --- /dev/null +++ b/linux/crates/app/src/test_support/fake_clocks.rs @@ -0,0 +1,25 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use crate::runtime::{ClockSample, ClockSource}; + +/// Clocks the test moves by hand. +#[derive(Clone, Default)] +pub(crate) struct FakeClocks(Rc>); + +impl FakeClocks { + pub(crate) fn advance(&self, boottime: u64, monotonic: u64) { + let now = self.0.get(); + self.0.set(ClockSample { + boottime: now.boottime + Duration::from_secs(boottime), + monotonic: now.monotonic + Duration::from_secs(monotonic), + }); + } +} + +impl ClockSource for FakeClocks { + fn sample(&self) -> ClockSample { + self.0.get() + } +} diff --git a/linux/crates/app/src/test_support/fake_reachability.rs b/linux/crates/app/src/test_support/fake_reachability.rs new file mode 100644 index 0000000000..5f6bb3226e --- /dev/null +++ b/linux/crates/app/src/test_support/fake_reachability.rs @@ -0,0 +1,86 @@ +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use futures::FutureExt; +use futures::channel::oneshot; +use futures::future::LocalBoxFuture; +use tablepro_core::NetworkEndpoint; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::health::Reachability; +use tablepro_session::runtime::Tasks; +use tablepro_session::{SessionClaim, SessionKey, SessionRegistry}; +use tablepro_test_support::{ConnectionScript, FakeReply}; + +use crate::runtime::ReachabilityProbe; +use crate::test_support::FakeSession; + +/// Answers each check with the next scripted reply, or holds it until +/// the test sends one, and records what it was asked. +#[derive(Default)] +pub(crate) struct FakeProbe { + asked: RefCell>, + replies: RefCell>>, +} + +impl FakeProbe { + pub(crate) fn answering(reachability: Reachability) -> Rc { + let probe = Rc::new(Self::default()); + probe.then(reachability); + probe + } + + pub(crate) fn then(&self, reachability: Reachability) { + self.replies + .borrow_mut() + .push_back(futures::future::ready(reachability).boxed_local()); + } + + /// The next check waits for the sender. + pub(crate) fn then_held(&self) -> oneshot::Sender { + let (sender, receiver) = oneshot::channel(); + self.replies + .borrow_mut() + .push_back(receiver.map(|reply| reply.unwrap_or_default()).boxed_local()); + sender + } + + pub(crate) fn asked(&self) -> Vec { + self.asked.borrow().clone() + } +} + +impl ReachabilityProbe for FakeProbe { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability> { + self.asked.borrow_mut().push(endpoint.clone()); + let next = self.replies.borrow_mut().pop_front(); + next.unwrap_or_else(|| futures::future::ready(Reachability::Reachable).boxed_local()) + } +} + +/// A session on `host` port 5432, attached under a key of its own. +pub(crate) struct Attached { + pub fake: FakeSession, + pub key: SessionKey, + _claim: SessionClaim, +} + +pub(crate) fn attach_remote( + registry: &Rc, + host: &str, + reconnects: Vec, + tasks: Tasks, +) -> Attached { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + let request = FakeConnector::network_request(host, 5432).expect("a request"); + let fake = FakeSession::start_for(request, script, reconnects, tasks); + let key = SessionKey::Saved(uuid::Uuid::new_v4()); + let claim = registry.claim(key.clone(), 1).expect("a claim"); + claim.attach(&fake.session); + Attached { + fake, + key, + _claim: claim, + } +} diff --git a/linux/crates/app/src/test_support/fake_session.rs b/linux/crates/app/src/test_support/fake_session.rs index b24f7330cb..84d19437d5 100644 --- a/linux/crates/app/src/test_support/fake_session.rs +++ b/linux/crates/app/src/test_support/fake_session.rs @@ -4,7 +4,7 @@ use tablepro_core::LivenessPolicy; use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; use tablepro_session::operation::DefaultDeadlines; use tablepro_session::runtime::Tasks; -use tablepro_session::{Session, SessionDeps}; +use tablepro_session::{ConnectRequest, Session, SessionDeps}; use tablepro_test_support::{ConnectionScript, FakeConnection}; /// A session over a fake connection, and what each reconnect gets. @@ -22,6 +22,17 @@ impl FakeSession { tasks: Tasks, ) -> Self { let request = FakeConnector::request().expect("a request"); + Self::start_for(request, script, reconnects, tasks) + } + + /// A session opened by `request`, which decides what the network has + /// to reach for it. + pub(crate) fn start_for( + request: ConnectRequest, + script: ConnectionScript, + reconnects: impl IntoIterator, + tasks: Tasks, + ) -> Self { let (pending, connection) = FakeConnector::pending_session(request, script); let connector = FakeConnector::new(reconnects); let session = Session::start( diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs deleted file mode 100644 index 9d152d6341..0000000000 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; - -use relm4::adw::prelude::*; -use relm4::gtk::glib; -use relm4::{ComponentSender, adw}; -use uuid::Uuid; - -use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; -use super::{App, AppMsg, WorkspaceTab, connection}; -use tablepro_session::runtime::ProcessHold; - -use crate::services::active_session::ActiveSession; -use crate::services::session_context::SessionContext; -use crate::workspace::{CloseDecision, InhibitReason}; - -/// What a window that closes still owes: what it shows written to disk, -/// and its connections closed rather than cut off, in order. -pub(super) struct WindowShutdown { - done: Cell, - parts: WindowShutdownParts, -} - -pub(super) struct WindowShutdownParts { - pub settings: Rc, - pub storage: crate::storage::SharedStorage, - pub drafts: Rc, - pub workspace: Rc, - pub tabs: WorkspaceTabs, - pub context: Rc, - pub connect_attempts: Rc, -} - -impl WindowShutdown { - pub(super) fn new(parts: WindowShutdownParts) -> Rc { - Rc::new(Self { - done: Cell::new(false), - parts, - }) - } - - /// Runs once, from the close request, or from the component's - /// shutdown for a window destroyed without one. `window` is the one - /// still on screen, whose size is kept. - pub(super) fn run(&self, window: Option<&adw::ApplicationWindow>) { - if self.done.replace(true) { - return; - } - let parts = &self.parts; - if let Some(window) = window { - crate::ui::window_geometry::persist(window, &parts.settings); - } - self.flush(); - close_connections( - &parts.tabs, - &parts.context.active, - &*parts.context.hold, - &parts.connect_attempts, - ); - } - - /// The last column drag, filter change, editor text and tab list may - /// still be on their way to disk. - fn flush(&self) { - let parts = &self.parts; - let hold = parts.context.hold.acquire(); - let column_widths = parts.storage.column_widths().flush(); - let filter_settings = parts.storage.filter_settings().flush(); - let editor_drafts = parts.drafts.flush_all(); - let tabs = parts.workspace.detach(); - glib::spawn_future_local(async move { - column_widths.await; - filter_settings.await; - for failure in editor_drafts.await { - tracing::warn!(failure, "an editor draft was not saved"); - } - if let Some(tabs) = tabs - && let Err(error) = tabs.await - { - tracing::debug!(%error, "the open tabs were not saved"); - } - drop(hold); - }); - } -} - -/// The tabs go first: each editor tab closes its own connection, which -/// outlives the window's, and every read on the window's session stops. -/// Only then does the session itself close. Every close holds the -/// process until it ends, so quitting does not cut a connection off -/// mid-close. -fn close_connections( - tabs: &WorkspaceTabs, - active: &ActiveSession, - hold: &dyn ProcessHold, - attempts: &tablepro_session::ConnectAttempts, -) { - for tab in tabs.borrow().values() { - match tab { - WorkspaceTab::Editor(slot) => slot.editor.shutdown(), - WorkspaceTab::Structure(slot) => slot.structure.shutdown(), - WorkspaceTab::Table(slot) => slot.browse.shutdown(), - } - } - connection::close_window_session(active, hold, attempts); -} - -impl App { - /// A tab asked to close. One with nothing to lose closes now; any - /// other waits for the prompts. - pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid, sender: ComponentSender) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - if self.close_guards.may_close_tab(&self.workspace_handle, id) { - self.finish_close_workspace_tab(id, &tab_view); - return; - } - self.close_guards - .ask_close_tab(self.window.upcast_ref(), &self.workspace_handle, id, move |decision| { - sender.input(AppMsg::TabCloseDecided { - id, - approved: decision == CloseDecision::Approved, - }); - }); - } - - pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - if approved { - self.finish_close_workspace_tab(id, &tab_view); - return; - } - let page = self.workspace_tabs.borrow().get(&id).map(|tab| match tab { - WorkspaceTab::Editor(slot) => slot.page.clone(), - WorkspaceTab::Structure(slot) => slot.page.clone(), - WorkspaceTab::Table(slot) => slot.page.clone(), - }); - if let Some(page) = page { - tab_view.close_page_finish(&page, false); - } - } - - /// Send `proceed` once every tab may close, for a disconnect or a - /// switch to another connection, which take the tabs with them. - pub(super) fn close_workspace_then(&self, proceed: AppMsg, sender: ComponentSender) { - self.close_guards.begin_teardown(); - if self.close_guards.may_tear_down(&self.workspace_handle) { - sender.input(proceed); - return; - } - self.close_guards - .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { - if decision == CloseDecision::Approved { - sender.input(proceed); - } - }); - } - - /// Logout, and suspend while a save runs, wait for the user while - /// the window holds something they would lose. - pub(super) fn refresh_inhibitor(&mut self) { - let dirty = !self.workspace_handle.dirty_tabs().is_empty(); - let saving = self.workspace_handle.ledger().in_flight() > 0; - self.inhibitor.update(InhibitReason::for_state(dirty, saving)); - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::HashMap; - use std::sync::Arc; - use std::time::Duration; - - use tablepro_core::{EditorSession, TransactionState}; - use tablepro_session::ConnectAttempts; - use tablepro_session::runtime::{CountingHold, HoldToken}; - use tablepro_storage::DraftId; - use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; - - use super::*; - use crate::test_support::{FakeSession, MemorySettings, wait_until}; - use crate::ui::app::window_tabs::WindowTabs; - use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; - use crate::ui::editor::{SqlEditor, SqlEditorInit}; - use crate::workspace::{TabDirectory, TabEntry}; - - const WAIT: Duration = Duration::from_secs(10); - - /// Names each hold it gives out, so a test reads which close began - /// first: every close holds the process from the moment it starts. - struct NamedHold { - name: &'static str, - log: Rc>>, - counting: Rc, - } - - impl ProcessHold for NamedHold { - fn acquire(&self) -> HoldToken { - self.log.borrow_mut().push(self.name); - self.counting.acquire() - } - } - - #[gtk4::test] - fn closing_the_window_names_open_transactions_and_closes_every_connection() { - adw::init().expect("libadwaita initialises under the test backend"); - crate::register_test_resources(); - let runtime = crate::test_support::test_runtime(); - let settings = MemorySettings::new(); - let mut runs = Replies::default(); - runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); - let editor_session = Arc::new(FakeEditorSession::new(runs)); - let mut script = ConnectionScript::default(); - let opened: Arc = editor_session.clone(); - script.open_editor_session.always(FakeReply::ok(opened)); - script.probe.always(FakeReply::ok(())); - let fake = FakeSession::start(script, [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(fake.session.clone()); - let hold = Rc::new(CountingHold::new()); - let log = Rc::new(RefCell::new(Vec::new())); - let named = |name| NamedHold { - name, - log: log.clone(), - counting: hold.clone(), - }; - let editor = SqlEditor::new(SqlEditorInit { - initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), - settings: settings.get().clone(), - history: None, - tasks: runtime.tasks(), - session: active.clone(), - hold: Rc::new(named("editor tab")), - close_timeout: Duration::from_secs(5), - }); - editor.run(); - wait_until(WAIT, || !editor.is_running()).expect("the run to end"); - let view = adw::TabView::new(); - let page = view.append(&editor); - page.set_title("• Query 1"); - let id = Uuid::new_v4(); - write_workspace_tab_id(&page, id); - let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( - id, - WorkspaceTab::Editor(EditorTabSlot { - editor, - page, - draft: DraftId::new(), - }), - )]))); - let directory = WindowTabs::new(tabs.clone()); - directory.set_view(Some(&view)); - - assert_eq!( - directory.tabs(), - [TabEntry { - id, - title: "Query 1".to_owned(), - open_transaction: true, - }] - ); - - log.borrow_mut().clear(); - close_connections(&tabs, &active, &named("window session"), &ConnectAttempts::new()); - - assert_eq!(*log.borrow(), ["editor tab", "window session"]); - assert!(!active.is_connected()); - wait_until(WAIT, || { - editor_session.close_count() == 1 && fake.connection.is_closed() && hold.live() == 0 - }) - .expect("both connections to close under the hold"); - assert!(fake.session.is_shut_down()); - drop(tabs); - crate::test_support::drain_main_context(); - runtime.shutdown(); - } -} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs deleted file mode 100644 index cdfbd702c1..0000000000 --- a/linux/crates/app/src/ui/app/connection.rs +++ /dev/null @@ -1,1459 +0,0 @@ -use std::sync::Arc; - -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{ComponentController, ComponentSender, adw, gtk}; - -use tablepro_core::meta::{ObjectInfo, ObjectScope}; -use tablepro_session::connect::AttemptGeneration; -use tablepro_session::runtime::{ProcessHold, WatchForwarder}; -use tablepro_session::{ - ClaimError, ConnectAttempts, ConnectError, CredentialSource, MonitorSignal, ObjectListSnapshot, PendingSession, - Session, SessionClaim, SessionHealth, SessionKey, -}; -use tablepro_storage::SavedConnection; -use uuid::Uuid; - -use tablepro_session::RequestTarget; -use tablepro_session::connect::StoreConnectionWrites; - -use crate::services::active_session::ActiveSession; -use crate::services::from_session::FromSession; -use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; -use crate::ui::error_text::TlsRemedy; -use crate::ui::file_read::{FileReadError, read_capped_file}; -use crate::ui::ssh_inputs::SshEnvironment; -use crate::workspace::CloseDecision; - -use super::connect_flow::{self, SubmitError}; -use super::{App, AppMsg, qualified_label}; - -/// What a failed connect can be retried from: the connection as it was -/// submitted, and the setting that would fix it. -#[derive(Debug)] -pub(crate) struct ConnectRetry { - pub prefill: ConnectPrefill, - pub remedy: Option, -} - -/// The password the user typed, so editing after a failure does not -/// make them type it again. It stays in memory. -fn typed_password(target: &RequestTarget) -> Option { - match target { - RequestTarget::Network { - credentials: tablepro_core::Credentials::Password { password, .. }, - .. - } => Some(password.clone()), - _ => None, - } -} - -/// A connect that worked, waiting for the window to adopt it. -pub(crate) struct ReadySession { - pub key: SessionKey, - pub pending: PendingSession, - /// What a reconnect reads its secrets from. - pub credentials: Arc, - /// Secrets the keyring refused to keep, one sentence each. - pub warnings: Vec, - /// The attempt it came from, when the window started it, so a late - /// result loses to a newer attempt. - pub generation: Option, -} - -impl std::fmt::Debug for ReadySession { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ReadySession") - .field("key", &self.key) - .field("pending", &self.pending) - .field("generation", &self.generation) - .finish_non_exhaustive() - } -} - -/// What ties the adopted session to this window. Dropping it releases -/// the registry key and stops the watchers that feed the banner and the -/// sidebar. -pub(crate) struct SessionLinks { - key: SessionKey, - _claim: SessionClaim, - _health: WatchForwarder, - /// Feeds the sidebar. Dropped before the session shuts down, so a - /// list read for a session the window let go of never repaints it. - _catalog_watch: WatchForwarder, -} - -fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { - glib::spawn_future_local(future); -} - -/// Deliver each new object list the session's catalog commits for the -/// sidebar's scope. The list on screen when the watch starts is not -/// delivered again, and a refresh that failed keeps the list it had. -pub(super) fn catalog_watch( - session: &Arc, - mut deliver: impl FnMut(FromSession) + 'static, -) -> WatchForwarder { - let objects = session.subscribe_objects(ObjectScope::DefaultSchema); - let origin = Arc::downgrade(session); - let mut shown = objects.borrow().generation; - WatchForwarder::new( - objects, - move |snapshot: ObjectListSnapshot| { - if snapshot.generation != shown { - shown = snapshot.generation; - deliver(FromSession::new(origin.clone(), snapshot)); - } else if let Some(error) = &snapshot.last_error { - tracing::warn!(%error, "the table list could not be read again"); - } - }, - spawn_local, - ) -} - -impl App { - pub(super) fn on_open_connect(&mut self, sender: ComponentSender) { - self.present_connect_dialog(None, None, sender); - } - - /// Show the connect dialog, filled from `prefill` when a connect - /// from it failed, with the focus on `focus`. - fn present_connect_dialog( - &mut self, - prefill: Option, - focus: Option, - sender: ComponentSender, - ) { - let dialog = ConnectDialog::new(ConnectDialogInit { - drivers: self.context.drivers.clone(), - connector: self.context.connector.clone(), - interaction: self.context.attended(), - tasks: self.tasks.clone(), - saved_connections: Arc::from(self.saved_connections.clone()), - remembered_driver: self.settings.connect_dialog_driver(), - ssh_environment: SshEnvironment::detect(), - prefill, - }); - let submitted = sender.clone(); - dialog.connect_submitted(move |_, submission| { - submitted.input(AppMsg::ConnectSubmitted(Box::new(submission))); - }); - let closed = sender; - dialog.connect_closed(move |_| closed.input(AppMsg::DialogClosed)); - dialog.present(Some(&self.window)); - if let Some(field) = focus { - dialog.reveal_field(field); - } - self.dialog = Some(dialog); - } - - /// Connect to what the dialog was filled in with, then save it. The - /// entry is only written once the connect works, so a typo never - /// lands in the connection list. - pub(super) fn on_connect_submission(&mut self, submission: ConnectSubmission, sender: ComponentSender) { - if self.context.active.is_connected() { - let proceed = AppMsg::ConnectSubmissionConfirmed(Box::new(submission)); - self.close_workspace_then(proceed, sender); - return; - } - self.connect_submission(submission, sender); - } - - pub(super) fn connect_submission(&mut self, submission: ConnectSubmission, sender: ComponentSender) { - let ConnectSubmission { request, pending_save } = submission; - self.set_loading_page( - &crate::i18n::gettext("Connecting…"), - &crate::i18n::gettext_f("Opening {name}", &[("name", &request.metadata.name)]), - &sender, - ); - let ticket = self.connect_attempts.begin(); - let generation = ticket.generation(); - let cancel = ticket.cancel().clone(); - let retry = ConnectRetry { - prefill: ConnectPrefill { - connection: pending_save.connection.clone(), - password: typed_password(&request.target), - }, - remedy: None, - }; - let connector = self.context.connector.clone(); - let interaction = self.context.attended(); - let secrets = self.context.secrets.clone(); - let writes = StoreConnectionWrites::new(self.storage.connections().clone(), self.tasks.clone()); - let connecting = async move { - connect_flow::connect_then_save( - &*connector, - request, - interaction, - cancel, - &*secrets, - &writes, - pending_save, - ) - .await - }; - let context = self.context.clone(); - let settings = self.settings.clone(); - crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| { - let failed = |message: String, retry: ConnectRetry| AppMsg::ConnectFailed { - generation, - message: Some(message), - edit: Some(Box::new(retry)), - }; - let message = match outcome { - Ok(Ok((pending, report))) => { - // The dialog opens on this driver next time. - if let Err(error) = settings.set_connect_dialog_driver(&report.saved.driver_id) { - tracing::warn!(%error, "could not remember the connect dialog driver"); - } - AppMsg::SessionReady(Box::new(ReadySession { - key: SessionKey::Saved(report.saved.id), - credentials: Arc::new(context.keyring_credentials(&report.saved)), - pending, - // A keyring refusal must not be silent: the - // connection opened, but the password will be - // asked for next time. - warnings: report - .secret_failures - .iter() - .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) - .collect(), - generation: Some(generation), - })) - } - Ok(Err(SubmitError::Connect(ConnectError::Cancelled))) => AppMsg::ConnectFailed { - generation, - message: None, - edit: None, - }, - Ok(Err(SubmitError::Connect(error))) => failed( - crate::ui::error_text::connect_error_message(&error), - ConnectRetry { - remedy: crate::ui::error_text::connect_remedy(&error), - ..retry - }, - ), - Ok(Err(SubmitError::Save(error))) => failed( - crate::i18n::gettext_f( - "The connection could not be saved: {error}", - &[("error", &error.to_string())], - ), - retry, - ), - Err(failure) => failed(crate::ui::error_text::task_failure_message(&failure), retry), - }; - sender.input(message); - }); - } - - pub(super) fn on_cancel_connect(&mut self) { - stop_connecting(&self.connect_attempts); - self.dismiss_loading_page(); - } - - pub(super) fn on_edit_connection(&mut self, retry: ConnectRetry, sender: ComponentSender) { - let focus = retry.remedy.map(crate::ui::connect_dialog::remedy_field); - self.present_connect_dialog(Some(retry.prefill), focus, sender); - } - - /// Adopt a session that connected: claim its key, start it, and - /// build the workspace on its catalog. - pub(super) fn on_session_ready(&mut self, ready: ReadySession, sender: ComponentSender) { - if ready - .generation - .is_some_and(|generation| !self.connect_attempts.accepts(generation)) - { - self.discard_pending(ready.pending); - return; - } - self.dismiss_loading_page(); - if self.session_links.as_ref().is_some_and(|links| links.key == ready.key) { - self.discard_pending(ready.pending); - self.show_toast(&crate::i18n::gettext("This connection is already open.")); - return; - } - // One window shows one database, so the one open now goes first. - // The switch asked before it connected; this asks again about - // anything edited or begun while it connected. - if self.context.active.is_connected() { - if !self.close_guards.may_tear_down(&self.workspace_handle) { - self.close_guards - .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { - sender.input(match decision { - CloseDecision::Approved => AppMsg::SessionReady(Box::new(ready)), - CloseDecision::Cancelled => AppMsg::SessionDeclined(Box::new(ready)), - }); - }); - return; - } - self.release_session(sender.clone()); - } - let ReadySession { - key, - pending, - credentials, - warnings, - generation: _, - } = ready; - let claim = match self.context.sessions.claim(key.clone(), self.window.id()) { - Ok(claim) => claim, - Err(ClaimError::AlreadyOpen { .. }) => { - self.discard_pending(pending); - self.show_toast(&crate::i18n::gettext( - "This connection is already open in another window.", - )); - return; - } - }; - let driver_id = pending.metadata().driver_id.clone(); - let session = Session::start(pending, self.context.session_deps(credentials)); - claim.attach(&session); - self.context.active.replace(session.clone()); - - let first = session.subscribe_objects(ObjectScope::DefaultSchema).borrow().clone(); - let health_sender = sender.clone(); - let health_origin = Arc::downgrade(&session); - let catalog_sender = sender.clone(); - self.session_links = Some(SessionLinks { - key, - _claim: claim, - _health: WatchForwarder::new( - session.health(), - move |health| { - health_sender.input(AppMsg::HealthChanged(FromSession::new(health_origin.clone(), health))); - }, - spawn_local, - ), - _catalog_watch: catalog_watch(&session, move |update| { - catalog_sender.input(AppMsg::CatalogChanged(update)); - }), - }); - for warning in warnings { - self.show_toast(&warning); - } - self.dialog = None; - self.on_connected(first.objects.to_vec(), driver_id, sender); - } - - /// A connect failed. A cancelled one returns to the list without an - /// error: the user is the one who stopped it. Otherwise the alert - /// offers to edit what was connected to, starting at the setting - /// that fixes a TLS failure when there is one. - pub(super) fn on_connect_failed( - &mut self, - generation: AttemptGeneration, - message: Option, - edit: Option>, - sender: ComponentSender, - ) { - if !self.connect_attempts.accepts(generation) { - return; - } - self.dismiss_loading_page(); - let Some(message) = message else { - return; - }; - tracing::warn!(error = %message, "connect failed"); - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Connect")), Some(&message)); - dialog.add_response("close", &crate::i18n::gettext("Close")); - dialog.set_close_response("close"); - dialog.set_default_response(Some("close")); - if let Some(retry) = edit { - let label = retry - .remedy - .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()); - dialog.add_response("edit", &label); - dialog.set_default_response(Some("edit")); - let retry = std::cell::RefCell::new(Some(retry)); - dialog.connect_response(Some("edit"), move |_, _| { - if let Some(retry) = retry.borrow_mut().take() { - sender.input(AppMsg::EditConnection(retry)); - } - }); - } - dialog.present(Some(&self.window)); - } - - /// Throw away a connection nobody adopted, closing it under the - /// close deadline while the process stays alive for it. - /// The user kept the tabs a switch would have closed. - pub(super) fn on_session_declined(&self, ready: ReadySession) { - self.discard_pending(ready.pending); - } - - fn discard_pending(&self, pending: PendingSession) { - let hold = self.context.hold.acquire(); - let closing = self.context.tasks.spawn_task(pending.discard()); - glib::spawn_future_local(async move { - if let Ok(report) = closing.await { - tracing::debug!(?report, "closed a connection that was not adopted"); - } - drop(hold); - }); - } - - fn on_connected(&mut self, objects: Vec, driver_id: String, sender: ComponentSender) { - self.dismiss_loading_page(); - self.dialog = None; - self.connected = true; - self.current_driver_id = Some(driver_id.clone()); - self.read_only = self.context.active.read_only(); - self.read_only_badge.set_visible(self.read_only); - self.reconnect_banner.set_revealed(false); - self.split_view.set_show_sidebar(true); - self.disconnect_action.set_enabled(true); - self.table_search.set_text(""); - // Build the unified workspace tab tree (Browse + Editor share one - // strip). Empty state shows "Select a table" until the user opens - // a tab via sidebar click or Ctrl+T. - self.ensure_workspace_root(sender.clone()); - self.content_holder.set_content(Some(&self.workspace_outer_stack)); - tracing::info!(driver = %driver_id, table_count = objects.len(), "workspace ready"); - self.show_objects(&objects); - self.refresh_window_title(); - if let Some(key) = self.session_key().cloned() { - self.restore_workspace_tabs(key, sender.clone()); - } - if let Some(connection_id) = self.context.active.metadata().map(|metadata| metadata.id) { - // Stamp `last_opened_at = now()` then reload connections so - // the popover + welcome view re-sort with the fresh - // timestamp. Sequencing matters: ReloadConnections reads - // the JSON; firing it before the touch lands would render - // the previous ordering until the next reload. - let sender_for_touch = sender.clone(); - let connections = self.storage.connections().clone(); - let touching = self - .tasks - .spawn_blocking_task(move || connections.touch_last_opened_blocking(connection_id)); - glib::spawn_future_local(async move { - match touching.await { - Ok(Err(error)) => tracing::warn!(%error, "could not stamp the last-opened time"), - Err(error) => tracing::warn!(%error, "the last-opened task failed"), - Ok(Ok(())) => {} - } - sender_for_touch.input(AppMsg::ReloadConnections); - }); - return; - } - sender.input(AppMsg::ReloadConnections); - } - - /// The key this window's session is claimed under, which its saved - /// tabs are kept by. - pub(super) fn session_key(&self) -> Option<&SessionKey> { - self.session_links.as_ref().map(|links| &links.key) - } - - pub(super) fn on_disconnect(&mut self, sender: ComponentSender) { - self.close_workspace_then(AppMsg::ForceDisconnect, sender); - } - - pub(super) fn do_disconnect(&mut self, sender: ComponentSender) { - self.release_session(sender.clone()); - self.split_view.set_show_sidebar(false); - self.disconnect_action.set_enabled(false); - self.refresh_window_title(); - self.show_welcome_page(sender); - tracing::info!("disconnected"); - } - - /// Close the workspace and shut the session down. The process is - /// held until the close ends, so quitting right after a disconnect - /// does not cut the connection off mid-close. - fn release_session(&mut self, _sender: ComponentSender) { - // The tabs are saved under the session's key, which goes with - // the session links below. - self.teardown_workspace_tabs(); - // The closed tabs name tables of the connection being released. - self.clear_closed_tabs_stack(); - self.session_links = None; - close_session(&self.context.active, &*self.context.hold); - self.reconnect_banner.set_revealed(false); - self.current_driver_id = None; - self.read_only = false; - self.read_only_badge.set_visible(false); - self.connected = false; - self.table_search.set_text(""); - self.sidebar_schemas.borrow_mut().clear(); - self.sidebar_factory.guard().clear(); - } - - /// The banner's Retry: the only thing that wakes a parked session. - pub(super) fn on_retry_connection(&self) { - if let Some(session) = self.context.active.get() { - session.signal(MonitorSignal::RetryNow); - } - } - - pub(super) fn on_reload_connections(&self, sender: ComponentSender) { - let store = self.storage.connections().clone(); - let reading = self.tasks.spawn_blocking_task(move || store.load_blocking()); - glib::spawn_future_local(async move { - match reading.await { - Ok(Ok(connections)) => sender.input(AppMsg::ConnectionsLoaded(connections.to_vec())), - Ok(Err(error)) => { - tracing::warn!(%error, "the saved connections could not be read"); - sender.input(AppMsg::ConnectionListUnavailable); - } - Err(failure) => tracing::warn!(%failure, "the connections read task failed"), - } - }); - } - - pub(super) fn on_connections_loaded(&mut self, connections: &[SavedConnection], sender: ComponentSender) { - self.saved_connections = connections.to_vec(); - // The popover shows the same list as the welcome page, so it - // sorts the same way and reads the same group headers. - crate::ui::connection_list::sort(&mut self.saved_connections); - let groups = crate::ui::connection_list::groups(&self.saved_connections); - let mut guard = self.connections_factory.guard(); - guard.clear(); - for saved in &self.saved_connections { - guard.push_back(crate::ui::connection_row::ConnectionRowInit { - saved: saved.clone(), - groups: groups.clone(), - }); - } - drop(guard); - crate::ui::connection_list::install_group_headers( - &self.connections_listbox, - std::rc::Rc::new(self.saved_connections.clone()), - ); - let _ = self - .welcome_view - .sender() - .send(crate::ui::welcome_view::WelcomeViewInput::SetConnections( - self.saved_connections.clone(), - )); - if !self.connected { - self.show_welcome_page(sender); - } - } - - pub(super) fn on_delete_connection(&self, id: Uuid, sender: ComponentSender) { - // The delete takes the keyring secrets with it and has no undo, - // so it asks whatever the preferences say. - let connection_name = self - .saved_connections - .iter() - .find(|s| s.id == id) - .map(|s| s.name.clone()) - .unwrap_or_else(|| crate::i18n::gettext("this connection")); - let title = crate::i18n::gettext_f("Delete {name}?", &[("name", &connection_name)]); - let body = crate::i18n::gettext( - "The saved entry, any stored passwords and the queries saved under this connection \ - will be removed. This cannot be undone.", - ); - let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); - 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_for_response = sender; - let connections_for_response = self.storage.connections().clone(); - let secrets_for_response = self.storage.secrets().clone(); - let column_widths = self.storage.column_widths().clone(); - let filter_settings = self.storage.filter_settings().clone(); - let tasks_for_response = self.tasks.clone(); - let saved_queries_for_response = self.history.store().map(|history| history.saved_queries()); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); - if response != "delete" { - return; - } - // Nothing can reach these once the entry is gone, so they - // go with it rather than accumulating in the state files. - column_widths.forget_connection(id); - filter_settings.forget_connection(id); - execute_delete_connection( - connections_for_response.clone(), - secrets_for_response.clone(), - tasks_for_response.clone(), - saved_queries_for_response.clone(), - id, - sender_for_response.clone(), - ); - }); - dialog.present(Some(&self.window)); - } - - /// Copy a saved connection so a second database on the same server - /// does not have to be typed out again. - /// - /// The copy takes a new id, so it carries its own secrets rather - /// than sharing the original's: removing either then leaves the - /// other able to connect. - pub(super) fn on_duplicate_connection(&self, source: SavedConnection, sender: ComponentSender) { - self.connections_popover.popdown(); - let copy = tablepro_storage::duplicate(&source, &self.saved_connections); - let name = copy.name.clone(); - let connections = self.storage.connections().clone(); - let secrets = self.storage.secrets().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let copy_id = copy.id; - let saved = tasks - .spawn_blocking_task(move || connections.upsert_blocking(copy)) - .await; - if let Ok(Err(error)) = saved { - tracing::warn!(%error, "could not save the duplicated connection"); - sender_clone.input(AppMsg::ShowToast(crate::i18n::gettext( - "The connection could not be duplicated.", - ))); - return; - } - // A copy with no password still connects once the - // user types one, so a keyring that refuses is - // reported rather than undoing the copy. - let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; - sender_clone.input(AppMsg::ReloadConnections); - sender_clone.input(AppMsg::ShowToast(if carried { - crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) - } else { - crate::i18n::gettext_f( - "Duplicated as {name}. Its password could not be copied, so enter it again.", - &[("name", &name)], - ) - })); - })); - } - - /// File a connection under a group, or take it out of the one it - /// is in. An empty name means no group. - pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option, sender: ComponentSender) { - let group = group.map(|name| name.trim().to_owned()).filter(|name| !name.is_empty()); - let Some(mut saved) = self.saved_connections.iter().find(|saved| saved.id == id).cloned() else { - return; - }; - if saved.group == group { - return; - } - saved.group = group; - self.write_connection(saved, crate::i18n::gettext("The group could not be saved."), sender); - } - - /// Put a colour on a connection, or take the one it has off. - /// - /// The whole entry is rewritten because that is what the store - /// takes, and it comes from the list the app already holds rather - /// than from a re-read: the row that asked is showing that entry. - pub(super) fn on_set_connection_color( - &self, - id: Uuid, - color: Option, - sender: ComponentSender, - ) { - let Some(mut saved) = self.saved_connections.iter().find(|saved| saved.id == id).cloned() else { - return; - }; - if saved.color == color { - return; - } - saved.color = color; - self.write_connection(saved, crate::i18n::gettext("The colour could not be saved."), sender); - } - - /// Write one connection back and reload the list from disk, so both - /// connection lists redraw from what was actually saved. - fn write_connection(&self, saved: SavedConnection, failure: String, sender: ComponentSender) { - let connections = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let written = tasks - .spawn_blocking_task(move || connections.upsert_blocking(saved)) - .await; - if let Ok(Err(error)) = written { - tracing::warn!(%error, "could not save the connection"); - sender_clone.input(AppMsg::ShowToast(failure)); - } - sender_clone.input(AppMsg::ReloadConnections); - })); - } - - /// Write the connection list to a file the user picks. - /// - /// The file carries no passwords, and the dialog's name says what - /// it does carry, because it is going somewhere the keyring does - /// not protect. - pub(super) fn on_export_connections(&self, sender: ComponentSender) { - self.connections_popover.popdown(); - let connections = self.storage.connections().clone(); - let reading = self.tasks.spawn_blocking_task(move || connections.export_blocking()); - let window = self.window.downgrade(); - let input = sender.input_sender().clone(); - glib::spawn_future_local(async move { - let error = match reading.await { - Ok(Ok(bytes)) => { - if let Some(window) = window.upgrade() { - ask_where_to_export(&window, bytes, input); - } - return; - } - Ok(Err(error)) => error.to_string(), - Err(error) => error.to_string(), - }; - tracing::warn!(%error, "could not read the connections to export"); - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( - "The connections could not be read.", - ))); - }); - } - - /// Ask for an exported file and read it off the GTK thread. - pub(super) fn on_import_connections(&self, sender: ComponentSender) { - self.connections_popover.popdown(); - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Import Connections")) - .modal(true) - .default_filter(&json_filter()) - .filters(&json_filters()) - .build(); - let input = sender.input_sender().clone(); - dialog.open(Some(&self.window), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - let input = input.clone(); - glib::spawn_future_local(async move { - let path = file.path().unwrap_or_default(); - let _ = input.send(match read_connections_file(&file).await { - Ok(bytes) => AppMsg::ConnectionsFileRead { path, bytes }, - Err(message) => AppMsg::ShowToast(message), - }); - }); - }); - } - - pub(super) fn on_connections_file_read( - &self, - path: std::path::PathBuf, - bytes: Vec, - sender: ComponentSender, - ) { - let connections = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let imported = tasks - .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) - .await; - let message = match imported { - Ok(Ok(report)) if report.total() == 0 => crate::i18n::gettext("That file has no connections in it."), - Ok(Ok(report)) => crate::i18n::ngettext_f( - "Imported {n} connection.", - "Imported {n} connections.", - report.total() as u32, - &[("n", &report.total().to_string())], - ), - Ok(Err(error)) => { - tracing::warn!(%error, "could not import the connections"); - crate::i18n::gettext("That file is not a TablePro connection export.") - } - Err(error) => { - tracing::warn!(%error, "the import task did not finish"); - crate::i18n::gettext("The connections could not be imported.") - } - }; - sender_clone.input(AppMsg::ReloadConnections); - sender_clone.input(AppMsg::ShowToast(message)); - })); - } - - pub(super) fn on_open_saved(&mut self, saved: SavedConnection, sender: ComponentSender) { - self.connections_popover.popdown(); - let key = SessionKey::Saved(saved.id); - if self.session_links.as_ref().is_some_and(|links| links.key == key) { - self.show_toast(&crate::i18n::gettext("This connection is already open.")); - return; - } - if self.context.active.is_connected() { - self.close_workspace_then(AppMsg::OpenSavedConfirmed(saved), sender); - return; - } - self.connect_saved(saved, sender); - } - - pub(super) fn connect_saved(&mut self, saved: SavedConnection, sender: ComponentSender) { - let key = SessionKey::Saved(saved.id); - self.set_loading_page( - &crate::i18n::gettext("Connecting…"), - &crate::i18n::gettext_f("Opening {name}", &[("name", &saved.name)]), - &sender, - ); - let ticket = self.connect_attempts.begin(); - let generation = ticket.generation(); - let cancel = ticket.cancel().clone(); - let drivers = self.context.drivers.clone(); - let connector = self.context.connector.clone(); - let interaction = self.context.attended(); - let credentials = self.context.keyring_credentials(&saved); - let source: Arc = Arc::new(credentials.clone()); - let retry = ConnectRetry { - prefill: ConnectPrefill { - connection: saved.clone(), - password: None, - }, - remedy: None, - }; - // Reading the keyring, asking for what it lacks and connecting - // all happen off the GTK thread; only the outcome comes back. - let connecting = async move { - connect_flow::connect_saved(&saved, &drivers, &credentials, &*connector, interaction, cancel).await - }; - crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| match outcome { - Ok(Ok((pending, refused))) => sender.input(AppMsg::SessionReady(Box::new(ReadySession { - key, - pending, - credentials: source, - warnings: refused - .iter() - .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) - .collect(), - generation: Some(generation), - }))), - Ok(Err(ConnectError::Cancelled)) => sender.input(AppMsg::ConnectFailed { - generation, - message: None, - edit: None, - }), - Ok(Err(error)) => sender.input(AppMsg::ConnectFailed { - generation, - message: Some(crate::ui::error_text::connect_error_message(&error)), - edit: Some(Box::new(ConnectRetry { - remedy: crate::ui::error_text::connect_remedy(&error), - ..retry - })), - }), - Err(failure) => sender.input(AppMsg::ConnectFailed { - generation, - message: Some(crate::ui::error_text::task_failure_message(&failure)), - edit: Some(Box::new(retry)), - }), - }); - } - - pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { - self.repopulate_sidebar(browsable(objects.to_vec())); - } - - /// Only what the grid can page through goes in the sidebar. - fn repopulate_sidebar(&mut self, objects: Vec) { - { - let mut schemas = self.sidebar_schemas.borrow_mut(); - schemas.clear(); - schemas.extend(objects.iter().map(|object| object.schema.clone())); - } - let mut guard = self.sidebar_factory.guard(); - guard.clear(); - for object in objects { - guard.push_back(object); - } - drop(guard); - self.sidebar_factory.widget().invalidate_headers(); - } - - /// Surfaces session health via `adw::Banner` only when degraded: - /// GNOME apps reserve banners for abnormal, user-actionable states. - pub(super) fn refresh_health_banner(&self, health: &SessionHealth) { - match health_banner_text(health) { - Some(title) => { - self.reconnect_banner.set_title(&title); - self.reconnect_banner.set_revealed(true); - } - None => self.reconnect_banner.set_revealed(false), - } - } - - pub(super) fn refresh_window_title(&self) { - // The table is in the tab title already, so the subtitle leaves - // it out, as GNOME Builder's names only the branch. - let metadata = self.context.active.metadata(); - let connection_name = metadata.as_ref().map(|m| m.name.as_str()); - let active = self.selected_browse_slot_table(); - let table_pair = active.as_ref().map(|(s, t)| (s.as_deref(), t.as_str())); - let (mut os_title, subtitle) = match (connection_name, &self.current_driver_id, table_pair) { - (Some(name), Some(driver), Some((schema, table))) => { - let label = qualified_label(schema, table); - (format!("{label} · {name} — TablePro"), format!("{name} · {driver}")) - } - (Some(name), Some(driver), None) => (format!("{name} — TablePro"), format!("{name} · {driver}")), - (None, Some(driver), _) => (format!("{driver} — TablePro"), driver.clone()), - _ => ("TablePro".to_string(), String::new()), - }; - // GNOME Text Editor convention: prefix the OS-level window - // title with "• " when any open document has unsaved changes, - // so the dirty state is visible from the Activities overview / - // Alt-Tab without needing the tab to be focused. - if !self.dirty_tabs().is_empty() { - os_title = format!("• {os_title}"); - } - self.window.set_title(Some(&os_title)); - self.window_title.set_subtitle(&subtitle); - - // The sidebar's header has no subtitle: the main header already - // names the driver. - match connection_name { - Some(name) => { - self.sidebar_title.set_title(name); - } - None => { - self.sidebar_title.set_title(&crate::i18n::gettext("Tables")); - } - } - } -} - -/// Supersede the connect in flight, not only cancel it: a driver -/// blocked in a socket call can still connect afterwards, and that late -/// session must not be adopted. -fn stop_connecting(attempts: &ConnectAttempts) { - drop(attempts.begin()); - attempts.cancel(); -} - -/// Shut the window's session down and close its connection under the -/// close deadline. The process is held until the close ends, so the -/// server sees the connection closed rather than cut off. -fn close_session(active: &ActiveSession, hold: &dyn ProcessHold) { - let Some(session) = active.take() else { - return; - }; - let hold = hold.acquire(); - let closing = session.begin_shutdown(); - glib::spawn_future_local(async move { - match closing.await { - Ok(report) => tracing::debug!(?report, "the session closed"), - Err(failure) => tracing::warn!(%failure, "the session close task failed"), - } - drop(hold); - }); -} - -/// What a closing window still owes its connection: no connect left to -/// land in it, and the session closed rather than dropped. -/// -/// The window's close request is where this has to start. relm4 calls -/// a detached root's `shutdown` only after the main loop has returned, -/// when no close can run any more and a hold no longer keeps the -/// process alive. -pub(super) fn close_window_session(active: &ActiveSession, hold: &dyn ProcessHold, attempts: &ConnectAttempts) { - stop_connecting(attempts); - close_session(active, hold); -} - -/// Move every secret the original holds onto the copy's own id. -/// -/// Returns whether all of them made it: a keyring that is locked or -/// absent is a reason to tell the user, not to refuse the copy. -async fn copy_secrets( - secrets: &dyn tablepro_core::credentials::SecretVault, - from: Uuid, - to: Uuid, - label: &str, -) -> bool { - use tablepro_core::credentials::{SecretKind, SecretLookup}; - - let mut carried = true; - for kind in SecretKind::ALL { - match secrets.load(from, kind).await { - Ok(SecretLookup::Found(secret)) => { - if let Err(error) = secrets.store(to, kind, &secret, label).await { - tracing::warn!(%error, ?kind, "could not copy a secret onto the duplicate"); - carried = false; - } - } - // Nothing stored for this kind, so nothing to carry. - Ok(SecretLookup::NotStored) => {} - Err(error) => { - tracing::warn!(%error, ?kind, "could not read a secret to duplicate"); - carried = false; - } - } - } - carried -} - -fn execute_delete_connection( - connections: tablepro_storage::ConnectionStore, - secrets: std::sync::Arc, - tasks: tablepro_session::runtime::Tasks, - saved_queries: Option, - id: Uuid, - sender: ComponentSender, -) { - let sender_clone = sender.clone(); - let spawner = tasks.clone(); - drop(spawner.spawn_task(async move { - // Secrets first: an entry with no secrets is recoverable, - // a secret with no entry is orphaned in the keyring. - if let Err(error) = secrets.delete_connection(id).await { - // Keep the entry: an entry with no secrets can be - // fixed, a secret with no entry is orphaned. - tracing::warn!(%error, "could not delete the connection secrets; keeping the entry"); - sender_clone.input(AppMsg::SecretDeleteFailed(id)); - return; - } - let removed = tasks.spawn_blocking_task(move || connections.remove_blocking(id)).await; - if let Ok(Err(error)) = removed { - tracing::warn!(%error, "could not remove the saved connection"); - } - // The queries saved here name tables only this database - // has, so they go with it. A failure leaves them listed - // under the connection's last name, which the user can - // still delete one by one. - if let Some(saved_queries) = saved_queries - && let Err(error) = saved_queries.delete_for_connection(id).await - { - tracing::warn!(%error, "could not remove the connection's saved queries"); - } - sender_clone.input(AppMsg::ReloadConnections); - })); -} - -impl App { - /// The list could not be read, so the user is told rather than shown - /// an empty welcome view that looks like their connections vanished. - pub(super) fn show_connection_list_banner(&self) { - let Some(content) = - crate::ui::connection_list_banner::banner_content(&self.storage.connections().snapshot().state) - else { - self.connection_list_banner.set_revealed(false); - return; - }; - self.connection_list_banner.set_title(&content.title); - self.connection_list_banner - .set_button_label(Some(&content.button_label)); - self.connection_list_banner.set_revealed(true); - } - - /// The keyring refused, so the entry stays and the user is offered - /// another go rather than being left with orphaned secrets. - pub(super) fn on_secret_delete_failed(&self, id: Uuid, sender: ComponentSender) { - let toast = adw::Toast::new(&crate::i18n::gettext( - "The stored passwords could not be deleted, so the connection was kept.", - )); - toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); - let retry_sender = sender; - toast.connect_button_clicked(move |_| { - retry_sender.input(AppMsg::DeleteConnection(id)); - }); - self.toast_overlay.add_toast(toast); - } - - pub(super) fn on_reset_connection_list(&self, sender: ComponentSender) { - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Reset Saved Connections?")), - Some(&crate::i18n::gettext( - "The unreadable file will be renamed so it can be recovered. Stored passwords stay in the keyring.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("reset", &crate::i18n::gettext("Reset")); - dialog.set_response_appearance("reset", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - - let store = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let banner = self.connection_list_banner.clone(); - let toasts = self.toast_overlay.clone(); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); - if response != "reset" { - return; - } - let store = store.clone(); - let resetting = - tasks.spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); - let (banner, toasts, sender) = (banner.clone(), toasts.clone(), sender.clone()); - glib::spawn_future_local(async move { - let error = match resetting.await { - Ok(Ok(moved)) => { - banner.set_revealed(false); - toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( - &moved, - ))); - sender.input(AppMsg::ReloadConnections); - return; - } - Ok(Err(error)) => error.to_string(), - Err(error) => error.to_string(), - }; - tracing::warn!(%error, "could not reset the saved connections"); - toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( - "Could not reset the saved connections: {error}", - &[("error", &error)], - ))); - }); - }); - dialog.present(Some(&self.window)); - } -} - -fn ask_where_to_export(window: &adw::ApplicationWindow, bytes: Vec, input: relm4::Sender) { - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Export Connections")) - .modal(true) - .initial_name("tablepro-connections.json") - .default_filter(&json_filter()) - .filters(&json_filters()) - .build(); - dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - glib::spawn_future_local(async move { - let written = file - .replace_contents_future(bytes, None, false, gio::FileCreateFlags::NONE) - .await; - let message = match written { - Ok(_) => crate::i18n::gettext("Connections exported."), - Err((_, error)) => { - tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); - crate::i18n::gettext("The connections could not be written to that file.") - } - }; - let _ = input.send(AppMsg::ShowToast(message)); - }); - }); -} - -/// An export holds names, hosts and options, never secrets, so even -/// thousands of connections come to well under this. -const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; - -/// The chosen export, whole, or what to tell the user instead. -async fn read_connections_file(file: &gio::File) -> Result, String> { - read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) - .await - .map_err(|error| match error { - FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), - FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), - FileReadError::Io(error) => { - tracing::warn!(%error, "could not read the file to import"); - crate::i18n::gettext("That file could not be read.") - } - }) -} - -/// The filter both file dialogs open with. Built fresh each time -/// because a GtkFileFilter belongs to the dialog it is given to. -fn json_filter() -> gtk::FileFilter { - let filter = gtk::FileFilter::new(); - filter.set_name(Some(&crate::i18n::gettext("JSON files"))); - filter.add_suffix("json"); - filter.add_mime_type("application/json"); - filter -} - -fn json_filters() -> gio::ListStore { - let filters = gio::ListStore::new::(); - filters.append(&json_filter()); - filters -} - -/// Only what the grid can page through: routines and sequences have no -/// rows to show. -fn browsable(objects: Vec) -> Vec { - objects - .into_iter() - .filter(|object| object.kind.is_browsable()) - .collect() -} - -/// The banner's sentence for a session that needs attention, `None` -/// while it is online. -fn health_banner_text(health: &SessionHealth) -> Option { - match health { - SessionHealth::Online => None, - SessionHealth::Reconnecting { attempt } => Some(crate::i18n::gettext_f( - "Connection lost. Reconnecting (attempt {n}).", - &[("n", &attempt.to_string())], - )), - SessionHealth::Offline => Some(crate::i18n::gettext( - "The server cannot be reached. TablePro will keep checking.", - )), - SessionHealth::Failed { cause } => Some(failed_banner_text(*cause)), - } -} - -fn failed_banner_text(cause: tablepro_session::FailureCause) -> String { - use tablepro_session::FailureCause; - match cause { - FailureCause::Authentication => { - crate::i18n::gettext("The server refused the login. Retry to enter the password again.") - } - FailureCause::Tls => crate::i18n::gettext("The secure connection failed. Check the connection's TLS settings."), - FailureCause::Configuration => { - crate::i18n::gettext("The connection settings no longer work. Check them and retry.") - } - FailureCause::HostKeyUnknown => crate::i18n::gettext("The SSH server's host key is not trusted yet."), - FailureCause::HostKeyChanged => crate::i18n::gettext( - "The SSH server's host key changed. Check with its administrator before connecting again.", - ), - FailureCause::HostKeyRevoked => crate::i18n::gettext("The SSH server's host key was revoked."), - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::VecDeque; - use std::rc::Rc; - use std::time::Duration; - - use tablepro_core::LivenessPolicy; - use tablepro_core::meta::ObjectKind; - use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; - use tablepro_session::operation::DefaultDeadlines; - use tablepro_session::runtime::CountingHold; - use tablepro_session::{FailureCause, SessionDeps}; - use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; - - use super::*; - use crate::test_support::FakeSession; - - fn read(path: &std::path::Path) -> Result, String> { - let file = gio::File::for_path(path); - crate::test_support::finish_on_main( - Duration::from_secs(10), - async move { read_connections_file(&file).await }, - ) - .expect("the read to end") - } - - #[gtk4::test] - fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - let made = std::process::Command::new("mkfifo") - .arg(&path) - .status() - .expect("mkfifo"); - assert!(made.success()); - - let refused = read(&path).expect_err("a refusal"); - - assert!(refused.contains("regular files"), "{refused}"); - } - - #[gtk4::test] - fn an_import_past_the_limit_is_refused() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); - - let refused = read(&path).expect_err("a refusal"); - - assert!(refused.contains("too large"), "{refused}"); - } - - #[gtk4::test] - fn an_export_is_read_whole() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); - - assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); - } - - #[gtk4::test] - fn a_closing_window_closes_its_session_and_stops_its_connect() { - let runtime = crate::test_support::test_runtime(); - let (pending, connection) = FakeConnector::pending_session( - FakeConnector::request().expect("a request"), - ConnectionScript::default(), - ); - let session = Session::start( - pending, - SessionDeps { - connector: Arc::new(FakeConnector::new([])), - credentials: Arc::new(FakeCredentialSource::new()), - deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), - tasks: runtime.tasks(), - prompter: None, - }, - ); - let active = ActiveSession::new(); - active.replace(session.clone()); - let hold = CountingHold::new(); - let attempts = ConnectAttempts::new(); - let connecting = attempts.begin(); - - close_window_session(&active, &hold, &attempts); - - assert!(connecting.is_cancelled(), "the connect in flight kept running"); - assert!( - !attempts.accepts(connecting.generation()), - "a late connect would still be adopted" - ); - assert!(!active.is_connected()); - assert_eq!(hold.live(), 1, "nothing kept the process alive for the close"); - crate::test_support::wait_until(Duration::from_secs(5), || hold.live() == 0).expect("the close to end"); - assert!(session.is_shut_down()); - assert!(connection.is_closed(), "the connection was cut off rather than closed"); - } - - fn names(snapshot: &ObjectListSnapshot) -> Vec { - snapshot.objects.iter().map(|object| object.name.clone()).collect() - } - - /// The window's input queue, holding what the catalog watch posted - /// until the window handles it. - type Queue = Rc>>>; - - fn post_to(queue: &Queue) -> impl FnMut(FromSession) + 'static { - let queue = queue.clone(); - move |update| queue.borrow_mut().push_back(update) - } - - /// Handle the queued lists the way the window's `CatalogChanged` does. - fn handle(queue: &Queue, active: &ActiveSession, sidebar: &mut Vec) { - let queued: Vec<_> = queue.borrow_mut().drain(..).collect(); - for update in queued { - if let Some(snapshot) = update.if_current(active) { - *sidebar = names(&snapshot); - } - } - } - - fn refresh_objects( - session: &Arc, - runtime: &crate::runtime::AppRuntime, - ) -> impl Future + use<> { - let session = session.clone(); - let refreshing = runtime - .tasks() - .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }); - async move { - let _ = refreshing.await; - } - } - - #[gtk4::test] - fn late_catalog_commit_after_disconnect_leaves_sidebar_untouched() { - let runtime = crate::test_support::test_runtime(); - let mut first = ConnectionScript::default(); - first.list_objects.always(FakeReply::Late { - after: Duration::from_millis(300), - outcome: Ok(vec![object("from_a", ObjectKind::Table)]), - }); - let a = FakeSession::start(first, [], runtime.tasks()); - let mut second = ConnectionScript::default(); - second - .list_objects - .always(FakeReply::ok(vec![object("from_b", ObjectKind::Table)])); - let b = FakeSession::start(second, [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(a.session.clone()); - let queue = Queue::default(); - let mut sidebar = Vec::new(); - let watching_a = catalog_watch(&a.session, post_to(&queue)); - let late = refresh_objects(&a.session, &runtime); - let counters = a.connection.counters(); - crate::test_support::wait_until(Duration::from_secs(5), || { - CallCounters::get(&counters.list_objects) == 1 - }) - .expect("A's list was asked for"); - - // Disconnect A and adopt B, in the order the window does. - drop(watching_a); - drop(a.session.begin_shutdown()); - active.replace(b.session.clone()); - let _watching_b = catalog_watch(&b.session, post_to(&queue)); - let refreshed_b = refresh_objects(&b.session, &runtime); - crate::test_support::finish_on_main(Duration::from_secs(5), async move { - refreshed_b.await; - late.await; - }) - .expect("both lists to land"); - crate::test_support::drain_main_context(); - handle(&queue, &active, &mut sidebar); - - assert_eq!( - names(&a.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), - ["from_a"], - "A's late list never landed, so the test proved nothing" - ); - assert_eq!(sidebar, ["from_b"]); - } - - #[gtk4::test] - fn a_list_queued_before_a_switch_is_not_shown() { - let runtime = crate::test_support::test_runtime(); - let mut first = ConnectionScript::default(); - first - .list_objects - .always(FakeReply::ok(vec![object("from_a", ObjectKind::Table)])); - let a = FakeSession::start(first, [], runtime.tasks()); - let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(a.session.clone()); - let queue = Queue::default(); - let mut sidebar = vec!["from_b".to_owned()]; - let watching_a = catalog_watch(&a.session, post_to(&queue)); - crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&a.session, &runtime)) - .expect("A's list to land"); - crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) - .expect("A's list was posted to the window"); - - // B's SessionReady was ahead of A's list in the queue. - drop(watching_a); - drop(a.session.begin_shutdown()); - active.replace(b.session.clone()); - handle(&queue, &active, &mut sidebar); - - assert_eq!(sidebar, ["from_b"], "A's list replaced B's sidebar"); - } - - fn object(name: &str, kind: ObjectKind) -> ObjectInfo { - ObjectInfo { - schema: Some("public".to_owned()), - name: name.to_owned(), - kind, - } - } - - #[test] - fn the_sidebar_lists_only_what_the_grid_can_page() { - let listed = browsable(vec![ - object("users", ObjectKind::Table), - object("active_users", ObjectKind::View), - object("next_id", ObjectKind::Sequence), - object("refresh", ObjectKind::Function), - ]); - - let names: Vec<&str> = listed.iter().map(|object| object.name.as_str()).collect(); - assert_eq!(names, ["users", "active_users"]); - } - - #[test] - fn the_banner_shows_only_when_the_session_needs_attention() { - assert_eq!(health_banner_text(&SessionHealth::Online), None); - let reconnecting = health_banner_text(&SessionHealth::Reconnecting { attempt: 3 }).expect("a banner"); - assert!(reconnecting.contains('3'), "{reconnecting}"); - assert!(health_banner_text(&SessionHealth::Offline).is_some()); - } - - #[test] - fn each_parked_cause_has_its_own_banner() { - let causes = [ - FailureCause::Authentication, - FailureCause::Tls, - FailureCause::Configuration, - FailureCause::HostKeyUnknown, - FailureCause::HostKeyChanged, - FailureCause::HostKeyRevoked, - ]; - - let texts: std::collections::HashSet = causes - .into_iter() - .map(|cause| health_banner_text(&SessionHealth::Failed { cause }).expect("a banner")) - .collect(); - - assert_eq!(texts.len(), causes.len()); - } -} diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs deleted file mode 100644 index 1564c8fad1..0000000000 --- a/linux/crates/app/src/ui/app/mod.rs +++ /dev/null @@ -1,1462 +0,0 @@ -mod close_flow; -mod connect_flow; -mod connection; -mod import; -mod row_ops; -mod saves; -mod sidebar_section_header; -mod status_pages; -mod structure; -mod window_tabs; -mod workspace_persistence; -mod workspace_tabs; - -use relm4::adw::prelude::*; -use relm4::factory::FactoryVecDeque; -use relm4::gtk::{gio, glib}; -use relm4::prelude::*; -use relm4::{Controller, adw, gtk}; - -use tablepro_core::QueryResult; -use tablepro_session::{ObjectListSnapshot, SessionHealth}; -use tablepro_storage::SavedConnection; -use uuid::Uuid; - -use super::browse_tab::{BrowseTab, BrowseTabInput}; -use super::connect_dialog::ConnectDialog; -use super::connection_row::{ConnectionRow, ConnectionRowOutput}; -use super::editor::SqlEditor; -use super::history_dialog::HistoryDialog; -use super::sidebar_row::{SidebarRow, SidebarRowOutput}; -use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; -use crate::services::change_tracker_registry::ChangeTrackerRegistry; -use crate::services::from_session::FromSession; -use crate::services::session_context::SessionContext; -use crate::services::structure_tracker_registry::StructureTrackerRegistry; - -pub(crate) use connection::{ConnectRetry, ReadySession}; - -/// What `run()` hands the root component: the session services and the -/// settings it opened before GTK started. -pub struct AppInit { - pub context: std::rc::Rc, - /// Credential prompts waiting for a window to show them in. - pub prompts: crate::ui::credential_prompt::PromptQueue, - pub settings: std::rc::Rc, - pub storage: crate::storage::SharedStorage, - pub tasks: tablepro_session::runtime::Tasks, - pub history: crate::services::history_service::HistoryService, -} - -pub struct App { - context: std::rc::Rc, - /// The adopted session's claim and watchers. Dropping it releases - /// the registry key and stops the banner and sidebar updates. - session_links: Option, - /// Ends the credential prompt loop with the window. - prompt_presenter: glib::JoinHandle<()>, - /// Which connect attempt is current, so a late result is discarded. - /// Shared with the close handler, which stops the one in flight. - connect_attempts: std::rc::Rc, - settings: std::rc::Rc, - storage: crate::storage::SharedStorage, - tasks: tablepro_session::runtime::Tasks, - history: crate::services::history_service::HistoryService, - drafts: std::rc::Rc, - window: adw::ApplicationWindow, - split_view: adw::OverlaySplitView, - window_title: adw::WindowTitle, - sidebar_title: adw::WindowTitle, - disconnect_action: gio::SimpleAction, - /// Watches `HistoryService` and flips `win.show-history`. Aborted in - /// `shutdown` so it does not outlive the window it acts on. - history_gate: glib::JoinHandle<()>, - sidebar_factory: FactoryVecDeque, - sidebar_schemas: std::rc::Rc>>>, - content_holder: adw::ToolbarView, - toast_overlay: adw::ToastOverlay, - /// Persistent "Connecting…" toast handle. Held so we can dismiss it - /// when the connect attempt resolves (success or failure). Native - /// alternative to a fire-and-forget 2 s toast that disappeared - /// before the connection actually completed. - connect_progress_toast: Option, - reconnect_banner: adw::Banner, - connection_list_banner: adw::Banner, - connections_factory: FactoryVecDeque, - /// The popover list's own list box, kept so the group headers can - /// be reinstalled whenever the list changes. - connections_listbox: gtk::ListBox, - connections_popover: gtk::Popover, - row_op_spinner: adw::Spinner, - read_only_badge: gtk::Label, - table_search: gtk::SearchEntry, - /// `"empty"` while no tab is open, `"tabs"` otherwise. - workspace_outer_stack: gtk::Stack, - /// AdwTabOverview wrapping the unified AdwTabBar + AdwTabView. - /// Built lazily on connect; torn down on disconnect. - workspace_root: Option, - workspace_tab_view: Option, - /// Idempotency flag for `ensure_workspace_root`. - workspace_root_added: std::cell::Cell, - /// Per-tab state. Each entry is either a Browse or Editor tab. - /// HashMap for O(1) tab_id lookup; display order is read from - /// `tab_view.pages()` since HashMap is unordered. - workspace_tabs: std::rc::Rc>>, - /// This window's pending row edits, one tracker per Table tab. - change_trackers: std::rc::Rc, - /// This window's pending DDL, one tracker per Structure tab. - structure_trackers: std::rc::Rc, - /// Starts, stops and settles this window's saves, and says what - /// closing any part of it would lose. - workspace_handle: crate::workspace::WorkspaceHandle, - /// The tabs as the close prompts read them. - window_tabs: std::rc::Rc, - /// One close prompt at a time, for a tab, the window, a disconnect - /// or a switch. - close_guards: std::rc::Rc, - /// What closing the window still owes its files and connections. - window_shutdown: std::rc::Rc, - /// Holds logout back while something here would be lost. - inhibitor: crate::workspace::WindowInhibitor, - dialog: Option, - history_dialog: Option>, - saved_queries_dialog: Option>, - import_dialog: Option>, - welcome_view: Controller, - /// Driver id is connection-wide, not per-tab. - current_driver_id: Option, - /// Read-only flag is connection-wide; fanned out to every BrowseTab - /// when toggled. - read_only: bool, - /// Default page size for newly-opened browse tabs (from preferences). - /// Per-tab page size lives on each BrowseTab. - saved_connections: Vec, - connected: bool, - /// Writes the tab list for the session this window shows. Tabs fire - /// `WorkspaceTabsChanged` on every selection, drag-reorder, - /// page-size change and state-changed event, and it writes once - /// after a burst. - workspace: std::rc::Rc, - /// Recently closed tabs for Ctrl+Shift+T. Cleared on disconnect, - /// since the tables they name belong to that connection. Pending - /// edits are not kept: the close has already cleared them. - closed_tabs_stack: std::rc::Rc>>, -} - -/// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T -/// reopen, so reopen routes back through the same `append_*` -/// constructors as a restore. -#[derive(Debug, Clone)] -pub enum ClosedTabDescriptor { - Editor { - query: String, - }, - Table { - schema: Option, - table: String, - offset: u64, - page_size: u64, - sort: Option<(usize, bool)>, - }, - Structure { - schema: Option, - table: String, - }, -} - -pub(super) const CLOSED_TABS_CAPACITY: usize = 10; - -pub struct EditorTabSlot { - pub editor: SqlEditor, - pub page: adw::TabPage, - /// The file this tab's text is written to. The text itself lives in - /// the buffer, not here. - pub draft: tablepro_storage::DraftId, -} - -pub struct StructureTabSlot { - pub id: Uuid, - pub structure: crate::ui::structure_tab::StructureTab, - pub page: adw::TabPage, - pub schema: Option, - /// Empty in `New` mode until SaveCompleted carries the canonical - /// table name back from the driver. Edit mode populates from the - /// sidebar click or restore record. - pub table: String, - pub mode: crate::ui::structure_tab::StructureMode, -} - -/// A tab showing one table's rows. The table's structure opens in a -/// tab of its own: one TabPage per surface, as GNOME Files and Builder -/// do. -pub struct TableTabSlot { - pub id: Uuid, - pub page: adw::TabPage, - pub schema: Option, - pub table: String, - pub browse: BrowseTab, -} - -/// A tab in the unified workspace. -/// -/// - **Table**: one (schema, table) entity in Data (Browse grid) -/// view. Default for every sidebar single-click. -/// - **Editor**: a free-form SQL workspace, orthogonal to any one -/// table. -/// - **Structure**: the DDL editor, for a new table's draft or an -/// existing table. -pub enum WorkspaceTab { - Editor(EditorTabSlot), - Structure(StructureTabSlot), - Table(TableTabSlot), -} - -impl WorkspaceTab { - /// The Browse tab. Only `Table` slots carry one. - pub fn browse_tab(&self) -> Option<&BrowseTab> { - match self { - WorkspaceTab::Table(s) => Some(&s.browse), - _ => None, - } - } - - /// The Structure-side controller. Only `Structure` slots carry - /// one (Table slots no longer fuse the DDL editor in). - pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { - match self { - WorkspaceTab::Structure(s) => Some(&s.structure), - _ => None, - } - } - - /// `(schema, table)` when the slot is pinned to one. Editor - /// returns `None`. - pub fn schema_table(&self) -> Option<(Option<&str>, &str)> { - match self { - WorkspaceTab::Structure(s) => Some((s.schema.as_deref(), &s.table)), - WorkspaceTab::Table(s) => Some((s.schema.as_deref(), &s.table)), - WorkspaceTab::Editor(_) => None, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum OpenMode { - /// Plain sidebar click: activate the table's tab, or append one. - SwitchOrAppend, - /// Ctrl+click / right-click "Open in new tab": always append a - /// new tab even when the same table is already open. - NewTab, -} - -// The page carries only the tab's id; its kind is in `workspace_tabs`. -fn workspace_tab_id_quark() -> glib::Quark { - static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); - *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) -} - -pub(super) fn write_workspace_tab_id(page: &adw::TabPage, id: Uuid) { - unsafe { - page.set_qdata(workspace_tab_id_quark(), id); - } -} - -pub(super) fn read_workspace_tab_id(page: &adw::TabPage) -> Option { - unsafe { page.qdata::(workspace_tab_id_quark()).map(|p| *p.as_ref()) } -} - -#[derive(Debug)] -pub enum AppMsg { - OpenConnect, - /// A connect worked; adopt its session in this window. - SessionReady(Box), - /// The user kept the tabs the connect would have replaced. - SessionDeclined(Box), - /// The connect dialog's Connect: connect, then save the entry. - ConnectSubmitted(Box), - /// The same, once the user agreed to close the open workspace. - ConnectSubmissionConfirmed(Box), - /// A connect failed. `None` when the user cancelled it, which needs - /// no error. `edit` reopens the dialog on what was submitted. - ConnectFailed { - generation: tablepro_session::connect::AttemptGeneration, - message: Option, - edit: Option>, - }, - /// The Cancel button on the Connecting toast. - CancelConnect, - /// Open the connect dialog on a connection that failed to connect, - /// with the focus on the row that fixes it. - EditConnection(Box), - /// The banner's Retry: ask the monitor to reconnect now. - RetryConnection, - /// The adopted session's health moved. - HealthChanged(FromSession), - /// The session's object list for the sidebar changed: a reconnect - /// read it again, or a schema change refreshed it. - CatalogChanged(FromSession), - DialogClosed, - SelectTable { - schema: Option, - name: String, - open_mode: OpenMode, - }, - ReloadConnections, - ConnectionsLoaded(Vec), - ConnectionListUnavailable, - ResetConnectionList, - SecretDeleteFailed(Uuid), - OpenSaved(SavedConnection), - /// Open a saved connection once the user agreed to close the open - /// workspace. - OpenSavedConfirmed(SavedConnection), - DeleteConnection(Uuid), - DuplicateConnection(SavedConnection), - /// "+ New query" button or Ctrl+T → append a new editor tab. - NewEditorTab, - /// Ctrl+W → close active workspace tab (browse or editor). - CloseActiveWorkspaceTab, - EditorTabRunStateChanged(Uuid, bool), - EditorTabContentChanged(Uuid), - /// The session's saved tabs and their editor text, read off the GTK - /// thread. - WorkspaceLoaded(FromSession), - /// The toast's Reset: move the unreadable saved tabs aside. - ResetWorkspaceLayout, - WorkspaceLayoutReset(FromSession, crate::workspace::WorkspaceSaveError>>), - ShowHistory, - ShowSavedQueries, - /// Keep the active editor tab's SQL under a name the user picks. - SaveActiveQuery, - SaveQueryNamed { - name: String, - query: String, - }, - /// A colour picked from a connection row's menu, or `None` when the - /// user cleared it. - SetConnectionColor(Uuid, Option), - /// A group picked from a connection row's menu, or `None` when the - /// user took it out of one. - SetConnectionGroup(Uuid, Option), - ExportConnections, - ImportConnections, - /// "Import CSV…" from a table's own menu: ask for a file, then - /// open the mapping dialog against that table's columns. - ImportCsvRequested { - schema: Option, - table: String, - }, - /// The file came back with the table's columns beside it, so the - /// mapping dialog has everything it needs. - ImportCsvReady { - schema: Option, - table: String, - columns: Vec, - bytes: Vec, - }, - /// The mapping is settled, so the rows can be written. - ImportCsvRun(Box), - /// The file the user picked, read off the GTK thread. - ConnectionsFileRead { - path: std::path::PathBuf, - bytes: Vec, - }, - OpenHistoryQuery(String), - ReplaceActiveTabQuery(String), - Disconnect, - /// Tear the connection down, once nothing would be lost or the user - /// agreed to lose it. - ForceDisconnect, - /// The close prompts decided about one tab's close. - TabCloseDecided { - id: Uuid, - approved: bool, - }, - RefreshPage, - ShowAbout, - ShowPreferences, - ExportResults { - result: QueryResult, - name: String, - target: Option, - }, - CopyToClipboard(String), - - // ── Workspace tab routing ──────────────────────────────────────── - /// User clicked the close-X on any workspace tab. - WorkspaceTabClosed(Uuid), - /// Tab right-click "Close Other Tabs" → close every tab except - /// the one whose context menu was used (per-tab close path so - /// each tab still gets the unsaved-changes prompt if dirty). - CloseOtherWorkspaceTabs(Uuid), - /// Tab right-click "Close Tabs to the Right" → close every tab - /// after the targeted one in TabView display order. - CloseWorkspaceTabsToRight(Uuid), - /// The tab order, the selection or a tab's state changed, so the - /// workspace is saved. - WorkspaceTabsChanged, - /// A tab's own Save: write what its tracker holds. - SaveTab(Uuid), - /// A tab's Stop Saving. - StopSaving(Uuid), - /// A save started or settled, or tabs' changes were dropped. - Workspace(crate::workspace::WorkspaceEvent), - /// The number of saves running in this window changed. - SavesInFlightChanged(usize), - /// An editor tab's transaction opened or ended. - EditorTransactionChanged, - /// Ctrl+S, for the active tab. - SaveActiveTab, - /// Ctrl+Z, for the active Table tab. - UndoActiveBrowseTab, - /// Ctrl+Y, for the active Table tab. - RedoActiveBrowseTab, - /// Show a small alert dialog; used by BrowseTab for "select exactly - /// one row" type messages. - ShowAlert { - title: String, - body: String, - }, - ShowToast(String), - /// Tracker for a specific browse tab moved between empty / non-empty. - /// Handler updates that tab's page title to add or remove the - /// "•" dirty marker. Mirrors GNOME Text Editor's leading-bullet - /// convention for unsaved buffers. - BrowseTabDirtyChanged(Uuid, bool), - /// Sidebar right-click → "New Table…" or schema-header "+" button. - /// Always appends a fresh draft Structure tab; never matches an - /// existing tab. - NewTableTab { - schema: Option, - }, - /// Sidebar right-click → "Edit Structure". Switches to an existing - /// Edit-mode Structure tab for `(schema, table)` if one is open; - /// otherwise appends a new Edit-mode Structure tab. - EditStructureTab { - schema: Option, - table: String, - }, - /// Sidebar right-click → "Show CREATE TABLE". The table's - /// definition goes through the dialect's `create_table_script`, and - /// the SQL opens in a fresh editor tab. - ShowCreateTableForExisting { - schema: Option, - table: String, - }, - /// The CREATE statement `ShowCreateTableForExisting` built, for a - /// new editor tab. - ShowCreateTableLoaded(FromSession), - /// Sidebar right-click → "Drop Table…", or in-tab Drop button. - /// App shows the AdwAlertDialog confirmation; on confirm dispatches - /// `DropTableConfirmed`. - DropTablePrompt { - schema: Option, - table: String, - }, - /// The user confirmed the drop. - DropTableConfirmed { - schema: Option, - table: String, - }, - /// The table is gone, so its tabs close without asking. - DropTableSucceeded(FromSession), - /// Tracker for a Structure tab crossed empty / non-empty boundary. - /// Mirrors `BrowseTabDirtyChanged` for the title prefix and the - /// AdwTabPage::set_needs_attention background-tab indicator. - StructureTabDirtyChanged(Uuid, bool), - /// Schema state changed (table created / dropped / altered): the - /// catalog reads the sidebar's list again and drops the table's - /// columns, and the tabs open on the table read it again. - SchemaChanged { - schema: Option, - table: Option, - }, - /// Ctrl+Shift+T → pop the most recent closed-tab descriptor and - /// reopen it. No-op when the stack is empty (e.g. no tabs closed - /// yet, or just reconnected). Editor tabs come back with their - /// buffer; Table tabs come back with their schema/table/mode and - /// last-known pagination, sort, page size. - ReopenClosedTab, - /// Ctrl+F → open the filter strip for the active Browse tab. - /// No-op when the active tab isn't a Browse / Table tab. - ShowFilterDialog, -} - -/// Tabs in either registry with unsaved changes. -fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { - let mut dirty = changes.dirty(); - dirty.extend(structures.dirty()); - dirty.sort_unstable(); - dirty.dedup(); - dirty -} - -impl App { - pub(super) fn dirty_tabs(&self) -> Vec { - dirty_tabs(&self.change_trackers, &self.structure_trackers) - } -} - -#[relm4::component(pub)] -impl SimpleComponent for App { - type Init = AppInit; - type Input = AppMsg; - type Output = (); - - view! { - #[name = "window"] - adw::ApplicationWindow { - set_title: Some("TablePro"), - set_default_width: 1200, - set_default_height: 760, - - adw::ToolbarView { - #[name = "header_bar"] - add_top_bar = &adw::HeaderBar { - #[name = "window_title"] - #[wrap(Some)] - set_title_widget = &adw::WindowTitle { - set_title: "TablePro", - }, - - // Two distinct affordances → two distinct buttons. - // SplitButton would imply they're variants of the - // same action, but "new connection" and "open - // saved" are semantically different (matches GNOME - // Files' "New" + "History" pattern, not the - // SplitButton-as-Save-with-format pattern). - #[name = "new_connection_button"] - pack_start = >k::Button { - set_icon_name: crate::ui::icons::LIST_ADD, - set_tooltip_text: Some(crate::i18n::gettext("New connection").as_str()), - connect_clicked => AppMsg::OpenConnect, - }, - - #[name = "saved_connections_button"] - pack_start = >k::MenuButton { - set_icon_name: crate::ui::icons::DOCUMENT_OPEN, - set_tooltip_text: Some(crate::i18n::gettext("Open saved connection").as_str()), - - #[wrap(Some)] - #[name = "connections_popover"] - set_popover = >k::Popover {}, - }, - - #[name = "read_only_badge"] - pack_end = >k::Label { - set_visible: false, - set_label: &crate::i18n::gettext("Read-only"), - set_margin_end: 6, - add_css_class: "warning", - add_css_class: "caption-heading", - }, - - #[name = "row_op_spinner"] - pack_end = &adw::Spinner { - set_visible: false, - set_margin_end: 6, - set_tooltip_text: Some(crate::i18n::gettext("Saving…").as_str()), - }, - - #[name = "primary_menu_button"] - pack_end = >k::MenuButton { - set_icon_name: crate::ui::icons::OPEN_MENU, - set_tooltip_text: Some(crate::i18n::gettext("Main menu").as_str()), - }, - }, - - #[wrap(Some)] - #[name = "split_view"] - set_content = &adw::OverlaySplitView { - set_min_sidebar_width: 220.0, - set_max_sidebar_width: 280.0, - set_show_sidebar: false, - - // The sidebar has its own header bar for the search - // toggle, as GNOME Files does; the window buttons are - // on the main one. - #[wrap(Some)] - #[name = "sidebar_root"] - set_sidebar = &adw::ToolbarView { - #[name = "sidebar_header"] - add_top_bar = &adw::HeaderBar { - set_show_start_title_buttons: false, - set_show_end_title_buttons: false, - - #[wrap(Some)] - #[name = "sidebar_title"] - set_title_widget = &adw::WindowTitle { - set_title: &crate::i18n::gettext("Tables"), - }, - - #[name = "table_search_toggle"] - pack_end = >k::ToggleButton { - set_icon_name: crate::ui::icons::SYSTEM_SEARCH, - set_tooltip_text: Some(crate::i18n::gettext("Search tables").as_str()), - }, - }, - - #[wrap(Some)] - set_content = >k::Box { - set_orientation: gtk::Orientation::Vertical, - - #[name = "table_search_bar"] - gtk::SearchBar { - set_show_close_button: true, - set_search_mode: false, - - #[wrap(Some)] - #[name = "table_search"] - set_child = >k::SearchEntry { - set_placeholder_text: Some(crate::i18n::gettext("Filter tables…").as_str()), - set_hexpand: true, - }, - }, - - #[name = "sidebar_scroll"] - gtk::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, - set_vexpand: true, - }, - }, - }, - - #[wrap(Some)] - #[name = "toast_overlay"] - set_content = &adw::ToastOverlay { - #[wrap(Some)] - #[name = "content_holder"] - set_child = &adw::ToolbarView { - #[name = "reconnect_banner"] - add_top_bar = &adw::Banner { - set_revealed: false, - set_use_markup: false, - set_button_label: Some(crate::i18n::gettext("Retry").as_str()), - }, - - #[name = "connection_list_banner"] - add_top_bar = &adw::Banner { - set_revealed: false, - set_use_markup: false, - }, - // The welcome view or the workspace goes in - // here once init() has run. - }, - }, - }, - }, - } - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let AppInit { - context, - prompts, - settings, - storage, - tasks, - history, - } = init; - let drafts = crate::workspace::DraftWriter::new( - tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), - tasks.clone(), - ); - let workspace = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); - let widgets = view_output!(); - - if crate::config::profile() == crate::config::Profile::Development { - widgets.window.add_css_class("devel"); - } - - crate::ui::window_geometry::restore(&widgets.window, &settings); - let change_trackers = std::rc::Rc::new(ChangeTrackerRegistry::default()); - let structure_trackers = std::rc::Rc::new(StructureTrackerRegistry::default()); - let connect_attempts = std::rc::Rc::new(tablepro_session::ConnectAttempts::new()); - let workspace_tabs: workspace_persistence::WorkspaceTabs = - std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); - let window_tabs = std::rc::Rc::new(window_tabs::WindowTabs::new(workspace_tabs.clone())); - let events_sender = sender.input_sender().clone(); - let workspace_handle = crate::workspace::WorkspaceHandle::new(crate::workspace::WorkspaceDeps { - session: context.active.clone(), - tasks: context.tasks.clone(), - hold: context.hold.clone(), - history: Some(history.clone()), - rows: change_trackers.clone(), - structures: structure_trackers.clone(), - directory: window_tabs.clone(), - events: std::rc::Rc::new(move |event| events_sender.emit(AppMsg::Workspace(event))), - }); - let saves_sender = sender.input_sender().clone(); - workspace_handle - .ledger() - .on_change(move |count| saves_sender.emit(AppMsg::SavesInFlightChanged(count))); - let close_guards = crate::workspace::CloseGuards::new(); - let window_shutdown = close_flow::WindowShutdown::new(close_flow::WindowShutdownParts { - settings: settings.clone(), - storage: storage.clone(), - drafts: drafts.clone(), - workspace: workspace.clone(), - tabs: workspace_tabs.clone(), - context: context.clone(), - connect_attempts: connect_attempts.clone(), - }); - let shutdown = window_shutdown.clone(); - close_guards.connect_window(&widgets.window, workspace_handle.clone(), move |window| { - shutdown.run(Some(window)); - }); - let inhibitor = - crate::workspace::WindowInhibitor::new(crate::workspace::ApplicationInhibit::new(&widgets.window)); - - let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length( - adw::BreakpointConditionLengthType::MaxWidth, - 600.0, - adw::LengthUnit::Sp, - )); - breakpoint.add_setter(&widgets.split_view, "collapsed", Some(&true.into())); - widgets.window.add_breakpoint(breakpoint); - - let sidebar_schemas: std::rc::Rc>>> = - std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - - let sidebar_factory: FactoryVecDeque = FactoryVecDeque::builder() - .launch( - gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::Single) - .activate_on_single_click(true) - .css_classes(["navigation-sidebar"]) - .build(), - ) - .forward(sender.input_sender(), |out| match out { - // Plain click + Enter activation route through the parent - // ListBox's `row-activated` signal (wired below), which is - // the only signal that fires for both mouse and keyboard. - // The factory only carries the Ctrl+click / right-click - // "open in new tab" path. - SidebarRowOutput::OpenInNewTab { schema, name } => AppMsg::SelectTable { - schema, - name, - open_mode: OpenMode::NewTab, - }, - SidebarRowOutput::EditStructure { schema, name } => AppMsg::EditStructureTab { schema, table: name }, - SidebarRowOutput::ShowCreateTable { schema, name } => { - AppMsg::ShowCreateTableForExisting { schema, table: name } - } - SidebarRowOutput::ImportCsv { schema, name } => AppMsg::ImportCsvRequested { schema, table: name }, - SidebarRowOutput::DropTable { schema, name } => AppMsg::DropTablePrompt { schema, table: name }, - }); - - let sidebar_listbox = sidebar_factory.widget(); - widgets.sidebar_scroll.set_child(Some(sidebar_listbox)); - - // Plain click + Enter on focused row → SwitchOrAppend. This is - // the single source of truth for sidebar activation; per-row - // keybinding signals (gtk::ListBoxRow::activate) only fire on - // Enter and would miss mouse clicks. - let schemas_for_activate = sidebar_schemas.clone(); - let activate_sender = sender.clone(); - sidebar_listbox.connect_row_activated(move |_, row| { - let name = row.widget_name().to_string(); - let idx = row.index() as usize; - let schema = schemas_for_activate.borrow().get(idx).cloned().unwrap_or(None); - activate_sender.input(AppMsg::SelectTable { - schema, - name, - open_mode: OpenMode::SwitchOrAppend, - }); - }); - - let search_for_filter = widgets.table_search.clone(); - let schemas_for_filter = sidebar_schemas.clone(); - sidebar_listbox.set_filter_func(move |row| { - let query = search_for_filter.text().to_lowercase(); - if query.is_empty() { - return true; - } - // SidebarRow stashes its table name in widget-name; same - // identifier is read by sync_sidebar_selection. Search - // also matches the row's schema (when present) so a query - // for "auth" surfaces every table in the auth schema, and - // the qualified `schema.table` form so users with - // multi-schema connections can disambiguate by typing the - // dotted name they see in the tab title. - let table_name = row.widget_name().to_lowercase(); - if table_name.contains(&query) { - return true; - } - let schemas = schemas_for_filter.borrow(); - let idx = row.index() as usize; - let Some(schema) = schemas.get(idx).and_then(|s| s.as_deref()) else { - return false; - }; - let schema_lc = schema.to_lowercase(); - schema_lc.contains(&query) || format!("{schema_lc}.{table_name}").contains(&query) - }); - let listbox_for_invalidate = sidebar_listbox.clone(); - widgets.table_search.connect_search_changed(move |_| { - listbox_for_invalidate.invalidate_filter(); - }); - widgets.table_search_bar.connect_entry(&widgets.table_search); - widgets - .table_search_bar - .set_key_capture_widget(Some(&widgets.sidebar_root)); - - // An empty list with no placeholder reads as broken. - let sidebar_placeholder = adw::StatusPage::builder() - .icon_name(crate::ui::icons::VIEW_LIST) - .title(crate::i18n::gettext("No tables")) - .description(crate::i18n::gettext( - "Nothing matches the current search, or this connection has no tables yet.", - )) - .build(); - sidebar_placeholder.add_css_class("compact"); - sidebar_listbox.set_placeholder(Some(&sidebar_placeholder)); - - // Two-way bind the sidebar header's search toggle to the SearchBar. - // Click toggle → SearchBar reveals + entry focuses; press Esc → - // SearchBar hides → toggle deactivates. - widgets - .table_search_toggle - .bind_property("active", &widgets.table_search_bar, "search-mode-enabled") - .bidirectional() - .sync_create() - .build(); - - let schemas_for_header = sidebar_schemas.clone(); - let sender_for_header = sender.clone(); - let context_for_header = context.clone(); - sidebar_listbox.set_header_func(move |row, before| { - let schemas = schemas_for_header.borrow(); - let total_distinct: std::collections::BTreeSet<&str> = - schemas.iter().filter_map(|s| s.as_deref()).collect(); - // A connection with one schema still gets a header, for its - // "New Table" button. - let multi_schema = total_distinct.len() >= 2; - let idx = row.index(); - let current = schemas.get(idx as usize).cloned().flatten(); - let prev_idx = before.map(|b| b.index()); - let prev = prev_idx.and_then(|i| schemas.get(i as usize)).cloned().flatten(); - let needs = match (¤t, &prev) { - (Some(c), Some(p)) => c != p, - (Some(_), None) => true, - (None, None) => before.is_none() && !multi_schema, - (None, Some(_)) => false, - }; - if !needs { - row.set_header(gtk::Widget::NONE); - return; - } - let can_create_table = context_for_header - .active - .get() - .and_then(|session| session.current_dialect()) - .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) - .unwrap_or(false); - let sender_for_button = sender_for_header.clone(); - let schema_for_button = current.clone(); - let header_box = - sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { - sender_for_button.input(AppMsg::NewTableTab { - schema: schema_for_button.clone(), - }); - }); - row.set_header(Some(&header_box)); - }); - - let connections_listbox = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - let connections_factory: FactoryVecDeque = FactoryVecDeque::builder() - .launch(connections_listbox.clone()) - .forward(sender.input_sender(), |out| match out { - ConnectionRowOutput::Open(saved) => AppMsg::OpenSaved(saved), - ConnectionRowOutput::Delete(id) => AppMsg::DeleteConnection(id), - ConnectionRowOutput::Duplicate(saved) => AppMsg::DuplicateConnection(saved), - ConnectionRowOutput::SetColor(id, color) => AppMsg::SetConnectionColor(id, color), - ConnectionRowOutput::SetGroup(id, group) => AppMsg::SetConnectionGroup(id, group), - }); - - // The SplitButton's tooltip already labels the popover, so we drop - // the in-popover "Saved Connections" header that previously sat - // above the list. Explicit width_request prevents AdwSplitButton's - // narrow dropdown trigger from constraining the popover width - // (which produced mid-word hyphenation of connection names). - let popover_content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .margin_top(6) - .margin_bottom(6) - .margin_start(6) - .margin_end(6) - .width_request(320) - .build(); - - let scroll = gtk::ScrolledWindow::builder() - .child(connections_factory.widget()) - .min_content_width(320) - .min_content_height(120) - .max_content_height(400) - .propagate_natural_height(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .build(); - popover_content.append(&scroll); - widgets.connections_popover.set_child(Some(&popover_content)); - - // Workspace outer stack: swaps between an empty StatusPage - // ("Select a table") when no tabs are open and the unified - // AdwTabOverview hosting both Browse and Editor tabs. The - // tab tree itself is built lazily on connect via - // `ensure_workspace_root` in app/workspace_tabs.rs. - let workspace_outer_stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .build(); - // CTA button parented inside the empty-state status page so - // the "open editor" affordance is reachable with the mouse: - // without it the only path was the keyboard shortcut and the - // tab-bar "+", and the tab bar is hidden in this empty state. - let workspace_empty_cta = gtk::Button::builder() - .label(crate::i18n::gettext("Open SQL editor")) - .action_name("win.open-editor") - .halign(gtk::Align::Center) - .build(); - workspace_empty_cta.add_css_class("suggested-action"); - workspace_empty_cta.add_css_class("pill"); - let workspace_empty_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::VIEW_GRID) - .title(crate::i18n::gettext("Select a table")) - .description(crate::i18n::gettext( - "Pick a table from the sidebar, or use the button below (Ctrl+T).", - )) - .child(&workspace_empty_cta) - .build(); - workspace_outer_stack.add_named(&workspace_empty_page, Some("empty")); - workspace_outer_stack.set_visible_child_name("empty"); - - let actions = install_window_actions(&widgets.window, sender.clone()); - let history_gate = spawn_history_gate( - vec![actions.show_history.clone(), actions.show_saved_queries.clone()], - history.availability(), - ); - install_window_shortcuts(&widgets.window); - widgets.primary_menu_button.set_menu_model(Some(&primary_menu_model())); - - let welcome_view = - WelcomeView::builder() - .launch(WelcomeViewInit) - .forward(sender.input_sender(), |out| match out { - WelcomeViewOutput::OpenConnect => AppMsg::OpenConnect, - WelcomeViewOutput::OpenSaved(saved) => AppMsg::OpenSaved(saved), - WelcomeViewOutput::Delete(id) => AppMsg::DeleteConnection(id), - WelcomeViewOutput::Duplicate(saved) => AppMsg::DuplicateConnection(saved), - WelcomeViewOutput::SetColor(id, color) => AppMsg::SetConnectionColor(id, color), - WelcomeViewOutput::SetGroup(id, group) => AppMsg::SetConnectionGroup(id, group), - }); - - let prompt_presenter = crate::ui::credential_prompt::CredentialPromptPresenter::start(prompts, &root); - let model = App { - context, - session_links: None, - prompt_presenter, - connect_attempts, - settings: settings.clone(), - storage: storage.clone(), - tasks: tasks.clone(), - history: history.clone(), - drafts: drafts.clone(), - window: root.clone(), - split_view: widgets.split_view.clone(), - window_title: widgets.window_title.clone(), - sidebar_title: widgets.sidebar_title.clone(), - disconnect_action: actions.disconnect, - history_gate, - sidebar_factory, - sidebar_schemas, - content_holder: widgets.content_holder.clone(), - toast_overlay: widgets.toast_overlay.clone(), - connect_progress_toast: None, - reconnect_banner: widgets.reconnect_banner.clone(), - connection_list_banner: widgets.connection_list_banner.clone(), - connections_factory, - connections_listbox, - connections_popover: widgets.connections_popover.clone(), - row_op_spinner: widgets.row_op_spinner.clone(), - read_only_badge: widgets.read_only_badge.clone(), - table_search: widgets.table_search.clone(), - workspace_outer_stack, - workspace_root: None, - workspace_tab_view: None, - workspace_root_added: std::cell::Cell::new(false), - workspace_tabs, - change_trackers, - structure_trackers, - workspace_handle, - window_tabs, - close_guards, - window_shutdown, - inhibitor, - dialog: None, - history_dialog: None, - saved_queries_dialog: None, - import_dialog: None, - welcome_view, - current_driver_id: None, - read_only: false, - saved_connections: Vec::new(), - connected: false, - workspace, - closed_tabs_stack: std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::with_capacity( - CLOSED_TABS_CAPACITY, - ))), - }; - sender.input(AppMsg::ReloadConnections); - model.show_welcome_page(sender.clone()); - - widgets - .new_connection_button - .update_property(&[gtk::accessible::Property::Label("New connection")]); - widgets - .saved_connections_button - .update_property(&[gtk::accessible::Property::Label("Open saved connection")]); - widgets - .primary_menu_button - .update_property(&[gtk::accessible::Property::Label("Main menu")]); - - let banner_sender = sender.clone(); - widgets.reconnect_banner.connect_button_clicked(move |_| { - banner_sender.input(AppMsg::RetryConnection); - }); - - let list_banner_sender = sender.clone(); - widgets.connection_list_banner.connect_button_clicked(move |_| { - list_banner_sender.input(AppMsg::ResetConnectionList); - }); - - ComponentParts { model, widgets } - } - - fn shutdown(&mut self, _widgets: &mut Self::Widgets, _output: relm4::Sender) { - // The gate holds the window's action, so it has to stop before - // the window does. - self.history_gate.abort(); - self.prompt_presenter.abort(); - // The close request already did this; a window destroyed - // without one still closes its tabs' connections, then its own, - // here. The claim on the session's key goes last, so no other - // window adopts a connection still closing. - self.window_shutdown.run(None); - self.session_links = None; - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - AppMsg::OpenConnect => self.on_open_connect(sender), - AppMsg::SessionReady(ready) => self.on_session_ready(*ready, sender), - AppMsg::SessionDeclined(ready) => self.on_session_declined(*ready), - AppMsg::ConnectSubmitted(submission) => self.on_connect_submission(*submission, sender), - AppMsg::ConnectSubmissionConfirmed(submission) => self.connect_submission(*submission, sender), - AppMsg::ConnectFailed { - generation, - message, - edit, - } => self.on_connect_failed(generation, message, edit, sender), - AppMsg::CancelConnect => self.on_cancel_connect(), - AppMsg::EditConnection(retry) => self.on_edit_connection(*retry, sender), - AppMsg::HealthChanged(update) => { - if let Some(health) = update.if_current(&self.context.active) { - self.refresh_health_banner(&health); - } - } - AppMsg::RetryConnection => self.on_retry_connection(), - AppMsg::CatalogChanged(update) => { - if let Some(snapshot) = update.if_current(&self.context.active) { - self.on_catalog_changed(&snapshot); - } - } - AppMsg::Disconnect => self.on_disconnect(sender), - AppMsg::ForceDisconnect => self.do_disconnect(sender), - AppMsg::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), - AppMsg::DialogClosed => self.dialog = None, - AppMsg::SelectTable { - schema, - name, - open_mode, - } => self.dispatch_select_table(schema, name, open_mode, sender), - AppMsg::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), - AppMsg::SaveTab(id) => self.request_save(id), - AppMsg::StopSaving(id) => self.stop_saving(id), - AppMsg::Workspace(event) => self.on_workspace_event(event, sender), - AppMsg::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), - AppMsg::EditorTransactionChanged => self.refresh_inhibitor(), - AppMsg::SaveActiveTab => { - if let Some(id) = self.selected_workspace_tab_id() { - self.request_save(id); - } - } - AppMsg::UndoActiveBrowseTab => { - // Ctrl+Z routes to BrowseTab undo only. Structure - // editing follows the snapshot+diff model: DDL undo - // is a session-level Discard, not a per-keystroke - // history. Inside a Structure-mode Entry, the native - // `gtk::Text` undo handles per-field text revert. - if let Some(id) = self.selected_browse_tab_id() { - self.dispatch_to_tab(id, BrowseTabInput::Undo); - } - } - AppMsg::RedoActiveBrowseTab => { - if let Some(id) = self.selected_browse_tab_id() { - self.dispatch_to_tab(id, BrowseTabInput::Redo); - } - } - AppMsg::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id, sender), - AppMsg::CloseOtherWorkspaceTabs(id) => self.close_other_workspace_tabs(id, sender), - AppMsg::CloseWorkspaceTabsToRight(id) => self.close_workspace_tabs_to_right(id, sender), - AppMsg::CloseActiveWorkspaceTab => self.close_active_workspace_tab(sender), - AppMsg::ShowAlert { title, body } => self.show_error_alert(&title, &body), - AppMsg::ShowToast(msg) => self.show_toast(&msg), - AppMsg::BrowseTabDirtyChanged(tab_id, dirty) => { - self.refresh_browse_tab_dirty(tab_id, dirty); - self.refresh_inhibitor(); - } - AppMsg::NewTableTab { schema } => self.on_new_table_tab(schema, sender), - AppMsg::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table, sender), - AppMsg::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table, sender), - AppMsg::ShowCreateTableLoaded(update) => { - if let Some(sql) = update.if_current(&self.context.active) { - self.append_editor_tab(Some(sql), sender); - } - } - AppMsg::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table, sender), - AppMsg::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table, sender), - AppMsg::DropTableSucceeded(update) => { - if let Some(table) = update.if_current(&self.context.active) { - self.on_drop_table_succeeded(table, sender); - } - } - AppMsg::StructureTabDirtyChanged(tab_id, dirty) => { - self.refresh_structure_tab_dirty(tab_id, dirty); - self.refresh_inhibitor(); - } - AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), - AppMsg::ReloadConnections => self.on_reload_connections(sender), - AppMsg::ConnectionListUnavailable => self.show_connection_list_banner(), - AppMsg::SecretDeleteFailed(id) => self.on_secret_delete_failed(id, sender), - AppMsg::ResetConnectionList => self.on_reset_connection_list(sender), - AppMsg::ConnectionsLoaded(connections) => { - let conns = connections; - self.on_connections_loaded(&conns, sender); - } - AppMsg::NewEditorTab => self.append_editor_tab(None, sender), - AppMsg::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), - AppMsg::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), - AppMsg::WorkspaceLoaded(update) => { - if let Some(loaded) = update.if_current(&self.context.active) { - self.on_workspace_loaded(loaded, sender); - } - } - AppMsg::ResetWorkspaceLayout => self.on_reset_workspace_layout(sender), - AppMsg::WorkspaceLayoutReset(update) => { - if let Some(outcome) = update.if_current(&self.context.active) { - self.on_workspace_layout_reset(outcome); - } - } - AppMsg::ShowHistory => self.on_show_history(sender), - AppMsg::ShowSavedQueries => self.on_show_saved_queries(sender), - AppMsg::SetConnectionColor(id, color) => self.on_set_connection_color(id, color, sender), - AppMsg::SetConnectionGroup(id, group) => self.on_set_connection_group(id, group, sender), - AppMsg::ExportConnections => self.on_export_connections(sender), - AppMsg::ImportConnections => self.on_import_connections(sender), - AppMsg::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table, sender), - AppMsg::ImportCsvReady { - schema, - table, - columns, - bytes, - } => self.on_import_csv_ready(schema, table, columns, bytes, sender), - AppMsg::ImportCsvRun(import) => self.on_import_csv_run(*import, sender), - AppMsg::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes, sender), - AppMsg::SaveActiveQuery => self.on_save_active_query(sender), - AppMsg::SaveQueryNamed { name, query } => self.on_save_query_named(name, query, sender), - AppMsg::OpenHistoryQuery(text) => { - if self.connected { - self.append_editor_tab(Some(text), sender); - } else { - self.show_toast(&crate::i18n::gettext("Connect to a database first to run SQL.")); - } - } - AppMsg::ReplaceActiveTabQuery(text) => { - if self.connected { - self.on_replace_active_tab_query(text, sender); - } else { - self.show_toast(&crate::i18n::gettext("Connect to a database first to run SQL.")); - } - } - AppMsg::RefreshPage => self.on_refresh_active_tab(), - AppMsg::ShowAbout => self.on_show_about(), - AppMsg::ShowPreferences => super::preferences_dialog::PreferencesDialog::new( - &self.settings, - &self.storage, - self.history.store(), - &self.tasks, - ) - .present(Some(&self.window)), - AppMsg::ExportResults { result, name, target } => { - let dialect = self.context.active.get().and_then(|session| session.current_dialect()); - super::export_dialog::present( - &self.window, - &self.toast_overlay, - result, - name, - target, - dialect, - &self.settings, - ) - } - AppMsg::CopyToClipboard(text) => self.on_copy_to_clipboard(text), - AppMsg::DeleteConnection(id) => self.on_delete_connection(id, sender), - AppMsg::DuplicateConnection(saved) => self.on_duplicate_connection(saved, sender), - AppMsg::OpenSaved(saved) => self.on_open_saved(saved, sender), - AppMsg::OpenSavedConfirmed(saved) => self.connect_saved(saved, sender), - AppMsg::ReopenClosedTab => self.on_reopen_closed_tab(sender), - AppMsg::ShowFilterDialog => self.on_show_filter_dialog(), - } - } -} - -fn qualified_label(schema: Option<&str>, table: &str) -> String { - match schema { - Some(s) => format!("{s}.{table}"), - None => table.to_string(), - } -} - -fn primary_menu_model() -> gio::Menu { - let menu = gio::Menu::new(); - let connection_section = gio::Menu::new(); - let disconnect_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Disconnect")), Some("win.disconnect")); - disconnect_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - connection_section.append_item(&disconnect_item); - menu.append_section(None, &connection_section); - let history_section = gio::Menu::new(); - history_section.append(Some(&crate::i18n::gettext("Query History")), Some("win.show-history")); - history_section.append( - Some(&crate::i18n::gettext("Saved Queries")), - Some("win.show-saved-queries"), - ); - menu.append_section(None, &history_section); - let connections_section = gio::Menu::new(); - connections_section.append( - Some(&crate::i18n::gettext("Import Connections…")), - Some("win.import-connections"), - ); - connections_section.append( - Some(&crate::i18n::gettext("Export Connections…")), - Some("win.export-connections"), - ); - menu.append_section(None, &connections_section); - let prefs_section = gio::Menu::new(); - prefs_section.append(Some(&crate::i18n::gettext("Preferences")), Some("win.preferences")); - menu.append_section(None, &prefs_section); - let app_section = gio::Menu::new(); - app_section.append(Some(&crate::i18n::gettext("Keyboard Shortcuts")), Some("app.shortcuts")); - app_section.append(Some(&crate::i18n::gettext("About TablePro")), Some("win.about")); - app_section.append(Some(&crate::i18n::gettext("Quit")), Some("win.quit")); - menu.append_section(None, &app_section); - menu -} - -/// Flip the actions that need the query database whenever -/// `HistoryService` changes state. -/// -/// The watch channel is read on the GTK thread, so the actions are only -/// ever touched from the thread that owns them. -fn spawn_history_gate( - actions: Vec, - mut availability: tokio::sync::watch::Receiver, -) -> glib::JoinHandle<()> { - glib::spawn_future_local(async move { - loop { - let state = availability.borrow_and_update().clone(); - for action in &actions { - action.set_enabled(state.is_ready()); - } - if let Some(failure) = state.failure() { - tracing::warn!(error = failure, "query history stays unavailable"); - } - if availability.changed().await.is_err() { - return; - } - } - }) -} - -/// The actions the model keeps a handle on because their enabled -/// state changes after the window is built. -struct WindowActions { - disconnect: gio::SimpleAction, - show_history: gio::SimpleAction, - show_saved_queries: gio::SimpleAction, -} - -fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSender) -> WindowActions { - let group = gio::SimpleActionGroup::new(); - - // Twelve identical action wrappers were inlined here before; the macro - // keeps the tuple-list intent obvious and removes 36 lines of boilerplate. - macro_rules! input_action { - ($name:expr, $msg:expr) => {{ - let s = sender.clone(); - gio::ActionEntry::builder($name) - .activate(move |_, _, _| s.input($msg)) - .build() - }}; - } - - let window_for_quit = window.clone(); - let quit = gio::ActionEntry::builder("quit") - .activate(move |_, _, _| window_for_quit.close()) - .build(); - - group.add_action_entries([ - input_action!("about", AppMsg::ShowAbout), - quit, - input_action!("open-editor", AppMsg::NewEditorTab), - input_action!("close-current", AppMsg::CloseActiveWorkspaceTab), - input_action!("preferences", AppMsg::ShowPreferences), - input_action!("refresh-page", AppMsg::RefreshPage), - input_action!("save-changes", AppMsg::SaveActiveTab), - input_action!("undo-change", AppMsg::UndoActiveBrowseTab), - input_action!("redo-change", AppMsg::RedoActiveBrowseTab), - input_action!("reopen-closed-tab", AppMsg::ReopenClosedTab), - input_action!("open-filter", AppMsg::ShowFilterDialog), - input_action!("save-query", AppMsg::SaveActiveQuery), - input_action!("export-connections", AppMsg::ExportConnections), - input_action!("import-connections", AppMsg::ImportConnections), - input_action!("reset-workspace-layout", AppMsg::ResetWorkspaceLayout), - ]); - let disconnect = gio::SimpleAction::new("disconnect", None); - let sender_for_disconnect = sender.clone(); - disconnect.connect_activate(move |_, _| sender_for_disconnect.input(AppMsg::Disconnect)); - disconnect.set_enabled(false); - group.add_action(&disconnect); - - // The history database opens on a background task, so the menu item - // and Ctrl+H stay insensitive until `HistoryService` reports Ready. - let show_history = gio::SimpleAction::new("show-history", None); - let sender_for_history = sender.clone(); - show_history.connect_activate(move |_, _| sender_for_history.input(AppMsg::ShowHistory)); - show_history.set_enabled(false); - group.add_action(&show_history); - - // Saved queries live in the same database, so they are reachable - // exactly when it is. - let show_saved_queries = gio::SimpleAction::new("show-saved-queries", None); - let sender_for_saved = sender.clone(); - show_saved_queries.connect_activate(move |_, _| sender_for_saved.input(AppMsg::ShowSavedQueries)); - show_saved_queries.set_enabled(false); - group.add_action(&show_saved_queries); - - window.insert_action_group("win", Some(&group)); - tracing::info!(enabled = disconnect.is_enabled(), "registered win.disconnect"); - WindowActions { - disconnect, - show_history, - show_saved_queries, - } -} - -fn install_window_shortcuts(window: &adw::ApplicationWindow) { - use gtk::gdk::{Key, ModifierType}; - let primary = ModifierType::CONTROL_MASK; - let primary_shift = ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK; - let controller = gtk::ShortcutController::new(); - controller.set_scope(gtk::ShortcutScope::Global); - controller.add_shortcut(make_shortcut(Key::q, primary, "win.quit")); - controller.add_shortcut(make_shortcut(Key::w, primary, "win.close-current")); - controller.add_shortcut(make_shortcut(Key::e, primary, "win.open-editor")); - // Ctrl+T mirrors Ctrl+E for the browser/IDE muscle memory ("new - // tab"). Both fire `win.open-editor` so the empty workspace state - // can be exited via either shortcut without focus tricks. - controller.add_shortcut(make_shortcut(Key::t, primary, "win.open-editor")); - controller.add_shortcut(make_shortcut(Key::F5, ModifierType::empty(), "win.refresh-page")); - controller.add_shortcut(make_shortcut(Key::f, primary, "win.open-filter")); - controller.add_shortcut(make_shortcut(Key::comma, primary, "win.preferences")); - controller.add_shortcut(make_shortcut(Key::h, primary, "win.show-history")); - controller.add_shortcut(make_shortcut(Key::s, primary, "win.save-changes")); - controller.add_shortcut(make_shortcut(Key::z, primary, "win.undo-change")); - controller.add_shortcut(make_shortcut(Key::y, primary, "win.redo-change")); - controller.add_shortcut(make_shortcut(Key::z, primary_shift, "win.redo-change")); - controller.add_shortcut(make_shortcut(Key::t, primary_shift, "win.reopen-closed-tab")); - controller.add_shortcut(make_shortcut(Key::s, primary_shift, "win.save-query")); - window.add_controller(controller); -} - -fn make_shortcut(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, action: &str) -> gtk::Shortcut { - gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new(key, modifiers)) - .action(>k::NamedAction::new(action)) - .build() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn shortcut_action_names(window: &adw::ApplicationWindow) -> Vec { - let controllers = window.observe_controllers(); - let mut names = Vec::new(); - for index in 0..controllers.n_items() { - let Some(controller) = controllers.item(index).and_downcast::() else { - continue; - }; - for position in 0..controller.n_items() { - let Some(shortcut) = controller.item(position).and_downcast::() else { - continue; - }; - if let Some(action) = shortcut.action().and_downcast::() { - names.push(action.action_name().to_string()); - } - } - } - names - } - - #[gtk4::test] - fn window_actions_exclude_shortcuts() { - let app = adw::Application::builder() - .application_id(crate::config::APP_ID) - .build(); - let window = adw::ApplicationWindow::new(&app); - - install_window_shortcuts(&window); - let names = shortcut_action_names(&window); - - assert!(names.iter().any(|name| name == "win.quit"), "{names:?}"); - assert!( - !names.iter().any(|name| name == "win.shortcuts"), - "AdwApplication owns app.shortcuts now: {names:?}" - ); - } - - #[gtk4::test] - fn the_history_gate_follows_availability() { - use crate::services::history_availability::HistoryAvailability; - - let action = gio::SimpleAction::new("show-history", None); - action.set_enabled(false); - let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); - let gate = spawn_history_gate(vec![action.clone()], receiver); - - crate::test_support::drain_main_context(); - assert!(!action.is_enabled(), "enabled while the database was still opening"); - - sender.send_replace(HistoryAvailability::Ready); - crate::test_support::drain_main_context(); - assert!(action.is_enabled(), "stayed disabled after the database opened"); - - sender.send_replace(HistoryAvailability::Failed("no disk".to_owned())); - crate::test_support::drain_main_context(); - assert!(!action.is_enabled(), "stayed enabled after the database failed"); - - gate.abort(); - } -} diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs deleted file mode 100644 index 1a5d65c209..0000000000 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ /dev/null @@ -1,14 +0,0 @@ -use relm4::adw::prelude::*; - -use super::App; - -impl App { - pub(super) fn set_row_op_in_flight(&self, in_flight: bool) { - self.row_op_spinner.set_visible(in_flight); - } - - pub(super) fn on_copy_to_clipboard(&self, text: String) { - self.window.clipboard().set_text(&text); - self.show_toast(&crate::i18n::gettext("Copied to clipboard")); - } -} diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs deleted file mode 100644 index 8cf48615f2..0000000000 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ /dev/null @@ -1,243 +0,0 @@ -use relm4::adw::prelude::*; -use relm4::{Component, ComponentController, ComponentSender, adw, gtk}; - -use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogOutput}; -use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; - -use super::{App, AppMsg}; - -impl App { - pub(super) fn show_welcome_page(&self, _sender: ComponentSender) { - self.content_holder.set_content(Some(self.welcome_view.widget())); - } - - /// The toast stays until the connect resolves: a remote or tunnelled - /// connect can take longer than any timeout would. - pub(super) fn set_loading_page(&mut self, title: &str, description: &str, sender: &ComponentSender) { - if let Some(prev) = self.connect_progress_toast.take() { - prev.dismiss(); - } - // GNOME inline-metadata separator (` · `) keeps the two - // strings reading as one phrase rather than two sentences - // colliding ("Connecting… Opening MyDB" → "Connecting… · - // Opening MyDB"). Same convention used in the browse - // paginator label and the editor status line. - let body = if description.is_empty() { - title.to_string() - } else { - format!("{title} · {description}") - }; - let toast = adw::Toast::builder().title(&body).timeout(0).build(); - // A connect through SSH or to a server that drops packets can - // take the whole connect timeout, so it can be stopped here. - toast.set_button_label(Some(&crate::i18n::gettext("Cancel"))); - let cancel = sender.clone(); - toast.connect_button_clicked(move |_| cancel.input(AppMsg::CancelConnect)); - self.toast_overlay.add_toast(toast.clone()); - self.connect_progress_toast = Some(toast); - } - - pub(super) fn dismiss_loading_page(&mut self) { - if let Some(toast) = self.connect_progress_toast.take() { - toast.dismiss(); - } - } - - pub(super) fn show_toast(&self, msg: &str) { - self.toast_overlay.add_toast(adw::Toast::new(msg)); - } - - pub(super) fn show_error_alert(&self, title: &str, message: &str) { - let dialog = adw::AlertDialog::new(Some(title), Some(message)); - // GNOME HIG dismiss-only alert: "Close" reads cleaner than "OK" - // (which implies acknowledgement of an action the user took) - // and matches GNOME Settings' info-alert convention. - dialog.add_response("close", &crate::i18n::gettext("Close")); - dialog.set_default_response(Some("close")); - dialog.set_close_response("close"); - dialog.present(Some(&self.window)); - } - - pub(super) fn on_show_history(&mut self, sender: ComponentSender) { - let dialog = HistoryDialog::builder() - .launch(HistoryDialogInit { - history: self.history.clone(), - tasks: self.tasks.clone(), - }) - .forward(sender.input_sender(), |out| match out { - HistoryDialogOutput::OpenInNewTab(text) => AppMsg::OpenHistoryQuery(text), - HistoryDialogOutput::ReplaceCurrentTabQuery(text) => AppMsg::ReplaceActiveTabQuery(text), - }); - dialog.model().dialog().present(Some(&self.window)); - self.history_dialog = Some(dialog); - } - - pub(super) fn on_show_saved_queries(&mut self, sender: ComponentSender) { - 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), - }) - .forward(sender.input_sender(), |out| match out { - SavedQueriesDialogOutput::OpenInNewTab(text) => AppMsg::OpenHistoryQuery(text), - SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => AppMsg::ReplaceActiveTabQuery(text), - SavedQueriesDialogOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), - SavedQueriesDialogOutput::ShowToast(text) => AppMsg::ShowToast(text), - }); - dialog.model().dialog().present(Some(&self.window)); - self.saved_queries_dialog = Some(dialog); - } - - /// Ask what to call the SQL in front of the user, then keep it. - /// - /// The text is read now rather than when the dialog closes, so what - /// is saved is what the user was looking at when they asked. - pub(super) fn on_save_active_query(&self, sender: ComponentSender) { - let Some(query) = self.active_editor_query() else { - self.show_toast(&crate::i18n::gettext("Open a SQL editor tab to save a query.")); - return; - }; - if query.trim().is_empty() { - self.show_toast(&crate::i18n::gettext("There is nothing in the editor to save.")); - return; - } - if !self.context.active.is_connected() { - self.show_toast(&crate::i18n::gettext( - "Connect to a database first: a saved query is kept under its connection.", - )); - return; - } - - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Query")), None); - let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); - entry.set_text(&suggested_query_name(&query)); - 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("save", &crate::i18n::gettext("Save")); - dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("save")); - dialog.set_close_response("cancel"); - - let sender = sender.input_sender().clone(); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); - if response != "save" { - return; - } - let name = entry.text().to_string(); - if name.trim().is_empty() { - return; - } - let _ = sender.send(AppMsg::SaveQueryNamed { - name, - query: query.clone(), - }); - }); - dialog.present(Some(&self.window)); - } - - pub(super) fn on_save_query_named(&self, name: String, query: String, sender: ComponentSender) { - let Some(store) = self.history.store().map(|history| history.saved_queries()) else { - self.show_toast(&crate::i18n::gettext("The query database is not open yet.")); - return; - }; - let Some(metadata) = self.context.active.metadata() else { - self.show_toast(&crate::i18n::gettext( - "Connect to a database first: a saved query is kept under its connection.", - )); - return; - }; - let new_query = tablepro_storage::saved_queries::NewSavedQuery { - name: name.clone(), - query, - connection_id: metadata.id, - connection_name: metadata.name, - }; - let sender_for_result = sender.clone(); - sender.command(move |_, shutdown| { - shutdown - .register(async move { - let message = match store.save(new_query).await { - Ok(outcome) if outcome.replaced => { - crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]) - } - Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), - Err(error) => { - tracing::warn!(%error, "could not save the query"); - crate::i18n::gettext("Could not save that query.") - } - }; - sender_for_result.input(AppMsg::ShowToast(message)); - }) - .drop_on_shutdown() - }); - } - - pub(super) fn on_show_about(&self) { - let dialog = adw::AboutDialog::builder() - .application_name(crate::i18n::gettext("TablePro")) - .application_icon(crate::config::APP_ID) - .developer_name(crate::i18n::gettext("TablePro Authors")) - .version(crate::config::VERSION) - .website("https://github.com/TableProApp/TablePro") - .issue_url("https://github.com/TableProApp/TablePro/issues") - .support_url("https://github.com/TableProApp/TablePro/discussions") - .copyright(crate::i18n::gettext("© 2025–2026 TablePro Authors")) - .license_type(gtk::License::Agpl30) - .comments(crate::i18n::gettext( - "A native Linux database client built with GTK4 + libadwaita.", - )) - .build(); - dialog.set_developers(&["TablePro Authors https://github.com/TableProApp/TablePro"]); - dialog.set_translator_credits(&crate::i18n::gettext("translator-credits")); - dialog.present(Some(&self.window)); - } -} - -/// A name for a query the user has not named, taken from the first -/// line that is neither blank nor a comment. It is a starting point in -/// an entry the user can edit, not a final name. -fn suggested_query_name(query: &str) -> String { - const MAX_CHARS: usize = 40; - for line in query.lines() { - let trimmed = line.trim().trim_end_matches(';').trim(); - if trimmed.is_empty() || trimmed.starts_with("--") { - continue; - } - return trimmed - .chars() - .take(MAX_CHARS) - .collect::() - .trim_end() - .to_owned(); - } - String::new() -} - -#[cfg(test)] -mod tests { - use super::suggested_query_name; - - #[test] - fn the_suggested_name_is_the_first_real_line() { - assert_eq!( - suggested_query_name("\n-- yesterday's orders\nSELECT * FROM orders;\n"), - "SELECT * FROM orders" - ); - } - - #[test] - fn a_long_first_line_is_cut_rather_than_run_on() { - let name = suggested_query_name("SELECT a, b, c, d, e, f, g, h FROM a_table_with_a_long_name"); - - assert_eq!(name.chars().count(), 40); - assert!(!name.ends_with(' ')); - } - - #[test] - fn a_query_that_is_only_comments_suggests_nothing() { - assert_eq!(suggested_query_name("-- nothing here\n\n"), ""); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/confirming_delete.rs b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs new file mode 100644 index 0000000000..95011dc730 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs @@ -0,0 +1,16 @@ +use libadwaita as adw; +use tablepro_core::ColumnInfo; + +use super::row_deletion::RowDeletion; + +/// A bulk delete the user has not answered yet. +/// +/// Its rows are keyed by the primary key of the columns they were read +/// under, so a change to those columns moves them or cancels the delete +/// before it lands. +pub(super) struct ConfirmingDelete { + pub(super) ticket: u64, + pub(super) deletion: RowDeletion, + pub(super) columns: Vec, + pub(super) dialog: adw::AlertDialog, +} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index a07bbac228..9756fa5b81 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::sync::Arc; -use tablepro_core::browse::{PageRequest, SortKey, build_count, build_page, split_page}; +use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_count, build_page}; use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; use tablepro_core::{ColumnInfo, FilterSet, QueryResult, RowLimit}; @@ -123,14 +123,15 @@ impl BrowseModel { )?; let limit = RowLimit::new(plan.limit).unwrap_or(RowLimit::EDITOR_DEFAULT); let result = lease.query_bound(&spec.statement, limit, options).await?; - let page = split_page(result, &spec)?; - Ok(QueryResult::new(page.columns, page.rows).truncated(page.truncated)) + check_shape(&result, &spec.layout)?; + Ok((result, spec.layout)) } }) .await - .map(|result| PageReply { + .map(|(result, layout)| PageReply { position: reply_position, result, + layout, }) }; self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); @@ -218,7 +219,7 @@ impl BrowseModel { /// stays with a toast; without one, the error page says why rather /// than a spinner that nothing will replace. fn on_filter_problem(&mut self, message: &str, fx: &mut Effects) { - if self.current_result.is_some() { + if self.page.is_some() { self.refresh_inner_stack_visibility(); fx.output(BrowseTabOutput::ShowToast(message.to_owned())); return; @@ -242,7 +243,11 @@ impl BrowseModel { } } Err(error) => { - tracing::warn!(%error, "could not read the table's columns"); + tracing::warn!( + kind = failure_kind(&error), + category = ?error.category(), + "could not read the table's columns" + ); self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } @@ -256,7 +261,7 @@ impl BrowseModel { match value { Ok(reply) => { let offset = reply.offset().unwrap_or(self.current_offset); - self.on_rows_loaded(offset, reply.result, fx); + self.on_rows_loaded(offset, reply, fx); } Err(error) => { self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); @@ -272,7 +277,11 @@ impl BrowseModel { match value { Ok(count) => self.on_row_count_loaded(count, fx), Err(error) => { - tracing::debug!(%error, "the row count could not be read"); + tracing::debug!( + kind = failure_kind(&error), + category = ?error.category(), + "the row count could not be read" + ); self.retry_if_link_moved(BrowseRequestKind::Count, fx); } } @@ -323,3 +332,29 @@ impl BrowseModel { } } } + +/// Every row has the columns the page asked for, visible then identity, +/// so the split on the GTK thread takes each row apart at the same place. +fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSqlError> { + let expected = layout.width(); + let found = std::iter::once(result.columns.len()) + .chain(result.rows.iter().map(Vec::len)) + .find(|width| *width != expected); + match found { + Some(found) => Err(BrowseSqlError::ShapeMismatch { expected, found }), + None => Ok(()), + } +} + +/// What a failed read was, for the log. A server's message can quote +/// the statement it rejected, and with it a literal from the user's +/// filter, so the log names the failure and leaves its text to the UI. +fn failure_kind(error: &OperationError) -> &'static str { + match error { + OperationError::Driver(_) => "driver", + OperationError::Sql(_) | OperationError::Browse(_) | OperationError::Build(_) => "statement", + OperationError::Panicked(_) => "panic", + OperationError::Cancelled => "cancelled", + OperationError::Closed => "closed", + } +} diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 2921376c7d..fb3ea4861f 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -34,6 +34,8 @@ pub struct BrowseTab { #[template_child] pub insert_button: TemplateChild, #[template_child] + pub delete_button: TemplateChild, + #[template_child] pub first_button: TemplateChild, #[template_child] pub prev_button: TemplateChild, @@ -107,21 +109,11 @@ fn install_bindings(klass: &mut ::Class) { none, send(|| BrowseTabInput::ClearSelection, glib::Propagation::Proceed), ); - klass.add_binding( - gdk::Key::Delete, - none, - send(|| BrowseTabInput::DeleteSelectedRow, glib::Propagation::Stop), - ); klass.add_binding( gdk::Key::n, ctrl, send(|| BrowseTabInput::InsertRow, glib::Propagation::Stop), ); - // No GNOME precedent for "set NULL"; Ctrl+Backspace is delete-word - // in every text field, so the null shortcut is Ctrl+Shift+N. - klass.add_binding(gdk::Key::n, ctrl | gdk::ModifierType::SHIFT_MASK, |tab| { - tab.set_focused_cell_null() - }); // A cell editor's text field consumes Ctrl+C and Ctrl+V first, so // these only see the grid's own copy and paste. klass.add_binding( @@ -169,8 +161,10 @@ impl ObjectImpl for BrowseTab { SIGNALS.get_or_init(|| { vec![ Signal::builder("state-changed").build(), + // Boxed: a GValue string ends at a NUL, and a copied cell + // can hold one. Signal::builder("copy-to-clipboard") - .param_types([String::static_type()]) + .param_types([glib::BoxedAnyObject::static_type()]) .build(), Signal::builder("export-results") .param_types([glib::BoxedAnyObject::static_type()]) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 1196cd2a61..81ef35d040 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -7,9 +7,12 @@ mod browse_command; mod browse_request_kind; +mod confirming_delete; mod fetch; mod imp; mod page_reply; +mod page_state; +mod row_deletion; #[cfg(test)] mod tests; @@ -19,13 +22,13 @@ use std::rc::Rc; use std::sync::Arc; use gtk4 as gtk; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; -use tablepro_core::edit::{CellInput, EditContext, EditParseError}; +use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; use tablepro_core::export::RenderInsertError; use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::{ColumnInfo, QueryResult, Value}; @@ -34,16 +37,21 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; +use self::confirming_delete::ConfirmingDelete; pub(crate) use self::page_reply::PageReply; +use self::page_state::PageState; +use self::row_deletion::{RowDeletion, plan_row_deletion}; use super::grid::{ - CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, RowObject, TabGridContext, - build_column_view, cell_editability, + CellPreset, ColumnWidthBinding, DeleteAvailability, GridContent, GridMsg, GridRowId, GridRowIndex, GridSink, + RowKeyBuilder, RowObject, TabGridContext, build_column_view, cell_editability, cell_presets, delete_availability, + edit_context, edit_row, export_snapshot, snapshot_result, }; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_tracker::{RowKey, StatementSource, pk_identity}; +use crate::services::change_tracker::{RowKey, StatementSource, new_draft_cell, pk_identity}; +use crate::services::column_relayout::ColumnRelayout; use crate::services::save_batch::SaveTarget; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; @@ -138,6 +146,7 @@ impl BrowseTab { imp.toolbar.add_top_bar(&filter_strip.widget); tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); + let delete_action = tab.install_delete_action(); // Handled at once: a tracked change redraws the cells bound to its // row in place, so the cell that sent it is still there for the @@ -176,7 +185,7 @@ impl BrowseTab { current_sort: init.initial_sort, current_filter: initial_filter, current_columns: Vec::new(), - current_result: None, + page: None, shown_view: None, current_selection: None, current_total_rows: None, @@ -201,6 +210,8 @@ impl BrowseTab { filter_badge: imp.filter_badge.get(), filter_strip: Some(filter_strip), insert_button: imp.insert_button.get(), + delete_button: imp.delete_button.get(), + delete_action, pending_revealer: imp.pending_revealer.get(), save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), @@ -209,6 +220,8 @@ impl BrowseTab { pending_label: imp.pending_label.get(), grid_sink, suppress_combo_emit: Cell::new(true), + confirming_delete: None, + next_confirmation: 0, }; imp.model.replace(Some(model)); imp.link_watch.replace(Some(WatchForwarder::new( @@ -278,10 +291,6 @@ impl BrowseTab { self.imp().model.try_borrow().ok()?.as_ref().map(read) } - pub fn snapshot(&self) -> Option { - self.read(|model| model.current_result.clone()).flatten() - } - pub fn columns(&self) -> Vec { self.read(|model| model.current_columns.clone()).unwrap_or_default() } @@ -315,7 +324,9 @@ impl BrowseTab { self.connect_closure( "copy-to-clipboard", false, - glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + glib::closure_local!(move |tab: &Self, text: glib::BoxedAnyObject| { + f(tab, text.borrow::().clone()); + }), ) } @@ -371,7 +382,9 @@ impl BrowseTab { fn emit_output(&self, output: BrowseTabOutput) { match output { BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), - BrowseTabOutput::CopyToClipboard(text) => self.emit_by_name::<()>("copy-to-clipboard", &[&text]), + BrowseTabOutput::CopyToClipboard(text) => { + self.emit_by_name::<()>("copy-to-clipboard", &[&glib::BoxedAnyObject::new(text)]); + } BrowseTabOutput::ExportResults(request) => { self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(request)]); } @@ -444,26 +457,30 @@ impl BrowseTab { }); } - /// Set the focused cell to NULL, for Ctrl+Shift+N. A locked cell - /// lets the key go on. - fn set_focused_cell_null(&self) -> glib::Propagation { - // `RootExt::focus` would return the window itself. - let focus = self - .root() - .and_downcast::() - .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)); - let Some(cell) = focus.as_ref().and_then(GridCell::from_focus) else { - return glib::Propagation::Proceed; - }; - let (None, Some(row)) = (cell.lock(), cell.row()) else { - return glib::Propagation::Proceed; - }; - self.send(BrowseTabInput::GridSetCellValue { - row, - column: cell.column(), - preset: CellPreset::Null, + /// `tab.delete-rows`, which the Delete key and the Delete button + /// both name. While it is disabled the key goes on to the window. + fn install_delete_action(&self) -> gio::SimpleAction { + let action = gio::SimpleAction::new("delete-rows", None); + action.set_enabled(false); + let tab = self.downgrade(); + action.connect_activate(move |_, _| { + let Some(tab) = tab.upgrade() else { + return; + }; + let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); + tab.send(BrowseTabInput::DeleteRows(rows)); }); - glib::Propagation::Stop + let group = gio::SimpleActionGroup::new(); + group.add_action(&action); + self.insert_action_group("tab", Some(&group)); + + let shortcuts = gtk::ShortcutController::new(); + shortcuts.add_shortcut(gtk::Shortcut::new( + Some(gtk::KeyvalTrigger::new(gdk::Key::Delete, gdk::ModifierType::empty())), + Some(gtk::NamedAction::new("tab.delete-rows")), + )); + self.add_controller(shortcuts); + action } } @@ -471,15 +488,15 @@ impl From for BrowseTabInput { fn from(msg: GridMsg) -> Self { match msg { GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, - GridMsg::CellEdited { row, column, input } => Self::GridCellEdited { row, column, input }, + GridMsg::Edit { row, column, edit } => Self::GridEdit { row, column, edit }, GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), GridMsg::ShowToast(text) => Self::GridShowToast(text), - GridMsg::CopyRowAsInsert { row } => Self::GridCopyRowAsInsert { row }, + GridMsg::CopyRowsAsInsert { rows } => Self::GridCopyRowsAsInsert(rows), GridMsg::SetCellValue { row, column, preset } => Self::GridSetCellValue { row, column, preset }, GridMsg::ExportResults(result) => Self::GridExportResults(result), - GridMsg::DeleteRowAt { row } => Self::GridDeleteRowAt { row }, + GridMsg::DeleteRows { rows } => Self::DeleteRows(rows), GridMsg::InsertRow => Self::InsertRow, - GridMsg::DuplicateRow { row } => Self::DuplicateRow { row }, + GridMsg::DuplicateRows { rows } => Self::DuplicateRows(rows), } } } @@ -549,9 +566,10 @@ pub(crate) struct BrowseModel { /// schema and table. current_filter: tablepro_core::FilterSet, current_columns: Vec, - current_result: Option, - /// What `current_result` was read under. A read that fails while - /// asking for that same page again leaves it on screen. + /// The page on screen, whose rows the grid's store holds. + page: Option, + /// What `page` was read under. A read that fails while asking for + /// that same page again leaves it on screen. shown_view: Option, current_selection: Option, current_total_rows: Option, @@ -588,6 +606,9 @@ pub(crate) struct BrowseModel { filter_badge: gtk::Label, filter_strip: Option, insert_button: gtk::Button, + delete_button: gtk::Button, + /// `tab.delete-rows`, enabled while the selection can be deleted. + delete_action: gio::SimpleAction, pending_revealer: gtk::Revealer, save_button: gtk::Button, discard_button: gtk::Button, @@ -598,6 +619,8 @@ pub(crate) struct BrowseModel { /// Set until the first page lands, so the page-size dropdown being /// set from a restored state does not count as the user choosing. suppress_combo_emit: Cell, + confirming_delete: Option, + next_confirmation: u64, } #[derive(Debug)] @@ -632,16 +655,15 @@ pub(crate) enum BrowseTabInput { /// Queued after an insert, so the focus lands on the column view /// as it is once the insert is drawn. FocusInsertedDraft, - DeleteSelectedRow, - GridCellEdited { + DeleteRows(Vec), + /// Checked against the cell's lock before the tracker takes it. + GridEdit { row: RowObject, column: usize, - input: String, + edit: TrackerEdit, }, - /// Cell context-menu "Set Value". The grid names the preset; the - /// tab resolves it against the column's declared type, because - /// only the tab holds the column metadata that says whether an - /// empty string is a value the column can hold. + /// A preset from a cell's menu or Ctrl+Backspace. The tab resolves it + /// against the column, so both paths reach the same value. GridSetCellValue { row: RowObject, column: usize, @@ -650,15 +672,8 @@ pub(crate) enum BrowseTabInput { GridShowToast(String), GridExportResults(QueryResult), ExportCurrentPage, - GridDeleteRowAt { - row: RowObject, - }, - GridCopyRowAsInsert { - row: RowObject, - }, - DuplicateRow { - row: RowObject, - }, + GridCopyRowsAsInsert(Vec), + DuplicateRows(Vec), GridCopyToClipboard(String), Undo, Redo, @@ -670,9 +685,11 @@ pub(crate) enum BrowseTabInput { DiscardAndRefresh, /// A delete the user confirmed, or one small enough to need no /// confirmation. - MarkRowsDeleted { - rows: Vec<(RowKey, Vec)>, - drafts: Vec, + MarkRowsDeleted(RowDeletion), + /// The user answered the bulk delete confirmation `ticket` opened. + BulkDeleteAnswered { + ticket: u64, + confirmed: bool, }, /// The save landed. Edits made while it ran stay pending. SaveCommitted, @@ -743,17 +760,6 @@ impl BrowseModel { } } - /// A page's rows, each named by its primary key, which is the key - /// the tracker files its edits under. - fn page_rows(&self, result: &QueryResult) -> Vec { - let pk_indices = self.pk_indices(); - result - .rows - .iter() - .map(|cells| RowObject::persisted(persisted_key(cells, &pk_indices), cells.clone().into())) - .collect() - } - /// Checked again here because a keyboard shortcut or a late event /// can reach a locked cell. fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { @@ -771,51 +777,41 @@ impl BrowseModel { false } - fn pk_indices(&self) -> Vec { - self.current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect() - } - - /// What the paginator's Export button writes. Built from the live - /// grid so it matches the context menu's Export Results row for - /// row; falls back to the fetch itself before the grid exists. + /// What the paginator's Export button writes: the grid's rows, so + /// it matches the context menu's Export Results row for row. fn export_payload(&self) -> Option { - let current = self.current_result.as_ref()?; - let Some(column_view) = self.current_column_view.as_ref() else { - return Some(current.clone()); - }; - Some(super::grid::export_snapshot( - column_view, - ¤t.columns, - current.truncated, - &self.grid_context(), - )) - } - - /// Resolve a "Set Value" preset against the column it lands in. - /// The grid offers Empty only on free-text columns, so the - /// fallback here is for the keyboard and action-activation paths: - /// an empty string means NULL on a column that takes one, and is - /// refused on a column that does not, exactly as typing an empty - /// value into the cell would be. - fn resolve_cell_preset(&self, preset: CellPreset, col_index: usize, context: EditContext) -> Result { - match preset { - CellPreset::Null => Ok(Value::Null), - CellPreset::Empty => parse_input_for_column("", self.current_columns.get(col_index), context), - } + let page = self.page.as_ref()?; + let rows = self + .current_column_view + .as_ref() + .map(|column_view| export_snapshot(column_view, &self.grid_context())) + .unwrap_or_default(); + Some(snapshot_result(page.result_columns.clone(), &rows, page.truncated)) } - /// Whether an edit lands in a draft row, which is the one place an - /// empty cell can mean the server's default. - fn edit_context_for(row: &RowObject) -> EditContext { - match row.id() { - GridRowId::Draft(_) => EditContext::InsertDraft, - GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, - } + /// Resolve a preset against the column it lands in. The menu offers + /// only the ones the cell can take; the keyboard's Set NULL reaches + /// here for any cell, and a column that refuses the value says why, + /// as committing an empty cell would. + fn resolve_cell_preset( + &self, + preset: CellPreset, + row: &RowObject, + col: usize, + ) -> Option> { + let Some(column) = self.current_columns.get(col) else { + tracing::error!(col, "a preset reached a column the tab does not have"); + return None; + }; + let presets = cell_presets(column, row.id()); + Some(match preset { + CellPreset::Empty => parse_edit_text("", column, edit_context(row.id())) + .map_err(|error| crate::ui::error_text::edit_parse_message(&error)), + CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), + CellPreset::Null => Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)), + CellPreset::Default if presets.default => Ok(CellInput::Default), + CellPreset::Default => Err(crate::i18n::gettext("This cell can't take the column's default")), + }) } fn table_label(&self) -> String { @@ -855,16 +851,30 @@ impl BrowseModel { /// row keeps its object, so the selection and the focus stay on it. fn apply_tracker_delta(&mut self, delta: &TrackerDelta, fx: &mut Effects) { if let Some(store) = self.list_store() { - for change in &delta.visual { - apply_visual_change(&store, &mut self.row_index, change); - } + apply_draft_changes(&store, &mut self.row_index, delta); } - for key in &delta.rows { + for key in &delta.changed { if let Some(row) = self.row_index.get(key) { row.bump_revision(); } } self.sync_pending_chrome(delta.pending_count, fx); + self.sync_delete_action(); + } + + fn selected_rows(&self) -> Vec { + self.current_selection.as_ref().map(selected_rows).unwrap_or_default() + } + + /// The rows on screen can change under the selection without it + /// changing, so this runs on every delta and page as well. + fn sync_delete_action(&self) { + show_delete_availability( + &self.delete_action, + &self.delete_button, + &self.selected_rows(), + self.read_only, + ); } /// The footer and the tab's dirty flag, which App turns into the @@ -887,21 +897,21 @@ impl BrowseModel { self.apply_tracker_delta(&delta, fx); } - /// A typed or preset value for one cell, against the row the edit - /// was made on, wherever it sits now. - fn edit_cell(&mut self, row: &RowObject, col: usize, value: Value, fx: &mut Effects) { - match row.id() { - GridRowId::Draft(draft_id) => { + /// A preset value for one cell, against the row the edit was made + /// on, wherever it sits now. + fn edit_cell(&mut self, row: &RowObject, col: usize, input: CellInput, fx: &mut Effects) { + match (row.id(), input) { + (GridRowId::Draft(draft_id), input) => { self.apply_edit( TrackerEdit::DraftCell { draft_id: *draft_id, col, - value, + input, }, fx, ); } - GridRowId::Persisted(row_key) => { + (GridRowId::Persisted(row_key), CellInput::Value(value)) => { let Some(original) = row.cell(col).cloned() else { return; }; @@ -915,7 +925,7 @@ impl BrowseModel { fx, ); } - GridRowId::Result | GridRowId::Unkeyed => {} + (GridRowId::Persisted(_), CellInput::Default) | (GridRowId::Result | GridRowId::Unkeyed, _) => {} } } @@ -1007,42 +1017,20 @@ impl BrowseModel { *self.pending_focus_restore.borrow_mut() = row.id().tracker_key(); } - /// Scroll + focus + select the freshly-prepended draft row, then - /// open its first editable cell for input. Queued on idle so the - /// inner-stack flip (empty → grid) has time to allocate the - /// ScrolledWindow's adjustments before `cv.scroll_to(...)` runs: - /// in the same tick GTK aborts with + /// Edit the first editable cell of the new draft, which is always + /// the top row. Queued on idle so the inner-stack flip (empty to + /// grid) has allocated the ScrolledWindow's adjustments before the + /// grid scrolls: in the same tick GTK aborts with /// `gtk_list_base_update_adjustments: bounds.y == 0`. fn focus_inserted_draft(&self) { let Some(cv) = self.current_column_view.clone() else { return; }; - let selection = self.current_selection.clone(); glib::idle_add_local_once(move || { - // The column view may be detached by now (F5 between the - // insert and this idle). The row was inserted; only the - // auto-edit is missed. - if cv.root().is_none() { - return; - } - // New drafts always land at position 0 (prepended). - if let Some(sel) = selection.as_ref() { - sel.select_item(0, true); - } - cv.scroll_to( - 0, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - let Some(window) = cv.root().and_then(|r| r.dynamic_cast::().ok()) else { - return; - }; - let Some(focused) = gtk::prelude::GtkWindowExt::focus(&window) else { - return; - }; - if let Some(cell) = GridCell::from_focus(&focused) { - super::grid::enter_edit_mode(&cell); + // F5 between the insert and this idle detaches the column + // view. The row was inserted; only the edit is missed. + if cv.root().is_some() { + edit_row(&cv, 0); } }); } @@ -1068,60 +1056,75 @@ impl BrowseModel { } } - /// Build the `ColumnView` once both the columns and the current - /// page are in. Until then the stack stays on the loading page, so - /// the user cannot edit cells whose editability is not known yet. + /// The columns came back the same, so the page on screen still + /// stands under them: only the chrome follows. fn render_grid_if_ready(&mut self, fx: &mut Effects) { - let Some(result) = self.current_result.clone() else { - return; - }; - if self.current_columns.is_empty() { + if self.page.is_none() || !self.column_view_matches_current_columns() { return; } + self.refresh_grid_chrome(); + self.restore_focused_row(); + fx.output(BrowseTabOutput::StateChanged); + } - // Same columns as the last render (sort flips, page changes, - // save reloads): keep the `ColumnView` and only refresh the - // `ListStore`. + /// Put a page's rows on screen. Under the same columns the store + /// takes them in one splice, which the column view sees as a single + /// change; otherwise the view is built for them. + fn show_page(&mut self, rows: Vec, fx: &mut Effects) { + let Some(page) = self.page.as_ref() else { + return; + }; if self.column_view_matches_current_columns() && let Some(store) = self.list_store() { - self.refresh_grid_data(&result, &store); - self.refresh_grid_chrome(&result); + store.splice(0, store.n_items(), &rows); + self.reindex_rows(); + self.sync_delete_action(); + self.refresh_grid_chrome(); self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); return; } clear_box(&self.grid_holder); - let tab_ctx = self.grid_context(); + let content = GridContent { + columns: page.result_columns.clone(), + rows, + truncated: page.truncated, + }; let (column_view, selection) = build_column_view( - &result, - self.page_rows(&result), + content, &self.current_columns, self.grid_sink.clone(), self.current_sort, true, - tab_ctx, + self.grid_context(), ); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); self.rendered_columns = self.current_columns.clone(); // The selection model is new per rebuild, so the previous - // handler goes with the old one. - let selection_label_for_signal = self.selection_label.clone(); + // handler goes with the old one. The tab holds the selection, so + // the handler holds the tab's widgets weakly. if let Some(sel) = self.current_selection.as_ref() { + let label = self.selection_label.downgrade(); + let button = self.delete_button.downgrade(); + let action = self.delete_action.clone(); + let read_only = self.read_only; sel.connect_selection_changed(move |sel, _, _| { + let (Some(label), Some(button)) = (label.upgrade(), button.upgrade()) else { + return; + }; let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); - update_selection_chrome(&selection_label_for_signal, n); + update_selection_chrome(&label, n); + show_delete_availability(&action, &button, &selected_rows(sel), read_only); }); update_selection_chrome(&self.selection_label, 0); } - // Pending draft rows survive page changes, sort flips and F5: - // the tracker holds them, and the rebuilt store would not. - self.reprepend_drafts(); self.reindex_rows(); + self.sync_delete_action(); let scrolled = gtk::ScrolledWindow::builder() .child(&column_view) @@ -1129,7 +1132,7 @@ impl BrowseModel { .vexpand(true) .build(); self.grid_holder.append(&scrolled); - self.refresh_grid_chrome(&result); + self.refresh_grid_chrome(); self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); } @@ -1159,20 +1162,10 @@ impl BrowseModel { self.rendered_columns.clear(); self.row_index = GridRowIndex::default(); update_selection_chrome(&self.selection_label, 0); + self.sync_delete_action(); self.show_loading(); } - /// New rows under the same columns. The drafts go back on top so - /// they survive the swap. - fn refresh_grid_data(&mut self, result: &QueryResult, store: >k::gio::ListStore) { - store.remove_all(); - for row in self.page_rows(result) { - store.append(&row); - } - self.reprepend_drafts(); - self.reindex_rows(); - } - fn reindex_rows(&mut self) { match self.list_store() { Some(store) => self.row_index.rebuild(&store), @@ -1181,14 +1174,14 @@ impl BrowseModel { } /// Paginator label, button sensitivity and stack child: chrome that - /// depends on the result but not the column structure. - fn refresh_grid_chrome(&self, result: &QueryResult) { + /// depends on the page but not the column structure. + fn refresh_grid_chrome(&self) { self.refresh_crud_buttons(); self.update_paginator_label(); let on_first_page = self.current_offset == 0; self.first_button.set_sensitive(!on_first_page); self.prev_button.set_sensitive(!on_first_page); - let n_rows = result.rows.len() as u64; + let n_rows = self.page.as_ref().map_or(0, |page| page.row_count as u64); self.next_button.set_sensitive(n_rows == self.page_size); // Last only enables when the total is known and the tab is not // already on the last page. @@ -1208,20 +1201,11 @@ impl BrowseModel { /// status page hid the column view, and inserting a draft into it /// then scrolled a view with no allocated adjustments. fn refresh_inner_stack_visibility(&self) { - if self.current_result.is_some() { + if self.page.is_some() { self.inner_stack.set_visible_child_name("grid"); } } - fn reprepend_drafts(&self) { - let Some(store) = self.list_store() else { - return; - }; - for draft in self.tracker.drafts() { - store.insert(0, &RowObject::draft(draft.draft_id)); - } - } - fn has_primary_key(&self) -> bool { self.current_columns.iter().any(|c| c.primary_key) } @@ -1269,11 +1253,11 @@ impl BrowseModel { } fn update_paginator_label(&self) { - let Some(result) = self.current_result.as_ref() else { + let Some(page) = self.page.as_ref() else { self.paginator_label.set_label(""); return; }; - let n_rows = result.rows.len(); + let n_rows = page.row_count; if n_rows == 0 { // Reachable when the user navigated past the end of a // table that shrank in another session, before the count @@ -1282,8 +1266,8 @@ impl BrowseModel { .set_label(&crate::i18n::gettext("No rows on this page")); return; } - let start = self.current_offset + 1; - let end = self.current_offset + n_rows as u64; + let start = page.start_ordinal + 1; + let end = page.start_ordinal + n_rows as u64; let start_s = format_thousands(start); let end_s = format_thousands(end); let label = match self.current_total_rows { @@ -1312,7 +1296,7 @@ impl BrowseModel { /// before are dropped, so a later reload does not render against /// them before its own page arrives. fn show_error(&mut self, message: &str) { - self.current_result = None; + self.page = None; self.shown_view = None; self.current_total_rows = None; self.paginator_label.set_label(""); @@ -1324,16 +1308,43 @@ impl BrowseModel { self.inner_stack.set_visible_child_name("error"); } - fn on_rows_loaded(&mut self, offset: u64, mut result: QueryResult, fx: &mut Effects) { + /// Take the page apart into rows the store owns. Each row's cells + /// move into it, and the identity cells after them name the row and + /// are dropped, so nothing past the visible columns reaches a copy + /// or an export. The tab's drafts stay on top. + fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { self.current_offset = offset; - // A zero-row page has no columns of its own; the catalog's list - // lets the headers render anyway. - if result.columns.is_empty() && !self.current_columns.is_empty() { - result.columns = result_columns(&self.current_columns); - } - self.current_result = Some(result); + let PageReply { result, layout, .. } = reply; + let QueryResult { + columns, + rows, + truncated, + } = result; + // The tracker saves a row by its primary key, so that is what + // names it here too. + let keys = RowKeyBuilder::for_page(&pk_identity(&self.current_columns), &self.current_columns, &layout); + let row_count = rows.len(); + let drafts = self.tracker.drafts(); + let mut objects = Vec::with_capacity(drafts.len() + row_count); + objects.extend(drafts.iter().rev().map(|draft| RowObject::draft(draft.draft_id))); + let visible_end = layout.visible.end; + objects.extend(rows.into_iter().map(|mut cells| { + let identity = cells.split_off(visible_end.min(cells.len())); + let key = keys.as_ref().and_then(|keys| keys.key_for(&cells, &identity)); + RowObject::persisted(key, cells.into()) + })); + let result_columns = match columns.len() == layout.visible.len() { + true => columns, + false => columns.get(layout.visible.clone()).map(Arc::from).unwrap_or_default(), + }; + self.page = Some(PageState { + result_columns, + truncated, + row_count, + start_ordinal: offset, + }); self.shown_view = Some(self.page_view()); - self.render_grid_if_ready(fx); + self.show_page(objects, fx); } fn page_view(&self) -> PageView { @@ -1349,7 +1360,7 @@ impl BrowseModel { /// and a schema change do, the page stays and a toast says why; any /// other page on screen no longer matches what the tab asks for. fn on_read_failed(&mut self, message: &str, fx: &mut Effects) { - if self.current_result.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { + if self.page.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { self.refresh_inner_stack_visibility(); fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( "Couldn't refresh the rows: {error}", @@ -1364,21 +1375,17 @@ impl BrowseModel { // A page read under other columns would put their names in the // headers, so the grid waits for the page read under these. if self.current_columns != columns { - self.current_result = None; + self.page = None; self.shown_view = None; self.drop_grid(); } self.current_columns = columns.clone(); + self.rebase_confirming_delete(fx); let delta = self.tracker.set_target(SaveTarget { table: self.table_ref(), columns: columns.clone(), }); self.apply_tracker_delta(&delta, fx); - if let Some(result) = self.current_result.as_mut() - && result.columns.is_empty() - { - result.columns = result_columns(&columns); - } self.refresh_crud_buttons(); // Operator lists narrow per type, so the strip rebuilds against // the new columns. @@ -1519,28 +1526,33 @@ impl BrowseModel { self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } - BrowseTabInput::DuplicateRow { row } => { - if self.current_columns.is_empty() || self.grid_is_stale() { + BrowseTabInput::DuplicateRows(rows) => { + if self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { return; } - // With its pending edits, since the copy is of the row the - // user is looking at. - let source_cells = self.grid_context().effective_cells(&row); - // The duplicate is a new row, so values the database owns - // (key, identity, generated) are left to it. - let values: Vec = self - .current_columns + let ctx = self.grid_context(); + let copies = rows .iter() - .enumerate() - .map(|(i, col)| { - if col.primary_key || col.is_auto_increment || col.is_generated { - Value::Null - } else { - source_cells.get(i).cloned().unwrap_or(Value::Null) - } + .map(|row| { + // With its pending edits, since the copy is of the + // row the user is looking at. + let source = ctx.effective_inputs(row); + // The duplicate is a new row, so values the database + // owns (key, identity, generated) are left to it. + self.current_columns + .iter() + .enumerate() + .map(|(i, col)| { + if col.primary_key || col.is_auto_increment || col.is_generated { + new_draft_cell(col) + } else { + source.get(i).cloned().unwrap_or(CellInput::Value(Value::Null)) + } + }) + .collect() }) .collect(); - self.apply_edit(TrackerEdit::Insert { values }, fx); + self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } @@ -1548,51 +1560,33 @@ impl BrowseModel { if self.current_columns.is_empty() { return; } - let values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); - self.apply_edit(TrackerEdit::Insert { values }, fx); + let cells = self.current_columns.iter().map(new_draft_cell).collect(); + self.apply_edit(TrackerEdit::InsertDrafts { rows: vec![cells] }, fx); self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } - BrowseTabInput::DeleteSelectedRow => self.delete_selected_rows(fx), - BrowseTabInput::GridCellEdited { row, column, input } => { + BrowseTabInput::DeleteRows(rows) => self.delete_rows(&rows, fx), + BrowseTabInput::GridEdit { row, column, edit } => { // The cell shows what was typed until its row is drawn again. if self.refuses_edit(&row, column) { row.bump_revision(); return; } - // A column that holds line breaks keeps them; everywhere - // else a pasted newline would reach the SQL layer. - let keeps_line_breaks = self - .current_columns - .get(column) - .is_some_and(|c| c.column_type.kind.accepts_line_breaks()); - let normalized = match keeps_line_breaks { - true => input, - false => normalize_single_line_input(&input), - }; - let col = self.current_columns.get(column); - let new = match parse_input_for_column(&normalized, col, Self::edit_context_for(&row)) { - Ok(v) => v, - Err(message) => { - fx.output(BrowseTabOutput::ShowToast(message)); - row.bump_revision(); - return; - } - }; - self.edit_cell(&row, column, new, fx); + self.apply_edit(edit, fx); } BrowseTabInput::GridSetCellValue { row, column, preset } => { if self.refuses_edit(&row, column) { return; } - let value = match self.resolve_cell_preset(preset, column, Self::edit_context_for(&row)) { - Ok(value) => value, - Err(message) => { + let input = match self.resolve_cell_preset(preset, &row, column) { + Some(Ok(input)) => input, + Some(Err(message)) => { fx.output(BrowseTabOutput::ShowToast(message)); return; } + None => return, }; - self.edit_cell(&row, column, value, fx); + self.edit_cell(&row, column, input, fx); } BrowseTabInput::GridShowToast(message) => { fx.output(BrowseTabOutput::ShowToast(message)); @@ -1600,7 +1594,7 @@ impl BrowseModel { BrowseTabInput::GridExportResults(mut result) => { // The grid owns the rows it shows; whether the fetch was // cut short belongs to the tab. - result.truncated = self.current_result.as_ref().is_some_and(|r| r.truncated); + result.truncated = self.page.as_ref().is_some_and(|page| page.truncated); fx.output(BrowseTabOutput::ExportResults(ExportRequest { result, name: self.export_name(), @@ -1618,24 +1612,9 @@ impl BrowseModel { target: Some(self.sql_target()), })); } - BrowseTabInput::GridDeleteRowAt { row } => { - if self.grid_is_stale() { - return; - } - let (Some(row_key), Some(cells)) = (row.key(), row.cells()) else { - return; - }; - self.apply_edit( - TrackerEdit::Delete { - row_key: row_key.clone(), - original_values: cells.to_vec(), - }, - fx, - ); - } - BrowseTabInput::GridCopyRowAsInsert { row } => { + BrowseTabInput::GridCopyRowsAsInsert(rows) => { if !self.grid_is_stale() { - self.copy_row_as_insert(&row, fx); + self.copy_rows_as_insert(&rows, fx); } } BrowseTabInput::GridCopyToClipboard(text) => { @@ -1664,19 +1643,33 @@ impl BrowseModel { self.apply_tracker_delta(&delta, fx); self.fetch(BrowseRequestKind::Page, fx); } - BrowseTabInput::MarkRowsDeleted { rows, drafts } => { - for (row_key, original_values) in rows { - self.apply_edit( - TrackerEdit::Delete { - row_key, - original_values, - }, - fx, - ); + BrowseTabInput::BulkDeleteAnswered { ticket, confirmed } => { + // An answer to a confirmation the tab already cancelled + // or replaced. + if self + .confirming_delete + .as_ref() + .is_none_or(|pending| pending.ticket != ticket) + { + return; + } + if !confirmed { + self.confirming_delete = None; + return; } - for draft_id in drafts { - self.apply_edit(TrackerEdit::DiscardDraft { draft_id }, fx); + self.rebase_confirming_delete(fx); + if let Some(pending) = self.confirming_delete.take() { + fx.input(BrowseTabInput::MarkRowsDeleted(pending.deletion)); } + } + BrowseTabInput::MarkRowsDeleted(deletion) => { + self.apply_edit( + TrackerEdit::DeleteRows { + persisted: deletion.persisted, + drafts: deletion.drafts, + }, + fx, + ); // A bulk action ends the selection it acted on. if let Some(selection) = self.current_selection.as_ref() { selection.unselect_all(); @@ -1742,113 +1735,156 @@ impl BrowseModel { ); } - /// Mark the selected rows for deletion: persisted rows go to the - /// tracker, drafts are dropped. Nothing leaves the tab until Save. - fn delete_selected_rows(&self, fx: &mut Effects) { - let Some(selection) = self.current_selection.as_ref() else { - return; - }; - let positions = super::grid::selected_positions(selection); - if positions.is_empty() { + /// Mark saved rows for deletion and drop drafts. Nothing leaves the + /// tab until Save. The action is off for rows this cannot delete, so + /// such rows reaching here is a bug, which changes nothing. + fn delete_rows(&mut self, rows: &[RowObject], fx: &mut Effects) { + if self.grid_is_stale() { return; } - let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); - let mut draft_ids: Vec = Vec::new(); - let mut had_unkeyed_row = false; - for row in positions - .iter() - .filter_map(|position| selection.item(*position).and_downcast::()) - { - match row.id() { - GridRowId::Draft(draft_id) => draft_ids.push(*draft_id), - GridRowId::Persisted(key) => { - snapshot.push((key.clone(), row.cells().map(|cells| cells.to_vec()).unwrap_or_default())); - } - GridRowId::Unkeyed | GridRowId::Result => had_unkeyed_row = true, - } + if self.read_only { + tracing::error!("Delete reached a read-only tab"); + return; } - // A selection of drafts only needs no key. - if had_unkeyed_row { - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Cannot delete"), - body: crate::i18n::gettext("This table has no primary key, so editing is disabled."), - }); + let deletion = plan_row_deletion(rows); + if deletion.unkeyed > 0 { + tracing::error!(unkeyed = deletion.unkeyed, "Delete reached rows with no key"); return; } - if snapshot.is_empty() && draft_ids.is_empty() { + if deletion.is_empty() { return; } - let count = snapshot.len() + draft_ids.len(); - if count < BULK_DELETE_CONFIRM_THRESHOLD { - fx.input(BrowseTabInput::MarkRowsDeleted { - rows: snapshot, - drafts: draft_ids, - }); + if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD { + fx.input(BrowseTabInput::MarkRowsDeleted(deletion)); return; } + self.confirm_delete(deletion); + } + + fn confirm_delete(&mut self, deletion: RowDeletion) { + let count = deletion.len(); let title = crate::i18n::ngettext_f( "Delete {n} row?", "Delete {n} rows?", u32::try_from(count).unwrap_or(u32::MAX), &[("n", &count.to_string())], ); - let body = - crate::i18n::gettext("These rows will be marked for deletion. They aren't removed until you click Save."); + let body = deletion.confirmation_body(); let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); 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 pending = RefCell::new(Some((snapshot, draft_ids))); + // A second Delete while one waits replaces it. + self.cancel_confirming_delete(); + let ticket = self.next_confirmation; + self.next_confirmation += 1; let tab = self.this.clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); - if response == "delete" - && let Some((rows, drafts)) = pending.borrow_mut().take() - && let Some(tab) = tab.upgrade() - { - tab.send(BrowseTabInput::MarkRowsDeleted { rows, drafts }); + dialog.connect_response(None, move |_, response| { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::BulkDeleteAnswered { + ticket, + confirmed: response == "delete", + }); } }); dialog.present(Some(&self.inner_stack)); + self.confirming_delete = Some(ConfirmingDelete { + ticket, + deletion, + columns: self.current_columns.clone(), + dialog, + }); } - /// One row as an INSERT for the server the tab is connected to, + fn cancel_confirming_delete(&mut self) { + if let Some(pending) = self.confirming_delete.take() { + pending.dialog.force_close(); + } + } + + /// Moves a waiting delete's rows onto the columns the tab read + /// since. A key read under another primary key can name another + /// row, so then the delete is cancelled rather than sent with it. + fn rebase_confirming_delete(&mut self, fx: &mut Effects) { + let Some(pending) = self.confirming_delete.as_mut() else { + return; + }; + if pending.columns == self.current_columns { + return; + } + let layout = ColumnRelayout::new(&pending.columns, &self.current_columns); + let moved: Option)>> = pending + .deletion + .persisted + .iter() + .map(|(key, values)| { + layout + .key(key) + .ok() + .map(|()| (key.clone(), layout.fetched(values).into())) + }) + .collect(); + match moved { + Some(rows) => { + pending.deletion.persisted = rows; + pending.columns = self.current_columns.clone(); + } + None => { + self.cancel_confirming_delete(); + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "The table's primary key changed, so no rows were marked for deletion. Select them again.", + ))); + } + } + } + + /// Each row as an INSERT for the server the tab is connected to, /// pending edits included. A draft's is the INSERT its save would /// run, so the columns the server fills stay out of it. - fn copy_row_as_insert(&self, row: &RowObject, fx: &mut Effects) { + fn copy_rows_as_insert(&self, rows: &[RowObject], fx: &mut Effects) { let Some(dialect) = self.session.current_dialect() else { fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( "Connect to a database first.", ))); return; }; - let cells = match row.id() { - GridRowId::Draft(draft_id) => self - .tracker - .draft_cells(*draft_id, &self.current_columns) - .unwrap_or_default(), - _ => self - .grid_context() - .effective_cells(row) - .into_iter() - .map(CellInput::Value) - .collect(), - }; - match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { - Ok(sql) => fx.output(BrowseTabOutput::CopyToClipboard(sql)), - Err(error) => { - tracing::debug!(%error, "a row could not be written as an INSERT"); - let message = match error { - RenderInsertError::Literal(_) => { - crate::i18n::gettext("This row has a value that can't be written as SQL") - } - RenderInsertError::NoValues => crate::i18n::gettext("This row has no value to put in an INSERT"), - }; - fx.output(BrowseTabOutput::ShowToast(message)); + let ctx = self.grid_context(); + let mut statements = Vec::with_capacity(rows.len()); + for row in rows { + let cells = match row.id() { + GridRowId::Draft(draft_id) => self + .tracker + .draft_cells(*draft_id, &self.current_columns) + .unwrap_or_default(), + _ => ctx + .snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) + .collect(), + }; + match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { + Ok(sql) => statements.push(sql), + Err(error) => { + tracing::debug!(%error, "a row could not be written as an INSERT"); + let message = match error { + RenderInsertError::Literal(_) => { + crate::i18n::gettext("This row has a value that can't be written as SQL") + } + RenderInsertError::NoValues => { + crate::i18n::gettext("This row has no value to put in an INSERT") + } + }; + fx.output(BrowseTabOutput::ShowToast(message)); + return; + } } } + if !statements.is_empty() { + fx.output(BrowseTabOutput::CopyToClipboard(statements.join("\n"))); + } } /// Same renderer as the context menu's Copy as > Rows: one @@ -1865,7 +1901,7 @@ impl BrowseModel { let rows: Vec> = positions .iter() .filter_map(|position| selection.item(*position).and_downcast::()) - .map(|row| ctx.effective_cells(&row)) + .map(|row| ctx.snapshot(&row).to_values()) .collect(); if rows.is_empty() { return; @@ -1887,51 +1923,46 @@ fn qualified(schema: Option<&str>, table: &str) -> String { } } -fn apply_visual_change(store: >k::gio::ListStore, index: &mut GridRowIndex, change: &VisualChange) { - match change { - VisualChange::InsertDraft { draft_id } => { - let row = RowObject::draft(*draft_id); - store.insert(0, &row); - index.insert(&row); - } - VisualChange::RemoveDraft { draft_id } => { - let key = RowKey::Draft(*draft_id); - if let Some(position) = index.position_in(store, &key) { - store.remove(position); - } - index.remove(&key); +/// The one place the store gains or loses draft rows. +fn apply_draft_changes(store: >k::gio::ListStore, index: &mut GridRowIndex, delta: &TrackerDelta) { + for draft_id in &delta.removed_drafts { + let key = RowKey::Draft(*draft_id); + if let Some(position) = index.position_in(store, &key) { + store.remove(position); } + index.remove(&key); + } + for draft_id in &delta.inserted_drafts { + let row = RowObject::draft(*draft_id); + store.insert(0, &row); + index.insert(&row); } } -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); - } +fn selected_rows(selection: >k::MultiSelection) -> Vec { + super::grid::selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() } -/// Collapse newlines / carriage returns to spaces, then squash any -/// resulting consecutive whitespace runs to a single space. Applied -/// at cell-edit commit time for non-JSON columns so a multi-line -/// clipboard paste into a single-line cell never reaches the SQL -/// layer with embedded `\n`. -fn normalize_single_line_input(text: &str) -> String { - let replaced: String = text - .chars() - .map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }) - .collect(); - replaced.split_whitespace().collect::>().join(" ") +/// The Delete key and button follow the action; the button's tooltip +/// says why it is off. +fn show_delete_availability(action: &gio::SimpleAction, button: >k::Button, rows: &[RowObject], read_only: bool) { + let availability = delete_availability(rows.iter().map(RowObject::id), read_only); + action.set_enabled(availability == DeleteAvailability::Available); + let explanation = match availability { + DeleteAvailability::Unavailable(block) => block.explanation(), + DeleteAvailability::Available => None, + }; + let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete selected rows (Delete)")); + button.set_tooltip_text(Some(&tooltip)); } -/// A fetched row's key from its primary key cells, in the table's -/// column order. `None` when the table has no primary key or the row is -/// too short to hold it. -fn persisted_key(cells: &[Value], pk_indices: &[usize]) -> Option { - let pk_values: Vec = pk_indices - .iter() - .map(|&i| cells.get(i).cloned()) - .collect::>()?; - RowKey::from_pk_values(&pk_values) +fn clear_box(b: >k::Box) { + while let Some(child) = b.first_child() { + b.remove(&child); + } } /// Update the selection-count badge in response to a `MultiSelection` @@ -1951,28 +1982,6 @@ fn update_selection_chrome(label: >k::Label, n: u32) { label.set_visible(true); } -/// The value the user typed, as the column's own type. -/// -/// Parsing against the typed column rather than a type-name string is -/// what makes an out-of-range number a message beside the cell instead -/// of a failed transaction. What an empty cell means is core's rule: -/// the empty string where the column holds one, the server's default -/// in a draft row, NULL where the column takes it, and a refusal -/// otherwise. -fn parse_input_for_column(text: &str, col: Option<&ColumnInfo>, context: EditContext) -> Result { - let Some(col) = col else { - return Ok(Value::Text(text.to_string())); - }; - match tablepro_core::edit::parse_edit_text(text, col, context) { - Ok(CellInput::Value(value)) => Ok(value), - // A draft holds NULL for a cell left to the server; the save - // leaves that column out of the INSERT. - Ok(CellInput::Default) => Ok(Value::Null), - Err(EditParseError::Required) => Err(crate::i18n::gettext("Field is required")), - Err(error) => Err(error.to_string()), - } -} - /// Format a positive integer with thousands separators (1000 → 1,000). /// Used for page-size dropdown labels so they read naturally instead /// of the abbreviated "1 K" / "5 K" form. diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs index 3bac9deb07..0630cd83d4 100644 --- a/linux/crates/app/src/ui/browse_tab/page_reply.rs +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -1,10 +1,14 @@ use tablepro_core::QueryResult; +use tablepro_core::browse::PageLayout; use tablepro_core::meta::PagePosition; +/// A page as the server returned it, the row identity cells still after +/// the visible ones. #[derive(Debug, Clone)] pub(crate) struct PageReply { pub position: PagePosition, pub result: QueryResult, + pub layout: PageLayout, } impl PageReply { diff --git a/linux/crates/app/src/ui/browse_tab/page_state.rs b/linux/crates/app/src/ui/browse_tab/page_state.rs new file mode 100644 index 0000000000..c7569925fd --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_state.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +/// What the tab knows of the page on screen. Its rows are the grid's: +/// the store holds the only copy. +#[derive(Debug, Clone)] +pub(crate) struct PageState { + /// The page's visible columns. + pub result_columns: Arc<[ResultColumn]>, + /// The server stopped at the row limit. + pub truncated: bool, + pub row_count: usize, + /// How many rows of the table come before the page. + pub start_ordinal: u64, +} diff --git a/linux/crates/app/src/ui/browse_tab/row_deletion.rs b/linux/crates/app/src/ui/browse_tab/row_deletion.rs new file mode 100644 index 0000000000..bf0f56331a --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_deletion.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridRowId, RowObject}; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RowDeletion { + /// Each with the row as it was read. + pub persisted: Vec<(RowKey, Arc<[Value]>)>, + pub drafts: Vec, + /// Rows a DELETE has no key to name by. + pub unkeyed: usize, +} + +impl RowDeletion { + pub fn len(&self) -> usize { + self.persisted.len() + self.drafts.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// A draft is dropped at once with its undo history, so a set that + /// holds one cannot be described as waiting for Save. + pub fn confirmation_body(&self) -> String { + match (self.persisted.is_empty(), self.drafts.is_empty()) { + (_, true) => crate::i18n::gettext( + "These rows will be marked for deletion. They aren't removed until you click Save.", + ), + (true, false) => crate::i18n::gettext( + "These rows were never saved. They're removed now, and Undo won't bring them back.", + ), + (false, false) => crate::i18n::gettext( + "Saved rows will be marked for deletion and aren't removed until you click Save. Rows never saved are removed now, and Undo won't bring them back.", + ), + } + } +} + +pub fn plan_row_deletion(rows: &[RowObject]) -> RowDeletion { + let mut deletion = RowDeletion::default(); + for row in rows { + match row.id() { + GridRowId::Draft(draft_id) => deletion.drafts.push(*draft_id), + GridRowId::Persisted(key) => deletion.persisted.push((key.clone(), row.cells().unwrap_or_default())), + GridRowId::Unkeyed | GridRowId::Result => deletion.unkeyed += 1, + } + } + deletion +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::plan_row_deletion; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + #[test] + fn plan_row_deletion_partitions_drafts_persisted_and_unkeyed() { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let rows = [ + RowObject::draft(4), + RowObject::persisted(Some(key.clone()), vec![Value::Int(1), Value::Text("a".into())].into()), + RowObject::persisted(None, vec![Value::Int(2)].into()), + RowObject::result(vec![Value::Int(3)].into()), + RowObject::draft(5), + ]; + + let deletion = plan_row_deletion(&rows); + + assert_eq!(deletion.drafts, vec![4, 5]); + assert_eq!( + deletion.persisted, + vec![(key, vec![Value::Int(1), Value::Text("a".into())].into())] + ); + assert_eq!(deletion.unkeyed, 2); + assert_eq!(deletion.len(), 3); + } + + #[test] + fn the_confirmation_says_drafts_go_at_once() { + let saved = RowObject::persisted( + Some(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")), + vec![Value::Int(1)].into(), + ); + + let only_saved = plan_row_deletion(std::slice::from_ref(&saved)).confirmation_body(); + let only_drafts = plan_row_deletion(&[RowObject::draft(1)]).confirmation_body(); + let mixed = plan_row_deletion(&[saved, RowObject::draft(1)]).confirmation_body(); + + assert!(only_saved.contains("click Save"), "{only_saved}"); + assert!(!only_saved.contains("removed now"), "{only_saved}"); + assert!(only_drafts.contains("removed now"), "{only_drafts}"); + assert!(!only_drafts.contains("click Save"), "{only_drafts}"); + assert!(mixed.contains("click Save") && mixed.contains("removed now"), "{mixed}"); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 2857592b22..9c5410f096 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use std::time::Duration; +use gtk4::glib; use gtk4::prelude::*; use tablepro_core::column::{ColumnDefault, ColumnType}; -use tablepro_core::edit::EditContext; +use tablepro_core::edit::CellInput; use tablepro_core::filter::{FilterOp, FilterRule}; use tablepro_core::meta::RowIdentity; use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; @@ -11,13 +12,11 @@ use tablepro_session::Session; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; -use super::{ - BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_visual_change, format_thousands, parse_input_for_column, - persisted_key, -}; +use super::row_deletion::RowDeletion; +use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands}; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; use crate::test_support::{FakeSession, drain_main_context, wait_until}; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; @@ -81,197 +80,29 @@ fn format_thousands_handles_edges() { assert_eq!(format_thousands(999), "999"); } -#[test] -fn clearing_a_text_cell_stores_the_empty_string() { - let cleared = - parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).expect("an empty string"); - - assert_eq!(cleared, Value::Text(String::new())); -} - -#[test] -fn clearing_a_column_with_no_empty_value_yields_null() { - let cleared = parse_input_for_column("", Some(&col("timestamp", true)), EditContext::UpdateRow).expect("a null"); - - assert!(matches!(cleared, Value::Null)); -} - -#[test] -fn empty_on_not_null_with_default_is_left_to_the_server_only_in_a_draft() { - let column = col_with_default("timestamp", "now()"); - - let draft = parse_input_for_column("", Some(&column), EditContext::InsertDraft).unwrap(); - assert!(matches!(draft, Value::Null), "a draft leaves it to the default"); - - let existing = parse_input_for_column("", Some(&column), EditContext::UpdateRow); - assert!(existing.is_err(), "an UPDATE cannot ask for the default: {existing:?}"); -} - -#[test] -fn empty_on_a_required_column_with_no_empty_value_is_rejected() { - let r = parse_input_for_column("", Some(&col("integer", false)), EditContext::UpdateRow); - - assert!(r.is_err()); - assert!(r.as_ref().is_err_and(|message| message.contains("required")), "{r:?}"); -} - -#[test] -fn parses_int_decimal_float_bool() { - assert!(matches!( - parse_input_for_column("42", Some(&col("integer", false)), EditContext::UpdateRow).unwrap(), - Value::Int(42) - )); - assert!(matches!( - parse_input_for_column("3.14", Some(&col("real", false)), EditContext::UpdateRow).unwrap(), - Value::Float32(_) - )); - assert!(matches!( - parse_input_for_column("99.99", Some(&col("decimal(10,2)", false)), EditContext::UpdateRow).unwrap(), - Value::Decimal(_) - )); - assert!(matches!( - parse_input_for_column("yes", Some(&col("boolean", false)), EditContext::UpdateRow).unwrap(), - Value::Bool(true) - )); - // MySQL spells boolean as tinyint(1), so it parses as the integer it - // is and the grid renders 0 and 1. - assert!(matches!( - parse_input_for_column("0", Some(&col("tinyint(1)", false)), EditContext::UpdateRow).unwrap(), - Value::Int(0) - )); -} - -#[test] -fn parses_uuid_json_date_time_datetime_timestamptz() { - let uuid = parse_input_for_column( - "550e8400-e29b-41d4-a716-446655440000", - Some(&col("uuid", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(uuid, Value::Uuid(_))); - - let json = parse_input_for_column(r#"{"a":1}"#, Some(&col("jsonb", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(json, Value::Json(_))); - - let date = parse_input_for_column("2024-01-15", Some(&col("date", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(date, Value::Date(_))); - - let time = parse_input_for_column("14:30:00", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time, Value::Time(_))); - let time_short = parse_input_for_column("14:30", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time_short, Value::Time(_))); - - let datetime = parse_input_for_column( - "2024-01-15 14:30:00", - Some(&col("timestamp", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime, Value::Timestamp(_))); - let datetime_t = parse_input_for_column( - "2024-01-15T14:30:00", - Some(&col("datetime", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime_t, Value::Timestamp(_))); -} - -#[test] -fn rejects_invalid_type_specific_input() { - assert!(parse_input_for_column("not-a-number", Some(&col("integer", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("not-a-uuid", Some(&col("uuid", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("{not json", Some(&col("jsonb", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("2024/01/15", Some(&col("date", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("13:00:99", Some(&col("time", false)), EditContext::UpdateRow).is_err()); - assert!( - parse_input_for_column("128", Some(&col("tinyint", false)), EditContext::UpdateRow).is_err(), - "a number past the column's range was accepted" - ); - assert!(parse_input_for_column("not-a-date", Some(&col("timestamp", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("maybe", Some(&col("boolean", false)), EditContext::UpdateRow).is_err()); -} - -#[test] -fn unknown_type_falls_through_to_text() { - let r = parse_input_for_column( - "anything goes here", - Some(&col("varchar(255)", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(r, Value::Text(_))); -} - -#[test] -fn null_sentinel_typed_literally_is_text() { - // Only an actually-empty input becomes NULL. The grid clears the - // visual sentinel before an edit, so `` here was typed on - // purpose. - let r = parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).unwrap(); - match r { - Value::Text(s) => assert_eq!(s, ""), - other => panic!("expected Text(\"\") got {other:?}"), - } -} - -#[test] -fn a_fetched_row_is_keyed_by_its_primary_key_cells() { - let cells = vec![Value::Int(42), Value::Text("alice".into())]; - - assert_eq!(persisted_key(&cells, &[0]), RowKey::from_pk_values(&[Value::Int(42)])); -} - -#[test] -fn a_composite_key_keeps_the_key_order() { - // Two tables with the same columns in a different key order must not - // produce colliding keys. - let cells = vec![ - Value::Text("eu-west".into()), - Value::Text("ignored".into()), - Value::Int(7), - ]; - - assert_eq!( - persisted_key(&cells, &[2, 0]), - RowKey::from_pk_values(&[Value::Int(7), Value::Text("eu-west".into())]) - ); -} - -#[test] -fn a_row_without_its_key_cells_is_unkeyed() { - assert!( - persisted_key(&[Value::Int(1)], &[]).is_none(), - "a table with no primary key" - ); - assert!(persisted_key(&[Value::Int(1)], &[5]).is_none()); - assert!(persisted_key(&[Value::Int(1)], &[0, 1]).is_none()); -} - -#[test] -fn a_null_key_cell_still_names_the_row() { - assert!(matches!( - persisted_key(&[Value::Null, Value::Text("x".into())], &[0]), - Some(RowKey::Persisted(_)) - )); -} - fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { store.item(position).and_downcast::().expect("a row") } #[test] -fn visual_changes_add_and_remove_draft_rows() { +fn draft_changes_add_and_remove_draft_rows() { let store = gtk4::gio::ListStore::new::(); store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); let mut index = GridRowIndex::default(); + let inserted = TrackerDelta { + inserted_drafts: vec![4], + ..TrackerDelta::default() + }; + let removed = TrackerDelta { + removed_drafts: vec![4], + ..TrackerDelta::default() + }; - apply_visual_change(&store, &mut index, &VisualChange::InsertDraft { draft_id: 4 }); + apply_draft_changes(&store, &mut index, &inserted); assert_eq!(row_at(&store, 0).draft_id(), Some(4)); assert_eq!(index.get(&RowKey::Draft(4)), Some(row_at(&store, 0))); - apply_visual_change(&store, &mut index, &VisualChange::RemoveDraft { draft_id: 4 }); + apply_draft_changes(&store, &mut index, &removed); assert_eq!(store.n_items(), 1); assert_eq!(row_at(&store, 0).draft_id(), None); assert_eq!(index.get(&RowKey::Draft(4)), None); @@ -370,11 +201,30 @@ impl Harness { tab } + /// The second cell of each saved row on screen, empty while the + /// tab shows no page. fn shown(&self) -> Vec { - self.tab() - .snapshot() - .map(|result| result.rows.into_iter().filter_map(|row| row.get(1).cloned()).collect()) - .unwrap_or_default() + self.saved_rows() + .iter() + .filter_map(|row| row.cell(1).cloned()) + .collect() + } + + /// The saved rows the grid holds for the page on screen. + fn saved_rows(&self) -> Vec { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = self.tab().imp().model.borrow(); + let Some(selection) = model + .as_ref() + .filter(|model| model.page.is_some()) + .and_then(|model| model.current_selection.clone()) + else { + return Vec::new(); + }; + (0..selection.n_items()) + .filter_map(|position| selection.item(position).and_downcast::()) + .filter(|row| row.draft_id().is_none()) + .collect() } fn visible_page(&self) -> Option { @@ -785,6 +635,29 @@ fn a_refresh_under_a_filter_that_no_longer_fits_keeps_the_rows() { assert_eq!(harness.visible_page().as_deref(), Some("grid")); } +#[gtk4::test] +fn no_dim_label_class_remains() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + wait_until(WAIT, || { + crate::test_support::descendants(harness.tab()) + .iter() + .any(|widget| widget.is::()) + }) + .expect("the grid draws its cells"); + harness.tab().send(BrowseTabInput::ToggleFilterStrip); + + let dimmed: Vec = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter(|widget| widget.has_css_class("dim-label")) + .map(|widget| widget.type_().name().to_owned()) + .collect(); + + assert!(dimmed.is_empty(), "still dim-label: {dimmed:?}"); +} + #[gtk4::test] fn export_and_save_reach_the_window_as_signals() { let mut script = serving_table(); @@ -862,10 +735,10 @@ fn an_edit_held_for_the_running_save_says_so_once() { let _saving = tracker.begin_save().expect("a save").expect("the drafts"); let drafts: Vec = tracker.drafts().iter().map(|draft| draft.draft_id).collect(); - harness.tab().send(BrowseTabInput::MarkRowsDeleted { - rows: Vec::new(), + harness.tab().send(BrowseTabInput::MarkRowsDeleted(RowDeletion { drafts, - }); + ..RowDeletion::default() + })); assert_eq!(tracker.drafts().len(), 2, "a draft the save holds was dropped"); assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); @@ -905,27 +778,59 @@ fn duplicate_row_copies_pending_edits_of_persisted_row() { wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); let ada = harness.grid_row(0); - harness.tab().send(BrowseTabInput::GridCellEdited { - row: ada.clone(), - column: 1, - input: "Ada Lovelace".to_owned(), - }); - harness.tab().send(BrowseTabInput::DuplicateRow { row: ada }); + harness.tab().send(typed(&ada, 1, "Ada Lovelace")); + harness.tab().send(BrowseTabInput::DuplicateRows(vec![ada])); let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 1, "{drafts:?}"); - let texts: Vec> = drafts[0] - .values - .iter() - .map(tablepro_core::export::value_to_text) - .collect(); assert_eq!( - texts, - vec![None, Some("Ada Lovelace".to_owned())], + drafts[0].cells, + vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("Ada Lovelace".to_owned())) + ], "the duplicate left out the row's pending edit" ); } +#[gtk4::test] +fn duplicating_a_draft_leaves_its_defaulted_cells_to_the_server() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(BrowseTabInput::InsertRow); + let draft = harness.grid_row(0); + assert!(draft.draft_id().is_some(), "the new row goes on top"); + + harness.tab().send(BrowseTabInput::DuplicateRows(vec![draft])); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2, "{drafts:?}"); + assert_eq!( + drafts[1].cells[1], + CellInput::Default, + "the copy wrote NULL where the server fills in" + ); +} + +#[gtk4::test] +fn duplicating_a_selection_is_one_undo_step() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + + harness.tab().send(BrowseTabInput::DuplicateRows(vec![ + harness.grid_row(0), + harness.grid_row(1), + ])); + assert_eq!(draft_rows(&harness.store()), 2); + harness.tab().send(BrowseTabInput::Undo); + + assert_eq!(draft_rows(&harness.store()), 0, "one undo left copies behind"); + assert_eq!(harness.tracker.pending_count(), 0); +} + #[gtk4::test] fn copy_row_as_insert_uses_row_below_draft() { let mut script = serving_table(); @@ -942,7 +847,7 @@ fn copy_row_as_insert_uses_row_below_draft() { let below_draft = harness.grid_row(1); harness .tab() - .send(BrowseTabInput::GridCopyRowAsInsert { row: below_draft }); + .send(BrowseTabInput::GridCopyRowsAsInsert(vec![below_draft])); assert_eq!( *copied.borrow(), @@ -950,10 +855,278 @@ fn copy_row_as_insert_uses_row_below_draft() { ); } +#[gtk4::test] +fn a_copy_with_a_nul_reaches_the_window_whole() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .tab() + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + + harness + .tab() + .send(BrowseTabInput::GridCopyToClipboard("a\0b".to_owned())); + + assert_eq!(*copied.borrow(), ["a\0b".to_owned()]); +} + fn key(id: i64) -> RowKey { RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") } +/// `names` keyed from `first` on, as the next page of `rows` would be. +fn rows_from(first: i64, names: &[&str]) -> QueryResult { + let mut page = rows(names); + for (row, id) in page.rows.iter_mut().zip(first..) { + row[0] = Value::Int(id); + } + page +} + +/// A partitioned PostgreSQL table with no key, whose page carries the +/// partition, the row address and its version after the visible cells. +fn partitioned_table() -> ConnectionScript { + let server = tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0); + let mut script = ConnectionScript { + dialect: Arc::new(drivers_postgres::PgDialect::new(&server)), + ..ConnectionScript::default() + }; + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", false), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::EngineRowId( + tablepro_core::meta::EngineRowId::PostgresTableoidCtid, + ))); + script.count_rows.always(FakeReply::ok(2)); + script.probe.always(FakeReply::ok(())); + let mut columns: Vec<_> = [column("id", false), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(); + for hidden in ["__tablepro_row_oid", "__tablepro_row_id", "__tablepro_row_xmin"] { + columns.push(column(hidden, false).result_column()); + } + let rows = [("Ada", "(0,1)"), ("Grace", "(0,1)")] + .iter() + .zip(0..) + .map(|((name, ctid), id)| { + vec![ + Value::Int(id), + Value::Text((*name).to_owned()), + Value::Int(16_401 + id), + Value::Text((*ctid).to_owned()), + Value::Text("812".to_owned()), + ] + }) + .collect(); + script + .query_bound + .always(FakeReply::ok(QueryResult::new(columns, rows))); + script +} + +fn visible_only(values: &[Value]) { + assert_eq!(values.len(), 2, "a row carried hidden cells: {values:?}"); +} + +#[gtk4::test] +fn row_objects_hold_only_visible_cells() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + visible_only(&row.cells().expect("a saved row's cells")); + assert_eq!(row.key(), None, "a table with no primary key took edits"); + } + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let page = model.as_ref().and_then(|model| model.page.clone()).expect("the page"); + assert_eq!(page.result_columns.len(), 2, "the grid was given the hidden columns"); +} + +#[gtk4::test] +fn page_rows_have_single_owner() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + let cells = row.shared_cells().expect("a saved row's cells"); + assert_eq!(Arc::strong_count(cells), 1, "the tab kept its own copy of the page"); + } +} + +#[gtk4::test] +fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .tab() + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + let rows = harness.saved_rows(); + + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(rows.clone())); + harness.tab().send(BrowseTabInput::DuplicateRows(rows)); + + let copied = copied.borrow(); + assert_eq!(copied.len(), 1); + for hidden in ["16401", "(0,1)", "812", "__tablepro"] { + assert!(!copied[0].contains(hidden), "{hidden} reached the copy: {}", copied[0]); + } + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2); + for draft in drafts { + assert_eq!( + draft.cells.len(), + 2, + "a duplicate carried hidden cells: {:?}", + draft.cells + ); + } +} + +#[gtk4::test] +fn page_export_snapshot_contains_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, request| sink.borrow_mut().push(request.result)); + + harness.tab().send(BrowseTabInput::ExportCurrentPage); + + let exported = exported.borrow(); + let [result] = exported.as_slice() else { + panic!("one export, not {}", exported.len()); + }; + let names: Vec<&str> = result.columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["id", "name"]); + assert_eq!(result.rows.len(), 2); + for row in &result.rows { + visible_only(row); + } +} + +/// A browse page served first, then `next` for every page after it. +fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(first)) + .always(FakeReply::ok(next)); + script +} + +/// Rows enough for a full page, so Next has somewhere to go. +fn full_page(first: i64) -> QueryResult { + let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + rows_from(first, &names) +} + +#[gtk4::test] +fn page_load_emits_one_items_changed_with_drafts_prepended() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = changes.clone(); + store.connect_items_changed(move |_, position, removed, added| { + log.borrow_mut().push((position, removed, added)); + }); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert_eq!(*changes.borrow(), [(0, 101, 3)], "the page was not one splice"); + assert!(harness.grid_row(0).draft_id().is_some(), "the draft left the top"); + assert_eq!(harness.grid_row(1).key(), Some(&key(100))); +} + +#[gtk4::test] +fn second_page_load_drops_previous_rows() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let _drawn = grid_cell_of(harness.tab(), 0, 1); + let first: Vec> = harness.saved_rows().iter().map(ObjectExt::downgrade).collect(); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + + wait_until(WAIT, || first.iter().all(|row| row.upgrade().is_none())).expect("the first page's rows were let go"); +} + +/// The row holding the focus leaves in the splice, which takes the +/// focus from it first: the open edit ends as a focus change ends it. +#[gtk4::test] +fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = view + .columns() + .item(1) + .and_downcast::() + .expect("the name column"); + view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(1))).expect("a cell takes the focus"); + let cell = focused_cell().expect("the focused cell"); + let _editor = type_into(&cell, "typed"); + // A focus left in a row the splice took out of the window is what a + // window focus-in then trips over. + let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = stranded.clone(); + let window = shown.0.downgrade(); + harness.store().connect_items_changed(move |_, _, _, _| { + let focus = window + .upgrade() + .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); + seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); + }); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + after_next_frame(&shown.0); + + assert!(!stranded.get(), "the focus stayed in a row the page removed"); + + assert!(!cell.is_editing(), "the edit outlived its row"); + let pending = harness.tracker.cell_overlay(&key(1), 1).pending; + assert!( + matches!(&pending, Some(CellInput::Value(Value::Other(typed))) if typed.text == "typed"), + "what was typed was lost, or landed on another row: {pending:?}" + ); + assert_eq!(harness.tracker.pending_count(), 1); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); + assert!( + focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left the grid" + ); +} + +fn after_next_frame(window: >k4::Window) { + let clock = window.frame_clock().expect("the window's frame clock"); + let painted = std::rc::Rc::new(std::cell::Cell::new(false)); + let flag = painted.clone(); + let handler = clock.connect_after_paint(move |_| flag.set(true)); + clock.request_phase(gtk4::gdk::FrameClockPhase::AFTER_PAINT); + wait_until(WAIT, || painted.get()).expect("the window paints a frame"); + clock.disconnect(handler); +} + /// The cell showing `column` of the saved row keyed `id`, once drawn. fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { let key = key(id); @@ -970,11 +1143,35 @@ fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { } fn cell_text(cell: &GridCell) -> String { - cell.editor() - .map(|editor| editor.text().to_string()) + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) .unwrap_or_default() } +/// What the grid sends once `text` is typed into `column` of a saved +/// row and committed. +fn typed(row: &RowObject, column: usize, text: &str) -> BrowseTabInput { + BrowseTabInput::GridEdit { + row: row.clone(), + column, + edit: TrackerEdit::Cell { + row_key: row.key().cloned().expect("a saved row"), + col: column, + original: row.cell(column).cloned().unwrap_or(Value::Null), + value: Value::Text(text.to_owned()), + }, + } +} + +/// Open the cell's one-line editor and type `text` over its value. +fn type_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + editor.set_text(text); + editor +} + fn has_class(cell: &GridCell, class: &str) -> bool { cell.content().is_some_and(|content| content.has_css_class(class)) } @@ -996,11 +1193,9 @@ fn a_commit_is_tracked_at_once_and_leaves_its_cell_in_the_window() { wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - editor.entry().set_text("Ada Lovelace"); + let editor = type_into(&cell, "Ada Lovelace"); - editor.stop_editing(true); + editor.emit_activate(); assert!( cell.root().is_some(), @@ -1073,18 +1268,13 @@ fn rejected_edit_restores_cell_display() { vec![vec![Value::Int(0), Value::Int(36)]], ))); let harness = Harness::open(script, []); - wait_until(WAIT, || { - harness.tab().snapshot().is_some_and(|page| page.rows.len() == 1) - }) - .expect("the first page"); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); let toasts = toasts(harness.tab()); let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - editor.entry().set_text("thirty-six"); + let editor = type_into(&cell, "thirty-six"); - editor.stop_editing(true); + editor.emit_activate(); wait_until(WAIT, || cell_text(&cell) == "36").expect("the cell kept the text that was refused"); assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); @@ -1101,12 +1291,17 @@ fn refused_edit_restores_cell_display() { // A saved row's key, which the tab refuses to change in place. let cell = grid_cell_of(harness.tab(), 0, 0); let row = cell.row().expect("a bound row"); - cell.editor().expect("a text cell").set_text("7"); + cell.display().expect("a text cell").set_text(Some("7")); - harness.tab().send(BrowseTabInput::GridCellEdited { - row, + harness.tab().send(BrowseTabInput::GridEdit { + row: row.clone(), column: 0, - input: "7".to_owned(), + edit: TrackerEdit::Cell { + row_key: key(0), + col: 0, + original: Value::Int(0), + value: Value::Int(7), + }, }); assert_eq!(cell_text(&cell), "0", "the cell kept the edit the tab refused"); @@ -1122,11 +1317,11 @@ fn undo_under_an_open_editor_leaves_nothing_pending() { let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); + crate::ui::grid::begin_edit(&cell); + assert!(cell.is_editing()); harness.tab().send(BrowseTabInput::Undo); - editor.stop_editing(false); + cell.cancel_edit(); assert_eq!( harness.tracker.pending_count(), @@ -1196,12 +1391,16 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { harness.tab().send(BrowseTabInput::InsertRow); let draft = harness.grid_row(0); assert!(draft.draft_id().is_some(), "the new row goes on top"); - harness.tab().send(BrowseTabInput::GridCellEdited { + harness.tab().send(BrowseTabInput::GridEdit { row: draft.clone(), column: 1, - input: "x".to_owned(), + edit: TrackerEdit::DraftCell { + draft_id: draft.draft_id().expect("a draft"), + col: 1, + input: CellInput::Value(Value::Text("x".into())), + }, }); - harness.tab().send(BrowseTabInput::GridCopyRowAsInsert { row: draft }); + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); assert_eq!( *copied.borrow(), @@ -1272,11 +1471,7 @@ fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { } fn edit_first_row(harness: &Harness, column: usize, input: &str) { - harness.tab().send(BrowseTabInput::GridCellEdited { - row: harness.grid_row(0), - column, - input: input.to_owned(), - }); + harness.tab().send(typed(&harness.grid_row(0), column, input)); assert_eq!(harness.tracker.pending_count(), 1); } @@ -1349,11 +1544,7 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { harness.tab().send(BrowseTabInput::SchemaChanged); wait_until(WAIT, || harness.tab().columns() == people(&["name", "email"])).expect("the new columns"); // Column 2 was name in the grid the event came from, and is email now. - harness.tab().send(BrowseTabInput::GridCellEdited { - row, - column: 2, - input: "Grace".to_owned(), - }); + harness.tab().send(typed(&row, 2, "Grace")); assert_eq!( harness.tracker.pending_count(), @@ -1362,3 +1553,882 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { ); assert_eq!(harness.visible_page().as_deref(), Some("loading")); } + +fn delete_action(tab: &BrowseTab) -> gtk4::gio::SimpleAction { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.delete_action.clone()) + .expect("the tab has a model") +} + +fn delete_rows(tab: &BrowseTab) { + tab.activate_action("tab.delete-rows", None) + .expect("the tab has a delete action"); +} + +fn draft_rows(store: >k4::gio::ListStore) -> usize { + (0..store.n_items()) + .filter(|position| row_at(store, *position).draft_id().is_some()) + .count() +} + +fn pending_bar_shown(tab: &BrowseTab) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().pending_revealer.reveals_child() +} + +#[gtk4::test] +fn deleting_a_draft_removes_its_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + harness.selection().select_item(0, true); + assert!( + delete_action(harness.tab()).is_enabled(), + "a draft could not be deleted" + ); + + delete_rows(harness.tab()); + + assert_eq!(store.n_items(), 2, "the dropped draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); + assert!( + !pending_bar_shown(harness.tab()), + "the footer still counts the dropped draft" + ); +} + +#[gtk4::test] +fn the_delete_key_names_the_tab_action() { + let harness = Harness::open(serving_table(), []); + let controllers = harness.tab().observe_controllers(); + let named: Vec = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .flat_map(|controller| { + (0..controller.n_items()) + .filter_map(move |index| controller.item(index).and_downcast::()) + .collect::>() + }) + .filter(|shortcut| { + shortcut + .trigger() + .and_downcast::() + .is_some_and(|trigger| trigger.keyval() == gtk4::gdk::Key::Delete) + }) + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(named, ["tab.delete-rows"]); +} + +#[gtk4::test] +fn deleting_mixed_selection_removes_drafts_and_marks_persisted() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + harness.selection().select_range(0, 2, true); + + delete_rows(harness.tab()); + + let store = harness.store(); + assert_eq!(store.n_items(), 2); + assert_eq!(draft_rows(&store), 0); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); + assert_eq!( + harness.tracker.row_state(&key(1)), + crate::services::change_tracker::RowState::Clean + ); + assert_eq!(harness.tracker.pending_count(), 1); + assert!( + crate::ui::grid::selected_positions(&harness.selection()).is_empty(), + "the delete kept the selection it acted on" + ); +} + +fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click") +} + +fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { + crate::test_support::first_descendant_of_type::(tab).expect("the grid") +} + +#[gtk4::test] +fn menu_delete_inside_selection_marks_whole_selection() { + let names: Vec = (0..20).map(|n| format!("user {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&names))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 20).expect("the first page"); + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt}; + let window = libadwaita::Window::new(); + window.set_default_size(600, 400); + window.set_content(Some(harness.tab())); + window.present(); + harness.selection().select_all(); + let cell = grid_cell_of(harness.tab(), 2, 1); + + menu_gesture(&cell).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + column_view(harness.tab()) + .activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + let mut dialog = None; + wait_until(WAIT, || { + dialog = window.visible_dialog().and_downcast::(); + dialog.is_some() + }) + .expect("a delete of 20 rows was not confirmed first"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "the rows were marked before the answer" + ); + let dialog = dialog.expect("the confirmation"); + dialog.emit_by_name::<()>("response", &[&"delete"]); + + wait_until(WAIT, || harness.tracker.pending_count() == 20).expect("the whole selection was not marked"); + dialog.force_close(); + window.set_content(None::<>k4::Widget>); + window.destroy(); +} + +#[gtk4::test] +fn undo_insert_removes_draft_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + + harness.tab().send(BrowseTabInput::Undo); + + assert_eq!(store.n_items(), 2, "the undone draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn redo_insert_restores_draft_row_once() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + harness.tab().send(BrowseTabInput::Undo); + + harness.tab().send(BrowseTabInput::Redo); + + let store = harness.store(); + assert_eq!(store.n_items(), 3); + assert_eq!(draft_rows(&store), 1, "the redone draft is in the grid more than once"); + assert!(row_at(&store, 0).draft_id().is_some(), "the redone draft is not on top"); +} + +#[gtk4::test] +fn delete_action_disabled_for_unkeyed_selection() { + let columns = [column("id", false), column("name", false)]; + let mut script = serving_table(); + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + vec![Value::Int(0), Value::Text("Ada".into())], + vec![Value::Int(1), Value::Text("Grace".into())], + ], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let action = delete_action(harness.tab()); + + harness.selection().select_item(0, true); + + assert!(!action.is_enabled(), "Delete was offered for a row with no key"); + let button = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness.tab().imp().delete_button.get() + }; + assert_eq!( + button.tooltip_text().as_deref(), + Some("Rows without a primary key or unique key can't be deleted here") + ); + let view = column_view(harness.tab()); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.position() == Some(0)); + cell.is_some() + }) + .expect("the row's cell is drawn"); + menu_gesture(&cell.expect("a cell")).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + // A menu item is a model button bound to the action's enabled state, + // with the item's label as its text. + let items: Vec = crate::test_support::descendants(&view) + .into_iter() + .filter(|widget| widget.type_().name() == "GtkModelButton") + .filter(|widget| widget.property::>("text").as_deref() == Some("Delete")) + .collect(); + assert!(!items.is_empty(), "the menu has no Delete item"); + wait_until(WAIT, || items.iter().all(|item| !item.is_sensitive())) + .expect("the menu offered Delete for a row with no key"); + view.activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + delete_rows(harness.tab()); + assert_eq!(harness.tracker.pending_count(), 0); +} + +fn named_col(name: &str, data_type: &str, nullable: bool) -> ColumnInfo { + let mut column = col(data_type, nullable); + column.name = name.to_owned(); + column +} + +/// A table of an `id` key and `column`, with one row, id 0, holding +/// `value`, drawn in a window. +fn one_cell(column: ColumnInfo, value: Value) -> (Harness, Shown) { + let columns = vec![self::column("id", true), column]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), value]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); + let shown = Shown::new(harness.tab()); + (harness, shown) +} + +/// The value the tracker holds for row 0's second cell. +fn pending_value(harness: &Harness, fetched: &Value) -> Value { + harness.tracker.cell_overlay(&key(0), 1).shown(fetched.clone()) +} + +/// Open the cell's one-line editor and paste `text` over its value +/// through the clipboard, as Ctrl+V does. +fn paste_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + let opened = editor.text(); + editor.clipboard().set_text(text); + editor.emit_paste_clipboard(); + wait_until(WAIT, || editor.text() != opened).expect("the paste never landed"); + editor +} + +fn calendar_of(cell: &GridCell) -> Option { + crate::test_support::first_descendant_of_type::(cell) +} + +#[gtk4::test] +fn unchanged_40kb_multiline_text_records_no_edit() { + let long = "lorem ipsum ".repeat(3_500); + let (harness, _shown) = one_cell(named_col("note", "text", true), Value::Text(long.clone())); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!(cell.inline_text().is_none(), "40 KB of text opened in a one-line field"); + let view = + crate::test_support::first_descendant_of_type::(&cell).expect("the multi-line editor"); + let buffer = view.buffer(); + assert_eq!(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true), long); + crate::test_support::descendants(&cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + assert_eq!( + harness.tracker.pending_count(), + 0, + "saving the text as it was recorded an edit" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn bigint_edit_is_exact() { + let fetched = Value::Int(1_234_567_890_123_456_789); + let (harness, _shown) = one_cell(named_col("n", "bigint", false), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, "1234567890123456790"); + assert_eq!( + editor.input_purpose(), + gtk4::InputPurpose::Number, + "a signed integer field offers a number keypad" + ); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(1_234_567_890_123_456_790)); +} + +#[gtk4::test] +#[expect( + clippy::approx_constant, + reason = "a double with every digit it can hold, which is the point, not pi" +)] +fn pressing_enter_on_float_keeps_full_precision() { + let fetched = Value::Float64(3.141_592_653_589_79); + let (harness, _shown) = one_cell(named_col("x", "double precision", false), fetched); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "3.14159265358979"); + editor.emit_activate(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "Enter rounded the value it opened on" + ); +} + +#[gtk4::test] +fn null_int_opens_empty_with_null_placeholder() { + let (harness, _shown) = one_cell(named_col("n", "integer", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "", "NULL opened as the text of its sentinel"); + assert_eq!(editor.placeholder_text().as_deref(), Some("NULL")); +} + +#[gtk4::test] +fn clearing_not_null_int_without_default_shows_required_toast() { + let fetched = Value::Int(5); + let (harness, _shown) = one_cell(named_col("n", "integer", false), fetched.clone()); + let toasts = toasts(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("Required")); + editor.emit_activate(); + + assert_eq!(*toasts.borrow(), ["This column needs a value".to_owned()]); + assert_eq!(harness.tracker.pending_count(), 0); + assert_eq!(cell_text(&cell), "5"); +} + +#[gtk4::test] +fn draft_not_null_with_default_placeholder_is_default() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(BrowseTabInput::InsertRow); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.draft_id().is_some())); + cell.is_some() + }) + .expect("the new row's cell is drawn"); + let cell = cell.expect("a cell"); + cell.cancel_edit(); + assert_eq!(cell_text(&cell), "DEFAULT", "a cell left to the server read as NULL"); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("DEFAULT")); +} + +#[gtk4::test] +fn pasted_newlines_in_text_cell_are_kept() { + let fetched = Value::Text("a".into()); + let (harness, _shown) = one_cell(named_col("note", "text", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "first line\nsecond line"); + editor.emit_activate(); + + assert_eq!( + pending_value(&harness, &fetched), + Value::Text("first line\nsecond line".into()) + ); +} + +#[gtk4::test] +fn a_line_break_pasted_into_a_number_keeps_the_first_line() { + let fetched = Value::Int(1); + let (harness, _shown) = one_cell(named_col("n", "integer", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "42\n43"); + assert_eq!(editor.text(), "42"); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(42)); +} + +#[gtk4::test] +fn tinyint1_value_2_renders_inconsistent_with_exact_tooltip() { + let mut flag = named_col("flag", "tinyint(1)", false); + flag.column_type.kind = tablepro_core::column::ColumnKind::Bool(tablepro_core::column::BoolStorage::Integer); + let (harness, _shown) = one_cell(flag, Value::Int(2)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let check = cell.check_button().expect("a check button"); + + assert!(check.is_inconsistent(), "2 showed as true or false"); + assert_eq!(check.tooltip_text().as_deref(), Some("2")); + check.set_active(true); + assert_eq!(pending_value(&harness, &Value::Int(2)), Value::Int(1)); +} + +fn a_date(year: i32, month: u32, day: u32) -> Value { + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + )) +} + +#[gtk4::test] +fn calendar_preselects_date_value() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let calendar = calendar_of(&cell).expect("a date opened its calendar"); + assert_eq!(calendar.date().ymd(), (2024, 2, 29)); +} + +#[gtk4::test] +fn picking_a_day_commits_it() { + let fetched = a_date(2024, 2, 29); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let calendar = calendar_of(&cell).expect("the calendar"); + + let picked = gtk4::glib::DateTime::from_utc(2024, 3, 1, 0, 0, 0.0).expect("a day"); + calendar.set_date(&picked); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 3, 1)); + assert!(!cell.is_editing()); +} + +/// The middle of the first of the calendar's labels that `matches`, +/// once the popover has laid it out. +fn calendar_label(calendar: >k4::Calendar, matches: impl Fn(>k4::Widget) -> bool) -> (f64, f64) { + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(calendar) + .into_iter() + .find(|widget| widget.width() > 0 && matches(widget)); + found.is_some() + }) + .expect("the label is drawn"); + let label = found.expect("a label"); + let middle = gtk4::graphene::Point::new(label.width() as f32 / 2.0, label.height() as f32 / 2.0); + let point = label + .compute_point(calendar, &middle) + .expect("the label is inside the calendar"); + (f64::from(point.x()), f64::from(point.y())) +} + +fn selected_day(calendar: >k4::Calendar) -> (f64, f64) { + calendar_label(calendar, |label| { + label.has_css_class("day-number") && label.state_flags().contains(gtk4::StateFlags::SELECTED) + }) +} + +/// What the calendar hears when a primary click at `(x, y)` ends. +fn release_on(calendar: >k4::Calendar, (x, y): (f64, f64)) { + let controllers = calendar.observe_controllers(); + for gesture in + (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) + { + gesture.emit_by_name::<()>("released", &[&1i32, &x, &y]); + } +} + +fn release_space(calendar: >k4::Calendar) { + use gtk4::glib::translate::IntoGlib; + let controllers = calendar.observe_controllers(); + for keys in (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + { + keys.emit_by_name::<()>( + "key-released", + &[ + >k4::gdk::Key::space.into_glib(), + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + } +} + +/// Open the cell's calendar until its popover shows. Test processes +/// share one X display, where another test's window can close an +/// autohide popover before it maps. +fn open_calendar(cell: &GridCell) -> gtk4::Calendar { + for _ in 0..5 { + crate::ui::grid::begin_edit(cell); + if let Some(calendar) = calendar_of(cell) + && wait_until(Duration::from_secs(2), || calendar.is_mapped() && calendar.width() > 0).is_ok() + { + return calendar; + } + cell.cancel_edit(); + drain_main_context(); + } + panic!("the calendar never showed"); +} + +fn shown_day(calendar: >k4::Calendar) -> Value { + let (year, month, day) = calendar.date().ymd(); + a_date(year, u32::try_from(month).unwrap(), u32::try_from(day).unwrap()) +} + +#[gtk4::test] +fn clicking_today_on_a_null_date_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_on( + &calendar, + calendar_label(&calendar, |label| label.has_css_class("day-name")), + ); + assert!(cell.is_editing(), "a click on a weekday name picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &Value::Null), today); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn picking_the_same_day_in_another_month_commits_it() { + let fetched = a_date(2024, 3, 15); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + + // As the next-month arrow does, this moves the date without a + // day-selected. + calendar.set_date(>k4::glib::DateTime::from_utc(2024, 4, 15, 0, 0, 0.0).expect("a day")); + assert!(cell.is_editing(), "a month change picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 4, 15)); +} + +#[gtk4::test] +fn space_on_the_selected_day_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_space(&calendar); + + assert_eq!(pending_value(&harness, &Value::Null), today); +} + +#[gtk4::test] +fn opening_calendar_on_existing_date_records_no_edit() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let popover = crate::test_support::first_descendant_of_type::(&cell).expect("the calendar popover"); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "opening the calendar recorded its own day" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn out_of_range_date_opens_inline_editor() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(12_000, 1, 1)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!( + calendar_of(&cell).is_none(), + "the calendar opened on a year it cannot show" + ); + let editor = cell.inline_text().expect("a one-line field"); + assert!(editor.text().contains("12000"), "{}", editor.text()); +} + +#[gtk4::test] +fn read_only_cells_use_inscription_not_selectable_label() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let _shown = Shown::new(harness.tab()); + // A saved row's key, which the row is named by and cannot change. + let cell = grid_cell_of(harness.tab(), 0, 0); + + assert!(cell.lock().is_some()); + assert!( + cell.display().is_some(), + "a locked cell does not draw in an Inscription" + ); + assert!( + crate::test_support::descendants(&cell) + .iter() + .all(|widget| !widget.is::()), + "a locked cell carries a selectable label" + ); + crate::ui::grid::begin_edit(&cell); + assert!(!cell.is_editing(), "a locked cell opened for editing"); +} + +/// More rows than a delete marks without asking first. +const DOZEN: [&str; 12] = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; + +/// The confirmation a bulk delete is waiting on, by its ticket. +fn confirming(tab: &BrowseTab) -> Option<(u64, libadwaita::AlertDialog)> { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().model.borrow().as_ref().and_then(|model| { + model + .confirming_delete + .as_ref() + .map(|pending| (pending.ticket, pending.dialog.clone())) + }) +} + +/// Every row selected and Delete pressed, which asks before marking. +fn ask_to_delete_every_row(harness: &Harness) -> libadwaita::AlertDialog { + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness.tab().send(BrowseTabInput::SelectAllRows); + delete_rows(harness.tab()); + let (_, dialog) = confirming(harness.tab()).expect("a bulk delete asks first"); + assert_eq!(harness.tracker.pending_count(), 0); + dialog +} + +fn answer_delete(dialog: &libadwaita::AlertDialog) { + dialog.emit_by_name::<()>("response", &[&"delete"]); + drain_main_context(); +} + +fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); +} + +/// Closing the dialog after the answer answers again with its close +/// response, and the rows stay marked. +#[gtk4::test] +fn a_confirmed_bulk_delete_marks_every_selected_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + answer_delete(&dialog); + libadwaita::prelude::AdwDialogExt::force_close(&dialog); + drain_main_context(); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert!(confirming(harness.tab()).is_none()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_the_primary_key_moves_is_cancelled() { + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(vec![column("id", false), column("name", true)])); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let toasts = toasts(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| { + columns.iter().any(|column| column.name == "name" && column.primary_key) + }); + answer_delete(&dialog); + + assert!(confirming(harness.tab()).is_none(), "the confirmation outlived the key"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "rows were marked by the key they had before" + ); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_columns() { + let widened = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(widened.to_vec())); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| columns.len() == widened.len()); + assert!( + confirming(harness.tab()).is_some(), + "a change that keeps the key cancelled the delete" + ); + answer_delete(&dialog); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); +} + +/// What this thread logs, as text. +#[derive(Clone, Default)] +struct LogLines(Arc>>); + +impl LogLines { + fn text(&self) -> String { + let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + String::from_utf8_lossy(&bytes).into_owned() + } +} + +impl std::io::Write for LogLines { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[gtk4::test] +fn a_failed_row_count_logs_its_category_and_not_the_server_message() { + let lines = LogLines::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer({ + let lines = lines.clone(); + move || lines.clone() + }) + .finish(); + let _logging = tracing::subscriber::set_default(subscriber); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + script.count_rows.always(FakeReply::err(DriverError::reported( + tablepro_core::ServerDiagnostics::new(None, "You have an error in your SQL syntax near 'hunter2' at line 1"), + ))); + let _harness = Harness::open(script, []); + + wait_until(WAIT, || lines.text().contains("the row count could not be read")).expect("the failure is logged"); + + let text = lines.text(); + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("category=Some(Server)"), "{text}"); +} + +#[gtk4::test] +fn a_second_delete_replaces_the_waiting_confirmation() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + ask_to_delete_every_row(&harness); + let (first, _) = confirming(harness.tab()).expect("the first confirmation"); + + delete_rows(harness.tab()); + drain_main_context(); + let (second, dialog) = confirming(harness.tab()).expect("the closed confirmation cancelled the new one"); + assert_ne!(first, second); + harness.tab().send(BrowseTabInput::BulkDeleteAnswered { + ticket: first, + confirmed: true, + }); + drain_main_context(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "an answer to the replaced confirmation marked the rows" + ); + assert_eq!(confirming(harness.tab()).map(|(ticket, _)| ticket), Some(second)); + answer_delete(&dialog); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + +#[gtk4::test] +fn insert_row_opens_the_new_rows_first_editable_cell() { + let (harness, shown) = one_cell(named_col("name", "text", true), Value::Text("Ada".into())); + grid_cell_of(harness.tab(), 0, 1); + + harness.tab().send(BrowseTabInput::InsertRow); + + let mut editing = None; + wait_until(WAIT, || { + editing = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.inline_text().is_some()); + editing.is_some() + }) + .expect("the new row opens for editing"); + let cell = editing.expect("an editing cell"); + assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); + assert_eq!(cell.column(), 0, "a new row sets its own key first"); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0).expect("a focused widget"); + assert_eq!(GridCell::from_focus(&focus).as_ref(), Some(&cell)); +} diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 90db84e2c5..5bcc77c084 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -40,6 +40,7 @@ pub use self::form_errors::FormErrors; pub use self::form_field::FormField; use self::pem_validation::{PemState, PemValidation}; pub use self::row_problem::show_problem; +pub(crate) use self::sqlite_file_row::sqlite_file_dialog; use crate::ui::error_text::TlsRemedy; use crate::ui::ssh_inputs::SshEnvironment; @@ -107,6 +108,7 @@ impl ConnectDialog { drivers.sort_by(|a, b| a.display_name.cmp(&b.display_name)); let names: Vec<&str> = drivers.iter().map(|entry| entry.display_name.as_str()).collect(); imp.driver_names.splice(0, imp.driver_names.n_items(), &names); + imp.file_row.set_tasks(init.tasks.clone()); let _ = imp.services.set(DialogServices { connector: init.connector, interaction: init.interaction, diff --git a/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs b/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs index 1dbc40c405..998e122b26 100644 --- a/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs +++ b/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use gtk4::glib; use gtk4::prelude::*; @@ -6,9 +6,12 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::{FileEndpoint, FileOpenMode}; +use tablepro_session::runtime::Tasks; + +use crate::services::file_location; mod imp { - use std::cell::RefCell; + use std::cell::{OnceCell, RefCell}; use std::sync::OnceLock; use glib::subclass::Signal; @@ -24,6 +27,8 @@ mod imp { #[template_child] pub create_button: TemplateChild, pub endpoint: RefCell>, + /// Reads the file a portal path stands for, off the GTK thread. + pub tasks: OnceCell, } #[glib::object_subclass] @@ -89,20 +94,41 @@ impl SqliteFileRow { self.imp().endpoint.borrow().clone() } + pub fn set_tasks(&self, tasks: Tasks) { + let _ = self.imp().tasks.set(tasks); + } + pub fn set_endpoint(&self, endpoint: Option) { - let subtitle = match &endpoint { - None => crate::i18n::gettext("No file chosen"), - Some(file) if file.mode() == FileOpenMode::CreateNew => crate::i18n::gettext_f( - "{path}, created when you connect", - &[("path", &file.path().display().to_string())], - ), - Some(file) => file.path().display().to_string(), - }; - self.set_subtitle(&subtitle); + self.set_subtitle(&subtitle(endpoint.as_ref(), None)); + if let Some(file) = &endpoint + && file_location::is_portal_path(file.path()) + { + self.show_host_path(file.path().to_path_buf()); + } self.imp().endpoint.replace(endpoint); self.emit_by_name::<()>("changed", &[]); } + /// A portal path's folder is an id, so the row names the file the + /// portal stands for once it is read. + fn show_host_path(&self, path: PathBuf) { + let Some(tasks) = self.imp().tasks.get() else { + return; + }; + let lookup = path.clone(); + let looking = tasks.spawn_blocking_task(move || file_location::portal_host_path_blocking(&lookup)); + let row = self.downgrade(); + glib::spawn_future_local(async move { + let (Ok(Some(host)), Some(row)) = (looking.await, row.upgrade()) else { + return; + }; + let endpoint = row.endpoint(); + if endpoint.as_ref().is_some_and(|file| file.path() == path) { + row.set_subtitle(&subtitle(endpoint.as_ref(), Some(&host))); + } + }); + } + /// Take a path the file chooser returned. The portal only hands out /// absolute paths, so a refusal here means it gave none at all. pub fn adopt_choice(&self, path: PathBuf, mode: FileOpenMode) { @@ -127,7 +153,7 @@ impl SqliteFileRow { } async fn choose_existing(&self) { - let dialog = file_dialog(&crate::i18n::gettext("Open Database File")); + let dialog = sqlite_file_dialog(&crate::i18n::gettext("Open Database File")); let chosen = dialog.open_future(self.parent_window().as_ref()).await; if let Some(path) = chosen.ok().and_then(|file| file.path()) { self.adopt_choice(path, FileOpenMode::OpenExisting); @@ -137,7 +163,7 @@ impl SqliteFileRow { /// The save chooser asks before replacing a file that exists, and /// the driver then replaces it whole, so no second question here. async fn choose_new(&self) { - let dialog = file_dialog(&crate::i18n::gettext("New Database File")); + let dialog = sqlite_file_dialog(&crate::i18n::gettext("New Database File")); dialog.set_initial_name(Some("Untitled.sqlite3")); let chosen = dialog.save_future(self.parent_window().as_ref()).await; if let Some(path) = chosen.ok().and_then(|file| file.path()) { @@ -150,7 +176,22 @@ impl SqliteFileRow { } } -fn file_dialog(title: &str) -> gtk4::FileDialog { +/// The row's subtitle: the file, named by `host_path` when a portal +/// hands it over. +fn subtitle(endpoint: Option<&FileEndpoint>, host_path: Option<&Path>) -> String { + let Some(file) = endpoint else { + return crate::i18n::gettext("No file chosen"); + }; + let shown = file_location::display_path(file.path(), host_path); + match file.mode() { + FileOpenMode::CreateNew => crate::i18n::gettext_f("{path}, created when you connect", &[("path", &shown)]), + FileOpenMode::OpenExisting => shown, + } +} + +/// The file chooser for a SQLite database, with every file a choice +/// when the filter misses it. +pub(crate) fn sqlite_file_dialog(title: &str) -> gtk4::FileDialog { let sqlite = gtk4::FileFilter::new(); sqlite.set_name(Some(&crate::i18n::gettext("SQLite databases"))); for mime in ["application/vnd.sqlite3", "application/x-sqlite3"] { @@ -172,3 +213,24 @@ fn file_dialog(title: &str) -> gtk4::FileDialog { .default_filter(&sqlite) .build() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_portal_file_reads_as_its_host_path_or_its_name() { + let portal = FileEndpoint::open_existing(PathBuf::from("/run/user/1000/doc/1a2b3c/app.db")).expect("absolute"); + let created = FileEndpoint::create_new(PathBuf::from("/run/user/1000/doc/1a2b3c/new.db")).expect("absolute"); + let local = FileEndpoint::open_existing(PathBuf::from("/home/me/app.db")).expect("absolute"); + + assert_eq!(subtitle(Some(&portal), None), "app.db"); + assert_eq!( + subtitle(Some(&portal), Some(Path::new("/srv/data/app.db"))), + "/srv/data/app.db" + ); + assert_eq!(subtitle(Some(&created), None), "new.db, created when you connect"); + assert_eq!(subtitle(Some(&local), None), "/home/me/app.db"); + assert_eq!(subtitle(None, None), "No file chosen"); + } +} diff --git a/linux/crates/app/src/ui/connection_row.rs b/linux/crates/app/src/ui/connection_row.rs index b8f4cff36a..d5ae6e1fcf 100644 --- a/linux/crates/app/src/ui/connection_row.rs +++ b/linux/crates/app/src/ui/connection_row.rs @@ -1,37 +1,43 @@ +use std::path::Path; + use relm4::adw::prelude::*; use relm4::factory::{DynamicIndex, FactoryComponent, FactorySender}; -use relm4::gtk::gio; +use relm4::gtk::{gio, glib}; use relm4::{adw, gtk}; use uuid::Uuid; use tablepro_core::AuthMode; +use tablepro_session::runtime::Tasks; use tablepro_storage::{ConnectionColor, SavedConnection}; +use crate::runtime::DeliverInput; +use crate::services::file_location; + /// What a row needs to build itself: the connection, plus the groups /// the rest of the list is filed under so its menu can offer them. #[derive(Debug, Clone)] pub struct ConnectionRowInit { pub saved: SavedConnection, pub groups: Vec, + /// Reads the file a portal path stands for. `None` names the file + /// alone. + pub tasks: Option, } #[derive(Debug)] pub struct ConnectionRow { saved: SavedConnection, groups: Vec, - /// AdwActionRow root widget. Cached so the trash button's - /// confirmation dialog can `present()` against it (the dialog - /// walks up to find the GtkWindow, but it needs *some* widget - /// in the tree to start from). + tasks: Option, + /// The row, which the New Group dialog presents over: a dialog + /// finds its window from any widget inside it. root: Option, } #[derive(Debug)] pub enum ConnectionRowMsg { Open, - /// Trash button pressed. Triggers a confirmation dialog before - /// any actual delete is dispatched — saved connections include - /// credentials and SSH config and a misclick is unrecoverable. + /// Delete from the row's menu. The window confirms it. RequestDelete, /// Copy the connection under a free name, so a second database on /// the same server does not have to be typed out again. @@ -66,9 +72,9 @@ impl FactoryComponent for ConnectionRow { view! { adw::ActionRow { set_title: &self.saved.name, - set_subtitle: &subtitle_for(&self.saved), + set_subtitle: &subtitle_for(&self.saved, None), set_activatable: true, - connect_activated => ConnectionRowMsg::Open, + connect_activated[sender] => move |_| sender.deliver(ConnectionRowMsg::Open), // The colour the user put on this connection, as a dot // ahead of the name. Absent rather than grey when there is @@ -98,6 +104,7 @@ impl FactoryComponent for ConnectionRow { Self { saved: init.saved, groups: init.groups, + tasks: init.tasks, root: None, } } @@ -112,17 +119,18 @@ impl FactoryComponent for ConnectionRow { let widgets = view_output!(); // Stash for the destructive-confirm dialog in update(). self.root = Some(root.clone().upcast::()); + self.show_host_path(&root); // Each row owns its actions, so the menu on one row cannot // act on another. let actions = gio::SimpleActionGroup::new(); let duplicate_sender = sender.clone(); let duplicate = gio::ActionEntry::builder("duplicate") - .activate(move |_, _, _| duplicate_sender.input(ConnectionRowMsg::RequestDuplicate)) + .activate(move |_, _, _| duplicate_sender.deliver(ConnectionRowMsg::RequestDuplicate)) .build(); let remove_sender = sender.clone(); let remove = gio::ActionEntry::builder("remove") - .activate(move |_, _, _| remove_sender.input(ConnectionRowMsg::RequestDelete)) + .activate(move |_, _, _| remove_sender.deliver(ConnectionRowMsg::RequestDelete)) .build(); actions.add_action_entries([duplicate, remove]); @@ -139,7 +147,7 @@ impl FactoryComponent for ConnectionRow { return; }; action.set_state(&id.to_variant()); - color_sender.input(ConnectionRowMsg::SetColor(ConnectionColor::from_id(&id))); + color_sender.deliver(ConnectionRowMsg::SetColor(ConnectionColor::from_id(&id))); }); actions.add_action(&color); @@ -156,7 +164,7 @@ impl FactoryComponent for ConnectionRow { return; }; action.set_state(&name.to_variant()); - group_sender.input(ConnectionRowMsg::SetGroup(match name.trim().is_empty() { + group_sender.deliver(ConnectionRowMsg::SetGroup(match name.trim().is_empty() { true => None, false => Some(name), })); @@ -164,7 +172,7 @@ impl FactoryComponent for ConnectionRow { actions.add_action(&group); let new_group_sender = sender.clone(); let new_group = gio::ActionEntry::builder("new-group") - .activate(move |_, _, _| new_group_sender.input(ConnectionRowMsg::NewGroupRequested)) + .activate(move |_, _, _| new_group_sender.deliver(ConnectionRowMsg::NewGroupRequested)) .build(); actions.add_action_entries([new_group]); root.insert_action_group("connection", Some(&actions)); @@ -199,8 +207,7 @@ impl FactoryComponent for ConnectionRow { dialog.set_close_response("cancel"); let id = self.saved.id; let output = sender.output_sender().clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); + dialog.connect_response(None, move |_, response| { if response != "add" { return; } @@ -212,41 +219,45 @@ impl FactoryComponent for ConnectionRow { }); dialog.present(self.root.as_ref()); } + // The window asks before anything is deleted, with the + // choices only it can offer: an open connection, its history. ConnectionRowMsg::RequestDelete => { - // GNOME HIG: destructive actions need explicit - // confirmation. AdwAlertDialog with a destructive- - // appearance Remove button is the documented pattern; - // the Cancel default + Esc-cancellable close response - // make a misclick a no-op. Body copy spells out the - // blast radius so the user knows what's actually lost. - let dialog = adw::AlertDialog::new(None, None); - dialog.set_heading(Some(&crate::i18n::gettext_f( - "Remove “{name}”?", - &[("name", &self.saved.name)], - ))); - dialog.set_body(&crate::i18n::gettext("The saved credentials and SSH settings will be deleted from this device. The database itself is unaffected.")); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("remove", &crate::i18n::gettext("Remove")); - dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let id = self.saved.id; - let output = sender.output_sender().clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); - if response == "remove" { - let _ = output.send(ConnectionRowOutput::Delete(id)); - } - }); - dialog.present(self.root.as_ref()); + let _ = sender.output(ConnectionRowOutput::Delete(self.saved.id)); } } } } -fn subtitle_for(saved: &SavedConnection) -> String { - if saved.driver_id == "sqlite" { - return format!("sqlite · {}", saved.database); +impl ConnectionRow { + /// A portal path's folder is an id, so the row names the file the + /// portal stands for once it is read. + fn show_host_path(&self, root: &adw::ActionRow) { + let path = std::path::PathBuf::from(&self.saved.database); + if !is_sqlite(&self.saved) || !file_location::is_portal_path(&path) { + return; + } + let Some(tasks) = &self.tasks else { + return; + }; + let looking = tasks.spawn_blocking_task(move || file_location::portal_host_path_blocking(&path)); + let (saved, row) = (self.saved.clone(), root.downgrade()); + glib::spawn_future_local(async move { + if let (Ok(Some(host)), Some(row)) = (looking.await, row.upgrade()) { + row.set_subtitle(&subtitle_for(&saved, Some(&host))); + } + }); + } +} + +fn is_sqlite(saved: &SavedConnection) -> bool { + saved.driver_id == "sqlite" +} + +/// `host_path` names the file a portal path stands for. +fn subtitle_for(saved: &SavedConnection, host_path: Option<&Path>) -> String { + if is_sqlite(saved) { + let shown = file_location::display_path(Path::new(&saved.database), host_path); + return format!("sqlite · {shown}"); } match saved.auth_mode { AuthMode::Kerberos => format!("{} · {}:{}", saved.driver_id, saved.host, saved.port), @@ -254,7 +265,7 @@ fn subtitle_for(saved: &SavedConnection) -> String { } } -/// The row's own menu. Remove sits in its own section so a destructive +/// The row's own menu. Delete sits in its own section so a destructive /// action is never the neighbour of an ordinary one. fn row_menu(groups: &[String]) -> gio::Menu { let menu = gio::Menu::new(); @@ -262,7 +273,8 @@ fn row_menu(groups: &[String]) -> gio::Menu { menu.append_submenu(Some(&crate::i18n::gettext("Colour")), &color_menu()); menu.append_submenu(Some(&crate::i18n::gettext("Group")), &group_menu(groups)); let danger = gio::Menu::new(); - danger.append(Some(&crate::i18n::gettext("Remove…")), Some("connection.remove")); + // Named as the dialog it opens names it. + danger.append(Some(&crate::i18n::gettext("Delete…")), Some("connection.remove")); menu.append_section(None, &danger); menu } @@ -402,15 +414,30 @@ mod tests { #[test] fn a_kerberos_row_has_no_username_separator_to_dangle() { assert_eq!( - subtitle_for(&saved("", AuthMode::Kerberos)), + subtitle_for(&saved("", AuthMode::Kerberos), None), "mssql · sql.corp.example:1433" ); assert_eq!( - subtitle_for(&saved("sa", AuthMode::Password)), + subtitle_for(&saved("sa", AuthMode::Password), None), "mssql · sa@sql.corp.example:1433" ); } + #[test] + fn portal_path_shows_host_path() { + let mut file = saved("", AuthMode::Password); + file.driver_id = "sqlite".into(); + file.database = "/run/user/1000/doc/1a2b3c/app.db".into(); + + assert_eq!( + subtitle_for(&file, Some(Path::new("/srv/data/app.db"))), + "sqlite · /srv/data/app.db" + ); + assert_eq!(subtitle_for(&file, None), "sqlite · app.db"); + file.database = "/home/me/app.db".into(); + assert_eq!(subtitle_for(&file, None), "sqlite · /home/me/app.db"); + } + #[test] fn a_connection_with_no_colour_carries_no_colour_class() { assert_eq!(tag_classes(None), vec!["tp-connection-tag"]); diff --git a/linux/crates/app/src/ui/credential_prompt/presenter.rs b/linux/crates/app/src/ui/credential_prompt/presenter.rs index 3cd0545d29..b1df2fec7d 100644 --- a/linux/crates/app/src/ui/credential_prompt/presenter.rs +++ b/linux/crates/app/src/ui/credential_prompt/presenter.rs @@ -1,6 +1,4 @@ -use std::cell::RefCell; use std::pin::pin; -use std::rc::Rc; use adw::prelude::*; use futures::future::{Either, select}; @@ -8,7 +6,7 @@ use libadwaita as adw; use tablepro_core::credentials::PromptReply; use tokio::sync::oneshot; -use super::prompt_form::{CANCEL, PromptForm}; +use super::prompt_form::PromptForm; use super::prompter::{PendingPrompt, PromptQueue}; /// Shows queued credential prompts over a window, one at a time. @@ -57,36 +55,12 @@ async fn response_while_wanted( parent: >k4::Widget, reply: &mut oneshot::Sender, ) -> Option { - match select(pin!(ask(dialog, parent)), pin!(reply.closed())).await { + match select(pin!(crate::workspace::respond(dialog, parent)), pin!(reply.closed())).await { Either::Left((response, _)) => Some(response), Either::Right(((), _)) => None, } } -/// Present the dialog and wait for the response it closes with. -/// -/// The closed signal answers too, as a cancel: a dialog torn down with -/// its window never sends a response, and the prompt must still end. -/// `choose_future` is not used because its callback aborts the process -/// when the dialog goes away without one. -async fn ask(dialog: &adw::AlertDialog, parent: >k4::Widget) -> 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()); - } - }); - dialog.connect_closed(move |_| { - if let Some(sender) = sender.borrow_mut().take() { - let _ = sender.send(CANCEL.to_owned()); - } - }); - dialog.present(Some(parent)); - receiver.await.unwrap_or_else(|_| CANCEL.to_owned()) -} - #[cfg(test)] mod tests { use std::time::Duration; @@ -191,6 +165,49 @@ mod tests { crate::test_support::drain_main_context(); } + #[gtk4::test] + fn connect_hands_over_the_typed_password_with_animations_on_or_off() { + use secrecy::ExposeSecret; + + adw::init().expect("libadwaita initialises under the test backend"); + let settings = gtk4::Settings::default().expect("the default settings"); + let animations = settings.is_gtk_enable_animations(); + for enabled in [true, false] { + settings.set_gtk_enable_animations(enabled); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let reply = std::rc::Rc::new(std::cell::RefCell::new(None)); + let asking = prompter.prompt(prompt("accepted")); + let sink = reply.clone(); + glib::spawn_future_local(async move { + *sink.borrow_mut() = Some(asking.await); + }); + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the prompt"); + let rows = open_alerts(&window)[0].extra_child().expect("the prompt's rows"); + crate::test_support::descendants(&rows) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the password row") + .set_text("hunter2"); + + assert!(crate::workspace::answer(&window, super::super::prompt_form::ACCEPT)); + + crate::test_support::wait_until(Duration::from_secs(5), || reply.borrow().is_some()).expect("an answer"); + let answered = reply.borrow_mut().take(); + let Some(PromptReply::Submitted { values, .. }) = answered else { + panic!("Connect answered {answered:?} with animations {enabled}"); + }; + assert_eq!(values[0].expose_secret(), "hunter2"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + settings.set_gtk_enable_animations(animations); + } + #[gtk4::test] fn a_prompt_whose_dialog_is_forced_shut_is_cancelled() { adw::init().expect("libadwaita initialises under the test backend"); diff --git a/linux/crates/app/src/ui/delete_connection_dialog.rs b/linux/crates/app/src/ui/delete_connection_dialog.rs new file mode 100644 index 0000000000..342969720a --- /dev/null +++ b/linux/crates/app/src/ui/delete_connection_dialog.rs @@ -0,0 +1,191 @@ +//! What a window asks before a saved connection is deleted. + +use adw::prelude::*; +use libadwaita as adw; + +use crate::services::connection_deletion::DeletionOptions; + +pub(crate) const CANCEL: &str = "cancel"; +pub(crate) const DELETE: &str = "delete"; +pub(crate) const SHOW: &str = "show"; + +/// Asks to delete a saved connection, and whether the queries run on it +/// go too. The history stays unless the user says otherwise: it is the +/// record of what they ran, and the delete has no undo. +#[derive(Debug, Clone)] +pub(crate) struct DeleteConnectionDialog { + pub dialog: adw::AlertDialog, + pub history: adw::SwitchRow, +} + +impl DeleteConnectionDialog { + pub fn new(name: &str) -> Self { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext_f("Delete “{name}”?", &[("name", name)])), + Some(&crate::i18n::gettext( + "The connection, its stored passwords and its saved queries will be removed. This cannot be undone.", + )), + ); + 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 history = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Delete Query History")) + .active(false) + .build(); + let group = adw::PreferencesGroup::new(); + group.add(&history); + dialog.set_extra_child(Some(&group)); + Self { dialog, history } + } + + /// Say what the switch removes, once the history is counted. + pub fn show_history_count(&self, count: u64) { + if count == 0 { + self.history + .set_subtitle(&crate::i18n::gettext("No queries were run on this connection")); + self.history.set_sensitive(false); + return; + } + 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), + &[("n", &count.to_string())], + )); + } + + /// The history database did not open, so the switch could remove + /// nothing. + pub fn show_history_unavailable(&self) { + self.history.set_active(false); + self.history + .set_subtitle(&crate::i18n::gettext("The query history is not available")); + self.history.set_sensitive(false); + } + + /// Run `chosen` with what the user picked when they press Delete. + pub fn connect_delete(&self, chosen: impl Fn(DeletionOptions) + 'static) { + // The row, not the dialog: the dialog holds this closure. + let history = self.history.clone(); + self.dialog.connect_response(Some(DELETE), move |_, _| { + chosen(DeletionOptions { + delete_history: history.is_active(), + }); + }); + } +} + +/// Says a connection open in a window cannot be deleted from under it, +/// and offers to bring that window forward. +pub(crate) fn is_open_dialog(name: &str) -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext_f("“{name}” Is Open", &[("name", name)])), + Some(&crate::i18n::gettext( + "Disconnect it or close its window, then delete it.", + )), + ); + dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); + dialog.add_response(SHOW, &crate::i18n::gettext("Show Window")); + dialog.set_response_appearance(SHOW, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(SHOW)); + dialog.set_close_response(CANCEL); + dialog +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + + #[gtk4::test] + fn delete_dialog_has_history_switch_off_by_default() { + adw::init().expect("libadwaita initialises under the test backend"); + + let asking = DeleteConnectionDialog::new("Prod"); + + assert_eq!(asking.dialog.heading().as_deref(), Some("Delete “Prod”?")); + assert!(!asking.history.is_active(), "the history would go by default"); + assert!(asking.history.is_sensitive()); + assert_eq!(asking.dialog.default_response().as_deref(), Some(CANCEL)); + assert_eq!(asking.dialog.close_response(), CANCEL); + assert_eq!( + asking.dialog.response_appearance(DELETE), + adw::ResponseAppearance::Destructive + ); + } + + #[gtk4::test] + fn the_history_switch_says_how_much_it_removes() { + adw::init().expect("libadwaita initialises under the test backend"); + let asking = DeleteConnectionDialog::new("Prod"); + + asking.show_history_count(12); + assert!(asking.history.subtitle().is_some_and(|text| text.contains("12"))); + + asking.show_history_count(0); + assert!( + !asking.history.is_sensitive(), + "a switch that removes nothing is offered" + ); + } + + #[gtk4::test] + fn an_unavailable_history_offers_no_switch() { + adw::init().expect("libadwaita initialises under the test backend"); + let asking = DeleteConnectionDialog::new("Prod"); + + asking.show_history_unavailable(); + + assert!( + !asking.history.is_sensitive(), + "a switch that can do nothing is offered" + ); + assert!(!asking.history.is_active()); + assert!( + asking + .history + .subtitle() + .is_some_and(|text| text.contains("not available")) + ); + } + + #[gtk4::test] + fn delete_hands_over_the_history_choice() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let chosen = Rc::new(Cell::new(None)); + let ask = || { + let asking = DeleteConnectionDialog::new("Prod"); + let sink = chosen.clone(); + asking.connect_delete(move |options| sink.set(Some(options))); + asking.dialog.present(Some(&window)); + asking + }; + + crate::test_support::respond(&ask().dialog, CANCEL); + assert_eq!(chosen.get(), None, "Cancel deleted"); + let asking = ask(); + asking.history.set_active(true); + crate::test_support::respond(&asking.dialog, DELETE); + + assert_eq!(chosen.get(), Some(DeletionOptions { delete_history: true })); + window.close(); + } + + #[gtk4::test] + fn the_is_open_dialog_offers_the_window() { + adw::init().expect("libadwaita initialises under the test backend"); + + let dialog = is_open_dialog("Prod"); + + assert_eq!(dialog.heading().as_deref(), Some("“Prod” Is Open")); + assert!(dialog.has_response(SHOW)); + assert_eq!(dialog.close_response(), CANCEL); + } +} diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index c819d456e8..5347e21c0a 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -82,8 +82,10 @@ impl ObjectImpl for SqlEditor { // No text: the buffer can be megabytes, and a listener // reads only what it needs from it. Signal::builder("content-changed").build(), + // Boxed: a GValue string ends at a NUL, and a copied cell + // can hold one. Signal::builder("copy-to-clipboard") - .param_types([String::static_type()]) + .param_types([glib::BoxedAnyObject::static_type()]) .build(), Signal::builder("toast").param_types([String::static_type()]).build(), Signal::builder("undoable-toast") diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 3aad36e2da..2763b3ff78 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -8,6 +8,7 @@ mod editor_session_slot; mod format_plan; mod imp; mod result_item; +mod result_rows; mod result_widgets; mod results_pane; mod run_history; @@ -23,6 +24,7 @@ mod tests; mod transaction_dirty; use std::rc::Rc; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use gtk4::glib; @@ -31,6 +33,7 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; use sourceview5::prelude::*; +use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_syntax::SqlGrammar; use tablepro_core::{QueryResult, RowLimit}; use tablepro_session::runtime::{ProcessHold, Tasks}; @@ -44,6 +47,7 @@ use self::script_runner::{ScriptRun, plan_batches, run_in_tab}; use super::grid::{GridMsg, GridSink}; use super::handler_guard::HandlerGuard; use crate::services::active_session::ActiveSession; +use crate::services::history_service::HistoryService; glib::wrapper! { pub struct SqlEditor(ObjectSubclass) @@ -54,7 +58,7 @@ glib::wrapper! { pub struct SqlEditorInit { pub initial_query: Option, pub settings: Rc, - pub history: Option, + pub history: Option, pub tasks: Tasks, /// The window's session, which the tab opens its own connection /// from on the first run. @@ -66,7 +70,7 @@ pub struct SqlEditorInit { pub(crate) struct EditorDeps { settings: Rc, - history: Option, + history: Option, tasks: Tasks, session: ActiveSession, hold: Rc, @@ -145,18 +149,24 @@ impl SqlEditor { } } - /// Close the tab's connection. The process is held until the close - /// ends, so quitting right after closing a tab does not cut it off. - pub fn shutdown(&self) { + /// Stop the running query and hand over the tab's connection, so a + /// teardown closes every tab's at once under one hold. + pub fn take_editor_session(&self) -> Option> { let imp = self.imp(); if imp.shut_down.replace(true) { - return; + return None; } if let Some(running) = imp.running.take() { running.cancel.cancel(); running.drain.abort(); } - let (Some(editor), Some(deps)) = (imp.slot.close(), imp.deps.get()) else { + imp.slot.close() + } + + /// Close the tab's connection. The process is held until the close + /// ends, so quitting right after closing a tab does not cut it off. + pub fn shutdown(&self) { + let (Some(editor), Some(deps)) = (self.take_editor_session(), self.deps()) else { return; }; let hold = deps.hold.acquire(); @@ -266,7 +276,9 @@ impl SqlEditor { self.connect_closure( "copy-to-clipboard", false, - glib::closure_local!(move |editor: &Self, text: String| f(editor, text)), + glib::closure_local!(move |editor: &Self, text: glib::BoxedAnyObject| { + f(editor, text.borrow::().clone()); + }), ) } @@ -360,6 +372,11 @@ impl SqlEditor { row_limit: RowLimit::EDITOR_DEFAULT, }; let metadata = session.metadata().clone(); + // Read again at the end from the run's own session, never the + // window's: the link can come up during the run, and the window + // can move to another engine before it ends. + let dialect = session.current_dialect(); + let ran_on = session.clone(); let started_at = SystemTime::now(); let cancel = CancellationToken::new(); let (events, mut received) = tokio::sync::mpsc::channel(8); @@ -391,7 +408,8 @@ impl SqlEditor { let finished = matches!(event, RunEvent::Finished); editor.apply_event(event); if finished { - editor.finish_run(query, &metadata, started_at); + let dialect = dialect.or_else(|| ran_on.current_dialect()); + editor.finish_run(query, &metadata, started_at, dialect); return; } } @@ -399,7 +417,8 @@ impl SqlEditor { // does; the list still has to end and the tab unlock. if let Some(editor) = weak.upgrade() { editor.apply_event(RunEvent::Finished); - editor.finish_run(query, &metadata, started_at); + let dialect = dialect.or_else(|| ran_on.current_dialect()); + editor.finish_run(query, &metadata, started_at, dialect); } }); imp.running.replace(Some(RunningScript { cancel, drain })); @@ -435,7 +454,13 @@ impl SqlEditor { })) } - fn finish_run(&self, query: String, metadata: &tablepro_session::ConnectionMetadata, started_at: SystemTime) { + fn finish_run( + &self, + query: String, + metadata: &tablepro_session::ConnectionMetadata, + started_at: SystemTime, + dialect: Option>, + ) { let imp = self.imp(); drop(imp.running.take()); imp.run_button.set_sensitive(true); @@ -462,22 +487,18 @@ impl SqlEditor { if imp.transaction_dirty.replace(dirty) != dirty { self.emit_by_name::<()>("transaction-changed", &[&dirty]); } - self.record_history(entry); + self.record_history(entry, dialect); self.emit_by_name::<()>("run-state-changed", &[&false]); } - fn record_history(&self, entry: tablepro_storage::query_history::NewEntry) { + fn record_history(&self, entry: tablepro_storage::query_history::NewEntry, dialect: Option>) { let Some(deps) = self.deps() else { return; }; - let Some(history) = deps.history.clone() else { + let Some(history) = deps.history.as_ref() else { return; }; - drop(deps.tasks.spawn_task(async move { - if let Err(error) = history.record(entry).await { - tracing::warn!(%error, "history record failed"); - } - })); + drop(deps.tasks.spawn_task(history.record(entry, dialect))); } /// Put the cursor where the server says a statement went wrong. @@ -598,7 +619,9 @@ impl SqlEditor { return; }; match message { - GridMsg::CopyToClipboard(text) => editor.emit_by_name::<()>("copy-to-clipboard", &[&text]), + GridMsg::CopyToClipboard(text) => { + editor.emit_by_name::<()>("copy-to-clipboard", &[&glib::BoxedAnyObject::new(text)]); + } GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), GridMsg::ExportResults(result) => { let name = export_name_for_query(&editor.selected_preview()); diff --git a/linux/crates/app/src/ui/editor/result_rows.rs b/linux/crates/app/src/ui/editor/result_rows.rs new file mode 100644 index 0000000000..6fc810883c --- /dev/null +++ b/linux/crates/app/src/ui/editor/result_rows.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; + +/// A result set as the results list keeps it. The grid that shows it +/// shares each row, so a page built again for the same result copies no +/// cell. +#[derive(Debug, Clone)] +pub struct ResultRows { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec>, + /// The driver stopped at the row limit. + pub truncated: bool, +} + +impl ResultRows { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { + columns, + rows: Vec::new(), + truncated: false, + } + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } +} diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index 89c175b456..058fa46c65 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -2,7 +2,7 @@ use gtk4::prelude::*; use libadwaita as adw; use super::run_log::{EntryKind, RunLogEntry}; -use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_column_view}; +use crate::ui::grid::{GridContent, GridSink, RowObject, TabGridContext, build_column_view}; use crate::ui::icons; /// The widget a results list line shows. `go_to_error` puts the editor @@ -10,16 +10,13 @@ use crate::ui::icons; pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, go_to_error: impl Fn(usize) + 'static) -> gtk4::Widget { match &entry.kind { EntryKind::ResultSet { result, total_rows } => { - let rows = result.rows.iter().map(|cells| RowObject::result(cells.clone().into())); - let (column_view, _selection) = build_column_view( - result, - rows, - &[], - grid_sink.clone(), - None, - false, - TabGridContext::default(), - ); + let content = GridContent { + columns: result.columns.clone(), + rows: result.rows.iter().cloned().map(RowObject::result).collect(), + truncated: result.truncated, + }; + let (column_view, _selection) = + build_column_view(content, &[], grid_sink.clone(), None, false, TabGridContext::default()); let scrolled = gtk4::ScrolledWindow::builder() .child(&column_view) .hexpand(true) diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs index 3baf121063..d25594c525 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -1,14 +1,15 @@ use tablepro_core::sql_syntax::script::BatchErrorPolicy; use tablepro_core::{ - DriverError, EditorSessionState, ErrorCategory, QueryResult, ResultEvent, RunEnd, RunSummary, TransactionState, - Truncation, + DriverError, EditorSessionState, ErrorCategory, ResultEvent, RunEnd, RunSummary, TransactionState, Truncation, }; +use super::result_rows::ResultRows; + /// One line of the results list. #[derive(Debug, Clone)] pub enum EntryKind { ResultSet { - result: QueryResult, + result: ResultRows, /// What the server says the whole set held, when it says. total_rows: Option, }, @@ -108,7 +109,7 @@ impl BatchCollector { ResultEvent::ResultSetStarted { columns } => { self.sets.push(self.entries.len()); self.entries.push(EntryKind::ResultSet { - result: QueryResult::new(columns, Vec::new()), + result: ResultRows::new(columns), total_rows: None, }); } @@ -118,7 +119,7 @@ impl BatchCollector { } } ResultEvent::Rows(rows) => match self.last_set() { - Some(result) => result.rows.extend(rows), + Some(result) => result.rows.extend(rows.into_iter().map(Into::into)), // Rows with no columns would draw a grid that cannot say // what any cell is. None => tracing::warn!(rows = rows.len(), "rows arrived before their result set"), @@ -196,7 +197,7 @@ impl BatchCollector { (self.entries, verdict, transaction) } - fn set(&mut self, set_index: usize) -> Option<&mut QueryResult> { + fn set(&mut self, set_index: usize) -> Option<&mut ResultRows> { let index = *self.sets.get(set_index)?; match self.entries.get_mut(index) { Some(EntryKind::ResultSet { result, .. }) => Some(result), @@ -204,7 +205,7 @@ impl BatchCollector { } } - fn last_set(&mut self) -> Option<&mut QueryResult> { + fn last_set(&mut self) -> Option<&mut ResultRows> { let last = self.sets.len().checked_sub(1)?; self.set(last) } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 043dd78299..5624de85a1 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -8,6 +8,7 @@ use tablepro_session::operation::DefaultDeadlines; use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; use super::*; +use crate::services::history_preferences::HistoryPreferences; use crate::test_support::{MemorySettings, SignalLog, wait_until}; const WAIT: Duration = Duration::from_secs(10); @@ -15,12 +16,17 @@ const WAIT: Duration = Duration::from_secs(10); struct Harness { editor: SqlEditor, fake: Arc, + history: Option, + active: ActiveSession, _settings: MemorySettings, - _runtime: crate::runtime::AppRuntime, + runtime: crate::runtime::AppRuntime, } fn session_with(fake: Arc, tasks: Tasks) -> Arc { - let mut script = ConnectionScript::default(); + session_on(fake, tasks, ConnectionScript::default()) +} + +fn session_on(fake: Arc, tasks: Tasks, mut script: ConnectionScript) -> Arc { let editor: Arc = fake; script.open_editor_session.always(FakeReply::ok(editor)); let request = FakeConnector::request().expect("a request"); @@ -38,29 +44,48 @@ fn session_with(fake: Arc, tasks: Tasks) -> Arc { } fn editor_with(runs: Replies, text: &str) -> Harness { + build_editor(runs, text, |_| None) +} + +fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) -> Option) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let settings = MemorySettings::new(); let fake = Arc::new(FakeEditorSession::new(runs)); let active = ActiveSession::new(); active.replace(session_with(fake.clone(), runtime.tasks())); + let history = history(&runtime.tasks()); let editor = SqlEditor::new(SqlEditorInit { initial_query: Some(text.to_owned()), settings: settings.get().clone(), - history: None, + history: history.clone(), tasks: runtime.tasks(), - session: active, + session: active.clone(), hold: Rc::new(tablepro_session::runtime::CountingHold::new()), close_timeout: Duration::from_secs(5), }); Harness { editor, fake, + history, + active, _settings: settings, - _runtime: runtime, + runtime, } } +/// An editor whose runs go into a real history under `root`. +fn editor_with_history(root: &tempfile::TempDir, text: &str) -> Harness { + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + build_editor(answering(), text, |tasks| { + let preferences = HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + Some(HistoryService::start(paths, preferences, tasks)) + }) +} + fn answering() -> Replies { let mut runs = Replies::default(); runs.always(FakeRun::completed(Vec::new())); @@ -291,6 +316,21 @@ fn a_dropped_file_goes_in_at_the_cursor_as_one_undo_step() { assert_eq!(buffer_text(&buffer), "SELECT 1;\n"); } +#[gtk4::test] +fn a_copy_from_a_result_grid_reaches_the_tab_whole() { + let harness = editor_with(answering(), "SELECT 1"); + let copied = Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .editor + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + let grid = harness.editor.imp().grid_sink.get().expect("the grid's sink").clone(); + + grid(GridMsg::CopyToClipboard("a\0b".to_owned())); + + assert_eq!(*copied.borrow(), ["a\0b".to_owned()]); +} + #[gtk4::test] fn closing_the_tab_closes_its_connection() { let harness = editor_with(answering(), "SELECT 1"); @@ -315,6 +355,87 @@ fn the_icon_buttons_have_accessible_names() { )); } +#[gtk4::test] +fn a_run_is_recorded_unless_it_carries_a_credential() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); + run_and_wait(&harness); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the plain run was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + +#[gtk4::test] +fn a_run_is_judged_by_the_engine_it_ran_on() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); + let ran_on = harness.active.get().expect("the window's session"); + drop(ran_on.begin_shutdown()); + assert!(ran_on.current_dialect().is_none(), "the run's engine is still known"); + + harness.editor.run(); + assert!(harness.editor.is_running(), "the run did not start"); + // SQLite has no logins, so its rules let CREATE USER into the history. + let sqlite = ConnectionScript { + dialect: Arc::new(drivers_sqlite::SqliteDialect::new(drivers_sqlite::SQLITE_MIN_VERSION)), + ..ConnectionScript::default() + }; + let moved_to = Arc::new(FakeEditorSession::new(answering())); + harness + .active + .replace(session_on(moved_to, harness.runtime.tasks(), sqlite)); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the run on the new engine was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + +/// What the harness's history holds, newest first. +fn recorded(harness: &Harness) -> Vec { + let Some(history) = harness.history.as_ref().and_then(HistoryService::store) else { + return Vec::new(); + }; + let search = history.search(tablepro_storage::query_history::SearchFilter { + limit: 100, + ..Default::default() + }); + block_on(&harness.runtime, search) + .expect("search") + .into_iter() + .map(|entry| entry.query) + .collect() +} + +/// The test drives the runtime from outside it, which is the one place +/// blocking on it cannot deadlock. +#[expect( + clippy::disallowed_methods, + reason = "a GTK test has no runtime of its own, so it drives the app's from outside" +)] +fn block_on(runtime: &crate::runtime::AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) +} + fn unconnected_editor(settings: &MemorySettings, tasks: Tasks) -> SqlEditor { crate::register_test_resources(); SqlEditor::new(SqlEditorInit { diff --git a/linux/crates/app/src/ui/error_text/ddl.rs b/linux/crates/app/src/ui/error_text/ddl.rs index d6f8331260..fef7fa3fa4 100644 --- a/linux/crates/app/src/ui/error_text/ddl.rs +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -17,6 +17,9 @@ pub fn refusal_text(reason: &RefusalReason) -> String { crate::i18n::gettext("The database numbers this column, so it has no default.") } RefusalReason::NotAnInteger => crate::i18n::gettext("Only an integer column can number itself."), + RefusalReason::AlwaysNumbered => crate::i18n::gettext( + "The database numbers this column while it is the table's integer primary key. Change its type or the primary key to stop it.", + ), RefusalReason::UnmodeledAttribute(attribute) => crate::i18n::gettext_f( "This column has an attribute TablePro can't restate: {attribute}", &[("attribute", attribute)], @@ -108,5 +111,12 @@ pub fn build_error_text(error: &BuildDdlError) -> String { &[("action", action.as_sql())], ), BuildDdlError::NoTableSource => crate::i18n::gettext("The database gave no CREATE statement for this table."), + BuildDdlError::UnreplayableDependent(name) => crate::i18n::gettext_f( + "The table can't be rebuilt: the stored statement of {name}, which uses it, can't be run again.", + &[("name", name)], + ), + BuildDdlError::NotAsReviewed => crate::i18n::gettext( + "The changes were edited or the connection changed during the review, so nothing was saved. Save again to review them.", + ), } } diff --git a/linux/crates/app/src/ui/error_text/edit.rs b/linux/crates/app/src/ui/error_text/edit.rs new file mode 100644 index 0000000000..542cebbc40 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/edit.rs @@ -0,0 +1,162 @@ +use tablepro_core::column::IntegerKind; +use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; + +/// What to tell the user about text a cell would not take. Every +/// variant has its own branch, so a new one fails to compile here +/// rather than reaching a widget as its log text. +pub fn edit_parse_message(error: &EditParseError) -> String { + match error { + EditParseError::Required => gettext("This column needs a value"), + EditParseError::InvalidInteger => gettext("Enter a whole number"), + EditParseError::IntegerOutOfRange { kind } => { + let (min, max) = integer_range(*kind); + gettext_f( + "Enter a whole number from {min} to {max}", + &[("min", &min), ("max", &max)], + ) + } + EditParseError::InvalidFloat => gettext("Enter a number"), + EditParseError::InvalidDecimal(_) => gettext("Enter a decimal number, such as 12.50"), + EditParseError::PrecisionExceeded { precision } => ngettext_f( + "This column keeps at most {n} digit", + "This column keeps at most {n} digits", + *precision, + &[("n", &precision.to_string())], + ), + EditParseError::ScaleExceeded { scale } => ngettext_f( + "This column keeps at most {n} decimal place", + "This column keeps at most {n} decimal places", + *scale, + &[("n", &scale.to_string())], + ), + EditParseError::InvalidBoolean => gettext("Enter true or false"), + EditParseError::InvalidUuid => gettext("Enter a UUID"), + EditParseError::InvalidJson(detail) => gettext_f("Enter valid JSON: {detail}", &[("detail", detail)]), + EditParseError::InvalidDate => gettext("Enter a date as YYYY-MM-DD"), + EditParseError::InvalidTime => gettext("Enter a time as HH:MM:SS"), + EditParseError::TimeOutOfRange => gettext("This time is outside the range the column can hold"), + EditParseError::InvalidTimestamp => gettext("Enter a date and time as YYYY-MM-DD HH:MM:SS"), + EditParseError::OffsetRequired => gettext("Add a time zone offset, such as +02:00"), + EditParseError::InvalidInterval => gettext("Enter an interval, such as 1 day 02:00:00"), + EditParseError::InvalidBits => gettext("Enter bits as 0 and 1"), + EditParseError::BitLengthMismatch { expected } => ngettext_f( + "Enter exactly {n} bit", + "Enter exactly {n} bits", + *expected, + &[("n", &expected.to_string())], + ), + EditParseError::UnknownEnumLabel => gettext("Enter one of the values this column allows"), + EditParseError::NotEditable(reason) => not_editable_message(*reason), + } +} + +fn not_editable_message(reason: ReadOnlyReason) -> String { + match reason { + ReadOnlyReason::Bytes => gettext("Binary values can't be edited here"), + ReadOnlyReason::Spatial => gettext("Spatial values can't be edited here"), + ReadOnlyReason::Undecodable => gettext("This value couldn't be read, so it can't be edited"), + ReadOnlyReason::Composite => gettext("Arrays and composite values can't be edited here"), + ReadOnlyReason::Variant => gettext("Values whose type changes by row can't be edited here"), + } +} + +/// The smallest and largest value of an integer column, written out. +fn integer_range(kind: IntegerKind) -> (String, String) { + let bits = kind.width.bits(); + match (kind.signed, bits) { + (true, 256) => ("-2^255".to_owned(), "2^255 - 1".to_owned()), + (false, 256) => ("0".to_owned(), "2^256 - 1".to_owned()), + (true, 128) => (i128::MIN.to_string(), i128::MAX.to_string()), + (false, 128) => ("0".to_owned(), u128::MAX.to_string()), + (true, bits) => { + let half = 1i128 << (bits - 1); + ((-half).to_string(), (half - 1).to_string()) + } + (false, bits) => ("0".to_owned(), ((1u128 << bits) - 1).to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tablepro_core::column::{IntegerKind, IntegerWidth}; + use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + use tablepro_core::value::DecimalParseError; + + use super::{edit_parse_message, integer_range}; + + #[test] + fn every_edit_parse_error_variant_has_a_distinct_message() { + let kind = IntegerKind { + width: IntegerWidth::W16, + signed: true, + }; + let errors = [ + EditParseError::Required, + EditParseError::InvalidInteger, + EditParseError::IntegerOutOfRange { kind }, + EditParseError::InvalidFloat, + EditParseError::InvalidDecimal(DecimalParseError::InvalidSyntax), + EditParseError::PrecisionExceeded { precision: 5 }, + EditParseError::ScaleExceeded { scale: 2 }, + EditParseError::InvalidBoolean, + EditParseError::InvalidUuid, + EditParseError::InvalidJson("expected value at line 1 column 1".to_owned()), + EditParseError::InvalidDate, + EditParseError::InvalidTime, + EditParseError::TimeOutOfRange, + EditParseError::InvalidTimestamp, + EditParseError::OffsetRequired, + EditParseError::InvalidInterval, + EditParseError::InvalidBits, + EditParseError::BitLengthMismatch { expected: 8 }, + EditParseError::UnknownEnumLabel, + EditParseError::NotEditable(ReadOnlyReason::Bytes), + ]; + + let messages: Vec = errors.iter().map(edit_parse_message).collect(); + + assert!(messages.iter().all(|message| !message.is_empty()), "{messages:?}"); + let distinct: HashSet<&String> = messages.iter().collect(); + assert_eq!(distinct.len(), messages.len(), "{messages:?}"); + assert_eq!(messages[2], "Enter a whole number from -32768 to 32767"); + } + + #[test] + fn every_read_only_reason_says_its_own_thing() { + let reasons = [ + ReadOnlyReason::Bytes, + ReadOnlyReason::Spatial, + ReadOnlyReason::Undecodable, + ReadOnlyReason::Composite, + ReadOnlyReason::Variant, + ]; + + let messages: HashSet = reasons + .iter() + .map(|reason| edit_parse_message(&EditParseError::NotEditable(*reason))) + .collect(); + + assert_eq!(messages.len(), reasons.len()); + } + + #[test] + fn integer_ranges_follow_width_and_sign() { + let range = |width, signed| integer_range(IntegerKind { width, signed }); + + assert_eq!(range(IntegerWidth::W8, false), ("0".to_owned(), "255".to_owned())); + assert_eq!( + range(IntegerWidth::W24, true), + ("-8388608".to_owned(), "8388607".to_owned()) + ); + assert_eq!( + range(IntegerWidth::W64, true), + (i64::MIN.to_string(), i64::MAX.to_string()) + ); + assert_eq!(range(IntegerWidth::W128, false).1, u128::MAX.to_string()); + assert_eq!(range(IntegerWidth::W256, true).0, "-2^255"); + } +} diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 4906757306..1df76bf2c6 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -7,6 +7,7 @@ mod ddl; mod driver; +mod edit; mod operation; mod script; mod secret; @@ -18,6 +19,7 @@ mod tls_remedy; pub use ddl::{build_error_text, refusal_text, save_blocker_text}; pub use driver::{config_message, driver_message}; +pub use edit::edit_parse_message; pub use operation::{connect_error_message, operation_error_message, task_failure_message}; pub use script::script_diagnostic_message; pub use tls_remedy::{TlsRemedy, connect_remedy}; diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs index e596fa7271..59d1b8da80 100644 --- a/linux/crates/app/src/ui/error_text/tests.rs +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -363,3 +363,15 @@ fn unterminated_dollar_quote_diagnostic_names_line() { assert!(message.contains("dollar-quoted"), "{message}"); assert!(message.contains("line 7"), "{message}"); } + +#[test] +fn a_rebuild_refusal_names_the_dependent_rather_than_a_column() { + let unreplayable = build_error_text(&tablepro_core::ddl::BuildDdlError::UnreplayableDependent( + "big_orders".to_owned(), + )); + let numbered = refusal_text(&tablepro_core::ddl::RefusalReason::AlwaysNumbered); + + assert!(unreplayable.contains("big_orders"), "{unreplayable}"); + assert!(!unreplayable.contains("Column"), "{unreplayable}"); + assert!(numbered.contains("primary key"), "{numbered}"); +} diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs index 097a0a70a9..ee1ed8dec3 100644 --- a/linux/crates/app/src/ui/export_dialog.rs +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use std::sync::Arc; use relm4::adw::prelude::*; -use relm4::gtk::gio; +use relm4::gtk::{gio, glib}; use relm4::{adw, gtk}; use tablepro_core::QueryResult; @@ -377,7 +377,6 @@ fn save_with_file_dialog(parent: &adw::ApplicationWindow, toast_overlay: &adw::T let title = name.clone(); file_dialog.save(Some(parent), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; - let Some(path) = file.path() else { return }; // Bytes rather than text, because a workbook is a zip archive. // The text formats hand over their own bytes unchanged. let encoded: Result, String> = match format { @@ -403,26 +402,34 @@ fn save_with_file_dialog(parent: &adw::ApplicationWindow, toast_overlay: &adw::T None => Err(crate::i18n::gettext("This result has no table to insert into.")), }, }; - let written = encoded.and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string())); - match written { - Ok(()) => toast_overlay.add_toast(adw::Toast::new(&crate::i18n::gettext_f( - "Exported to {path}", - &[("path", &path.display().to_string())], - ))), - Err(error) => { - let alert = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Couldn't export")), - Some(&crate::i18n::gettext_f( - "Writing {path} failed: {error}", - &[("path", &path.display().to_string()), ("error", &error)], - )), - ); - alert.add_response("close", &crate::i18n::gettext("Close")); - alert.set_default_response(Some("close")); - alert.set_close_response("close"); - alert.present(Some(&parent_for_alert)); + glib::spawn_future_local(async move { + let written = match encoded { + Ok(bytes) => crate::ui::file_write::replace_file(&file, bytes) + .await + .map_err(|error| error.to_string()), + Err(error) => Err(error), + }; + let path = file.parse_name().to_string(); + match written { + Ok(()) => toast_overlay.add_toast(adw::Toast::new(&crate::i18n::gettext_f( + "Exported to {path}", + &[("path", &path)], + ))), + Err(error) => { + let alert = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Couldn't export")), + Some(&crate::i18n::gettext_f( + "Writing {path} failed: {error}", + &[("path", &path), ("error", &error)], + )), + ); + alert.add_response("close", &crate::i18n::gettext("Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(&parent_for_alert)); + } } - } + }); }); } diff --git a/linux/crates/app/src/ui/file_write.rs b/linux/crates/app/src/ui/file_write.rs new file mode 100644 index 0000000000..0ac60dfaf6 --- /dev/null +++ b/linux/crates/app/src/ui/file_write.rs @@ -0,0 +1,49 @@ +use gtk4::glib; +use gtk4::prelude::*; + +/// Replace `file` with `contents`. GIO writes a local file on its own +/// worker thread, so a slow disk or a network mount never stalls the +/// window that asked. +pub(crate) async fn replace_file(file: &gio::File, contents: Vec) -> Result<(), glib::Error> { + file.replace_contents_future(contents, None, false, gio::FileCreateFlags::NONE) + .await + .map(drop) + .map_err(|(_, error)| error) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn replace(file: gio::File, contents: &[u8]) -> Result<(), glib::Error> { + let contents = contents.to_vec(); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { replace_file(&file, contents).await }, + ) + .expect("the write to end") + } + + #[gtk4::test] + fn a_file_is_replaced_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("export.csv"); + std::fs::write(&path, "an older, longer export\n").expect("seed"); + + replace(gio::File::for_path(&path), b"a,b\n").expect("written"); + + assert_eq!(std::fs::read(&path).expect("read back"), b"a,b\n"); + } + + #[gtk4::test] + fn a_folder_that_is_not_there_is_an_error() { + let root = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(root.path().join("missing").join("export.csv")); + + let error = replace(file, b"a,b\n").expect_err("no folder to write into"); + + assert!(error.matches(gio::IOErrorEnum::NotFound), "{error}"); + } +} diff --git a/linux/crates/app/src/ui/filter_strip.rs b/linux/crates/app/src/ui/filter_strip.rs index c6ff8a901c..04b5735f1a 100644 --- a/linux/crates/app/src/ui/filter_strip.rs +++ b/linux/crates/app/src/ui/filter_strip.rs @@ -236,7 +236,7 @@ pub fn build(columns: Vec, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc now() - interval '1 day'")) .hexpand(true) diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs new file mode 100644 index 0000000000..2d1edcaabe --- /dev/null +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -0,0 +1,198 @@ +use chrono::Datelike; +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::edit::CellInput; +use tablepro_core::value::Temporal; +use tablepro_core::{ColumnInfo, Value}; + +use super::edit_commit::{commit_edit, send_commit}; +use super::inline_editor::{InlineEditor, InlineOptions}; +use super::{EditSnapshot, GridCell}; + +#[derive(Debug, Clone)] +enum CalendarStart { + On(glib::DateTime), + /// A NULL the column may keep, which stays until a day is picked. + Today, +} + +/// A date column's editor: a calendar for a day it can show, and the +/// one-line field for anything else, such as an infinity. +pub struct CalendarEditor; + +impl CalendarEditor { + /// `text` is the cell's edit text, for the one-line field. + pub(super) fn open( + cell: &GridCell, + snapshot: EditSnapshot, + column: &ColumnInfo, + text: String, + inline: InlineOptions, + ) { + let Some(start) = calendar_start(&snapshot.original, column) else { + InlineEditor::begin(cell, snapshot, &text, inline); + return; + }; + cell.cancel_edit(); + let calendar = gtk::Calendar::new(); + // Before the handler: set_date emits day-selected when the day + // changes, and that is not a pick. + if let CalendarStart::On(date) = &start { + calendar.set_date(date); + } + let type_value = gtk::Button::with_label(&crate::i18n::gettext("Type a Value")); + type_value.add_css_class("flat"); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .build(); + content.append(&calendar); + content.append(&type_value); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_cell = cell.downgrade(); + calendar.connect_day_selected(move |calendar| { + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, calendar); + } + }); + // day-selected fires only when the date changes, so a click or + // Space on the day already selected, today on a NULL or the same + // day after a month change, would pick nothing. + let click = gtk::GestureClick::new(); + let weak_cell = cell.downgrade(); + click.connect_released(move |gesture, _, x, y| { + let Some(calendar) = gesture.widget().and_downcast::() else { + return; + }; + let on_a_day = calendar + .pick(x, y, gtk::PickFlags::DEFAULT) + .is_some_and(|label| label.has_css_class("day-number")); + if !on_a_day { + return; + } + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } + }); + calendar.add_controller(click); + let keys = gtk::EventControllerKey::new(); + let weak_cell = cell.downgrade(); + keys.connect_key_released(move |controller, keyval, _, _| { + if !matches!(keyval, gtk::gdk::Key::space | gtk::gdk::Key::KP_Space) { + return; + } + let Some(calendar) = controller.widget().and_downcast::() else { + return; + }; + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } + }); + calendar.add_controller(keys); + let weak_cell = cell.downgrade(); + type_value.connect_clicked(move |_| { + let Some(cell) = weak_cell.upgrade() else { + return; + }; + let Some(snapshot) = cell.take_edit() else { + return; + }; + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + InlineEditor::begin(&cell, snapshot, &text, inline.clone()); + }); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + calendar.grab_focus(); + } +} + +/// The popover closes before the tab hears of the day, as every editor's +/// does. A second pick of the same click finds the edit taken. +fn pick(cell: &GridCell, calendar: >k::Calendar) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + let (year, month, day) = calendar.date().ymd(); + let day = format!("{year:04}-{month:02}-{day:02}"); + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &day); + send_commit(cell, snapshot, result); +} + +/// `None` for a value the calendar cannot show: an infinity, a year +/// outside 1 to 9999, the server's default or a NULL the column refuses. +fn calendar_start(input: &CellInput, column: &ColumnInfo) -> Option { + match input { + CellInput::Value(Value::Date(Temporal::Finite(date))) if (1..=9999).contains(&date.year()) => { + let month = i32::try_from(date.month()).ok()?; + let day = i32::try_from(date.day()).ok()?; + glib::DateTime::from_utc(date.year(), month, day, 0, 0, 0.0) + .ok() + .map(CalendarStart::On) + } + CellInput::Value(Value::Null) if column.nullable => Some(CalendarStart::Today), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::value::Temporal; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CalendarStart, calendar_start}; + + fn date_column(nullable: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("date"); + column_type.kind = ColumnKind::Date; + ColumnInfo { + name: "day".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn date(year: i32, month: u32, day: u32) -> CellInput { + CellInput::Value(Value::Date(Temporal::Finite( + NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + ))) + } + + #[test] + fn the_calendar_opens_only_on_a_day_it_can_show() { + let nullable = date_column(true); + + let Some(CalendarStart::On(start)) = calendar_start(&date(2026, 9, 15), &nullable) else { + panic!("a date did not open the calendar on its day"); + }; + assert_eq!(start.ymd(), (2026, 9, 15)); + assert!(matches!( + calendar_start(&CellInput::Value(Value::Null), &nullable), + Some(CalendarStart::Today) + )); + assert!(calendar_start(&CellInput::Value(Value::Null), &date_column(false)).is_none()); + assert!(calendar_start(&CellInput::Default, &nullable).is_none()); + assert!(calendar_start(&date(12_000, 1, 1), &nullable).is_none()); + assert!(calendar_start(&CellInput::Value(Value::Date(Temporal::Infinity)), &nullable).is_none()); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_accessibility.rs b/linux/crates/app/src/ui/grid/cell_accessibility.rs new file mode 100644 index 0000000000..461ce3b261 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_accessibility.rs @@ -0,0 +1,40 @@ +use crate::services::change_tracker::{CellState, RowState}; + +/// What a screen reader adds to a cell's value. A new row's cells say +/// nothing of their own: the row says it is new. +pub fn cell_description(state: CellState) -> Option { + match state { + CellState::Modified => Some(crate::i18n::gettext("Modified")), + CellState::Clean | CellState::InsertDraft => None, + } +} + +/// What a screen reader adds to a row. An edited row says nothing of its +/// own: its edited cells do. +pub fn row_description(state: RowState) -> Option { + match state { + RowState::InsertDraft => Some(crate::i18n::gettext("New row")), + RowState::PendingDelete => Some(crate::i18n::gettext("Marked for deletion")), + RowState::Clean | RowState::Modified => None, + } +} + +#[cfg(test)] +mod tests { + use super::{cell_description, row_description}; + use crate::services::change_tracker::{CellState, RowState}; + + #[test] + fn cell_and_row_descriptions() { + assert_eq!(cell_description(CellState::Modified).as_deref(), Some("Modified")); + assert_eq!(cell_description(CellState::Clean), None); + assert_eq!(cell_description(CellState::InsertDraft), None); + assert_eq!(row_description(RowState::InsertDraft).as_deref(), Some("New row")); + assert_eq!( + row_description(RowState::PendingDelete).as_deref(), + Some("Marked for deletion") + ); + assert_eq!(row_description(RowState::Clean), None); + assert_eq!(row_description(RowState::Modified), None); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs new file mode 100644 index 0000000000..d2d8e5a706 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_edit.rs @@ -0,0 +1,106 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; + +use super::calendar_editor::CalendarEditor; +use super::cell_editor_kind::CellEditorKind; +use super::edit_commit::empty_placeholder; +use super::inline_editor::{InlineEditor, InlineOptions}; +use super::multiline_editor::MultilineEditor; +use super::{EditSnapshot, GridCell, GridMsg}; +use crate::ui::error_text::edit_parse_message; + +/// Open the editor the cell's column and value call for. It starts from +/// the whole value with any pending edit, never from the text the cell +/// draws, which is cut short or stands in for the value. +/// +/// A boolean cell opens nothing: its check button is the editor, so +/// opening it never writes a value. +pub(crate) fn begin_edit(cell: &GridCell) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let kind = CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err)); + match kind { + CellEditorKind::ReadOnly(_) | CellEditorKind::Toggle(_) => return, + CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar => {} + } + let original = drawing.tab_ctx.edit_input(&row, cell.column()); + let text = match &original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + let editable = match &text { + // A GTK editor would hold the text only up to the NUL, and + // committing it would cut the value there. + EditText::Editable(editable) if editable.contains('\0') => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(crate::i18n::gettext( + "Values with a NUL character can't be edited here", + ))); + } + return; + } + EditText::Editable(editable) => editable.clone(), + EditText::ReadOnly(reason) => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(edit_parse_message(&EditParseError::NotEditable( + *reason, + )))); + } + return; + } + }; + let inline = |accepts_line_breaks, purpose| InlineOptions { + accepts_line_breaks, + purpose, + placeholder: empty_placeholder(column, row.id()), + }; + let snapshot = EditSnapshot { + row: row.clone(), + column: cell.column(), + original, + }; + match kind.for_text(&text) { + CellEditorKind::Inline { + accepts_line_breaks, + purpose, + } => InlineEditor::begin(cell, snapshot, &editable, inline(accepts_line_breaks, purpose)), + CellEditorKind::Multiline { language } => MultilineEditor::open(cell, snapshot, &editable, language), + CellEditorKind::Calendar => CalendarEditor::open( + cell, + snapshot, + column, + editable, + inline(false, gtk::InputPurpose::FreeForm), + ), + CellEditorKind::Toggle(_) | CellEditorKind::ReadOnly(_) => {} + } +} + +pub(super) fn install_edit_triggers(cell: &GridCell) { + // Editing takes a double-click, since a single click in a grid + // selects the row. Capture phase, because the column view's row + // selection takes the press in the bubble phase. The handler reads + // the cell from its gesture: the cell owns its controllers, so + // capturing it would be a cycle. + let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + gesture.connect_pressed(move |gesture, n_press, _, _| { + if n_press != 2 { + return; + } + let Some(cell) = gesture.widget().and_downcast::() else { + return; + }; + if cell.lock().is_some() || cell.is_editing() { + return; + } + gesture.set_state(gtk::EventSequenceState::Claimed); + begin_edit(&cell); + }); + cell.add_controller(gesture); +} diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs deleted file mode 100644 index 50a3b95b6a..0000000000 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! The display page is a plain label with no pointer controllers while -//! the cell takes edits, so a single click reaches the column view's row -//! selection. The edit page shows only after `start_editing`. - -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; -use gtk4::{glib, pango}; - -mod imp { - use super::*; - - pub struct CellEditor { - pub stack: gtk4::Stack, - pub label: gtk4::Label, - pub entry: gtk4::Text, - } - - impl Default for CellEditor { - fn default() -> Self { - Self { - stack: gtk4::Stack::new(), - label: gtk4::Label::new(None), - entry: gtk4::Text::new(), - } - } - } - - #[glib::object_subclass] - impl ObjectSubclass for CellEditor { - const NAME: &'static str = "TableProCellEditor"; - type Type = super::CellEditor; - type ParentType = gtk4::Widget; - - fn class_init(klass: &mut Self::Class) { - klass.set_layout_manager_type::(); - } - } - - impl ObjectImpl for CellEditor { - fn constructed(&self) { - self.parent_constructed(); - // App styles select this class, not the type's node name. - self.obj().add_css_class("tp-cell-editor"); - let stack = &self.stack; - stack.set_transition_type(gtk4::StackTransitionType::None); - stack.set_hhomogeneous(true); - stack.set_vhomogeneous(true); - let label = &self.label; - label.set_xalign(0.0); - label.set_hexpand(true); - label.set_ellipsize(pango::EllipsizeMode::End); - let entry = &self.entry; - entry.set_hexpand(true); - stack.add_named(label, Some("display")); - stack.add_named(entry, Some("edit")); - stack.set_visible_child_name("display"); - stack.set_parent(&*self.obj()); - - let weak_for_activate = self.obj().downgrade(); - entry.connect_activate(move |_| { - if let Some(this) = weak_for_activate.upgrade() { - this.stop_editing(true); - } - }); - - // Capture phase, so Escape cancels before the GtkText can - // take it to clear its own buffer. - let key_ctrl = gtk4::EventControllerKey::new(); - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let weak_for_key = self.obj().downgrade(); - key_ctrl.connect_key_pressed(move |_, keyval, _, _| { - if keyval == gtk4::gdk::Key::Escape - && let Some(this) = weak_for_key.upgrade() - && this.is_editing() - { - this.stop_editing(false); - return glib::Propagation::Stop; - } - glib::Propagation::Proceed - }); - entry.add_controller(key_ctrl); - - // GtkText flushes an IME preedit on focus-out before this - // runs, so the commit copies the composed text. - let focus_ctrl = gtk4::EventControllerFocus::new(); - let weak_for_focus = self.obj().downgrade(); - focus_ctrl.connect_leave(move |_| { - if let Some(this) = weak_for_focus.upgrade() - && this.is_editing() - { - this.stop_editing(true); - } - }); - entry.add_controller(focus_ctrl); - } - - fn dispose(&self) { - // GTK warns about a finalized widget that still has children. - self.stack.unparent(); - } - } - - impl WidgetImpl for CellEditor {} -} - -glib::wrapper! { - pub struct CellEditor(ObjectSubclass) - @extends gtk4::Widget, - @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; -} - -impl Default for CellEditor { - fn default() -> Self { - Self::new() - } -} - -impl CellEditor { - pub fn new() -> Self { - glib::Object::new() - } - - pub fn text(&self) -> glib::GString { - if self.is_editing() { - self.entry_widget().text() - } else { - self.label_widget().text() - } - } - - pub fn set_text(&self, s: &str) { - self.label_widget().set_text(s); - self.entry_widget().set_text(s); - } - - pub fn set_selectable(&self, selectable: bool) { - let label = self.label_widget(); - if label.is_selectable() != selectable { - label.set_selectable(selectable); - } - } - - /// The entry is left alone: a row marked for deletion is not edited. - pub fn set_strikethrough(&self, on: bool) { - let label = self.label_widget(); - if on { - let attrs = pango::AttrList::new(); - attrs.insert(pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } - } - - pub fn is_editing(&self) -> bool { - self.stack_widget() - .visible_child_name() - .map(|n| n == "edit") - .unwrap_or(false) - } - - /// `text` is the whole value, never what the label drew: that has - /// `` for a NULL. The label takes it too, so Escape puts back - /// what the edit started from and sends nothing. - pub fn start_editing(&self, text: &str) { - let stack = self.stack_widget(); - let entry = self.entry_widget(); - self.set_text(text); - stack.set_visible_child_name("edit"); - entry.grab_focus(); - // The first keystroke replaces the value, as in a Files rename. - entry.select_region(0, -1); - } - - pub fn stop_editing(&self, commit: bool) { - if commit { - let new_text = self.entry_widget().text(); - self.label_widget().set_text(&new_text); - } else { - self.entry_widget().set_text(&self.label_widget().text()); - } - self.stack_widget().set_visible_child_name("display"); - } - - pub fn connect_editing_notify(&self, callback: F) -> glib::SignalHandlerId { - let stack = self.stack_widget(); - let weak = self.downgrade(); - stack.connect_visible_child_name_notify(move |_| { - if let Some(this) = weak.upgrade() { - callback(&this); - } - }) - } - - pub fn entry(&self) -> gtk4::Text { - self.entry_widget() - } - - fn stack_widget(&self) -> gtk4::Stack { - self.imp().stack.clone() - } - - fn label_widget(&self) -> gtk4::Label { - self.imp().label.clone() - } - - fn entry_widget(&self) -> gtk4::Text { - self.imp().entry.clone() - } -} diff --git a/linux/crates/app/src/ui/grid/cell_editor_kind.rs b/linux/crates/app/src/ui/grid/cell_editor_kind.rs index 04c4e70c16..0966a9ac35 100644 --- a/linux/crates/app/src/ui/grid/cell_editor_kind.rs +++ b/linux/crates/app/src/ui/grid/cell_editor_kind.rs @@ -1,71 +1,276 @@ -/// A bool column never gets here: it is a check button, not an editor. +use gtk4 as gtk; + +use tablepro_core::ColumnInfo; +use tablepro_core::column::{BoolStorage, ColumnKind, IntegerKind}; +use tablepro_core::edit::{EditText, ReadOnlyReason, read_only_reason}; + +use super::CellLock; + +/// Past this many bytes a value opens in the multi-line editor: a +/// one-line entry lays all of it out at once and shows almost none. +pub const INLINE_EDIT_MAX_BYTES: usize = 4096; + +/// The editor a cell opens with. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum CellEditorKind { - Text, - Date, - Int, - Float, - Json, +pub enum CellEditorKind { + Toggle(BoolStorage), + Inline { + accepts_line_breaks: bool, + purpose: gtk::InputPurpose, + }, + Multiline { + language: Option<&'static str>, + }, + Calendar, + ReadOnly(CellLock), } -pub(super) fn classify_editor_kind(data_type: &str) -> CellEditorKind { - // JSON before the numbers: a contrived type name can match both. - if is_date_type(data_type) { - CellEditorKind::Date - } else if is_json_type(data_type) { - CellEditorKind::Json - } else if is_int_type(data_type) { - CellEditorKind::Int - } else if is_float_type(data_type) { - CellEditorKind::Float - } else { - CellEditorKind::Text +impl CellEditorKind { + pub fn for_cell(column: &ColumnInfo, editability: Result<(), CellLock>) -> Self { + if let Err(lock) = editability { + return Self::ReadOnly(lock); + } + let kind = &column.column_type.kind; + let inline = |purpose| Self::Inline { + accepts_line_breaks: kind.accepts_line_breaks(), + purpose, + }; + match kind { + ColumnKind::Bool(storage) => Self::Toggle(*storage), + ColumnKind::Json { .. } => Self::Multiline { language: Some("json") }, + ColumnKind::Xml => Self::Multiline { language: Some("xml") }, + ColumnKind::Date => Self::Calendar, + ColumnKind::Integer(IntegerKind { signed: false, .. }) => inline(gtk::InputPurpose::Digits), + ColumnKind::Integer(_) | ColumnKind::Float(_) | ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => { + inline(gtk::InputPurpose::Number) + } + ColumnKind::Text { .. } + | ColumnKind::Enum { .. } + | ColumnKind::Uuid + | ColumnKind::Network(_) + | ColumnKind::Interval + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::BitString { .. } + | ColumnKind::Other => inline(gtk::InputPurpose::FreeForm), + ColumnKind::Bytes { .. } + | ColumnKind::Spatial + | ColumnKind::Array { .. } + | ColumnKind::Variant + | ColumnKind::Composite => Self::ReadOnly(CellLock::Value( + read_only_reason(kind).unwrap_or(ReadOnlyReason::Composite), + )), + } } -} -pub(super) fn is_bool_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "bool" | "boolean" | "bit" | "tinyint(1)") + /// The editor for this value: none for a value with no text form, + /// and the multi-line one for text a one-line entry would mangle. + pub fn for_text(self, text: &EditText) -> Self { + match (self, text) { + (_, EditText::ReadOnly(reason)) => Self::ReadOnly(CellLock::Value(*reason)), + (Self::Inline { .. }, EditText::Editable(text)) + if text.contains(['\n', '\r']) || text.len() > INLINE_EDIT_MAX_BYTES => + { + Self::Multiline { language: None } + } + (kind, EditText::Editable(_)) => kind, + } + } } -/// A calendar picks a day and nothing else, so a type with a time in it -/// stays on the text editor. -fn is_date_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt == "date" || (dt.starts_with("date") && !dt.contains("datetime") && !dt.contains("time")) -} +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use tablepro_core::ColumnInfo; + use tablepro_core::column::{ + BoolStorage, ColumnDefault, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, + TemporalZone, TextLength, TimeRange, + }; + use tablepro_core::edit::{EditText, ReadOnlyReason}; -fn is_int_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!( - dt.as_str(), - "int" - | "int2" - | "int4" - | "int8" - | "integer" - | "smallint" - | "bigint" - | "tinyint" - | "mediumint" - | "serial" - | "bigserial" - | "smallserial" - ) || dt.starts_with("int(") - || dt.starts_with("integer(") - || dt.starts_with("smallint(") - || dt.starts_with("bigint(") - || dt.starts_with("mediumint(") -} + use super::{CellEditorKind, INLINE_EDIT_MAX_BYTES}; + use crate::ui::grid::CellLock; -/// Decimal, numeric and money are left out because `SqlDecimal` keeps -/// every digit and `GtkSpinButton` holds an f64. -fn is_float_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "float" | "double" | "real" | "double precision") || dt.starts_with("float(") -} + fn column(kind: ColumnKind) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(signed: bool) -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed, + }) + } + + #[test] + fn editor_kind_for_cell_matrix() { + let free = |accepts_line_breaks| CellEditorKind::Inline { + accepts_line_breaks, + purpose: gtk::InputPurpose::FreeForm, + }; + let number = CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Number, + }; + let cases = [ + ( + ColumnKind::Bool(BoolStorage::Native), + CellEditorKind::Toggle(BoolStorage::Native), + ), + ( + ColumnKind::Bool(BoolStorage::Integer), + CellEditorKind::Toggle(BoolStorage::Integer), + ), + ( + integer(false), + CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Digits, + }, + ), + (integer(true), number), + (ColumnKind::Float(FloatWidth::F64), number), + ( + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + number, + ), + (ColumnKind::Money { scale: Some(2) }, number), + ( + ColumnKind::Text { + length: TextLength::Unbounded, + }, + free(true), + ), + ( + ColumnKind::Enum { + labels: vec!["a".to_owned()], + multiple: false, + }, + free(false), + ), + (ColumnKind::Uuid, free(false)), + (ColumnKind::Network(NetworkFamily::Inet), free(false)), + (ColumnKind::Interval, free(false)), + ( + ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range: TimeRange::TimeOfDay, + }, + free(false), + ), + ( + ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Offset, + }, + free(false), + ), + ( + ColumnKind::BitString { + length: Some(8), + varying: false, + }, + free(false), + ), + (ColumnKind::Other, free(true)), + ( + ColumnKind::Json { binary: true }, + CellEditorKind::Multiline { language: Some("json") }, + ), + (ColumnKind::Xml, CellEditorKind::Multiline { language: Some("xml") }), + (ColumnKind::Date, CellEditorKind::Calendar), + ( + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)), + ), + ( + ColumnKind::Spatial, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Spatial)), + ), + ( + ColumnKind::Array { + element: Box::new(integer(true)), + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ( + ColumnKind::Variant, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Variant)), + ), + ( + ColumnKind::Composite, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ]; + for (kind, expected) in cases { + assert_eq!( + CellEditorKind::for_cell(&column(kind.clone()), Ok(())), + expected, + "{kind:?}" + ); + } -fn is_json_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt.contains("json") + let text = column(ColumnKind::Text { + length: TextLength::Unbounded, + }); + for lock in [ + CellLock::Value(ReadOnlyReason::Undecodable), + CellLock::KeyColumn, + CellLock::Generated, + CellLock::AutoIncrement, + CellLock::UnkeyedRow, + CellLock::ResultRow, + CellLock::ReadOnlySession, + ] { + assert_eq!( + CellEditorKind::for_cell(&text, Err(lock)), + CellEditorKind::ReadOnly(lock) + ); + } + } + + #[test] + fn inline_escalates_to_multiline_for_line_breaks_and_4096_bytes() { + let inline = CellEditorKind::Inline { + accepts_line_breaks: true, + purpose: gtk::InputPurpose::FreeForm, + }; + let multiline = CellEditorKind::Multiline { language: None }; + let editable = |text: String| EditText::Editable(text); + + assert_eq!(inline.for_text(&editable("one line".to_owned())), inline); + assert_eq!(inline.for_text(&editable("two\nlines".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("carriage\rreturn".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES))), inline); + assert_eq!( + inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + multiline + ); + assert_eq!( + CellEditorKind::Calendar.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + CellEditorKind::Calendar, + "only the one-line editor gives way" + ); + assert_eq!( + inline.for_text(&EditText::ReadOnly(ReadOnlyReason::Bytes)), + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)) + ); + } } diff --git a/linux/crates/app/src/ui/grid/cell_focus.rs b/linux/crates/app/src/ui/grid/cell_focus.rs new file mode 100644 index 0000000000..e003c67411 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_focus.rs @@ -0,0 +1,419 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::GridCell; +use super::cell_edit::begin_edit; +use super::edit_navigation::{EditDirection, next_edit_target}; +use super::grid_columns::ShownColumn; +use super::selection::row_at; + +fn window_focus(widget: &impl IsA) -> Option { + let window = widget.root().and_downcast::()?; + gtk::prelude::GtkWindowExt::focus(&window) +} + +pub(super) fn focused_cell(widget: &impl IsA) -> Option { + GridCell::from_focus(&window_focus(widget)?) +} + +/// Unlike `focused_cell`, not a cell whose editor has the focus. +fn cell_holding_focus(view: >k::ColumnView) -> Option { + window_focus(view)?.first_child()?.downcast().ok() +} + +fn n_rows(view: >k::ColumnView) -> u32 { + view.model().map_or(0, |model| model.n_items()) +} + +pub(super) fn focus_cell(view: >k::ColumnView, position: u32, column: &ShownColumn) -> Option { + if position >= n_rows(view) { + return None; + } + view.scroll_to(position, Some(&column.view_column), gtk::ListScrollFlags::FOCUS, None); + let landed = |cell: &GridCell| { + cell.position() == Some(position) && cell.column() == column.index && cell.column_view().as_ref() == Some(view) + }; + // `scroll_to` moves the focus only while it is in the list already. + // Otherwise it marks the cell the list focuses next, which the grab + // then reaches. + if !cell_holding_focus(view).is_some_and(|cell| landed(&cell)) { + view.grab_focus(); + } + cell_holding_focus(view).filter(landed) +} + +/// Give the focus back to `cell` once its editor closed, since the +/// editor took the focus with it. +pub(super) fn return_focus(cell: &GridCell) { + let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { + return; + }; + if let Some(column) = columns + .shown(&view) + .into_iter() + .find(|column| column.index == cell.column()) + { + focus_cell(&view, position, &column); + } +} + +/// Edit the cell Tab or Shift+Tab goes to from `cell`. With none left +/// on the page, the focus stays on `cell`. +pub(super) fn edit_next(cell: &GridCell, direction: EditDirection) { + let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { + return; + }; + let shown = columns.shown(&view); + let Some(from) = shown.iter().position(|column| column.index == cell.column()) else { + return; + }; + let opens_editor = |row: u32, column: usize| { + shown + .get(column) + .zip(row_at(&view, row)) + .is_some_and(|(column, row)| column.opens_editor(&row)) + }; + let target = next_edit_target(opens_editor, shown.len(), from, position, n_rows(&view), direction) + .and_then(|(row, column)| Some((row, shown.get(column)?))); + match target { + Some((row, column)) => { + if let Some(next) = focus_cell(&view, row, column) { + begin_edit(&next); + } + } + None => { + if let Some(column) = shown.get(from) { + focus_cell(&view, position, column); + } + } + } +} + +/// Select the row at `position` and edit its first cell that opens an +/// editor. A row with none keeps the focus on the cell the row gave it. +pub(crate) fn edit_row(view: >k::ColumnView, position: u32) { + if position >= n_rows(view) { + return; + } + // Every cell knows the grid's columns, so the row takes the focus + // first and its cell finds the column to edit. The grab reaches the + // row when the focus was outside the list, where `scroll_to` only + // marks it. + view.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + view.grab_focus(); + let (Some(columns), Some(row)) = ( + focused_cell(view).and_then(|cell| cell.columns()), + row_at(view, position), + ) else { + return; + }; + let Some(column) = columns.shown(view).into_iter().find(|column| column.opens_editor(&row)) else { + return; + }; + if let Some(cell) = focus_cell(view, position, &column) { + begin_edit(&cell); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + use std::time::Duration; + + use gtk4::prelude::*; + use gtk4::{self as gtk, gdk, glib}; + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + use tablepro_core::value::Temporal; + + use super::edit_row; + use crate::test_support::{drain_main_context, first_descendant_of_type, wait_until}; + use crate::ui::grid::test_grid::{Grid, WAIT, edited_rows, key, named, text}; + use crate::ui::grid::{GridCell, begin_edit}; + + /// Press `key` in the cell's open one-line editor, through the + /// handler a real key press reaches first. + fn press_in_editor(cell: &GridCell, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let editor = cell.inline_text().expect("an open one-line editor"); + let controllers = editor.observe_controllers(); + let keys = (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .find(|controller| controller.propagation_phase() == gtk::PropagationPhase::Capture) + .expect("the editor's key handler"); + keys.emit_by_name::("key-pressed", &[&key, &0u32, &modifiers]) + } + + /// The editing cell once the focus is in its editor. + fn editing(grid: &Grid) -> GridCell { + let mut editing = None; + wait_until(WAIT, || { + editing = grid.focused_cell().filter(|cell| cell.inline_text().is_some()); + editing.is_some() + }) + .expect("a cell is open for editing with the focus"); + editing.expect("an editing cell") + } + + fn at(cell: &GridCell) -> (Option, usize) { + (cell.position(), cell.column()) + } + + /// A key, a text, a check box and a text: the key is locked on the + /// saved rows, and a check box opens no editor. + fn mixed_rows() -> Grid { + Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + named("note", "text", false), + ], + vec![ + vec![ + Value::Int(1), + Value::Text("a".into()), + Value::Bool(false), + Value::Text("x".into()), + ], + vec![ + Value::Int(2), + Value::Text("b".into()), + Value::Bool(true), + Value::Text("y".into()), + ], + ], + ) + } + + #[gtk4::test] + fn tab_in_edit_mode_moves_to_next_editable_column() { + let grid = mixed_rows(); + let name = grid.focus(0, 1); + crate::ui::grid::begin_edit(&name); + name.inline_text().expect("an editor").set_text("a2"); + let none = gdk::ModifierType::empty(); + + assert!(press_in_editor(&name, gdk::Key::Tab, none)); + let note = editing(&grid); + assert_eq!(at(¬e), (Some(0), 3), "Tab did not skip the check box"); + assert_eq!( + note.inline_text().map(|editor| editor.text().to_string()).as_deref(), + Some("x") + ); + + assert!(press_in_editor(¬e, gdk::Key::Tab, none)); + let next_row = editing(&grid); + assert_eq!(at(&next_row), (Some(1), 1), "Tab did not skip the saved row's key"); + + assert!(press_in_editor( + &next_row, + gdk::Key::ISO_Left_Tab, + gdk::ModifierType::SHIFT_MASK + )); + let back = editing(&grid); + assert_eq!(back, note, "Shift+Tab did not go back a row"); + + assert!(!name.is_editing() && !next_row.is_editing()); + assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]); + } + + #[gtk4::test] + fn tab_past_the_last_editable_cell_keeps_the_focus_there() { + let grid = mixed_rows(); + let note = grid.focus(1, 3); + crate::ui::grid::begin_edit(¬e); + + assert!(press_in_editor(¬e, gdk::Key::Tab, gdk::ModifierType::empty())); + + assert!(!note.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); + } + + #[gtk4::test] + fn escape_cancels_and_returns_focus_to_cell() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + crate::ui::grid::begin_edit(&name); + name.inline_text().expect("an editor").set_text("typed"); + + assert!(press_in_editor(&name, gdk::Key::Escape, gdk::ModifierType::empty())); + + assert!(!name.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&name), "the focus did not come back"); + assert!(grid.take_messages().is_empty(), "Escape sent the typed text"); + } + + #[gtk4::test] + fn ctrl_tab_in_an_editor_is_left_to_the_window() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + crate::ui::grid::begin_edit(&name); + + assert!(!press_in_editor(&name, gdk::Key::Tab, gdk::ModifierType::CONTROL_MASK)); + assert!(name.is_editing()); + } + + #[gtk4::test] + fn new_draft_enters_edit_mode_on_first_editable_cell() { + let mut serial = named("id", "integer", true); + serial.is_auto_increment = true; + let grid = Grid::open( + vec![serial, named("name", "text", false), named("note", "text", false)], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("x".into())]], + ); + grid.insert_draft(); + + edit_row(&grid.view, 0); + + let cell = editing(&grid); + assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); + assert_eq!(cell.column(), 1, "the edit did not skip the auto-increment key"); + let selection = grid.view.model().expect("a selection model"); + assert!(selection.is_selected(0) && !selection.is_selected(1)); + } + + fn focus_in(grid: &Grid, popover: >k::Popover) -> bool { + gtk::prelude::GtkWindowExt::focus(&grid.window).is_some_and(|focus| focus.is_ancestor(popover)) + } + + /// Open the cell's editor popover with the focus in it. Test processes + /// share one X display, where another test's window can close an + /// autohide popover before it maps. + fn open_popover(grid: &Grid, cell: &GridCell) -> gtk::Popover { + for _ in 0..5 { + begin_edit(cell); + if let Some(popover) = first_descendant_of_type::(cell) + && wait_until(Duration::from_secs(2), || { + popover.is_mapped() && focus_in(grid, &popover) + }) + .is_ok() + { + return popover; + } + cell.cancel_edit(); + drain_main_context(); + } + panic!("the editor popover never showed"); + } + + /// GTK moves a focus it finds in a hidden popover once the frame is + /// painted, so the focus is checked after that. + fn after_next_frame(grid: &Grid) { + let clock = grid.window.frame_clock().expect("the window's frame clock"); + let painted = Rc::new(Cell::new(false)); + let flag = painted.clone(); + let handler = clock.connect_after_paint(move |_| flag.set(true)); + clock.request_phase(gdk::FrameClockPhase::AFTER_PAINT); + wait_until(WAIT, || painted.get()).expect("the window paints a frame"); + clock.disconnect(handler); + } + + fn button(popover: >k::Popover, label: &str) -> gtk::Button { + crate::test_support::descendants(popover) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label)) + .expect("the popover's button") + } + + fn day(day: u32) -> Value { + Value::Date(Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2024, 3, day).expect("a date"), + )) + } + + fn dated_row() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("day", "date", false)], + vec![vec![Value::Int(1), day(15)]], + ) + } + + #[gtk4::test] + fn picking_a_day_returns_focus_to_cell() { + let grid = dated_row(); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + let calendar = first_descendant_of_type::(&popover).expect("the calendar"); + + calendar.set_date(&glib::DateTime::from_utc(2024, 3, 16, 0, 0, 0.0).expect("a day")); + after_next_frame(&grid); + + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, CellInput::Value(day(16)))] + ); + } + + #[gtk4::test] + fn closing_the_calendar_returns_focus_to_cell() { + let grid = dated_row(); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + + // As Escape does. + popover.popdown(); + after_next_frame(&grid); + + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); + assert!(grid.take_messages().is_empty()); + } + + #[gtk4::test] + fn removing_the_row_under_an_open_calendar_drops_its_edit() { + let grid = Grid::open( + vec![named("id", "integer", true), named("day", "date", false)], + vec![vec![Value::Int(1), day(15)], vec![Value::Int(2), day(20)]], + ); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + + grid.store.remove(0); + after_next_frame(&grid); + + assert!(popover.parent().is_none(), "the popover outlived its row"); + assert!(!cell.is_editing()); + assert!(grid.take_messages().is_empty()); + let focus = gtk::prelude::GtkWindowExt::focus(&grid.window); + assert!( + focus.is_some_and(|focus| focus == *grid.view.upcast_ref::() || focus.is_ancestor(&grid.view)), + "the focus left the grid" + ); + } + + #[gtk4::test] + fn the_multi_line_editor_returns_focus_to_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("note", "text", false)], + vec![vec![Value::Int(1), Value::Text("one\ntwo".into())]], + ); + let cell = grid.focus(0, 1); + + let popover = open_popover(&grid, &cell); + button(&popover, "Cancel").emit_clicked(); + after_next_frame(&grid); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Cancel left the grid"); + + let popover = open_popover(&grid, &cell); + let view = first_descendant_of_type::(&popover).expect("the multi-line editor"); + view.buffer().set_text("one\nthree"); + button(&popover, "Save").emit_clicked(); + after_next_frame(&grid); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Save left the grid"); + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text("one\nthree"))] + ); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_popovers.rs b/linux/crates/app/src/ui/grid/cell_popovers.rs deleted file mode 100644 index e843aceca1..0000000000 --- a/linux/crates/app/src/ui/grid/cell_popovers.rs +++ /dev/null @@ -1,303 +0,0 @@ -use chrono::Datelike; -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; -use sourceview5::prelude::*; - -use super::display_text::truncate_for_display; -use super::{GridCell, GridMsg, GridSink}; - -fn current_text(cell: &GridCell) -> String { - cell.edit_text().unwrap_or_default() -} - -/// Parent the popover to the cell and keep it there until it closes. -fn present(cell: &GridCell, popover: >k::Popover) { - popover.set_parent(cell); - cell.set_popover(popover); - // The cell holds the popover in its slot and as its child, so the - // popover holds the cell weakly and itself not at all. - let cell_for_close = cell.downgrade(); - popover.connect_closed(move |popover| { - popover.unparent(); - if let Some(cell) = cell_for_close.upgrade() { - cell.release_popover(popover); - } - }); - popover.popup(); -} - -/// The popover is closed before the tab hears of the value, so an edit -/// the tab redraws the row for never meets an open popover. -fn commit(cell: &GridCell, popover: >k::Popover, input: String, sink: &GridSink) { - // Drawn now rather than after the tracker's round trip. - if let Some(editor) = cell.editor() { - editor.set_text(&truncate_for_display(&input)); - } - let row = cell.row(); - popover.popdown(); - if let Some(row) = row { - sink(GridMsg::CellEdited { - row, - column: cell.column(), - input, - }); - } -} - -pub(super) fn show_calendar_popover(cell: &GridCell, sink: &GridSink) { - let calendar = gtk::Calendar::new(); - if let Ok(parsed) = chrono::NaiveDate::parse_from_str(¤t_text(cell), "%Y-%m-%d") - && let Ok(dt) = glib::DateTime::from_local(parsed.year(), parsed.month() as i32, parsed.day() as i32, 0, 0, 0.0) - { - calendar.set_date(&dt); - } - - let popover = gtk::Popover::builder().child(&calendar).build(); - let cell_for_cal = cell.downgrade(); - let popover_for_cal = popover.downgrade(); - let sink = sink.clone(); - calendar.connect_day_selected(move |c| { - let (Some(cell), Some(popover)) = (cell_for_cal.upgrade(), popover_for_cal.upgrade()) else { - return; - }; - let dt = c.date(); - let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); - commit(&cell, &popover, formatted, &sink); - }); - present(cell, &popover); -} - -pub(super) fn show_spin_button_popover(cell: &GridCell, sink: &GridSink, is_float: bool) { - let current_text = current_text(cell); - let (initial, lower, upper, step, digits) = if is_float { - let val = current_text.parse::().unwrap_or(0.0); - (val, f64::MIN, f64::MAX, 0.1_f64, 6_u32) - } else { - let val = current_text.parse::().unwrap_or(0) as f64; - (val, i64::MIN as f64, i64::MAX as f64, 1.0_f64, 0_u32) - }; - let adjustment = gtk::Adjustment::new(initial, lower, upper, step, step * 10.0, 0.0); - let spin = gtk::SpinButton::new(Some(&adjustment), step, digits); - spin.set_numeric(true); - spin.set_width_chars(20); - - let popover = gtk::Popover::builder().child(&spin).build(); - let cell_for_commit = cell.downgrade(); - let popover_for_commit = popover.downgrade(); - let sink = sink.clone(); - spin.connect_activate(move |s| { - let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { - return; - }; - // f64's Display claims no more precision than the value has. - let formatted = if is_float { - format!("{}", s.value()) - } else { - format!("{}", s.value() as i64) - }; - commit(&cell, &popover, formatted, &sink); - }); - present(cell, &popover); - spin.grab_focus(); -} - -pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { - let buffer = sourceview5::Buffer::new(None); - if let Some(lang) = sourceview5::LanguageManager::default().language("json") { - buffer.set_language(Some(&lang)); - } - buffer.set_text(¤t_text(cell)); - - let view = sourceview5::View::with_buffer(&buffer); - view.set_show_line_numbers(true); - view.set_monospace(true); - view.set_auto_indent(true); - view.set_tab_width(2); - view.set_indent_width(2); - show_buffer_popover(cell, sink, &view); -} - -/// For a text the one-line editor cannot lay out without stalling. -pub(super) fn show_long_text_popover(cell: &GridCell, sink: &GridSink, text: &str) { - let buffer = sourceview5::Buffer::new(None); - buffer.set_text(text); - let view = sourceview5::View::with_buffer(&buffer); - view.set_wrap_mode(gtk::WrapMode::WordChar); - show_buffer_popover(cell, sink, &view); -} - -/// Saves through a button, since Return inserts a newline in a -/// multi-line buffer. -fn show_buffer_popover(cell: &GridCell, sink: &GridSink, view: &sourceview5::View) { - let buffer = view.buffer(); - - let scrolled = gtk::ScrolledWindow::builder() - .child(view) - .min_content_width(420) - .min_content_height(280) - .has_frame(true) - .build(); - - let save_button = gtk::Button::with_label(&crate::i18n::gettext("Save")); - save_button.add_css_class("suggested-action"); - - let cancel_button = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); - - let button_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .halign(gtk::Align::End) - .build(); - button_box.append(&cancel_button); - button_box.append(&save_button); - - let container = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(8) - .margin_bottom(8) - .margin_start(8) - .margin_end(8) - .build(); - container.append(&scrolled); - container.append(&button_box); - - let popover = gtk::Popover::builder().child(&container).build(); - - let popover_for_cancel = popover.downgrade(); - cancel_button.connect_clicked(move |_| { - if let Some(popover) = popover_for_cancel.upgrade() { - popover.popdown(); - } - }); - - let cell_for_commit = cell.downgrade(); - let popover_for_commit = popover.downgrade(); - let sink = sink.clone(); - let buffer_for_commit = buffer.clone(); - save_button.connect_clicked(move |_| { - let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { - return; - }; - let start = buffer_for_commit.start_iter(); - let end = buffer_for_commit.end_iter(); - let text = buffer_for_commit.text(&start, &end, true).to_string(); - // The tab parses the text for its column and refuses, with a - // toast, a value the column cannot take. - commit(&cell, &popover, text, &sink); - }); - - present(cell, &popover); - view.grab_focus(); -} - -#[cfg(test)] -mod tests { - use gtk4::prelude::*; - use gtk4::{self as gtk, glib}; - - use super::{show_calendar_popover, show_json_popover, show_spin_button_popover}; - use crate::ui::grid::{CellEditor, GridCell, GridSink}; - - #[gtk4::test] - fn calendar_selects_cell_date() { - let calendar = gtk::Calendar::new(); - let date = glib::DateTime::from_local(2026, 9, 15, 0, 0, 0.0).unwrap(); - - calendar.set_date(&date); - - assert_eq!(calendar.date().year(), 2026); - assert_eq!(calendar.date().month(), 9); - assert_eq!(calendar.date().day_of_month(), 15); - } - - #[derive(Clone, Copy, Debug)] - enum PopoverEditor { - Date, - Int, - Json, - } - - /// Commit through the editor's own control, which also closes it. - fn commit_popover(editor: PopoverEditor, popover: >k::Popover) { - use crate::test_support::first_descendant_of_type; - match editor { - PopoverEditor::Date => first_descendant_of_type::(popover) - .expect("the calendar") - .emit_by_name::<()>("day-selected", &[]), - PopoverEditor::Int => { - let spin = first_descendant_of_type::(popover).expect("the spin button"); - spin.set_value(42.0); - spin.emit_by_name::<()>("activate", &[]); - } - PopoverEditor::Json => { - first_descendant_of_type::(popover) - .expect("the JSON view") - .buffer() - .set_text("[1]"); - crate::test_support::descendants(popover) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); - } - } - } - - /// Another test's window can take the focus and close an autohide - /// popover before it opens, so a dismissed one is shown again. - fn open_popover(editor: PopoverEditor, cell: &GridCell, sink: &GridSink) -> Option { - for _ in 0..5 { - match editor { - PopoverEditor::Date => show_calendar_popover(cell, sink), - PopoverEditor::Int => show_spin_button_popover(cell, sink, false), - PopoverEditor::Json => show_json_popover(cell, sink), - } - let popover = crate::test_support::first_descendant_of_type::(cell)?; - let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - popover.is_mapped() || !popover.is_visible() - }); - if settled.is_ok() && popover.is_mapped() { - return Some(popover); - } - if popover.parent().is_some() { - popover.unparent(); - } - cell.take_popover(); - } - None - } - - #[gtk4::test] - fn a_closed_popover_editor_frees_its_cell() { - use crate::test_support::wait_until; - let timeout = std::time::Duration::from_secs(5); - let window = gtk::Window::new(); - window.present(); - for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { - let sink: GridSink = std::rc::Rc::new(|_| {}); - let cell = GridCell::new(); - let text = CellEditor::new(); - text.set_text("x"); - cell.set_content(&text); - window.set_child(Some(&cell)); - wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); - let popover = open_popover(editor, &cell, &sink).expect("the editor popover opens"); - - commit_popover(editor, &popover); - wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); - assert_ne!(text.text(), "x", "{editor:?} did not commit"); - assert!(!cell.is_editing(), "{editor:?} left its popover in the cell"); - // Only the cell is checked: GTK itself keeps a closed autohide - // popover alive once a child of it has taken focus. - let freed = cell.downgrade(); - window.set_child(None::<>k::Widget>); - drop((cell, text, popover)); - crate::test_support::drain_main_context(); - - assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); - } - window.destroy(); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/ui/grid/cell_preset.rs index 319403172f..61c6cab363 100644 --- a/linux/crates/app/src/ui/grid/cell_preset.rs +++ b/linux/crates/app/src/ui/grid/cell_preset.rs @@ -1,5 +1,95 @@ +use tablepro_core::ColumnInfo; + +use super::GridRowId; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellPreset { Empty, Null, + /// Leave the column out of the new row's INSERT. + Default, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellPresets { + pub empty: bool, + pub null: bool, + pub default: bool, +} + +/// Only an INSERT can leave a column out, so Default is for new rows +/// alone, and only where the server has something to fill in. +pub fn cell_presets(column: &ColumnInfo, row: &GridRowId) -> CellPresets { + let server_fills = !column.default.is_none() || column.is_auto_increment || column.is_generated; + CellPresets { + empty: column.column_type.kind.accepts_empty_string(), + null: column.nullable, + default: matches!(row, GridRowId::Draft(_)) && server_fills, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, TextLength}; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CellPresets, cell_presets}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + fn column(kind: ColumnKind, nullable: bool, default: ColumnDefault) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default, + comment: None, + } + } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where a default expression comes from" + )] + fn presets_enabled_per_kind_nullability_and_draft() { + let text = ColumnKind::Text { + length: TextLength::Unbounded, + }; + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + let now = ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text("now()")); + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(1); + let presets = |empty, null, default| CellPresets { empty, null, default }; + + assert_eq!( + cell_presets(&column(text.clone(), true, ColumnDefault::None), &saved), + presets(true, true, false) + ); + assert_eq!( + cell_presets(&column(text, false, ColumnDefault::None), &draft), + presets(true, false, false), + "a column with nothing to fill offered Default" + ); + assert_eq!( + cell_presets(&column(integer.clone(), false, now.clone()), &draft), + presets(false, false, true) + ); + assert_eq!( + cell_presets(&column(integer.clone(), true, now), &saved), + presets(false, true, false), + "a saved row offered Default" + ); + let mut serial = column(integer, false, ColumnDefault::None); + serial.is_auto_increment = true; + assert_eq!(cell_presets(&serial, &draft), presets(false, false, true)); + } } diff --git a/linux/crates/app/src/ui/grid/cell_tooltip.rs b/linux/crates/app/src/ui/grid/cell_tooltip.rs new file mode 100644 index 0000000000..6683fdc6ea --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_tooltip.rs @@ -0,0 +1,61 @@ +/// Shorter text fits a typical 200 px column, so its ellipsized display +/// needs no tooltip. +const TOOLTIP_MIN_CHARS: usize = 40; +/// A tooltip is a glance, not a viewer: past this the full value is a +/// menu item away. +pub(super) const TOOLTIP_MAX_CHARS: usize = 300; + +/// The tooltip for a cell's text. `None` for a placeholder and for text +/// short enough to read in the cell. +pub(super) fn cell_tooltip(text: &str, truncated_bytes: usize, placeholder: bool) -> Option { + if placeholder { + return None; + } + let mut chars = text.char_indices(); + if truncated_bytes == 0 && chars.nth(TOOLTIP_MIN_CHARS).is_none() { + return None; + } + let cut = text.char_indices().nth(TOOLTIP_MAX_CHARS).map(|(index, _)| index); + let mut tooltip = match cut { + Some(end) => format!("{}…", &text[..end]), + None => text.to_owned(), + }; + if cut.is_some() || truncated_bytes > 0 { + tooltip.push('\n'); + tooltip.push_str(&crate::i18n::gettext("Use Show Row as JSON to see the full value")); + } + Some(tooltip) +} + +#[cfg(test)] +mod tests { + use super::{TOOLTIP_MAX_CHARS, cell_tooltip}; + + #[test] + fn cell_tooltip_none_for_short_and_placeholder() { + assert_eq!(cell_tooltip("short", 0, false), None); + assert_eq!(cell_tooltip(&"x".repeat(40), 0, false), None); + assert_eq!(cell_tooltip(&"x".repeat(500), 0, true), None, "a placeholder"); + assert_eq!( + cell_tooltip(&"x".repeat(41), 0, false).as_deref(), + Some("x".repeat(41).as_str()) + ); + } + + #[test] + fn cell_tooltip_capped_at_300_chars_with_hint() { + let tooltip = cell_tooltip(&"é".repeat(1_000), 0, false).expect("a tooltip"); + let (head, hint) = tooltip.split_once('\n').expect("a hint line"); + + assert_eq!(head.chars().count(), TOOLTIP_MAX_CHARS + 1); + assert!(head.ends_with('…')); + assert!(hint.contains("Show Row as JSON"), "{hint}"); + } + + #[test] + fn a_cut_cell_says_so_even_when_its_text_is_short() { + let tooltip = cell_tooltip("head", 12, false).expect("a tooltip"); + + assert!(tooltip.starts_with("head\n"), "{tooltip}"); + } +} diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index d2227eeaae..5891dd8733 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -1,10 +1,17 @@ +use std::rc::Rc; + use gtk4 as gtk; use gtk4::prelude::*; +use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; use tablepro_core::{ColumnInfo, Value}; -use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text}; -use super::{CellEditor, CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; +use super::cell_tooltip::cell_tooltip; +use super::display_text::{DisplayText, display_text}; +use super::grid_attributes::GridAttributes; +use super::toggle_cell::{ToggleCell, toggle_state}; +use super::{CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; +use crate::services::change_tracker::CellState; const PENDING_CSS_CLASSES: &[&str] = &[ "tp-cell-modified", @@ -13,20 +20,20 @@ const PENDING_CSS_CLASSES: &[&str] = &[ "tp-row-leftmost-error-flash", ]; -/// Shorter text fits a typical 200 px column, so its ellipsized label -/// needs no tooltip. -const TOOLTIP_MIN_CHARS: usize = 40; - /// What a column's cells read to draw a row. The column makes one and /// every cell it builds shares it. #[derive(Debug)] pub struct ColumnDrawing { /// `None` for a column the grid knows only from the result set. pub catalog: Option, + /// The type the result set gives the column, which a value is + /// written out in. + pub column_type: ColumnType, pub tab_ctx: TabGridContext, - /// The server fills these in, so a draft row shows a placeholder + /// The server fills these in, so a new row shows a placeholder /// rather than an empty cell the user should type in. pub auto_filled: bool, + pub attributes: Rc, } impl ColumnDrawing { @@ -37,71 +44,74 @@ impl ColumnDrawing { } } - pub fn draw(&self, cell: &GridCell, row: &RowObject) { - let (value, pending_classes) = self.tab_ctx.bind_state(row, cell.column()); - draw_cell(cell, &value, &pending_classes, cell.lock().is_none(), self.auto_filled); + pub fn draw(&self, cell: &GridCell, row: &RowObject) -> CellState { + let column = cell.column(); + let (pending_classes, state) = self.tab_ctx.pending_marks(row, column); + self.tab_ctx.with_effective_value(row, column, |value, default| { + // An edit opened on a value the row no longer has would, once + // it ended, send that value back. An edit on the same value + // keeps what was typed. + if cell.edit_left_behind(value, default) { + cell.cancel_edit(); + } + let Some(content) = cell.content() else { + return; + }; + apply_pending_classes(content, &pending_classes); + let pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + if let Some(check) = content.downcast_ref::() { + let storage = match self.catalog.as_ref().map(|column| &column.column_type.kind) { + Some(ColumnKind::Bool(storage)) => *storage, + _ => BoolStorage::Native, + }; + ToggleCell::draw(cell, check, &toggle_state(value, default, &self.column_type, storage)); + check.set_sensitive(cell.lock().is_none()); + // A check box has no strikethrough, so a pending delete dims it. + check.set_opacity(if pending_delete { 0.5 } else { 1.0 }); + } else if let Some(display) = content.downcast_ref::() { + let shown = CellText { + value, + default, + editable: cell.lock().is_none(), + pending_delete, + }; + cell.with_scratch(|scratch| self.draw_text(display, &shown, scratch)); + } + }); + state } -} -/// `editable` is whether this row's cell takes an edit: such a cell -/// shows NULL as ``, so it reads differently from an empty -/// string, and a locked one dims a plain NULL. -fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static str], editable: bool, auto_filled: bool) { - let is_null = matches!(value, Value::Null); - let text = if is_null && auto_filled { - auto_filled_sentinel() - } else if is_null && editable { - editable_null_sentinel() - } else { - value_to_display_text(value) - }; - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - let Some(content) = grid_cell.content() else { - return; - }; - if let Some(editor) = content.downcast_ref::() { - // An edit opened on a value the row no longer has would, once it - // ended, send that value back or leave it on screen. An edit on - // the same value keeps what was typed. - if grid_cell.record_drawn_text(&text) && (editor.is_editing() || grid_cell.is_editing()) { - grid_cell.cancel_edit(); - } - if !editor.is_editing() { - editor.set_text(&text); - } - // A locked cell's text is there to be read and copied, as a - // read-only column's is. - editor.set_selectable(!editable); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null && !editable); - set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); - apply_pending_classes(content, pending_classes); - editor.set_strikethrough(is_pending_delete); - } else if let Some(checkbox) = content.downcast_ref::() { - // Suppressed, so drawing the value does not echo back as an edit. - grid_cell.suppress_toggle(true); - // A value that is not a bool shows as inconsistent rather than as - // a confidently wrong check box. - let (active, inconsistent) = match value { - Value::Bool(flag) => (*flag, false), - _ => (false, true), + /// An editable cell shows NULL as ``, so it reads differently + /// from an empty string, and a locked one dims a plain NULL. + fn draw_text(&self, display: >k::Inscription, cell: &CellText<'_>, scratch: &mut String) { + let attributes = &self.attributes; + let shown = match (cell.default, cell.value) { + (true, _) if self.auto_filled => DisplayText::placeholder(&attributes.auto_filled), + (true, _) => DisplayText::placeholder("DEFAULT"), + (false, Value::Null) if cell.editable => DisplayText::placeholder(&attributes.editable_null), + (false, value) => display_text(value, &self.column_type, scratch), }; - checkbox.set_inconsistent(inconsistent); - checkbox.set_active(active); - grid_cell.suppress_toggle(false); - checkbox.set_sensitive(editable); - apply_pending_classes(content, pending_classes); - // A check box has no strikethrough, so a pending delete dims it. - checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); - } else if let Some(label) = content.downcast_ref::() { - label.set_text(&text); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null); - apply_pending_classes(content, pending_classes); - set_label_strikethrough(label, is_pending_delete); + display.set_text(Some(shown.text)); + let widget = display.upcast_ref::(); + widget.set_tooltip_text(cell_tooltip(shown.text, shown.truncated_bytes, shown.placeholder).as_deref()); + set_class( + widget, + "dimmed", + !cell.default && cell.value.is_null() && !cell.editable, + ); + display.set_attributes(attributes.for_text(cell.pending_delete, shown.placeholder)); } } +/// One cell's value and the state it is drawn in. +struct CellText<'a> { + value: &'a Value, + /// A draft's cell left to the server. + default: bool, + editable: bool, + pending_delete: bool, +} + fn set_class(widget: >k::Widget, class: &str, on: bool) { if on { widget.add_css_class(class); @@ -115,23 +125,3 @@ fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) set_class(widget, class, pending_classes.contains(class)); } } - -fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { - if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { - widget.set_tooltip_text(None); - } else { - widget.set_tooltip_text(Some(text)); - } -} - -/// A Pango attribute, because CSS `text-decoration` does not cascade -/// reliably into a cell's label. -fn set_label_strikethrough(label: >k::Label, on: bool) { - if on { - let attrs = gtk::pango::AttrList::new(); - attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } -} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 659e8c861f..0cdd4ff26d 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -3,59 +3,73 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{self as gtk, glib}; -use tablepro_core::column::ResultColumn; -use tablepro_core::{ColumnInfo, QueryResult}; +use tablepro_core::ColumnInfo; +use tablepro_core::column::{ColumnKind, ResultColumn}; +use super::cell_edit::install_edit_triggers; use super::cell_editability::column_takes_edits; -use super::cell_editor_kind::{CellEditorKind, classify_editor_kind, is_bool_type}; use super::column_drawing::ColumnDrawing; -use super::context_menu::{CellMenuTarget, attach_cell_gesture}; +use super::context_menu::attach_cell_gesture; +use super::grid_attributes::GridAttributes; +use super::grid_columns::GridColumns; +use super::grid_keyboard::GridKeyboard; use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; -use super::inline_edit::{install_edit_commit_handler, install_edit_triggers}; -use super::{CellEditor, GridCell, GridMsg, GridSink, RowObject, TabGridContext}; +use super::row_factory::install_row_factory; +use super::toggle_cell::ToggleCell; +use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext}; const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. const MIN_COLUMN_WIDTH_PX: i32 = 120; pub fn build_column_view( - result: &QueryResult, - rows: impl IntoIterator, + content: GridContent, schema_columns: &[ColumnInfo], sink: GridSink, sort: Option<(usize, bool)>, sortable: bool, tab_ctx: TabGridContext, ) -> (gtk::ColumnView, gtk::MultiSelection) { + let GridContent { + columns: result_columns, + rows, + truncated, + } = content; let store = gtk4::gio::ListStore::new::(); - for row in rows { - store.append(&row); - } + store.splice(0, 0, &rows); let selection = gtk::MultiSelection::new(Some(store)); + // Tab leaves the grid, and the arrow keys move between cells. let column_view = gtk::ColumnView::builder() .model(&selection) .show_row_separators(true) .show_column_separators(true) + .tab_behavior(gtk::ListTabBehavior::Cell) .build(); + // GTK's own denser rows for a grid of data. + column_view.add_css_class("data-table"); + install_row_factory(&column_view, tab_ctx.clone()); let grid_menus = install_grid_context_menus(GridMenuInit { column_view: &column_view, sink: sink.clone(), - columns: result.columns.clone(), - truncated: result.truncated, + columns: result_columns.clone(), + truncated, tab_ctx: tab_ctx.clone(), }); + GridKeyboard::install(&column_view, grid_menus.clone(), sink.clone()); // Sharing the viewport by `expand` alone leaves a wide table with 20 // to 30 px columns that show nothing, so a column with no saved width // starts at a readable minimum there. - let default_min_width = if result.columns.len() > WIDE_TABLE_THRESHOLD { + let default_min_width = if result_columns.len() > WIDE_TABLE_THRESHOLD { Some(MIN_COLUMN_WIDTH_PX) } else { None }; - let mut columns: Vec = Vec::with_capacity(result.columns.len()); - for (i, column) in result.columns.iter().enumerate() { + let mut columns: Vec = Vec::with_capacity(result_columns.len()); + let grid_columns = Rc::new(GridColumns::default()); + let attributes = Rc::new(GridAttributes::new()); + for (i, column) in result_columns.iter().enumerate() { let col = build_column(ColumnInit { info: column, idx: i, @@ -66,6 +80,8 @@ pub fn build_column_view( default_min_width, column_view: column_view.downgrade(), grid_menus: grid_menus.clone(), + grid_columns: grid_columns.clone(), + attributes: attributes.clone(), }); column_view.append_column(&col); columns.push(col); @@ -116,13 +132,14 @@ pub fn build_column_view( (column_view, selection) } -/// How a column's cells are built. A column no row can edit gets the -/// plain label a read-only grid has. +/// How a column's cells are built. Editors are made when an edit +/// opens, so only the display is chosen here. #[derive(Clone, Copy)] enum CellKind { + /// Drawn and never edited, so it takes no edit triggers. ReadOnly, Toggle, - Text(CellEditorKind), + Text, } struct ColumnInit<'a> { @@ -136,6 +153,8 @@ struct ColumnInit<'a> { default_min_width: Option, column_view: glib::WeakRef, grid_menus: GridMenus, + grid_columns: Rc, + attributes: Rc, } fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { @@ -149,15 +168,16 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { default_min_width, column_view, grid_menus, + grid_columns, + attributes, } = init; let factory = gtk::SignalListItemFactory::new(); let kind = match catalog.as_ref() { Some(column) if tab_ctx.editable && column_takes_edits(column, &tab_ctx.identity, false) => { - if is_bool_type(&info.column_type.name) { - CellKind::Toggle - } else { - CellKind::Text(classify_editor_kind(&info.column_type.name)) + match column.column_type.kind { + ColumnKind::Bool(_) => CellKind::Toggle, + _ => CellKind::Text, } } _ => CellKind::ReadOnly, @@ -167,27 +187,38 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { .is_some_and(|column| column.is_auto_increment || column.is_generated); let drawing = Rc::new(ColumnDrawing { catalog, + column_type: info.column_type.clone(), tab_ctx: tab_ctx.clone(), auto_filled, + attributes, }); - let target = CellMenuTarget { - text_editable: matches!(kind, CellKind::Text(_)), - accepts_empty: matches!(kind, CellKind::Text(_)) && info.column_type.kind.accepts_empty_string(), - }; + let numeric = info.column_type.kind.is_numeric(); + let setup_drawing = drawing.clone(); + let setup_columns = grid_columns.clone(); factory.connect_setup(move |_, item| { let Some(cell) = item.downcast_ref::() else { return; }; + // The focus lands on the cell, never inside it, so every key + // the grid answers sees the same widget. Set through the + // property: `ListItemExt::set_focusable` sets the GtkListItem + // field, which the column view's cell widget never reads. + cell.set_property("focusable", true); let grid_cell = GridCell::new(); grid_cell.attach(cell, idx); - grid_cell.set_drawing(drawing.clone()); + grid_cell.set_drawing(setup_drawing.clone()); + grid_cell.set_sink(sink.clone()); + grid_cell.set_columns(setup_columns.clone()); match kind { - CellKind::ReadOnly => setup_readonly_cell(&grid_cell), - CellKind::Toggle => setup_toggle_cell(&grid_cell, sink.clone()), - CellKind::Text(editor_kind) => setup_editable_cell(&grid_cell, sink.clone(), editor_kind), + CellKind::ReadOnly => grid_cell.set_content(&text_display(numeric)), + CellKind::Toggle => ToggleCell::setup(&grid_cell), + CellKind::Text => { + grid_cell.set_content(&text_display(numeric)); + install_edit_triggers(&grid_cell); + } } - attach_cell_gesture(&grid_cell, &column_view, target, &grid_menus); + attach_cell_gesture(&grid_cell, &column_view, &grid_menus); cell.set_child(Some(&grid_cell)); }); @@ -237,310 +268,64 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { // Editor results remember no widths, but can be just as wide. column.set_fixed_width(min); } + grid_columns.push(idx, &column, drawing); column } -fn setup_editable_cell(grid_cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { - let editor = CellEditor::new(); - editor.set_hexpand(true); - editor.set_margin_start(8); - editor.set_margin_end(8); - grid_cell.set_content(&editor); - grid_cell.set_sink(sink.clone()); - install_edit_commit_handler(grid_cell, sink.clone()); - install_edit_triggers(grid_cell, sink, editor_kind); -} - -fn setup_toggle_cell(grid_cell: &GridCell, sink: GridSink) { - let checkbox = gtk::CheckButton::builder() - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .margin_start(8) - .margin_end(8) - .build(); - grid_cell.set_content(&checkbox); - // The cell owns the check button, so the handler finds the cell - // through its parent rather than holding it. - checkbox.connect_toggled(move |checkbox| { - let Some(grid_cell) = checkbox.parent().and_downcast::() else { - return; - }; - if grid_cell.toggle_suppressed() || grid_cell.lock().is_some() { - return; - } - let Some(row) = grid_cell.row() else { - return; - }; - let input = if checkbox.is_active() { "true" } else { "false" }; - sink(GridMsg::CellEdited { - row, - column: grid_cell.column(), - input: input.to_owned(), - }); - }); -} - -fn setup_readonly_cell(grid_cell: &GridCell) { - let label = gtk::Label::builder() - .xalign(0.0) - .hexpand(true) - .selectable(true) - .ellipsize(gtk::pango::EllipsizeMode::End) - .margin_start(8) - .margin_end(8) - .build(); - grid_cell.set_content(&label); +/// Every cell but a check box draws its value in an Inscription, which +/// lays out only what fits and offers no text selection of its own. A +/// number lines up on its last digit in tabular figures. +fn text_display(numeric: bool) -> gtk::Inscription { + let display = gtk::Inscription::new(None); + display.set_xalign(if numeric { 1.0 } else { 0.0 }); + display.set_hexpand(true); + display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); + display.set_min_chars(3); + display.set_nat_chars(14); + display.set_margin_start(8); + display.set_margin_end(8); + display.add_css_class("tp-cell-text"); + if numeric { + display.add_css_class("numeric"); + } + display } #[cfg(test)] mod tests { - use std::cell::RefCell; - use std::rc::Rc; - use std::time::Duration; - use gtk4::prelude::*; - use gtk4::{self as gtk, gio, glib}; + use gtk4::{self as gtk, glib}; - use tablepro_core::meta::RowIdentity; - use tablepro_core::{ColumnInfo, QueryResult, Value}; + use tablepro_core::Value; + use tablepro_core::edit::CellInput; use super::build_column_view; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_delta::{TrackerDelta, VisualChange}; use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::test_support::{descendants, drain_main_context, first_descendant_of_type, wait_until}; use crate::ui::grid::cell_editability::column_takes_edits; use crate::ui::grid::display_text::editable_null_sentinel; - use crate::ui::grid::{ - CellEditor, CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridSink, RowObject, TabGridContext, - enter_edit_mode, + use crate::ui::grid::test_grid::{ + Grid, WAIT, col, content, display_text, edited_rows, flag_rows, key, keyed_by_id, named, persisted, recorder, + text, tracked, two_text_rows, type_into, }; + use crate::ui::grid::{CellPreset, ColumnWidthBinding, GridCell, GridMsg, RowObject, TabGridContext, begin_edit}; - const WAIT: Duration = Duration::from_secs(5); - - #[expect( - clippy::disallowed_methods, - reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" - )] - fn col(data_type: &str, primary_key: bool) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: "x".into(), - column_type: tablepro_core::column::ColumnType { - name: data_type.to_owned(), - dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), - kind, - catalog: Some(tablepro_core::column::CatalogType { - declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - }), - read_form: tablepro_core::column::ReadForm::Native, - }, - nullable: true, - primary_key, - is_auto_increment: false, - is_generated: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - } - } - - fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { - let mut column = col(data_type, primary_key); - column.name = name.to_owned(); - column - } - - fn key(id: i64) -> RowKey { - RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") - } - - fn keyed_by_id() -> RowIdentity { - RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - } - } - - fn tracked(tracker: &TabTrackerHandle) -> TabGridContext { - TabGridContext { - tracker: Some(tracker.clone()), - editable: true, - identity: keyed_by_id(), - column_widths: None, - } - } - - /// Persisted rows keyed by their first cell, as a browse page is. - fn persisted(rows: &[Vec]) -> Vec { - rows.iter() - .map(|cells| { - let id = cells.first().cloned().unwrap_or(Value::Null); - RowObject::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) - }) - .collect() - } - - fn recorder() -> (GridSink, Rc>>) { - let messages = Rc::new(RefCell::new(Vec::new())); - let log = messages.clone(); - (Rc::new(move |msg| log.borrow_mut().push(msg)), messages) - } - - struct Grid { - window: gtk::Window, - view: gtk::ColumnView, - store: gio::ListStore, - tracker: TabTrackerHandle, - messages: Rc>>, - } - - impl Grid { - /// Two rows, A (id 1) and B (id 2), in a grid that edits them. - fn open(columns: Vec, rows: Vec>) -> Self { - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); - let tracker = TabTrackerHandle::default(); - let (sink, messages) = recorder(); - let (view, selection) = build_column_view( - &result, - persisted(&rows), - &columns, - sink, - None, - false, - tracked(&tracker), - ); - let store = selection - .model() - .and_downcast::() - .expect("a list store"); - let window = gtk::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(&view)); - window.present(); - let grid = Self { - window, - view, - store, - tracker, - messages, - }; - wait_until(WAIT, || grid.cells().len() == columns.len() * rows.len()).expect("the cells are drawn"); - grid - } - - fn cells(&self) -> Vec { - descendants(&self.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|cell| cell.row().is_some()) - .collect() - } - - /// The cell that shows `column` of the row named `id`. - fn cell_of(&self, id: i64, column: usize) -> GridCell { - self.cells() - .into_iter() - .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))) - .expect("the row's cell is drawn") - } - - fn insert_draft(&self) { - let delta = self.tracker.apply(TrackerEdit::Insert { - values: vec![Value::Null, Value::Null], - }); - let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { - panic!("an insert adds a draft row"); - }; - self.store.insert(0, &RowObject::draft(draft_id)); - drain_main_context(); - } - - fn column(&self, index: u32) -> gtk::ColumnViewColumn { - self.view - .columns() - .item(index) - .and_downcast::() - .expect("a column") - } - - fn take_messages(&self) -> Vec { - self.messages.take() - } - - /// What the tab does with a tracker change. - fn redraw(&self, delta: &TrackerDelta) { - for position in 0..self.store.n_items() { - if let Some(row) = self.store.item(position).and_downcast::() - && row.id().tracker_key().is_some_and(|key| delta.rows.contains(&key)) - { - row.bump_revision(); - } - } - } - } - - impl Drop for Grid { - fn drop(&mut self) { - self.window.destroy(); - drain_main_context(); - } - } - - fn two_text_rows() -> Grid { - Grid::open( - vec![named("id", "integer", true), named("name", "text", false)], - vec![ - vec![Value::Int(1), Value::Text("A".into())], - vec![Value::Int(2), Value::Text("B".into())], - ], - ) - } - - fn type_into(cell: &GridCell, text: &str) -> CellEditor { - enter_edit_mode(cell); - let editor = cell.editor().expect("a text cell"); - assert!(editor.is_editing(), "the cell did not open for editing"); - editor.entry().set_text(text); - editor - } - - fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, String)> { - messages - .iter() - .filter_map(|msg| match msg { - GridMsg::CellEdited { row, column, input } => Some((row.key().cloned(), *column, input.clone())), - _ => None, - }) - .collect() - } - - /// Without focusable cells, which come with the grid's keyboard - /// model, a grid cell takes the focus through its open editor. #[gtk4::test] fn edit_after_draft_insert_targets_edited_row() { let grid = two_text_rows(); grid.insert_draft(); - grid.view - .scroll_to(2, Some(&grid.column(1)), gtk::ListScrollFlags::FOCUS, None); - - let editor = type_into(&grid.cell_of(2, 1), "renamed"); - let mut focused = None; - wait_until(WAIT, || { - focused = gtk::prelude::GtkWindowExt::focus(&grid.window).and_then(|focus| GridCell::from_focus(&focus)); - focused.is_some() - }) - .expect("the focused editor resolves to its grid cell"); - let cell = focused.expect("a focused cell"); + + let cell = grid.focus(2, 1); assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(2))); - editor.stop_editing(true); + assert_eq!(cell.column(), 1); + let editor = type_into(&cell, "renamed"); + editor.emit_activate(); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "renamed".to_owned())] + vec![(Some(key(2)), 1, text("renamed"))] ); } @@ -556,7 +341,7 @@ mod tests { drain_main_context(); grid.view .activate_action("cell.set-null", None) - .expect("the menu's Set NULL"); + .expect("the menu's Set to NULL"); let set: Vec<(Option, usize, CellPreset)> = grid .take_messages() @@ -576,11 +361,11 @@ mod tests { let editor = type_into(&cell, "renamed"); grid.insert_draft(); - editor.stop_editing(true); + editor.emit_activate(); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "renamed".to_owned())] + vec![(Some(key(2)), 1, text("renamed"))] ); } @@ -594,20 +379,48 @@ mod tests { ], ); grid.insert_draft(); - let toggle = grid - .cell_of(2, 1) - .content() - .and_then(|content| content.downcast_ref::().cloned()) - .expect("a check button"); + let toggle = grid.cell_of(2, 1).check_button().expect("a check button"); toggle.set_active(true); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "true".to_owned())] + vec![(Some(key(2)), 1, CellInput::Value(Value::Bool(true)))] ); } + #[gtk4::test] + fn a_text_with_a_nul_draws_and_refuses_an_editor() { + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("a\0b".into())]], + ); + let cell = grid.cell_of(1, 1); + assert_eq!(display_text(&cell), "a\u{2400}b"); + + begin_edit(&cell); + + assert!( + cell.inline_text().is_none(), + "an editor opened on a value it cannot hold" + ); + assert!( + matches!(grid.take_messages().as_slice(), [GridMsg::ShowToast(_)]), + "the refusal was not explained" + ); + } + + #[gtk4::test] + fn opening_a_boolean_cell_writes_nothing() { + let grid = flag_rows(); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(grid.take_messages().is_empty(), "opening the cell flipped it"); + assert!(!cell.check_button().expect("a check button").is_active()); + } + #[gtk4::test] fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { let grid = two_text_rows(); @@ -625,33 +438,26 @@ mod tests { assert!(grid.cell_of(1, 1).lock().is_none()); assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); - enter_edit_mode(&grid.cell_of(1, 0)); - assert!(!grid.cell_of(1, 0).editor().expect("a text cell").is_editing()); - } - - fn label_of(cell: &GridCell) -> gtk::Label { - descendants(cell) - .into_iter() - .find_map(|widget| widget.downcast::().ok()) - .expect("a cell draws its value in a label") + begin_edit(&grid.cell_of(1, 0)); + assert!(!grid.cell_of(1, 0).is_editing()); + begin_edit(&draft_key_cell); + assert!(draft_key_cell.inline_text().is_some()); } #[gtk4::test] - fn a_locked_key_cell_keeps_its_text_selectable() { + fn locked_cells_draw_in_an_inscription_with_no_label() { let grid = two_text_rows(); - grid.insert_draft(); - let draft_key_cell = grid - .cells() - .into_iter() - .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) - .expect("the draft's key cell"); + let locked = grid.cell_of(1, 0); assert!( - label_of(&grid.cell_of(1, 0)).is_selectable(), - "a saved row's key cannot be selected" + locked.display().is_some(), + "a locked cell does not draw in an Inscription" ); - assert!(!label_of(&draft_key_cell).is_selectable()); - assert!(!label_of(&grid.cell_of(1, 1)).is_selectable()); + assert!( + descendants(&locked).iter().all(|widget| !widget.is::()), + "a locked cell carries a label, whose own menu takes Shift+F10" + ); + assert_eq!(display_text(&locked), "1"); } #[gtk4::test] @@ -672,7 +478,7 @@ mod tests { .bump_revision(); assert_eq!(delta.pending_count, 1); - assert_eq!(cell.editor().expect("a text cell").text(), "new"); + assert_eq!(display_text(&cell), "new"); assert!( cell.content() .is_some_and(|content| content.has_css_class("tp-cell-modified")) @@ -687,12 +493,12 @@ mod tests { let row = cell.row().expect("a bound row"); row.bump_revision(); - editor.stop_editing(false); - assert_eq!(editor.text(), "B", "Escape did not go back to the row's value"); - type_into(&cell, "typed"); - row.bump_revision(); - assert_eq!(editor.entry().text(), "typed", "the redraw overwrote what was typed"); + assert_eq!(editor.text(), "typed", "the redraw overwrote what was typed"); + cell.cancel_edit(); + assert_eq!(display_text(&cell), "B", "Escape did not go back to the row's value"); + assert!(cell.inline_text().is_none()); + assert_eq!(edited_rows(&grid.take_messages()), vec![]); } #[gtk4::test] @@ -723,7 +529,7 @@ mod tests { /// Row 1's name edited from `from` to `to`, its editor opened on /// `to`, then the edit undone under the open editor. - fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell, CellEditor) { + fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell) { let grid = name_rows(from.clone()); let cell = grid.cell_of(1, 1); grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { @@ -732,62 +538,66 @@ mod tests { original: from, value: to, })); - enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - assert!(editor.is_editing(), "the cell did not open for editing"); + begin_edit(&cell); + assert!(cell.inline_text().is_some(), "the cell did not open for editing"); grid.redraw(&grid.tracker.undo().expect("an edit to undo")); - (grid, cell, editor) + (grid, cell) } #[gtk4::test] fn escape_after_an_undo_under_the_editor_sends_nothing() { - let (grid, cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + let (grid, cell) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); assert!( - !editor.is_editing(), + !cell.is_editing(), "the editor stayed open on a value the row no longer has" ); - editor.stop_editing(false); + cell.cancel_edit(); cell.unbind(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text(), "A"); + assert_eq!(display_text(&cell), "A"); assert_eq!(grid.tracker.pending_count(), 0); } #[gtk4::test] fn escape_after_undoing_an_edit_of_null_sends_nothing() { - let (grid, _cell, editor) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); + let (grid, cell) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); - editor.stop_editing(false); + cell.cancel_edit(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text().as_str(), editable_null_sentinel()); + assert_eq!(display_text(&cell), editable_null_sentinel()); } #[gtk4::test] - fn enter_after_an_undo_under_the_editor_shows_the_undone_value() { - let (grid, _cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + begin_edit(&cell); - editor.stop_editing(true); + cell.row().expect("a bound row").bump_revision(); + assert!(cell.is_editing(), "a redraw of the same value closed the editor"); + cell.cancel_edit(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text(), "A", "the cell shows an edit the tracker no longer has"); } #[gtk4::test] - fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + fn a_null_text_cell_left_as_it_opened_stays_null() { let grid = name_rows(Value::Null); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - cell.row().expect("a bound row").bump_revision(); - assert!(editor.is_editing(), "a redraw of the same value closed the editor"); - editor.stop_editing(false); + let editor = type_into(&cell, ""); + assert_eq!( + editor.placeholder_text().unwrap_or_default(), + "", + "a text column keeps the empty string, so empty means nothing more" + ); + editor.emit_activate(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); } @@ -796,16 +606,21 @@ mod tests { format!("tpyo{}", "x".repeat(60_000)) } - /// The text of whichever editor the cell opened: its entry, or the - /// popover it opened instead. - fn opened_text(cell: &GridCell) -> Option { - let editor = cell.editor()?; - if editor.is_editing() { - return Some(editor.entry().text().to_string()); - } - let view = crate::test_support::first_descendant_of_type::(cell)?; + fn source_view(cell: &GridCell) -> Option { + first_descendant_of_type::(cell) + } + + fn buffer_text(view: &sourceview5::View) -> String { let buffer = view.buffer(); - Some(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string()) + buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() + } + + fn save_button(cell: &GridCell) -> gtk::Button { + descendants(cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") } #[gtk4::test] @@ -813,9 +628,10 @@ mod tests { let grid = name_rows(Value::Text(long_text())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); - let opened = opened_text(&cell).expect("an editor opened"); + assert!(cell.inline_text().is_none(), "a long text opened in a one-line field"); + let opened = buffer_text(&source_view(&cell).expect("the multi-line editor")); assert_eq!(opened.len(), long_text().len(), "the editor opened on the cut text"); assert_eq!(opened, long_text()); } @@ -824,23 +640,19 @@ mod tests { fn a_long_text_saved_from_its_editor_sends_the_whole_value() { let grid = name_rows(Value::Text(long_text())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); let fixed = long_text().replacen("tpyo", "typo", 1); - let view = crate::test_support::first_descendant_of_type::(&cell).expect("a text popover"); + let view = source_view(&cell).expect("the multi-line editor"); view.buffer().set_text(&fixed); - descendants(&cell) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); + save_button(&cell).emit_clicked(); - assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, fixed)]); - assert!( - cell.editor().is_some_and(|editor| editor.text().len() < 20_000), - "the cell drew the whole value" + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text(&fixed))] ); + assert!(!cell.is_editing()); + assert!(display_text(&cell).len() < 20_000, "the cell drew the whole value"); } #[gtk4::test] @@ -848,9 +660,14 @@ mod tests { let grid = name_rows(Value::Text(editable_null_sentinel())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + let opened = cell_editor_text(&cell); - assert_eq!(opened_text(&cell), Some(editable_null_sentinel())); + assert_eq!(opened, Some(editable_null_sentinel())); + } + + fn cell_editor_text(cell: &GridCell) -> Option { + begin_edit(cell); + cell.inline_text().map(|editor| editor.text().to_string()) } #[gtk4::test] @@ -858,9 +675,9 @@ mod tests { let grid = name_rows(Value::Bytes(vec![1, 2, 3])); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); - assert_eq!(opened_text(&cell), None, "the editor opened on the size placeholder"); + assert!(!cell.is_editing(), "the editor opened on the size placeholder"); assert!( grid.take_messages() .iter() @@ -869,6 +686,45 @@ mod tests { ); } + /// A closed editor popover must leave nothing holding its cell. + #[gtk4::test] + fn a_closed_popover_editor_frees_its_cell() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("doc", "jsonb", false), + named("day", "date", false), + ], + vec![vec![ + Value::Int(1), + Value::Json(tablepro_core::value::JsonText::parse("[1]".to_owned()).expect("json")), + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2026, 9, 15).expect("a date"), + )), + ]], + ); + for column in [1, 2] { + let cell = grid.cell_of(1, column); + begin_edit(&cell); + let popover = first_descendant_of_type::(&cell).expect("the editor popover"); + assert!(cell.is_editing()); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert!(!cell.is_editing(), "column {column} left its popover in the cell"); + } + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); + + drop(grid); + + assert!( + freed.iter().all(|cell| cell.upgrade().is_none()), + "an editor popover kept its cell alive" + ); + } + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { ColumnWidthBinding { store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), @@ -884,10 +740,6 @@ mod tests { let root = tempfile::tempdir().expect("tempdir"); let columns = vec![named("name", "text", false)]; let rows = vec![vec![Value::Text("a".into())]]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let (sink, _messages) = recorder(); let context = TabGridContext { editable: true, @@ -895,8 +747,7 @@ mod tests { ..TabGridContext::default() }; let (view, selection) = build_column_view( - &result, - persisted(&rows), + content(&columns, persisted(&rows)), &columns, sink, Some((0, true)), @@ -933,15 +784,10 @@ mod tests { named("flag", "boolean", false), ]; let rows = vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let tracker = TabTrackerHandle::default(); let (sink, _messages) = recorder(); let (view, selection) = build_column_view( - &result, - persisted(&rows), + content(&columns, persisted(&rows)), &columns, sink, None, @@ -959,12 +805,15 @@ mod tests { .collect() }; wait_until(WAIT, || cells().len() == 3).expect("the key, text and bool cells are drawn"); - // A right-click is what fills the menu's target, so it has to be - // in the picture too. + // A right-click is what fills the menu's target, and an open + // editor is another hold on a cell, so both are in the picture. for cell in cells() { let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); } + if let Some(name) = cells().into_iter().find(|cell| cell.column() == 1) { + begin_edit(&name); + } let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); window.destroy(); @@ -976,6 +825,70 @@ mod tests { assert_eq!(alive, 0, "cells outlived their column view"); } + #[gtk4::test] + fn column_view_carries_data_table_class() { + let grid = two_text_rows(); + + assert!(grid.view.has_css_class("data-table")); + } + + #[gtk4::test] + fn numeric_column_cells_align_end_with_numeric_class() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("price", "numeric(10,2)", false), + ], + vec![vec![ + Value::Int(1), + Value::Text("tea".into()), + Value::Decimal("4.5".parse().expect("a decimal")), + ]], + ); + let display = |column| grid.cell_of(1, column).display().expect("a text cell"); + + for column in [0, 2] { + assert_eq!(display(column).xalign(), 1.0, "column {column}"); + assert!(display(column).has_css_class("numeric"), "column {column}"); + } + assert_eq!(display(1).xalign(), 0.0); + assert!(!display(1).has_css_class("numeric")); + assert_eq!(display_text(&grid.cell_of(1, 2)), "4.50", "the column's scale"); + } + + #[gtk4::test] + fn pending_delete_row_uses_shared_strikethrough_attributes() { + let grid = two_text_rows(); + let attributes = |id, column| { + grid.cell_of(id, column) + .display() + .and_then(|display| display.attributes()) + .map(|attrs| attrs.as_ptr()) + }; + assert_eq!(attributes(1, 1), None, "plain text carries no attributes"); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + + let struck = attributes(1, 0).expect("the deleted row's text is struck through"); + assert_eq!(attributes(1, 1), Some(struck), "each cell built its own list"); + assert_eq!(attributes(2, 1), None); + } + + #[gtk4::test] + fn a_null_placeholder_is_italic_and_a_locked_one_dimmed() { + let grid = name_rows(Value::Null); + let name = grid.cell_of(1, 1).display().expect("a text cell"); + + assert_eq!(name.text().as_deref(), Some(editable_null_sentinel().as_str())); + assert!(name.attributes().is_some(), "a placeholder reads like a value"); + assert!(!name.has_css_class("dimmed"), "an editable NULL was dimmed"); + assert!(name.tooltip_text().is_none()); + } + #[test] fn only_columns_with_an_empty_value_accept_an_empty_string() { // Clearing a text cell stores the empty string; clearing a diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index c12b256f10..7a404b7753 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -3,6 +3,7 @@ use gtk4::{self as gtk, glib}; use super::GridCell; use super::grid_menus::GridMenus; +use super::menu_rows::menu_rows_of; use super::selection::select_row_for_menu; /// The cell a menu was opened on. The cell holds the menu through its @@ -23,40 +24,26 @@ impl CellContext { } } -/// What a column's cells offer in the menu while their row is unlocked. -#[derive(Clone, Copy)] -pub(super) struct CellMenuTarget { - pub(super) text_editable: bool, - pub(super) accepts_empty: bool, +/// Arm the cell menu for `cell` and pick the one its lock calls for. +/// The cell's lock follows the row bound to it, so the menu is picked +/// when it opens. +pub(super) fn prepare_menu(menus: &GridMenus, cell: &GridCell, column_view: >k::ColumnView) -> gtk::PopoverMenu { + menus.context.set(cell); + if let Some(position) = cell.position() { + select_row_for_menu(column_view, position); + } + menus.actions.arm(cell, &menu_rows_of(column_view, cell)); + match cell.lock() { + None => menus.editable_popover.clone(), + Some(_) => menus.readonly_popover.clone(), + } } -pub(super) fn attach_cell_gesture( - cell: &GridCell, - column_view: &glib::WeakRef, - target: CellMenuTarget, - menus: &GridMenus, -) { - // The cell's lock follows the row bound to it, so the menu is picked - // when it opens. - let prepare = { - let menus = menus.clone(); - move |cell: &GridCell, column_view: >k::ColumnView| -> gtk::PopoverMenu { - menus.context.set(cell); - menus.actions.arm(&target); - if let Some(position) = cell.position() { - select_row_for_menu(column_view, position); - } - match cell.lock() { - None => menus.editable_popover.clone(), - Some(_) => menus.readonly_popover.clone(), - } - } - }; - - // The handlers below read the cell from their own arguments: the - // cell owns its controllers, so capturing it would be a cycle. +pub(super) fn attach_cell_gesture(cell: &GridCell, column_view: &glib::WeakRef, menus: &GridMenus) { + // The handler reads the cell from its gesture: the cell owns its + // controllers, so capturing it would be a cycle. let view_for_gesture = column_view.clone(); - let prepare_for_gesture = prepare.clone(); + let menus = menus.clone(); let gesture = gtk::GestureClick::new(); gesture.set_button(3); gesture.connect_pressed(move |g, _, x, y| { @@ -64,7 +51,7 @@ pub(super) fn attach_cell_gesture( return; }; g.set_state(gtk::EventSequenceState::Claimed); - let popover = prepare_for_gesture(&cell, &cv); + let popover = prepare_menu(&menus, &cell, &cv); // The popover is parented to the column view, so it points in // the view's coordinates, not the cell's. let local = gtk::graphene::Point::new(x as f32, y as f32); @@ -76,36 +63,4 @@ pub(super) fn attach_cell_gesture( popover.popup(); }); cell.add_controller(gesture); - - let view_for_key = column_view.clone(); - let menu_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Menu, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |widget, _| { - let (Some(cv), Some(cell)) = (view_for_key.upgrade(), widget.downcast_ref::()) else { - return glib::Propagation::Proceed; - }; - let popover = prepare(cell, &cv); - // The Menu key has no pointer position, so the popover points - // at the whole cell. - if let Some(bounds) = cell.compute_bounds(&cv) { - let rect = gtk::gdk::Rectangle::new( - bounds.x() as i32, - bounds.y() as i32, - bounds.width() as i32, - bounds.height() as i32, - ); - popover.set_pointing_to(Some(&rect)); - } else { - popover.set_pointing_to(None); - } - popover.popup(); - glib::Propagation::Stop - })) - .build(); - let shortcut_controller = gtk::ShortcutController::new(); - shortcut_controller.add_shortcut(menu_shortcut); - cell.add_controller(shortcut_controller); } diff --git a/linux/crates/app/src/ui/grid/delete_availability.rs b/linux/crates/app/src/ui/grid/delete_availability.rs new file mode 100644 index 0000000000..d5c462712c --- /dev/null +++ b/linux/crates/app/src/ui/grid/delete_availability.rs @@ -0,0 +1,91 @@ +use super::GridRowId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteAvailability { + Available, + Unavailable(DeleteBlock), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteBlock { + NoRows, + /// A DELETE names a row by its key, and these rows have none. + UnkeyedRows, + ReadOnlySession, +} + +impl DeleteBlock { + /// `None` with nothing selected, which needs no explaining. + pub fn explanation(self) -> Option { + match self { + Self::NoRows => None, + Self::UnkeyedRows => Some(crate::i18n::gettext( + "Rows without a primary key or unique key can't be deleted here", + )), + Self::ReadOnlySession => Some(crate::i18n::gettext("This connection is read-only")), + } + } +} + +/// Whether Delete can act on `rows`. A draft needs no key: deleting it +/// only drops it. +pub fn delete_availability<'a>(rows: impl IntoIterator, read_only: bool) -> DeleteAvailability { + if read_only { + return DeleteAvailability::Unavailable(DeleteBlock::ReadOnlySession); + } + let mut any = false; + for row in rows { + match row { + GridRowId::Persisted(_) | GridRowId::Draft(_) => any = true, + GridRowId::Unkeyed | GridRowId::Result => { + return DeleteAvailability::Unavailable(DeleteBlock::UnkeyedRows); + } + } + } + match any { + true => DeleteAvailability::Available, + false => DeleteAvailability::Unavailable(DeleteBlock::NoRows), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::{DeleteAvailability, DeleteBlock, delete_availability}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + #[test] + fn delete_availability_blocks_unkeyed_result_and_read_only() { + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(2); + let blocked = |block| DeleteAvailability::Unavailable(block); + + assert_eq!( + delete_availability(&[saved.clone(), draft.clone()], false), + DeleteAvailability::Available + ); + assert_eq!( + delete_availability(std::slice::from_ref(&draft), false), + DeleteAvailability::Available, + "a draft needs no key to be dropped" + ); + assert_eq!(delete_availability(&[], false), blocked(DeleteBlock::NoRows)); + assert_eq!( + delete_availability(&[saved.clone(), GridRowId::Unkeyed], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[GridRowId::Result], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[saved], true), + blocked(DeleteBlock::ReadOnlySession) + ); + assert_eq!(DeleteBlock::NoRows.explanation(), None); + assert!(DeleteBlock::UnkeyedRows.explanation().is_some()); + assert!(DeleteBlock::ReadOnlySession.explanation().is_some()); + } +} diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs index e6883cd517..0067f88776 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -1,21 +1,41 @@ +use std::fmt::Write; + use tablepro_core::Value; +use tablepro_core::column::ColumnType; /// A million-character value would hang the main thread in Pango /// layout. The model keeps the whole value; only the cell is cut. -const DISPLAY_TEXT_MAX_CHARS: usize = 10_000; -/// UTF-8 is at most 4 bytes a char, so a shorter string skips the -/// O(n) char count. -const DISPLAY_TEXT_BYTES_THRESHOLD: usize = DISPLAY_TEXT_MAX_CHARS * 4; +pub(super) const DISPLAY_MAX_CHARS: usize = 10_000; + +/// What a cell shows for a value. `text` borrows the value where it +/// can, so a text cell draws with no copy of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DisplayText<'a> { + pub text: &'a str, + /// The text stands in for a value rather than spelling it. + pub placeholder: bool, + /// How much of the value's text the cell left out. + pub truncated_bytes: usize, +} + +impl<'a> DisplayText<'a> { + pub(super) fn placeholder(text: &'a str) -> Self { + Self { + text, + placeholder: true, + truncated_bytes: 0, + } + } +} /// The brackets keep it apart from a literal `NULL` text value. -pub(crate) fn editable_null_sentinel() -> String { +pub(super) fn editable_null_sentinel() -> String { crate::i18n::gettext("") } /// A read-only cell dims its NULL instead, so it needs no brackets. -fn readonly_null_sentinel() -> String { - crate::i18n::gettext("NULL") -} +/// A technical term, so it is not translated. +const READONLY_NULL: &str = "NULL"; /// For a column the server fills in on INSERT, so a new draft row does /// not read as if its id were stored as NULL. @@ -23,209 +43,290 @@ pub(super) fn auto_filled_sentinel() -> String { crate::i18n::gettext("(auto)") } -pub(super) fn value_to_display_text(value: &Value) -> String { +/// The text of `value` as the cell draws it. Text, JSON and a value the +/// driver kept as the server's text are borrowed; anything else is +/// written into `scratch`, the cell's own buffer. +pub(super) fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut String) -> DisplayText<'a> { + scratch.clear(); match value { - Value::Null => readonly_null_sentinel(), - // A blob has no readable text form, so the grid says how big - // it is rather than showing bytes. - Value::Bytes(b) => format!("<{} bytes>", b.len()), - // The driver could not read this, and saying so is better than - // an empty cell that looks like a NULL. - Value::Undecodable(undecodable) => format!("", undecodable.type_name), - // Everything else renders the way it exports, so what the grid - // shows and what a copy produces are the same text. - other => match tablepro_core::export::value_to_text(other) { - Some(text) => truncate_for_display(&text), - None => readonly_null_sentinel(), - }, + Value::Null => DisplayText::placeholder(READONLY_NULL), + Value::Text(text) => shown(text, scratch), + // The server's own text, never parsed and written out again. + Value::Json(json) => shown(json.as_str(), scratch), + Value::Other(other) => shown(&other.text, scratch), + // A blob has no readable text form, so the grid says how big it + // is rather than showing bytes. + Value::Bytes(bytes) => { + let count = bytes.len(); + *scratch = crate::i18n::ngettext_f( + "<{count} byte>", + "<{count} bytes>", + plural_count(count), + &[("count", &count.to_string())], + ); + DisplayText::placeholder(scratch) + } + // The driver could not read this, and saying so is better than an + // empty cell that looks like a NULL. + Value::Undecodable(undecodable) => { + *scratch = crate::i18n::gettext_f("", &[("type", &undecodable.type_name)]); + DisplayText::placeholder(scratch) + } + other => { + write_value(other, column, scratch); + cut_in_place(scratch) + } } } -/// The whole value as an editor starts from it, which committed -/// unchanged writes the value back. Never the display text, which is cut -/// short or stands in for a value. `None` for a value with no text form. -pub(super) fn value_to_edit_text(value: &Value) -> Option { - match value { - Value::Null => Some(String::new()), - Value::Bytes(_) | Value::Undecodable(_) => None, - other => tablepro_core::export::value_to_text(other), +/// Everything else renders the way it exports, so what the grid shows +/// and what a copy produces are the same text. The common scalars are +/// written straight into the buffer. +fn write_value(value: &Value, column: &ColumnType, scratch: &mut String) { + // Writing to a String cannot fail. + let _ = match value { + Value::Bool(flag) => scratch.write_str(if *flag { "true" } else { "false" }), + Value::Int(number) => write!(scratch, "{number}"), + Value::UInt(number) => write!(scratch, "{number}"), + Value::Float32(number) if number.is_finite() => write!(scratch, "{number}"), + Value::Float64(number) if number.is_finite() => write!(scratch, "{number}"), + Value::Uuid(id) => write!(scratch, "{id}"), + other => { + if let Some(text) = tablepro_core::export::value_text(other, column) { + *scratch = text; + } + Ok(()) + } + }; +} + +/// `text` as it is when it is short and holds no NUL, else its head, +/// cleaned, in `scratch`. +fn shown<'a>(text: &'a str, scratch: &'a mut String) -> DisplayText<'a> { + let end = cut_at(text).unwrap_or(text.len()); + let head = &text[..end]; + if end == text.len() && !head.contains('\0') { + return DisplayText { + text, + placeholder: false, + truncated_bytes: 0, + }; } + push_without_nul(scratch, head); + finish_cut(scratch, text.len() - end) } -/// Whether the cell shows only the start of `s`. Such a text is too long -/// for the one-line editor, which lays out all of it. -pub(super) fn is_cut_for_display(s: &str) -> bool { - s.len() >= DISPLAY_TEXT_BYTES_THRESHOLD && s.chars().nth(DISPLAY_TEXT_MAX_CHARS).is_some() +fn cut_in_place(scratch: &mut String) -> DisplayText<'_> { + let truncated_bytes = match cut_at(scratch) { + Some(end) => { + let left_out = scratch.len() - end; + scratch.truncate(end); + left_out + } + None => 0, + }; + if scratch.contains('\0') { + *scratch = scratch.replace('\0', "\u{2400}"); + } + finish_cut(scratch, truncated_bytes) } -pub(super) fn truncate_for_display(s: &str) -> String { - if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { - return s.to_string(); +fn finish_cut(scratch: &mut String, truncated_bytes: usize) -> DisplayText<'_> { + if truncated_bytes > 0 { + scratch.push_str(&crate::i18n::ngettext_f( + "… ({count} more byte)", + "… ({count} more bytes)", + plural_count(truncated_bytes), + &[("count", &truncated_bytes.to_string())], + )); } - // Cut on a char boundary. - let mut cut = s.len(); - for (i, (byte_idx, _)) in s.char_indices().enumerate() { - if i >= DISPLAY_TEXT_MAX_CHARS { - cut = byte_idx; - break; - } + DisplayText { + text: scratch, + placeholder: false, + truncated_bytes, } - if cut >= s.len() { - return s.to_string(); +} + +/// Where the cell cuts `text`, on a char boundary. A string of no more +/// bytes than the cap has no more chars either, so it is not walked. +fn cut_at(text: &str) -> Option { + if text.len() <= DISPLAY_MAX_CHARS { + return None; } - let head = &s[..cut]; - let remaining = s[cut..].chars().count(); - format!("{head}… (+{remaining} more chars)") + text.char_indices().nth(DISPLAY_MAX_CHARS).map(|(index, _)| index) +} + +/// GTK takes C strings, which end at a NUL, so the cell shows U+2400 in +/// its place rather than a value cut short. +fn push_without_nul(scratch: &mut String, text: &str) { + let mut parts = text.split('\0'); + if let Some(first) = parts.next() { + scratch.push_str(first); + } + for part in parts { + scratch.push('\u{2400}'); + scratch.push_str(part); + } +} + +fn plural_count(count: usize) -> u32 { + u32::try_from(count).unwrap_or(u32::MAX) } #[cfg(test)] mod tests { use tablepro_core::Value; + use tablepro_core::column::ColumnType; + use tablepro_core::value::{JsonText, OffsetTimestamp, SqlTime, Temporal, UndecodableReason, UndecodedValue}; - use super::{is_cut_for_display, truncate_for_display, value_to_display_text, value_to_edit_text}; + use super::{DISPLAY_MAX_CHARS, DisplayText, display_text}; - #[test] - fn display_text_primitive_variants() { - assert_eq!(value_to_display_text(&Value::Null), "NULL"); - assert_eq!(value_to_display_text(&Value::Bool(true)), "true"); - assert_eq!(value_to_display_text(&Value::Int(42)), "42"); - assert_eq!(value_to_display_text(&Value::Text("hello".into())), "hello"); - assert_eq!(value_to_display_text(&Value::Bytes(vec![0u8; 16])), "<16 bytes>"); + fn text_column() -> ColumnType { + ColumnType::untyped_text("text") } - fn date_value(year: i32, month: u32, day: u32) -> Value { - Value::Date(tablepro_core::value::Temporal::Finite( - chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), - )) + fn shown(value: &Value) -> String { + let mut scratch = String::new(); + display_text(value, &text_column(), &mut scratch).text.to_owned() } #[test] - fn display_text_temporal_variants() { - let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); - assert_eq!(value_to_display_text(&date_value(2026, 4, 26)), "2026-04-26"); - - let time = chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("a time"); - assert_eq!( - value_to_display_text(&Value::Time(tablepro_core::value::SqlTime::from_time_of_day(time))), - "14:30:00" + fn display_text_borrows_text_without_allocating() { + let value = Value::Text("hello".into()); + let mut scratch = String::new(); + + let display = display_text(&value, &text_column(), &mut scratch); + + let Value::Text(stored) = &value else { + unreachable!("built as text"); + }; + assert_eq!(display.text, "hello"); + assert!( + std::ptr::eq(display.text.as_ptr(), stored.as_ptr()), + "the text was copied" ); + assert!(!display.placeholder); + assert_eq!(scratch.capacity(), 0, "the scratch buffer was written to"); + } - let datetime = chrono::NaiveDateTime::new(date, time); - assert_eq!( - value_to_display_text(&Value::Timestamp(tablepro_core::value::Temporal::Finite(datetime))), - "2026-04-26 14:30:00" - ); + #[test] + fn display_text_truncates_emoji_at_char_boundary_with_byte_suffix() { + let value = Value::Text("🦀".repeat(DISPLAY_MAX_CHARS + 5)); + let mut scratch = String::new(); - let tz = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); - assert_eq!( - value_to_display_text(&Value::TimestampTz(tablepro_core::value::Temporal::Finite( - tablepro_core::value::OffsetTimestamp::from_datetime(tz.fixed_offset()) - ))), - "2026-04-26 14:30:00+00:00" + let display = display_text(&value, &text_column(), &mut scratch); + + assert_eq!(display.truncated_bytes, 5 * 4, "the bytes left out"); + assert!(display.text.starts_with(&"🦀".repeat(DISPLAY_MAX_CHARS))); + assert!( + !display.text.starts_with(&"🦀".repeat(DISPLAY_MAX_CHARS + 1)), + "the cut kept more than the cap" + ); + assert!( + display.text.ends_with("… (20 more bytes)"), + "{}", + &display.text[40_000..] ); } #[test] - fn display_text_extended_variants() { - let dec: tablepro_core::value::SqlDecimal = "1234.56789".parse().expect("a decimal"); - assert_eq!(value_to_display_text(&Value::Decimal(dec)), "1234.56789"); + fn a_long_formatted_value_is_cut_too() { + let value = Value::Array(vec![Value::Int(1); DISPLAY_MAX_CHARS]); + let mut scratch = String::new(); - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a uuid"); - assert_eq!( - value_to_display_text(&Value::Uuid(id)), - "550e8400-e29b-41d4-a716-446655440000" - ); + let display = display_text(&value, &text_column(), &mut scratch); - let json = tablepro_core::value::JsonText::parse(r#"{"a":1,"b":[2,3]}"#.to_owned()).expect("valid json"); - let text = value_to_display_text(&Value::Json(json)); - assert!(text.contains("\"a\":1"), "{text}"); + assert!(display.truncated_bytes > 0); + assert!(display.text.contains("more bytes")); } #[test] - fn a_value_the_driver_could_not_read_says_so() { - let undecodable = Value::Undecodable(Box::new(tablepro_core::value::UndecodedValue { - type_name: "geography".to_owned(), - reason: tablepro_core::value::UndecodableReason::UnsupportedType, - })); + fn display_text_json_shows_stored_text_verbatim() { + // Spacing and key order a re-serialisation would change. + let stored = r#"{ "b":1, "a" : [2,3] }"#; + let value = Value::Json(JsonText::parse(stored.to_owned()).expect("valid json")); + let mut scratch = String::new(); - let text = value_to_display_text(&undecodable); + let display = display_text(&value, &text_column(), &mut scratch); - assert!(text.contains("geography"), "{text}"); - assert_ne!(text, "NULL", "an unreadable value looked like a missing one"); + assert_eq!(display.text, stored); + assert_eq!(scratch.capacity(), 0, "the JSON was copied"); } #[test] - fn edit_text_distinguishes_null_from_text_null() { - assert_eq!(value_to_edit_text(&Value::Null).as_deref(), Some("")); - assert_eq!(value_to_edit_text(&Value::Text("NULL".into())).as_deref(), Some("NULL")); - assert_eq!(value_to_edit_text(&Value::Int(0)).as_deref(), Some("0")); - } + fn display_text_bytes_and_undecodable_placeholders() { + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let mut scratch = String::new(); - #[test] - fn edit_text_keeps_extended_variants_visible() { assert_eq!( - value_to_edit_text(&date_value(2026, 4, 26)).as_deref(), - Some("2026-04-26") + display_text(&Value::Bytes(vec![0u8; 16]), &text_column(), &mut scratch), + DisplayText::placeholder("<16 bytes>") ); - - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!(shown(&Value::Bytes(vec![7])), "<1 byte>"); + let unreadable = display_text(&undecodable, &text_column(), &mut scratch); + assert!(unreadable.placeholder); + assert!(unreadable.text.contains("geography"), "{}", unreadable.text); assert_eq!( - value_to_edit_text(&Value::Uuid(id)).as_deref(), - Some("550e8400-e29b-41d4-a716-446655440000") + display_text(&Value::Null, &text_column(), &mut scratch), + DisplayText::placeholder("NULL") ); } #[test] - fn edit_text_is_the_whole_value_however_long() { - let huge = "x".repeat(1_000_000); - - assert_eq!(value_to_edit_text(&Value::Text(huge.clone())), Some(huge.clone())); - assert!(is_cut_for_display(&huge)); - assert!(!is_cut_for_display(&"x".repeat(39_999))); - // 40,000 bytes, but only 10,000 characters, which the cell shows - // whole. - assert!(!is_cut_for_display(&"🦀".repeat(10_000))); + fn a_nul_in_text_shows_as_its_symbol() { + assert_eq!(shown(&Value::Text("a\0b".into())), "a\u{2400}b"); } #[test] - fn a_value_shown_by_a_stand_in_has_no_edit_text() { - assert_eq!(value_to_edit_text(&Value::Bytes(vec![0u8; 16])), None); + fn display_text_primitive_variants() { + assert_eq!(shown(&Value::Bool(true)), "true"); + assert_eq!(shown(&Value::Int(42)), "42"); + assert_eq!(shown(&Value::UInt(7)), "7"); + assert_eq!(shown(&Value::Float64(1.5)), "1.5"); + assert_eq!(shown(&Value::Float32(0.1)), "0.1"); + assert_eq!(shown(&Value::Text("hello".into())), "hello"); } #[test] - fn truncate_short_text_passes_through() { - let s = "hello world"; - assert_eq!(truncate_for_display(s), "hello world"); - } + fn display_text_temporal_variants() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); + assert_eq!(shown(&Value::Date(Temporal::Finite(date))), "2026-04-26"); - #[test] - fn truncate_caps_long_text_at_char_boundary() { - // 100k ASCII chars: should truncate to ~10k + suffix. - let s = "a".repeat(100_000); - let out = truncate_for_display(&s); - assert!(out.starts_with(&"a".repeat(10_000))); - assert!(out.contains("more chars")); - assert!(out.len() < 10_500); // ~10k chars + suffix bytes + let time = chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("a time"); + assert_eq!(shown(&Value::Time(SqlTime::from_time_of_day(time))), "14:30:00"); + + let datetime = chrono::NaiveDateTime::new(date, time); + assert_eq!( + shown(&Value::Timestamp(Temporal::Finite(datetime))), + "2026-04-26 14:30:00" + ); + + let tz = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); + assert_eq!( + shown(&Value::TimestampTz(Temporal::Finite(OffsetTimestamp::from_datetime( + tz.fixed_offset() + )))), + "2026-04-26 14:30:00+00:00" + ); } #[test] - fn truncate_handles_multibyte_boundary() { - // 30k 4-byte emoji (120k bytes): truncation must land on a - // char boundary, not mid-codepoint. - let s = "🦀".repeat(30_000); - let out = truncate_for_display(&s); - // Must be valid UTF-8. - assert!(std::str::from_utf8(out.as_bytes()).is_ok()); - // First DISPLAY_TEXT_MAX_CHARS chars should be 🦀 plus the - // suffix appended after. - assert!(out.contains("more chars")); + fn display_text_extended_variants() { + let dec: tablepro_core::value::SqlDecimal = "1234.56789".parse().expect("a decimal"); + assert_eq!(shown(&Value::Decimal(dec)), "1234.56789"); + + let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a uuid"); + assert_eq!(shown(&Value::Uuid(id)), "550e8400-e29b-41d4-a716-446655440000"); } #[test] - fn display_text_truncates_huge_text_value() { - let huge = "x".repeat(1_000_000); - let display = value_to_display_text(&Value::Text(huge)); - assert!(display.len() < 100_000); // bounded - assert!(display.contains("more chars")); + fn a_huge_text_value_draws_bounded() { + let display = shown(&Value::Text("x".repeat(1_000_000))); + + assert!(display.len() < 11_000, "{}", display.len()); + assert!(display.ends_with("… (990000 more bytes)")); } } diff --git a/linux/crates/app/src/ui/grid/edit_commit.rs b/linux/crates/app/src/ui/grid/edit_commit.rs new file mode 100644 index 0000000000..4a0afefd5d --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_commit.rs @@ -0,0 +1,281 @@ +use tablepro_core::edit::{ + CellInput, EditContext, EditText, EmptyInputMeaning, edit_text, empty_input_meaning, is_unchanged, parse_edit_text, +}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{EditSnapshot, GridCell, GridMsg, GridRowId}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::error_text::edit_parse_message; + +/// What committing an editor's text comes to. +#[derive(Debug, Clone, PartialEq)] +pub enum CommitResult { + Edit(TrackerEdit), + Unchanged, + /// The text is not a value of the column, with what to tell the user. + Rejected(String), +} + +/// Only a new row can leave a column to the server. +pub fn edit_context(row: &GridRowId) -> EditContext { + match row { + GridRowId::Draft(_) => EditContext::InsertDraft, + GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, + } +} + +/// The text an editor ends with, as an edit of the row it began on. +/// Every editor commits through here, and none reads the cell's display. +/// +/// Text left as the editor opened it is no edit: NULL and the empty +/// string open as the same empty field, and committing it untouched must +/// not turn one into the other. +pub fn commit_edit(column: &ColumnInfo, snapshot: &EditSnapshot, input: &str) -> CommitResult { + let opened_with = match &snapshot.original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + if matches!(&opened_with, EditText::Editable(text) if text == input) { + return CommitResult::Unchanged; + } + match parse_edit_text(input, column, edit_context(snapshot.row.id())) { + Ok(parsed) => commit_input(column, snapshot, parsed), + Err(error) => { + tracing::debug!(%error, column = %column.name, "refused an edit"); + CommitResult::Rejected(edit_parse_message(&error)) + } + } +} + +/// `input` as an edit of the row the snapshot names, or no edit when it +/// leaves the cell as it was. +pub fn commit_input(column: &ColumnInfo, snapshot: &EditSnapshot, input: CellInput) -> CommitResult { + let unchanged = match (&snapshot.original, &input) { + (CellInput::Value(original), CellInput::Value(value)) => is_unchanged(original, value, &column.column_type), + (CellInput::Default, CellInput::Default) => true, + _ => false, + }; + if unchanged { + return CommitResult::Unchanged; + } + match (snapshot.row.id(), input) { + (GridRowId::Draft(draft_id), input) => CommitResult::Edit(TrackerEdit::DraftCell { + draft_id: *draft_id, + col: snapshot.column, + input, + }), + (GridRowId::Persisted(row_key), CellInput::Value(value)) => CommitResult::Edit(TrackerEdit::Cell { + row_key: row_key.clone(), + col: snapshot.column, + original: snapshot.row.cell(snapshot.column).cloned().unwrap_or(Value::Null), + value, + }), + (GridRowId::Persisted(_), CellInput::Default) => { + tracing::error!("a saved row's cell was given the server's default"); + CommitResult::Unchanged + } + (GridRowId::Result | GridRowId::Unkeyed, _) => CommitResult::Unchanged, + } +} + +/// Hand the outcome to the tab. A refused value is said in a toast and +/// the cell draws its value again. +pub(super) fn send_commit(cell: &GridCell, snapshot: EditSnapshot, result: CommitResult) { + let Some(sink) = cell.sink() else { + return; + }; + match result { + CommitResult::Edit(edit) => sink(GridMsg::Edit { + row: snapshot.row, + column: snapshot.column, + edit, + }), + CommitResult::Unchanged => {} + CommitResult::Rejected(message) => { + sink(GridMsg::ShowToast(message)); + cell.render(); + } + } +} + +/// What an empty editor commits, said where the text would be. The +/// keywords are SQL and stay untranslated. +pub fn empty_placeholder(column: &ColumnInfo, row: &GridRowId) -> Option { + match empty_input_meaning(column, edit_context(row)) { + EmptyInputMeaning::EmptyText => None, + EmptyInputMeaning::Default => Some("DEFAULT".to_owned()), + EmptyInputMeaning::Null => Some("NULL".to_owned()), + EmptyInputMeaning::Required => Some(crate::i18n::gettext("Required")), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CommitResult, commit_edit, empty_placeholder}; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{EditSnapshot, GridRowId, RowObject}; + + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" + )] + fn column(data_type: &str, nullable: bool, default: Option<&str>) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "c".to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text(data_type) + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: match default { + Some(text) => ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text(text)), + None => ColumnDefault::None, + }, + comment: None, + } + } + + fn saved(value: Value) -> EditSnapshot { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + EditSnapshot { + row: RowObject::persisted(Some(key), vec![Value::Int(1), value.clone()].into()), + column: 1, + original: CellInput::Value(value), + } + } + + fn draft(original: CellInput) -> EditSnapshot { + EditSnapshot { + row: RowObject::draft(3), + column: 1, + original, + } + } + + fn value_of(result: CommitResult) -> Option { + match result { + CommitResult::Edit(TrackerEdit::Cell { value, .. }) => Some(CellInput::Value(value)), + CommitResult::Edit(TrackerEdit::DraftCell { input, .. }) => Some(input), + _ => None, + } + } + + #[test] + fn text_left_as_it_opened_is_no_edit() { + let text = column("text", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + commit_edit(&text, &saved(Value::Null), ""), + CommitResult::Unchanged, + "an untouched NULL became the empty string" + ); + assert_eq!( + commit_edit(&stamped, &draft(CellInput::Default), ""), + CommitResult::Unchanged + ); + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Null), "a")), + Some(CellInput::Value(Value::Text("a".into()))) + ); + } + + #[test] + fn clearing_a_cell_means_what_the_column_makes_of_empty() { + let text = column("text", true, None); + let stamp = column("timestamp", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Text("a".into())), "")), + Some(CellInput::Value(Value::Text(String::new()))) + ); + assert_eq!( + value_of(commit_edit(&stamp, &saved(Value::Int(1)), "")), + Some(CellInput::Value(Value::Null)) + ); + assert_eq!( + value_of(commit_edit(&stamped, &draft(CellInput::Value(Value::Int(1))), "")), + Some(CellInput::Default), + "a new row leaves an empty cell to the server" + ); + assert!( + matches!( + commit_edit(&stamped, &saved(Value::Int(1)), ""), + CommitResult::Rejected(_) + ), + "an UPDATE cannot ask for the default" + ); + } + + #[test] + fn a_commit_names_the_row_and_the_value_it_was_read_with() { + let text = column("text", true, None); + let snapshot = saved(Value::Text("old".into())); + + let result = commit_edit(&text, &snapshot, "new"); + + assert_eq!( + result, + CommitResult::Edit(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }) + ); + assert_eq!(commit_edit(&text, &snapshot, "old"), CommitResult::Unchanged); + } + + #[test] + fn a_value_the_column_cannot_hold_is_refused_with_its_reason() { + let integer = column("integer", false, None); + + let CommitResult::Rejected(message) = commit_edit(&integer, &saved(Value::Int(1)), "thirty") else { + panic!("text in an integer column was accepted"); + }; + + assert_eq!(message, "Enter a whole number"); + } + + #[test] + fn a_cell_that_has_no_row_to_write_to_records_nothing() { + let text = column("text", true, None); + let snapshot = EditSnapshot { + row: RowObject::result(vec![Value::Text("a".into())].into()), + column: 0, + original: CellInput::Value(Value::Text("a".into())), + }; + + assert_eq!(commit_edit(&text, &snapshot, "b"), CommitResult::Unchanged); + } + + #[test] + fn the_placeholder_says_what_empty_commits() { + let saved_row = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft_row = GridRowId::Draft(1); + + assert_eq!(empty_placeholder(&column("text", true, None), &saved_row), None); + assert_eq!( + empty_placeholder(&column("integer", true, None), &saved_row).as_deref(), + Some("NULL") + ); + assert_eq!( + empty_placeholder(&column("integer", false, Some("0")), &draft_row).as_deref(), + Some("DEFAULT") + ); + assert_eq!( + empty_placeholder(&column("integer", false, None), &saved_row).as_deref(), + Some("Required") + ); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_navigation.rs b/linux/crates/app/src/ui/grid/edit_navigation.rs new file mode 100644 index 0000000000..029e5d6dbc --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_navigation.rs @@ -0,0 +1,89 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditDirection { + Forward, + Backward, +} + +/// The cell Tab or Shift+Tab edits next, as `(row, column)` with the +/// column counted in the order the grid shows them: the next cell along +/// the row that opens an editor, then the row after it. +/// +/// The walk stops one row out. Drafts sit together above the saved +/// rows and rows of one kind lock the same columns, so a row with +/// nothing to edit has no editable row past it either. +pub fn next_edit_target( + opens_editor: impl Fn(u32, usize) -> bool, + n_columns: usize, + column: usize, + position: u32, + n_rows: u32, + direction: EditDirection, +) -> Option<(u32, usize)> { + if position >= n_rows || column >= n_columns { + return None; + } + let candidates: Vec<(u32, usize)> = match direction { + EditDirection::Forward => { + let rest = (column + 1..n_columns).map(|c| (position, c)); + let below = position + .checked_add(1) + .filter(|row| *row < n_rows) + .into_iter() + .flat_map(|row| (0..n_columns).map(move |c| (row, c))); + rest.chain(below).collect() + } + EditDirection::Backward => { + let rest = (0..column).rev().map(|c| (position, c)); + let above = position + .checked_sub(1) + .into_iter() + .flat_map(|row| (0..n_columns).rev().map(move |c| (row, c))); + rest.chain(above).collect() + } + }; + candidates.into_iter().find(|&(row, c)| opens_editor(row, c)) +} + +#[cfg(test)] +mod tests { + use super::{EditDirection, next_edit_target}; + + #[test] + fn next_edit_target_skips_locked_and_wraps_rows() { + // Column 0 is a key, locked on the saved rows 1 and 2 and open + // on the draft in row 0; column 2 is generated. + let opens = |row: u32, column: usize| match column { + 0 => row == 0, + 1 | 3 => true, + _ => false, + }; + let next = |column, position, direction| next_edit_target(opens, 4, column, position, 3, direction); + + assert_eq!(next(1, 1, EditDirection::Forward), Some((1, 3)), "the generated column"); + assert_eq!(next(3, 1, EditDirection::Forward), Some((2, 1)), "a saved row's key"); + assert_eq!(next(3, 0, EditDirection::Forward), Some((1, 1))); + assert_eq!(next(0, 0, EditDirection::Forward), Some((0, 1))); + assert_eq!(next(1, 1, EditDirection::Backward), Some((0, 3)), "a saved row's key"); + assert_eq!(next(1, 0, EditDirection::Backward), Some((0, 0)), "a draft's own key"); + assert_eq!(next(3, 2, EditDirection::Forward), None, "past the last row"); + assert_eq!(next(0, 0, EditDirection::Backward), None, "before the first row"); + } + + #[test] + fn a_row_with_nothing_to_edit_ends_the_walk() { + let opens = |row: u32, _column: usize| row != 1; + + assert_eq!(next_edit_target(opens, 2, 1, 0, 3, EditDirection::Forward), None); + assert_eq!(next_edit_target(opens, 2, 0, 2, 3, EditDirection::Backward), None); + assert_eq!( + next_edit_target(|_, _| true, 2, 0, 5, 5, EditDirection::Forward), + None, + "a row past the page" + ); + assert_eq!( + next_edit_target(|_, _| true, 0, 0, 0, 1, EditDirection::Forward), + None, + "a grid with no shown columns" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_snapshot.rs b/linux/crates/app/src/ui/grid/edit_snapshot.rs index 85fcb26b51..85473a3110 100644 --- a/linux/crates/app/src/ui/grid/edit_snapshot.rs +++ b/linux/crates/app/src/ui/grid/edit_snapshot.rs @@ -1,10 +1,13 @@ +use tablepro_core::edit::CellInput; + use super::RowObject; /// An edit as it stood when it began. The commit goes to `row`, not to -/// whatever row the cell shows by then. +/// whatever row the cell shows by then, and is measured against +/// `original`, the cell's value with any pending edit. #[derive(Debug, Clone)] pub struct EditSnapshot { pub row: RowObject, pub column: usize, - pub original_text: String, + pub original: CellInput, } diff --git a/linux/crates/app/src/ui/grid/grid_attributes.rs b/linux/crates/app/src/ui/grid/grid_attributes.rs new file mode 100644 index 0000000000..20e5a8802a --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_attributes.rs @@ -0,0 +1,56 @@ +use gtk4::pango; + +use super::display_text::{auto_filled_sentinel, editable_null_sentinel}; + +/// How a grid's cells mark their text, built once per column view and +/// shared by every cell in it, so a bind sets a list it does not build. +/// Strikethrough marks a pending delete; italic marks a placeholder. +/// Pango attributes, because CSS `text-decoration` does not reach an +/// Inscription's text reliably. +#[derive(Debug)] +pub struct GridAttributes { + strikethrough: pango::AttrList, + placeholder: pango::AttrList, + strikethrough_placeholder: pango::AttrList, + /// The stand-in texts, translated once rather than on every bind. + pub editable_null: String, + pub auto_filled: String, +} + +impl Default for GridAttributes { + fn default() -> Self { + Self::new() + } +} + +impl GridAttributes { + pub fn new() -> Self { + let list = |strikethrough: bool, italic: bool| { + let attrs = pango::AttrList::new(); + if strikethrough { + attrs.insert(pango::AttrInt::new_strikethrough(true)); + } + if italic { + attrs.insert(pango::AttrInt::new_style(pango::Style::Italic)); + } + attrs + }; + Self { + strikethrough: list(true, false), + placeholder: list(false, true), + strikethrough_placeholder: list(true, true), + editable_null: editable_null_sentinel(), + auto_filled: auto_filled_sentinel(), + } + } + + /// `None` for plain text. + pub fn for_text(&self, pending_delete: bool, placeholder: bool) -> Option<&pango::AttrList> { + match (pending_delete, placeholder) { + (false, false) => None, + (true, false) => Some(&self.strikethrough), + (false, true) => Some(&self.placeholder), + (true, true) => Some(&self.strikethrough_placeholder), + } + } +} diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index bbc7433be9..de32324240 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -4,11 +4,19 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::edit::CellInput; +use super::cell_accessibility::cell_description; use super::cell_binding::CellBinding; +use super::cell_focus::return_focus; use super::column_drawing::ColumnDrawing; -use super::display_text::value_to_edit_text; -use super::{CellEditor, CellLock, EditSnapshot, GridSink, RowObject}; +use super::grid_columns::GridColumns; +use super::inline_editor::InlineEditor; +use super::{CellLock, EditSnapshot, GridSink, RowObject}; + +/// Enough for any number, date or short text. +const SCRATCH_KEPT_BYTES: usize = 256; mod imp { use super::*; @@ -17,7 +25,11 @@ mod imp { pub struct GridCell { pub list_item: glib::WeakRef, pub column: Cell, + /// What the cell draws its value with: an Inscription, or a check + /// button for a boolean column. pub content: OnceCell, + /// The one-line editor, laid over the hidden display while open. + pub inline: RefCell>, /// An open editor popover. Unbind pops it down, or the recycled /// cell is finalized with a GtkPopover child still in it. pub popover: RefCell>, @@ -25,18 +37,15 @@ mod imp { /// Set while the bind draws a check button, which would otherwise /// echo back as an edit of the value it just drew. pub suppress_toggle: Cell, - /// Set while an IME composition is in flight: focus moving to the - /// IME popover ends the edit, and committing then would send the - /// text from before the composition. - pub preedit_active: Cell, pub lock: Cell>, pub drawing: OnceCell>, - /// Where an editor that opens in a popover sends its value. + /// Where the cell's editors send their edits. pub sink: OnceCell, + pub(super) columns: OnceCell>, pub binding: RefCell>, - /// The label cannot say what was drawn once an edit has cleared - /// a NULL sentinel from it. - pub drawn_text: RefCell, + /// Where a value that is not text is written to draw it, kept + /// from one bind to the next. + pub scratch: RefCell, #[cfg(test)] pub renders: Cell, } @@ -56,6 +65,10 @@ mod imp { impl ObjectImpl for GridCell { fn dispose(&self) { self.binding.take(); + self.edit.take(); + if let Some(editor) = self.inline.take() { + editor.widget().unparent(); + } if let Some(popover) = self.popover.take() { popover.unparent(); } @@ -65,7 +78,12 @@ mod imp { } } - impl WidgetImpl for GridCell {} + impl WidgetImpl for GridCell { + fn unroot(&self) { + self.obj().leave_detached_row(); + self.parent_unroot(); + } + } } glib::wrapper! { @@ -104,10 +122,24 @@ impl GridCell { self.imp().content.get() } - pub fn editor(&self) -> Option { + /// The text display every cell but a check box draws with. + pub fn display(&self) -> Option { self.content()?.clone().downcast().ok() } + pub fn check_button(&self) -> Option { + self.content()?.clone().downcast().ok() + } + + /// The one-line editor's text field while it is open. + pub fn inline_text(&self) -> Option { + self.imp() + .inline + .borrow() + .as_ref() + .map(|editor| editor.widget().clone()) + } + pub fn row(&self) -> Option { self.imp().list_item.upgrade()?.item().and_downcast() } @@ -122,7 +154,8 @@ impl GridCell { } pub fn is_editing(&self) -> bool { - self.imp().edit.borrow().is_some() || self.imp().popover.borrow().is_some() + let imp = self.imp(); + imp.edit.borrow().is_some() || imp.popover.borrow().is_some() || imp.inline.borrow().is_some() } /// The cell that holds the keyboard focus. A focused column view @@ -143,6 +176,10 @@ impl GridCell { let _ = self.imp().drawing.set(drawing); } + pub(super) fn drawing(&self) -> Option> { + self.imp().drawing.get().cloned() + } + pub(super) fn set_sink(&self, sink: GridSink) { let _ = self.imp().sink.set(sink); } @@ -151,18 +188,16 @@ impl GridCell { self.imp().sink.get().cloned() } - /// The whole value the cell draws, pending edit included, as an - /// editor starts from it. `None` with no row bound or no text form. - pub(super) fn edit_text(&self) -> Option { - let row = self - .imp() - .binding - .borrow() - .as_ref() - .map(|binding| binding.row().clone())?; - let drawing = self.imp().drawing.get()?; - let (value, _) = drawing.tab_ctx.bind_state(&row, self.column()); - value_to_edit_text(&value) + pub(super) fn set_columns(&self, columns: Rc) { + let _ = self.imp().columns.set(columns); + } + + pub(super) fn columns(&self) -> Option> { + self.imp().columns.get().cloned() + } + + pub(super) fn column_view(&self) -> Option { + self.ancestor(gtk::ColumnView::static_type()).and_downcast() } pub(super) fn bind(&self, row: &RowObject) { @@ -180,31 +215,20 @@ impl GridCell { pub(super) fn unbind(&self) { self.imp().binding.take(); self.cancel_edit(); + self.reset_property(gtk::AccessibleProperty::Description); } - /// The snapshot goes first, so the editor closing sends nothing. - pub(super) fn cancel_edit(&self) { + /// The snapshot goes first, so an editor closing sends nothing. + pub(crate) fn cancel_edit(&self) { self.take_edit(); - if let Some(editor) = self.editor() - && editor.is_editing() - { - editor.stop_editing(false); + if let Some(editor) = self.take_inline() { + editor.close(self); } if let Some(popover) = self.take_popover() { popover.popdown(); } } - /// `true` when `text` differs from what the cell drew before it. - pub(super) fn record_drawn_text(&self, text: &str) -> bool { - let mut drawn = self.imp().drawn_text.borrow_mut(); - if drawn.as_str() == text { - return false; - } - text.clone_into(&mut drawn); - true - } - pub(super) fn render(&self) { #[cfg(test)] self.imp().renders.set(self.imp().renders.get() + 1); @@ -214,10 +238,16 @@ impl GridCell { .borrow() .as_ref() .map(|binding| binding.row().clone()); - let (Some(row), Some(drawing)) = (row, self.imp().drawing.get().cloned()) else { + let (Some(row), Some(drawing)) = (row, self.drawing()) else { return; }; - drawing.draw(self, &row); + let state = drawing.draw(self, &row); + // On the cell, since GtkColumnViewCell passes on no accessible + // property of its own. + match cell_description(state) { + Some(description) => self.update_property(&[gtk::accessible::Property::Description(&description)]), + None => self.reset_property(gtk::AccessibleProperty::Description), + } } #[cfg(test)] @@ -233,10 +263,105 @@ impl GridCell { self.imp().edit.take() } - pub(super) fn set_popover(&self, popover: >k::Popover) { + /// Whether an open edit started from another value than `value`. + /// `default` is a draft's cell left to the server. + pub(super) fn edit_left_behind(&self, value: &Value, default: bool) -> bool { + match self.imp().edit.borrow().as_ref().map(|snapshot| &snapshot.original) { + None => false, + Some(CellInput::Default) => !default, + Some(CellInput::Value(original)) => default || original != value, + } + } + + /// The cell's text buffer. One grown past a short value is let go, + /// so a page of long values does not keep a long buffer per cell. + pub(super) fn with_scratch(&self, f: impl FnOnce(&mut String) -> R) -> R { + let mut scratch = self.imp().scratch.borrow_mut(); + let drawn = f(&mut scratch); + if scratch.capacity() > SCRATCH_KEPT_BYTES { + *scratch = String::new(); + } + drawn + } + + pub(super) fn set_inline(&self, editor: InlineEditor) { + self.imp().inline.replace(Some(editor)); + } + + pub(super) fn take_inline(&self) -> Option { + self.imp().inline.take() + } + + /// Parent `popover` to the cell and keep it there until it closes. + /// A popover the user closes ends its edit; one the editor took back + /// first has already dealt with it. + pub(super) fn present_popover(&self, popover: >k::Popover) { + popover.set_parent(self); if let Some(previous) = self.imp().popover.replace(Some(popover.clone())) { previous.popdown(); } + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let cell = self.downgrade(); + popover.connect_closed(move |popover| { + let cell = cell.upgrade(); + // GTK moves a focus left in a hidden popover to the popover's + // parent after the next frame, and by then it has none, so the + // focus would leave the grid. An unbound cell's row is gone. + if let Some(cell) = &cell + && cell.is_bound() + && holds_focus(popover) + { + return_focus(cell); + } + popover.unparent(); + if let Some(cell) = cell + && cell.release_popover(popover) + { + cell.take_edit(); + } + }); + popover.popup(); + } + + /// GTK detaches a removed row before it unbinds it, and moves a focus + /// left inside only after the next frame; a window focus-in between + /// the two crashes GTK 4.22 on the detached row. So the focus goes + /// before the edit ends, and the grid takes it back after the frame. + fn leave_detached_row(&self) { + let window = self.root().and_downcast::(); + let held = window + .as_ref() + .and_then(gtk::prelude::GtkWindowExt::focus) + .is_some_and(|focus| focus.is_ancestor(self) || focus == *self.upcast_ref::()); + if let Some(window) = window.as_ref().filter(|_| held) { + gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>); + } + self.cancel_edit(); + let (Some(window), true) = (window, held) else { + return; + }; + let (Some(view), Some(clock)) = (self.column_view(), window.frame_clock()) else { + return; + }; + let view = view.downgrade(); + let handler: Rc>> = Rc::default(); + let slot = handler.clone(); + let id = clock.connect_after_paint(move |clock| { + if let Some(id) = slot.borrow_mut().take() { + clock.disconnect(id); + } + if let Some(view) = view.upgrade() + && view.root().is_some() + { + view.grab_focus(); + } + }); + handler.replace(Some(id)); + } + + fn is_bound(&self) -> bool { + self.imp().binding.borrow().is_some() } pub(super) fn take_popover(&self) -> Option { @@ -244,12 +369,14 @@ impl GridCell { } /// Forget `popover` if it is still this cell's, leaving a newer one - /// in place. - pub(super) fn release_popover(&self, popover: >k::Popover) { + /// in place. `true` when it was. + fn release_popover(&self, popover: >k::Popover) -> bool { let mut slot = self.imp().popover.borrow_mut(); if slot.as_ref() == Some(popover) { slot.take(); + return true; } + false } pub(super) fn toggle_suppressed(&self) -> bool { @@ -259,38 +386,64 @@ impl GridCell { pub(super) fn suppress_toggle(&self, suppressed: bool) { self.imp().suppress_toggle.set(suppressed); } +} - pub(super) fn preedit_active(&self) -> bool { - self.imp().preedit_active.get() - } - - pub(super) fn set_preedit_active(&self, active: bool) { - self.imp().preedit_active.set(active); - } +fn holds_focus(widget: &impl IsA) -> bool { + widget + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .is_some_and(|focus| focus.is_ancestor(widget)) } #[cfg(test)] mod tests { use gtk4 as gtk; use gtk4::prelude::*; + use tablepro_core::Value; use super::GridCell; - use crate::ui::grid::CellEditor; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::test_grid::{key, two_text_rows}; + + fn described(widget: &impl IsA) -> bool { + gtk::test_accessible_has_property(widget, gtk::AccessibleProperty::Description) + } + + #[gtk4::test] + fn pending_edit_sets_description_on_grid_cell() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!described(&cell)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert!(described(&cell), "an edited cell said nothing"); + assert!(!described(&grid.cell_of(2, 1))); + + grid.redraw(&grid.tracker.undo().expect("an edit to undo")); + assert!(!described(&cell), "an undone edit still read as modified"); + } #[gtk4::test] fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); let cell = GridCell::new(); - let editor = CellEditor::new(); - cell.set_content(&editor); + let editor = gtk::Text::new(); + editor.set_parent(&cell); cell_widget.append(&cell); let below = GridCell::from_focus(cell_widget.upcast_ref()); - let above = GridCell::from_focus(editor.entry().upcast_ref()); + let above = GridCell::from_focus(editor.upcast_ref()); assert_eq!(below.as_ref(), Some(&cell)); assert_eq!(above.as_ref(), Some(&cell)); assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); + editor.unparent(); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/grid/grid_columns.rs b/linux/crates/app/src/ui/grid/grid_columns.rs new file mode 100644 index 0000000000..2255ec7fad --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_columns.rs @@ -0,0 +1,76 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::RowObject; +use super::cell_editor_kind::CellEditorKind; +use super::column_drawing::ColumnDrawing; + +/// The grid's columns, for finding a cell's neighbours. The user can +/// drag the columns into another order, so the order is read from the +/// view each time. +#[derive(Default)] +pub(super) struct GridColumns { + columns: RefCell>, +} + +struct GridColumn { + index: usize, + /// The view owns its columns, and their cells hold this. + view_column: glib::WeakRef, + drawing: Rc, +} + +#[derive(Clone)] +pub(super) struct ShownColumn { + pub(super) view_column: gtk::ColumnViewColumn, + /// The column's place in the result, which is what its cells hold. + pub(super) index: usize, + drawing: Rc, +} + +impl ShownColumn { + /// Whether the column's cell in `row` opens an editor. A check box + /// is edited where it stands, so it opens none. + pub(super) fn opens_editor(&self, row: &RowObject) -> bool { + let Some(column) = self.drawing.catalog.as_ref() else { + return false; + }; + let editability = self.drawing.lock(row.id()).map_or(Ok(()), Err); + matches!( + CellEditorKind::for_cell(column, editability), + CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar + ) + } +} + +impl GridColumns { + pub(super) fn push(&self, index: usize, view_column: >k::ColumnViewColumn, drawing: Rc) { + self.columns.borrow_mut().push(GridColumn { + index, + view_column: view_column.downgrade(), + drawing, + }); + } + + pub(super) fn shown(&self, view: >k::ColumnView) -> Vec { + let columns = self.columns.borrow(); + let model = view.columns(); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .filter(gtk::ColumnViewColumn::is_visible) + .filter_map(|view_column| { + let column = columns + .iter() + .find(|column| column.view_column.upgrade().as_ref() == Some(&view_column))?; + Some(ShownColumn { + view_column, + index: column.index, + drawing: column.drawing.clone(), + }) + }) + .collect() + } +} diff --git a/linux/crates/app/src/ui/grid/grid_content.rs b/linux/crates/app/src/ui/grid/grid_content.rs new file mode 100644 index 0000000000..e65ca1968d --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_content.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +use super::RowObject; + +/// What a grid is built to show: the result's columns and its rows, +/// which the grid's store then owns. +#[derive(Debug, Clone)] +pub struct GridContent { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec, + /// From the read that filled the grid, so Export Results reports the + /// same cut the footer does. + pub truncated: bool, +} diff --git a/linux/crates/app/src/ui/grid/grid_keyboard.rs b/linux/crates/app/src/ui/grid/grid_keyboard.rs new file mode 100644 index 0000000000..0086dd9cd0 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_keyboard.rs @@ -0,0 +1,494 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; + +use super::cell_edit::begin_edit; +use super::cell_focus::focused_cell; +use super::cell_preset::cell_presets; +use super::context_menu::prepare_menu; +use super::grid_menus::GridMenus; +use super::{CellPreset, GridCell, GridMsg, GridSink}; + +pub(super) struct GridKeyboard; + +impl GridKeyboard { + /// One controller on the column view, run in the capture phase. + /// GtkListFactoryWidget class-binds Return, ISO_Enter and KP_Enter + /// to activate-keybinding and space to listitem.select, and a + /// G_TYPE_NONE signal action counts as handled, so on a focused cell + /// those keys never reach a bubble-phase ancestor. + /// + /// Each action proceeds while the cell is editing. GtkShortcutController + /// then tries its next shortcut and lets the key go on, so an open + /// editor keeps Return, space, Menu and Ctrl+Backspace (delete word). + pub(super) fn install(column_view: >k::ColumnView, menus: GridMenus, sink: GridSink) { + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Local); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let none = gdk::ModifierType::empty(); + controller.add_shortcut(gtk::Shortcut::new( + Some(any_of(&[ + (gdk::Key::F2, none), + (gdk::Key::Return, none), + (gdk::Key::ISO_Enter, none), + (gdk::Key::KP_Enter, none), + ])), + Some(on_focused_cell(|_, cell| edit_or_toggle(cell))), + )); + controller.add_shortcut(gtk::Shortcut::new( + Some(any_of(&[(gdk::Key::space, none), (gdk::Key::KP_Space, none)])), + Some(on_focused_cell(|_, cell| toggle(cell))), + )); + controller.add_shortcut(gtk::Shortcut::new( + Some(any_of(&[ + (gdk::Key::Menu, none), + (gdk::Key::F10, gdk::ModifierType::SHIFT_MASK), + ])), + Some(on_focused_cell(move |widget, cell| show_menu(widget, cell, &menus))), + )); + controller.add_shortcut(gtk::Shortcut::new( + Some(any_of(&[(gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)])), + Some(on_focused_cell(move |_, cell| set_null(cell, &sink))), + )); + column_view.add_controller(controller); + } +} + +fn any_of(keys: &[(gdk::Key, gdk::ModifierType)]) -> gtk::ShortcutTrigger { + keys.iter() + .map(|&(key, modifiers)| gtk::KeyvalTrigger::new(key, modifiers).upcast::()) + .reduce(|first, second| gtk::AlternativeTrigger::new(first, second).upcast()) + .unwrap_or_else(|| gtk::NeverTrigger::get().upcast()) +} + +fn on_focused_cell(act: impl Fn(>k::Widget, &GridCell) -> glib::Propagation + 'static) -> gtk::CallbackAction { + gtk::CallbackAction::new(move |widget, _| match focused_cell(widget) { + Some(cell) if !cell.is_editing() => act(widget, &cell), + _ => glib::Propagation::Proceed, + }) +} + +/// A locked cell lets the key go on, so the list's own activation runs. +fn edit_or_toggle(cell: &GridCell) -> glib::Propagation { + if cell.check_button().is_some() { + return toggle(cell); + } + if cell.lock().is_some() { + return glib::Propagation::Proceed; + } + begin_edit(cell); + glib::Propagation::Stop +} + +/// Space on any other cell goes on to select the row. +fn toggle(cell: &GridCell) -> glib::Propagation { + let (None, Some(check)) = (cell.lock(), cell.check_button()) else { + return glib::Propagation::Proceed; + }; + // The check button's toggled handler sends the edit. + check.set_active(!check.is_active()); + glib::Propagation::Stop +} + +fn show_menu(widget: >k::Widget, cell: &GridCell, menus: &GridMenus) -> glib::Propagation { + let Some(column_view) = widget.downcast_ref::() else { + return glib::Propagation::Proceed; + }; + let popover = prepare_menu(menus, cell, column_view); + // A key has no pointer position, so the menu points at the whole + // cell, in the coordinates of the view the menu is parented to. + let bounds = cell.compute_bounds(column_view).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }); + popover.set_pointing_to(bounds.as_ref()); + popover.popup(); + glib::Propagation::Stop +} + +fn set_null(cell: &GridCell, sink: &GridSink) -> glib::Propagation { + let (None, Some(row), Some(drawing)) = (cell.lock(), cell.row(), cell.drawing()) else { + return glib::Propagation::Proceed; + }; + let Some(column) = drawing.catalog.as_ref() else { + return glib::Propagation::Proceed; + }; + if !cell_presets(column, row.id()).null { + return glib::Propagation::Proceed; + } + sink(GridMsg::SetCellValue { + row, + column: cell.column(), + preset: CellPreset::Null, + }); + glib::Propagation::Stop +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{self as gtk, gdk}; + use tablepro_core::Value; + + use crate::application::APP_ACCELS; + use crate::test_support::{descendants, wait_until}; + use crate::ui::grid::test_grid::{Grid, WAIT, content, edited_rows, key, named, recorder, text, two_text_rows}; + use crate::ui::grid::{CellPreset, GridCell, GridMsg, RowObject, TabGridContext, begin_edit, build_column_view}; + + type Accel = (gdk::Key, gdk::ModifierType); + + fn keys_of(trigger: >k::ShortcutTrigger, into: &mut Vec) { + if let Some(keyval) = trigger.downcast_ref::() { + into.push((keyval.keyval().to_lower(), keyval.modifiers())); + } else if let Some(pair) = trigger.downcast_ref::() { + keys_of(&pair.first(), into); + keys_of(&pair.second(), into); + } + } + + fn shortcuts(controller: >k::ShortcutController) -> Vec { + (0..controller.n_items()) + .filter_map(|position| controller.item(position).and_downcast::()) + .collect() + } + + fn accels(shortcut: >k::Shortcut) -> Vec { + let mut keys = Vec::new(); + if let Some(trigger) = shortcut.trigger() { + keys_of(&trigger, &mut keys); + } + keys + } + + fn keyboard(view: >k::ColumnView) -> gtk::ShortcutController { + let controllers = view.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .find(|controller| { + shortcuts(controller) + .iter() + .any(|shortcut| accels(shortcut).contains(&(gdk::Key::F2, gdk::ModifierType::empty()))) + }) + .expect("the grid's keyboard controller") + } + + /// Run what `key` does on the focused cell, as a key press reaching + /// the controller would. `true` when the grid took the key. + fn press(view: >k::ColumnView, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let action = shortcuts(&keyboard(view)) + .into_iter() + .find(|shortcut| accels(shortcut).contains(&(key.to_lower(), modifiers))) + .and_then(|shortcut| shortcut.action()) + .expect("the grid binds the key"); + action.activate(gtk::ShortcutActionFlags::empty(), view, None) + } + + fn none() -> gdk::ModifierType { + gdk::ModifierType::empty() + } + + fn parse(accel: &str) -> Accel { + let (key, modifiers) = gtk::accelerator_parse(accel).expect("an accelerator GTK can parse"); + (key.to_lower(), modifiers) + } + + #[gtk4::test] + fn grid_accels_do_not_collide_with_app_accels() { + let grid = two_text_rows(); + let bound: Vec = shortcuts(&keyboard(&grid.view)).iter().flat_map(accels).collect(); + assert!(bound.contains(&parse("BackSpace"))); + + let reserved = APP_ACCELS + .iter() + .flat_map(|(_, accels)| accels.iter()) + .chain(std::iter::once(&"question")); + for accel in reserved { + assert!(!bound.contains(&parse(accel)), "the grid binds {accel}"); + } + for accel in ["q", "n", "comma", "question"] { + assert!(!bound.contains(&parse(accel)), "the grid binds {accel}"); + } + } + + #[gtk4::test] + fn grid_shortcut_controller_runs_in_capture_phase_above_focus() { + let grid = two_text_rows(); + let controller = keyboard(&grid.view); + + grid.focus(1, 1); + + assert_eq!(controller.propagation_phase(), gtk::PropagationPhase::Capture); + assert_eq!(controller.scope(), gtk::ShortcutScope::Local); + let focus = gtk::prelude::GtkWindowExt::focus(&grid.window).expect("a focused widget"); + let owner = controller.widget().expect("the controller is on a widget"); + assert!( + focus.is_ancestor(&owner), + "a key on the focused cell does not pass the controller" + ); + assert_eq!(focus.css_name(), "cell", "the focus is not on a cell"); + } + + #[gtk4::test] + fn grid_shortcuts_proceed_while_editing() { + let grid = two_text_rows(); + let cell = grid.focus(0, 1); + begin_edit(&cell); + assert!(cell.inline_text().is_some()); + + assert!(!press(&grid.view, gdk::Key::Return, none())); + assert!(!press(&grid.view, gdk::Key::space, none())); + assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert!(!press(&grid.view, gdk::Key::Menu, none())); + + assert!(cell.inline_text().is_some(), "a grid key closed the open editor"); + assert!(grid.take_messages().is_empty()); + } + + #[gtk4::test] + fn f2_starts_editing_focused_text_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + + assert!(press(&grid.view, gdk::Key::F2, none())); + + let editor = cell.inline_text().expect("F2 opened no editor"); + assert_eq!(editor.text(), "B"); + } + + #[gtk4::test] + fn kp_enter_starts_editing_focused_int_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("n", "integer", false)], + vec![vec![Value::Int(1), Value::Int(42)]], + ); + let cell = grid.focus(0, 1); + + assert!(press(&grid.view, gdk::Key::KP_Enter, none())); + + let editor = cell.inline_text().expect("KP_Enter opened no editor"); + assert_eq!(editor.text(), "42"); + assert_eq!(editor.input_purpose(), gtk::InputPurpose::Number); + } + + #[gtk4::test] + fn return_toggles_a_focused_boolean_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![vec![Value::Int(1), Value::Bool(false)]], + ); + let cell = grid.focus(0, 1); + + assert!(press(&grid.view, gdk::Key::Return, none())); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![( + Some(key(1)), + 1, + tablepro_core::edit::CellInput::Value(Value::Bool(true)) + )] + ); + assert!(!cell.is_editing()); + } + + #[gtk4::test] + fn space_toggles_a_focused_check_box_and_selects_from_any_other_cell() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + ], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Bool(false)]], + ); + + grid.focus(0, 2); + assert!(press(&grid.view, gdk::Key::space, none())); + grid.focus(0, 1); + assert!( + !press(&grid.view, gdk::Key::space, none()), + "Space on a text cell did not go on to select the row" + ); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![( + Some(key(1)), + 2, + tablepro_core::edit::CellInput::Value(Value::Bool(true)) + )] + ); + } + + #[gtk4::test] + fn return_commits_and_returns_focus_to_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + assert!(press(&grid.view, gdk::Key::Return, none())); + let editor = cell.inline_text().expect("Return opened no editor"); + editor.set_text("renamed"); + + editor.emit_activate(); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, text("renamed"))] + ); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus did not come back"); + } + + #[gtk4::test] + fn shift_f10_opens_menu_anchored_to_focused_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + wait_until(WAIT, || { + cell.compute_bounds(&grid.view) + .is_some_and(|bounds| bounds.width() > 0.0 && bounds.y() > 0.0) + }) + .expect("the cell is laid out"); + + assert!(press(&grid.view, gdk::Key::F10, gdk::ModifierType::SHIFT_MASK)); + + let mut popover = None; + wait_until(WAIT, || { + popover = descendants(&grid.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|popover| popover.is_visible()); + popover.is_some() + }) + .expect("the cell menu opens"); + let popover = popover.expect("an open menu"); + let bounds = cell.compute_bounds(&grid.view).expect("the cell's bounds"); + let (pointed, rect) = popover.pointing_to(); + assert!(pointed); + assert_eq!( + (rect.x(), rect.y(), rect.width(), rect.height()), + ( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32 + ) + ); + grid.view + .activate_action("cell.set-null", None) + .expect("the menu's Set to NULL"); + popover.popdown(); + let set: Vec<_> = grid + .take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), + _ => None, + }) + .collect(); + assert_eq!( + set, + vec![(Some(key(2)), 1, CellPreset::Null)], + "the menu acts on another cell" + ); + } + + #[gtk4::test] + fn ctrl_backspace_sets_focused_nullable_cell_null() { + let mut required = named("code", "text", false); + required.nullable = false; + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false), required], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("b".into())]], + ); + let ctrl = gdk::ModifierType::CONTROL_MASK; + + grid.focus(0, 1); + assert!(press(&grid.view, gdk::Key::BackSpace, ctrl)); + grid.focus(0, 2); + assert!( + !press(&grid.view, gdk::Key::BackSpace, ctrl), + "a NOT NULL column took NULL" + ); + grid.focus(0, 0); + assert!( + !press(&grid.view, gdk::Key::BackSpace, ctrl), + "a saved row's key took NULL" + ); + + let set: Vec<_> = grid + .take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), + _ => None, + }) + .collect(); + assert_eq!(set, vec![(Some(key(1)), 1, CellPreset::Null)]); + } + + #[gtk4::test] + fn focused_read_only_cell_resolves_grid_cell() { + let grid = two_text_rows(); + + let cell = grid.focus(0, 0); + + assert!(cell.lock().is_some(), "a saved row's key was editable"); + assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(1))); + assert!(!press(&grid.view, gdk::Key::F2, none()), "F2 took a locked cell"); + assert!(!cell.is_editing()); + assert!(press(&grid.view, gdk::Key::Menu, none()), "a locked cell has no menu"); + } + + #[gtk4::test] + fn a_result_grid_opens_its_read_only_menu_from_the_keyboard() { + let columns = [named("id", "integer", true)]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_column_view( + content(&columns, vec![RowObject::result(vec![Value::Int(7)].into())]), + &[], + sink, + None, + false, + TabGridContext::default(), + ); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + view.grab_focus(); + let mut focused = None; + wait_until(WAIT, || { + focused = gtk::prelude::GtkWindowExt::focus(&window).and_then(|focus| GridCell::from_focus(&focus)); + focused.is_some() + }) + .expect("a result cell takes the focus"); + + assert!(!press(&view, gdk::Key::Return, none()), "Return edited a result cell"); + assert!(press(&view, gdk::Key::Menu, none())); + let open: Vec = descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|popover| popover.is_visible()) + .collect(); + assert_eq!(open.len(), 1); + assert!( + view.activate_action("cell.set-null", None).is_err(), + "a result grid offers Set to NULL" + ); + for popover in open { + popover.popdown(); + } + window.destroy(); + } + + #[gtk4::test] + fn grid_keys_proceed_with_no_focused_cell() { + let grid = two_text_rows(); + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + + assert!(!press(&grid.view, gdk::Key::F2, none())); + assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert!(grid.take_messages().is_empty()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index 5a8643c219..f0417c3f86 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -5,28 +5,62 @@ use gtk4::{self as gtk, gio}; use tablepro_core::column::ResultColumn; -use super::context_menu::{CellContext, CellMenuTarget}; -use super::inline_edit::enter_edit_mode; +use super::cell_edit::begin_edit; +use super::cell_editor_kind::CellEditorKind; +use super::cell_preset::cell_presets; +use super::context_menu::CellContext; +use super::menu_rows::menu_rows_of; use super::row_json_dialog::show_row_json_dialog; -use super::selection::{export_snapshot, rows_for_menu}; -use super::{CellPreset, GridMsg, GridSink, TabGridContext}; +use super::selection::{export_snapshot, snapshot_result}; +use super::{ + CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, TabGridContext, delete_availability, +}; -/// Enabled per right-clicked cell. Their items are hidden while -/// disabled, so a cell they do not apply to shows no dead entry. `None` -/// on a read-only grid, which registers neither. +/// Enabled per right-clicked cell. `None` on a read-only grid, which +/// registers none of them. #[derive(Clone, Default)] pub(super) struct CellActions { + /// Hidden while disabled, so a cell they do not apply to shows no + /// dead entry. edit: Option, set_empty: Option, + set_null: Option, + set_default: Option, + /// Shown insensitive while disabled, since a menu item cannot carry + /// a tooltip to say why. + delete_row: Option, } impl CellActions { - pub(super) fn arm(&self, target: &CellMenuTarget) { - if let Some(edit) = &self.edit { - edit.set_enabled(target.text_editable); - } - if let Some(set_empty) = &self.set_empty { - set_empty.set_enabled(target.accepts_empty); + /// Set for `cell` and `rows`, what the menu acts on, as it opens. + pub(super) fn arm(&self, cell: &GridCell, rows: &[RowObject]) { + let column = cell.drawing().and_then(|drawing| drawing.catalog.clone()); + let row = cell.row(); + let editor = column + .as_ref() + .map(|column| CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err))); + let presets = column + .as_ref() + .zip(row.as_ref()) + .map(|(column, row)| cell_presets(column, row.id())); + let enable = |action: &Option, enabled: bool| { + if let Some(action) = action { + action.set_enabled(enabled); + } + }; + enable( + &self.edit, + matches!( + editor, + Some(CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar) + ), + ); + enable(&self.set_empty, presets.is_some_and(|presets| presets.empty)); + enable(&self.set_null, presets.is_some_and(|presets| presets.null)); + enable(&self.set_default, presets.is_some_and(|presets| presets.default)); + if let Some(delete_row) = &self.delete_row { + let availability = delete_availability(rows.iter().map(RowObject::id), false); + delete_row.set_enabled(availability == DeleteAvailability::Available); } } } @@ -66,11 +100,23 @@ impl MenuShape { fn build_cell_menu(shape: MenuShape) -> gio::Menu { let menu = gio::Menu::new(); + let edit_section = gio::Menu::new(); + let hidden_while_disabled = |label: String, action: &str| { + let item = gio::MenuItem::new(Some(&label), Some(action)); + item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + edit_section.append_item(&item); + }; if shape.edit_cell { - let edit_section = gio::Menu::new(); - let edit_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Edit cell")), Some("cell.edit")); - edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - edit_section.append_item(&edit_item); + hidden_while_disabled(crate::i18n::gettext("Edit cell"), "cell.edit"); + } + if shape.set_value { + // Each is offered only where the column can hold it, so a menu + // never offers a value the save would refuse. + hidden_while_disabled(crate::i18n::gettext("Set to Empty"), "cell.set-empty"); + hidden_while_disabled(crate::i18n::gettext("Set to NULL"), "cell.set-null"); + hidden_while_disabled(crate::i18n::gettext("Set to DEFAULT"), "cell.set-default"); + } + if shape.edit_cell || shape.set_value { menu.append_section(None, &edit_section); } @@ -113,17 +159,6 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { menu.append_section(None, &json_section); let action_section = gio::Menu::new(); - if shape.set_value { - let set_value = gio::Menu::new(); - // Only a free-text column can hold an empty string: elsewhere - // "Empty" would either be rejected by the server or mean NULL, - // which the item below already says plainly. - let empty_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Empty")), Some("cell.set-empty")); - empty_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - set_value.append_item(&empty_item); - set_value.append(Some("NULL"), Some("cell.set-null")); - action_section.append_submenu(Some(&crate::i18n::gettext("Set Value")), &set_value); - } action_section.append(Some(&crate::i18n::gettext("Export Results…")), Some("cell.export")); if shape.row_ops { action_section.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); @@ -187,14 +222,24 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { )), } } + // The rows each item acts on are read when it is chosen, from the + // row the cell shows then. + macro_rules! rows_action { + ($name:literal, |$cell:ident, $rows:ident| $body:expr) => {{ + let view = view.clone(); + cell_action!($name, |$cell, _row| { + let Some(cv) = view.upgrade() else { return }; + let $rows = menu_rows_of(&cv, $cell); + $body; + }) + }}; + } macro_rules! copy_action { ($name:literal, |$rows:ident| $message:expr) => {{ let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); - cell_action!($name, |_cell, row| { - let Some(cv) = view.upgrade() else { return }; - let $rows = rows_for_menu(&cv, &tab, &row); + rows_action!($name, |_cell, rows| { + let $rows = row_values(&tab, &rows); s($message); }) }}; @@ -206,27 +251,23 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { }}; } - let edit_action = cell_action!("edit", |cell, _row| enter_edit_mode(cell)); + let edit_action = cell_action!("edit", |cell, _row| begin_edit(cell)); // Plain Copy reads the cell's value, never the widget's text: the // widget carries the display form, which is the `` sentinel, // the `(auto)` placeholder, `` for a blob and a value cut // at the 10k display cap. let copy_action = { let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); let cols = columns.clone(); - cell_action!("copy", |cell, row| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, &row); + rows_action!("copy", |cell, rows| { let column = cell.column(); let message = match rows.as_slice() { [row] => GridMsg::CopyToClipboard( - row.get(column) - .and_then(|value| tablepro_core::export::cell_text(&cols, column, value)) + tablepro_core::export::cell_text(&cols, column, tab.snapshot(row).value(column)) .unwrap_or_default(), ), - many => clipboard_message(tablepro_core::export::render_tsv(&cols, many, false)), + many => clipboard_message(tablepro_core::export::render_tsv(&cols, &row_values(&tab, many), false)), }; s(message); }) @@ -277,11 +318,9 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { // shorter list that quietly selects different rows. let copy_in_clause_action = { let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); - cell_action!("copy-in-clause", |cell, row| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, &row); + rows_action!("copy-in-clause", |cell, rows| { + let rows = row_values(&tab, &rows); let clause = tablepro_core::export::render_in_clause(&rows, cell.column()); if clause.sql.is_empty() { s(GridMsg::ShowToast(crate::i18n::gettext( @@ -306,14 +345,17 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .unwrap_or_default() )) }; - let copy_row_insert_action = send_action!("copy-row-insert", |_cell, row| GridMsg::CopyRowAsInsert { row }); + let copy_row_insert_action = { + let s = sink.clone(); + rows_action!("copy-row-insert", |_cell, rows| s(GridMsg::CopyRowsAsInsert { rows })) + }; let show_row_json_action = { let cols = columns.clone(); let view = view.clone(); let tab = tab.clone(); cell_action!("show-row-json", |_cell, row| { let Some(cv) = view.upgrade() else { return }; - let text = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); + let text = tablepro_core::export::row_to_json(&cols, &tab.snapshot(&row).to_values()); show_row_json_dialog(&cv, text); }) }; @@ -327,6 +369,11 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { column: cell.column(), preset: CellPreset::Null, }); + let set_default_action = send_action!("set-default", |cell, row| GridMsg::SetCellValue { + row, + column: cell.column(), + preset: CellPreset::Default, + }); let export_action = { let s = sink.clone(); let view = view.clone(); @@ -335,7 +382,8 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { gio::ActionEntry::builder("export") .activate(move |_, _, _| { let Some(cv) = view.upgrade() else { return }; - s(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))); + let rows = export_snapshot(&cv, &tab); + s(GridMsg::ExportResults(snapshot_result(cols.clone(), &rows, truncated))); }) .build() }; @@ -345,8 +393,14 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .activate(move |_, _, _| s(GridMsg::InsertRow)) .build() }; - let delete_row_action = send_action!("delete-row", |_cell, row| GridMsg::DeleteRowAt { row }); - let duplicate_row_action = send_action!("duplicate-row", |_cell, row| GridMsg::DuplicateRow { row }); + let delete_row_action = { + let s = sink.clone(); + rows_action!("delete-row", |_cell, rows| s(GridMsg::DeleteRows { rows })) + }; + let duplicate_row_action = { + let s = sink.clone(); + rows_action!("duplicate-row", |_cell, rows| s(GridMsg::DuplicateRows { rows })) + }; let mut entries = vec![ copy_action, @@ -369,6 +423,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { edit_action, set_empty_action, set_null_action, + set_default_action, copy_row_insert_action, insert_row_action, delete_row_action, @@ -381,6 +436,9 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let actions = CellActions { edit: editable.then(|| simple_action(&group, "edit")).flatten(), set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), + set_null: editable.then(|| simple_action(&group, "set-null")).flatten(), + set_default: editable.then(|| simple_action(&group, "set-default")).flatten(), + delete_row: editable.then(|| simple_action(&group, "delete-row")).flatten(), }; // Popovers are parented eagerly so each PopoverMenu's action muxer @@ -445,6 +503,11 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { } } +/// The rows a menu item copies, as the grid shows them. +fn row_values(tab: &TabGridContext, rows: &[RowObject]) -> Vec> { + rows.iter().map(|row| tab.snapshot(row).to_values()).collect() +} + fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { group .lookup_action(name) diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs index 9cb92366ea..75d08a9b95 100644 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use tablepro_core::QueryResult; use super::{CellPreset, RowObject}; +use crate::services::tracker_edit::TrackerEdit; /// Where a grid sends its events. The owner holds the grid, so the /// closure holds the owner weakly. @@ -13,30 +14,30 @@ pub enum GridMsg { /// The sorter's state after the click, not a toggle hint: a click on /// another column fires both sort notifies, and a hint would flip twice. SortChanged(usize, bool), - CellEdited { + /// An edit of `column` in `row`, already parsed for the column. + Edit { row: RowObject, column: usize, - input: String, + edit: TrackerEdit, }, CopyToClipboard(String), ShowToast(String), - CopyRowAsInsert { - row: RowObject, + CopyRowsAsInsert { + rows: Vec, }, - /// An intent, not a `Value`: the grid does not know the column's - /// declared type, and `Text("")` in an `int` or a `date` column is an - /// UPDATE the server rejects. + /// An intent the tab resolves against the column, so the keyboard + /// path and the menu reach the same value. SetCellValue { row: RowObject, column: usize, preset: CellPreset, }, ExportResults(QueryResult), - DeleteRowAt { - row: RowObject, + DeleteRows { + rows: Vec, }, InsertRow, - DuplicateRow { - row: RowObject, + DuplicateRows { + rows: Vec, }, } diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs deleted file mode 100644 index a28c397187..0000000000 --- a/linux/crates/app/src/ui/grid/inline_edit.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; - -use super::cell_editor_kind::CellEditorKind; -use super::cell_popovers::{ - show_calendar_popover, show_json_popover, show_long_text_popover, show_spin_button_popover, -}; -use super::display_text::is_cut_for_display; -use super::{EditSnapshot, GridCell, GridMsg, GridSink}; - -/// Opens on the whole value, never on the text the cell draws: that -/// is cut short for a long value, and saving it would cut the value. -/// A value too long for the one-line editor opens in a popover. -pub(crate) fn enter_edit_mode(cell: &GridCell) { - if cell.lock().is_some() { - return; - } - let Some(editor) = cell.editor() else { - return; - }; - let Some(text) = editable_text(cell) else { - return; - }; - if !is_cut_for_display(&text) { - editor.start_editing(&text); - return; - } - if let Some(sink) = cell.sink() { - show_long_text_popover(cell, &sink, &text); - } -} - -/// The cell's whole value, or `None` after telling the user why it -/// cannot be typed over. -fn editable_text(cell: &GridCell) -> Option { - let text = cell.edit_text(); - if text.is_none() - && let Some(sink) = cell.sink() - { - sink(GridMsg::ShowToast(crate::i18n::gettext( - "This value has no text form to edit.", - ))); - } - text -} - -/// `child_focus` on the window is what a real Tab press does. -fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { - let Some(root) = widget.root() else { return }; - let Ok(window) = root.dynamic_cast::() else { - return; - }; - window.child_focus(direction); -} - -pub(super) fn install_edit_triggers(cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { - let open: Rc = match editor_kind { - CellEditorKind::Text => Rc::new(enter_edit_mode), - CellEditorKind::Date => Rc::new(move |cell| show_calendar_popover(cell, &sink)), - CellEditorKind::Int => Rc::new(move |cell| show_spin_button_popover(cell, &sink, false)), - CellEditorKind::Float => Rc::new(move |cell| show_spin_button_popover(cell, &sink, true)), - CellEditorKind::Json => Rc::new(move |cell| show_json_popover(cell, &sink)), - }; - let trigger = move |cell: &GridCell| { - if cell.lock().is_some() { - return; - } - // The text editor checks for itself, since the menu opens it too. - if editor_kind != CellEditorKind::Text && editable_text(cell).is_none() { - return; - } - open(cell); - }; - let trigger = Rc::new(trigger); - - // Editing takes a double-click, since a single click in a grid - // selects the row. Capture phase, because the column view's row - // selection takes the press in the bubble phase. - // - // The handlers read the cell from their controller: the cell owns - // both controllers, so capturing it would be a cycle. - let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); - gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - let trigger_for_press = trigger.clone(); - gesture.connect_pressed(move |gesture, n_press, _, _| { - if n_press != 2 { - return; - } - let Some(cell) = gesture.widget().and_downcast::() else { - return; - }; - // Left to a locked cell's label, which selects a word with it. - if cell.lock().is_some() { - return; - } - gesture.set_state(gtk::EventSequenceState::Claimed); - trigger_for_press(&cell); - }); - cell.add_controller(gesture); - - // While editing, Return and Escape belong to the CellEditor's entry. - let controller = gtk::EventControllerKey::new(); - controller.connect_key_pressed(move |controller, keyval, _, modifiers| { - let Some(cell) = controller.widget().and_downcast::() else { - return glib::Propagation::Proceed; - }; - let Some(editor) = cell.editor() else { - return glib::Propagation::Proceed; - }; - let editing = editor.is_editing(); - let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); - - if !editing { - match keyval { - gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { - trigger(&cell); - return glib::Propagation::Stop; - } - // Left to GTK, Tab leaves the grid for the paginator. - gtk::gdk::Key::Tab if !shift => { - move_focus(&cell, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - move_focus(&cell, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - // The column view moves between rows only. - gtk::gdk::Key::Right => { - move_focus(&cell, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Left => { - move_focus(&cell, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - _ => {} - } - return glib::Propagation::Proceed; - } - - match keyval { - gtk::gdk::Key::Tab if !shift => { - editor.stop_editing(true); - move_focus(&cell, gtk::DirectionType::TabForward); - glib::Propagation::Stop - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - editor.stop_editing(true); - move_focus(&cell, gtk::DirectionType::TabBackward); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, - } - }); - cell.add_controller(controller); -} - -/// The row is taken when the edit begins, so a commit after rows moved -/// still lands on it. Focus moving to an IME popover ends the edit -/// mid-composition, so the commit waits until the preedit clears. -pub(super) fn install_edit_commit_handler(cell: &GridCell, sink: GridSink) { - let Some(editor) = cell.editor() else { - return; - }; - // The editor is the cell's own child, so its handlers hold the cell - // weakly. - let cell_for_preedit = cell.downgrade(); - let sink_for_preedit = sink.clone(); - editor.entry().connect_preedit_changed(move |_, preedit| { - let Some(cell) = cell_for_preedit.upgrade() else { - return; - }; - let active = !preedit.is_empty(); - cell.set_preedit_active(active); - // The edit ended during the composition, so this is its commit. - if !active && !cell.editor().is_some_and(|editor| editor.is_editing()) { - commit_cell_edit(&cell, &sink_for_preedit); - } - }); - - let cell_for_edit = cell.downgrade(); - editor.connect_editing_notify(move |editor| { - let Some(cell) = cell_for_edit.upgrade() else { - return; - }; - if editor.is_editing() { - if let Some(row) = cell.row() { - cell.begin_edit(EditSnapshot { - row, - column: cell.column(), - original_text: editor.text().to_string(), - }); - } - return; - } - if cell.preedit_active() { - return; - } - commit_cell_edit(&cell, &sink); - }); -} - -fn commit_cell_edit(cell: &GridCell, sink: &GridSink) { - let Some(snapshot) = cell.take_edit() else { - return; - }; - let Some(editor) = cell.editor() else { - return; - }; - let input = editor.text().to_string(); - if input == snapshot.original_text { - return; - } - sink(GridMsg::CellEdited { - row: snapshot.row, - column: snapshot.column, - input, - }); -} diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs new file mode 100644 index 0000000000..6a9ca29ca1 --- /dev/null +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -0,0 +1,211 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::cell_focus::{edit_next, return_focus}; +use super::edit_commit::{commit_edit, send_commit}; +use super::edit_navigation::EditDirection; +use super::{EditSnapshot, GridCell}; + +/// How the one-line editor treats what is typed into it. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineOptions { + /// Off for a column a line break means nothing in: a pasted break + /// keeps the first line, as a one-line field does elsewhere. + pub accepts_line_breaks: bool, + pub purpose: gtk::InputPurpose, + /// What an empty commit means, shown while the field is empty. + pub placeholder: Option, +} + +/// A `gtk::Text` laid over the cell's display while an edit is open, and +/// removed when it commits or is cancelled. +#[derive(Debug)] +pub struct InlineEditor { + text: gtk::Text, +} + +impl InlineEditor { + pub(super) fn begin(cell: &GridCell, snapshot: EditSnapshot, initial: &str, options: InlineOptions) { + cell.cancel_edit(); + let text = gtk::Text::new(); + text.set_hexpand(true); + text.set_margin_start(8); + text.set_margin_end(8); + text.add_css_class("tp-cell-text"); + text.set_truncate_multiline(!options.accepts_line_breaks); + text.set_input_purpose(options.purpose); + text.set_placeholder_text(options.placeholder.as_deref()); + text.set_text(initial); + connect_commit(cell, &text); + if let Some(display) = cell.content() { + display.set_visible(false); + } + text.set_parent(cell); + cell.begin_edit(snapshot); + cell.set_inline(Self { text: text.clone() }); + text.grab_focus(); + // The first keystroke replaces the value, as in a Files rename. + text.select_region(0, -1); + } + + pub(super) fn widget(&self) -> >k::Text { + &self.text + } + + /// Take the field out of the cell and show the display again, with + /// the text the field held. + pub(super) fn close(self, cell: &GridCell) -> String { + let typed = self.text.text().to_string(); + self.text.unparent(); + if let Some(display) = cell.content() { + display.set_visible(true); + } + typed + } +} + +/// End the cell's one-line edit. The field leaves before the commit is +/// sent, so the redraw the commit causes meets no open editor. +fn finish_inline(cell: &GridCell, commit: bool) { + let Some(editor) = cell.take_inline() else { + return; + }; + let snapshot = cell.take_edit(); + let typed = editor.close(cell); + if !commit { + return; + } + let (Some(snapshot), Some(drawing)) = (snapshot, cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &typed); + send_commit(cell, snapshot, result); +} + +/// The field is the cell's child, so its handlers hold the cell weakly. +fn connect_commit(cell: &GridCell, text: >k::Text) { + let weak = cell.downgrade(); + text.connect_activate(move |_| { + if let Some(cell) = weak.upgrade() { + finish_inline(&cell, true); + return_focus(&cell); + } + }); + + // Focus moving to an IME popover ends the edit mid-composition, and + // committing then would send the text from before it, so the commit + // waits for the preedit to clear. + let composing = Rc::new(Cell::new(false)); + let commit_when_composed = Rc::new(Cell::new(false)); + + // Capture phase, so Escape cancels before the GtkText can take it to + // clear its own buffer, and Tab commits before the window moves the + // focus. While a preedit is open both keys are the input method's. + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = cell.downgrade(); + let keys_composing = composing.clone(); + keys.connect_key_pressed(move |_, keyval, _, modifiers| { + let Some(cell) = weak.upgrade() else { + return glib::Propagation::Proceed; + }; + if keys_composing.get() { + return glib::Propagation::Proceed; + } + match edit_key(keyval, modifiers) { + Some(EditKey::Cancel) => { + finish_inline(&cell, false); + return_focus(&cell); + } + Some(EditKey::Move(direction)) => { + finish_inline(&cell, true); + edit_next(&cell, direction); + } + None => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + text.add_controller(keys); + + let weak = cell.downgrade(); + let preedit_composing = composing.clone(); + let preedit_commit = commit_when_composed.clone(); + text.connect_preedit_changed(move |_, preedit| { + preedit_composing.set(!preedit.is_empty()); + if preedit.is_empty() + && preedit_commit.replace(false) + && let Some(cell) = weak.upgrade() + { + finish_inline(&cell, true); + } + }); + + let focus = gtk::EventControllerFocus::new(); + let weak = cell.downgrade(); + focus.connect_leave(move |_| { + let Some(cell) = weak.upgrade() else { + return; + }; + if composing.get() { + commit_when_composed.set(true); + return; + } + finish_inline(&cell, true); + }); + text.add_controller(focus); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EditKey { + Cancel, + /// Commit, then edit the next cell that way. + Move(EditDirection), +} + +/// Ctrl+Tab and Alt+Tab belong to the window's tabs and switcher. +fn edit_key(keyval: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> Option { + use gtk::gdk::{Key, ModifierType}; + if modifiers.intersects(ModifierType::CONTROL_MASK | ModifierType::ALT_MASK) { + return None; + } + match keyval { + Key::Escape => Some(EditKey::Cancel), + Key::ISO_Left_Tab => Some(EditKey::Move(EditDirection::Backward)), + Key::Tab | Key::KP_Tab if modifiers.contains(ModifierType::SHIFT_MASK) => { + Some(EditKey::Move(EditDirection::Backward)) + } + Key::Tab | Key::KP_Tab => Some(EditKey::Move(EditDirection::Forward)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use gtk4::gdk::{Key, ModifierType}; + + use super::{EditKey, edit_key}; + use crate::ui::grid::edit_navigation::EditDirection; + + #[test] + fn tab_moves_escape_cancels_and_window_keys_pass() { + let none = ModifierType::empty(); + let shift = ModifierType::SHIFT_MASK; + + assert_eq!(edit_key(Key::Escape, none), Some(EditKey::Cancel)); + assert_eq!(edit_key(Key::Tab, none), Some(EditKey::Move(EditDirection::Forward))); + assert_eq!( + edit_key(Key::ISO_Left_Tab, shift), + Some(EditKey::Move(EditDirection::Backward)) + ); + assert_eq!(edit_key(Key::Tab, shift), Some(EditKey::Move(EditDirection::Backward))); + assert_eq!(edit_key(Key::Tab, ModifierType::CONTROL_MASK), None); + assert_eq!(edit_key(Key::Tab, ModifierType::ALT_MASK), None); + assert_eq!(edit_key(Key::Return, none), None, "Return is the field's activate"); + } +} diff --git a/linux/crates/app/src/ui/grid/menu_rows.rs b/linux/crates/app/src/ui/grid/menu_rows.rs new file mode 100644 index 0000000000..6fc794b514 --- /dev/null +++ b/linux/crates/app/src/ui/grid/menu_rows.rs @@ -0,0 +1,81 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::selection::{selected_positions, selection_of}; +use super::{GridCell, RowObject}; + +/// The rows a context menu item acts on: the whole selection when the +/// clicked row is in it, else the clicked row alone, so rows that moved +/// since the click never stand in for it. +pub fn menu_rows(selection: >k::MultiSelection, clicked: &RowObject, clicked_position: u32) -> Vec { + let clicked_is_selected = selection.is_selected(clicked_position) + && selection.item(clicked_position).and_downcast_ref::() == Some(clicked); + if !clicked_is_selected { + return vec![clicked.clone()]; + } + selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() +} + +/// The rows a menu opened on `cell` acts on, from the row the cell +/// shows when the item is chosen. +pub(super) fn menu_rows_of(column_view: >k::ColumnView, cell: &GridCell) -> Vec { + let Some(row) = cell.row() else { + return Vec::new(); + }; + match (selection_of(column_view), cell.position()) { + (Some(selection), Some(position)) => menu_rows(&selection, &row, position), + _ => vec![row], + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::gio; + use gtk4::prelude::*; + use tablepro_core::Value; + + use super::menu_rows; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + fn rows(n: i64) -> (gtk::MultiSelection, Vec) { + let store = gio::ListStore::new::(); + let rows: Vec = (0..n) + .map(|id| RowObject::persisted(RowKey::from_pk_values(&[Value::Int(id)]), vec![Value::Int(id)].into())) + .collect(); + for row in &rows { + store.append(row); + } + (gtk::MultiSelection::new(Some(store)), rows) + } + + #[gtk4::test] + fn menu_rows_uses_selection_when_clicked_row_is_selected() { + let (selection, rows) = rows(5); + selection.select_item(3, false); + selection.select_item(1, false); + + assert_eq!( + menu_rows(&selection, &rows[3], 3), + vec![rows[1].clone(), rows[3].clone()], + "the rows are not the selection in position order" + ); + } + + #[gtk4::test] + fn menu_rows_uses_only_clicked_row_outside_selection() { + let (selection, rows) = rows(5); + selection.select_range(0, 2, true); + + assert_eq!(menu_rows(&selection, &rows[4], 4), vec![rows[4].clone()]); + assert_eq!( + menu_rows(&selection, &rows[4], 1), + vec![rows[4].clone()], + "a selected row at the clicked position stood in for the clicked one" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 6b41ae1917..494cccb51b 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -1,39 +1,63 @@ +mod calendar_editor; +mod cell_accessibility; mod cell_binding; +mod cell_edit; mod cell_editability; -mod cell_editor; mod cell_editor_kind; +mod cell_focus; mod cell_lock; -mod cell_popovers; mod cell_preset; +mod cell_tooltip; mod column_drawing; mod column_factory; mod column_width_binding; mod context_menu; +mod delete_availability; mod display_text; +mod edit_commit; +mod edit_navigation; mod edit_snapshot; +mod grid_attributes; mod grid_cell; +mod grid_columns; +mod grid_content; +mod grid_keyboard; mod grid_menus; mod grid_msg; mod grid_row_id; mod grid_row_index; -mod inline_edit; +mod inline_editor; +mod menu_rows; +mod multiline_editor; +mod row_factory; mod row_json_dialog; +mod row_key_builder; mod row_object; +mod row_snapshot; mod selection; mod tab_grid_context; +#[cfg(test)] +mod test_grid; +mod toggle_cell; +#[cfg(test)] +pub(crate) use self::cell_edit::begin_edit; pub(crate) use self::cell_editability::cell_editability; -pub(crate) use self::cell_editor::CellEditor; +pub(crate) use self::cell_focus::edit_row; pub(crate) use self::cell_lock::CellLock; -pub(crate) use self::cell_preset::CellPreset; +pub(crate) use self::cell_preset::{CellPreset, cell_presets}; pub(crate) use self::column_factory::build_column_view; pub(crate) use self::column_width_binding::ColumnWidthBinding; +pub(crate) use self::delete_availability::{DeleteAvailability, delete_availability}; +pub(crate) use self::edit_commit::edit_context; pub(crate) use self::edit_snapshot::EditSnapshot; pub(crate) use self::grid_cell::GridCell; +pub(crate) use self::grid_content::GridContent; pub(crate) use self::grid_msg::{GridMsg, GridSink}; pub(crate) use self::grid_row_id::GridRowId; pub(crate) use self::grid_row_index::GridRowIndex; -pub(crate) use self::inline_edit::enter_edit_mode; +pub(crate) use self::row_key_builder::RowKeyBuilder; pub(crate) use self::row_object::RowObject; -pub(crate) use self::selection::{export_snapshot, selected_positions}; +pub(crate) use self::row_snapshot::RowSnapshot; +pub(crate) use self::selection::{export_snapshot, selected_positions, snapshot_result}; pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/multiline_editor.rs b/linux/crates/app/src/ui/grid/multiline_editor.rs new file mode 100644 index 0000000000..621c36682e --- /dev/null +++ b/linux/crates/app/src/ui/grid/multiline_editor.rs @@ -0,0 +1,107 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use sourceview5::prelude::*; + +use super::edit_commit::{commit_edit, send_commit}; +use super::{EditSnapshot, GridCell}; + +/// A popover with a source view, for text that spans lines or is too +/// long for a one-line field. Return adds a line, so Save is a button +/// and Ctrl+Return. +pub struct MultilineEditor; + +impl MultilineEditor { + pub(super) fn open(cell: &GridCell, snapshot: EditSnapshot, text: &str, language: Option<&'static str>) { + cell.cancel_edit(); + let buffer = sourceview5::Buffer::new(None); + if let Some(language) = language.and_then(|id| sourceview5::LanguageManager::default().language(id)) { + buffer.set_language(Some(&language)); + } + buffer.set_text(text); + let view = sourceview5::View::with_buffer(&buffer); + view.set_monospace(true); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_auto_indent(true); + crate::ui::style_scheme_resolver::apply(&view, "Adwaita"); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&view) + .min_content_width(420) + .min_content_height(280) + .has_frame(true) + .build(); + let save = gtk::Button::with_label(&crate::i18n::gettext("Save")); + save.add_css_class("suggested-action"); + let cancel = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); + let buttons = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::End) + .build(); + buttons.append(&cancel); + buttons.append(&save); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + content.append(&scrolled); + content.append(&buttons); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_popover = popover.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(popover) = weak_popover.upgrade() { + popover.popdown(); + } + }); + let weak_cell = cell.downgrade(); + save.connect_clicked(move |_| { + if let Some(cell) = weak_cell.upgrade() { + save_text(&cell, &buffer); + } + }); + let save_shortcut = gtk::ShortcutController::new(); + let weak_save = save.downgrade(); + save_shortcut.add_shortcut(gtk::Shortcut::new( + Some(gtk::KeyvalTrigger::new( + gtk::gdk::Key::Return, + gtk::gdk::ModifierType::CONTROL_MASK, + )), + Some(gtk::CallbackAction::new(move |_, _| { + if let Some(save) = weak_save.upgrade() { + save.emit_clicked(); + } + glib::Propagation::Stop + })), + )); + view.add_controller(save_shortcut); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + view.grab_focus(); + } +} + +/// The popover closes before the tab hears of the value, so an edit the +/// tab redraws the row for never meets an open popover. +fn save_text(cell: &GridCell, buffer: &sourceview5::Buffer) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string(); + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &text); + send_commit(cell, snapshot, result); +} diff --git a/linux/crates/app/src/ui/grid/row_factory.rs b/linux/crates/app/src/ui/grid/row_factory.rs new file mode 100644 index 0000000000..b403312e1b --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_factory.rs @@ -0,0 +1,126 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::cell_accessibility::row_description; +use super::{RowObject, TabGridContext}; + +/// A bound row's revision handler, so unbind can take it off the row. +struct RowWatch { + row: RowObject, + handler: glib::SignalHandlerId, +} + +/// Rows never take the focus, so it always lands on a cell and the +/// arrow keys move between cells. Each row tells a screen reader when +/// it is new or marked for deletion. +pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGridContext) { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(list_row) = item.downcast_ref::() { + list_row.set_focusable(false); + } + }); + + let watches: Rc>> = Rc::default(); + let tab_ctx = Rc::new(tab_ctx); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let Some(list_row) = item.downcast_ref::() else { + return; + }; + let Some(row) = list_row.item().and_downcast::() else { + return; + }; + describe(list_row, &tab_ctx, &row); + // The row outlives the list row it is bound to, so the + // handler holds the list row weakly. + let weak = list_row.downgrade(); + let tab_ctx = tab_ctx.clone(); + let handler = row.connect_revision_notify(move |row| { + if let Some(list_row) = weak.upgrade() { + describe(&list_row, &tab_ctx, row); + } + }); + if let Some(stale) = watches.borrow_mut().insert(list_row.clone(), RowWatch { row, handler }) { + stale.row.disconnect(stale.handler); + } + }); + } + factory.connect_unbind(move |_, item| { + let Some(list_row) = item.downcast_ref::() else { + return; + }; + if let Some(watch) = watches.borrow_mut().remove(list_row) { + watch.row.disconnect(watch.handler); + } + list_row.set_property("accessible-description", None::); + }); + column_view.set_row_factory(Some(&factory)); +} + +/// Through the property, since the setter takes no NULL to clear it. +fn describe(list_row: >k::ColumnViewRow, tab_ctx: &TabGridContext, row: &RowObject) { + list_row.set_property("accessible-description", row_description(tab_ctx.row_state(row))); +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + use tablepro_core::Value; + + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::wait_until; + use crate::ui::grid::test_grid::{WAIT, key, two_text_rows}; + + #[gtk4::test] + fn pending_delete_sets_row_widget_description() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); + let described = || gtk::test_accessible_has_property(&row, gtk::AccessibleProperty::Description); + assert!(gtk::test_accessible_has_role(&row, gtk::AccessibleRole::Row)); + assert!(!described()); + assert!(!row.is_focusable(), "a row took the focus a cell should"); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + assert!(described(), "a row marked for deletion said nothing"); + + grid.redraw(&grid.tracker.undo().expect("a delete to undo")); + assert!(!described(), "a restored row still read as marked"); + } + + #[gtk4::test] + fn a_new_row_is_described_and_its_cells_are_not() { + let grid = two_text_rows(); + grid.insert_draft(); + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .cells() + .into_iter() + .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft is drawn"); + let draft = draft.expect("the draft's cell"); + let row = draft.parent().and_then(|cell| cell.parent()).expect("the row widget"); + + assert!(gtk::test_accessible_has_property( + &row, + gtk::AccessibleProperty::Description + )); + assert!(!gtk::test_accessible_has_property( + &draft, + gtk::AccessibleProperty::Description + )); + } +} diff --git a/linux/crates/app/src/ui/grid/row_key_builder.rs b/linux/crates/app/src/ui/grid/row_key_builder.rs new file mode 100644 index 0000000000..bc67f336fd --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_key_builder.rs @@ -0,0 +1,231 @@ +use std::ops::Range; + +use tablepro_core::browse::PageLayout; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, Value}; + +use crate::services::change_tracker::{KeyValue, RowKey}; + +/// Names each row of a page by every cell of the identity it was keyed +/// on, so two rows that share part of it never share a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowKeyBuilder { + source: KeySource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeySource { + /// Positions among the visible cells, in key order. + Visible(Box<[usize]>), + /// Positions among the identity cells the page projected after the + /// visible ones. + Identity(Range), +} + +impl RowKeyBuilder { + /// `None` when nothing on the page names a row, so its rows are + /// read-only: a sorting key can match several rows. + pub fn for_page(identity: &RowIdentity, columns: &[ColumnInfo], layout: &PageLayout) -> Option { + let source = match identity { + RowIdentity::UniqueKey { columns: key } if !key.is_empty() => { + let positions = key + .iter() + .map(|name| { + columns + .iter() + .position(|column| column.name == *name) + .filter(|position| layout.visible.contains(position)) + }) + .collect::>>()?; + KeySource::Visible(positions) + } + RowIdentity::EngineRowId(_) if !layout.identity.is_empty() => KeySource::Identity(0..layout.identity.len()), + RowIdentity::UniqueKey { .. } + | RowIdentity::EngineRowId(_) + | RowIdentity::SortingKey { .. } + | RowIdentity::Unordered => return None, + }; + Some(Self { source }) + } + + /// The row's key, `None` when a cell of it could not be read and so + /// cannot name the row to the server again. + pub fn key_for(&self, visible: &[Value], identity: &[Value]) -> Option { + let parts = match &self.source { + KeySource::Visible(positions) => positions + .iter() + .map(|position| visible.get(*position).filter(|value| readable(value))) + .map(|value| value.cloned().map(KeyValue::new)) + .collect::>>()?, + // A null row address names nothing. + KeySource::Identity(range) => identity + .get(range.clone())? + .iter() + .map(|value| (readable(value) && !value.is_null()).then(|| KeyValue::new(value.clone()))) + .collect::>>()?, + }; + Some(RowKey::Persisted(parts)) + } +} + +fn readable(value: &Value) -> bool { + !matches!(value, Value::Undecodable(_)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::browse::{PageLayout, PageRequest, build_page}; + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{EngineRowId, PagePosition, RowIdentity, TableRef}; + use tablepro_core::value::{UndecodableReason, UndecodedValue}; + use tablepro_core::{ColumnInfo, FilterSet}; + + use super::RowKeyBuilder; + use crate::services::change_tracker::RowKey; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn columns(names: &[&str]) -> Vec { + names.iter().map(|name| column(name)).collect() + } + + /// The layout the page builder gives `identity` over `columns`. + fn layout(identity: &RowIdentity, columns: &[ColumnInfo]) -> PageLayout { + let dialect = drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + build_page( + &dialect, + &PageRequest { + table: &TableRef::unqualified("t"), + columns, + filter: &FilterSet::default(), + sort: &[], + identity, + position: &PagePosition::First, + limit: 100, + }, + ) + .expect("a page") + .layout + } + + fn key(values: &[Value]) -> Option { + RowKey::from_pk_values(values) + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[test] + fn row_key_builder_unique_key_uses_visible_key_columns() { + let columns = columns(&["tenant", "name", "id"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + let row = [text("eu"), text("Ada"), Value::Int(7)]; + + assert!(layout.identity.is_empty(), "a unique key needs no hidden column"); + assert_eq!(builder.key_for(&row, &[]), key(&[Value::Int(7), text("eu")])); + } + + #[test] + fn row_key_builder_tableoid_ctid_keys_on_both_identity_columns() { + let columns = columns(&["name"]); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + let ctid = text("(0,1)"); + let xmin = text("812"); + + let first = builder.key_for(&[text("a")], &[Value::Int(16_401), ctid.clone(), xmin.clone()]); + let second = builder.key_for(&[text("a")], &[Value::Int(16_402), ctid, xmin]); + + assert_eq!(layout.identity.len(), 3, "tableoid, ctid and xmin"); + assert!(first.is_some()); + assert_ne!(first, second, "a ctid from another partition named the same row"); + } + + #[test] + fn row_key_builder_rowid_uses_single_identity_column() { + let columns = columns(&["name", "note"]); + let identity = RowIdentity::EngineRowId(EngineRowId::SqliteRowid); + let layout = tablepro_core::browse::PageLayout { + visible: 0..2, + identity: 2..3, + }; + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Int(11)]), + key(&[Value::Int(11)]) + ); + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Null]), + None, + "a null row address named a row" + ); + } + + #[test] + fn row_key_builder_returns_none_for_undecodable_key() { + let unreadable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let columns = columns(&["id", "name"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!(builder.key_for(&[unreadable, text("a")], &[]), None); + assert_eq!( + builder.key_for(&[Value::Null, text("a")], &[]), + key(&[Value::Null]), + "a NULL key cell still names its row, as SQLite allows" + ); + } + + #[test] + fn a_page_nothing_names_has_no_builder() { + let columns = columns(&["day", "hits"]); + let layout = PageLayout { + visible: 0..2, + identity: 2..2, + }; + let sorting = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + let missing = RowIdentity::UniqueKey { + columns: vec!["gone".to_owned()], + }; + + assert_eq!(RowKeyBuilder::for_page(&sorting, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::Unordered, &columns, &layout), + None + ); + assert_eq!(RowKeyBuilder::for_page(&missing, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::UniqueKey { columns: Vec::new() }, &columns, &layout), + None, + "a table with no primary key" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs index 869ca8fe25..8d89294aff 100644 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -91,6 +91,12 @@ impl RowObject { self.imp().cells.get()?.get(column) } + /// The row's own hold on its cells, to count who else holds them. + #[cfg(test)] + pub(crate) fn shared_cells(&self) -> Option<&Arc<[Value]>> { + self.imp().cells.get() + } + pub(crate) fn bump_revision(&self) { let revision = &self.imp().revision; revision.set(revision.get().wrapping_add(1)); diff --git a/linux/crates/app/src/ui/grid/row_snapshot.rs b/linux/crates/app/src/ui/grid/row_snapshot.rs new file mode 100644 index 0000000000..4a98b76d1f --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_snapshot.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +static NULL: Value = Value::Null; + +/// A row as the grid shows it, pending edits included, for a copy or an +/// export. It shares the cells the page read and holds its own copy of +/// only the cells the user changed, and it can leave the GTK thread. +#[derive(Debug, Clone)] +pub struct RowSnapshot { + cells: Arc<[Value]>, + /// Pending values by column, in column order. + overrides: Box<[(usize, Value)]>, +} + +impl RowSnapshot { + pub fn new(cells: Arc<[Value]>, overrides: Vec<(usize, Value)>) -> Self { + Self { + cells, + overrides: overrides.into_boxed_slice(), + } + } + + /// NULL for a column past the row's end. + pub fn value(&self, column: usize) -> &Value { + match self.overrides.binary_search_by_key(&column, |(col, _)| *col) { + Ok(index) => &self.overrides[index].1, + Err(_) => self.cells.get(column).unwrap_or(&NULL), + } + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + /// The row as the owned cells an encoder takes. + pub fn to_values(&self) -> Vec { + (0..self.len()).map(|column| self.value(column).clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::Value; + + use super::RowSnapshot; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{RowObject, TabGridContext}; + + #[test] + fn row_snapshot_shares_unmodified_cells() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 2, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let cells: Arc<[Value]> = vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("old".into())].into(); + let row = RowObject::persisted(Some(key), cells.clone()); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + let snapshot = context.snapshot(&row); + + assert!(Arc::ptr_eq(&snapshot.cells, &cells), "the row's cells were copied"); + assert_eq!(snapshot.overrides.len(), 1, "an unedited cell was copied"); + assert_eq!(snapshot.overrides[0].0, 2); + assert_eq!( + snapshot.to_values(), + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("new".into())] + ); + assert_eq!(snapshot.value(9), &Value::Null); + } + + #[test] + fn a_draft_snapshot_reads_a_default_as_null() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![ + tablepro_core::edit::CellInput::Default, + tablepro_core::edit::CellInput::Value(Value::Int(3)), + ]], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + assert_eq!(context.snapshot(&draft).to_values(), vec![Value::Null, Value::Int(3)]); + } + + #[test] + fn a_snapshot_can_leave_the_gtk_thread() { + fn sendable() {} + + sendable::(); + } +} diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs index d6532de30f..45d88b171b 100644 --- a/linux/crates/app/src/ui/grid/selection.rs +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -1,10 +1,12 @@ +use std::sync::Arc; + use gtk4 as gtk; use gtk4::prelude::*; +use tablepro_core::QueryResult; use tablepro_core::column::ResultColumn; -use tablepro_core::{QueryResult, Value}; -use super::{RowObject, TabGridContext}; +use super::{RowObject, RowSnapshot, TabGridContext}; pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { let bitset = selection.selection(); @@ -20,7 +22,7 @@ pub(super) fn row_at(column_view: >k::ColumnView, position: u32) -> Option().ok() } -fn selection_of(column_view: >k::ColumnView) -> Option { +pub(super) fn selection_of(column_view: >k::ColumnView) -> Option { column_view.model()?.downcast::().ok() } @@ -37,39 +39,20 @@ pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) selection.select_item(position, true); } -/// The selection when it holds the clicked row, else the clicked row -/// alone, so rows that moved since the click never stand in for it. -pub(super) fn rows_for_menu( - column_view: >k::ColumnView, - ctx: &TabGridContext, - clicked: &RowObject, -) -> Vec> { - let selected: Vec = selection_of(column_view) - .map(|selection| selected_positions(&selection)) - .unwrap_or_default() - .into_iter() +/// The paginator's Export button and the menu's Export Results both +/// read the rows here, so one label cannot mean two files. +pub(crate) fn export_snapshot(column_view: >k::ColumnView, ctx: &TabGridContext) -> Vec { + let Some(model) = column_view.model() else { + return Vec::new(); + }; + (0..model.n_items()) .filter_map(|position| row_at(column_view, position)) - .collect(); - if !selected.contains(clicked) { - return vec![ctx.effective_cells(clicked)]; - } - selected.iter().map(|row| ctx.effective_cells(row)).collect() + .map(|row| ctx.snapshot(&row)) + .collect() } -/// The paginator's Export button and the menu's Export Results both -/// build their payload here, so one label cannot mean two files. -pub(crate) fn export_snapshot( - column_view: >k::ColumnView, - columns: &[ResultColumn], - truncated: bool, - ctx: &TabGridContext, -) -> QueryResult { - let rows = match column_view.model() { - Some(model) => (0..model.n_items()) - .filter_map(|p| row_at(column_view, p)) - .map(|r| ctx.effective_cells(&r)) - .collect(), - None => Vec::new(), - }; - QueryResult::new(columns.to_vec(), rows).truncated(truncated) +/// Rows for an encoder that takes owned cells, under the columns the +/// grid shows. +pub(crate) fn snapshot_result(columns: Arc<[ResultColumn]>, rows: &[RowSnapshot], truncated: bool) -> QueryResult { + QueryResult::new(columns, rows.iter().map(RowSnapshot::to_values).collect()).truncated(truncated) } diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index f15e6ffae8..da45135f44 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -1,7 +1,10 @@ +use std::sync::Arc; + use tablepro_core::Value; +use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; -use super::{ColumnWidthBinding, GridRowId, RowObject}; +use super::{ColumnWidthBinding, GridRowId, RowObject, RowSnapshot}; use crate::services::cell_overlay::CellOverlay; use crate::services::change_tracker::{CellState, RowKey, RowState}; use crate::services::tab_tracker_handle::TabTrackerHandle; @@ -37,33 +40,72 @@ impl TabGridContext { Some((self.tracker.as_ref()?, row.id().tracker_key()?)) } - /// Every copy and export path reads this rather than - /// `RowObject::cells`, which holds a saved row as fetched and a - /// draft not at all. - pub(crate) fn effective_cells(&self, row: &RowObject) -> Vec { + /// `Clean` for a row nothing tracks. + pub(super) fn row_state(&self, row: &RowObject) -> RowState { + self.tracked_key(row) + .map_or(RowState::Clean, |(tracker, key)| tracker.row_state(&key)) + } + + /// The row as the grid shows it, for every copy and export path: + /// `RowObject::cells` holds a saved row as read and a draft not at + /// all. A draft's cell left to the server reads as NULL. + pub(crate) fn snapshot(&self, row: &RowObject) -> RowSnapshot { if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { - return tracker.draft_values(*draft_id).unwrap_or_default(); + return RowSnapshot::new(tracker.draft_values(*draft_id).unwrap_or_default().into(), Vec::new()); } - let Some(raw) = row.cells() else { - return Vec::new(); - }; - let Some((tracker, key)) = self.tracked_key(row) else { - return raw.to_vec(); - }; - raw.iter() - .enumerate() - .map(|(col, value)| tracker.cell_overlay(&key, col, value).value) + let cells = row.cells().unwrap_or_else(|| Arc::from([])); + let overrides = self + .tracked_key(row) + .map(|(tracker, key)| tracker.pending_values(&key, cells.len())) + .unwrap_or_default(); + RowSnapshot::new(cells, overrides) + } + + /// Like `snapshot`, except a draft's cell left to the server stays + /// `Default` rather than reading as NULL. + pub(crate) fn effective_inputs(&self, row: &RowObject) -> Vec { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return tracker.draft_inputs(*draft_id).unwrap_or_default(); + } + self.snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) .collect() } - pub(super) fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { - let raw = row.cell(col).cloned().unwrap_or(Value::Null); + /// What an editor opens on: a draft's cell as the tracker holds it, + /// a saved row's pending value, else the value as it was read. + pub(crate) fn edit_input(&self, row: &RowObject, col: usize) -> CellInput { + self.with_effective_value(row, col, |value, default| match default { + true => CellInput::Default, + false => CellInput::Value(value.clone()), + }) + } + + /// Calls `f` with the value the cell shows, borrowed from the row + /// unless the tracker holds a pending one. `default` is set for a + /// draft's cell left to the server, whose value then reads as NULL. + pub(crate) fn with_effective_value(&self, row: &RowObject, col: usize, f: impl FnOnce(&Value, bool) -> R) -> R { + let pending = self + .tracked_key(row) + .and_then(|(tracker, key)| tracker.cell_overlay(&key, col).pending); + match (pending, row.cell(col)) { + (Some(CellInput::Value(value)), _) => f(&value, false), + (Some(CellInput::Default), _) => f(&Value::Null, true), + (None, Some(value)) => f(value, false), + (None, None) => f(&Value::Null, false), + } + } + + /// The classes that tint the cell for its row's pending change, and + /// the cell's own state. + pub(super) fn pending_marks(&self, row: &RowObject, col: usize) -> (Vec<&'static str>, CellState) { let Some((tracker, key)) = self.tracked_key(row) else { - return (raw, Vec::new()); + return (Vec::new(), CellState::Clean); }; - let overlay = tracker.cell_overlay(&key, col, &raw); - let classes = pending_classes(tracker.row_state(&key), &overlay, col); - (overlay.value, classes) + let overlay = tracker.cell_overlay(&key, col); + (pending_classes(tracker.row_state(&key), &overlay, col), overlay.state) } } @@ -84,11 +126,11 @@ fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Ve #[cfg(test)] mod tests { use tablepro_core::Value; + use tablepro_core::edit::CellInput; use super::{RowObject, TabGridContext}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_delta::VisualChange; use crate::services::tracker_edit::TrackerEdit; fn context(tracker: &TabTrackerHandle) -> TabGridContext { @@ -99,8 +141,10 @@ mod tests { } fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { - let delta = tracker.apply(TrackerEdit::Insert { values }); - let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![values.into_iter().map(CellInput::Value).collect()], + }); + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); }; draft_id @@ -111,10 +155,15 @@ mod tests { let tracker = TabTrackerHandle::default(); let draft_id = insert_draft(&tracker, vec![Value::Int(4)]); let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); + let draft = RowObject::draft(draft_id); - let (value, classes) = context(&tracker).bind_state(&RowObject::draft(draft_id), 0); + let (classes, _) = context(&tracker).pending_marks(&draft, 0); - assert_eq!(value, Value::Int(4), "a draft's cells are read from the tracker"); + assert_eq!( + context(&tracker).edit_input(&draft, 0), + CellInput::Value(Value::Int(4)), + "a draft's cells are read from the tracker" + ); assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); } @@ -122,14 +171,37 @@ mod tests { fn an_untracked_grid_draws_the_fetched_value_plainly() { let row = RowObject::result(vec![Value::Int(1)].into()); - let (value, classes) = TabGridContext::default().bind_state(&row, 0); + let (classes, _) = TabGridContext::default().pending_marks(&row, 0); - assert_eq!(value, Value::Int(1)); + assert_eq!( + TabGridContext::default().edit_input(&row, 0), + CellInput::Value(Value::Int(1)) + ); assert!(classes.is_empty()); } #[test] - fn effective_cells_include_pending_edits_and_draft_cells() { + fn a_clean_cell_is_drawn_from_the_row_itself() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let row = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); + let cells = row.cells().expect("a saved row's cells"); + + let borrowed = context(&tracker).with_effective_value(&row, 0, |value, _| std::ptr::eq(value, &cells[0])); + let pending = context(&tracker).with_effective_value(&row, 1, |value, default| (value.clone(), default)); + + assert!(borrowed, "an unedited cell was copied out of its row"); + assert_eq!(pending, (Value::Text("new".into()), false)); + } + + #[test] + fn snapshots_include_pending_edits_and_draft_cells() { let tracker = TabTrackerHandle::default(); let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); let _ = tracker.apply(TrackerEdit::Cell { @@ -142,12 +214,29 @@ mod tests { let saved = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); assert_eq!( - context(&tracker).effective_cells(&saved), + context(&tracker).snapshot(&saved).to_values(), vec![Value::Int(1), Value::Text("new".into())] ); assert_eq!( - context(&tracker).effective_cells(&RowObject::draft(draft_id)), + context(&tracker).snapshot(&RowObject::draft(draft_id)).to_values(), vec![Value::Null, Value::Text("draft".into())] ); } + + #[test] + fn a_draft_cell_left_to_the_server_opens_as_the_default() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Default, CellInput::Value(Value::Null)]], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + + assert_eq!(context(&tracker).edit_input(&draft, 0), CellInput::Default); + assert_eq!(context(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Null)); + assert_eq!( + context(&tracker).snapshot(&draft).to_values(), + vec![Value::Null, Value::Null], + "a copy has no value to give a default" + ); + } } diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs new file mode 100644 index 0000000000..7633eb5bdd --- /dev/null +++ b/linux/crates/app/src/ui/grid/test_grid.rs @@ -0,0 +1,277 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; + +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, Value}; + +use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext, begin_edit, build_column_view}; +use crate::services::change_tracker::RowKey; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::TrackerDelta; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{descendants, drain_main_context, wait_until}; + +pub(super) const WAIT: Duration = Duration::from_secs(5); + +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +pub(super) fn col(data_type: &str, primary_key: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable: true, + primary_key, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +pub(super) fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { + let mut column = col(data_type, primary_key); + column.name = name.to_owned(); + column +} + +pub(super) fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +pub(super) fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +pub(super) fn tracked(tracker: &TabTrackerHandle) -> TabGridContext { + TabGridContext { + tracker: Some(tracker.clone()), + editable: true, + identity: keyed_by_id(), + column_widths: None, + } +} + +/// Persisted rows keyed by their first cell, as a browse page is. +pub(super) fn persisted(rows: &[Vec]) -> Vec { + rows.iter() + .map(|cells| { + let id = cells.first().cloned().unwrap_or(Value::Null); + RowObject::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) + }) + .collect() +} + +/// `rows` under the result columns of `columns`. +pub(super) fn content(columns: &[ColumnInfo], rows: Vec) -> GridContent { + GridContent { + columns: columns.iter().map(ColumnInfo::result_column).collect(), + rows, + truncated: false, + } +} + +pub(super) fn recorder() -> (GridSink, Rc>>) { + let messages = Rc::new(RefCell::new(Vec::new())); + let log = messages.clone(); + (Rc::new(move |msg| log.borrow_mut().push(msg)), messages) +} + +pub(super) struct Grid { + pub(super) window: gtk::Window, + pub(super) view: gtk::ColumnView, + pub(super) store: gio::ListStore, + pub(super) tracker: TabTrackerHandle, + messages: Rc>>, + width: usize, +} + +impl Grid { + /// `rows` keyed by their first cell, in a grid that edits them. + pub(super) fn open(columns: Vec, rows: Vec>) -> Self { + let tracker = TabTrackerHandle::default(); + let (sink, messages) = recorder(); + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + None, + false, + tracked(&tracker), + ); + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + let grid = Self { + window, + view, + store, + tracker, + messages, + width: columns.len(), + }; + wait_until(WAIT, || grid.cells().len() == columns.len() * rows.len()).expect("the cells are drawn"); + grid + } + + pub(super) fn cells(&self) -> Vec { + descendants(&self.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + } + + /// The cell that shows `column` of the row named `id`. + pub(super) fn cell_of(&self, id: i64, column: usize) -> GridCell { + self.cells() + .into_iter() + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))) + .expect("the row's cell is drawn") + } + + pub(super) fn insert_draft(&self) { + let delta = self.tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Value(Value::Null); self.width]], + }); + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { + panic!("an insert adds a draft row"); + }; + self.store.insert(0, &RowObject::draft(draft_id)); + drain_main_context(); + } + + pub(super) fn column(&self, index: u32) -> gtk::ColumnViewColumn { + self.view + .columns() + .item(index) + .and_downcast::() + .expect("a column") + } + + pub(super) fn focused_cell(&self) -> Option { + let focus = gtk::prelude::GtkWindowExt::focus(&self.window)?; + GridCell::from_focus(&focus) + } + + /// Focus the cell of the `column`th shown column at `position`, as + /// the arrow keys do. `scroll_to` moves the focus only within a + /// focused list, so the view's grab carries it there. The view shows + /// the columns in result order. + pub(super) fn focus(&self, position: u32, column: u32) -> GridCell { + self.view + .scroll_to(position, Some(&self.column(column)), gtk::ListScrollFlags::FOCUS, None); + self.view.grab_focus(); + let mut focused = None; + wait_until(WAIT, || { + focused = self + .focused_cell() + .filter(|cell| cell.position() == Some(position) && u32::try_from(cell.column()) == Ok(column)); + focused.is_some() + }) + .expect("the cell takes the focus"); + focused.expect("a focused cell") + } + + pub(super) fn take_messages(&self) -> Vec { + self.messages.take() + } + + /// What the tab does with a tracker change. + pub(super) fn redraw(&self, delta: &TrackerDelta) { + for position in 0..self.store.n_items() { + if let Some(row) = self.store.item(position).and_downcast::() + && row.id().tracker_key().is_some_and(|key| delta.changed.contains(&key)) + { + row.bump_revision(); + } + } + } +} + +impl Drop for Grid { + fn drop(&mut self) { + self.window.destroy(); + drain_main_context(); + } +} + +pub(super) fn two_text_rows() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![ + vec![Value::Int(1), Value::Text("A".into())], + vec![Value::Int(2), Value::Text("B".into())], + ], + ) +} + +/// Open the cell's one-line editor and type `text` over its value. +pub(super) fn type_into(cell: &GridCell, text: &str) -> gtk::Text { + begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open for editing"); + editor.set_text(text); + editor +} + +pub(super) fn display_text(cell: &GridCell) -> String { + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) + .unwrap_or_default() +} + +/// Each edit the grid sent, as the row, the column and the value. +pub(super) fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, CellInput)> { + messages + .iter() + .filter_map(|msg| match msg { + GridMsg::Edit { row, column, edit } => { + let input = match edit { + TrackerEdit::Cell { value, .. } => CellInput::Value(value.clone()), + TrackerEdit::DraftCell { input, .. } => input.clone(), + other => panic!("a cell commit made {other:?}"), + }; + Some((row.key().cloned(), *column, input)) + } + _ => None, + }) + .collect() +} + +pub(super) fn text(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) +} + +pub(super) fn flag_rows() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(false)], + vec![Value::Int(2), Value::Bool(false)], + ], + ) +} diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs new file mode 100644 index 0000000000..912a99c667 --- /dev/null +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -0,0 +1,150 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk}; +use tablepro_core::Value; +use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; +use tablepro_core::edit::CellInput; + +use super::display_text::display_text; +use super::edit_commit::{commit_input, send_commit}; +use super::{EditSnapshot, GridCell}; + +pub struct ToggleCell; + +/// How a check button shows a cell's value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToggleState { + pub active: bool, + /// A value that is neither true nor false shows as neither, with the + /// value itself in the tooltip, rather than as a confidently wrong + /// box. + pub inconsistent: bool, + pub tooltip: Option, +} + +impl ToggleCell { + pub(super) fn setup(cell: &GridCell) { + let check = gtk::CheckButton::builder() + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .margin_start(8) + .margin_end(8) + // The cell takes the focus, and toggles on F2, Return or Space, + // so Tab leaves the grid rather than stopping inside the cell. + .focusable(false) + .focus_on_click(false) + .build(); + cell.set_content(&check); + // The cell owns the check button, so the handler finds the cell + // through its parent rather than holding it. + check.connect_toggled(|check| { + let Some(cell) = check.parent().and_downcast::() else { + return; + }; + if !cell.toggle_suppressed() && cell.lock().is_none() { + commit_toggle(&cell, check.is_active()); + } + }); + } + + pub(super) fn draw(cell: &GridCell, check: >k::CheckButton, state: &ToggleState) { + // Suppressed, so drawing the value does not echo back as an edit. + cell.suppress_toggle(true); + check.set_inconsistent(state.inconsistent); + check.set_active(state.active); + cell.suppress_toggle(false); + check.set_tooltip_text(state.tooltip.as_deref()); + } +} + +/// `default` is a draft's cell left to the server. +pub fn toggle_state(value: &Value, default: bool, column: &ColumnType, storage: BoolStorage) -> ToggleState { + let neither = |tooltip: &str| ToggleState { + active: false, + inconsistent: true, + tooltip: Some(tooltip.to_owned()), + }; + let shown = |active| ToggleState { + active, + inconsistent: false, + tooltip: None, + }; + match (default, value, storage) { + (true, ..) => neither("DEFAULT"), + (false, Value::Bool(flag), _) => shown(*flag), + (false, Value::Int(0), BoolStorage::Integer) => shown(false), + (false, Value::Int(1), BoolStorage::Integer) => shown(true), + (false, other, _) => neither(display_text(other, column, &mut String::new()).text), + } +} + +/// `tinyint(1)` keeps any integer, so a toggle writes 0 or 1 as one. +pub fn toggled_value(storage: BoolStorage, active: bool) -> Value { + match storage { + BoolStorage::Native => Value::Bool(active), + BoolStorage::Integer => Value::Int(i64::from(active)), + } +} + +fn commit_toggle(cell: &GridCell, active: bool) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let ColumnKind::Bool(storage) = column.column_type.kind else { + return; + }; + let snapshot = EditSnapshot { + original: drawing.tab_ctx.edit_input(&row, cell.column()), + row, + column: cell.column(), + }; + let result = commit_input(column, &snapshot, CellInput::Value(toggled_value(storage, active))); + send_commit(cell, snapshot, result); +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::column::{BoolStorage, ColumnType}; + + use super::{ToggleState, toggle_state, toggled_value}; + + fn state(active: bool, inconsistent: bool, tooltip: Option<&str>) -> ToggleState { + ToggleState { + active, + inconsistent, + tooltip: tooltip.map(str::to_owned), + } + } + + #[test] + fn a_toggle_shows_only_true_and_false_as_checked_or_not() { + let column = ColumnType::untyped_text("boolean"); + let value = |value: Value, storage| toggle_state(&value, false, &column, storage); + + assert_eq!(value(Value::Bool(true), BoolStorage::Native), state(true, false, None)); + assert_eq!(value(Value::Int(1), BoolStorage::Integer), state(true, false, None)); + assert_eq!(value(Value::Int(0), BoolStorage::Integer), state(false, false, None)); + assert_eq!( + value(Value::Int(2), BoolStorage::Integer), + state(false, true, Some("2")) + ); + assert_eq!( + value(Value::Null, BoolStorage::Native), + state(false, true, Some("NULL")) + ); + assert_eq!( + toggle_state(&Value::Null, true, &column, BoolStorage::Integer), + state(false, true, Some("DEFAULT")) + ); + } + + #[test] + fn a_toggle_writes_the_columns_own_form() { + assert_eq!(toggled_value(BoolStorage::Native, true), Value::Bool(true)); + assert_eq!(toggled_value(BoolStorage::Integer, true), Value::Int(1)); + assert_eq!(toggled_value(BoolStorage::Integer, false), Value::Int(0)); + } +} diff --git a/linux/crates/app/src/ui/history_dialog.rs b/linux/crates/app/src/ui/history_dialog.rs index bf9f8ebfe1..b3861994bf 100644 --- a/linux/crates/app/src/ui/history_dialog.rs +++ b/linux/crates/app/src/ui/history_dialog.rs @@ -12,6 +12,8 @@ use tablepro_storage::query_history::{Entry, SearchFilter}; use uuid::Uuid; +use crate::runtime::DeliverInput; + pub struct HistoryDialog { history: Option, tasks: Tasks, @@ -189,7 +191,7 @@ impl Component for HistoryDialog { for combo in [&filter_connection, &filter_status, &filter_window] { let s = sender.clone(); - combo.connect_selected_notify(move |_| s.input(HistoryDialogInput::FiltersChanged)); + combo.connect_selected_notify(move |_| s.deliver(HistoryDialogInput::FiltersChanged)); } header.pack_start(&filter_button); @@ -200,7 +202,7 @@ impl Component for HistoryDialog { .build(); let s_select = sender.clone(); select_button.connect_toggled(move |btn| { - s_select.input(HistoryDialogInput::ToggleSelectMode(btn.is_active())); + s_select.deliver(HistoryDialogInput::ToggleSelectMode(btn.is_active())); }); header.pack_start(&select_button); @@ -227,23 +229,23 @@ impl Component for HistoryDialog { let action_group = gio::SimpleActionGroup::new(); let export_sql_sender = sender.clone(); let export_sql = gio::ActionEntry::builder("export-sql") - .activate(move |_, _, _| export_sql_sender.input(HistoryDialogInput::ExportSelectedSql)) + .activate(move |_, _, _| export_sql_sender.deliver(HistoryDialogInput::ExportSelectedSql)) .build(); let export_csv_sender = sender.clone(); let export_csv = gio::ActionEntry::builder("export-csv") - .activate(move |_, _, _| export_csv_sender.input(HistoryDialogInput::ExportSelectedCsv)) + .activate(move |_, _, _| export_csv_sender.deliver(HistoryDialogInput::ExportSelectedCsv)) .build(); let delete_sender = sender.clone(); let delete = gio::ActionEntry::builder("delete-selected") - .activate(move |_, _, _| delete_sender.input(HistoryDialogInput::DeleteSelected)) + .activate(move |_, _, _| delete_sender.deliver(HistoryDialogInput::DeleteSelected)) .build(); let clear_sender = sender.clone(); let clear_all = gio::ActionEntry::builder("clear-all") - .activate(move |_, _, _| clear_sender.input(HistoryDialogInput::ClearAllRequested)) + .activate(move |_, _, _| clear_sender.deliver(HistoryDialogInput::ClearAllRequested)) .build(); let storage_sender = sender.clone(); let show_storage = gio::ActionEntry::builder("show-storage") - .activate(move |_, _, _| storage_sender.input(HistoryDialogInput::OpenStorageLocation)) + .activate(move |_, _, _| storage_sender.deliver(HistoryDialogInput::OpenStorageLocation)) .build(); action_group.add_action_entries([export_sql, export_csv, delete, clear_all, show_storage]); @@ -252,7 +254,7 @@ impl Component for HistoryDialog { .hexpand(true) .build(); let s = sender.clone(); - search.connect_search_changed(move |_| s.input(HistoryDialogInput::SearchChanged)); + search.connect_search_changed(move |_| s.deliver(HistoryDialogInput::SearchChanged)); let search_bar = gtk::SearchBar::builder() .child(&search) @@ -351,7 +353,7 @@ impl Component for HistoryDialog { let delete_button = gtk::Button::builder().label(crate::i18n::gettext("Delete")).build(); delete_button.add_css_class("destructive-action"); let s_del_bar = sender.clone(); - delete_button.connect_clicked(move |_| s_del_bar.input(HistoryDialogInput::DeleteSelected)); + delete_button.connect_clicked(move |_| s_del_bar.deliver(HistoryDialogInput::DeleteSelected)); let selection_bar_inner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -403,7 +405,7 @@ impl Component for HistoryDialog { }; model.load_connections(&sender); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); ComponentParts { model, widgets: () } } @@ -447,7 +449,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history set_pinned failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::Delete(id) => { @@ -460,7 +462,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history delete failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::ToggleSelected(id, on) => { @@ -516,10 +518,9 @@ impl Component for HistoryDialog { dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let s = sender; - dialog.connect_response(None, move |d, response| { - d.close(); + dialog.connect_response(None, move |_, response| { if response == "delete" { - s.input(HistoryDialogInput::DeleteSelectedConfirmed); + s.deliver(HistoryDialogInput::DeleteSelectedConfirmed); } }); dialog.present(Some(&self.root)); @@ -538,7 +539,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history delete_many failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::ExportSelectedSql => self.export_selected("sql", sender), @@ -557,10 +558,9 @@ impl Component for HistoryDialog { dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let s = sender; - dialog.connect_response(None, move |d, response| { - d.close(); + dialog.connect_response(None, move |_, response| { if response == "clear" { - s.input(HistoryDialogInput::ClearAllConfirmed); + s.deliver(HistoryDialogInput::ClearAllConfirmed); } }); dialog.present(Some(&self.root)); @@ -617,7 +617,7 @@ impl Component for HistoryDialog { HistoryDialogCmd::Loaded(entries) => self.render_entries(entries, &sender), HistoryDialogCmd::Cleared => { self.selected_ids.clear(); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogCmd::ExportReady(content, suggested_name) => self.save_export(content, suggested_name), HistoryDialogCmd::ExportFailed(msg) => { @@ -643,7 +643,7 @@ impl HistoryDialog { &self.tasks, async move { history.known_connections().await }, move |listed| match listed { - Ok(Ok(connections)) => sender.input(HistoryDialogInput::ConnectionsLoaded(connections)), + Ok(Ok(connections)) => sender.deliver(HistoryDialogInput::ConnectionsLoaded(connections)), Ok(Err(error)) => tracing::warn!(%error, "could not list the connections in the history"), Err(failure) => tracing::warn!(%failure, "the history connections task failed"), }, @@ -655,7 +655,7 @@ impl HistoryDialog { fn run_search(&self, sender: ComponentSender) { let filter = self.build_filter(); let Some(history) = self.history.clone() else { - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); return; }; sender.command(move |out, shutdown| { @@ -685,7 +685,7 @@ impl HistoryDialog { let slot = self.filter_debounce.clone(); let id = gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { slot.borrow_mut().take(); - s.input(HistoryDialogInput::Refresh); + s.deliver(HistoryDialogInput::Refresh); }); *self.filter_debounce.borrow_mut() = Some(id); } @@ -828,7 +828,7 @@ impl HistoryDialog { let id_for_check = entry.id; let s_check = sender.clone(); select_check.connect_toggled(move |btn| { - s_check.input(HistoryDialogInput::ToggleSelected(id_for_check, btn.is_active())); + s_check.deliver(HistoryDialogInput::ToggleSelected(id_for_check, btn.is_active())); }); row.add_prefix(&select_check); @@ -847,7 +847,7 @@ impl HistoryDialog { } let id = entry.id; let s = sender.clone(); - pin_btn.connect_clicked(move |_| s.input(HistoryDialogInput::TogglePin(id))); + pin_btn.connect_clicked(move |_| s.deliver(HistoryDialogInput::TogglePin(id))); row.add_suffix(&pin_btn); let delete_btn = gtk::Button::builder() @@ -858,11 +858,11 @@ impl HistoryDialog { delete_btn.add_css_class("flat"); delete_btn.add_css_class("destructive-action"); let s_del = sender.clone(); - delete_btn.connect_clicked(move |_| s_del.input(HistoryDialogInput::Delete(id))); + delete_btn.connect_clicked(move |_| s_del.deliver(HistoryDialogInput::Delete(id))); row.add_suffix(&delete_btn); let s_act = sender.clone(); - row.connect_activated(move |_| s_act.input(HistoryDialogInput::Activate(id))); + row.connect_activated(move |_| s_act.deliver(HistoryDialogInput::Activate(id))); let menu = gio::Menu::new(); let nav_section = gio::Menu::new(); @@ -894,15 +894,15 @@ impl HistoryDialog { let row_actions = gio::SimpleActionGroup::new(); let s_open = sender.clone(); let act_open = gio::ActionEntry::builder("open") - .activate(move |_, _, _| s_open.input(HistoryDialogInput::Activate(id))) + .activate(move |_, _, _| s_open.deliver(HistoryDialogInput::Activate(id))) .build(); let s_replace = sender.clone(); let act_replace = gio::ActionEntry::builder("replace") - .activate(move |_, _, _| s_replace.input(HistoryDialogInput::ReplaceCurrent(id))) + .activate(move |_, _, _| s_replace.deliver(HistoryDialogInput::ReplaceCurrent(id))) .build(); let s_pin = sender.clone(); let act_pin = gio::ActionEntry::builder("pin") - .activate(move |_, _, _| s_pin.input(HistoryDialogInput::TogglePin(id))) + .activate(move |_, _, _| s_pin.deliver(HistoryDialogInput::TogglePin(id))) .build(); let query_for_copy = entry.query.clone(); let row_for_copy = row.clone(); @@ -913,7 +913,7 @@ impl HistoryDialog { .build(); let s_del2 = sender.clone(); let act_del = gio::ActionEntry::builder("delete") - .activate(move |_, _, _| s_del2.input(HistoryDialogInput::Delete(id))) + .activate(move |_, _, _| s_del2.deliver(HistoryDialogInput::Delete(id))) .build(); row_actions.add_action_entries([act_open, act_replace, act_pin, act_copy, act_del]); row.insert_action_group("history-row", Some(&row_actions)); @@ -1013,10 +1013,11 @@ impl HistoryDialog { let parent_window = self.root.root().and_then(|r| r.downcast::().ok()); dialog.save(parent_window.as_ref(), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; - let Some(path) = file.path() else { return }; - if let Err(e) = std::fs::write(&path, content.as_bytes()) { - tracing::warn!(error = %e, path = %path.display(), "history export write failed"); - } + glib::spawn_future_local(async move { + if let Err(error) = crate::ui::file_write::replace_file(&file, content.into_bytes()).await { + tracing::warn!(%error, path = %file.parse_name(), "history export write failed"); + } + }); }); } diff --git a/linux/crates/app/src/ui/host_key_failure.rs b/linux/crates/app/src/ui/host_key_failure.rs new file mode 100644 index 0000000000..dbad5a5fa3 --- /dev/null +++ b/linux/crates/app/src/ui/host_key_failure.rs @@ -0,0 +1,152 @@ +//! A host key ssh refused, in the terms the review dialog shows it. + +use std::path::PathBuf; + +use tablepro_core::{DriverError, SshFailure, TransportError}; +use tablepro_session::ConnectError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyFailure { + /// The name ssh looks the host up by: `host`, `[host]:port` or an + /// alias, as ssh printed it. + pub host: String, + pub algorithm: String, + /// Empty when ssh refused without showing the key. + pub fingerprint: String, + pub kind: HostKeyFailureKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum HostKeyFailureKind { + /// `trust_allowed` is false when the user's ssh configuration takes + /// only keys already in known_hosts, so ssh never asks. + Unknown { + trust_allowed: bool, + }, + /// The old key is at `line` of `known_hosts`. + Changed { + known_hosts: PathBuf, + line: usize, + }, + Revoked { + detail: String, + }, +} + +impl HostKeyFailure { + pub(crate) fn from_connect_error(error: &ConnectError) -> Option { + let ConnectError::Driver(DriverError::Transport(TransportError::Ssh(failure))) = error else { + return None; + }; + Self::from_ssh_failure(failure) + } + + fn from_ssh_failure(failure: &SshFailure) -> Option { + match failure { + SshFailure::HostKeyUnknown { + host, + algorithm, + sha256_fingerprint, + trust_allowed, + .. + } => Some(Self { + host: host.clone(), + algorithm: algorithm.clone(), + fingerprint: sha256_fingerprint.clone(), + kind: HostKeyFailureKind::Unknown { + trust_allowed: *trust_allowed, + }, + }), + SshFailure::HostKeyChanged { + host, + algorithm, + sha256_fingerprint, + known_hosts, + line, + } => Some(Self { + host: host.clone(), + algorithm: algorithm.clone(), + fingerprint: sha256_fingerprint.clone(), + kind: HostKeyFailureKind::Changed { + known_hosts: known_hosts.clone(), + line: *line, + }, + }), + SshFailure::HostKeyRevoked { host, detail } => Some(Self { + host: host.clone(), + algorithm: String::new(), + fingerprint: String::new(), + kind: HostKeyFailureKind::Revoked { detail: detail.clone() }, + }), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::LossPhase; + + use super::*; + + fn ssh(failure: SshFailure) -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh(failure))) + } + + #[test] + fn each_host_key_refusal_becomes_a_failure_to_review() { + let unknown = ssh(SshFailure::HostKeyUnknown { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new".to_owned(), + trust_allowed: true, + declined: true, + }); + let changed = ssh(SshFailure::HostKeyChanged { + host: "[db.example.com]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new".to_owned(), + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + }); + let revoked = ssh(SshFailure::HostKeyRevoked { + host: "bastion".to_owned(), + detail: "revoked by file /etc/ssh/krl".to_owned(), + }); + + assert_eq!( + HostKeyFailure::from_connect_error(&unknown).map(|failure| failure.kind), + Some(HostKeyFailureKind::Unknown { trust_allowed: true }) + ); + let changed = HostKeyFailure::from_connect_error(&changed).unwrap(); + assert_eq!(changed.host, "[db.example.com]:2222"); + assert_eq!(changed.fingerprint, "SHA256:new"); + assert_eq!( + changed.kind, + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3 + } + ); + assert!(matches!( + HostKeyFailure::from_connect_error(&revoked).map(|failure| failure.kind), + Some(HostKeyFailureKind::Revoked { .. }) + )); + } + + #[test] + fn other_failures_have_no_key_to_review() { + let errors = [ + ssh(SshFailure::InteractionCancelled), + ConnectError::Driver(DriverError::auth(None)), + ConnectError::Driver(DriverError::ConnectionLost { + during: LossPhase::Connect, + }), + ConnectError::Cancelled, + ]; + + for error in errors { + assert_eq!(HostKeyFailure::from_connect_error(&error), None, "{error:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/host_key_review.rs b/linux/crates/app/src/ui/host_key_review.rs new file mode 100644 index 0000000000..c811917835 --- /dev/null +++ b/linux/crates/app/src/ui/host_key_review.rs @@ -0,0 +1,308 @@ +//! The dialog that shows a refused SSH host key and asks what to do. + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::host_key_failure::{HostKeyFailure, HostKeyFailureKind}; + +const CANCEL: &str = "cancel"; +const CLOSE: &str = "close"; +const TRUST: &str = "trust"; +const REPLACE: &str = "replace"; +const RETRY: &str = "retry"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostKeyReviewOutcome { + /// Connect, trusting the key shown. + Trust, + /// Remove the old key from known_hosts, then connect trusting the + /// new one. + Replace, + /// Connect again answering nothing, for a key the user fixed outside + /// TablePro. + Retry, + Dismissed, +} + +/// What the dialog says and offers, worked out before any widget exists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyDialogSpec { + pub heading: String, + pub body: String, + pub responses: Vec, + pub default: &'static str, + pub close: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyResponse { + pub id: &'static str, + pub label: String, + pub appearance: adw::ResponseAppearance, +} + +impl HostKeyResponse { + fn new(id: &'static str, label: String, appearance: adw::ResponseAppearance) -> Self { + Self { id, label, appearance } + } + + fn cancel() -> Self { + Self::new(CANCEL, crate::i18n::gettext("Cancel"), adw::ResponseAppearance::Default) + } + + fn close() -> Self { + Self::new(CLOSE, crate::i18n::gettext("Close"), adw::ResponseAppearance::Default) + } + + fn retry() -> Self { + Self::new(RETRY, crate::i18n::gettext("Retry"), adw::ResponseAppearance::Default) + } +} + +/// Cancel is the default wherever there is something to accept, so +/// Enter never trusts a key or deletes one. +pub(crate) fn host_key_dialog_spec(failure: &HostKeyFailure) -> HostKeyDialogSpec { + let host = failure.host.as_str(); + match &failure.kind { + HostKeyFailureKind::Unknown { trust_allowed: true } => { + let (body, accept) = match failure.fingerprint.is_empty() { + false => ( + crate::i18n::gettext_f( + "TablePro has not connected to “{host}” before and cannot confirm its identity. Compare the fingerprint with the one from your server administrator.", + &[("host", host)], + ), + crate::i18n::gettext("Trust and Connect"), + ), + true => ( + crate::i18n::gettext_f( + "TablePro has not connected to “{host}” before. Connect to see its key’s fingerprint and decide whether to trust it.", + &[("host", host)], + ), + crate::i18n::gettext("Connect"), + ), + }; + HostKeyDialogSpec { + heading: crate::i18n::gettext("Unknown SSH Host"), + body, + responses: vec![ + HostKeyResponse::cancel(), + HostKeyResponse::new(TRUST, accept, adw::ResponseAppearance::Suggested), + ], + default: CANCEL, + close: CANCEL, + } + } + HostKeyFailureKind::Unknown { trust_allowed: false } => fixed_elsewhere( + crate::i18n::gettext("Unknown SSH Host"), + crate::i18n::gettext_f( + "The SSH configuration for “{host}” accepts only keys already in known_hosts. Add the server’s key there, then retry.", + &[("host", host)], + ), + ), + // Without the file ssh named, there is no key to replace. + HostKeyFailureKind::Changed { known_hosts, .. } if known_hosts.as_os_str().is_empty() => fixed_elsewhere( + crate::i18n::gettext("SSH Host Key Changed"), + crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in known_hosts. Someone may be intercepting the connection, or the server was reinstalled. If your server administrator confirms the new fingerprint, remove the old key from known_hosts, then retry.", + &[("host", host)], + ), + ), + HostKeyFailureKind::Changed { known_hosts, line } => HostKeyDialogSpec { + heading: crate::i18n::gettext("SSH Host Key Changed"), + body: crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in {file}, line {line}. Someone may be intercepting the connection, or the server was reinstalled. Replace the key only if your server administrator confirms the new fingerprint.", + &[ + ("host", host), + ("file", &known_hosts.display().to_string()), + ("line", &line.to_string()), + ], + ), + responses: vec![ + HostKeyResponse::cancel(), + HostKeyResponse::new( + REPLACE, + crate::i18n::gettext("Replace Key and Connect"), + adw::ResponseAppearance::Destructive, + ), + ], + default: CANCEL, + close: CANCEL, + }, + HostKeyFailureKind::Revoked { detail } => fixed_elsewhere( + crate::i18n::gettext("SSH Host Key Revoked"), + crate::i18n::gettext_f( + "The key “{host}” sent is revoked, so TablePro will not connect to it. Ask your server administrator for its current key. ssh reported: {detail}", + &[("host", host), ("detail", detail)], + ), + ), + } +} + +/// Only a change outside TablePro clears these, so the dialog offers the +/// retry the banner would have. +fn fixed_elsewhere(heading: String, body: String) -> HostKeyDialogSpec { + HostKeyDialogSpec { + heading, + body, + responses: vec![HostKeyResponse::close(), HostKeyResponse::retry()], + default: CLOSE, + close: CLOSE, + } +} + +/// Show the key over `parent` and wait for the answer. Closing the +/// dialog any other way dismisses it. +pub(crate) async fn review_host_key(parent: >k::Widget, failure: &HostKeyFailure) -> HostKeyReviewOutcome { + let dialog = build_dialog(failure); + let response = crate::workspace::dialog_response::respond(&dialog, parent).await; + match response.as_str() { + TRUST => HostKeyReviewOutcome::Trust, + REPLACE => HostKeyReviewOutcome::Replace, + RETRY => HostKeyReviewOutcome::Retry, + _ => HostKeyReviewOutcome::Dismissed, + } +} + +fn build_dialog(failure: &HostKeyFailure) -> adw::AlertDialog { + let spec = host_key_dialog_spec(failure); + let dialog = adw::AlertDialog::new(Some(&spec.heading), Some(&spec.body)); + for response in &spec.responses { + dialog.add_response(response.id, &response.label); + dialog.set_response_appearance(response.id, response.appearance); + } + dialog.set_default_response(Some(spec.default)); + dialog.set_close_response(spec.close); + if !failure.fingerprint.is_empty() { + dialog.set_extra_child(Some(&fingerprint_box(failure))); + } + dialog +} + +/// Selectable, so the fingerprint can be copied and compared. +fn fingerprint_box(failure: &HostKeyFailure) -> gtk::Box { + let column = gtk::Box::new(gtk::Orientation::Vertical, 6); + let caption = match failure.algorithm.is_empty() { + true => crate::i18n::gettext("Key fingerprint"), + false => crate::i18n::gettext_f("{algorithm} key fingerprint", &[("algorithm", &failure.algorithm)]), + }; + let caption = gtk::Label::new(Some(&caption)); + caption.add_css_class("dimmed"); + caption.add_css_class("caption"); + let fingerprint = gtk::Label::new(Some(&failure.fingerprint)); + fingerprint.add_css_class("monospace"); + fingerprint.set_selectable(true); + fingerprint.set_wrap(true); + fingerprint.set_wrap_mode(gtk::pango::WrapMode::Char); + fingerprint.update_relation(&[gtk::accessible::Relation::LabelledBy(&[caption.upcast_ref()])]); + column.append(&caption); + column.append(&fingerprint); + column +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use gtk4::glib; + + use super::*; + use crate::test_support::wait_until; + use crate::workspace::dialog_response::visible_alert; + + fn failure(kind: HostKeyFailureKind, fingerprint: &str) -> HostKeyFailure { + HostKeyFailure { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: fingerprint.to_owned(), + kind, + } + } + + fn ids(spec: &HostKeyDialogSpec) -> Vec<&'static str> { + spec.responses.iter().map(|response| response.id).collect() + } + + #[test] + fn host_key_dialog_spec_matrix() { + let unknown = host_key_dialog_spec(&failure( + HostKeyFailureKind::Unknown { trust_allowed: true }, + "SHA256:new", + )); + let unseen = host_key_dialog_spec(&failure(HostKeyFailureKind::Unknown { trust_allowed: true }, "")); + let strict = host_key_dialog_spec(&failure(HostKeyFailureKind::Unknown { trust_allowed: false }, "")); + let changed = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + }, + "SHA256:new", + )); + let revoked = host_key_dialog_spec(&failure( + HostKeyFailureKind::Revoked { + detail: "revoked by file /etc/ssh/krl".to_owned(), + }, + "", + )); + let unplaced = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::new(), + line: 0, + }, + "SHA256:new", + )); + + assert_eq!(ids(&unknown), [CANCEL, TRUST]); + assert_eq!(ids(&unseen), [CANCEL, TRUST]); + assert_eq!(unknown.responses[1].appearance, adw::ResponseAppearance::Suggested); + assert_eq!(ids(&strict), [CLOSE, RETRY]); + assert_eq!(ids(&changed), [CANCEL, REPLACE]); + assert_eq!(changed.responses[1].appearance, adw::ResponseAppearance::Destructive); + assert!(changed.body.contains("/home/u/.ssh/known_hosts"), "{}", changed.body); + assert!(changed.body.contains('3'), "{}", changed.body); + assert_eq!(ids(&revoked), [CLOSE, RETRY]); + assert!(revoked.body.contains("/etc/ssh/krl"), "{}", revoked.body); + assert_eq!(ids(&unplaced), [CLOSE, RETRY]); + assert!(!unplaced.body.contains(", line 0"), "{}", unplaced.body); + for spec in [&unknown, &unseen, &strict, &changed, &revoked, &unplaced] { + assert!(spec.body.contains("bastion"), "{}", spec.body); + assert_eq!(spec.default, spec.close, "{spec:?}"); + let default = spec.responses.iter().find(|response| response.id == spec.default); + assert_eq!( + default.map(|response| response.appearance), + Some(adw::ResponseAppearance::Default), + "{spec:?}" + ); + } + } + + #[gtk4::test] + fn review_dialog_built_from_spec() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let failure = failure(HostKeyFailureKind::Unknown { trust_allowed: true }, "SHA256:new"); + let outcome = std::rc::Rc::new(std::cell::Cell::new(None)); + let sink = outcome.clone(); + let parent = window.clone().upcast::(); + glib::spawn_future_local(async move { + sink.set(Some(review_host_key(&parent, &failure).await)); + }); + wait_until(Duration::from_secs(10), || visible_alert(&window).is_some()).expect("the dialog"); + + let dialog = visible_alert(&window).unwrap(); + let fingerprint = crate::test_support::descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.label() == "SHA256:new") + .expect("the fingerprint label"); + assert!(fingerprint.is_selectable()); + assert!(fingerprint.has_css_class("monospace")); + dialog.force_close(); + + wait_until(Duration::from_secs(10), || outcome.get().is_some()).expect("an outcome"); + assert_eq!(outcome.get(), Some(HostKeyReviewOutcome::Dismissed)); + } +} diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 32b9bdfa5f..3665033c59 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -14,6 +14,7 @@ pub const GO_LAST: &str = "go-last-symbolic"; pub const GO_NEXT: &str = "go-next-symbolic"; pub const GO_PREVIOUS: &str = "go-previous-symbolic"; pub const LIST_ADD: &str = "list-add-symbolic"; +pub const LIST_REMOVE: &str = "list-remove-symbolic"; pub const MEDIA_PLAYBACK_STOP: &str = "media-playback-stop-symbolic"; pub const NETWORK_SERVER: &str = "network-server-symbolic"; pub const OPEN_MENU: &str = "open-menu-symbolic"; @@ -59,6 +60,7 @@ pub const ALL: &[&str] = &[ GO_NEXT, GO_PREVIOUS, LIST_ADD, + LIST_REMOVE, MEDIA_PLAYBACK_STOP, NETWORK_SERVER, OPEN_MENU, diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs index 191d1907b4..b40e012f5c 100644 --- a/linux/crates/app/src/ui/import_dialog.rs +++ b/linux/crates/app/src/ui/import_dialog.rs @@ -9,6 +9,8 @@ use tablepro_core::ColumnInfo; use tablepro_core::export::CsvDelimiter; use tablepro_core::import::{CsvImportOptions, CsvSheet, MAX_PREVIEW_ROWS, read_csv, suggest_mapping}; +use crate::runtime::DeliverInput; + /// The most a CSV may be before the import refuses it. /// /// Every row becomes an INSERT in one transaction, so the file's size @@ -107,7 +109,7 @@ impl Component for ImportCsvDialog { 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.input(ImportCsvDialogInput::Import)); + import_button.connect_clicked(move |_| sender_for_import.deliver(ImportCsvDialogInput::Import)); header.pack_end(&import_button); toolbar.add_top_bar(&header); @@ -158,11 +160,11 @@ impl Component for ImportCsvDialog { root.set_child(Some(&toolbar)); let sender_for_header = sender.clone(); - header_row.connect_active_notify(move |_| sender_for_header.input(ImportCsvDialogInput::Reread)); + 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.input(ImportCsvDialogInput::Reread)); + 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.input(ImportCsvDialogInput::MappingChanged)); + null_row.connect_changed(move |_| sender_for_null.deliver(ImportCsvDialogInput::MappingChanged)); let mut model = ImportCsvDialog { root: root.clone(), @@ -269,7 +271,7 @@ impl ImportCsvDialog { .build(); row.set_selected(field.map_or(0, |index| index as u32 + 1)); let sender = sender.clone(); - row.connect_selected_notify(move |_| sender.input(ImportCsvDialogInput::MappingChanged)); + row.connect_selected_notify(move |_| sender.deliver(ImportCsvDialogInput::MappingChanged)); self.mapping_group.add(&row); self.mapping_rows.borrow_mut().push(row); } diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 93d077658f..11c46e6ed7 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -1,31 +1,43 @@ -mod app; mod browse_tab; mod connect_dialog; mod connection_list; mod connection_list_banner; mod connection_row; pub(crate) mod credential_prompt; +mod delete_connection_dialog; mod editor; mod editor_font_provider; pub(crate) mod error_text; mod export_dialog; mod file_read; +mod file_write; mod filter_strip; mod grid; mod handler_guard; mod history_dialog; +mod host_key_failure; +mod host_key_review; pub(crate) mod icons; mod import_dialog; mod preferences_dialog; mod saved_queries_dialog; +mod session_window; mod sidebar_row; mod ssh_inputs; mod ssh_section; mod structure_tab; -mod structure_tab_dialogs; mod style_scheme_grid; mod style_scheme_resolver; mod welcome_view; mod window_geometry; +mod workspace; -pub use app::{App, AppInit}; +#[cfg(test)] +pub(crate) use browse_tab::BrowseTab; +#[cfg(test)] +pub(crate) use editor::{SqlEditor, SqlEditorInit}; +pub(crate) use preferences_dialog::PreferencesDialog; +#[cfg(test)] +pub(crate) use session_window::install_window_shortcuts; +pub(crate) use session_window::{SessionWindow, SessionWindowInit, SessionWindowInput}; +pub(crate) use structure_tab::review_dialog; diff --git a/linux/crates/app/src/ui/preferences_dialog/imp.rs b/linux/crates/app/src/ui/preferences_dialog/imp.rs index e4e925c46a..15011d0935 100644 --- a/linux/crates/app/src/ui/preferences_dialog/imp.rs +++ b/linux/crates/app/src/ui/preferences_dialog/imp.rs @@ -10,11 +10,15 @@ use tablepro_storage::AppSettings; #[derive(Default, gtk4::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/preferences-dialog.ui")] pub struct PreferencesDialog { + #[template_child] + pub restore_session_row: TemplateChild, #[template_child] pub page_size_row: TemplateChild, #[template_child] pub confirm_row: TemplateChild, #[template_child] + pub save_history_row: TemplateChild, + #[template_child] pub retention_row: TemplateChild, #[template_child] pub clear_row: TemplateChild, diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index e3407bcbca..51450a1178 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -40,12 +40,19 @@ impl PreferencesDialog { let imp = self.imp(); let gio_settings = settings.gio(); + gio_settings + .bind(keys::RESTORE_SESSION, &imp.restore_session_row.get(), "active") + .build(); gio_settings .bind(keys::DEFAULT_PAGE_SIZE, &imp.page_size_row.get(), "value") .build(); gio_settings .bind(keys::CONFIRM_DESTRUCTIVE, &imp.confirm_row.get(), "active") .build(); + // Off stops new records only; Clear is what removes the old ones. + gio_settings + .bind(keys::SAVE_QUERY_HISTORY, &imp.save_history_row.get(), "active") + .build(); gio_settings .bind(keys::HISTORY_RETENTION_DAYS, &imp.retention_row.get(), "value") .build(); @@ -232,6 +239,38 @@ mod tests { assert!(settings.get().confirm_destructive()); } + #[gtk4::test] + fn the_restore_session_row_is_bound() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); + let row = dialog.imp().restore_session_row.get(); + assert!(row.is_active(), "the row does not show the default"); + + row.set_active(false); + assert!(!settings.get().restore_session()); + + settings.get().set_restore_session(true).expect("store the flag"); + assert!(row.is_active()); + } + + #[gtk4::test] + fn preferences_history_switch_bound_to_setting() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); + let row = dialog.imp().save_history_row.get(); + assert!(row.is_active(), "the row does not show the default"); + + row.set_active(false); + assert!(!settings.get().save_query_history()); + + settings.get().set_save_query_history(true).expect("store the flag"); + assert!(row.is_active()); + } + #[gtk4::test] fn the_font_row_is_insensitive_while_the_system_font_is_on() { let settings = MemorySettings::new(); @@ -392,7 +431,7 @@ mod tests { crate::test_support::drain_main_context(); let alert = crate::test_support::first_descendant_of_type::(window).expect("the confirmation alert"); - alert.emit_by_name::<()>("response", &[&"clear"]); + crate::test_support::respond(&alert, "clear"); crate::test_support::drain_main_context(); } } diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index 7cf2058f7a..ec09fc1ae3 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -7,6 +7,8 @@ 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. /// @@ -94,7 +96,7 @@ impl Component for SavedQueriesDialog { search_bar.append(&search); toolbar.add_top_bar(&search_bar); let sender_for_search = sender.clone(); - search.connect_search_changed(move |_| sender_for_search.input(SavedQueriesDialogInput::SearchChanged)); + search.connect_search_changed(move |_| sender_for_search.deliver(SavedQueriesDialogInput::SearchChanged)); let groups = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -136,7 +138,7 @@ impl Component for SavedQueriesDialog { status_page, queries: Vec::new(), }; - sender.input(SavedQueriesDialogInput::Refresh); + sender.deliver(SavedQueriesDialogInput::Refresh); ComponentParts { model, widgets: () } } @@ -202,10 +204,10 @@ impl Component for SavedQueriesDialog { self.queries = queries; self.render(&sender); } - SavedQueriesDialogCmd::Changed => sender.input(SavedQueriesDialogInput::Refresh), + SavedQueriesDialogCmd::Changed => sender.deliver(SavedQueriesDialogInput::Refresh), SavedQueriesDialogCmd::Failed(message) => { let _ = sender.output(SavedQueriesDialogOutput::ShowToast(message)); - sender.input(SavedQueriesDialogInput::Refresh); + sender.deliver(SavedQueriesDialogInput::Refresh); } } } @@ -290,7 +292,7 @@ impl SavedQueriesDialog { let id = saved.id; let sender_for_activate = sender.clone(); - row.connect_activated(move |_| sender_for_activate.input(SavedQueriesDialogInput::Activate(id))); + row.connect_activated(move |_| sender_for_activate.deliver(SavedQueriesDialogInput::Activate(id))); let menu = gio::Menu::new(); let open_section = gio::Menu::new(); @@ -311,7 +313,7 @@ impl SavedQueriesDialog { let actions = gio::SimpleActionGroup::new(); let entry = |name: &str, message: fn(i64) -> SavedQueriesDialogInput, sender: ComponentSender| { gio::ActionEntry::::builder(name) - .activate(move |_, _, _| sender.input(message(id))) + .activate(move |_, _, _| sender.deliver(message(id))) .build() }; actions.add_action_entries([ @@ -352,8 +354,7 @@ impl SavedQueriesDialog { dialog.set_close_response("cancel"); let sender = sender.clone(); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); + dialog.connect_response(None, move |_, response| { if response != "rename" { return; } @@ -361,7 +362,7 @@ impl SavedQueriesDialog { if name.trim().is_empty() { return; } - sender.input(SavedQueriesDialogInput::Rename(id, name)); + sender.deliver(SavedQueriesDialogInput::Rename(id, name)); }); dialog.present(Some(&self.root)); } @@ -383,10 +384,9 @@ impl SavedQueriesDialog { dialog.set_close_response("cancel"); let sender = sender.clone(); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); + dialog.connect_response(None, move |_, response| { if response == "delete" { - sender.input(SavedQueriesDialogInput::Delete(id)); + sender.deliver(SavedQueriesDialogInput::Delete(id)); } }); dialog.present(Some(&self.root)); diff --git a/linux/crates/app/src/ui/session_window/connect_failed_page.rs b/linux/crates/app/src/ui/session_window/connect_failed_page.rs new file mode 100644 index 0000000000..bbfd601a5d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -0,0 +1,87 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::credentials::SecretKind; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connect-failed-page.ui")] + pub struct ConnectFailedPage { + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub reenter_button: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] + pub edit_button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectFailedPage { + const NAME: &'static str = "TableProConnectFailedPage"; + type Type = super::ConnectFailedPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectFailedPage {} + impl WidgetImpl for ConnectFailedPage {} + impl BinImpl for ConnectFailedPage {} +} + +glib::wrapper! { + /// A connect that failed: why, and Enter Password, Retry, Edit + /// Connection and Choose Connection as the ways on. + pub struct ConnectFailedPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectFailedPage { + /// `edit` labels the button that opens the connect dialog, and is + /// `None` for a connection the dialog cannot edit, such as a file + /// nobody saved. `reenter` is the secret the server refused, which + /// the user is offered to type again. + pub fn new(message: &str, edit: Option<&str>, reenter: Option) -> Self { + let page: Self = glib::Object::new(); + let imp = page.imp(); + imp.status_page.set_description(Some(message)); + if let Some(label) = edit { + imp.edit_button.set_label(label); + imp.edit_button.set_visible(true); + } + if let Some(kind) = reenter { + imp.reenter_button.set_label(&super::secret_retry::reenter_label(kind)); + imp.reenter_button + .set_action_target_value(Some(&kind.attribute().to_variant())); + imp.reenter_button.set_visible(true); + imp.retry_button.remove_css_class("suggested-action"); + } + page + } + + #[cfg(test)] + pub fn message(&self) -> Option { + self.imp().status_page.description().map(|text| text.to_string()) + } + + /// The button that types a refused secret again, while it shows. + #[cfg(test)] + pub fn reenter_button(&self) -> Option { + let button = self.imp().reenter_button.get(); + button.is_visible().then_some(button) + } +} diff --git a/linux/crates/app/src/ui/app/connect_flow.rs b/linux/crates/app/src/ui/session_window/connect_flow.rs similarity index 66% rename from linux/crates/app/src/ui/app/connect_flow.rs rename to linux/crates/app/src/ui/session_window/connect_flow.rs index 6701894e9d..7bd80d56b5 100644 --- a/linux/crates/app/src/ui/app/connect_flow.rs +++ b/linux/crates/app/src/ui/session_window/connect_flow.rs @@ -8,8 +8,9 @@ use tablepro_core::credentials::{CredentialInteraction, SecretError, SecretKind, use tablepro_session::connect::ConnectionWrites; use tablepro_session::{ ConnectError, ConnectRequest, Connector, CredentialSource, PendingSave, PendingSession, PersistReport, + RefreshedRequest, }; -use tablepro_storage::{CredentialDemand, KeyringCredentials, SavedConnection, StorageError}; +use tablepro_storage::{CredentialDemand, SavedConnection, StorageError}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -70,35 +71,66 @@ async fn unsave(secrets: &dyn SecretVault, writes: &dyn ConnectionWrites, id: Uu } } +/// A saved connection that connected, and what became of the secrets +/// typed for it. +#[derive(Debug)] +pub(super) struct SavedLogin { + pub pending: PendingSession, + /// Secrets the user asked to remember that the keyring refused. + pub refused: Vec<(SecretKind, SecretError)>, + /// Secrets typed for this login that the keyring does not hold, + /// which the session's reconnects keep using. + pub session_only: Vec, +} + /// A secret typed into a prompt is kept only once the login it was -/// typed for has worked. +/// typed for has worked. `demand` names a secret the server refused, +/// which is asked for again rather than read from the keyring. pub(super) async fn connect_saved( saved: &SavedConnection, drivers: &DriverRegistry, - credentials: &KeyringCredentials, + credentials: &dyn CredentialSource, connector: &dyn Connector, interaction: CredentialInteraction, + demand: CredentialDemand, cancel: CancellationToken, -) -> Result<(PendingSession, Vec<(SecretKind, SecretError)>), ConnectError> { +) -> Result { // Dropping the resolve on a cancel drops its prompt too, which is // what takes a password dialog still on screen down with it. - let resolving = tablepro_session::resolve_saved( - saved, - drivers, - credentials, - interaction.clone(), - CredentialDemand::default(), - ); - let refreshed = cancel + let resolving = tablepro_session::resolve_saved(saved, drivers, credentials, interaction.clone(), demand); + let RefreshedRequest { + request, + remember, + typed, + } = cancel .run_until_cancelled(resolving) .await .ok_or(ConnectError::Cancelled)??; - let pending = connector - .establish(refreshed.request, interaction, cancel.clone()) - .await?; + let pending = connector.establish(request, interaction, cancel.clone()).await?; let pending = unless_cancelled(pending, &cancel).await?; - let refused = credentials.remember(refreshed.remember).await; - Ok((pending, refused)) + let kept: Vec = remember.iter().map(|save| save.kind).collect(); + let refused = credentials.remember(remember).await; + let session_only = typed + .into_iter() + .filter(|kind| !kept.contains(kind) || refused.iter().any(|(failed, _)| failed == kind)) + .collect(); + Ok(SavedLogin { + pending, + refused, + session_only, + }) +} + +/// Connect to a request that carries every secret it needs, such as a +/// file nobody saved. +pub(super) async fn connect_request( + connector: &dyn Connector, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, +) -> Result { + let pending = connector.establish(request, interaction, cancel.clone()).await?; + unless_cancelled(pending, &cancel).await } /// A driver blocked in a C call can still connect after the user @@ -126,7 +158,7 @@ mod tests { use tablepro_core::{AuthMode, TlsConfig}; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::fakes::{FakeConnector, FakeEstablish}; - use tablepro_storage::{ConnectionStore, StoragePaths}; + use tablepro_storage::{ConnectionStore, KeyringCredentials, StoragePaths}; use tablepro_test_support::{ConnectionScript, FakeDriver, FakeSecretVault}; use super::*; @@ -353,6 +385,7 @@ mod tests { &credentials, &connector, CredentialInteraction::Attended(prompter), + CredentialDemand::default(), cancel.clone(), ); let asking = async { @@ -369,6 +402,148 @@ mod tests { assert!(connector.calls().is_empty(), "a cancelled attempt still connected"); } + #[tokio::test(start_paused = true)] + async fn a_refused_password_is_asked_for_again_and_kept_once_it_works() { + use tablepro_core::credentials::{PromptPurpose, PromptReason, PromptReply}; + use tablepro_test_support::Call; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("old"), + "label", + ) + .await + .expect("the refused password"); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let drivers = drivers(); + let loads_before = vault.calls().len(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::reenter(SecretKind::DatabasePassword), + CancellationToken::new(), + ); + let answering = async { + let shown = queue.next().await.expect("the password prompt"); + assert_eq!( + shown.prompt.purpose, + PromptPurpose::DatabasePassword { + reason: PromptReason::Rejected + } + ); + shown + .reply + .send(PromptReply::Submitted { + values: vec![SecretString::from("new")], + remember: true, + }) + .expect("the connect is waiting"); + }; + let (outcome, ()) = tokio::join!(connecting, answering); + + let login = outcome.expect("a connection"); + assert!(login.refused.is_empty()); + assert!( + login.session_only.is_empty(), + "a password the keyring now holds was kept for the session" + ); + assert!( + !vault.calls()[loads_before..].contains(&Call::Load(saved.id, SecretKind::DatabasePassword)), + "the refused password was read again" + ); + assert_eq!( + vault.stored_secret(saved.id, SecretKind::DatabasePassword).as_deref(), + Some("new") + ); + let _closed = login.pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_password_typed_but_not_kept_is_the_sessions_own() { + use tablepro_core::credentials::PromptReply; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("old"), + "label", + ) + .await + .expect("the refused password"); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let prompter: Arc = + Arc::new(tablepro_test_support::FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("new")], + remember: false, + }])); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let login = connect_saved( + &saved, + &drivers(), + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::reenter(SecretKind::DatabasePassword), + CancellationToken::new(), + ) + .await + .expect("a connection"); + + assert_eq!(login.session_only, [SecretKind::DatabasePassword]); + assert_eq!( + vault.stored_secret(saved.id, SecretKind::DatabasePassword).as_deref(), + Some("old") + ); + let _closed = login.pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_password_the_keyring_refused_to_keep_is_the_sessions_own() { + use tablepro_core::credentials::PromptReply; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let prompter: Arc = + Arc::new(tablepro_test_support::FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("typed")], + remember: true, + }])); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let login = connect_saved( + &saved, + &drivers(), + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + CancellationToken::new(), + ) + .await + .expect("a connection"); + + assert_eq!(login.refused.len(), 1); + assert_eq!(login.session_only, [SecretKind::DatabasePassword]); + let _closed = login.pending.discard().await; + } + #[tokio::test(start_paused = true)] async fn a_saved_connect_that_lands_after_its_cancel_keeps_no_typed_secret() { let saved = connection(Uuid::new_v4()); @@ -386,6 +561,7 @@ mod tests { &credentials, &connector, CredentialInteraction::Attended(prompter), + CredentialDemand::default(), cancel.clone(), ); let answering = async { diff --git a/linux/crates/app/src/ui/session_window/connecting_page.rs b/linux/crates/app/src/ui/session_window/connecting_page.rs new file mode 100644 index 0000000000..11f690353a --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connecting_page.rs @@ -0,0 +1,53 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connecting-page.ui")] + pub struct ConnectingPage { + #[template_child] + pub status_page: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectingPage { + const NAME: &'static str = "TableProConnectingPage"; + type Type = super::ConnectingPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectingPage {} + impl WidgetImpl for ConnectingPage {} + impl BinImpl for ConnectingPage {} +} + +glib::wrapper! { + /// A connect on its way, which `win.cancel-connect` stops. A remote + /// or tunnelled connect can take the whole connect timeout. + pub struct ConnectingPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectingPage { + pub fn new(name: &str) -> Self { + let page: Self = glib::Object::new(); + page.imp() + .status_page + .set_title(&crate::i18n::gettext_f("Connecting to {name}", &[("name", name)])); + page + } +} diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs new file mode 100644 index 0000000000..dd8f0a5114 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -0,0 +1,1005 @@ +//! The saved connections a window's picker lists, and the connect +//! dialog that adds to them. + +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::ComponentController; + +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::{ConnectSource, PendingSave, RequestTarget, SessionKey}; +use tablepro_storage::{ConnectionListState, SavedConnection}; +use uuid::Uuid; + +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::connection_deletion::DeletionOptions; +use crate::services::deletion_error::DeletionError; +use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, FormField}; +use crate::ui::delete_connection_dialog::{self, DeleteConnectionDialog}; +use crate::ui::file_read::{FileReadError, read_capped_file}; +use crate::ui::ssh_inputs::SshEnvironment; + +/// The password the user typed, so editing after a failure does not +/// make them type it again. It stays in memory. +fn typed_password(target: &RequestTarget) -> Option { + match target { + RequestTarget::Network { + credentials: tablepro_core::Credentials::Password { password, .. }, + .. + } => Some(password.clone()), + _ => None, + } +} + +/// What the connect dialog opens on to edit a connection that failed: +/// the connection as it was submitted, with the password typed for it. +pub(super) fn prefill_for(source: &ConnectSource) -> Option { + match source { + ConnectSource::Saved(saved) => Some(ConnectPrefill { + connection: (**saved).clone(), + password: None, + }), + ConnectSource::Intent(intent) => intent.pending_save.as_ref().map(|save| ConnectPrefill { + connection: save.connection.clone(), + password: typed_password(&intent.request.target), + }), + ConnectSource::File(_) => None, + } +} + +impl SessionWindow { + /// Show the connect dialog over this window, filled from `prefill` + /// when a connect from it failed, with the focus on `focus`. It + /// opens once the user's SSH keys are looked for, which reads the + /// disk and so runs off the GTK thread. + pub(super) fn present_connect_dialog(&self, prefill: Option, focus: Option) { + let Some(state) = self.state() else { + return; + }; + // A second request while the first waits would stack two dialogs. + if self.imp().dialog_opening.replace(true) { + return; + } + let detecting = state + .services + .tasks + .spawn_blocking_task(SshEnvironment::detect_blocking); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let environment = detecting.await.unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not look for SSH keys"); + SshEnvironment::default() + }); + if let Some(window) = window.upgrade() { + window.imp().dialog_opening.set(false); + window.show_connect_dialog(prefill, focus, environment); + } + }); + } + + fn show_connect_dialog(&self, prefill: Option, focus: Option, ssh: SshEnvironment) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + if self.imp().shut_down.get() { + return; + } + let context = &state.context; + let dialog = ConnectDialog::new(ConnectDialogInit { + drivers: context.drivers.clone(), + connector: context.connector.clone(), + interaction: context.attended(), + tasks: state.services.tasks.clone(), + saved_connections: Arc::from(self.imp().saved_connections.borrow().clone()), + remembered_driver: state.services.settings.connect_dialog_driver(), + ssh_environment: ssh, + prefill, + }); + let submitted = input.clone(); + dialog.connect_submitted(move |_, submission| { + submitted.deliver(SessionWindowInput::ConnectFromDialog(Box::new(submission))); + }); + dialog.connect_closed(move |_| input.deliver(SessionWindowInput::DialogClosed)); + dialog.present(Some(self)); + if let Some(field) = focus { + dialog.reveal_field(field); + } + self.imp().dialog.replace(Some(dialog)); + } + + /// Keep the dialog's edits to a connection open in another window, + /// for its next connect. The keyring is written first, so a secret + /// it refuses is reported rather than lost without a word. + pub(super) fn persist_pending_save(&self, save: PendingSave) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let secrets = state.context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + match tablepro_session::persist_pending_save(&*secrets, &writes, save).await { + Ok(report) => { + for (_, error) in &report.secret_failures { + input.deliver(SessionWindowInput::ShowToast( + crate::services::secret_save_report::not_saved_message(error), + )); + } + } + Err(error) => { + tracing::warn!(%error, "could not save the edited connection"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + ))); + } + } + input.deliver(SessionWindowInput::ReloadConnections); + }); + } + + /// Stamp the connection's last-opened time, then read the list again + /// so the picker sorts by it. The read waits for the stamp, or it + /// would show the old order until the next one. + pub(super) fn touch_last_opened(&self, id: Uuid) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let hold = state.services.hold().acquire(); + let touching = state + .services + .tasks + .spawn_blocking_task(move || connections.touch_last_opened_blocking(id)); + glib::spawn_future_local(async move { + match touching.await { + Ok(Err(error)) => tracing::warn!(%error, "could not stamp the last-opened time"), + Err(error) => tracing::warn!(%error, "the last-opened task failed"), + Ok(Ok(())) => {} + } + drop(hold); + input.deliver(SessionWindowInput::ReloadConnections); + }); + } + + pub(super) fn on_reload_connections(&self) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); + glib::spawn_future_local(async move { + match reading.await { + Ok(Ok(connections)) => input.deliver(SessionWindowInput::ConnectionsLoaded(connections.to_vec())), + Ok(Err(error)) => { + tracing::warn!(%error, "the saved connections could not be read"); + input.deliver(SessionWindowInput::ConnectionListUnavailable); + } + Err(failure) => tracing::warn!(%failure, "the connections read task failed"), + } + }); + } + + pub(super) fn on_connections_loaded(&self, mut connections: Vec) { + crate::ui::connection_list::sort(&mut connections); + self.imp().saved_connections.replace(connections.clone()); + if let Some(welcome) = self.imp().welcome.borrow().as_ref() { + welcome + .sender() + .deliver(crate::ui::welcome_view::WelcomeViewInput::SetConnections(connections)); + } + } + + /// `saved` as the connection list holds it now, or `None` once it is + /// deleted. The list every window shares is read without touching + /// the disk; before it is first read, `saved` is all there is. + pub(super) fn saved_as_stored(&self, saved: &SavedConnection) -> Option { + let Some(state) = self.state() else { + return Some(saved.clone()); + }; + match state.services.storage.connections().snapshot().state { + ConnectionListState::Ready(list) => list.iter().find(|entry| entry.id == saved.id).cloned(), + ConnectionListState::Loading | ConnectionListState::Unavailable(_) => Some(saved.clone()), + } + } + + fn saved_connection(&self, id: Uuid) -> Option { + self.imp() + .saved_connections + .borrow() + .iter() + .find(|saved| saved.id == id) + .cloned() + } + + /// The name a dialog or toast calls a saved connection by. + fn connection_name(&self, id: Uuid) -> String { + self.saved_connection(id) + .map_or_else(|| crate::i18n::gettext("this connection"), |saved| saved.name) + } + + /// Ask before deleting: the delete takes the keyring secrets with it + /// and has no undo, so it asks whatever the preferences say. + pub(super) fn on_delete_connection(&self, id: Uuid) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let name = self.connection_name(id); + if self.offer_open_window(id, &name) { + return; + } + let asking = DeleteConnectionDialog::new(&name); + let history = state.services.history(); + let tasks = state.services.tasks.clone(); + let counting = asking.clone(); + glib::spawn_future_local(async move { + let Some(store) = history.ready_store().await else { + counting.show_history_unavailable(); + return; + }; + match tasks + .spawn_task(async move { store.count_for_connection(id).await }) + .await + { + Ok(Ok(count)) => counting.show_history_count(count), + Ok(Err(error)) => tracing::warn!(%error, "could not count the connection's query history"), + Err(failure) => tracing::warn!(%failure, "the query history count did not finish"), + } + }); + asking.connect_delete(move |options| input.deliver(SessionWindowInput::ConfirmDelete(id, options))); + asking.dialog.present(Some(self)); + } + + /// A connection open in a window, or connecting in one, is not + /// deleted from under it: the user is offered that window instead. + /// True when it is open. + fn offer_open_window(&self, id: Uuid, name: &str) -> bool { + let Some(state) = self.state() else { + return false; + }; + let Some(owner) = state.context.sessions.window_for(&SessionKey::Saved(id)) else { + return false; + }; + let dialog = delete_connection_dialog::is_open_dialog(name); + let host = state.host.clone(); + dialog.connect_response(Some(delete_connection_dialog::SHOW), move |_, _| { + if !host.present_window(owner) { + tracing::warn!(owner, "the window holding the connection is gone"); + } + }); + dialog.present(Some(self)); + true + } + + /// Delete a saved connection the user confirmed, under a hold so a + /// quit right after it does not cut it off between the keyring and + /// the list. + /// + /// The delete claims the connection's key until it ends, so a + /// connect started meanwhile from a picker that still lists it is + /// refused rather than given an entry whose tabs are being purged. + pub(super) fn on_confirm_delete(&self, id: Uuid, options: DeletionOptions) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let name = self.connection_name(id); + // It may have been opened since the dialog asked. + let Ok(claim) = state.context.sessions.claim(SessionKey::Saved(id), self.id()) else { + self.offer_open_window(id, &name); + return; + }; + let services = state.services.clone(); + let hold = services.hold().acquire(); + glib::spawn_future_local(async move { + let history = services.history().ready_store().await; + match services.connection_deletion(history).run(id, options).await { + Ok(report) if report.is_complete() => {} + Ok(report) => { + for failure in &report.purge_failures { + tracing::warn!(%failure, "kept something stored for a deleted connection"); + } + if let Some(summary) = report.summary(&name) { + input.deliver(SessionWindowInput::ShowToast(summary)); + } + } + Err(DeletionError::Secrets(error)) => { + tracing::warn!(%error, "could not delete the connection's secrets; keeping the entry"); + input.deliver(SessionWindowInput::SecretDeleteFailed(id, options)); + } + Err(error) => { + tracing::warn!(%error, "could not delete the connection"); + input.deliver(SessionWindowInput::ShowToast(error.summary(&name))); + } + } + drop(claim); + drop(hold); + input.deliver(SessionWindowInput::ReloadConnections); + }); + } + + /// Copy a saved connection so a second database on the same server + /// does not have to be typed out again. + /// + /// The copy takes a new id, so it carries its own secrets rather + /// than sharing the original's: removing either then leaves the + /// other able to connect. + pub(super) fn on_duplicate_connection(&self, source: SavedConnection) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let copy = tablepro_storage::duplicate(&source, &self.imp().saved_connections.borrow()); + let name = copy.name.clone(); + let connections = state.services.storage.connections().clone(); + let secrets = state.services.storage.secrets().clone(); + let tasks = state.services.tasks.clone(); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + let copy_id = copy.id; + let saved = tasks + .spawn_blocking_task(move || connections.upsert_blocking(copy)) + .await; + if let Ok(Err(error)) = saved { + tracing::warn!(%error, "could not save the duplicated connection"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The connection could not be duplicated.", + ))); + return; + } + // A copy with no password still connects once the user types + // one, so a keyring that refuses is reported rather than + // undoing the copy. + let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; + input.deliver(SessionWindowInput::ReloadConnections); + input.deliver(SessionWindowInput::ShowToast(if carried { + crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) + } else { + crate::i18n::gettext_f( + "Duplicated as {name}. Its password could not be copied, so enter it again.", + &[("name", &name)], + ) + })); + }); + } + + /// File a connection under a group, or take it out of the one it + /// is in. An empty name means no group. + pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option) { + let group = group.map(|name| name.trim().to_owned()).filter(|name| !name.is_empty()); + let Some(mut saved) = self.saved_connection(id) else { + return; + }; + if saved.group == group { + return; + } + saved.group = group; + self.write_connection(saved, crate::i18n::gettext("The group could not be saved.")); + } + + /// Put a colour on a connection, or take the one it has off. + /// + /// The whole entry is rewritten because that is what the store + /// takes, and it comes from the list the window already holds rather + /// than from a re-read: the row that asked is showing that entry. + pub(super) fn on_set_connection_color(&self, id: Uuid, color: Option) { + let Some(mut saved) = self.saved_connection(id) else { + return; + }; + if saved.color == color { + return; + } + saved.color = color; + self.write_connection(saved, crate::i18n::gettext("The colour could not be saved.")); + } + + /// Write one connection back and reload the list from disk, so the + /// picker redraws from what was actually saved. The process is held + /// until the write lands, so a quit right after it keeps it. + fn write_connection(&self, saved: SavedConnection, failure: String) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + let written = tasks + .spawn_blocking_task(move || connections.upsert_blocking(saved)) + .await; + if let Ok(Err(error)) = written { + tracing::warn!(%error, "could not save the connection"); + input.deliver(SessionWindowInput::ShowToast(failure)); + } + input.deliver(SessionWindowInput::ReloadConnections); + }); + } + + /// Write the connection list to a file the user picks. + /// + /// The file carries no passwords, and the dialog's name says what + /// it does carry, because it is going somewhere the keyring does + /// not protect. + pub(super) fn on_export_connections(&self) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let reading = state + .services + .tasks + .spawn_blocking_task(move || connections.export_blocking()); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let error = match reading.await { + Ok(Ok(bytes)) => { + if let Some(window) = window.upgrade() { + ask_where_to_export(&window, bytes, input); + } + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not read the connections to export"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The connections could not be read.", + ))); + }); + } + + /// Ask for an exported file and read it off the GTK thread. + pub(super) fn on_import_connections(&self) { + let Some(input) = self.input() else { + return; + }; + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Import Connections")) + .modal(true) + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.open(Some(self), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let path = file.path().unwrap_or_default(); + input.deliver(match read_connections_file(&file).await { + Ok(bytes) => SessionWindowInput::ConnectionsFileRead { path, bytes }, + Err(message) => SessionWindowInput::ShowToast(message), + }); + }); + }); + } + + pub(super) fn on_connections_file_read(&self, path: std::path::PathBuf, bytes: Vec) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + let imported = tasks + .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) + .await; + let message = match imported { + Ok(Ok(report)) if report.total() == 0 => crate::i18n::gettext("That file has no connections in it."), + Ok(Ok(report)) => crate::i18n::ngettext_f( + "Imported {n} connection.", + "Imported {n} connections.", + u32::try_from(report.total()).unwrap_or(u32::MAX), + &[("n", &report.total().to_string())], + ), + Ok(Err(error)) => { + tracing::warn!(%error, "could not import the connections"); + crate::i18n::gettext("That file is not a TablePro connection export.") + } + Err(error) => { + tracing::warn!(%error, "the import task did not finish"); + crate::i18n::gettext("The connections could not be imported.") + } + }; + input.deliver(SessionWindowInput::ReloadConnections); + input.deliver(SessionWindowInput::ShowToast(message)); + }); + } + + /// The list could not be read, so the user is told rather than shown + /// an empty picker that looks like their connections vanished. + pub(super) fn show_connection_list_banner(&self) { + let (Some(state), Some(picker)) = (self.state(), self.imp().picker.get()) else { + return; + }; + let banner = picker.banner(); + let Some(content) = + crate::ui::connection_list_banner::banner_content(&state.services.storage.connections().snapshot().state) + else { + banner.set_revealed(false); + return; + }; + banner.set_title(&content.title); + banner.set_button_label(Some(&content.button_label)); + banner.set_revealed(true); + } + + /// The keyring refused, so the entry stays and the user is offered + /// another go rather than being left with orphaned secrets. Retry + /// runs the delete they already confirmed. + pub(super) fn on_secret_delete_failed(&self, id: Uuid, options: DeletionOptions) { + let Some(input) = self.input() else { + return; + }; + let toast = adw::Toast::new(&crate::i18n::gettext( + "The stored passwords could not be deleted, so the connection was kept.", + )); + toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); + toast.connect_button_clicked(move |_| input.deliver(SessionWindowInput::ConfirmDelete(id, options))); + self.add_toast(toast); + } + + pub(super) fn on_reset_connection_list(&self) { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Reset Saved Connections?")), + Some(&crate::i18n::gettext( + "The unreadable file will be renamed so it can be recovered. Stored passwords stay in the keyring.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("reset", &crate::i18n::gettext("Reset")); + dialog.set_response_appearance("reset", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let window = self.downgrade(); + dialog.connect_response(None, move |_, response| { + if response == "reset" + && let Some(window) = window.upgrade() + { + window.reset_connection_list(); + } + }); + dialog.present(Some(self)); + } + + /// Move the unreadable list aside, holding the process until the + /// rename lands so a quit right after Reset keeps it. + pub(super) fn reset_connection_list(&self) { + let (Some(state), Some(input), Some(picker)) = (self.state(), self.input(), self.imp().picker.get()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let held = state.services.hold().acquire(); + let resetting = state + .services + .tasks + .spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); + let banner = picker.banner(); + let toasts = self.imp().toast_overlay.get(); + glib::spawn_future_local(async move { + let reset = resetting.await; + drop(held); + let error = match reset { + Ok(Ok(moved)) => { + banner.set_revealed(false); + toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( + &moved, + ))); + input.deliver(SessionWindowInput::ReloadConnections); + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not reset the saved connections"); + toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( + "Could not reset the saved connections: {error}", + &[("error", &error)], + ))); + }); + } +} + +/// Move every secret the original holds onto the copy's own id. +/// +/// Returns whether all of them made it: a keyring that is locked or +/// absent is a reason to tell the user, not to refuse the copy. +async fn copy_secrets( + secrets: &dyn tablepro_core::credentials::SecretVault, + from: Uuid, + to: Uuid, + label: &str, +) -> bool { + use tablepro_core::credentials::{SecretKind, SecretLookup}; + + let mut carried = true; + for kind in SecretKind::ALL { + match secrets.load(from, kind).await { + Ok(SecretLookup::Found(secret)) => { + if let Err(error) = secrets.store(to, kind, &secret, label).await { + tracing::warn!(%error, ?kind, "could not copy a secret onto the duplicate"); + carried = false; + } + } + // Nothing stored for this kind, so nothing to carry. + Ok(SecretLookup::NotStored) => {} + Err(error) => { + tracing::warn!(%error, ?kind, "could not read a secret to duplicate"); + carried = false; + } + } + } + carried +} + +fn ask_where_to_export(window: &SessionWindow, bytes: Vec, input: InputSender) { + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Connections")) + .modal(true) + .initial_name("tablepro-connections.json") + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let message = match crate::ui::file_write::replace_file(&file, bytes).await { + Ok(()) => crate::i18n::gettext("Connections exported."), + Err(error) => { + tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); + crate::i18n::gettext("The connections could not be written to that file.") + } + }; + input.deliver(SessionWindowInput::ShowToast(message)); + }); + }); +} + +/// An export holds names, hosts and options, never secrets, so even +/// thousands of connections come to well under this. +const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; + +/// The chosen export, whole, or what to tell the user instead. +async fn read_connections_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the file to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + +/// The filter both file dialogs open with. Built fresh each time +/// because a GtkFileFilter belongs to the dialog it is given to. +fn json_filter() -> gtk::FileFilter { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("JSON files"))); + filter.add_suffix("json"); + filter.add_mime_type("application/json"); + filter +} + +fn json_filters() -> gio::ListStore { + let filters = gio::ListStore::new::(); + filters.append(&json_filter()); + filters +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use secrecy::SecretString; + use tablepro_core::credentials::{SecretError, SecretKind, SecretVault}; + use tablepro_storage::ConnectionColor; + use tablepro_test_support::Call; + + use super::*; + use crate::test_support::{finish_on_main, respond, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { read_connections_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn an_import_past_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[gtk4::test] + fn an_export_is_read_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); + + assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); + } + + fn open_alert(window: &SessionWindow) -> Option { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .next() + } + + fn wait_for_alert(window: &SessionWindow) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = open_alert(window); + shown.is_some() + }) + .expect("a dialog"); + shown.expect("the dialog") + } + + /// The picker lists the test's connection, which names it in the + /// dialogs. + fn wait_for_list(window: &SessionWindow) { + wait_until(WAIT, || !window.imp().saved_connections.borrow().is_empty()).expect("the connection list"); + } + + fn listed(test: &TestWindow, id: Uuid) -> bool { + test.services + .storage + .connections() + .load_blocking() + .expect("the list") + .iter() + .any(|saved| saved.id == id) + } + + fn store_password(test: &TestWindow) { + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("s3cret"), "label") + .await + }) + .expect("the store to end") + .expect("the password stored"); + } + + #[gtk4::test] + fn open_connection_shows_is_open_dialog() { + let test = TestWindow::connected("DeleteOpen"); + let other = test + .app + .open_window(crate::application::WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + wait_for_list(&other); + test.window.set_visible(false); + + other.send(SessionWindowInput::DeleteConnection(test.saved.id)); + let alert = wait_for_alert(&other); + assert_eq!(alert.heading().as_deref(), Some("“Fake” Is Open")); + respond(&alert, delete_connection_dialog::SHOW); + wait_until(WAIT, || test.window.is_visible()).expect("the owner to come forward"); + + assert!(listed(&test, test.saved.id), "an open connection was deleted"); + assert!( + !test.vault.calls().contains(&Call::DeleteConnection(test.saved.id)), + "an open connection's secrets were deleted" + ); + } + + #[gtk4::test] + fn a_confirmed_delete_removes_the_entry_and_its_secrets() { + let test = TestWindow::picker("DeleteConfirmed"); + wait_for_list(&test.window); + store_password(&test); + + test.window.send(SessionWindowInput::DeleteConnection(test.saved.id)); + let alert = wait_for_alert(&test.window); + assert_eq!(alert.heading().as_deref(), Some("Delete “Fake”?")); + let switch = crate::test_support::descendants(&alert.extra_child().expect("the dialog's rows")) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the history switch"); + assert!(!switch.is_active(), "the history would go by default"); + respond(&alert, delete_connection_dialog::DELETE); + + wait_until(WAIT, || !listed(&test, test.saved.id)).expect("the entry to go"); + assert!(test.vault.is_empty(), "the secrets outlived the entry"); + wait_until(WAIT, || test.window.imp().saved_connections.borrow().is_empty()) + .expect("the picker to drop the entry"); + } + + #[gtk4::test] + fn retry_toast_reruns_deletion() { + let test = TestWindow::picker("DeleteRetry"); + wait_for_list(&test.window); + store_password(&test); + test.vault.fail_delete_connection(SecretError::Locked); + + test.window.send(SessionWindowInput::DeleteConnection(test.saved.id)); + respond(&wait_for_alert(&test.window), delete_connection_dialog::DELETE); + let mut retry = None; + wait_until(WAIT, || { + retry = test + .window + .imp() + .toasts + .borrow() + .iter() + .find(|toast| toast.button_label().is_some()) + .cloned(); + retry.is_some() + }) + .expect("a toast offering Retry"); + assert!(listed(&test, test.saved.id), "the entry went while its secrets stayed"); + assert!(!test.vault.is_empty()); + test.vault.clear_failures(); + + retry.expect("the toast").emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || !listed(&test, test.saved.id)).expect("the retried delete"); + assert!(test.vault.is_empty()); + assert!(open_alert(&test.window).is_none(), "Retry asked again"); + } + + #[gtk4::test] + fn connection_list_writes_hold_the_process_until_they_land() { + let test = TestWindow::picker("ListWritesHeld"); + wait_for_list(&test.window); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let id = test.saved.id; + let stored = || test.services.storage.connections().load_blocking().expect("the list"); + + test.window.on_set_connection_color(id, Some(ConnectionColor::Red)); + assert_eq!(test.hold.live(), 1, "the colour was written without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + assert_eq!( + stored()[0].color, + Some(ConnectionColor::Red), + "the hold ended before the write" + ); + + test.window.on_duplicate_connection(test.saved.clone()); + assert_eq!(test.hold.live(), 1, "the copy was written without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + let copy = stored() + .iter() + .find(|saved| saved.id != id) + .cloned() + .expect("the copy, written before the hold ended"); + + test.window.on_confirm_delete(copy.id, DeletionOptions::default()); + assert_eq!(test.hold.live(), 1, "the delete ran without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + assert!(!listed(&test, copy.id), "the hold ended before the delete"); + } + + #[gtk4::test] + fn a_delete_keeps_the_connection_from_opening_until_it_ends() { + let test = TestWindow::picker("DeleteClaims"); + wait_for_list(&test.window); + let key = test.key(); + + test.window.on_confirm_delete(test.saved.id, DeletionOptions::default()); + + assert!( + test.services + .sessions + .claim(key.clone(), test.window.id() + 100) + .is_err(), + "a connect could claim the entry being deleted" + ); + wait_until(WAIT, || test.hold.live() == 0).expect("the delete to end"); + assert!(!listed(&test, test.saved.id)); + assert_eq!( + test.services.sessions.window_for(&key), + None, + "the key stayed claimed after the delete" + ); + } + + #[gtk4::test] + fn a_deleted_connection_still_listed_elsewhere_does_not_open() { + let test = TestWindow::picker("DeletedRow"); + wait_for_list(&test.window); + test.window.on_confirm_delete(test.saved.id, DeletionOptions::default()); + wait_until(WAIT, || test.hold.live() == 0).expect("the delete to end"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || { + test.window + .imp() + .toasts + .borrow() + .iter() + .any(|toast| toast.title().as_deref() == Some("The connection was deleted.")) + }) + .expect("the user to be told"); + + assert_eq!(test.window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a deleted connection was connected to" + ); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + } + + #[gtk4::test] + fn reset_holds_the_process_until_the_list_is_moved_aside() { + let test = TestWindow::with_script( + "ResetHeld", + crate::ui::session_window::tests::Script { + unreadable_list: true, + ..Default::default() + }, + ); + let state = || test.services.storage.connections().snapshot().state; + wait_until(WAIT, || matches!(state(), ConnectionListState::Unavailable(_))).expect("the unreadable list"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + + test.window.reset_connection_list(); + assert_eq!(test.hold.live(), 1, "the reset ran without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + assert!( + matches!(state(), ConnectionListState::Ready(_)), + "the hold ended before the list was moved aside" + ); + } + + #[gtk4::test] + fn reset_closes_its_alert_once() { + let test = TestWindow::with_script( + "ResetClosedOnce", + crate::ui::session_window::tests::Script { + unreadable_list: true, + ..Default::default() + }, + ); + let state = || test.services.storage.connections().snapshot().state; + wait_until(WAIT, || matches!(state(), ConnectionListState::Unavailable(_))).expect("the unreadable list"); + test.window.on_reset_connection_list(); + wait_for_alert(&test.window); + + // With animations off, closing the alert after its click already + // closed it logs a critical, which this makes abort the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let answered = crate::workspace::answer(&test.window, "reset"); + crate::test_support::drain_main_context(); + glib::log_set_always_fatal(previous); + + assert!(answered, "the alert has no Reset button"); + assert!(open_alert(&test.window).is_none(), "the alert stayed open"); + wait_until(WAIT, || matches!(state(), ConnectionListState::Ready(_))).expect("the list to be moved aside"); + } +} diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs new file mode 100644 index 0000000000..5585d79729 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -0,0 +1,101 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use relm4::Controller; +use tablepro_session::{ConnectAttempts, SessionKey}; +use tablepro_storage::SavedConnection; + +use super::locate_file::LocateChooser; +use super::picker_page::PickerPage; +use super::session_page::SessionPage; +use super::{SessionWindowInput, WindowState}; +use crate::runtime::InputSender; +use crate::services::file_location::LocationClassifier; +use crate::ui::connect_dialog::ConnectDialog; +use crate::ui::welcome_view::WelcomeView; +use crate::workspace::{CloseGuards, WindowInhibitor}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/session-window.ui")] +pub struct SessionWindow { + #[template_child] + pub toast_overlay: TemplateChild, + + /// What the application handed the window, set once it is added. + pub(super) state: OnceCell, + pub(super) input: OnceCell>, + /// Handles the queued input. Aborted at shutdown, which closes a + /// connect still queued and drops every other reply. + pub(super) pump: RefCell>>, + /// Ends the credential prompt loop with the window. + pub(super) prompt_presenter: RefCell>>, + pub(super) page: RefCell, + /// Which connect attempt is current, so a late result is discarded. + pub(super) attempts: ConnectAttempts, + /// The recorded session the window was opened to restore, until the + /// saved connection is looked up or the file resolved. + pub(super) restoring: RefCell>, + /// One close prompt at a time, for a tab, the window or a + /// disconnect. + pub(super) close_guards: OnceCell>, + /// Holds logout back while the workspace has something to lose. + pub(super) inhibitor: RefCell>, + pub(super) dirty: Cell, + pub(super) saving: Cell, + pub(super) saved_connections: RefCell>, + pub(super) welcome: RefCell>>, + pub(super) picker: OnceCell, + pub(super) dialog: RefCell>, + /// The connect dialog was asked for and opens once the SSH keys are + /// looked for. + pub(super) dialog_opening: Cell, + pub(super) shut_down: Cell, + /// Every toast shown, which the overlay does not list. + #[cfg(test)] + pub(super) toasts: RefCell>, + /// A file handed to the window is being resolved, so the window is + /// not free for another one. + pub(super) opening_file: Cell, + /// The window opened for a file and has shown nothing else yet, so + /// it closes again when another window already has that file. + pub(super) opened_for_file: Cell, + /// Reads which filesystem a database file is on. `None` reads the + /// real one; a test stands in for a network share. + pub(super) classify_location: Cell>, + /// Asks where a lost database file is now. `None` opens the file + /// chooser; a test answers for the user. + pub(super) locate_chooser: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for SessionWindow { + const NAME: &'static str = "TableProSessionWindow"; + type Type = super::SessionWindow; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for SessionWindow { + /// A window dropped without its close request still closes what it + /// holds. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for SessionWindow {} +impl WindowImpl for SessionWindow {} +impl ApplicationWindowImpl for SessionWindow {} +impl AdwApplicationWindowImpl for SessionWindow {} diff --git a/linux/crates/app/src/ui/session_window/locate_file.rs b/linux/crates/app/src/ui/session_window/locate_file.rs new file mode 100644 index 0000000000..e38e66e4e4 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/locate_file.rs @@ -0,0 +1,397 @@ +//! A saved SQLite connection whose file is gone: find the file again, +//! save its new path and connect to it. + +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use futures::FutureExt; +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::ConnectSource; +use tablepro_session::connect::{ConnectionWrites, StoreConnectionWrites}; +use tablepro_storage::SavedConnection; +use uuid::Uuid; + +use super::session_page::SessionPage; +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::DeliverInput; + +const CANCEL: &str = "cancel"; +const LOCATE: &str = "locate"; + +/// Where the user says a lost database file is now. +pub(crate) trait LocateChooser { + fn choose(&self, parent: >k::Window, missing: &Path) -> LocalBoxFuture<'static, Option>; +} + +/// The file chooser, opened where the file used to be. +struct FileDialogChooser; + +impl LocateChooser for FileDialogChooser { + fn choose(&self, parent: >k::Window, missing: &Path) -> LocalBoxFuture<'static, Option> { + let dialog = crate::ui::connect_dialog::sqlite_file_dialog(&crate::i18n::gettext("Locate Database File")); + // An initial file is read synchronously, which hangs on a dead + // mount; a folder is only stored. + if let Some(folder) = missing.parent() { + dialog.set_initial_folder(Some(&gio::File::for_path(folder))); + } + let parent = parent.clone(); + async move { + dialog + .open_future(Some(&parent)) + .await + .ok() + .and_then(|file| file.path()) + } + .boxed_local() + } +} + +fn not_found_dialog(missing: &Path) -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Database File Not Found")), + Some(&crate::i18n::gettext_f( + "{file} could not be found. If it was moved or renamed, locate it to update the connection.", + &[("file", &missing.display().to_string())], + )), + ); + dialog.add_responses(&[ + (CANCEL, &crate::i18n::gettext("_Cancel")), + (LOCATE, &crate::i18n::gettext("_Locate…")), + ]); + dialog.set_response_appearance(LOCATE, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(LOCATE)); + dialog.set_close_response(CANCEL); + dialog +} + +impl SessionWindow { + /// Offer to find the file a saved connection lost. The failed page + /// stays behind the question, so Cancel leaves Retry and Edit + /// Connection. + pub(super) fn offer_locate(&self, id: Uuid, missing: PathBuf) { + let dialog = not_found_dialog(&missing); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let Some(window) = window.upgrade() else { + return; + }; + if crate::workspace::respond(&dialog, window.upcast_ref()).await != LOCATE { + return; + } + let chooser = window + .imp() + .locate_chooser + .borrow() + .clone() + .unwrap_or_else(|| Rc::new(FileDialogChooser)); + let chosen = chooser.choose(window.upcast_ref(), &missing).await; + if let Some(path) = chosen { + window.send(SessionWindowInput::FileLocated { id, path }); + } + }); + } + + /// Save the file's new path on the connection the failed page is + /// about, then connect to it once the write lands, so the entry and + /// the open session name the same file. + pub(super) fn on_file_located(&self, id: Uuid, path: PathBuf) { + let saved = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { + source: ConnectSource::Saved(saved), + .. + } if saved.id == id => (**saved).clone(), + _ => return, + }; + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + // Deleted in another window while the question was open: writing + // the page's copy back would bring it back without its secrets. + let Some(mut located) = self.saved_as_stored(&saved) else { + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + return; + }; + located.database = path.display().to_string(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + let update = located.clone(); + let held = state.services.hold().acquire(); + crate::runtime::spawn_and_deliver( + &state.services.tasks, + async move { writes.upsert(update).await }, + move |written| { + drop(held); + let error = match written { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(failure) => Some(failure.to_string()), + }; + if let Some(error) = error { + tracing::warn!(%error, "could not save the database file's new location"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The new location could not be saved.", + ))); + } + input.deliver(SessionWindowInput::ReloadConnections); + input.deliver(SessionWindowInput::LocatedSaved(Box::new(located))); + }, + ); + } + + /// Connect to the located file, unless the user left the failed + /// page while its path was saved. + pub(super) fn on_located_saved(&self, located: SavedConnection) { + let current = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { + source: ConnectSource::Saved(saved), + .. + } => saved.id == located.id, + _ => false, + }; + if !current { + return; + } + let SessionPage::ConnectFailed { claim, .. } = self.imp().page.replace(SessionPage::Picker) else { + return; + }; + self.begin_connect(claim, ConnectSource::Saved(Box::new(located))); + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, FileEndpoint}; + use tablepro_session::fakes::FakeEstablish; + use tablepro_session::{ConnectError, RequestTarget}; + + use super::super::tests::{Script, TestWindow, WAIT, dialog_submission}; + use super::*; + use crate::test_support::wait_until; + use crate::workspace::{answer, visible_alert}; + + /// Answers with a file the test made, as a user picking it would. + struct ChosenFile(PathBuf); + + impl LocateChooser for ChosenFile { + fn choose(&self, _parent: >k::Window, _missing: &Path) -> LocalBoxFuture<'static, Option> { + let path = self.0.clone(); + async move { Some(path) }.boxed_local() + } + } + + fn not_found() -> Script { + Script { + establish: vec![FakeEstablish::Fail(ConnectError::Driver(DriverError::FileNotFound { + path: PathBuf::from("/srv/gone.db"), + }))], + ..Script::default() + } + } + + fn opened_path(call: &tablepro_session::fakes::EstablishCall) -> PathBuf { + match &call.request.target { + RequestTarget::File(endpoint) => { + assert_eq!(endpoint.mode(), tablepro_core::FileOpenMode::OpenExisting); + endpoint.path().to_path_buf() + } + RequestTarget::Network { .. } => panic!("a file connection opened a network target"), + } + } + + #[gtk4::test] + fn file_not_found_offers_locate_and_retries_with_new_path() { + let test = TestWindow::with_script("Locate", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + let found = moved.path().join("moved.db"); + std::fs::write(&found, b"").expect("seed"); + test.window + .imp() + .locate_chooser + .replace(Some(Rc::new(ChosenFile(found.clone())))); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + let alert = visible_alert(&test.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Database File Not Found")); + assert_eq!(alert.default_response().as_deref(), Some(LOCATE)); + + assert!(answer(&test.window, LOCATE)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 2); + assert_eq!(opened_path(&calls[1]), found); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + let entry = stored + .iter() + .find(|entry| entry.id == test.saved.id) + .expect("the entry"); + assert_eq!(entry.database, found.display().to_string()); + assert_eq!(entry.name, test.saved.name); + } + + #[gtk4::test] + fn cancel_leaves_the_failed_page_and_saves_nothing() { + let test = TestWindow::with_script("LocateCancel", not_found()); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + let body = visible_alert(&test.window) + .map(|alert| alert.body().to_string()) + .unwrap_or_default(); + assert!(body.contains("/srv/gone.db"), "{body}"); + assert!(answer(&test.window, CANCEL)); + wait_until(WAIT, || visible_alert(&test.window).is_none()).expect("the question to close"); + crate::test_support::drain_main_context(); + + assert_eq!(test.window.page_name(), "connect-failed"); + assert_eq!(test.connector.calls().len(), 1); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert_eq!( + stored + .iter() + .find(|entry| entry.id == test.saved.id) + .map(|entry| &entry.database), + Some(&test.saved.database) + ); + } + + #[gtk4::test] + fn another_failure_offers_no_locate() { + let test = TestWindow::with_script( + "LocateOther", + Script { + establish: vec![FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { + diagnostics: None, + }))], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn locate_after_the_entry_was_deleted_returns_to_the_picker_and_saves_nothing() { + let test = TestWindow::with_script("LocateDeleted", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + test.window + .imp() + .locate_chooser + .replace(Some(Rc::new(ChosenFile(moved.path().join("moved.db"))))); + let store = test.services.storage.connections().clone(); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + store.remove_blocking(test.saved.id).expect("delete the entry"); + assert!(answer(&test.window, LOCATE)); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert_eq!(test.connector.calls().len(), 1); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + let stored = store.load_blocking().expect("the connection list"); + assert!( + stored.iter().all(|entry| entry.id != test.saved.id), + "the deleted entry came back" + ); + } + + #[gtk4::test] + fn a_file_located_for_another_connection_is_ignored() { + let test = TestWindow::with_script("LocateOtherId", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + assert!(answer(&test.window, CANCEL)); + + test.window.send(SessionWindowInput::FileLocated { + id: Uuid::new_v4(), + path: moved.path().join("moved.db"), + }); + + assert!( + wait_until(std::time::Duration::from_millis(500), || test.connector.calls().len() + > 1) + .is_err(), + "connected to a file located for another connection" + ); + assert_eq!(test.window.page_name(), "connect-failed"); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert_eq!( + stored + .iter() + .find(|entry| entry.id == test.saved.id) + .map(|entry| &entry.database), + Some(&test.saved.database) + ); + } + + #[gtk4::test] + fn a_file_handed_over_that_is_not_found_offers_no_locate() { + let test = TestWindow::with_script("LocateFileSource", not_found()); + let dir = tempfile::tempdir().expect("tempdir"); + let database = dir.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn a_dialog_connect_that_is_not_found_offers_no_locate() { + let test = TestWindow::with_script("LocateIntent", not_found()); + let dir = tempfile::tempdir().expect("tempdir"); + let endpoint = FileEndpoint::open_existing(dir.path().join("app.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } +} diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs new file mode 100644 index 0000000000..85c62707a7 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -0,0 +1,1091 @@ +//! One window per connection session. It opens on the picker, shows +//! the connect while it runs and the failure when it fails, then the +//! session's workspace. A disconnect tears the workspace down in order +//! and returns to the picker; it never moves to another session. + +mod connect_failed_page; +mod connect_flow; +mod connecting_page; +mod connections; +mod imp; +mod locate_file; +#[cfg(test)] +mod open_file_tests; +mod picker_page; +mod resolve_file_identity; +mod secret_retry; +mod session_page; +mod session_restore; +mod session_teardown; +mod session_window_command; +mod session_window_input; +mod sqlite_location_dialog; +#[cfg(test)] +pub(crate) mod tests; +mod window_actions; + +use std::rc::Rc; + +use futures::FutureExt; +use futures::future::BoxFuture; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use relm4::prelude::*; + +use tablepro_core::DriverError; +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::registry::FileIdentity; +use tablepro_session::runtime::ProcessHold; +use tablepro_session::{ + ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, Session, SessionClaim, + SessionKey, +}; +use tablepro_storage::{CredentialDemand, SavedConnection}; + +use self::connect_failed_page::ConnectFailedPage; +use self::connecting_page::ConnectingPage; +use self::picker_page::PickerPage; +use self::resolve_file_identity::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; +use self::session_page::SessionPage; +use self::session_window_command::{AttemptError, ConnectFailure, Connected, SessionWindowCommand}; +pub(crate) use self::session_window_input::SessionWindowInput; +use self::sqlite_location_dialog::FileCheck; +pub(crate) use self::window_actions::install_window_shortcuts; +use crate::application::{AppServices, WindowHost, WindowLaunch}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::session_context::SessionContext; +use crate::ui::connect_dialog::ConnectSubmission; +use crate::ui::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; +use crate::ui::workspace::{Workspace, WorkspaceInit, WorkspaceInput}; +use crate::workspace::{ + ApplicationInhibit, CloseDecision, CloseGuards, CloseReason, InhibitReason, WindowInhibitor, WorkspaceHandle, +}; + +glib::wrapper! { + pub struct SessionWindow(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +/// What the application hands each window it opens. +pub struct SessionWindowInit { + pub services: Rc, + pub host: Rc, + pub launch: WindowLaunch, +} + +/// What a window keeps from its init for as long as it is open. +pub(crate) struct WindowState { + services: Rc, + /// Reaches the application's other windows. + host: Rc, + context: Rc, +} + +impl Default for SessionWindow { + fn default() -> Self { + Self::new() + } +} + +impl SessionWindow { + pub fn new() -> Self { + glib::Object::new() + } + + /// Start the window on `init.launch`. Called once the window is + /// added to the application, which gives it the id its session is + /// claimed under. + pub fn start(&self, init: SessionWindowInit) { + let SessionWindowInit { services, host, launch } = init; + let imp = self.imp(); + let (context, prompts) = services.window_context(); + let (input, receiver) = crate::runtime::input_queue::(); + let _ = imp.input.set(input.clone()); + let (tasks, hold) = (services.tasks.clone(), services.hold()); + imp.pump.replace(Some(crate::runtime::pump_inputs_with_leftovers( + self, + receiver, + |window, message| window.handle_input(message), + move |message| { + if let Some(pending) = unadopted(message) { + discard_pending(pending, &tasks, &*hold); + } + }, + ))); + if crate::config::profile() == crate::config::Profile::Development { + self.add_css_class("devel"); + } + crate::ui::window_geometry::restore(self.upcast_ref(), &services.settings); + window_actions::install(self.upcast_ref(), &input); + install_window_shortcuts(self.upcast_ref()); + imp.prompt_presenter + .replace(Some(crate::ui::credential_prompt::CredentialPromptPresenter::start( + prompts, self, + ))); + + let guards = CloseGuards::new(); + let window = self.downgrade(); + guards.connect_window( + self, + move || window.upgrade().and_then(|window| window.workspace_handle()), + SessionWindow::on_close_approved, + ); + let _ = imp.close_guards.set(guards); + imp.inhibitor + .replace(Some(WindowInhibitor::new(ApplicationInhibit::new(self)))); + + let welcome_input = input.clone(); + let welcome = WelcomeView::builder() + .launch(WelcomeViewInit { + tasks: Some(services.tasks.clone()), + }) + .connect_receiver(move |_, out| { + welcome_input.deliver(match out { + WelcomeViewOutput::OpenConnect => SessionWindowInput::OpenConnectDialog, + WelcomeViewOutput::OpenSaved(saved) => SessionWindowInput::OpenSaved(Box::new(saved)), + WelcomeViewOutput::Delete(id) => SessionWindowInput::DeleteConnection(id), + WelcomeViewOutput::Duplicate(saved) => SessionWindowInput::DuplicateConnection(Box::new(saved)), + WelcomeViewOutput::SetColor(id, color) => SessionWindowInput::SetConnectionColor(id, color), + WelcomeViewOutput::SetGroup(id, group) => SessionWindowInput::SetConnectionGroup(id, group), + }); + }); + let picker = PickerPage::new(welcome.widget()); + let reset_input = input.clone(); + picker + .banner() + .connect_button_clicked(move |_| reset_input.deliver(SessionWindowInput::ResetConnectionList)); + imp.welcome.replace(Some(welcome)); + let _ = imp.picker.set(picker); + let _ = imp.state.set(WindowState { + services, + host, + context, + }); + + self.show_page(SessionPage::Picker); + input.deliver(SessionWindowInput::ReloadConnections); + match launch { + WindowLaunch::Picker => {} + WindowLaunch::Connect(saved) => input.deliver(SessionWindowInput::OpenSaved(saved)), + WindowLaunch::OpenFile(file) => { + imp.opening_file.set(true); + imp.opened_for_file.set(true); + input.deliver(SessionWindowInput::OpenFile(file)); + } + WindowLaunch::Restore(key) => { + self.imp().restoring.replace(Some(key.clone())); + input.deliver(SessionWindowInput::Restore(key)); + } + } + } + + /// What the window's actions, pages and background work send it. + pub(crate) fn input(&self) -> Option> { + self.imp().input.get().cloned() + } + + pub(crate) fn send(&self, message: SessionWindowInput) { + if let Some(input) = self.imp().input.get() { + input.deliver(message); + } + } + + /// On the picker with nothing under way, so a file handed to the + /// application can open here. + pub(crate) fn is_idle(&self) -> bool { + let imp = self.imp(); + self.page_is(|page| matches!(page, SessionPage::Picker)) + && !imp.opening_file.get() + && !imp.dialog_opening.get() + && imp.dialog.try_borrow().is_ok_and(|dialog| dialog.is_none()) + } + + /// Whether the window's claim is a session to reopen: it shows the + /// session or is connecting it. A failed connect's claim is not. + pub(crate) fn keeps_session(&self) -> bool { + self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::Workspace { .. })) + } + + /// Whether the window holds logout back now. + pub(crate) fn is_inhibiting(&self) -> bool { + self.imp() + .inhibitor + .borrow() + .as_ref() + .is_some_and(WindowInhibitor::is_held) + } + + /// A test session has no session manager to grant an inhibition. + #[cfg(test)] + pub(crate) fn set_inhibitor(&self, inhibitor: WindowInhibitor) { + self.imp().inhibitor.replace(Some(inhibitor)); + } + + /// Stop the window's input and close what it still holds: the + /// session it shows, or the connect it runs. Runs once, from the + /// application as the window goes, or from dispose. + pub fn shutdown(&self) { + let imp = self.imp(); + if imp.shut_down.replace(true) { + return; + } + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + if let Some(presenter) = imp.prompt_presenter.take() { + presenter.abort(); + } + self.release_session(); + drop(imp.dialog.take()); + drop(imp.welcome.take()); + drop(imp.inhibitor.take()); + } + + fn state(&self) -> Option<&WindowState> { + self.imp().state.get() + } + + fn close_guards(&self) -> Option<&Rc> { + self.imp().close_guards.get() + } + + fn page_is(&self, matches: impl FnOnce(&SessionPage) -> bool) -> bool { + self.imp().page.try_borrow().is_ok_and(|page| matches(&page)) + } + + /// The page's workspace, while the window shows one. + pub(crate) fn workspace(&self) -> Option { + match &*self.imp().page.try_borrow().ok()? { + SessionPage::Workspace { workspace, .. } => Some(workspace.clone()), + _ => None, + } + } + + fn workspace_handle(&self) -> Option { + self.workspace().and_then(|workspace| workspace.handle()) + } + + fn handle_input(&self, message: SessionWindowInput) { + match message { + SessionWindowInput::OpenSaved(saved) => self.on_open_saved(*saved), + SessionWindowInput::OpenFile(file) => self.on_open_file(file), + SessionWindowInput::FileResolved(resolved) => self.on_file_resolved(*resolved), + SessionWindowInput::Restore(key) => self.on_restore(key), + SessionWindowInput::RestoreSaved { id, saved } => self.on_restore_saved(id, saved.map(|saved| *saved)), + SessionWindowInput::OpenConnectDialog => self.present_connect_dialog(None, None), + SessionWindowInput::ConnectFromDialog(submission) => self.on_connect_from_dialog(*submission), + SessionWindowInput::DialogClosed => drop(self.imp().dialog.take()), + SessionWindowInput::CancelConnect => self.on_cancel_connect(), + SessionWindowInput::RetryConnect => self.on_retry_connect(), + SessionWindowInput::RetryWithNewSecret(kind) => self.on_retry_with_new_secret(kind), + SessionWindowInput::EditConnection => self.on_edit_connection(), + SessionWindowInput::Disconnect => self.on_disconnect(), + SessionWindowInput::TeardownApproved => { + if self.tear_down() { + self.show_page(SessionPage::Picker); + tracing::info!("disconnected"); + } + } + SessionWindowInput::CloseCurrent => self.on_close_current(), + SessionWindowInput::Reconnect => { + if let SessionPage::Workspace { session, .. } = &*self.imp().page.borrow() { + session.signal(MonitorSignal::RetryNow); + } + } + SessionWindowInput::ResetWorkspaceLayout => { + if let Some(input) = self.workspace().and_then(|workspace| workspace.input()) { + input.deliver(WorkspaceInput::ResetWorkspaceLayout); + } + } + SessionWindowInput::CloseRequested(CloseReason::Quit(reply)) => { + if let Some(guards) = self.close_guards() { + guards.request_window_close(self, move |decision: CloseDecision| { + // A quit that went away no longer needs the answer. + let _ = reply.send(decision); + }); + } + } + // Only the workspace on screen speaks for the window: one + // torn down has nothing left to lose. + SessionWindowInput::DirtyChanged(dirty) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().dirty.set(dirty); + self.refresh_inhibitor(); + } + } + SessionWindowInput::SavesInFlightChanged(count) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().saving.set(count > 0); + self.refresh_inhibitor(); + } + } + SessionWindowInput::TitleChanged(title) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.set_title(Some(&title)); + } + } + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { generation, result } => self.on_attempt(generation, result), + }, + SessionWindowInput::LocationChosen { generation, choice } => self.on_location_chosen(generation, choice), + SessionWindowInput::FileLocated { id, path } => self.on_file_located(id, path), + SessionWindowInput::LocatedSaved(located) => self.on_located_saved(*located), + SessionWindowInput::ReloadConnections => self.on_reload_connections(), + SessionWindowInput::ConnectionsLoaded(connections) => self.on_connections_loaded(connections), + SessionWindowInput::ConnectionListUnavailable => self.show_connection_list_banner(), + SessionWindowInput::ResetConnectionList => self.on_reset_connection_list(), + SessionWindowInput::SecretDeleteFailed(id, options) => self.on_secret_delete_failed(id, options), + SessionWindowInput::DeleteConnection(id) => self.on_delete_connection(id), + SessionWindowInput::ConfirmDelete(id, options) => self.on_confirm_delete(id, options), + SessionWindowInput::DuplicateConnection(saved) => self.on_duplicate_connection(*saved), + SessionWindowInput::SetConnectionColor(id, color) => self.on_set_connection_color(id, color), + SessionWindowInput::SetConnectionGroup(id, group) => self.on_set_connection_group(id, group), + SessionWindowInput::ExportConnections => self.on_export_connections(), + SessionWindowInput::ImportConnections => self.on_import_connections(), + SessionWindowInput::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes), + SessionWindowInput::ShowToast(message) => self.show_toast(&message), + } + } + + fn show_toast(&self, message: &str) { + self.add_toast(adw::Toast::new(message)); + } + + fn add_toast(&self, toast: adw::Toast) { + #[cfg(test)] + self.imp().toasts.borrow_mut().push(toast.clone()); + self.imp().toast_overlay.add_toast(toast); + } + + /// Move to `page`. The page left behind drops with what it held, + /// which releases a claim. A workspace left behind is torn down + /// first, so its session is closed rather than cut off. + fn show_page(&self, page: SessionPage) { + self.tear_down(); + let imp = self.imp(); + let child: Option = match &page { + SessionPage::Picker => { + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + imp.picker.get().map(|picker| picker.clone().upcast()) + } + SessionPage::Connecting { source, .. } => Some(ConnectingPage::new(&source_name(source)).upcast()), + SessionPage::ConnectFailed { source, failure, .. } => Some( + ConnectFailedPage::new( + &failure.message, + edit_label(source, failure).as_deref(), + failure.reenter, + ) + .upcast(), + ), + SessionPage::Workspace { workspace, .. } => Some(workspace.clone().upcast()), + }; + let editable = match &page { + SessionPage::ConnectFailed { source, .. } => is_editable(source), + _ => false, + }; + window_actions::sync(self.upcast_ref(), &page, editable); + if !matches!(page, SessionPage::Picker) { + imp.opened_for_file.set(false); + } + let left = imp.page.replace(page); + imp.toast_overlay.set_child(child.as_ref()); + drop(left); + } + + /// A connect worked or failed. A late result, from an attempt the + /// user cancelled or replaced, is closed rather than adopted. + fn on_attempt( + &self, + generation: tablepro_session::connect::AttemptGeneration, + result: Result, + ) { + let imp = self.imp(); + let current = + imp.attempts.accepts(generation) && self.page_is(|page| matches!(page, SessionPage::Connecting { .. })); + if !current { + if let Ok(connected) = result { + self.discard_pending(connected.pending); + } + return; + } + let SessionPage::Connecting { claim, source } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match result { + Ok(connected) => self.adopt(claim, source, connected), + Err(AttemptError::Cancelled) => { + let key = claim.key().clone(); + drop(claim); + self.show_page(SessionPage::Picker); + self.forget_session(&key); + } + Err(AttemptError::AskLocation(kind)) => { + imp.page.replace(SessionPage::Connecting { claim, source }); + self.ask_about_location(generation, &kind); + } + // A session restored at launch that no longer opens is not + // tried again at the next one; a Retry that works records it. + Err(AttemptError::Failed(failure)) => { + tracing::warn!(error = %failure.message, "connect failed"); + let key = claim.key().clone(); + let missing = match &source { + ConnectSource::Saved(saved) => failure.missing_file.clone().map(|missing| (saved.id, missing)), + _ => None, + }; + self.show_page(SessionPage::ConnectFailed { claim, source, failure }); + self.forget_session(&key); + if let Some((id, missing)) = missing { + self.offer_locate(id, missing); + } + } + } + } + + /// Start the session that connected and build its workspace. + fn adopt(&self, claim: SessionClaim, source: ConnectSource, connected: Connected) { + let Connected { + pending, + saved, + warnings, + session_only, + } = connected; + let (Some(state), Some(guards)) = (self.state(), self.close_guards()) else { + self.discard_pending(pending); + return; + }; + let key = claim.key().clone(); + let credentials = state.context.session_credentials(saved.as_ref(), &session_only); + let session = Session::start(pending, state.context.session_deps(credentials)); + claim.attach(&session); + self.remember_session(&key, saved.as_ref()); + if let ConnectSource::Intent(intent) = &source + && let Err(error) = state + .services + .settings + .set_connect_dialog_driver(&intent.request.metadata.driver_id) + { + tracing::warn!(%error, "could not remember the connect dialog driver"); + } + let workspace = Workspace::new(WorkspaceInit { + key: key.clone(), + session: session.clone(), + services: state.services.clone(), + context: state.context.clone(), + window: self.clone().upcast(), + close_guards: guards.clone(), + }); + self.connect_workspace(&workspace); + self.show_page(SessionPage::Workspace { + claim, + session, + workspace: workspace.clone(), + }); + workspace.start(); + drop(self.imp().dialog.take()); + for warning in warnings { + self.show_toast(&warning); + } + if let SessionKey::Saved(id) = key { + self.touch_last_opened(id); + } + } + + fn connect_workspace(&self, workspace: &Workspace) { + let Some(input) = self.input() else { + return; + }; + let dirty = input.clone(); + workspace.connect_dirty_changed(move |_, value| dirty.deliver(SessionWindowInput::DirtyChanged(value))); + let saves = input.clone(); + workspace.connect_saves_in_flight_changed(move |_, count| { + saves.deliver(SessionWindowInput::SavesInFlightChanged(count)); + }); + workspace.connect_title_changed(move |_, title| input.deliver(SessionWindowInput::TitleChanged(title))); + } + + fn discard_pending(&self, pending: PendingSession) { + if let Some(state) = self.state() { + discard_pending(pending, &state.services.tasks, &*state.services.hold()); + } + } + + /// Claim the key, then connect, so a second window asking for the + /// same database brings the first forward instead of connecting. + fn claim(&self, key: SessionKey) -> Option { + let state = self.state()?; + match state.context.sessions.claim(key, self.id()) { + Ok(claim) => Some(claim), + Err(ClaimError::AlreadyOpen { window_id }) => { + // A window that stayed to say the owner could not be + // shown is the user's picker from then on. + let opened_for_file = self.imp().opened_for_file.replace(false); + if self.present_owner(window_id) && opened_for_file { + self.close(); + } + None + } + } + } + + /// False when the owner could not be brought forward, and this + /// window says so instead. + fn present_owner(&self, window_id: u32) -> bool { + let presented = self.state().is_some_and(|state| state.host.present_window(window_id)); + if !presented { + self.show_toast(&crate::i18n::gettext( + "This connection is already open in another window.", + )); + } + presented + } + + fn on_open_saved(&self, saved: SavedConnection) { + let Some(state) = self.state() else { + return; + }; + // A picker another window deleted the entry under still lists it. + let Some(saved) = self.saved_as_stored(&saved) else { + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + self.on_reload_connections(); + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + // This window has its session; another one opens this. + let key = SessionKey::Saved(saved.id); + if let Some(owner) = state.context.sessions.window_for(&key) + && state.host.present_window(owner) + { + return; + } + state.host.open_window(WindowLaunch::Connect(Box::new(saved))); + return; + } + if let Some(claim) = self.claim(SessionKey::Saved(saved.id)) { + self.begin_connect(claim, ConnectSource::Saved(Box::new(saved))); + } + } + + /// The file is resolved off the GTK thread: canonicalizing its path + /// and matching it against the saved connections read the disk. + fn on_open_file(&self, file: gio::File) { + let Some(state) = self.state() else { + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + state.host.open_window(WindowLaunch::OpenFile(file)); + return; + } + self.imp().opening_file.set(true); + let path = file.path(); + let uri = file.uri().to_string(); + let resolving_uri = uri.clone(); + let connections = state.services.storage.connections().clone(); + let resolving = state + .services + .tasks + .spawn_blocking_task(move || resolve_file_blocking(path, resolving_uri, &connections)); + let input = self.input(); + glib::spawn_future_local(async move { + let resolved = resolving.await.unwrap_or_else(|failure| ResolvedFile { + uri, + target: Err(FileResolveError::Interrupted(failure)), + }); + if let Some(input) = input { + input.deliver(SessionWindowInput::FileResolved(Box::new(resolved))); + } + }); + } + + fn on_file_resolved(&self, resolved: ResolvedFile) { + self.imp().opening_file.set(false); + let ResolvedFile { uri, target } = resolved; + self.end_restore(&SessionKey::File(uri.clone())); + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + if let Some(state) = self.state() { + state.host.open_window(WindowLaunch::OpenFile(gio::File::for_uri(&uri))); + } + return; + } + // A file restored under a key it no longer resolves to would be + // reopened under both. + let arrived = SessionKey::File(uri.clone()); + match target { + Ok(FileTarget::Saved(saved)) => { + self.forget_unclaimed(&arrived); + self.on_open_saved(*saved); + } + Ok(FileTarget::File(identity)) => { + if identity.uri != uri { + self.forget_unclaimed(&arrived); + } + if let Some(claim) = self.claim(SessionKey::File(identity.uri.clone())) { + self.begin_connect(claim, ConnectSource::File(identity)); + } + } + Err(error) => { + tracing::warn!(%error, %uri, "could not open the file"); + self.forget_unclaimed(&arrived); + let Some(claim) = self.claim(SessionKey::File(uri.clone())) else { + return; + }; + let failure = ConnectFailure::new(file_failure_message(&uri, &error)); + self.show_page(SessionPage::ConnectFailed { + claim, + source: ConnectSource::File(FileIdentity::new(None, uri)), + failure, + }); + } + } + } + + fn on_connect_from_dialog(&self, submission: ConnectSubmission) { + let ConnectSubmission { request, pending_save } = submission; + match &*self.imp().page.borrow() { + SessionPage::Picker | SessionPage::ConnectFailed { .. } => {} + SessionPage::Connecting { .. } | SessionPage::Workspace { .. } => { + tracing::warn!("a connect from the dialog reached a window with a session"); + return; + } + } + // A failed connect's claim goes first: the dialog may be editing + // that same connection. + self.show_page(SessionPage::Picker); + let key = SessionKey::Saved(pending_save.connection.id); + let Some(state) = self.state() else { + return; + }; + match state.context.sessions.claim(key.clone(), self.id()) { + Ok(claim) => self.begin_connect( + claim, + ConnectSource::Intent(Box::new(ConnectIntent { + key, + request, + pending_save: Some(pending_save), + })), + ), + Err(ClaimError::AlreadyOpen { window_id }) => { + // The edits are kept for the next connect; the session + // open in the other window goes on as it was. + self.persist_pending_save(pending_save); + self.present_owner(window_id); + } + } + } + + fn begin_connect(&self, claim: SessionClaim, source: ConnectSource) { + self.start_connect(claim, source, None, CredentialDemand::default()); + } + + fn begin_connect_with(&self, claim: SessionClaim, source: ConnectSource, demand: CredentialDemand) { + self.start_connect(claim, source, None, demand); + } + + /// `answer` is how the user chose to open a database file on a + /// filesystem whose locking SQLite cannot trust, once asked. + fn start_connect( + &self, + claim: SessionClaim, + source: ConnectSource, + answer: Option, + demand: CredentialDemand, + ) { + let Some(state) = self.state() else { + return; + }; + let check = self.file_check(&source, answer); + let ticket = self.imp().attempts.begin(); + let generation = ticket.generation(); + let cancel = ticket.cancel().clone(); + let work = connect_work(state, &source, check, demand, cancel); + self.show_page(SessionPage::Connecting { claim, source }); + let input = self.input(); + let (tasks, hold) = (state.services.tasks.clone(), state.services.hold()); + crate::runtime::spawn_and_deliver(&state.services.tasks, work, move |outcome| { + let result = outcome.unwrap_or_else(|failure| { + Err(AttemptError::Failed(ConnectFailure::new( + crate::ui::error_text::task_failure_message(&failure), + ))) + }); + let attempt = SessionWindowCommand::Attempt { generation, result }; + let Some(input) = input else { + return; + }; + if let Err(unsent) = input.send(SessionWindowInput::Command(Box::new(attempt))) + && let Some(pending) = unadopted(unsent.0) + { + discard_pending(pending, &tasks, &*hold); + } + }); + } + + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + fn on_cancel_connect(&self) { + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::ConnectFailed { .. })) { + let key = self.imp().page.borrow().key().cloned(); + self.show_page(SessionPage::Picker); + if let Some(key) = key { + self.forget_session(&key); + } + } + } + + fn on_retry_connect(&self) { + let imp = self.imp(); + if !self.page_is(|page| matches!(page, SessionPage::ConnectFailed { .. })) { + return; + } + let SessionPage::ConnectFailed { claim, source, .. } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match source { + // The file is resolved again, since it may have moved. + ConnectSource::File(identity) => { + drop(claim); + self.show_page(SessionPage::Picker); + self.on_open_file(gio::File::for_uri(&identity.uri)); + } + ConnectSource::Saved(saved) => self.reconnect_saved(claim, *saved, CredentialDemand::default()), + source => self.begin_connect(claim, source), + } + } + + /// Connect a failed saved connection again, as it is saved now: the + /// entry may have been edited or deleted in another window since. + fn reconnect_saved(&self, claim: SessionClaim, saved: SavedConnection, demand: CredentialDemand) { + match self.saved_as_stored(&saved) { + Some(current) => self.begin_connect_with(claim, ConnectSource::Saved(Box::new(current)), demand), + None => { + drop(claim); + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + } + } + + fn on_edit_connection(&self) { + let prefill = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { source, failure, .. } => connections::prefill_for(source) + .map(|prefill| (prefill, failure.remedy.map(crate::ui::connect_dialog::remedy_field))), + _ => None, + }; + if let Some((prefill, focus)) = prefill { + self.present_connect_dialog(Some(prefill), focus); + } + } + + fn on_disconnect(&self) { + let (Some(handle), Some(guards)) = (self.workspace_handle(), self.close_guards()) else { + return; + }; + guards.begin_teardown(); + if guards.may_tear_down(&handle) { + self.send(SessionWindowInput::TeardownApproved); + return; + } + let input = self.input(); + guards.ask_tear_down(self.upcast_ref(), &handle, move |decision| { + if decision == CloseDecision::Approved + && let Some(input) = input + { + input.deliver(SessionWindowInput::TeardownApproved); + } + }); + } + + /// Ctrl+W: the workspace closes its current tab, and with no + /// workspace the window closes, through its close prompts. + fn on_close_current(&self) { + match self.workspace().and_then(|workspace| workspace.input()) { + Some(input) => input.deliver(WorkspaceInput::CloseActiveWorkspaceTab), + None => self.close(), + } + } + + /// The close prompts approved the window's close, which goes ahead + /// once this returns: what it holds is written and closed first. + fn on_close_approved(&self) { + if let Some(state) = self.state() { + crate::ui::window_geometry::persist(self.upcast_ref(), &state.services.settings); + } + self.release_session(); + } + + /// Close the session the window shows, or stop the connect it runs. + fn release_session(&self) { + self.abandon_restore(); + if self.tear_down() { + return; + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + let left = match self.imp().page.try_borrow_mut() { + Ok(mut page) => std::mem::take(&mut *page), + Err(_) => { + tracing::warn!("the window closed while its page was in use"); + return; + } + }; + let key = left.key().cloned(); + drop(left); + if let Some(key) = key { + self.forget_session(&key); + } + } + + fn refresh_inhibitor(&self) { + let imp = self.imp(); + let reason = InhibitReason::for_state(imp.dirty.get(), imp.saving.get()); + if let Some(inhibitor) = imp.inhibitor.borrow_mut().as_mut() { + inhibitor.update(reason); + } + } + + #[cfg(test)] + pub(crate) fn page_name(&self) -> &'static str { + self.imp().page.borrow().name() + } +} + +/// The connection a message carries that its window will never adopt +/// now. It is closed rather than dropped, so the server sees it end. +fn unadopted(message: SessionWindowInput) -> Option { + match message { + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { result, .. } => result.ok().map(|connected| connected.pending), + }, + _ => None, + } +} + +/// Throw away a connection nobody adopted, closing it under the close +/// deadline while the process stays alive for it. +fn discard_pending(pending: PendingSession, tasks: &tablepro_session::runtime::Tasks, hold: &dyn ProcessHold) { + crate::runtime::spawn_held(tasks, hold, async move { + let report = pending.discard().await; + tracing::debug!(?report, "closed a connection that was not adopted"); + }); +} + +/// Supersede the connect in flight, not only cancel it: a driver +/// blocked in a socket call can still connect afterwards, and that late +/// session must not be adopted. +fn stop_connecting(attempts: &tablepro_session::ConnectAttempts) { + drop(attempts.begin()); + attempts.cancel(); +} + +/// The connect a source makes, off the GTK thread, once `check` finds +/// its file safe to open. Reading the keyring, asking for what it lacks +/// and connecting all happen there; only the outcome comes back. +fn connect_work( + state: &WindowState, + source: &ConnectSource, + check: FileCheck, + demand: CredentialDemand, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let connecting = connect_source(state, source, check.read_only(), demand, cancel.clone()); + check.guard(&state.services.tasks, cancel, connecting) +} + +/// `read_only` opens it read-only whatever the source says. +fn connect_source( + state: &WindowState, + source: &ConnectSource, + read_only: bool, + demand: CredentialDemand, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let context = &state.context; + let connector = context.connector.clone(); + let interaction = context.attended(); + match source { + ConnectSource::Saved(saved) => { + let saved = (**saved).clone(); + let mut opened = saved.clone(); + opened.read_only |= read_only; + let drivers = context.drivers.clone(); + let credentials = context.credentials_for(Some(&saved)); + async move { + let connected = connect_flow::connect_saved( + &opened, + &drivers, + &*credentials, + &*connector, + interaction, + demand, + cancel, + ) + .await; + match connected { + Ok(login) => Ok(Connected { + pending: login.pending, + saved: Some(saved), + warnings: login + .refused + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + session_only: login.session_only, + }), + Err(error) => Err(match attempt_error(&error) { + AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { + reenter: secret_retry::refused_secret(&error, &saved), + ..failure + }), + cancelled => cancelled, + }), + } + } + .boxed() + } + ConnectSource::Intent(intent) => { + let mut request = intent.request.clone(); + request.workspace_settings.read_only |= read_only; + let Some(pending_save) = intent.pending_save.clone() else { + return async move { + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(|pending| Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + }) + .map_err(|error| attempt_error(&error)) + } + .boxed(); + }; + let secrets = context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + async move { + let saving = connect_flow::connect_then_save( + &*connector, + request, + interaction, + cancel, + &*secrets, + &writes, + pending_save, + ) + .await; + match saving { + Ok((pending, report)) => Ok(Connected { + pending, + // A keyring refusal must not be silent: the + // connection opened, but the password will be + // asked for next time. + warnings: report + .secret_failures + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + session_only: report.secret_failures.iter().map(|(kind, _)| *kind).collect(), + saved: Some(report.saved), + }), + Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error)), + Err(connect_flow::SubmitError::Save(error)) => { + Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + )))) + } + } + } + .boxed() + } + ConnectSource::File(identity) => { + let request = resolve_file_identity::file_request(identity, &context.drivers, read_only); + async move { + let request = request.map_err(|error| attempt_error(&error))?; + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(|pending| Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + }) + .map_err(|error| attempt_error(&error)) + } + .boxed() + } + } +} + +fn attempt_error(error: &ConnectError) -> AttemptError { + match error { + ConnectError::Cancelled => AttemptError::Cancelled, + error => AttemptError::Failed(ConnectFailure { + message: crate::ui::error_text::connect_error_message(error), + remedy: crate::ui::error_text::connect_remedy(error), + reenter: None, + missing_file: match error { + ConnectError::Driver(DriverError::FileNotFound { path }) => Some(path.clone()), + _ => None, + }, + }), + } +} + +/// What the connecting page names: the connection, or the file. +fn source_name(source: &ConnectSource) -> String { + match source { + ConnectSource::Saved(saved) => saved.name.clone(), + ConnectSource::Intent(intent) => intent.request.metadata.name.clone(), + ConnectSource::File(identity) => file_display_name(&identity.uri), + } +} + +fn file_display_name(uri: &str) -> String { + let file = gio::File::for_uri(uri); + file.basename() + .map_or_else(|| uri.to_owned(), |name| name.to_string_lossy().into_owned()) +} + +/// Only a connection the connect dialog shows can be edited from a +/// failed connect. +fn is_editable(source: &ConnectSource) -> bool { + matches!(source, ConnectSource::Saved(_) | ConnectSource::Intent(_)) +} + +/// The failed page's Edit button: named after the setting that fixes a +/// TLS failure when there is one. +fn edit_label(source: &ConnectSource, failure: &ConnectFailure) -> Option { + is_editable(source).then(|| { + failure + .remedy + .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()) + }) +} + +fn file_failure_message(uri: &str, error: &FileResolveError) -> String { + let name = gio::File::for_uri(uri).parse_name().to_string(); + match error { + FileResolveError::NotLocal => crate::i18n::gettext_f( + "{file} is not on this computer. Copy it to a local folder to open it.", + &[("file", &name)], + ), + FileResolveError::NotFound(_) => crate::i18n::gettext_f("{file} could not be found.", &[("file", &name)]), + FileResolveError::Interrupted(failure) => crate::i18n::gettext_f( + "{file} could not be opened. {reason}", + &[ + ("file", &name), + ("reason", &crate::ui::error_text::task_failure_message(failure)), + ], + ), + } +} diff --git a/linux/crates/app/src/ui/session_window/open_file_tests.rs b/linux/crates/app/src/ui/session_window/open_file_tests.rs new file mode 100644 index 0000000000..004896ed12 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/open_file_tests.rs @@ -0,0 +1,80 @@ +//! Files the application hands to its windows, from Open With or the +//! command line. + +use gtk4::prelude::*; + +use super::tests::{TestWindow, WAIT}; +use super::*; +use crate::test_support::wait_until; + +/// An empty file, which SQLite reads as an empty database. +fn database(dir: &tempfile::TempDir, name: &str) -> gio::File { + let file = gio::File::for_path(dir.path().join(name)); + file.replace_contents(b"", None, false, gio::FileCreateFlags::NONE, gio::Cancellable::NONE) + .expect("seed the database file"); + file +} + +fn other_windows(test: &TestWindow) -> Vec { + test.app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .filter(|window| window != &test.window) + .collect() +} + +#[gtk4::test] +fn an_opened_file_goes_to_the_idle_window_then_its_owner_then_a_new_window() { + let test = TestWindow::picker("OpenFiles"); + let dir = tempfile::tempdir().expect("tempdir"); + let first = database(&dir, "first.sqlite3"); + let second = database(&dir, "second.sqlite3"); + + test.app.open(std::slice::from_ref(&first), ""); + test.wait_for_page("workspace"); + assert_eq!(test.app.windows().len(), 1, "the idle picker did not take the file"); + + test.window.set_visible(false); + test.app.open(&[first], ""); + wait_until(WAIT, || test.window.is_visible()).expect("the file's window to come forward"); + assert_eq!(test.app.windows().len(), 1, "a second window opened on the same file"); + assert_eq!(test.connector.calls().len(), 1, "the open file was connected to again"); + + test.app.open(&[second], ""); + wait_until(WAIT, || { + other_windows(&test) + .iter() + .any(|window| window.page_name() == "workspace") + }) + .expect("a new window on the second file"); + assert_eq!(test.app.windows().len(), 2); +} + +#[gtk4::test] +fn a_window_opened_for_a_file_another_window_has_closes_again() { + let test = TestWindow::picker("OpenFileTwice"); + let dir = tempfile::tempdir().expect("tempdir"); + let file = database(&dir, "app.sqlite3"); + test.window.send(SessionWindowInput::OpenFile(file.clone())); + test.wait_for_page("workspace"); + test.window.set_visible(false); + + assert!(test.app.open_window(WindowLaunch::OpenFile(file)).is_some()); + wait_until(WAIT, || test.app.windows().len() == 1).expect("the extra window to close"); + + assert!(test.window.is_visible(), "the file's window stayed hidden"); + assert_eq!(test.connector.calls().len(), 1); +} + +#[gtk4::test] +fn a_window_resolving_a_file_takes_no_other() { + let test = TestWindow::picker("OpenFileBusy"); + assert!(test.window.is_idle()); + + test.window.imp().opening_file.set(true); + assert!(!test.window.is_idle()); + test.window.imp().opening_file.set(false); + test.window.send(SessionWindowInput::OpenConnectDialog); + wait_until(WAIT, || !test.window.is_idle()).expect("the connect dialog to count as busy"); +} diff --git a/linux/crates/app/src/ui/session_window/picker_page.rs b/linux/crates/app/src/ui/session_window/picker_page.rs new file mode 100644 index 0000000000..e71d85659d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/picker_page.rs @@ -0,0 +1,58 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/picker-page.ui")] + pub struct PickerPage { + #[template_child] + pub connection_list_banner: TemplateChild, + #[template_child] + pub welcome_slot: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PickerPage { + const NAME: &'static str = "TableProPickerPage"; + type Type = super::PickerPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for PickerPage {} + impl WidgetImpl for PickerPage {} + impl BinImpl for PickerPage {} +} + +glib::wrapper! { + /// The saved connections, with a header bar of their own and the + /// banner that says when the list could not be read. + pub struct PickerPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl PickerPage { + pub fn new(welcome: &impl IsA) -> Self { + let page: Self = glib::Object::new(); + page.imp().welcome_slot.set_child(Some(welcome)); + page + } + + pub fn banner(&self) -> adw::Banner { + self.imp().connection_list_banner.get() + } +} diff --git a/linux/crates/app/src/ui/session_window/resolve_file_identity.rs b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs new file mode 100644 index 0000000000..589d2741c9 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs @@ -0,0 +1,106 @@ +//! What a database file handed to TablePro connects with, once it is +//! resolved off the GTK thread. + +use std::path::PathBuf; +use std::sync::Arc; + +use tablepro_core::{DatabaseDriver, DriverError, DriverRegistry, FileEndpoint, WorkspaceSettings}; +use tablepro_session::registry::FileIdentity; +use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, RequestTarget}; + +pub(crate) use crate::services::file_resolution::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; + +/// The request that opens a file nobody saved, through the driver that +/// opens files. +pub(crate) fn file_request( + identity: &FileIdentity, + drivers: &DriverRegistry, + read_only: bool, +) -> Result { + let driver = file_driver(drivers).ok_or_else(|| ConnectError::UnknownDriver("sqlite".to_owned()))?; + let path = identity + .canonical_path + .clone() + .unwrap_or_else(|| PathBuf::from(&identity.uri)); + let name = path + .file_name() + .map_or_else(|| identity.uri.clone(), |name| name.to_string_lossy().into_owned()); + let endpoint = FileEndpoint::open_existing(path).map_err(DriverError::from)?; + Ok(ConnectRequest { + metadata: ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name, + driver_id: driver.id().to_owned(), + }, + driver, + target: RequestTarget::File(endpoint), + workspace_settings: WorkspaceSettings::workspace(read_only), + }) +} + +fn file_driver(drivers: &DriverRegistry) -> Option> { + drivers + .get("sqlite") + .filter(|driver| driver.capabilities().is_file_based()) + .or_else(|| { + drivers + .iter() + .find(|driver| driver.capabilities().is_file_based()) + .cloned() + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::services::file_resolution::resolve_file_identity_blocking; + + #[gtk4::test] + fn symlinked_path_canonicalises() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + let link = root.path().join("link.db"); + std::os::unix::fs::symlink(&database, &link).expect("symlink"); + let uri = format!("file://{}", link.display()); + + let resolving = runtime + .tasks() + .spawn_blocking_task(move || resolve_file_identity_blocking(Some(&link), &uri)); + let identity = crate::test_support::finish_on_main(Duration::from_secs(10), resolving) + .expect("the resolve to end") + .expect("the task to finish") + .expect("the file resolves"); + + let canonical = std::fs::canonicalize(&database).expect("canonical"); + assert_eq!(identity.canonical_path.as_deref(), Some(canonical.as_path())); + assert!( + identity.uri.ends_with("/app.db"), + "the key followed the link, not the file: {}", + identity.uri + ); + runtime.shutdown(); + } + + #[test] + fn an_unsaved_file_opens_through_the_driver_that_opens_files() { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_test_support::FakeDriver::connecting( + &tablepro_test_support::FakeDriver::FILE_CAPABILITIES, + tablepro_test_support::ConnectionScript::default(), + ))); + let identity = FileIdentity::new(Some(PathBuf::from("/srv/app.db")), "file:///srv/app.db".to_owned()); + + let request = file_request(&identity, &drivers, false).expect("a request"); + let read_only = file_request(&identity, &drivers, true).expect("a read-only request"); + + assert_eq!(request.metadata.name, "app.db"); + assert!(matches!(request.target, RequestTarget::File(_))); + assert!(!request.workspace_settings.read_only); + assert!(read_only.workspace_settings.read_only); + assert!(file_request(&identity, &DriverRegistry::new(), false).is_err()); + } +} diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs new file mode 100644 index 0000000000..c65d1853fc --- /dev/null +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -0,0 +1,482 @@ +//! A failed connect whose secret the server refused offers to take it +//! again, and the retry asks for it rather than reading the keyring's +//! copy, which is the one that was just refused. + +use gtk4::subclass::prelude::*; +use tablepro_core::credentials::SecretKind; +use tablepro_core::{AuthMode, DriverError, SshFailure, TransportError}; +use tablepro_session::{ConnectError, ConnectSource}; +use tablepro_storage::{CredentialDemand, SavedConnection, SavedSshAuth}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// The failed page's Enter Password: connect again, asking for + /// `kind` whatever the keyring holds. A stale activation, from a + /// page that has moved on, does nothing. + pub(super) fn on_retry_with_new_secret(&self, kind: SecretKind) { + let imp = self.imp(); + let offered = self.page_is(|page| { + matches!( + page, + SessionPage::ConnectFailed { + source: ConnectSource::Saved(_), + failure, + .. + } if failure.reenter == Some(kind) + ) + }); + if !offered { + return; + } + let SessionPage::ConnectFailed { + claim, + source: ConnectSource::Saved(saved), + .. + } = imp.page.replace(SessionPage::Picker) + else { + return; + }; + self.reconnect_saved(claim, *saved, CredentialDemand::reenter(kind)); + } +} + +/// The secret of `saved` that `error` says the server refused, when the +/// user can type it in TablePro. +/// +/// A key's passphrase counts only when the keyring holds it: without +/// one, ssh asks for it at each connect anyway. A keyring that could +/// not be read refused nothing, so it is not offered: the retry's +/// prompt would say the server turned the secret down. +pub(super) fn refused_secret(error: &ConnectError, saved: &SavedConnection) -> Option { + match error { + ConnectError::Driver(DriverError::Auth { .. }) => { + (saved.auth_mode == AuthMode::Password).then_some(SecretKind::DatabasePassword) + } + ConnectError::Driver(DriverError::Transport(TransportError::Ssh(SshFailure::AuthenticationRejected { + .. + }))) => match saved.ssh.as_ref().map(|ssh| &ssh.auth)? { + SavedSshAuth::Password => Some(SecretKind::SshPassword), + SavedSshAuth::PrivateKey { + has_passphrase: true, .. + } => Some(SecretKind::SshPassphrase), + SavedSshAuth::PrivateKey { .. } | SavedSshAuth::Agent | SavedSshAuth::KeyboardInteractive => None, + }, + _ => None, + } +} + +/// The label of the failed page's button for typing `kind` again. +pub(super) fn reenter_label(kind: SecretKind) -> String { + match kind { + SecretKind::DatabasePassword => crate::i18n::gettext("Enter Password…"), + SecretKind::SshPassword => crate::i18n::gettext("Enter SSH Password…"), + SecretKind::SshPassphrase => crate::i18n::gettext("Enter Passphrase…"), + } +} + +/// The kind an action target names, as `SecretKind::attribute` spells +/// it. +pub(super) fn secret_kind_named(name: &str) -> Option { + SecretKind::ALL.into_iter().find(|kind| kind.attribute() == name) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::TlsConfig; + use tablepro_storage::{KeyringError, SavedSshConfig}; + use uuid::Uuid; + + use super::*; + + fn saved(auth_mode: AuthMode, ssh: Option) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode, + ssh: ssh.map(|auth| SavedSshConfig { + host: "bastion.example.com".to_owned(), + port: None, + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth, + }), + last_opened_at: None, + color: None, + group: None, + } + } + + fn ssh_rejected() -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::AuthenticationRejected { + user: "deploy".to_owned(), + host: "bastion.example.com".to_owned(), + methods: vec!["password".to_owned()], + }, + ))) + } + + #[test] + fn a_refused_database_login_offers_the_database_password() { + let refused = ConnectError::Driver(DriverError::auth(None)); + + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Password, None)), + Some(SecretKind::DatabasePassword) + ); + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Kerberos, None)), + None, + "a Kerberos login has no password to type" + ); + } + + #[test] + fn a_refused_ssh_login_offers_the_secret_the_keyring_holds_for_it() { + let password = saved(AuthMode::Password, Some(SavedSshAuth::Password)); + let stored_passphrase = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: true, + }), + ); + let asked_by_ssh = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: false, + }), + ); + let agent = saved(AuthMode::Password, Some(SavedSshAuth::Agent)); + + assert_eq!( + refused_secret(&ssh_rejected(), &password), + Some(SecretKind::SshPassword) + ); + assert_eq!( + refused_secret(&ssh_rejected(), &stored_passphrase), + Some(SecretKind::SshPassphrase) + ); + assert_eq!(refused_secret(&ssh_rejected(), &asked_by_ssh), None); + assert_eq!(refused_secret(&ssh_rejected(), &agent), None); + } + + #[test] + fn an_unreadable_keyring_offers_nothing_to_type_again() { + let unreadable = ConnectError::Keyring(KeyringError::Secret { + kind: SecretKind::SshPassword, + error: tablepro_core::credentials::SecretError::Backend { + detail: "broken".to_owned(), + }, + }); + + assert_eq!( + refused_secret(&unreadable, &saved(AuthMode::Password, Some(SavedSshAuth::Password))), + None, + "a keyring read error was offered as a refused password" + ); + } + + #[test] + fn failures_a_secret_cannot_fix_offer_nothing() { + let lost = ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + }); + + assert_eq!(refused_secret(&lost, &saved(AuthMode::Password, None)), None); + assert_eq!( + refused_secret(&ConnectError::Cancelled, &saved(AuthMode::Password, None)), + None + ); + } + + #[test] + fn every_kind_round_trips_through_its_action_target() { + for kind in SecretKind::ALL { + assert_eq!(secret_kind_named(kind.attribute()), Some(kind)); + } + assert_eq!(secret_kind_named("password"), None); + } +} + +#[cfg(test)] +mod window_tests { + use gtk4::glib; + use gtk4::prelude::*; + use libadwaita as adw; + use libadwaita::prelude::*; + use secrecy::{ExposeSecret, SecretString}; + use tablepro_core::Credentials; + use tablepro_core::credentials::{SecretError, SecretVault}; + use tablepro_session::RequestTarget; + use tablepro_session::fakes::{EstablishCall, FakeCredentialSource}; + use tablepro_test_support::Call; + + use super::*; + use crate::test_support::{descendants, finish_on_main, wait_until}; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::connect_failed_page::ConnectFailedPage; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT, refused}; + + fn open_prompt(window: &SessionWindow) -> Option { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .next() + } + + /// Type `secret` into the credential prompt the window shows and + /// press its Connect, as the user would. Returns what the prompt + /// said. + fn answer_prompt(window: &SessionWindow, secret: &str, remember: bool) -> String { + let mut shown = None; + wait_until(WAIT, || { + shown = open_prompt(window); + shown.is_some() + }) + .expect("a credential prompt"); + let alert = shown.expect("the prompt"); + let body = alert.body().to_string(); + let rows = alert.extra_child().expect("the prompt's rows"); + for row in descendants(&rows) { + if let Some(entry) = row.downcast_ref::() { + entry.set_text(secret); + } else if let Some(switch) = row.downcast_ref::() { + switch.set_active(remember); + } + } + assert!( + crate::workspace::answer(window, "accept"), + "the prompt has no Connect button" + ); + body + } + + fn password_of(call: &EstablishCall) -> Option { + match &call.request.target { + RequestTarget::Network { + credentials: Credentials::Password { password, .. }, + .. + } => Some(password.expose_secret().to_owned()), + _ => None, + } + } + + fn failed_page(window: &SessionWindow) -> ConnectFailedPage { + window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page") + } + + fn toasts(window: &SessionWindow) -> Vec { + window + .imp() + .toasts + .borrow() + .iter() + .filter_map(|toast| toast.title().map(|title| title.to_string())) + .collect() + } + + fn password_loads(test: &TestWindow) -> usize { + test.vault + .calls() + .iter() + .filter(|call| **call == Call::Load(test.saved.id, SecretKind::DatabasePassword)) + .count() + } + + #[gtk4::test] + fn a_connect_the_user_starts_may_ask_for_what_is_missing() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Attended", + Script { + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes(), [true]); + assert!(test.connector.calls().iter().all(|call| call.attended)); + } + + #[gtk4::test] + fn enter_password_asks_again_and_skips_the_refused_secret() { + let test = TestWindow::with_script( + "EnterPassword", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the old password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let button = failed_page(&test.window) + .reenter_button() + .expect("an Enter Password button"); + assert_eq!(button.label().as_deref(), Some("Enter Password…")); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| action.is_enabled()) + ); + let loads = password_loads(&test); + + button.emit_clicked(); + let asked = answer_prompt(&test.window, "new", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("refused"), "{asked}"); + assert_eq!(password_loads(&test), loads, "the refused password was read again"); + let calls = test.connector.calls(); + assert_eq!(password_of(&calls[0]).as_deref(), Some("old")); + assert_eq!(password_of(&calls[1]).as_deref(), Some("new")); + assert_eq!( + test.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("new"), + "the password that worked was not remembered" + ); + } + + #[gtk4::test] + fn a_reconnect_uses_the_password_typed_without_remember() { + let test = TestWindow::with_script( + "ReenterReconnect", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the old password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + failed_page(&test.window) + .reenter_button() + .expect("an Enter Password button") + .emit_clicked(); + answer_prompt(&test.window, "new", false); + test.wait_for_page("workspace"); + + WidgetExt::activate_action(&test.window, "win.reconnect", None).expect("win.reconnect"); + wait_until(WAIT, || test.connector.calls().len() == 3).expect("the reconnect"); + + let calls = test.connector.calls(); + assert_eq!( + password_of(&calls[2]).as_deref(), + Some("new"), + "the reconnect logged in with the refused password" + ); + assert!(open_prompt(&test.window).is_none(), "the reconnect asked again"); + assert_eq!( + test.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("old"), + "a password typed without Remember was kept" + ); + } + + #[gtk4::test] + fn a_failure_no_password_fixes_offers_no_enter_password() { + let lost = tablepro_session::fakes::FakeEstablish::Fail(ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + })); + let test = TestWindow::with_script( + "NoEnterPassword", + Script { + establish: vec![lost], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the password stored"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + assert!(failed_page(&test.window).reenter_button().is_none()); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| !action.is_enabled()) + ); + } + + #[gtk4::test] + fn adoption_stores_remembered_secret_and_toasts_failure() { + let test = TestWindow::with_script( + "RememberRefused", + Script { + network: true, + ..Script::default() + }, + ); + test.vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + let asked = answer_prompt(&test.window, "typed", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("No password is saved"), "{asked}"); + assert_eq!(password_of(&test.connector.calls()[0]).as_deref(), Some("typed")); + assert!( + test.vault + .calls() + .contains(&Call::Store(test.saved.id, SecretKind::DatabasePassword)), + "the typed password was not offered to the keyring" + ); + wait_until(WAIT, || { + toasts(&test.window) + .iter() + .any(|toast| toast.starts_with("Password not saved in the keyring")) + }) + .expect("the refused save to be reported"); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_page.rs b/linux/crates/app/src/ui/session_window/session_page.rs new file mode 100644 index 0000000000..45c29823f0 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_page.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use tablepro_session::{ConnectSource, Session, SessionClaim, SessionKey}; + +use super::session_window_command::ConnectFailure; +use crate::ui::workspace::Workspace; + +/// Which page a window shows, and what it holds for that page. The +/// claim on the session's key lives here, so leaving the page releases +/// it. +#[derive(Default)] +pub(crate) enum SessionPage { + /// The saved connections, to pick one from. + #[default] + Picker, + Connecting { + claim: SessionClaim, + source: ConnectSource, + }, + ConnectFailed { + claim: SessionClaim, + source: ConnectSource, + failure: ConnectFailure, + }, + Workspace { + claim: SessionClaim, + session: Arc, + workspace: Workspace, + }, +} + +impl SessionPage { + /// The key the page holds a claim on. + pub(crate) fn key(&self) -> Option<&SessionKey> { + match self { + Self::Picker => None, + Self::Connecting { claim, .. } | Self::ConnectFailed { claim, .. } | Self::Workspace { claim, .. } => { + Some(claim.key()) + } + } + } + + #[cfg(test)] + pub(crate) fn name(&self) -> &'static str { + match self { + Self::Picker => "picker", + Self::Connecting { .. } => "connecting", + Self::ConnectFailed { .. } => "connect-failed", + Self::Workspace { .. } => "workspace", + } + } +} + +impl std::fmt::Debug for SessionPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Picker => f.write_str("Picker"), + Self::Connecting { claim, .. } => f.debug_struct("Connecting").field("key", claim.key()).finish(), + Self::ConnectFailed { claim, failure, .. } => f + .debug_struct("ConnectFailed") + .field("key", claim.key()) + .field("failure", failure) + .finish(), + Self::Workspace { claim, .. } => f.debug_struct("Workspace").field("key", claim.key()).finish(), + } + } +} diff --git a/linux/crates/app/src/ui/session_window/session_restore.rs b/linux/crates/app/src/ui/session_window/session_restore.rs new file mode 100644 index 0000000000..f2dd688975 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_restore.rs @@ -0,0 +1,327 @@ +//! Reopening a session that was open when the application last quit, +//! and keeping the record of open sessions in step with the window. + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_session::SessionKey; +use tablepro_storage::SavedConnection; +use uuid::Uuid; + +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::DeliverInput; + +impl SessionWindow { + /// A file opens as the file manager would open it. A saved + /// connection is looked up first, since it may have been deleted + /// since the quit. + pub(super) fn on_restore(&self, key: SessionKey) { + let id = match key { + SessionKey::File(uri) => { + self.on_open_file(gio::File::for_uri(&uri)); + return; + } + SessionKey::Saved(id) => id, + }; + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); + glib::spawn_future_local(async move { + match reading.await { + Ok(Ok(connections)) => { + let saved = connections.iter().find(|saved| saved.id == id).cloned().map(Box::new); + input.deliver(SessionWindowInput::RestoreSaved { id, saved }); + } + // The key stays recorded: the list may read again once + // the user resets it, which the picker's banner offers. + Ok(Err(error)) => { + tracing::warn!(%error, "a session was not restored: the saved connections could not be read") + } + Err(failure) => { + tracing::warn!(%failure, "a session was not restored: the connections read task failed") + } + } + }); + } + + pub(super) fn on_restore_saved(&self, id: Uuid, saved: Option) { + self.end_restore(&SessionKey::Saved(id)); + match saved { + Some(saved) => self.on_open_saved(saved), + None => { + self.forget_session(&SessionKey::Saved(id)); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + } + } + + /// Record the session the window adopted when a later launch can + /// open it again: a file, or a connection the saved list holds. One + /// that was never saved has nothing to reopen it from. + pub(super) fn remember_session(&self, key: &SessionKey, saved: Option<&SavedConnection>) { + let restorable = match key { + SessionKey::File(_) => true, + SessionKey::Saved(_) => saved.is_some(), + }; + if restorable && let Some(state) = self.state() { + state.services.open_sessions().add(key.clone()); + } + } + + pub(super) fn forget_session(&self, key: &SessionKey) { + if let Some(state) = self.state() { + state.services.open_sessions().remove(key); + } + } + + /// The restore has its answer, and the page the window shows next + /// speaks for the key. + pub(super) fn end_restore(&self, key: &SessionKey) { + let restoring = &self.imp().restoring; + if restoring.borrow().as_ref() == Some(key) { + restoring.replace(None); + } + } + + /// A window closed before its restore had an answer holds no claim + /// whose release would forget the key. + pub(super) fn abandon_restore(&self) { + if let Some(key) = self.imp().restoring.take() { + self.forget_unclaimed(&key); + } + } + + /// Forget a key this window has no claim on, unless another window + /// shows that session. + pub(super) fn forget_unclaimed(&self, key: &SessionKey) { + if let Some(state) = self.state() + && state.context.sessions.window_for(key).is_none() + { + state.services.open_sessions().remove(key); + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gtk4::prelude::*; + use tablepro_session::fakes::{FakeConnector, FakeEstablish}; + use tablepro_session::{PendingSave, SessionKey}; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::application::WindowLaunch; + use crate::test_support::wait_until; + use crate::ui::connect_dialog::ConnectSubmission; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; + use crate::workspace::OpenSessionsRecord; + + impl TestWindow { + fn recorded(&self) -> Vec { + self.services.open_sessions().keys() + } + + /// Open a window on `key` the way a launch restoring it does. + fn restored_window(&self, key: &SessionKey) -> SessionWindow { + self.services + .open_sessions() + .restore(OpenSessionsRecord::from_keys([key])); + self.app + .open_window(WindowLaunch::Restore(key.clone())) + .and_then(|window| window.downcast::().ok()) + .expect("a restored window") + } + + /// The URI a file under the test's directory is keyed by. + fn file_key(&self, name: &str) -> SessionKey { + let dir = std::fs::canonicalize(self.dir()).expect("the canonical directory"); + let uri = glib::filename_to_uri(dir.join(name), None).expect("a file URI"); + SessionKey::File(uri.to_string()) + } + } + + fn wait_for(window: &SessionWindow, page: &str) { + wait_until(WAIT, || window.page_name() == page) + .unwrap_or_else(|_| panic!("the window stayed on {}, not {page}", window.page_name())); + } + + fn submission(test: &TestWindow) -> ConnectSubmission { + let mut connection = test.saved.clone(); + connection.id = Uuid::new_v4(); + connection.name = "Submitted".to_owned(); + ConnectSubmission { + request: FakeConnector::request().expect("a request"), + pending_save: PendingSave { + connection, + secrets: Vec::new(), + forget: Vec::new(), + is_new: true, + }, + } + } + + #[gtk4::test] + fn adoption_adds_persisted_key_only() { + let test = TestWindow::picker("AdoptPersisted"); + let list = test.services.storage.paths().connections_file(); + // A directory where the list goes makes its write fail. + std::fs::remove_file(&list).expect("remove the list"); + std::fs::create_dir(&list).expect("block the list"); + let refused = submission(&test); + let refused_key = SessionKey::Saved(refused.pending_save.connection.id); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(refused))); + test.wait_for_page("connect-failed"); + assert!( + !test.recorded().contains(&refused_key), + "a connection whose entry was not saved was recorded" + ); + + std::fs::remove_dir(&list).expect("unblock the list"); + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + let saved = submission(&test); + let saved_key = SessionKey::Saved(saved.pending_save.connection.id); + test.window.send(SessionWindowInput::ConnectFromDialog(Box::new(saved))); + test.wait_for_page("workspace"); + + assert_eq!(test.recorded(), [saved_key]); + } + + #[gtk4::test] + fn an_opened_file_is_recorded_and_a_disconnect_forgets_it() { + let test = TestWindow::picker("RecordFile"); + std::fs::write(test.dir().join("local.db"), b"").expect("seed"); + let key = test.file_key("local.db"); + + test.window.send(SessionWindowInput::OpenFile(gio::File::for_path( + test.dir().join("local.db"), + ))); + test.wait_for_page("workspace"); + assert_eq!(test.recorded(), std::slice::from_ref(&key)); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + assert!(test.recorded().is_empty(), "a disconnected session stayed recorded"); + } + + #[gtk4::test] + fn a_closed_window_forgets_its_session() { + let test = TestWindow::connected("CloseForgets"); + assert_eq!(test.recorded(), [test.key()]); + + test.window.close(); + + wait_until(WAIT, || test.recorded().is_empty()).expect("the closed window's session to be forgotten"); + } + + #[gtk4::test] + fn a_restored_saved_connection_connects_and_stays_recorded() { + let test = TestWindow::picker("RestoreSaved"); + + let window = test.restored_window(&test.key()); + wait_for(&window, "workspace"); + + assert_eq!(test.services.sessions.window_for(&test.key()), Some(window.id())); + assert_eq!(test.recorded(), [test.key()]); + } + + #[gtk4::test] + fn cancel_connect_removes_key() { + let test = TestWindow::with_script( + "RestoreCancel", + Script { + establish: vec![FakeEstablish::Late { + after: Duration::from_millis(300), + script: ConnectionScript::default(), + }], + ..Script::default() + }, + ); + let window = test.restored_window(&test.key()); + wait_for(&window, "connecting"); + assert_eq!(test.recorded(), [test.key()]); + + window.send(SessionWindowInput::CancelConnect); + wait_for(&window, "picker"); + + assert!(test.recorded().is_empty(), "a cancelled restore stayed recorded"); + } + + #[gtk4::test] + fn a_deleted_saved_connection_is_forgotten_and_shows_the_picker() { + let test = TestWindow::picker("RestoreDeleted"); + let gone = SessionKey::Saved(Uuid::new_v4()); + + let window = test.restored_window(&gone); + wait_until(WAIT, || test.recorded().is_empty()).expect("the deleted connection to be forgotten"); + + assert_eq!(window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a deleted connection was connected to" + ); + } + + #[gtk4::test] + fn a_restored_window_closed_before_its_lookup_forgets_the_session() { + let test = TestWindow::picker("RestoreClosedEarly"); + + let window = test.restored_window(&test.key()); + window.close(); + + assert!( + test.recorded().is_empty(), + "the closed window's session stayed recorded" + ); + let _ = wait_until(Duration::from_millis(300), || { + test.services.sessions.window_for(&test.key()).is_some() + }); + assert_eq!( + test.services.sessions.window_for(&test.key()), + None, + "the closed window connected" + ); + } + + #[gtk4::test] + fn a_failed_open_of_a_file_another_window_shows_keeps_it_recorded() { + let test = TestWindow::picker("OpenGoneFile"); + let path = test.dir().join("local.db"); + std::fs::write(&path, b"").expect("seed"); + let key = test.file_key("local.db"); + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&path))); + test.wait_for_page("workspace"); + std::fs::remove_file(&path).expect("delete the file"); + let SessionKey::File(uri) = &key else { + panic!("{key:?} is not a file key"); + }; + test.window.set_visible(false); + + test.app.open_window(WindowLaunch::OpenFile(gio::File::for_uri(uri))); + wait_until(WAIT, || test.window.is_visible()).expect("the window showing the file to come forward"); + + assert_eq!(test.recorded(), [key]); + } + + #[gtk4::test] + fn a_missing_file_is_forgotten_and_a_retry_records_it_again() { + let test = TestWindow::picker("RestoreMissing"); + let key = test.file_key("later.db"); + + let window = test.restored_window(&key); + wait_for(&window, "connect-failed"); + assert!(test.recorded().is_empty(), "a file that is gone stayed recorded"); + + std::fs::write(test.dir().join("later.db"), b"").expect("create the file"); + window.send(SessionWindowInput::RetryConnect); + wait_for(&window, "workspace"); + + assert_eq!(test.recorded(), [key]); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs new file mode 100644 index 0000000000..27a21600f2 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -0,0 +1,209 @@ +//! The ordered teardown of a window's session, for a disconnect and an +//! approved close. + +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::EditorSession; +use tablepro_session::Session; +use tablepro_session::runtime::{ProcessHold, Tasks}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// Take the workspace down once its close prompts approved, and + /// close the session under one hold. False when the window shows + /// no workspace. + /// + /// The order matters. What the workspace shows is written before it + /// is freed; the workspace goes before the claim, so nothing of it + /// reads for a key another window can now claim; and the claim goes + /// before the close finishes, so the same connection can be picked + /// again at once. + pub(super) fn tear_down(&self) -> bool { + let imp = self.imp(); + let Some(state) = self.state() else { + return false; + }; + let Ok(mut page) = imp.page.try_borrow_mut() else { + tracing::warn!("the session was torn down while its page was in use"); + return false; + }; + if !matches!(*page, SessionPage::Workspace { .. }) { + return false; + } + let SessionPage::Workspace { + claim, + session, + workspace, + } = std::mem::take(&mut *page) + else { + return false; + }; + drop(page); + workspace.flush(); + let editor_sessions = workspace.take_editor_sessions(); + imp.toast_overlay.set_child(gtk::Widget::NONE); + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + workspace.shutdown(); + drop(workspace); + let key = claim.key().clone(); + drop(claim); + // A quit freezes the record first, so only a disconnect or a + // window closed by hand takes the session off it. + self.forget_session(&key); + close_session( + session, + editor_sessions, + &*state.services.hold(), + &state.services.tasks, + state.context.close_timeout(), + ); + imp.dirty.set(false); + imp.saving.set(false); + self.refresh_inhibitor(); + super::window_actions::sync(self.upcast_ref(), &SessionPage::Picker, false); + true + } +} + +/// Close the session and every editor tab's connection together, under +/// the close deadline. One hold keeps the process alive until all of +/// them end, so quitting right after a disconnect closes them rather +/// than cutting them off. +fn close_session( + session: Arc, + editor_sessions: Vec>, + hold: &dyn ProcessHold, + tasks: &Tasks, + close_timeout: Duration, +) { + let hold = hold.acquire(); + let editors_closing = tablepro_session::close_editor_sessions(editor_sessions, close_timeout, tasks); + let closing = session.begin_shutdown(); + drop(session); + glib::spawn_future_local(async move { + let (closed, editors) = futures::future::join(closing, editors_closing).await; + match closed { + Ok(report) => tracing::debug!(?report, "the session closed"), + Err(failure) => tracing::warn!(%failure, "the session close task failed"), + } + match editors { + Ok(reports) => tracing::debug!(?reports, "the editor tabs' sessions closed"), + Err(failure) => tracing::warn!(%failure, "closing the editor tabs' sessions failed"), + } + drop(hold); + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::EditorSession; + use tablepro_test_support::{CallCounters, CloseBehaviour, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use std::cell::RefCell; + use std::rc::Rc; + + use tablepro_session::RegistryEvent; + + use super::*; + use crate::test_support::wait_until; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn disconnect_runs_ordered_teardown() { + let test = TestWindow::connected("OrderedTeardown"); + let workspace = test.workspace().downgrade(); + let key = test.key(); + wait_until(WAIT, || test.hold.live() == 0).expect("the workspace's opening writes to land"); + // Each step records what had happened before it. Only the flush + // holds the process before the claim goes, and the close's hold + // comes after it. + let steps = Rc::new(RefCell::new(Vec::new())); + let (detached, hold) = (steps.clone(), test.hold.clone()); + test.workspace() + .connect_notify_local(Some("parent"), move |workspace, _| { + if workspace.parent().is_none() { + detached.borrow_mut().push(("detached", hold.live() > 0)); + } + }); + let (released, gone) = (steps.clone(), workspace.clone()); + let _subscription = test.services.sessions.subscribe(move |event| { + if matches!(event, RegistryEvent::Released { .. }) { + let shut_down = gone.upgrade().is_none_or(|workspace| workspace.handle().is_none()); + released.borrow_mut().push(("released", shut_down)); + } + }); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + + assert_eq!( + *steps.borrow(), + [("detached", true), ("released", true)], + "a step ran before the one it depends on" + ); + + let child = test.window.imp().toast_overlay.child(); + assert!( + child.is_some_and(|child| child.type_().name() == "TableProPickerPage"), + "the picker did not replace the workspace" + ); + let counters = test.connection().counters(); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + crate::test_support::drain_main_context(); + assert_eq!( + CallCounters::get(&counters.close), + 1, + "the connection closed more than once" + ); + assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); + let claim = test + .services + .sessions + .claim(key, test.window.id() + 100) + .expect("the key is free again at once"); + drop(claim); + } + + #[gtk4::test] + fn disconnect_closes_editor_sessions_once() { + let delayed = || { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + std::sync::Arc::new(FakeEditorSession::with_close( + runs, + CloseBehaviour::Delay(Duration::from_millis(100)), + )) + }; + let (first, second) = (delayed(), delayed()); + let test = TestWindow::connected_with("EditorSessions", |script| { + let (a, b): (Arc, Arc) = (first.clone(), second.clone()); + script.open_editor_session.once(FakeReply::ok(a)); + script.open_editor_session.once(FakeReply::ok(b)); + }); + test.run_in_new_editor_tab(); + test.run_in_new_editor_tab(); + wait_until(WAIT, || !first.texts().is_empty() && !second.texts().is_empty()) + .expect("both tabs to open their sessions"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + assert!(test.hold.live() > 0, "nothing held the process for the closes"); + wait_until(WAIT, || test.hold.live() == 0).expect("the holds to end"); + + assert!( + first.is_closed() && second.is_closed(), + "the hold ended before both delayed closes did" + ); + assert_eq!((first.close_count(), second.close_count()), (1, 1)); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_command.rs b/linux/crates/app/src/ui/session_window/session_window_command.rs new file mode 100644 index 0000000000..a164ba15f8 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_command.rs @@ -0,0 +1,65 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_session::PendingSession; +use tablepro_session::connect::AttemptGeneration; +use tablepro_storage::SavedConnection; + +use crate::ui::error_text::TlsRemedy; + +/// What background work a window started brings back to it. +#[derive(Debug)] +pub(crate) enum SessionWindowCommand { + /// A connect attempt ended. Its generation says which one, so a + /// result that lands after the user moved on is discarded. + Attempt { + generation: AttemptGeneration, + result: Result, + }, +} + +/// A connect that worked, waiting for the window to adopt it. +#[derive(Debug)] +pub(crate) struct Connected { + pub pending: PendingSession, + /// The saved entry it connected to, as saved, which a reconnect + /// reads its secrets for. `None` for a file nobody saved. + pub saved: Option, + /// Secrets the keyring refused to keep, one sentence each. + pub warnings: Vec, + /// Secrets typed for this login that the keyring does not hold. The + /// session reconnects with them rather than with the keyring's copy. + pub session_only: Vec, +} + +#[derive(Debug)] +pub(crate) enum AttemptError { + /// The user stopped it, which needs no error. + Cancelled, + /// The database file is on a filesystem whose locking SQLite cannot + /// trust, so the user is asked how to open it first. + AskLocation(crate::services::file_location::FileLocationKind), + Failed(ConnectFailure), +} + +/// What the failed page says, and the setting that would fix it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectFailure { + pub message: String, + pub remedy: Option, + /// The secret the server refused, which the user can type again in + /// place of the keyring's copy. + pub reenter: Option, + /// The database file the driver could not find, which the user can + /// locate again for a saved connection. + pub missing_file: Option, +} + +impl ConnectFailure { + pub(crate) fn new(message: String) -> Self { + Self { + message, + remedy: None, + reenter: None, + missing_file: None, + } + } +} 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 new file mode 100644 index 0000000000..cfbf5eeaed --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -0,0 +1,107 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_session::SessionKey; +use tablepro_storage::{ConnectionColor, SavedConnection}; +use uuid::Uuid; + +use super::resolve_file_identity::ResolvedFile; +use super::session_window_command::SessionWindowCommand; +use super::sqlite_location_dialog::LocationChoice; +use crate::services::connection_deletion::DeletionOptions; +use crate::ui::connect_dialog::ConnectSubmission; +use crate::workspace::CloseReason; + +#[derive(Debug)] +pub(crate) enum SessionWindowInput { + /// Open a saved connection: in this window from the picker, in + /// another one otherwise. + OpenSaved(Box), + /// Open a database file, as the file manager or the command line + /// hands it over. + OpenFile(gio::File), + /// The file resolved, off the GTK thread, to the session it opens. + FileResolved(Box), + /// Open a session that was open when the application last quit. + Restore(SessionKey), + /// The restored saved connection as the list holds it now, or `None` + /// once it is deleted. + RestoreSaved { + id: Uuid, + saved: Option>, + }, + OpenConnectDialog, + /// The connect dialog's Connect: connect, then save the entry. + ConnectFromDialog(Box), + DialogClosed, + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + CancelConnect, + /// Connect again from where the failed connect came from, reading + /// the keyring again. + RetryConnect, + /// Connect a saved connection whose login was refused again, asking + /// for this secret instead of reading the keyring's copy. + RetryWithNewSecret(SecretKind), + /// Open the connect dialog on a connection that failed to connect, + /// with the focus on the setting that fixes it. + EditConnection, + /// Close the workspace through its prompts and return to the + /// picker. + Disconnect, + /// The prompts approved the disconnect. + TeardownApproved, + /// Close the workspace's current tab, or the window when it shows + /// no tab. + CloseCurrent, + /// The health banner's Retry: ask the session to reconnect now. + Reconnect, + /// The toast's Reset: move the workspace's unreadable saved tabs + /// aside. + ResetWorkspaceLayout, + /// The application asks the window to close. + CloseRequested(CloseReason), + /// The workspace gained or lost something a logout would lose. + DirtyChanged(bool), + /// The number of saves running in the workspace changed. + SavesInFlightChanged(usize), + TitleChanged(String), + Command(Box), + /// The answer for a file on a filesystem whose locking SQLite cannot + /// trust, or ReadWrite when there was nothing to ask. + LocationChosen { + generation: tablepro_session::connect::AttemptGeneration, + choice: LocationChoice, + }, + /// The user found the file the saved connection `id` lost. + FileLocated { + id: Uuid, + path: std::path::PathBuf, + }, + /// The located file's path is saved; connect to it. + LocatedSaved(Box), + ReloadConnections, + ConnectionsLoaded(Vec), + ConnectionListUnavailable, + ResetConnectionList, + /// The keyring kept a deleted connection's secrets, so the entry + /// was kept too. The toast's Retry runs the same delete again. + SecretDeleteFailed(Uuid, DeletionOptions), + /// Ask whether to delete a saved connection. + DeleteConnection(Uuid), + /// The user confirmed the delete. + ConfirmDelete(Uuid, DeletionOptions), + DuplicateConnection(Box), + /// A colour picked from a connection row's menu, or `None` when the + /// user cleared it. + SetConnectionColor(Uuid, Option), + /// A group picked from a connection row's menu, or `None` when the + /// user took it out of one. + SetConnectionGroup(Uuid, Option), + ExportConnections, + ImportConnections, + /// The file the user picked to import, read off the GTK thread. + ConnectionsFileRead { + path: std::path::PathBuf, + bytes: Vec, + }, + ShowToast(String), +} diff --git a/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs b/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs new file mode 100644 index 0000000000..dac092df5e --- /dev/null +++ b/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs @@ -0,0 +1,369 @@ +//! The question asked before a SQLite file on a network or FUSE +//! filesystem opens read-write. + +use std::path::PathBuf; + +use futures::FutureExt; +use futures::future::BoxFuture; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::FileOpenMode; +use tablepro_session::connect::AttemptGeneration; +use tablepro_session::runtime::Tasks; +use tablepro_session::{ConnectSource, RequestTarget}; +use tablepro_storage::CredentialDemand; +use tokio_util::sync::CancellationToken; + +use super::session_page::SessionPage; +use super::session_window_command::{AttemptError, Connected}; +use super::{SessionWindow, SessionWindowInput}; +use crate::services::file_location::{self, FileLocationKind, LocationClassifier}; + +const CANCEL: &str = "cancel"; +const READ_ONLY: &str = "read-only"; +const READ_WRITE: &str = "read-write"; + +/// How the user chose to open a file the location check flagged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocationChoice { + Cancel, + ReadOnly, + ReadWrite, +} + +/// What a connect knows about where its database file is. +#[derive(Debug)] +pub(crate) enum FileCheck { + /// Read the filesystem `path` is on before connecting, and stop to + /// ask when SQLite's locking cannot be trusted there. + Pending { + path: PathBuf, + classify: LocationClassifier, + }, + /// Nothing to check, or the user answered. + Settled { read_only: bool }, +} + +impl FileCheck { + pub(crate) fn read_only(&self) -> bool { + matches!(self, Self::Settled { read_only: true }) + } + + /// Run `connecting` once the file is known to be on a filesystem + /// whose locking SQLite can trust. The check reads the disk on the + /// spawner, and only a file that needs the question comes back to + /// the GTK thread before connecting. + pub(crate) fn guard( + self, + tasks: &Tasks, + cancel: CancellationToken, + connecting: BoxFuture<'static, Result>, + ) -> BoxFuture<'static, Result> { + let Self::Pending { path, classify } = self else { + return connecting; + }; + let checking = tasks.spawn_blocking_task(move || classify(&path)); + async move { + let kind = cancel + .run_until_cancelled(checking) + .await + .ok_or(AttemptError::Cancelled)? + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not read the database file's filesystem"); + FileLocationKind::Unknown { fs_type: String::new() } + }); + if kind.requires_read_only_prompt() { + return Err(AttemptError::AskLocation(kind)); + } + connecting.await + } + .boxed() + } +} + +/// The question for a file at `kind`, or `None` when it opens without +/// one. +pub(crate) fn for_location(kind: &FileLocationKind) -> Option { + kind.requires_read_only_prompt().then(location_dialog) +} + +fn location_dialog() -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Database on a Network Filesystem")), + Some(&crate::i18n::gettext( + "SQLite can corrupt a database on a network or FUSE filesystem when more than one program writes to it.", + )), + ); + dialog.add_responses(&[ + (CANCEL, &crate::i18n::gettext("_Cancel")), + (READ_ONLY, &crate::i18n::gettext("Open _Read-Only")), + (READ_WRITE, &crate::i18n::gettext("Open Read-_Write")), + ]); + dialog.set_response_appearance(READ_ONLY, adw::ResponseAppearance::Suggested); + dialog.set_response_appearance(READ_WRITE, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(READ_ONLY)); + dialog.set_close_response(CANCEL); + dialog +} + +impl SessionWindow { + /// The file a connect would open read-write. A file opened + /// read-only needs no question, nor does a new one, which no other + /// program writes to yet. + pub(super) fn file_to_check(&self, source: &ConnectSource) -> Option { + match source { + ConnectSource::File(identity) => identity.canonical_path.clone(), + ConnectSource::Saved(saved) => { + let file_based = self + .state()? + .context + .drivers + .get(&saved.driver_id) + .is_some_and(|driver| driver.capabilities().is_file_based()); + (file_based && !saved.read_only).then(|| PathBuf::from(&saved.database)) + } + ConnectSource::Intent(intent) => match &intent.request.target { + RequestTarget::File(endpoint) + if endpoint.mode() == FileOpenMode::OpenExisting + && !intent.request.workspace_settings.read_only => + { + Some(endpoint.path().to_path_buf()) + } + _ => None, + }, + } + } + + /// What a connect from `source` checks before it starts. `answer` + /// is how the user chose to open a file they were already asked + /// about. + pub(super) fn file_check(&self, source: &ConnectSource, answer: Option) -> FileCheck { + if let Some(read_only) = answer { + return FileCheck::Settled { read_only }; + } + match self.file_to_check(source) { + Some(path) => FileCheck::Pending { + path, + classify: self + .imp() + .classify_location + .get() + .unwrap_or(file_location::classify_blocking), + }, + None => FileCheck::Settled { read_only: false }, + } + } + + /// The connect stopped before connecting to ask about its file. The + /// connecting page stays behind the question. + pub(super) fn ask_about_location(&self, generation: AttemptGeneration, kind: &FileLocationKind) { + let Some(dialog) = for_location(kind) else { + self.send(SessionWindowInput::LocationChosen { + generation, + choice: LocationChoice::ReadWrite, + }); + return; + }; + let window = self.downgrade(); + glib::spawn_future_local(async move { + let Some(window) = window.upgrade() else { + return; + }; + let choice = match crate::workspace::respond(&dialog, window.upcast_ref()).await.as_str() { + READ_ONLY => LocationChoice::ReadOnly, + READ_WRITE => LocationChoice::ReadWrite, + _ => LocationChoice::Cancel, + }; + window.send(SessionWindowInput::LocationChosen { generation, choice }); + }); + } + + pub(super) fn on_location_chosen(&self, generation: AttemptGeneration, choice: LocationChoice) { + let asking = self.imp().attempts.accepts(generation) + && self.page_is(|page| matches!(page, SessionPage::Connecting { .. })); + if !asking { + return; + } + let read_only = match choice { + LocationChoice::Cancel => { + self.on_cancel_connect(); + return; + } + LocationChoice::ReadOnly => true, + LocationChoice::ReadWrite => false, + }; + let SessionPage::Connecting { claim, source } = self.imp().page.replace(SessionPage::Picker) else { + return; + }; + self.start_connect(claim, source, Some(read_only), CredentialDemand::default()); + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tablepro_core::FileEndpoint; + + use super::super::tests::{TestWindow, WAIT, dialog_submission}; + use super::*; + use crate::test_support::wait_until; + use crate::workspace::{answer, visible_alert}; + + fn on_a_share(_path: &Path) -> FileLocationKind { + FileLocationKind::Remote { + fs_type: "nfs4".to_owned(), + } + } + + fn open_saved_on_a_share(name: &str) -> TestWindow { + let test = TestWindow::picker(name); + test.window.imp().classify_location.set(Some(on_a_share)); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + test + } + + #[gtk4::test] + fn default_response_is_read_only() { + adw::init().expect("libadwaita initialises under the test backend"); + + let dialog = for_location(&on_a_share(Path::new("/mnt/share/app.db"))).expect("a question"); + + assert_eq!(dialog.default_response().as_deref(), Some(READ_ONLY)); + assert_eq!(dialog.close_response(), CANCEL); + assert_eq!( + dialog.response_appearance(READ_ONLY), + adw::ResponseAppearance::Suggested + ); + assert_eq!( + dialog.response_appearance(READ_WRITE), + adw::ResponseAppearance::Destructive + ); + assert!(for_location(&FileLocationKind::Local).is_none()); + } + + #[gtk4::test] + fn portal_path_without_host_path_offers_read_only_default() { + adw::init().expect("libadwaita initialises under the test backend"); + let portal = FileLocationKind::portal(None, |_| true, |_| FileLocationKind::Local); + + let dialog = for_location(&portal).expect("a question for a portal file"); + + assert_eq!(dialog.default_response().as_deref(), Some(READ_ONLY)); + } + + #[gtk4::test] + fn a_file_on_a_share_opens_read_only_when_asked() { + let test = open_saved_on_a_share("ShareReadOnly"); + assert!( + test.connector.calls().is_empty(), + "the connect did not wait for the answer" + ); + + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn a_file_on_a_share_opens_read_write_when_asked() { + let test = open_saved_on_a_share("ShareReadWrite"); + + assert!(answer(&test.window, READ_WRITE)); + test.wait_for_page("workspace"); + + assert!(!test.connector.calls()[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn cancel_at_the_question_connects_nothing_and_frees_the_connection() { + let test = open_saved_on_a_share("ShareCancel"); + + assert!(answer(&test.window, CANCEL)); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert!(test.connector.calls().is_empty()); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + } + + #[gtk4::test] + fn a_read_only_connection_is_not_asked_about() { + let test = TestWindow::picker("ShareSavedReadOnly"); + test.window.imp().classify_location.set(Some(on_a_share)); + let mut saved = test.saved.clone(); + saved.read_only = true; + test.services + .storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the entry read-only"); + + test.window.send(SessionWindowInput::OpenSaved(Box::new(saved))); + test.wait_for_page("workspace"); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn a_file_handed_over_from_a_share_is_asked_about() { + let test = TestWindow::picker("ShareFile"); + test.window.imp().classify_location.set(Some(on_a_share)); + let dir = tempfile::tempdir().expect("tempdir"); + let database = dir.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + assert!(test.connector.calls().is_empty()); + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn an_existing_file_from_the_connect_dialog_is_asked_about() { + let test = TestWindow::picker("ShareDialog"); + test.window.imp().classify_location.set(Some(on_a_share)); + let endpoint = FileEndpoint::open_existing(PathBuf::from("/mnt/share/app.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + assert!(test.connector.calls().is_empty()); + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + assert!(test.connector.calls()[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn a_new_file_from_the_connect_dialog_is_not_asked_about() { + let test = TestWindow::picker("ShareDialogNew"); + test.window.imp().classify_location.set(Some(on_a_share)); + let endpoint = FileEndpoint::create_new(PathBuf::from("/mnt/share/new.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + test.wait_for_page("workspace"); + + assert!(visible_alert(&test.window).is_none()); + assert!(!test.connector.calls()[0].request.workspace_settings.read_only); + } +} diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs new file mode 100644 index 0000000000..6850172c0f --- /dev/null +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -0,0 +1,808 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_core::{AuthMode, DriverError, DriverRegistry, LivenessPolicy, TlsConfig}; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_session::runtime::CountingHold; +use tablepro_session::{ConnectError, CredentialSource, SessionKey}; +use tablepro_storage::{SavedConnection, StoragePaths}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeDriver, FakeReply, FakeSecretVault}; + +use super::*; +use crate::application::{AppServicesParts, TableProApplication}; +use crate::runtime::Delivery; +use crate::services::session_context::CredentialSourceFactory; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, assert_labelled, descendants, drain_main_context, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(10); + +/// Every saved connection, whatever it is, reads its secrets from one +/// fake, so a test can count the reads. +struct FakeSources(FakeCredentialSource); + +impl CredentialSourceFactory for FakeSources { + fn for_connection( + &self, + _saved: Option<&SavedConnection>, + _session_only: &[tablepro_core::credentials::SecretKind], + ) -> Arc { + Arc::new(self.0.clone()) + } +} + +/// What a scripted world can change before the application starts. +#[derive(Default)] +pub(crate) struct Script { + pub connection: ConnectionScript, + /// What each connect gets, before `connection` from then on. + pub establish: Vec, + pub credentials: Option, + pub ssh: Option, + /// The saved connection logs in to a server with a password, rather + /// than opening a file. + pub network: bool, + /// The connection list on disk cannot be read, so it holds nothing. + pub unreadable_list: bool, +} + +/// A TablePro over fakes with one window open on the picker and `saved` +/// in its connection list. The application has an id of its own, so it +/// never meets another test's, or a TablePro the developer has open, on +/// the session bus. +pub(crate) struct TestWindow { + pub app: TableProApplication, + pub window: SessionWindow, + pub services: Rc, + pub hold: Rc, + pub connector: FakeConnector, + pub saved: SavedConnection, + /// The keyring every saved connection reads, unless the script + /// brings its own credentials. + pub vault: Arc, + runtime: Option, + _settings: MemorySettings, + _dir: tempfile::TempDir, +} + +impl TestWindow { + pub(crate) fn picker(name: &str) -> Self { + Self::with_script(name, Script::default()) + } + + pub(crate) fn with_script(name: &str, script: Script) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let list_file = paths.connections_file(); + let vault = Arc::new(FakeSecretVault::new()); + let storage = AppStorage::new(paths, vault.clone(), &runtime.tasks()); + let Script { + mut connection, + establish, + credentials, + ssh, + network, + unreadable_list, + } = script; + let saved = if network { + network_connection() + } else { + saved_connection(&dir.path().join("fake.db")) + }; + if unreadable_list { + if let Some(parent) = list_file.parent() { + std::fs::create_dir_all(parent).expect("the config directory"); + } + std::fs::write(&list_file, b"{ not json").expect("the unreadable list"); + } else { + storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the test's connection"); + } + connection.probe.always(FakeReply::ok(())); + connection.list_schemas.always(FakeReply::ok(Vec::new())); + connection.list_objects.always(FakeReply::ok(Vec::new())); + let mut drivers = DriverRegistry::new(); + let capabilities = if network { + &FakeDriver::NETWORK_CAPABILITIES + } else { + &FakeDriver::FILE_CAPABILITIES + }; + drivers.register(Arc::new(FakeDriver::connecting(capabilities, connection.clone()))); + let connector = FakeConnector::new(establish).always(FakeEstablish::Connect(connection)); + let liveness = LivenessPolicy::DESKTOP; + let hold = Rc::new(CountingHold::new()); + let mut services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }) + .with_hold(hold.clone()); + if let Some(credentials) = credentials { + services = services.with_credentials(Rc::new(FakeSources(credentials))); + } + if let Some(ssh) = ssh { + services = services.with_ssh(ssh); + } + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::SCHEMA_ID))); + app.register(gio::Cancellable::NONE).expect("register the application"); + let window = app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a session window"); + let services = app.services().expect("the application's services"); + let test = Self { + app, + window, + services, + hold, + connector, + saved, + vault, + runtime: Some(runtime), + _settings: settings, + _dir: dir, + }; + test.wait_for_page("picker"); + test + } + + /// A window that opened the saved connection and shows its + /// workspace. + pub(crate) fn connected(name: &str) -> Self { + Self::connected_with(name, |_| {}) + } + + pub(crate) fn connected_with(name: &str, script: impl FnOnce(&mut ConnectionScript)) -> Self { + let mut connection = ConnectionScript::default(); + script(&mut connection); + let test = Self::with_script( + name, + Script { + connection, + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test + } + + pub(crate) fn wait_for_page(&self, name: &str) { + wait_until(WAIT, || self.window.page_name() == name) + .unwrap_or_else(|_| panic!("the window stayed on {}, not {name}", self.window.page_name())); + } + + pub(crate) fn workspace(&self) -> Workspace { + self.window.workspace().expect("the window shows a workspace") + } + + pub(crate) fn key(&self) -> SessionKey { + SessionKey::Saved(self.saved.id) + } + + /// Where the test keeps its files, the fake database among them. + pub(crate) fn dir(&self) -> &std::path::Path { + self._dir.path() + } + + /// The connection the last connect built. + pub(crate) fn connection(&self) -> Arc { + self.connector.connections().pop().expect("a connection") + } + + /// Open an editor tab through its window action and run what it + /// holds, which opens the tab's own editor session. + pub(crate) fn run_in_new_editor_tab(&self) { + let before = editors(&self.window); + WidgetExt::activate_action(&self.window, "win.open-editor", None).expect("win.open-editor"); + wait_until(WAIT, || editors(&self.window).len() == before.len() + 1).expect("a new editor tab"); + let editor = editors(&self.window) + .into_iter() + .find(|editor| !before.contains(editor)) + .expect("the new editor"); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + } +} + +impl Drop for TestWindow { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +pub(crate) fn editors(window: &SessionWindow) -> Vec { + descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() +} + +fn saved_connection(database: &std::path::Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// A server on this machine, so no reachability check runs. +fn network_connection() -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Server".to_owned(), + driver_id: "fake".to_owned(), + host: "127.0.0.1".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The connect dialog's Connect on a new entry for the file `endpoint`. +pub(crate) fn dialog_submission(endpoint: tablepro_core::FileEndpoint) -> ConnectSubmission { + let mut request = FakeConnector::request().expect("a request"); + let mut connection = saved_connection(endpoint.path()); + connection.id = request.metadata.id; + request.target = tablepro_session::RequestTarget::File(endpoint); + ConnectSubmission { + request, + pending_save: tablepro_session::PendingSave { + connection, + secrets: Vec::new(), + forget: Vec::new(), + is_new: true, + }, + } +} + +pub(crate) fn refused() -> FakeEstablish { + FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { diagnostics: None })) +} + +#[gtk4::test] +fn a_connected_window_shows_the_workspace_and_claims_its_key() { + let test = TestWindow::connected("Connects"); + + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + assert_eq!(test.services.sessions.live_count(), 1); + assert!( + test.window + .lookup_action("disconnect") + .is_some_and(|action| action.is_enabled()) + ); +} + +#[gtk4::test] +fn the_header_icon_buttons_are_labelled() { + let test = TestWindow::connected("Labels"); + let picker = test.window.imp().picker.get().expect("the picker").clone(); + + for root in [picker.upcast::(), test.workspace().upcast()] { + // The tab overview's own header is libadwaita's. + let headers = descendants(&root).into_iter().filter(|widget| { + widget.is::() && widget.ancestor(adw::TabOverview::static_type()).is_none() + }); + let buttons: Vec = headers + .flat_map(|header| descendants(&header)) + // A menu button's own toggle is labelled through the menu + // button. + .filter(|widget| { + widget.is::() + || (widget.is::() + && !widget.parent().is_some_and(|parent| parent.is::())) + }) + .collect(); + assert!(!buttons.is_empty(), "no header buttons under {}", root.type_().name()); + for button in buttons { + assert_labelled(&button).unwrap_or_else(|problem| panic!("{problem:?}: {:?}", button.tooltip_text())); + } + } +} + +#[gtk4::test] +fn the_connect_dialog_opens_once_over_its_own_window() { + let test = TestWindow::picker("ConnectDialog"); + + test.window.send(SessionWindowInput::OpenConnectDialog); + test.window.send(SessionWindowInput::OpenConnectDialog); + wait_until(WAIT, || test.window.imp().dialog.borrow().is_some()).expect("the connect dialog"); + drain_main_context(); + + let dialogs = descendants(&test.window) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + assert_eq!(dialogs, 1); +} + +#[gtk4::test] +fn claiming_open_key_presents_owner_window() { + let test = TestWindow::connected("Owner"); + let other = test + .app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + test.window.set_visible(false); + + other.send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.window.is_visible()).expect("the owner to come forward"); + + assert_eq!(other.page_name(), "picker", "the second window connected too"); + assert_eq!(test.connector.calls().len(), 1, "a second connection was built"); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); +} + +#[gtk4::test] +fn cancel_connect_discards_late_attempt() { + let test = TestWindow::with_script( + "CancelLate", + Script { + establish: vec![FakeEstablish::Late { + after: Duration::from_millis(300), + script: ConnectionScript::default(), + }], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + // A cancel before the driver is asked builds no connection at all. + wait_until(WAIT, || !test.connector.calls().is_empty()).expect("the connect to reach the driver"); + + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + assert_eq!( + test.services.sessions.window_for(&test.key()), + None, + "the claim outlived the cancel" + ); + wait_until(WAIT, || { + test.connector + .connections() + .first() + .is_some_and(|connection| connection.is_closed()) + }) + .expect("the late connection to close"); + drain_main_context(); + + assert_eq!(test.window.page_name(), "picker", "the late connect was adopted"); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[gtk4::test] +fn a_connect_that_lands_after_its_window_closed_is_closed() { + let test = TestWindow::picker("LandsAfterClose"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + // The connect ends on the runtime while the main loop waits, so its + // answer reaches the window only after the window is gone. + let deadline = std::time::Instant::now() + WAIT; + while test.connector.connections().is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + + test.window.destroy(); + let connection = test.connection(); + wait_until(WAIT, || connection.is_closed()).expect("the late connection to close"); + + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[gtk4::test] +fn a_connect_still_queued_when_its_window_shuts_down_is_closed() { + let test = TestWindow::picker("QueuedAtShutdown"); + let request = FakeConnector::request().expect("a request"); + let (pending, connection) = FakeConnector::pending_session(request, ConnectionScript::default()); + let attempt = SessionWindowCommand::Attempt { + generation: tablepro_session::connect::AttemptGeneration::FIRST, + result: Ok(Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + }), + }; + + test.window.send(SessionWindowInput::Command(Box::new(attempt))); + test.window.shutdown(); + + wait_until(WAIT, || connection.is_closed()).expect("the queued connection to close"); +} + +#[gtk4::test] +fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { + let test = TestWindow::with_script( + "Failed", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let page = test + .window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page"); + assert!(page.message().is_some_and(|message| !message.is_empty())); + assert!( + test.window + .lookup_action("retry-connect") + .is_some_and(|action| action.is_enabled()) + ); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + + WidgetExt::activate_action(&test.window, "win.cancel-connect", None).expect("win.cancel-connect"); + test.wait_for_page("picker"); + + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + +#[gtk4::test] +fn a_close_while_the_page_is_read_does_not_panic() { + let test = TestWindow::with_script( + "CloseWhileRead", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + let page = test.window.imp().page.borrow(); + test.window.release_session(); + drop(page); + + assert_eq!(test.window.page_name(), "connect-failed"); +} + +#[gtk4::test] +fn retry_rereads_credentials() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Retry", + Script { + establish: vec![refused()], + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let before = credentials.refreshes().len(); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes().len(), before + 1); +} + +#[gtk4::test] +fn retry_connects_to_the_entry_as_saved_now() { + let test = TestWindow::with_script( + "RetryEdited", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let mut renamed = test.saved.clone(); + renamed.name = "Renamed".to_owned(); + store.upsert_blocking(renamed).expect("edit the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + let names: Vec = test + .connector + .calls() + .iter() + .map(|call| call.request.metadata.name.clone()) + .collect(); + assert_eq!(names, ["Fake", "Renamed"]); +} + +#[gtk4::test] +fn retry_after_the_entry_was_deleted_returns_to_the_picker() { + let test = TestWindow::with_script( + "RetryDeleted", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + store.remove_blocking(test.saved.id).expect("delete the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("picker"); + + assert_eq!(test.connector.calls().len(), 1, "the deleted entry was connected to"); + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + +#[gtk4::test] +fn missing_file_goes_to_connect_failed() { + let test = TestWindow::picker("MissingFile"); + let missing = test._dir.path().join("gone.db"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&missing))); + test.wait_for_page("connect-failed"); + + let message = test + .window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .and_then(|page| page.message()) + .expect("the failed page's message"); + assert!(message.contains("gone.db"), "{message}"); + assert!(test.connector.calls().is_empty(), "a missing file was connected to"); +} + +#[gtk4::test] +fn a_file_whose_resolve_never_ran_goes_to_connect_failed() { + let mut test = TestWindow::picker("ResolveStopped"); + let database = test._dir.path().join("local.db"); + // With the runtime gone, the resolve is cancelled before it starts. + if let Some(runtime) = test.runtime.take() { + runtime.shutdown(); + } + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("connect-failed"); + + assert!( + test.connector.calls().is_empty(), + "a file nobody resolved was connected to" + ); +} + +#[gtk4::test] +fn an_unsaved_file_opens_a_session_of_its_own() { + let test = TestWindow::picker("File"); + let database = test._dir.path().join("local.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("workspace"); + + let uri = glib::filename_to_uri(std::fs::canonicalize(&database).expect("canonical"), None) + .expect("a uri") + .to_string(); + assert_eq!( + test.services.sessions.window_for(&SessionKey::File(uri)), + Some(test.window.id()) + ); +} + +#[gtk4::test] +fn a_file_window_that_could_not_show_the_owner_stays_open_from_then_on() { + let test = TestWindow::connected("OpenFileOwnerGone"); + let database = test._dir.path().join("local.db"); + std::fs::write(&database, b"").expect("seed"); + let uri = glib::filename_to_uri(std::fs::canonicalize(&database).expect("canonical"), None) + .expect("a uri") + .to_string(); + // An owner the application cannot bring forward. + let _held = test + .services + .sessions + .claim(SessionKey::File(uri), test.window.id() + 100) + .expect("claim the file"); + let extra = test + .app + .open_window(WindowLaunch::OpenFile(gio::File::for_path(&database))) + .and_then(|window| window.downcast::().ok()) + .expect("a window for the file"); + wait_until(WAIT, || !extra.imp().opening_file.get()).expect("the file to resolve"); + drain_main_context(); + assert_eq!(extra.page_name(), "picker"); + test.window.set_visible(false); + + extra.send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.window.is_visible()).expect("the connection's window to come forward"); + drain_main_context(); + + assert_eq!(test.app.windows().len(), 2, "the picker closed under the user"); + assert_eq!(extra.page_name(), "picker"); +} + +#[gtk4::test] +fn late_show_create_reply_after_disconnect_is_dropped() { + let test = TestWindow::connected_with("LateShowCreate", |script| { + script.dialect = Arc::new(drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))); + script.fetch_table_definition.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(users_table()), + }); + }); + let counters = test.connection().counters(); + let workspace = test.workspace().downgrade(); + let input = test.workspace().input().expect("the workspace's input"); + input.deliver(WorkspaceInput::ShowCreateTableForExisting { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }); + wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 1) + .expect("the definition was asked for"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + drain_main_context(); + // The reply comes back through the workspace's input, which the + // teardown closes before the reply is due. + assert_eq!( + input.try_deliver(WorkspaceInput::RefreshPage), + Delivery::ReceiverGone, + "the torn-down workspace still takes input" + ); + let _ = wait_until(Duration::from_millis(600), || false); + drain_main_context(); + + assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); + assert_eq!(test.window.page_name(), "picker"); + assert!(editors(&test.window).is_empty(), "the late reply opened an editor tab"); +} + +#[gtk4::test] +fn an_edit_kept_for_its_next_connect_is_written_under_a_hold() { + let test = TestWindow::picker("PersistHeld"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let mut edited = test.saved.clone(); + edited.name = "Edited".to_owned(); + + test.window.persist_pending_save(tablepro_session::PendingSave { + connection: edited, + secrets: Vec::new(), + forget: Vec::new(), + is_new: false, + }); + assert_eq!(test.hold.live(), 1, "the write started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert!( + stored.iter().any(|saved| saved.name == "Edited"), + "the hold ended before the write" + ); +} + +#[gtk4::test] +fn quit_closes_a_connected_window_and_its_session() { + let test = TestWindow::connected("Quit"); + let counters = test.connection().counters(); + + let decision = crate::test_support::finish_on_main(WAIT, crate::application::QuitSequence::run(test.app.clone())) + .expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + assert!(test.app.windows().is_empty()); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[expect( + clippy::disallowed_methods, + reason = "a test stands in for the catalogue a driver would read" +)] +fn users_table() -> tablepro_core::ddl::TableDefinition { + use tablepro_core::column::{ + CatalogType, ColumnDefault, ColumnKind, ColumnType, ReadForm, SqlTypeExpr, TextLength, + }; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; + use tablepro_core::meta::TableRef; + + let column = ColumnDefinition::new( + tablepro_core::ColumnInfo { + name: "id".to_owned(), + column_type: ColumnType { + name: "text".to_owned(), + kind: ColumnKind::Text { + length: TextLength::Unbounded, + }, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("text"), + cast_target: SqlTypeExpr::from_catalog_text("text"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ); + TableDefinition { + table: TableRef::new(Some("public".to_owned()), "users"), + columns: vec![column], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } +} diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs new file mode 100644 index 0000000000..ea6b1e2159 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -0,0 +1,233 @@ +//! The window's own actions and shortcuts. +//! +//! The actions go on the window's own action map, which GTK already +//! exports as `win`: a second group inserted under that name would hide +//! it from the application's accelerators and from D-Bus. + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::SessionWindowInput; +use super::session_page::SessionPage; +use crate::runtime::{DeliverInput, InputSender}; + +/// Builds the input an action sends. +type ActionInput = fn() -> SessionWindowInput; + +/// The window's actions and the input each one sends. A closure holds +/// only the window's input sender, so an action that outlives the window +/// drops its input rather than reaching a window that is gone. +const ACTIONS: [(&str, ActionInput); 9] = [ + ("close-current", || SessionWindowInput::CloseCurrent), + ("disconnect", || SessionWindowInput::Disconnect), + ("reconnect", || SessionWindowInput::Reconnect), + ("cancel-connect", || SessionWindowInput::CancelConnect), + ("retry-connect", || SessionWindowInput::RetryConnect), + ("edit-connection", || SessionWindowInput::EditConnection), + ("reset-workspace-layout", || SessionWindowInput::ResetWorkspaceLayout), + ("export-connections", || SessionWindowInput::ExportConnections), + ("import-connections", || SessionWindowInput::ImportConnections), +]; + +pub(super) fn install(window: &adw::ApplicationWindow, input: &InputSender) { + let entries = ACTIONS.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(entries); + window.add_action_entries([retry_with_new_secret(input.clone())]); +} + +/// Takes the secret to ask for as its target, named the way the keyring +/// attribute names it. +fn retry_with_new_secret(input: InputSender) -> gio::ActionEntry { + gio::ActionEntry::builder("retry-with-new-secret") + .parameter_type(Some(glib::VariantTy::STRING)) + .activate(move |_, _, target| { + if let Some(kind) = target + .and_then(|target| target.str()) + .and_then(super::secret_retry::secret_kind_named) + { + input.deliver(SessionWindowInput::RetryWithNewSecret(kind)); + } + }) + .build() +} + +/// Enable each action on the page it means something on, so its menu +/// item and button are insensitive elsewhere. +pub(super) fn sync(window: &adw::ApplicationWindow, page: &SessionPage, editable: bool) { + let connected = matches!(page, SessionPage::Workspace { .. }); + let failed = matches!(page, SessionPage::ConnectFailed { .. }); + let reenter = matches!(page, SessionPage::ConnectFailed { failure, .. } if failure.reenter.is_some()); + for (name, enabled) in [ + ("disconnect", connected), + ("reconnect", connected), + ("reset-workspace-layout", connected), + ( + "cancel-connect", + matches!(page, SessionPage::Connecting { .. }) || failed, + ), + ("retry-connect", failed), + ("retry-with-new-secret", reenter), + ("edit-connection", failed && editable), + ] { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } + } +} + +/// Quit, New Window and Preferences are application accelerators, which +/// GTK runs before these, so none of their keys is bound here. The +/// workspace adds the actions these name while it is open, apart from +/// close-current, which the window keeps so Ctrl+W works on every page. +pub(crate) fn install_window_shortcuts(window: &adw::ApplicationWindow) { + use gtk::gdk::{Key, ModifierType}; + let primary = ModifierType::CONTROL_MASK; + let primary_shift = ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK; + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Global); + controller.add_shortcut(make_shortcut(Key::w, primary, "win.close-current")); + controller.add_shortcut(make_shortcut(Key::e, primary, "win.open-editor")); + // Ctrl+T mirrors Ctrl+E for the browser's "new tab". + controller.add_shortcut(make_shortcut(Key::t, primary, "win.open-editor")); + controller.add_shortcut(make_shortcut(Key::F5, ModifierType::empty(), "win.refresh-page")); + controller.add_shortcut(make_shortcut(Key::f, primary, "win.open-filter")); + controller.add_shortcut(make_shortcut(Key::h, primary, "win.show-history")); + controller.add_shortcut(make_shortcut(Key::s, primary, "win.save-changes")); + controller.add_shortcut(make_shortcut(Key::z, primary, "win.undo-change")); + controller.add_shortcut(make_shortcut(Key::y, primary, "win.redo-change")); + controller.add_shortcut(make_shortcut(Key::z, primary_shift, "win.redo-change")); + controller.add_shortcut(make_shortcut(Key::t, primary_shift, "win.reopen-closed-tab")); + controller.add_shortcut(make_shortcut(Key::s, primary_shift, "win.save-query")); + window.add_controller(controller); +} + +fn make_shortcut(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, action: &str) -> gtk::Shortcut { + gtk::Shortcut::builder() + .trigger(>k::KeyvalTrigger::new(key, modifiers)) + .action(>k::NamedAction::new(action)) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::Delivery; + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; + + fn shortcut_action_names(window: &adw::ApplicationWindow) -> Vec { + let controllers = window.observe_controllers(); + let mut names = Vec::new(); + for index in 0..controllers.n_items() { + let Some(controller) = controllers.item(index).and_downcast::() else { + continue; + }; + for position in 0..controller.n_items() { + let Some(shortcut) = controller.item(position).and_downcast::() else { + continue; + }; + if let Some(action) = shortcut.action().and_downcast::() { + names.push(action.action_name().to_string()); + } + } + } + names + } + + #[gtk4::test] + fn window_shortcuts_leave_the_application_keys_alone() { + let window = adw::ApplicationWindow::builder().build(); + + install_window_shortcuts(&window); + let names = shortcut_action_names(&window); + + assert!(names.iter().any(|name| name == "win.close-current"), "{names:?}"); + for owned_by_the_application in ["win.quit", "win.preferences", "win.shortcuts"] { + assert!( + !names.iter().any(|name| name == owned_by_the_application), + "{owned_by_the_application} is an application action now: {names:?}" + ); + } + } + + #[gtk4::test] + fn actions_live_on_application_window_map() { + let window = adw::ApplicationWindow::builder().build(); + let (input, mut receiver) = crate::runtime::input_queue::(); + + install(&window, &input); + sync(&window, &SessionPage::Picker, false); + + assert!( + window + .lookup_action("disconnect") + .is_some_and(|action| !action.is_enabled()) + ); + for moved in ["quit", "about", "preferences"] { + assert!( + window.lookup_action(moved).is_none(), + "win.{moved} is still on the window" + ); + } + WidgetExt::activate_action(&window, "win.export-connections", None) + .expect("win resolves to the window's own map"); + assert!(matches!(receiver.try_recv(), Ok(SessionWindowInput::ExportConnections))); + } + + #[gtk4::test] + fn action_after_workspace_drop_does_not_panic() { + let test = TestWindow::connected("ActionAfterDrop"); + let action = test + .window + .lookup_action("refresh-page") + .expect("the workspace's refresh action"); + let input = test.workspace().input().expect("the workspace's input"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert!( + test.window.lookup_action("refresh-page").is_none(), + "the action outlived its workspace" + ); + action.activate(None); + assert_eq!( + input.try_deliver(crate::ui::workspace::WorkspaceInput::RefreshPage), + Delivery::ReceiverGone + ); + } + + #[gtk4::test] + fn close_current_closes_a_window_with_no_workspace() { + let test = TestWindow::picker("CloseCurrentPicker"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the picker's window to close"); + } + + #[gtk4::test] + fn close_current_closes_the_tab_and_still_works_after_a_disconnect() { + let test = TestWindow::connected("CloseCurrentTab"); + WidgetExt::activate_action(&test.window, "win.open-editor", None).expect("win.open-editor"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + wait_until(WAIT, || editors(&test.window).is_empty()).expect("the tab to close"); + assert_eq!(test.app.windows().len(), 1, "the window closed with its tab"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current on the picker"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the window to close"); + } +} diff --git a/linux/crates/app/src/ui/sidebar_row.rs b/linux/crates/app/src/ui/sidebar_row.rs index 15a76fc235..07ea56e934 100644 --- a/linux/crates/app/src/ui/sidebar_row.rs +++ b/linux/crates/app/src/ui/sidebar_row.rs @@ -7,6 +7,8 @@ use relm4::gtk::prelude::*; use tablepro_core::meta::ObjectInfo; +use crate::runtime::DeliverInput; + #[derive(Debug)] pub struct SidebarRow { pub info: ObjectInfo, @@ -151,7 +153,7 @@ impl FactoryComponent for SidebarRow { let state = gesture.current_event_state(); if state.contains(gdk::ModifierType::CONTROL_MASK) { gesture.set_state(gtk::EventSequenceState::Claimed); - sender_for_ctrl.input(SidebarRowMsg::OpenInNewTab); + sender_for_ctrl.deliver(SidebarRowMsg::OpenInNewTab); } }); root.add_controller(click_gesture); @@ -216,23 +218,23 @@ impl FactoryComponent for SidebarRow { let group = gtk::gio::SimpleActionGroup::new(); let sender_open = sender.clone(); let open_action = gtk::gio::ActionEntry::builder("open-in-new-tab") - .activate(move |_, _, _| sender_open.input(SidebarRowMsg::OpenInNewTab)) + .activate(move |_, _, _| sender_open.deliver(SidebarRowMsg::OpenInNewTab)) .build(); let sender_edit = sender.clone(); let edit_action = gtk::gio::ActionEntry::builder("edit-structure") - .activate(move |_, _, _| sender_edit.input(SidebarRowMsg::EditStructure)) + .activate(move |_, _, _| sender_edit.deliver(SidebarRowMsg::EditStructure)) .build(); let sender_show = sender.clone(); let show_create_action = gtk::gio::ActionEntry::builder("show-create-table") - .activate(move |_, _, _| sender_show.input(SidebarRowMsg::ShowCreateTable)) + .activate(move |_, _, _| sender_show.deliver(SidebarRowMsg::ShowCreateTable)) .build(); let sender_import = sender.clone(); let import_action = gtk::gio::ActionEntry::builder("import-csv") - .activate(move |_, _, _| sender_import.input(SidebarRowMsg::ImportCsv)) + .activate(move |_, _, _| sender_import.deliver(SidebarRowMsg::ImportCsv)) .build(); let sender_drop = sender.clone(); let drop_action = gtk::gio::ActionEntry::builder("drop-table") - .activate(move |_, _, _| sender_drop.input(SidebarRowMsg::DropTable)) + .activate(move |_, _, _| sender_drop.deliver(SidebarRowMsg::DropTable)) .build(); group.add_action_entries([open_action, edit_action, show_create_action, import_action, drop_action]); root.insert_action_group("sidebar-row", Some(&group)); diff --git a/linux/crates/app/src/ui/ssh_inputs.rs b/linux/crates/app/src/ui/ssh_inputs.rs index 5a829fe15a..36784746aa 100644 --- a/linux/crates/app/src/ui/ssh_inputs.rs +++ b/linux/crates/app/src/ui/ssh_inputs.rs @@ -4,6 +4,8 @@ use secrecy::{ExposeSecret, SecretString}; use tablepro_ssh::{SshAuth, SshConfig, SshDestination}; use tablepro_storage::{SavedSshAuth, SavedSshConfig}; +pub use crate::services::ssh_environment::SshEnvironment; + /// How the SSH server is logged in to, in the order the combo shows /// them. OpenSSH does the work for each; the app only picks which one /// it tries first. @@ -67,26 +69,6 @@ impl SshAuthChoice { } } -/// What the machine offers for SSH logins, read once when the dialog -/// opens so a test can say what it has instead. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SshEnvironment { - pub agent: bool, - pub default_key: Option, -} - -impl SshEnvironment { - pub fn detect() -> Self { - let agent = std::env::var_os("SSH_AUTH_SOCK").is_some_and(|socket| !socket.is_empty()); - let ssh_dir = glib::home_dir().join(".ssh"); - let default_key = ["id_ed25519", "id_ecdsa", "id_rsa"] - .into_iter() - .map(|candidate| ssh_dir.join(candidate)) - .find(|path| path.exists()); - Self { agent, default_key } - } -} - /// The SSH rows as typed. Port, username and jump hosts may be empty: /// OpenSSH then takes them from the user's SSH configuration, which is /// also where a host alias is resolved. diff --git a/linux/crates/app/src/ui/ssh_section.rs b/linux/crates/app/src/ui/ssh_section.rs index 38d4c0bc97..d0de2c89a2 100644 --- a/linux/crates/app/src/ui/ssh_section.rs +++ b/linux/crates/app/src/ui/ssh_section.rs @@ -265,9 +265,14 @@ impl SshSection { .filters(&filters) .default_filter(&filter) .build(); - let ssh_dir = glib::home_dir().join(".ssh"); - if ssh_dir.is_dir() { - dialog.set_initial_folder(Some(&gio::File::for_path(&ssh_dir))); + // Asked through GIO, which reads the disk on its own threads. + let ssh_dir = gio::File::for_path(glib::home_dir().join(".ssh")); + let is_dir = ssh_dir + .query_info_future("standard::type", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT) + .await + .is_ok_and(|info| info.file_type() == gio::FileType::Directory); + if is_dir { + dialog.set_initial_folder(Some(&ssh_dir)); } let parent = self.root().and_downcast::(); if let Some(path) = dialog diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 20fa9da769..ba4bec6607 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -1,54 +1,33 @@ //! The column rows of the Structure tab's Columns page. use std::cell::{Cell, RefCell}; -use std::rc::Rc; -use std::sync::Arc; use gtk4 as gtk; use gtk4::gio; use libadwaita as adw; use libadwaita::prelude::*; -use tablepro_core::ddl::{ColumnCapabilities, DraftColumn, DraftType, Permission}; +use tablepro_core::ddl::{DraftColumn, DraftType, Permission}; use tablepro_core::dialect::SqlDialect; +use super::context::StructureContext; +use super::draft_column_object::DraftColumnObject; use super::{ColumnField, StructureTabInput, StructureTabSender}; use crate::ui::error_text::refusal_text; +use crate::ui::handler_guard::HandlerGuard; -/// What a column row needs beyond the column: which changes the engine -/// allows on it, and the dialect that checks what the user types. -pub(super) struct ColumnRowContext { - pub permissions: ColumnCapabilities, - pub type_suggestions: &'static [&'static str], - pub dialect: Option>, - /// The default as SQL the user could type back in, or the text they - /// typed that did not parse. - pub default_text: String, - pub default_error: Option, - /// Numbering goes off with the key, on an engine that numbers only - /// a key column. - pub numbering_follows_key: bool, +/// Sends a row's edits as edits of its column, while the column lives. +#[derive(Clone)] +struct ColumnEdits { + column: glib::WeakRef, + sender: StructureTabSender, } -/// The collapsed row's summary, such as `varchar(255) · NOT NULL · -/// Primary key`. -fn format_column_subtitle(col: &DraftColumn) -> String { - let mut parts: Vec = Vec::new(); - let type_text = col.column_type.text(); - if !type_text.is_empty() { - parts.push(type_text.to_owned()); - } - parts.push(if col.nullable { - crate::i18n::gettext("nullable") - } else { - crate::i18n::gettext("NOT NULL") - }); - if col.primary_key { - parts.push(crate::i18n::gettext("Primary key")); - } - if col.auto_increment { - parts.push(crate::i18n::gettext("auto-increment")); +impl ColumnEdits { + fn send(&self, field: ColumnField) { + if let Some(column) = self.column.upgrade() { + self.sender.input(StructureTabInput::ColumnEdited { column, field }); + } } - parts.join(" · ") } /// Turn a control off with the reason, in its tooltip and for screen @@ -97,33 +76,52 @@ fn mark_invalid(row: &adw::EntryRow, error: Option<&str>) { } } -/// Whether the engine's parser takes `text` as one type. A catalogue -/// declaration left as it was is never parsed again. +/// Whether the engine's parser takes `text` as one type. The +/// catalogue's own declaration, typed back as it was, is never parsed +/// again. fn type_error(dialect: Option<&dyn SqlDialect>, column: &DraftColumn, text: &str) -> Option { - let unchanged = matches!(&column.column_type, DraftType::Catalog(_)) && column.column_type.text() == text; - if unchanged { + let declared = column + .original + .as_ref() + .is_some_and(|original| DraftType::Catalog(original.info.column_type.clone()).text() == text); + if declared { return None; } let dialect = dialect?; dialect.parse_type(text).err().map(|error| error.to_string()) } -/// While `suppress_emit` is set, the signals GTK fires as the initial -/// values go in are not taken for edits. -pub(super) fn build_column_expander_row( - index: usize, - col: &DraftColumn, - context: ColumnRowContext, - sender: StructureTabSender, - suppress_emit: Rc>, - popover_registry: Rc>>, +/// Mark the Default field with the error the column holds, now and each +/// time it changes. The column outlives its row when the list builds +/// its rows again, so the handler goes with the row. +fn follow_default_error(column: &DraftColumnObject, row: &adw::EntryRow) { + mark_invalid(row, column.default_error().as_deref()); + let weak_row = row.downgrade(); + let handler = column.connect_default_error_notify(move |column| { + if let Some(row) = weak_row.upgrade() { + mark_invalid(&row, column.default_error().as_deref()); + } + }); + let guard = RefCell::new(Some(HandlerGuard::new(column, handler))); + row.connect_destroy(move |_| drop(guard.take())); +} + +/// The row of one column. Each control takes its value from the column +/// before its handler is connected, so building a row sends no edit. +pub(super) fn build_column_row( + column: &DraftColumnObject, + context: &StructureContext, + sender: &StructureTabSender, ) -> adw::ExpanderRow { - let permissions = &context.permissions; - let row = adw::ExpanderRow::builder() - .title(glib::markup_escape_text(&col.name)) - .subtitle(glib::markup_escape_text(&format_column_subtitle(col))) - .build(); - row.set_widget_name(&format!("col-row-{index}")); + let draft = column.column(); + let permissions = context.column_permissions(&draft); + let edits = ColumnEdits { + column: column.downgrade(), + sender: sender.clone(), + }; + let row = adw::ExpanderRow::builder().use_markup(false).build(); + column.bind_property("name", &row, "title").sync_create().build(); + column.bind_property("summary", &row, "subtitle").sync_create().build(); // On the header, so it shows whether or not the row is expanded. let remove_button = gtk::Button::builder() @@ -132,127 +130,94 @@ pub(super) fn build_column_expander_row( .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - remove_button.set_widget_name(&format!("col-remove-{index}")); + remove_button.set_widget_name("col-remove"); gate(&remove_button, &permissions.drop); + let column_for_remove = column.downgrade(); let sender_for_remove = sender.clone(); - remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveColumn(index))); + remove_button.connect_clicked(move |_| { + if let Some(column) = column_for_remove.upgrade() { + sender_for_remove.input(StructureTabInput::RemoveColumn(column)); + } + }); row.add_suffix(&remove_button); - // The expander's title mirrors this entry live, so the collapsed - // header always shows what the user typed. let name_row = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); - name_row.set_text(&col.name); - name_row.set_widget_name(&format!("col-name-{index}")); + name_row.set_text(&draft.name); + name_row.set_widget_name("col-name"); gate(&name_row, &permissions.rename); - let sender_for_name = sender.clone(); - let suppress_for_name = suppress_emit.clone(); - // The row owns this entry, so the entry holds the row weakly. - let row_for_name = row.downgrade(); - name_row.connect_changed(move |e| { - if suppress_for_name.get() { - return; - } - let text = e.text().to_string(); - if let Some(row) = row_for_name.upgrade() { - row.set_title(&glib::markup_escape_text(&text)); - } - sender_for_name.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::Name(text), - }); - }); + let edits_for_name = edits.clone(); + name_row.connect_changed(move |entry| edits_for_name.send(ColumnField::Name(entry.text().to_string()))); row.add_row(&name_row); // Free text, so `decimal(10,2)` or an enum type need no list; the // menu only suggests. let type_row = adw::EntryRow::builder().title(crate::i18n::gettext("Type")).build(); - type_row.set_text(col.column_type.text()); - type_row.set_widget_name(&format!("col-type-{index}")); - gate(&type_row, &permissions.alter_type); + type_row.set_text(draft.column_type.text()); + type_row.set_widget_name("col-type"); + // Before the gate, whose reason the tooltip has to keep. mark_invalid( &type_row, - type_error(context.dialect.as_deref(), col, col.column_type.text()).as_deref(), + type_error(context.dialect.as_deref(), &draft, draft.column_type.text()).as_deref(), ); - let sender_for_type = sender.clone(); - let suppress_for_type = suppress_emit.clone(); + gate(&type_row, &permissions.alter_type); + let edits_for_type = edits.clone(); let dialect_for_type = context.dialect.clone(); - let column_for_type = col.clone(); - type_row.connect_changed(move |e| { - if suppress_for_type.get() { - return; - } - let text = e.text().to_string(); - mark_invalid( - e, - type_error(dialect_for_type.as_deref(), &column_for_type, &text).as_deref(), - ); - sender_for_type.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::Type(text), - }); + type_row.connect_changed(move |entry| { + let text = entry.text().to_string(); + let error = edits_for_type + .column + .upgrade() + .and_then(|column| type_error(dialect_for_type.as_deref(), &column.column(), &text)); + mark_invalid(entry, error.as_deref()); + edits_for_type.send(ColumnField::Type(text)); }); if type_row.is_sensitive() { - let (suggestions_button, suggestions_popover) = - build_type_suggestions_button(context.type_suggestions, &type_row); - type_row.add_suffix(&suggestions_button); - popover_registry.borrow_mut().push(suggestions_popover); + type_row.add_suffix(&build_type_suggestions_button(context.type_suggestions(), &type_row)); } row.add_row(&type_row); let nullable_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Nullable")) - .active(col.nullable) + .active(draft.nullable) .build(); - nullable_row.set_widget_name(&format!("col-nullable-{index}")); + nullable_row.set_widget_name("col-nullable"); gate(&nullable_row, &permissions.alter_nullability); - let sender_for_null = sender.clone(); - let suppress_for_null = suppress_emit.clone(); - nullable_row.connect_active_notify(move |s| { - if suppress_for_null.get() { - return; - } - sender_for_null.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::Nullable(s.is_active()), - }); - }); + let edits_for_null = edits.clone(); + nullable_row.connect_active_notify(move |switch| edits_for_null.send(ColumnField::Nullable(switch.is_active()))); row.add_row(&nullable_row); // SQL, as the engine's parser reads it: a quoted literal or an - // expression. Empty means no DEFAULT clause. + // expression. Empty means no DEFAULT clause. Text the parser refused + // stays as the user typed it. let default_row = adw::EntryRow::builder() .title(crate::i18n::gettext("Default value")) .build(); - default_row.set_text(&context.default_text); - default_row.set_widget_name(&format!("col-default-{index}")); + let default_text = column + .unparsed_default() + .map_or_else(|| context.default_text(&draft), |unparsed| unparsed.text); + default_row.set_text(&default_text); + default_row.set_widget_name("col-default"); + follow_default_error(column, &default_row); gate(&default_row, &permissions.alter_default); - mark_invalid(&default_row, context.default_error.as_deref()); - let sender_for_default = sender.clone(); - let suppress_for_default = suppress_emit.clone(); - default_row.connect_changed(move |e| { - if suppress_for_default.get() { - return; - } - sender_for_default.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::Default(e.text().to_string()), - }); - }); + let edits_for_default = edits.clone(); + default_row.connect_changed(move |entry| edits_for_default.send(ColumnField::Default(entry.text().to_string()))); row.add_row(&default_row); let pk_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Primary key")) - .active(col.primary_key) + .active(draft.primary_key) .build(); - pk_row.set_widget_name(&format!("col-pk-{index}")); + pk_row.set_widget_name("col-pk"); gate(&pk_row, &permissions.alter_primary_key); let auto_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Auto increment")) - .active(col.auto_increment) + .active(draft.auto_increment) .build(); - auto_row.set_widget_name(&format!("col-auto-{index}")); - let follows_key = context.numbering_follows_key; + auto_row.set_widget_name("col-auto"); + // Numbering goes off with the key, on an engine that numbers only a + // key column. + let follows_key = !context.capabilities().auto_increment_outside_primary_key; match &permissions.alter_auto_increment { Permission::Allowed if follows_key => { pk_row @@ -267,25 +232,18 @@ pub(super) fn build_column_expander_row( // A refused nullability change keeps the reason it already shows. let hold = permissions.alter_nullability.is_allowed(); if hold { - hold_not_null(&nullable_row, col.primary_key); + hold_not_null(&nullable_row, draft.primary_key); } - let sender_for_pk = sender.clone(); - let suppress_for_pk = suppress_emit.clone(); + let edits_for_pk = edits.clone(); // The row owns both switches, so this holds them weakly. let nullable_for_pk = nullable_row.downgrade(); let auto_for_pk = auto_row.downgrade(); // Given back when the column leaves the key. - let nullable_before_key = Cell::new(col.nullable); - pk_row.connect_active_notify(move |s| { - if suppress_for_pk.get() { - return; - } - let key = s.is_active(); - sender_for_pk.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::PrimaryKey(key), - }); - // Each switch sends its own edit, so the model holds what the + let nullable_before_key = Cell::new(draft.nullable); + pk_row.connect_active_notify(move |switch| { + let key = switch.is_active(); + edits_for_pk.send(ColumnField::PrimaryKey(key)); + // Each switch sends its own edit, so the column holds what the // row shows. if hold && let Some(nullable) = nullable_for_pk.upgrade() { if key { @@ -305,37 +263,20 @@ pub(super) fn build_column_expander_row( }); row.add_row(&pk_row); - let sender_for_auto = sender.clone(); - let suppress_for_auto = suppress_emit.clone(); - auto_row.connect_active_notify(move |s| { - if suppress_for_auto.get() { - return; - } - sender_for_auto.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::AutoIncrement(s.is_active()), - }); - }); + let edits_for_auto = edits.clone(); + auto_row.connect_active_notify(move |switch| edits_for_auto.send(ColumnField::AutoIncrement(switch.is_active()))); row.add_row(&auto_row); // Empty means the column carries no description; clearing one the // server had drops it. let comment_row = adw::EntryRow::builder().title(crate::i18n::gettext("Comment")).build(); - comment_row.set_text(col.comment.as_deref().unwrap_or("")); - comment_row.set_widget_name(&format!("col-comment-{index}")); + comment_row.set_text(draft.comment.as_deref().unwrap_or("")); + comment_row.set_widget_name("col-comment"); gate(&comment_row, &permissions.alter_comment); - let sender_for_comment = sender; - let suppress_for_comment = suppress_emit; - comment_row.connect_changed(move |e| { - if suppress_for_comment.get() { - return; - } - let text = e.text().to_string(); + comment_row.connect_changed(move |entry| { + let text = entry.text().to_string(); let value = if text.is_empty() { None } else { Some(text) }; - sender_for_comment.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::Comment(value), - }); + edits.send(ColumnField::Comment(value)); }); row.add_row(&comment_row); @@ -345,12 +286,7 @@ pub(super) fn build_column_expander_row( /// A picked type is written into `target`, whose `changed` handler /// takes it like typed text. Each button has its own action group, so /// two columns' menus never share the `types.apply` action. -/// -/// The popover comes back too, to be popped down before its row goes. -fn build_type_suggestions_button( - suggestions: &'static [&'static str], - target: &adw::EntryRow, -) -> (gtk::MenuButton, gtk::Popover) { +fn build_type_suggestions_button(suggestions: &'static [&'static str], target: &adw::EntryRow) -> gtk::MenuButton { let action_group = gio::SimpleActionGroup::new(); let apply_action = gio::SimpleAction::new("apply", Some(&String::static_variant_type())); // The entry owns this button and its actions. @@ -373,10 +309,9 @@ fn build_type_suggestions_button( .icon_name(crate::ui::icons::PAN_DOWN) .tooltip_text(crate::i18n::gettext("Suggested types")) .valign(gtk::Align::Center) + .menu_model(&menu) .build(); button.add_css_class("flat"); button.insert_action_group("types", Some(&action_group)); - let popover = gtk::PopoverMenu::from_model(Some(&menu)); - button.set_popover(Some(&popover)); - (button, popover.upcast()) + button } diff --git a/linux/crates/app/src/ui/structure_tab/context.rs b/linux/crates/app/src/ui/structure_tab/context.rs new file mode 100644 index 0000000000..fc311ff0d0 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/context.rs @@ -0,0 +1,148 @@ +//! What the Structure tab knows about the table it edits: whether it +//! exists, the definition the last read returned, and the dialect whose +//! DDL rules decide which changes each row offers. + +use std::sync::Arc; + +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::ddl::{ + ColumnCapabilities, DdlCapabilities, DdlDialect, DraftColumn, Permission, RefusalReason, TableDefinition, + default_text, parse_default, primary_key_permission, +}; +use tablepro_core::dialect::SqlDialect; + +use super::StructureMode; + +/// The lists build their rows from this while the tab changes their +/// stores, so it lives apart from the model the tab is changing. +pub(super) struct StructureContext { + pub mode: StructureMode, + /// The connection's dialect, which plans the DDL. `None` until the + /// first read, and the SQL cannot be written before then. + pub dialect: Option>, + /// The table as the last read returned it, which Save plans against + /// and Discard restores. `None` for a new table. + pub definition: Option, +} + +impl StructureContext { + pub(super) fn ddl(&self) -> Option<&dyn DdlDialect> { + self.dialect.as_deref().and_then(|dialect| dialect.ddl()) + } + + /// The definition Save diffs the draft against: none for a new table. + pub(super) fn original(&self) -> Option<&TableDefinition> { + match self.mode { + StructureMode::New => None, + StructureMode::Edit => self.definition.as_ref(), + } + } + + pub(super) fn capabilities(&self) -> DdlCapabilities { + let Some(ddl) = self.ddl() else { + return DdlCapabilities::default(); + }; + match self.original() { + Some(definition) => ddl.table_capabilities(definition), + None => *ddl.capabilities(), + } + } + + pub(super) fn type_suggestions(&self) -> &'static [&'static str] { + self.ddl().map(|ddl| ddl.type_suggestions()).unwrap_or_default() + } + + /// Which changes the row of `column` offers. A column the user added + /// in this tab is only a draft, so everything about it can change, + /// apart from joining the primary key of a table whose key is held. + pub(super) fn column_permissions(&self, column: &DraftColumn) -> ColumnCapabilities { + let capabilities = self.capabilities(); + let (Some(ddl), Some(definition), Some(original)) = (self.ddl(), self.original(), column.original.as_ref()) + else { + let primary_key = match self.original() { + Some(definition) => primary_key_permission(&capabilities, definition), + None => Permission::Allowed, + }; + return ColumnCapabilities { + rename: Permission::Allowed, + drop: Permission::Allowed, + alter_type: Permission::Allowed, + alter_nullability: Permission::Allowed, + alter_default: Permission::Allowed, + alter_primary_key: primary_key, + alter_auto_increment: Permission::Allowed, + alter_comment: Permission::unless(capabilities.column_comments, RefusalReason::EngineUnsupported), + }; + }; + ddl.column_capabilities(definition, original) + } + + /// A draft's indexes and foreign keys are only entries in it, so the + /// engine's limits apply to a table that exists. + pub(super) fn table_permission(&self, allowed: bool) -> Permission { + match self.mode { + StructureMode::New => Permission::Allowed, + StructureMode::Edit => Permission::unless(allowed, RefusalReason::EngineUnsupported), + } + } + + pub(super) fn can_add_column(&self) -> bool { + match self.mode { + StructureMode::New => true, + StructureMode::Edit => self.capabilities().add_column, + } + } + + pub(super) fn can_add_index(&self) -> bool { + self.capabilities().create_index + } + + /// A new table declares its keys inside CREATE TABLE, which every + /// engine that creates tables takes. + pub(super) fn can_add_foreign_key(&self) -> bool { + let capabilities = self.capabilities(); + let allowed = match self.mode { + StructureMode::New => capabilities.create_table, + StructureMode::Edit => capabilities.add_foreign_key, + }; + allowed && self.ddl().is_some() + } + + /// The type a default's literal is read against: the column's own, or + /// the one the user typed once it parses. + fn default_column_type(&self, column: &DraftColumn) -> Option { + let dialect = self.dialect.as_deref()?; + column.column_type.column_type(dialect, &column.name).ok() + } + + /// The default as SQL the user could type back in. + pub(super) fn default_text(&self, column: &DraftColumn) -> String { + match (self.dialect.as_deref(), self.default_column_type(column)) { + (Some(dialect), Some(column_type)) => default_text(dialect, &column.default, &column_type), + _ => match &column.default { + ColumnDefault::None => String::new(), + ColumnDefault::Literal(value) => tablepro_core::export::value_to_text(value).unwrap_or_default(), + ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), + }, + } + } + + /// The default the user typed, as the engine's parser reads it. The + /// text the row showed for the loaded default gives that default + /// back, so seeing it again is no edit. + pub(super) fn parse_default(&self, column: &DraftColumn, text: &str) -> Result { + if let Some(original) = column.original.as_ref().filter(|original| { + let loaded = DraftColumn::from_definition((*original).clone()); + self.default_text(&loaded) == text + }) { + return Ok(original.info.default.clone()); + } + let Some(dialect) = self.dialect.as_deref() else { + return Err(crate::i18n::gettext("Connect to a database to check the default.")); + }; + let column_type = self + .default_column_type(column) + .unwrap_or_else(|| ColumnType::untyped_text(column.column_type.text())); + parse_default(dialect, text, &column_type).map_err(|error| error.to_string()) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/draft_column_object.rs b/linux/crates/app/src/ui/structure_tab/draft_column_object.rs new file mode 100644 index 0000000000..b3cde852aa --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/draft_column_object.rs @@ -0,0 +1,82 @@ +//! A column of the draft, as the Columns list holds it. + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::ddl::DraftColumn; + +use super::draft_column_object_imp as imp; + +glib::wrapper! { + /// The row bound to a column follows its `name` and `summary`, and + /// the tab finds the column by this object rather than by position. + pub struct DraftColumnObject(ObjectSubclass); +} + +/// Default text the engine's parser refused. The column keeps the +/// default it had, and Save waits until the text parses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UnparsedDefault { + pub text: String, + pub error: String, +} + +impl DraftColumnObject { + pub(crate) fn new(column: DraftColumn) -> Self { + let object: Self = glib::Object::new(); + object.imp().column.replace(column); + object + } + + pub(crate) fn column(&self) -> DraftColumn { + self.imp().column.borrow().clone() + } + + /// Change the column, and tell the rows bound to it when its name or + /// summary changed. + pub(crate) fn update(&self, change: impl FnOnce(&mut DraftColumn)) { + let (name, summary) = (self.name(), self.summary()); + change(&mut self.imp().column.borrow_mut()); + // The borrow has ended by now, since a bound row reads the + // column back as it hears of the change. + if self.name() != name { + self.notify_name(); + } + if self.summary() != summary { + self.notify_summary(); + } + } + + pub(crate) fn unparsed_default(&self) -> Option { + self.imp().unparsed_default.borrow().clone() + } + + pub(crate) fn set_unparsed_default(&self, unparsed: Option) { + if *self.imp().unparsed_default.borrow() == unparsed { + return; + } + self.imp().unparsed_default.replace(unparsed); + self.notify_default_error(); + } +} + +/// The collapsed row's summary, such as `varchar(255) · NOT NULL · +/// Primary key`. +pub(super) fn column_summary(column: &DraftColumn) -> String { + let mut parts: Vec = Vec::new(); + let type_text = column.column_type.text(); + if !type_text.is_empty() { + parts.push(type_text.to_owned()); + } + parts.push(if column.nullable { + crate::i18n::gettext("nullable") + } else { + crate::i18n::gettext("NOT NULL") + }); + if column.primary_key { + parts.push(crate::i18n::gettext("Primary key")); + } + if column.auto_increment { + parts.push(crate::i18n::gettext("auto-increment")); + } + parts.join(" · ") +} diff --git a/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs b/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs new file mode 100644 index 0000000000..f1eb635667 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs @@ -0,0 +1,64 @@ +use std::cell::RefCell; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::ddl::{DraftColumn, DraftType}; + +use super::draft_column_object::{UnparsedDefault, column_summary}; + +#[derive(glib::Properties)] +#[properties(wrapper_type = super::draft_column_object::DraftColumnObject)] +pub struct DraftColumnObject { + pub(super) column: RefCell, + pub(super) unparsed_default: RefCell>, + #[property(get = Self::name)] + name: PhantomData, + /// What the collapsed row says under the name. + #[property(get = Self::summary)] + summary: PhantomData, + /// Why the engine's parser refused the default the user typed. + #[property(get = Self::default_error)] + default_error: PhantomData>, +} + +/// What an object made by `glib::Object::new` rather than +/// `DraftColumnObject::new` holds: a column with no name or type. +impl Default for DraftColumnObject { + fn default() -> Self { + Self { + column: RefCell::new(DraftColumn::new(String::new(), DraftType::Typed(String::new()))), + unparsed_default: RefCell::default(), + name: PhantomData, + summary: PhantomData, + default_error: PhantomData, + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for DraftColumnObject { + const NAME: &'static str = "TableProDraftColumnObject"; + type Type = super::draft_column_object::DraftColumnObject; +} + +#[glib::derived_properties] +impl ObjectImpl for DraftColumnObject {} + +impl DraftColumnObject { + fn name(&self) -> String { + self.column.borrow().name.clone() + } + + fn summary(&self) -> String { + column_summary(&self.column.borrow()) + } + + fn default_error(&self) -> Option { + self.unparsed_default + .borrow() + .as_ref() + .map(|unparsed| unparsed.error.clone()) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs index 3fc84a5367..e9679ec587 100644 --- a/linux/crates/app/src/ui/structure_tab/fks.rs +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -1,25 +1,26 @@ -//! Foreign-key row builder used by the Foreign Keys page of the -//! Structure tab. +//! The foreign key rows of the Structure tab's Foreign Keys page. use gtk4 as gtk; use libadwaita as adw; use libadwaita::prelude::*; -use tablepro_core::ForeignKeyInfo; -use tablepro_core::ddl::{Permission, ReferentialAction}; +use tablepro_core::ddl::Permission; use super::columns::gate; +use super::foreign_key_object::ForeignKeyObject; +use super::referential_action_label::action_text; use super::{StructureTabInput, StructureTabSender}; -/// Foreign-key row as a native `AdwActionRow`. Subtitle encodes both -/// local columns and the reference target so users see the full -/// relationship in a glance: `col_a, col_b → other_table (ref_a, ref_b)`. +/// The subtitle reads the whole relationship at a glance: +/// `col_a, col_b → other_table (ref_a, ref_b)`. `drafted` is a key the +/// user added in this tab rather than one read from the table. pub(super) fn build_fk_row( - index: usize, - fk: &ForeignKeyInfo, + object: &ForeignKeyObject, removable: &Permission, - sender: StructureTabSender, + drafted: bool, + sender: &StructureTabSender, ) -> adw::ActionRow { + let fk = object.info(); let qualified_ref = match &fk.ref_schema { Some(s) if !s.is_empty() => format!("{s}.{}", fk.ref_table), _ => fk.ref_table.clone(), @@ -29,19 +30,18 @@ pub(super) fn build_fk_row( fk.columns.join(", "), fk.ref_columns.join(", "), ); - // `None` is an action this build does not know, so it says so rather - // than claiming the default it may not be. if fk.on_delete.is_some() || fk.on_update.is_some() { subtitle.push_str(&format!( " · ON DELETE {} · ON UPDATE {}", - action_text(fk.on_delete), - action_text(fk.on_update), + action_text(fk.on_delete, drafted), + action_text(fk.on_update, drafted), )); } let row = adw::ActionRow::builder() - .title(glib::markup_escape_text(&fk.name)) - .subtitle(glib::markup_escape_text(&subtitle)) + .use_markup(false) + .title(fk.name.as_str()) + .subtitle(subtitle) .build(); let remove_button = gtk::Button::builder() @@ -50,29 +50,16 @@ pub(super) fn build_fk_row( .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - remove_button.set_widget_name(&format!("fk-remove-{index}")); + remove_button.set_widget_name("fk-remove"); gate(&remove_button, removable); + let key_for_remove = object.downgrade(); let sender_for_remove = sender.clone(); - remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveForeignKey(index))); + remove_button.connect_clicked(move |_| { + if let Some(key) = key_for_remove.upgrade() { + sender_for_remove.input(StructureTabInput::RemoveForeignKey(key)); + } + }); row.add_suffix(&remove_button); row } - -fn action_text(action: Option) -> String { - action.map_or_else( - || crate::i18n::gettext("not reported"), - |action| action.as_sql().to_owned(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_unknown_action_says_it_was_not_reported() { - assert_eq!(action_text(Some(ReferentialAction::SetNull)), "SET NULL"); - assert_eq!(action_text(None), "not reported"); - } -} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs new file mode 100644 index 0000000000..c30a834cb2 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -0,0 +1,724 @@ +//! Add Foreign Key: a name, the referenced table picked from the +//! catalog, the columns of the key each paired with a column of that +//! table, and what deleting or updating a referenced row does. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ForeignKeyInfo; +use tablepro_core::ddl::{DdlDialect, ReferentialAction}; +use tablepro_core::meta::{ObjectScope, TableRef}; +use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_session::{OperationError, Session}; + +use super::foreign_key_draft::{ForeignKeyDraft, ForeignKeyDraftError}; +use super::form_dialog::{FormDialog, give_state, mark_invalid}; +use super::referential_action_label::{action_at, action_labels}; +use super::table_choice_object::{TableChoiceObject, table_choices}; + +pub(super) struct ForeignKeyDialogInit { + /// The draft's columns, in table order. + pub columns: Vec, + /// The names the table's foreign keys already take. + pub existing: Vec, + /// The actions the engine takes, in the order it offers them. + pub actions: &'static [ReferentialAction], + /// Where the referenced table can be (`reference_scope`). + pub scope: ObjectScope, + pub session: Arc, + pub tasks: Tasks, +} + +/// The dialog, which owns the form's state (`give_state`). +pub(super) struct ForeignKeyDialog { + dialog: adw::Dialog, + #[cfg(test)] + inner: Rc, +} + +/// The pair rows as built: the key's columns and the referenced table's, +/// once read. A pair chosen in a row changes neither. +type PairShape = (Vec, Option>); + +struct Inner { + form: FormDialog, + name_row: adw::EntryRow, + table_row: adw::ComboRow, + table_spinner: adw::Spinner, + tables: gio::ListStore, + column_checks: Vec<(String, gtk::CheckButton)>, + pairs_group: adw::PreferencesGroup, + pair_rows: RefCell>, + shown_pairs: RefCell>, + columns: Vec, + existing: Vec, + scope: ObjectScope, + session: Arc, + tasks: Tasks, + draft: RefCell, + /// The chosen table's columns, once read. + referenced_columns: RefCell>>, + /// Counts the tables chosen, so the columns of one chosen earlier, + /// read after it, are dropped. + choice: Cell, + /// A read of the catalog that failed, which the form says first. + load_error: RefCell>, + name_edited: Cell, + columns_edited: Cell, + on_add: Box, +} + +impl ForeignKeyDialog { + pub(super) fn new(init: ForeignKeyDialogInit, on_add: impl Fn(ForeignKeyInfo) + 'static) -> Self { + let ForeignKeyDialogInit { + columns, + existing, + actions, + scope, + session, + tasks, + } = init; + let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("_Add")); + let details = adw::PreferencesGroup::new(); + let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + details.add(&name_row); + + let tables = gio::ListStore::new::(); + let table_row = adw::ComboRow::builder() + .title(crate::i18n::gettext("Referenced Table")) + .model(&tables) + .enable_search(true) + .sensitive(false) + .build(); + table_row.set_search_match_mode(gtk::StringFilterMatchMode::Substring); + table_row.set_expression(Some(>k::PropertyExpression::new( + TableChoiceObject::static_type(), + None::<>k::Expression>, + "label", + ))); + let table_spinner = adw::Spinner::new(); + table_row.add_suffix(&table_spinner); + let references = adw::PreferencesGroup::new(); + references.add(&table_row); + + let checklist = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Columns")) + .description(crate::i18n::gettext( + "The columns of this table that hold the reference.", + )) + .build(); + let column_checks: Vec<(String, gtk::CheckButton)> = columns + .iter() + .map(|column| { + let row = adw::ActionRow::builder().title(column).use_markup(false).build(); + let check = gtk::CheckButton::builder().valign(gtk::Align::Center).build(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + checklist.add(&row); + (column.clone(), check) + }) + .collect(); + + let pairs_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Referenced Columns")) + .visible(false) + .build(); + + let labels = action_labels(actions); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + let on_delete_row = adw::ComboRow::builder() + .title(crate::i18n::gettext("On Delete")) + .model(>k::StringList::new(&labels)) + .build(); + let on_update_row = adw::ComboRow::builder() + .title(crate::i18n::gettext("On Update")) + .model(>k::StringList::new(&labels)) + .build(); + let actions_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Actions")) + .build(); + actions_group.add(&on_delete_row); + actions_group.add(&on_update_row); + + for group in [&details, &references, &checklist, &pairs_group, &actions_group] { + form.page.add(group); + } + + let inner = Rc::new(Inner { + form, + name_row: name_row.clone(), + table_row: table_row.clone(), + table_spinner, + tables, + column_checks, + pairs_group, + pair_rows: RefCell::default(), + shown_pairs: RefCell::default(), + columns, + existing, + scope, + session, + tasks, + draft: RefCell::default(), + referenced_columns: RefCell::default(), + choice: Cell::new(0), + load_error: RefCell::default(), + name_edited: Cell::new(false), + columns_edited: Cell::new(false), + on_add: Box::new(on_add), + }); + + let weak = Rc::downgrade(&inner); + name_row.connect_changed(move |row| { + if let Some(inner) = weak.upgrade() { + inner.name_edited.set(true); + inner.draft.borrow_mut().name = row.text().to_string(); + inner.refresh(); + } + }); + for (column, check) in &inner.column_checks { + let (weak, column) = (Rc::downgrade(&inner), column.clone()); + check.connect_toggled(move |check| { + if let Some(inner) = weak.upgrade() { + inner.columns_edited.set(true); + let referenced = inner.referenced_columns.borrow().clone().unwrap_or_default(); + inner + .draft + .borrow_mut() + .set_checked(&inner.columns, &column, check.is_active(), &referenced); + inner.refresh(); + } + }); + } + for (row, on_delete) in [(&on_delete_row, true), (&on_update_row, false)] { + let weak = Rc::downgrade(&inner); + row.connect_selected_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let action = action_at(actions, row.selected()); + let mut draft = inner.draft.borrow_mut(); + match on_delete { + true => draft.on_delete = action, + false => draft.on_update = action, + } + } + }); + } + let weak = Rc::downgrade(&inner); + inner.form.submit.connect_clicked(move |_| { + if let Some(inner) = weak.upgrade() { + inner.submit(); + } + }); + inner.load_tables(); + inner.refresh(); + give_state(&dialog, inner.clone()); + Self { + dialog, + #[cfg(test)] + inner, + } + } + + pub(super) fn present(&self, parent: >k::Widget) { + self.dialog.present(Some(parent)); + } +} + +impl Inner { + fn submit(&self) { + let valid = self.draft.borrow().validate(&self.existing); + if let Ok(key) = valid { + (self.on_add)(key); + self.form.close(); + } + } + + /// The catalog's tables, read once as the dialog opens. + fn load_tables(self: &Rc) { + let (session, scope) = (self.session.clone(), self.scope.clone()); + let weak = Rc::downgrade(self); + let _delivery = + crate::runtime::spawn_and_deliver(&self.tasks, async move { session.objects(scope).await }, move |read| { + if let Some(inner) = weak.upgrade() { + inner.tables_read(read); + } + }); + } + + fn tables_read( + self: &Rc, + read: Result, Arc>, TaskFailure>, + ) { + self.table_spinner.set_visible(false); + match settled(read) { + Ok(objects) => { + // The first table is no more the user's choice than any + // other, so the row starts on none. + let choices: Vec = std::iter::once(TableChoiceObject::none()) + .chain(table_choices(&objects)) + .collect(); + self.tables.splice(0, self.tables.n_items(), &choices); + self.table_row.set_selected(0); + self.table_row.set_sensitive(true); + let weak = Rc::downgrade(self); + self.table_row.connect_selected_item_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let table = row + .selected_item() + .and_downcast::() + .and_then(|choice| choice.table().cloned()); + inner.choose_table(table); + } + }); + } + Err(error) => { + self.load_error.replace(Some(crate::i18n::gettext_f( + "Couldn't read the tables: {error}", + &[("error", &error)], + ))); + } + } + self.refresh(); + } + + /// The key's columns lose their partners until the new table's + /// columns are read, then pair with the ones of the same name. + fn choose_table(self: &Rc, table: Option) { + let choice = self.choice.get() + 1; + self.choice.set(choice); + self.referenced_columns.replace(None); + self.load_error.replace(None); + { + let mut draft = self.draft.borrow_mut(); + draft.referenced = table.clone(); + draft.pair_with(&[]); + } + self.table_spinner.set_visible(table.is_some()); + if let Some(table) = table { + let session = self.session.clone(); + let weak = Rc::downgrade(self); + let read_table = table.clone(); + let _delivery = crate::runtime::spawn_and_deliver( + &self.tasks, + async move { session.columns(read_table).await }, + move |read| { + if let Some(inner) = weak.upgrade() + && inner.choice.get() == choice + { + inner.columns_read(&table, read); + } + }, + ); + } + self.refresh(); + } + + fn columns_read( + self: &Rc, + table: &TableRef, + read: Result, Arc>, TaskFailure>, + ) { + self.table_spinner.set_visible(false); + match settled(read) { + Ok(columns) => { + let names: Vec = columns.iter().map(|column| column.name.clone()).collect(); + self.draft.borrow_mut().pair_with(&names); + self.referenced_columns.replace(Some(names)); + } + Err(error) => { + self.load_error.replace(Some(crate::i18n::gettext_f( + "Couldn't read the columns of {table}: {error}", + &[("table", &table.name), ("error", &error)], + ))); + } + } + self.refresh(); + } + + fn refresh(self: &Rc) { + self.show_pairs(); + let errors = match self.draft.borrow().validate(&self.existing) { + Ok(_) => Vec::new(), + Err(errors) => errors, + }; + let loaded = self.referenced_columns.borrow().is_some(); + let shown: Vec<&ForeignKeyDraftError> = errors + .iter() + .filter(|error| match error { + ForeignKeyDraftError::NameMissing | ForeignKeyDraftError::NameTaken => self.name_edited.get(), + ForeignKeyDraftError::NoTable | ForeignKeyDraftError::NoPairs => self.columns_edited.get(), + ForeignKeyDraftError::UnpairedColumn(_) => loaded, + }) + .collect(); + mark_invalid( + &self.name_row, + shown.iter().any(|error| { + matches!( + error, + ForeignKeyDraftError::NameMissing | ForeignKeyDraftError::NameTaken + ) + }), + ); + mark_invalid( + &self.table_row, + shown.iter().any(|error| matches!(error, ForeignKeyDraftError::NoTable)), + ); + for (column, row) in self.pair_rows.borrow().iter() { + let unpaired = shown + .iter() + .any(|error| matches!(error, ForeignKeyDraftError::UnpairedColumn(unpaired) if unpaired == column)); + mark_invalid(row, unpaired); + } + let problem = self + .load_error + .borrow() + .clone() + .or_else(|| shown.first().map(|error| error.message())); + self.form.show_state(errors.is_empty(), problem); + } + + /// A row per column of the key, choosing among the referenced + /// table's columns, built again only when the key's columns or the + /// table's change. + fn show_pairs(self: &Rc) { + let pairs = self.draft.borrow().pairs.clone(); + let referenced = self.referenced_columns.borrow().clone(); + let shape: PairShape = ( + pairs.iter().map(|(column, _)| column.clone()).collect(), + referenced.clone(), + ); + if self.shown_pairs.borrow().as_ref() == Some(&shape) { + return; + } + for (_, row) in self.pair_rows.borrow_mut().drain(..) { + self.pairs_group.remove(&row); + } + let candidates = referenced.unwrap_or_default(); + // A column pairs with nothing until the user or a matching name + // pairs it, never with whichever column comes first. + let none = crate::i18n::gettext("None"); + let labels: Vec<&str> = std::iter::once(none.as_str()) + .chain(candidates.iter().map(String::as_str)) + .collect(); + let rows: Vec<(String, adw::ComboRow)> = pairs + .iter() + .map(|(column, partner)| { + let row = adw::ComboRow::builder() + .title(crate::i18n::gettext_f("{column} references", &[("column", column)])) + .use_markup(false) + .model(>k::StringList::new(&labels)) + .sensitive(shape.1.is_some()) + .build(); + let selected = partner + .as_ref() + .and_then(|partner| candidates.iter().position(|candidate| candidate == partner)) + .and_then(|position| u32::try_from(position + 1).ok()) + .unwrap_or(0); + row.set_selected(selected); + let (weak, column_for_choice, candidates) = (Rc::downgrade(self), column.clone(), candidates.clone()); + row.connect_selected_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let partner = row + .selected() + .checked_sub(1) + .and_then(|position| usize::try_from(position).ok()) + .and_then(|position| candidates.get(position).cloned()); + inner.draft.borrow_mut().pair(&column_for_choice, partner); + inner.refresh(); + } + }); + self.pairs_group.add(&row); + (column.clone(), row) + }) + .collect(); + self.pairs_group.set_visible(!rows.is_empty()); + self.pair_rows.replace(rows); + self.shown_pairs.replace(Some(shape)); + } +} + +/// Where a key of a table in `schema` can find its referenced table: any +/// schema, or the table's own where the engine looks there alone. +pub(super) fn reference_scope(ddl: &dyn DdlDialect, schema: Option<&str>) -> ObjectScope { + match (ddl.references_other_schemas(), schema) { + (true, _) => ObjectScope::AllSchemas, + (false, Some(schema)) => ObjectScope::Schema(schema.to_owned()), + (false, None) => ObjectScope::DefaultSchema, + } +} + +/// A read that never answered reads like the operation it ran. +fn settled(read: Result>, TaskFailure>) -> Result { + match read { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(crate::ui::error_text::operation_error_message(&error)), + Err(failure) => Err(crate::ui::error_text::operation_error_message(&OperationError::from( + failure, + ))), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{ObjectInfo, ObjectKind}; + use tablepro_core::{ColumnInfo, DriverError}; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::super::form_dialog::tests::{WAIT, window}; + use super::*; + use crate::test_support::{FakeSession, drain_main_context, wait_until}; + + struct Harness { + dialog: Option, + added: Rc>>, + _window: adw::Window, + _fake: FakeSession, + runtime: Option, + } + + impl Harness { + fn open(script: ConnectionScript) -> Self { + let window = window(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let added = Rc::new(RefCell::new(Vec::new())); + let sink = added.clone(); + let dialog = ForeignKeyDialog::new( + ForeignKeyDialogInit { + columns: vec!["id".to_owned(), "account_id".to_owned(), "owner_id".to_owned()], + existing: vec!["fk_taken".to_owned()], + actions: &ReferentialAction::ALL, + scope: ObjectScope::AllSchemas, + session: fake.session.clone(), + tasks: runtime.tasks(), + }, + move |key| sink.borrow_mut().push(key), + ); + dialog.present(window.upcast_ref()); + Self { + dialog: Some(dialog), + added, + _window: window, + _fake: fake, + runtime: Some(runtime), + } + } + + fn inner(&self) -> &Rc { + &self.dialog.as_ref().expect("the dialog is open").inner + } + + fn wait_for_tables(&self) { + let inner = self.inner().clone(); + wait_until(WAIT, || inner.table_row.is_sensitive()).expect("the tables to be read"); + } + + fn check(&self, column: &str) { + let check = self + .inner() + .column_checks + .iter() + .find(|(name, _)| name == column) + .map(|(_, check)| check.clone()) + .expect("a check for the column"); + check.set_active(true); + } + + fn pair_row(&self, column: &str) -> adw::ComboRow { + self.inner() + .pair_rows + .borrow() + .iter() + .find(|(name, _)| name == column) + .map(|(_, row)| row.clone()) + .expect("a pair row for the column") + } + } + + impl Drop for Harness { + fn drop(&mut self) { + if let Some(dialog) = self.dialog.take() { + dialog.dialog.force_close(); + } + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } + } + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("integer"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn catalog() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("accounts", ObjectKind::Table), + ])); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id"), column("owner_id")])); + script + } + + fn table_labels(inner: &Inner) -> Vec { + (0..inner.tables.n_items()) + .filter_map(|position| inner.tables.item(position).and_downcast::()) + .map(|choice| choice.label()) + .collect() + } + + #[gtk4::test] + fn referenced_table_combo_searches_catalog_tables() { + let harness = Harness::open(catalog()); + + harness.wait_for_tables(); + let inner = harness.inner(); + + assert_eq!(table_labels(inner), ["None", "accounts", "users"]); + assert!(inner.table_row.enables_search()); + assert_eq!( + inner.table_row.search_match_mode(), + gtk::StringFilterMatchMode::Substring + ); + assert!(inner.table_row.expression().is_some()); + assert_eq!(inner.table_row.selected(), 0); + assert!(!inner.table_spinner.is_visible()); + assert_eq!(inner.draft.borrow().referenced, None); + } + + #[gtk4::test] + fn choosing_table_loads_columns_and_autopairs_same_name() { + let harness = Harness::open(catalog()); + harness.wait_for_tables(); + let inner = harness.inner().clone(); + inner.name_row.set_text("fk_owner"); + harness.check("owner_id"); + assert!(!harness.pair_row("owner_id").is_sensitive()); + assert_eq!( + inner.form.caption().as_deref(), + Some("Choose the table the key references.") + ); + + inner.table_row.set_selected(1); + wait_until(WAIT, || inner.referenced_columns.borrow().is_some()).expect("the columns to be read"); + + let pair = harness.pair_row("owner_id"); + assert!(pair.is_sensitive()); + assert_eq!( + pair.selected_item() + .and_downcast::() + .map(|item| item.string().to_string()) + .as_deref(), + Some("owner_id") + ); + assert!(inner.form.submit.is_sensitive()); + inner.form.submit.emit_clicked(); + let added = harness.added.borrow(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].columns, ["owner_id"]); + assert_eq!(added[0].ref_columns, ["owner_id"]); + assert_eq!( + (added[0].ref_schema.as_deref(), added[0].ref_table.as_str()), + (Some("public"), "accounts") + ); + assert_eq!((added[0].on_delete, added[0].on_update), (None, None)); + } + + #[gtk4::test] + fn a_column_with_no_partner_waits_for_one() { + let harness = Harness::open(catalog()); + harness.wait_for_tables(); + let inner = harness.inner().clone(); + inner.name_row.set_text("fk_account"); + harness.check("account_id"); + inner.table_row.set_selected(1); + wait_until(WAIT, || inner.referenced_columns.borrow().is_some()).expect("the columns to be read"); + + let pair = harness.pair_row("account_id"); + assert_eq!(pair.selected(), 0); + assert!(pair.has_css_class("error")); + assert!(!inner.form.submit.is_sensitive()); + assert_eq!( + inner.form.caption().as_deref(), + Some("Choose the column “account_id” references.") + ); + + pair.set_selected(1); + + assert!(!pair.has_css_class("error")); + assert!(inner.form.submit.is_sensitive()); + assert_eq!( + inner.draft.borrow().pairs, + [("account_id".to_owned(), Some("id".to_owned()))] + ); + } + + #[test] + fn a_sqlite_key_picks_its_table_from_its_own_schema() { + use tablepro_core::dialect::SqlDialect; + + let sqlite = drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new(3, 45, 0)); + let postgres = + drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let sqlite = sqlite.ddl().expect("SQLite plans DDL"); + let postgres = postgres.ddl().expect("PostgreSQL plans DDL"); + + assert_eq!( + reference_scope(sqlite, Some("aux")), + ObjectScope::Schema("aux".to_owned()) + ); + assert_eq!(reference_scope(sqlite, None), ObjectScope::DefaultSchema); + assert_eq!(reference_scope(postgres, Some("sales")), ObjectScope::AllSchemas); + } + + #[gtk4::test] + fn a_catalog_that_cannot_be_read_says_so() { + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::err(DriverError::server("permission denied"))); + let harness = Harness::open(script); + let inner = harness.inner().clone(); + + wait_until(WAIT, || inner.form.caption().is_some()).expect("the failure to show"); + + assert!( + inner + .form + .caption() + .is_some_and(|caption| caption.starts_with("Couldn't read the tables:")), + "{:?}", + inner.form.caption() + ); + assert!(!inner.table_row.is_sensitive()); + assert!(!inner.table_spinner.is_visible()); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs new file mode 100644 index 0000000000..b122072093 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs @@ -0,0 +1,222 @@ +//! A foreign key as the Add Foreign Key dialog holds it, checked on +//! every change. + +use tablepro_core::ForeignKeyInfo; +use tablepro_core::ddl::ReferentialAction; +use tablepro_core::meta::TableRef; + +use super::index_draft::same_name; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ForeignKeyDraft { + pub name: String, + /// Each column of the key, in the table's column order, with the + /// referenced column it pairs with once one is chosen. + pub pairs: Vec<(String, Option)>, + pub referenced: Option, + /// `None` leaves the action to the database, and the DDL says + /// nothing about it. + pub on_delete: Option, + pub on_update: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ForeignKeyDraftError { + NameMissing, + NameTaken, + NoTable, + NoPairs, + UnpairedColumn(String), +} + +impl ForeignKeyDraftError { + pub(crate) fn message(&self) -> String { + match self { + Self::NameMissing => crate::i18n::gettext("Foreign key name is required."), + Self::NameTaken => crate::i18n::gettext("The table already has a foreign key with this name."), + Self::NoTable => crate::i18n::gettext("Choose the table the key references."), + Self::NoPairs => crate::i18n::gettext("Choose at least one column."), + Self::UnpairedColumn(column) => { + crate::i18n::gettext_f("Choose the column “{column}” references.", &[("column", column)]) + } + } + } +} + +impl ForeignKeyDraft { + /// The key, or every problem in the order the form shows its + /// fields. `existing` are the names the table's foreign keys already + /// take. + pub(crate) fn validate(&self, existing: &[String]) -> Result> { + let name = self.name.trim(); + let mut errors = Vec::new(); + if name.is_empty() { + errors.push(ForeignKeyDraftError::NameMissing); + } else if existing.iter().any(|taken| same_name(taken, name)) { + errors.push(ForeignKeyDraftError::NameTaken); + } + if self.referenced.is_none() { + errors.push(ForeignKeyDraftError::NoTable); + } + if self.pairs.is_empty() { + errors.push(ForeignKeyDraftError::NoPairs); + } + errors.extend( + self.pairs + .iter() + .filter(|(_, referenced)| referenced.is_none()) + .map(|(column, _)| ForeignKeyDraftError::UnpairedColumn(column.clone())), + ); + let Some(referenced) = self.referenced.as_ref().filter(|_| errors.is_empty()) else { + return Err(errors); + }; + Ok(ForeignKeyInfo { + name: name.to_owned(), + columns: self.pairs.iter().map(|(column, _)| column.clone()).collect(), + ref_schema: referenced.schema.clone(), + ref_table: referenced.name.clone(), + ref_columns: self + .pairs + .iter() + .filter_map(|(_, referenced)| referenced.clone()) + .collect(), + on_delete: self.on_delete, + on_update: self.on_update, + }) + } + + /// Add `column` to the key or take it out, keeping the key in the + /// order of `columns`. An added column pairs with the column of the + /// same name in `referenced_columns`, if there is one. + pub(crate) fn set_checked( + &mut self, + columns: &[String], + column: &str, + checked: bool, + referenced_columns: &[String], + ) { + let present = self.pairs.iter().any(|(paired, _)| paired == column); + match (checked, present) { + (true, false) => { + let partner = same_column(referenced_columns, column); + self.pairs.push((column.to_owned(), partner)); + self.pairs + .sort_by_key(|(paired, _)| columns.iter().position(|candidate| candidate == paired)); + } + (false, true) => self.pairs.retain(|(paired, _)| paired != column), + _ => {} + } + } + + pub(crate) fn pair(&mut self, column: &str, referenced: Option) { + if let Some((_, partner)) = self.pairs.iter_mut().find(|(paired, _)| paired == column) { + *partner = referenced; + } + } + + /// The columns of a newly chosen table: each column of the key pairs + /// with the one of the same name there, or with none. + pub(crate) fn pair_with(&mut self, referenced_columns: &[String]) { + for (column, partner) in &mut self.pairs { + *partner = same_column(referenced_columns, column); + } + } +} + +/// The exact name first, then one that differs only in case. +fn same_column(candidates: &[String], column: &str) -> Option { + candidates + .iter() + .find(|candidate| *candidate == column) + .or_else(|| candidates.iter().find(|candidate| same_name(candidate, column))) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + #[test] + fn fk_requires_table_and_complete_pairs() { + let columns = names(&["id", "account_id", "owner"]); + let mut draft = ForeignKeyDraft::default(); + + assert_eq!( + draft.validate(&[]), + Err(vec![ + ForeignKeyDraftError::NameMissing, + ForeignKeyDraftError::NoTable, + ForeignKeyDraftError::NoPairs, + ]) + ); + + draft.name = "fk_account".to_owned(); + draft.set_checked(&columns, "account_id", true, &[]); + draft.set_checked(&columns, "owner", true, &[]); + assert_eq!( + draft.validate(&names(&["FK_ACCOUNT"])), + Err(vec![ + ForeignKeyDraftError::NameTaken, + ForeignKeyDraftError::NoTable, + ForeignKeyDraftError::UnpairedColumn("account_id".to_owned()), + ForeignKeyDraftError::UnpairedColumn("owner".to_owned()), + ]) + ); + + draft.referenced = Some(TableRef::new(Some("billing".to_owned()), "accounts")); + draft.pair_with(&names(&["id", "Owner"])); + assert_eq!( + draft.validate(&[]), + Err(vec![ForeignKeyDraftError::UnpairedColumn("account_id".to_owned())]) + ); + + draft.pair("account_id", Some("id".to_owned())); + let key = draft.validate(&[]).expect("a complete key"); + assert_eq!(key.columns, ["account_id", "owner"]); + assert_eq!(key.ref_columns, ["id", "Owner"]); + assert_eq!( + (key.ref_schema.as_deref(), key.ref_table.as_str()), + (Some("billing"), "accounts") + ); + } + + #[test] + fn fk_pairs_keep_source_order() { + let columns = names(&["a", "b", "c"]); + let referenced = names(&["c", "a"]); + let mut draft = ForeignKeyDraft::default(); + + draft.set_checked(&columns, "c", true, &referenced); + draft.set_checked(&columns, "a", true, &referenced); + draft.set_checked(&columns, "b", true, &referenced); + draft.set_checked(&columns, "b", false, &referenced); + + assert_eq!( + draft.pairs, + [ + ("a".to_owned(), Some("a".to_owned())), + ("c".to_owned(), Some("c".to_owned())), + ] + ); + } + + #[test] + fn database_default_action_is_none() { + let mut draft = ForeignKeyDraft { + name: "fk".to_owned(), + referenced: Some(TableRef::unqualified("parent")), + ..ForeignKeyDraft::default() + }; + draft.set_checked(&names(&["parent_id"]), "parent_id", true, &names(&["parent_id"])); + draft.on_update = Some(ReferentialAction::Cascade); + + let key = draft.validate(&[]).expect("a complete key"); + + assert_eq!(key.on_delete, None); + assert_eq!(key.on_update, Some(ReferentialAction::Cascade)); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs new file mode 100644 index 0000000000..03e9ec6921 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs @@ -0,0 +1,53 @@ +//! A foreign key of the draft, as the Foreign Keys list holds it. + +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::ForeignKeyInfo; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct ForeignKeyObject { + pub info: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ForeignKeyObject { + const NAME: &'static str = "TableProForeignKeyObject"; + type Type = super::ForeignKeyObject; + } + + impl ObjectImpl for ForeignKeyObject {} +} + +glib::wrapper! { + /// The tab finds the key by this object rather than by position. + pub struct ForeignKeyObject(ObjectSubclass); +} + +/// What an object made by `glib::Object::new` rather than +/// `ForeignKeyObject::new` reads as: a key of nothing. +static UNSET: ForeignKeyInfo = ForeignKeyInfo { + name: String::new(), + columns: Vec::new(), + ref_schema: None, + ref_table: String::new(), + ref_columns: Vec::new(), + on_delete: None, + on_update: None, +}; + +impl ForeignKeyObject { + pub(crate) fn new(info: ForeignKeyInfo) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().info.set(info); + object + } + + pub(crate) fn info(&self) -> &ForeignKeyInfo { + self.imp().info.get().unwrap_or(&UNSET) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/form_dialog.rs b/linux/crates/app/src/ui/structure_tab/form_dialog.rs new file mode 100644 index 0000000000..5b86fb025f --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/form_dialog.rs @@ -0,0 +1,230 @@ +//! The frame the Add Index and Add Foreign Key forms share: an +//! `adw::Dialog` with Cancel and the suggested submit in its header bar +//! and the fields on a preferences page, as in GNOME Settings' Add +//! dialogs. The HIG keeps AlertDialog for confirmations. +//! +//! The form is checked on every change. Submit is on only while it is +//! valid, so Enter, which reaches it as the default widget, does +//! nothing until then; the first problem the user can act on shows +//! under the fields. + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +pub(super) struct FormDialog { + /// Weak, since the dialog owns the state the form lives in + /// (`give_state`), and a strong reference back would keep both. + dialog: glib::WeakRef, + pub page: adw::PreferencesPage, + pub submit: gtk::Button, + caption: gtk::Label, +} + +impl FormDialog { + /// The form, and the dialog it is drawn in, which nothing else holds + /// until it is presented. + pub(super) fn new(title: &str, submit_label: &str) -> (Self, adw::Dialog) { + let dialog = adw::Dialog::builder() + .title(title) + .content_width(420) + .content_height(560) + .build(); + let header = adw::HeaderBar::builder() + .show_start_title_buttons(false) + .show_end_title_buttons(false) + .build(); + let cancel = gtk::Button::with_mnemonic(&crate::i18n::gettext("_Cancel")); + let submit = gtk::Button::with_mnemonic(submit_label); + submit.add_css_class("suggested-action"); + submit.set_sensitive(false); + header.pack_start(&cancel); + header.pack_end(&submit); + + let page = adw::PreferencesPage::new(); + let caption = gtk::Label::builder() + .xalign(0.0) + .wrap(true) + .margin_top(6) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .visible(false) + .build(); + caption.add_css_class("caption"); + caption.add_css_class("error"); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&page)); + toolbar.add_bottom_bar(&caption); + dialog.set_child(Some(&toolbar)); + // A row reaches this only with activates-default set, which is + // why every entry row of a form sets it. + dialog.set_default_widget(Some(&submit)); + + let dialog_for_cancel = dialog.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(dialog) = dialog_for_cancel.upgrade() { + dialog.close(); + } + }); + let form = Self { + dialog: dialog.downgrade(), + page, + submit, + caption, + }; + (form, dialog) + } + + /// An entry row that submits the form on Enter. A name typed into it + /// is never markup. + pub(super) fn entry_row(title: &str) -> adw::EntryRow { + adw::EntryRow::builder() + .title(title) + .use_markup(false) + .activates_default(true) + .build() + } + + /// Submit on only for a valid form, and `problem` under the fields. + pub(super) fn show_state(&self, valid: bool, problem: Option) { + self.submit.set_sensitive(valid); + match problem { + Some(problem) => { + self.caption.set_label(&problem); + self.caption.set_visible(true); + } + None => self.caption.set_visible(false), + } + } + + #[cfg(test)] + pub(super) fn caption(&self) -> Option { + self.caption.is_visible().then(|| self.caption.label().to_string()) + } + + pub(super) fn close(&self) { + if let Some(dialog) = self.dialog.upgrade() { + dialog.close(); + } + } +} + +/// The dialog keeps `state` until GTK lets the dialog go, once it closes +/// or with its window. The form's handlers hold the state weakly, and +/// the row that opens a form keeps nothing, so without this every +/// control would stop answering as soon as the form opened. +pub(super) fn give_state(dialog: &adw::Dialog, state: Rc) { + // Never disconnected: it runs once, as the dialog is disposed. + let _owner = dialog.add_weak_ref_notify_local(move || drop(state)); +} + +/// The `error` style on a field the problem shown is about. +pub(super) fn mark_invalid(widget: &impl IsA, invalid: bool) { + match invalid { + true => widget.add_css_class("error"), + false => widget.remove_css_class("error"), + } +} + +/// A GtkPopoverMenu reads an underscore in a label as a mnemonic, so a +/// column name keeps its underscores by doubling them. +pub(super) fn menu_label(text: &str) -> String { + text.replace('_', "__") +} + +#[cfg(test)] +pub(super) mod tests { + use std::time::Duration; + + use super::*; + + pub(crate) const WAIT: Duration = Duration::from_secs(5); + + /// The GtkText inside an AdwEntryRow. Enter reaches the row through + /// this delegate, so that is where a test presses it. + pub(crate) fn press_enter(row: &adw::EntryRow) { + let text = gtk::prelude::EditableExt::delegate(row) + .and_then(|delegate| delegate.downcast::().ok()) + .expect("an AdwEntryRow delegates to a GtkText"); + text.emit_activate(); + } + + /// A mapped window: GtkButton refuses to activate an unrealized + /// button, so a form that is not on screen would pass an Enter test + /// for the wrong reason. + pub(crate) fn window() -> adw::Window { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + window + } + + #[test] + fn a_column_name_keeps_its_underscores_in_a_menu() { + assert_eq!(menu_label("account_id"), "account__id"); + } + + /// A form handed to its dialog as the add dialogs hand theirs: the + /// state holds the form, and the form its dialog. + fn open_form(window: &adw::Window) -> (glib::WeakRef, std::rc::Weak) { + let (form, dialog) = FormDialog::new("Add Index", "_Add"); + let state = Rc::new(form); + let held = Rc::downgrade(&state); + give_state(&dialog, state); + dialog.present(Some(window.upcast_ref::())); + crate::test_support::drain_main_context(); + (dialog.downgrade(), held) + } + + #[gtk4::test] + fn an_open_form_keeps_its_state_until_it_closes() { + let window = window(); + let (dialog, held) = open_form(&window); + + assert!(held.upgrade().is_some(), "the state went while the form was open"); + dialog.upgrade().expect("the open form").force_close(); + + crate::test_support::wait_until(WAIT, || held.upgrade().is_none()).expect("a closed form kept its state"); + assert!(dialog.upgrade().is_none(), "a closed form stayed alive"); + } + + #[gtk4::test] + fn a_form_lets_go_of_its_state_with_its_window() { + let window = window(); + let (dialog, held) = open_form(&window); + + // AdwWindow lets its dialogs go as it is finalized, not as it is + // destroyed, so the test's own reference has to go too. + window.destroy(); + drop(window); + + crate::test_support::wait_until(WAIT, || held.upgrade().is_none()) + .expect("a form torn down with its window kept its state"); + assert!( + dialog.upgrade().is_none(), + "a form torn down with its window stayed alive" + ); + } + + #[gtk4::test] + fn a_form_dialog_starts_with_submit_off_and_as_its_default() { + adw::init().expect("libadwaita initialises under the test backend"); + let (form, dialog) = FormDialog::new("Add Index", "_Add"); + + assert_eq!(dialog.default_widget().as_ref(), Some(form.submit.upcast_ref())); + assert!(!form.submit.is_sensitive()); + assert_eq!(form.caption(), None); + form.show_state(false, Some("Index name is required.".to_owned())); + assert_eq!(form.caption().as_deref(), Some("Index name is required.")); + form.show_state(true, None); + assert!(form.submit.is_sensitive()); + assert_eq!(form.caption(), None); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index 7a5ec4777e..bde7daad35 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -1,6 +1,5 @@ use std::cell::{Cell, RefCell}; use std::collections::VecDeque; -use std::rc::Rc; use std::sync::OnceLock; use glib::subclass::Signal; @@ -26,10 +25,28 @@ pub struct StructureTab { #[template_child] pub columns_box: TemplateChild, #[template_child] + pub columns_list: TemplateChild, + #[template_child] + pub add_column_list: TemplateChild, + #[template_child] + pub add_column_row: TemplateChild, + #[template_child] pub indexes_box: TemplateChild, #[template_child] + pub indexes_list: TemplateChild, + #[template_child] + pub add_index_list: TemplateChild, + #[template_child] + pub add_index_row: TemplateChild, + #[template_child] pub fks_box: TemplateChild, #[template_child] + pub fks_list: TemplateChild, + #[template_child] + pub add_fk_list: TemplateChild, + #[template_child] + pub add_fk_row: TemplateChild, + #[template_child] pub copy_sql_button: TemplateChild, #[template_child] pub sql_view: TemplateChild, @@ -53,7 +70,6 @@ pub struct StructureTab { pub(super) model: RefCell>, pub(super) queue: RefCell>, pub(super) dispatching: Cell, - pub(super) suppress_emit: Rc>, /// Delivers each reconnect, for as long as the tab is open. pub(super) link_watch: RefCell>, } diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs new file mode 100644 index 0000000000..0f26c5ea01 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -0,0 +1,475 @@ +//! Add Index: a name, Unique, and the key columns in order, each with +//! its own menu to sort it descending, move it or remove it. + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::IndexInfo; + +use super::form_dialog::{FormDialog, give_state, mark_invalid, menu_label}; +use super::index_draft::{DraftKey, IndexDraft, IndexDraftError}; + +/// The dialog, which owns the form's state (`give_state`). +pub(super) struct IndexDialog { + dialog: adw::Dialog, + #[cfg(test)] + inner: Rc, +} + +struct Inner { + form: FormDialog, + name_row: adw::EntryRow, + keys_group: adw::PreferencesGroup, + add_key_button: gtk::MenuButton, + add_key_menu: gio::Menu, + key_rows: RefCell>, + /// The keys the rows show, so a change that leaves them alone does + /// not build them again. + shown_keys: RefCell>>, + /// The draft's columns, in table order. + columns: Vec, + /// The names the table's indexes already take. + existing: Vec, + draft: RefCell, + /// An empty name is a problem to point out only once the user has + /// typed one. + name_edited: Cell, + refresh_queued: Cell, + on_add: Box, +} + +impl IndexDialog { + pub(super) fn new(columns: Vec, existing: Vec, on_add: impl Fn(IndexInfo) + 'static) -> Self { + let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("_Add")); + let details = adw::PreferencesGroup::new(); + let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + let unique_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Unique")) + .subtitle(crate::i18n::gettext("Refuse rows that repeat the key columns' values")) + .build(); + details.add(&name_row); + details.add(&unique_row); + + let add_key_menu = gio::Menu::new(); + let add_key_button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::LIST_ADD) + .menu_model(&add_key_menu) + .tooltip_text(crate::i18n::gettext("Add Key Column")) + .valign(gtk::Align::Center) + .build(); + add_key_button.add_css_class("flat"); + add_key_button.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext( + "Add Key Column", + ))]); + let keys_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Key Columns")) + .header_suffix(&add_key_button) + .build(); + form.page.add(&details); + form.page.add(&keys_group); + + let inner = Rc::new(Inner { + form, + name_row: name_row.clone(), + keys_group, + add_key_button, + add_key_menu, + key_rows: RefCell::default(), + shown_keys: RefCell::default(), + columns, + existing, + draft: RefCell::default(), + name_edited: Cell::new(false), + refresh_queued: Cell::new(false), + on_add: Box::new(on_add), + }); + + let actions = gio::SimpleActionGroup::new(); + let add_key = gio::SimpleAction::new("add-key", Some(glib::VariantTy::STRING)); + let weak = Rc::downgrade(&inner); + add_key.connect_activate(move |_, parameter| { + if let (Some(inner), Some(column)) = (weak.upgrade(), parameter.and_then(|value| value.str())) { + inner.draft.borrow_mut().add_key(column); + inner.refresh(); + } + }); + actions.add_action(&add_key); + dialog.insert_action_group("index", Some(&actions)); + + let weak = Rc::downgrade(&inner); + name_row.connect_changed(move |row| { + if let Some(inner) = weak.upgrade() { + inner.name_edited.set(true); + inner.draft.borrow_mut().name = row.text().to_string(); + inner.refresh(); + } + }); + let weak = Rc::downgrade(&inner); + unique_row.connect_active_notify(move |row| { + if let Some(inner) = weak.upgrade() { + inner.draft.borrow_mut().unique = row.is_active(); + } + }); + let weak = Rc::downgrade(&inner); + inner.form.submit.connect_clicked(move |_| { + if let Some(inner) = weak.upgrade() { + inner.submit(); + } + }); + inner.refresh(); + give_state(&dialog, inner.clone()); + Self { + dialog, + #[cfg(test)] + inner, + } + } + + pub(super) fn present(&self, parent: >k::Widget) { + self.dialog.present(Some(parent)); + } +} + +impl Inner { + fn submit(&self) { + let valid = self.draft.borrow().validate(&self.existing); + if let Ok(index) = valid { + (self.on_add)(index); + self.form.close(); + } + } + + fn refresh(self: &Rc) { + let errors = match self.draft.borrow().validate(&self.existing) { + Ok(_) => Vec::new(), + Err(errors) => errors, + }; + let name_error = errors + .iter() + .copied() + .find(|error| matches!(error, IndexDraftError::NameMissing | IndexDraftError::NameTaken)) + .filter(|_| self.name_edited.get()); + mark_invalid(&self.name_row, name_error.is_some()); + // An empty key list says so in the group's description. + self.form + .show_state(errors.is_empty(), name_error.map(IndexDraftError::message)); + self.show_keys(); + } + + /// The menu header and the rows of the keys, built again only when + /// the keys changed. + fn show_keys(self: &Rc) { + let keys = self.draft.borrow().keys.clone(); + if self.shown_keys.borrow().as_ref() == Some(&keys) { + return; + } + for row in self.key_rows.borrow_mut().drain(..) { + self.keys_group.remove(&row); + } + let rows: Vec = keys + .iter() + .enumerate() + .map(|(position, key)| self.key_row(position, key, keys.len())) + .collect(); + for row in &rows { + self.keys_group.add(row); + } + self.key_rows.replace(rows); + self.keys_group.set_description( + keys.is_empty() + .then(|| crate::i18n::gettext("Add the columns the index sorts by.")) + .as_deref(), + ); + + self.add_key_menu.remove_all(); + let unused: Vec = self + .draft + .borrow() + .unused(&self.columns) + .into_iter() + .map(str::to_owned) + .collect(); + for column in &unused { + let item = gio::MenuItem::new(Some(&menu_label(column)), None); + item.set_action_and_target_value(Some("index.add-key"), Some(&column.to_variant())); + self.add_key_menu.append_item(&item); + } + self.add_key_button.set_sensitive(!unused.is_empty()); + self.shown_keys.replace(Some(keys)); + } + + fn key_row(self: &Rc, position: usize, key: &DraftKey, count: usize) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(&key.column).use_markup(false).build(); + if key.descending { + row.set_subtitle(&crate::i18n::gettext("Descending")); + } + let order = gio::Menu::new(); + order.append(Some(&crate::i18n::gettext("Sort _Descending")), Some("key.descending")); + let moves = gio::Menu::new(); + moves.append(Some(&crate::i18n::gettext("Move _Up")), Some("key.move-up")); + moves.append(Some(&crate::i18n::gettext("Move D_own")), Some("key.move-down")); + let removal = gio::Menu::new(); + removal.append(Some(&crate::i18n::gettext("_Remove")), Some("key.remove")); + let menu = gio::Menu::new(); + menu.append_section(None, &order); + menu.append_section(None, &moves); + menu.append_section(None, &removal); + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::VIEW_MORE) + .menu_model(&menu) + .tooltip_text(crate::i18n::gettext("Key Options")) + .valign(gtk::Align::Center) + .build(); + button.add_css_class("flat"); + button.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("Key Options"))]); + row.add_suffix(&button); + + let actions = gio::SimpleActionGroup::new(); + let descending = gio::SimpleAction::new_stateful("descending", None, &key.descending.to_variant()); + let weak = Rc::downgrade(self); + descending.connect_activate(move |action, _| { + let next = !action.state().and_then(|state| state.get::()).unwrap_or(false); + action.set_state(&next.to_variant()); + edit_later(&weak, move |draft| draft.set_descending(position, next)); + }); + actions.add_action(&descending); + let steps = [ + ("move-up", position > 0, Edit::Up), + ("move-down", position + 1 < count, Edit::Down), + ("remove", true, Edit::Remove), + ]; + for (name, enabled, edit) in steps { + let action = gio::SimpleAction::new(name, None); + action.set_enabled(enabled); + let weak = Rc::downgrade(self); + action.connect_activate(move |_, _| { + edit_later(&weak, move |draft| match edit { + Edit::Up => draft.move_key(position, true), + Edit::Down => draft.move_key(position, false), + Edit::Remove => draft.remove_key(position), + }); + }); + actions.add_action(&action); + } + row.insert_action_group("key", Some(&actions)); + row + } +} + +#[derive(Debug, Clone, Copy)] +enum Edit { + Up, + Down, + Remove, +} + +/// The draft changes at once, and the rows a moment later: the menu the +/// edit came from belongs to a row that is about to be replaced, and is +/// still closing. +fn edit_later(inner: &Weak, edit: impl FnOnce(&mut IndexDraft)) { + let Some(inner) = inner.upgrade() else { + return; + }; + edit(&mut inner.draft.borrow_mut()); + if inner.refresh_queued.replace(true) { + return; + } + let weak = Rc::downgrade(&inner); + glib::idle_add_local_once(move || { + if let Some(inner) = weak.upgrade() { + inner.refresh_queued.set(false); + inner.refresh(); + } + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::super::form_dialog::tests::{WAIT, press_enter, window}; + use super::*; + use crate::test_support::{drain_main_context, wait_until}; + + fn dialog(added: &Rc>>) -> IndexDialog { + let sink = added.clone(); + IndexDialog::new( + vec!["id".to_owned(), "email".to_owned(), "created_at".to_owned()], + vec!["users_pkey".to_owned()], + move |index| sink.borrow_mut().push(index), + ) + } + + fn add_key(dialog: &IndexDialog, column: &str) { + assert!( + dialog + .dialog + .activate_action("index.add-key", Some(&column.to_variant())) + .is_ok(), + "no index.add-key action" + ); + } + + fn key_titles(dialog: &IndexDialog) -> Vec { + dialog + .inner + .key_rows + .borrow() + .iter() + .map(|row| row.title().to_string()) + .collect() + } + + #[gtk4::test] + fn add_button_insensitive_until_valid() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let submit = dialog.inner.form.submit.clone(); + + assert!(!submit.is_sensitive()); + dialog.inner.name_row.set_text("by_email"); + assert!(!submit.is_sensitive(), "an index without keys can be added"); + add_key(&dialog, "email"); + assert!(submit.is_sensitive()); + dialog.inner.name_row.set_text("USERS_PKEY"); + assert!(!submit.is_sensitive(), "a name the table already has can be added"); + assert_eq!( + dialog.inner.form.caption().as_deref(), + Some("The table already has an index with this name.") + ); + } + + #[gtk4::test] + fn empty_name_row_has_error_class() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let name_row = dialog.inner.name_row.clone(); + + assert!(!name_row.has_css_class("error"), "an untouched form opens marked wrong"); + assert_eq!(dialog.inner.form.caption(), None); + name_row.set_text("by_email"); + name_row.set_text(""); + + assert!(name_row.has_css_class("error")); + assert_eq!(dialog.inner.form.caption().as_deref(), Some("Index name is required.")); + name_row.set_text("by_email"); + assert!(!name_row.has_css_class("error")); + } + + #[gtk4::test] + fn the_add_menu_offers_only_unused_columns() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let labels = |dialog: &IndexDialog| -> Vec { + let menu = &dialog.inner.add_key_menu; + (0..menu.n_items()) + .filter_map(|item| menu.item_attribute_value(item, "label", Some(glib::VariantTy::STRING))) + .filter_map(|label| label.str().map(str::to_owned)) + .collect() + }; + + assert_eq!(labels(&dialog), ["id", "email", "created__at"]); + add_key(&dialog, "id"); + add_key(&dialog, "email"); + add_key(&dialog, "created_at"); + + assert!(labels(&dialog).is_empty()); + assert!(!dialog.inner.add_key_button.is_sensitive()); + } + + #[gtk4::test] + fn move_up_reorders_keys() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + add_key(&dialog, "email"); + add_key(&dialog, "created_at"); + let second = dialog.inner.key_rows.borrow()[1].clone(); + + assert!(second.activate_action("key.move-up", None).is_ok()); + drain_main_context(); + let first = dialog.inner.key_rows.borrow()[0].clone(); + assert!(first.activate_action("key.descending", None).is_ok()); + drain_main_context(); + + assert_eq!(key_titles(&dialog), ["created_at", "email"]); + dialog.inner.name_row.set_text("by_created"); + dialog.inner.form.submit.emit_clicked(); + let index = added.borrow().first().cloned().expect("the index was added"); + assert_eq!( + index + .keys + .iter() + .map(|key| (key.column_name().unwrap_or_default().to_owned(), key.descending())) + .collect::>(), + [("created_at".to_owned(), true), ("email".to_owned(), false)] + ); + let first = dialog.inner.key_rows.borrow()[0].clone(); + assert_eq!(first.subtitle().as_deref(), Some("Descending")); + } + + #[gtk4::test] + fn removing_the_last_key_leaves_the_form_invalid() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.inner.name_row.set_text("by_email"); + add_key(&dialog, "email"); + let row = dialog.inner.key_rows.borrow()[0].clone(); + + assert!(row.activate_action("key.remove", None).is_ok()); + drain_main_context(); + + assert!(key_titles(&dialog).is_empty()); + assert!(!dialog.inner.form.submit.is_sensitive()); + assert_eq!( + dialog.inner.keys_group.description().as_deref(), + Some("Add the columns the index sorts by.") + ); + } + + #[gtk4::test] + fn enter_on_valid_form_submits() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + name_row.set_text("by_email"); + add_key(&dialog, "email"); + + press_enter(&name_row); + + wait_until(WAIT, || added.borrow().len() == 1).expect("Enter did not add the index"); + assert_eq!(added.borrow()[0].name, "by_email"); + } + + #[gtk4::test] + fn enter_on_invalid_form_does_nothing() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + name_row.set_text("by_email"); + + press_enter(&name_row); + + assert!( + wait_until(Duration::from_secs(1), || !added.borrow().is_empty()).is_err(), + "Enter added an index with no keys" + ); + assert!(name_row.is_mapped(), "Enter closed an invalid form"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/index_draft.rs b/linux/crates/app/src/ui/structure_tab/index_draft.rs new file mode 100644 index 0000000000..e24fe07eb9 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_draft.rs @@ -0,0 +1,200 @@ +//! An index as the Add Index dialog holds it, checked on every change. + +use tablepro_core::IndexInfo; +use tablepro_core::meta::IndexKey; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct IndexDraft { + pub name: String, + pub unique: bool, + /// In key order, each column at most once. + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DraftKey { + pub column: String, + pub descending: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IndexDraftError { + NameMissing, + NameTaken, + NoKeys, +} + +impl IndexDraftError { + pub(crate) fn message(self) -> String { + match self { + Self::NameMissing => crate::i18n::gettext("Index name is required."), + Self::NameTaken => crate::i18n::gettext("The table already has an index with this name."), + Self::NoKeys => crate::i18n::gettext("Add at least one key column."), + } + } +} + +impl IndexDraft { + /// The index, or every problem in the order the form shows its + /// fields. `existing` are the names the table's indexes already + /// take. + pub(crate) fn validate(&self, existing: &[String]) -> Result> { + let name = self.name.trim(); + let mut errors = Vec::new(); + if name.is_empty() { + errors.push(IndexDraftError::NameMissing); + } else if existing.iter().any(|taken| same_name(taken, name)) { + errors.push(IndexDraftError::NameTaken); + } + if self.keys.is_empty() { + errors.push(IndexDraftError::NoKeys); + } + if !errors.is_empty() { + return Err(errors); + } + Ok(IndexInfo { + name: name.to_owned(), + keys: self + .keys + .iter() + .map(|key| IndexKey::Column { + name: key.column.clone(), + descending: key.descending, + }) + .collect(), + unique: self.unique, + primary: false, + predicate: None, + }) + } + + /// The columns of `columns` no key uses yet, in table order. + pub(crate) fn unused<'a>(&self, columns: &'a [String]) -> Vec<&'a str> { + columns + .iter() + .map(String::as_str) + .filter(|column| !self.keys.iter().any(|key| key.column == *column)) + .collect() + } + + pub(crate) fn add_key(&mut self, column: &str) { + if !self.keys.iter().any(|key| key.column == column) { + self.keys.push(DraftKey { + column: column.to_owned(), + descending: false, + }); + } + } + + pub(crate) fn remove_key(&mut self, position: usize) { + if position < self.keys.len() { + self.keys.remove(position); + } + } + + pub(crate) fn set_descending(&mut self, position: usize, descending: bool) { + if let Some(key) = self.keys.get_mut(position) { + key.descending = descending; + } + } + + /// Swap the key at `position` with the one before it, or after it + /// when `up` is false. A key at the end it moves toward stays. + pub(crate) fn move_key(&mut self, position: usize, up: bool) { + let other = match up { + true => position.checked_sub(1), + false => position.checked_add(1).filter(|other| *other < self.keys.len()), + }; + if let Some(other) = other + && position < self.keys.len() + { + self.keys.swap(position, other); + } + } +} + +/// Engines differ on whether names fold case, so two that differ only +/// in case are taken as the same. +pub(crate) fn same_name(left: &str, right: &str) -> bool { + left.trim().to_lowercase() == right.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft(name: &str, keys: &[&str]) -> IndexDraft { + let mut draft = IndexDraft { + name: name.to_owned(), + ..IndexDraft::default() + }; + for key in keys { + draft.add_key(key); + } + draft + } + + #[test] + fn index_requires_name_and_key() { + assert_eq!( + draft(" ", &[]).validate(&[]), + Err(vec![IndexDraftError::NameMissing, IndexDraftError::NoKeys]) + ); + assert_eq!(draft("by_email", &[]).validate(&[]), Err(vec![IndexDraftError::NoKeys])); + assert_eq!( + draft("", &["email"]).validate(&[]), + Err(vec![IndexDraftError::NameMissing]) + ); + let index = draft(" by_email ", &["email"]).validate(&[]).expect("a valid index"); + assert_eq!(index.name, "by_email"); + assert!(!index.primary && index.predicate.is_none()); + } + + #[test] + fn index_name_unique_case_insensitively() { + let existing = vec!["By_Email".to_owned()]; + + assert_eq!( + draft("by_email", &["email"]).validate(&existing), + Err(vec![IndexDraftError::NameTaken]) + ); + assert_eq!( + draft("BY_EMAIL", &["email"]).validate(&existing), + Err(vec![IndexDraftError::NameTaken]) + ); + assert!(draft("by_email_2", &["email"]).validate(&existing).is_ok()); + } + + #[test] + fn key_order_is_preserved() { + let mut draft = draft("by_name", &["last", "first", "last"]); + draft.set_descending(1, true); + + let keys = draft.validate(&[]).expect("a valid index").keys; + assert_eq!( + keys, + [ + IndexKey::Column { + name: "last".to_owned(), + descending: false + }, + IndexKey::Column { + name: "first".to_owned(), + descending: true + }, + ] + ); + + draft.move_key(1, true); + draft.move_key(0, true); + draft.move_key(1, false); + assert_eq!( + draft.keys.iter().map(|key| key.column.as_str()).collect::>(), + ["first", "last"] + ); + let columns = ["first".to_owned(), "middle".to_owned(), "last".to_owned()]; + assert_eq!(draft.unused(&columns), ["middle"]); + draft.remove_key(0); + assert_eq!(draft.unused(&columns), ["first", "middle"]); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/index_object.rs b/linux/crates/app/src/ui/structure_tab/index_object.rs new file mode 100644 index 0000000000..360a6f4946 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_object.rs @@ -0,0 +1,51 @@ +//! An index of the draft, as the Indexes list holds it. + +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::IndexInfo; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct IndexObject { + pub info: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for IndexObject { + const NAME: &'static str = "TableProIndexObject"; + type Type = super::IndexObject; + } + + impl ObjectImpl for IndexObject {} +} + +glib::wrapper! { + /// The tab finds the index by this object rather than by position. + pub struct IndexObject(ObjectSubclass); +} + +/// What an object made by `glib::Object::new` rather than +/// `IndexObject::new` reads as: an index of nothing. +static UNSET: IndexInfo = IndexInfo { + name: String::new(), + keys: Vec::new(), + unique: false, + primary: false, + predicate: None, +}; + +impl IndexObject { + pub(crate) fn new(info: IndexInfo) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().info.set(info); + object + } + + pub(crate) fn info(&self) -> &IndexInfo { + self.imp().info.get().unwrap_or(&UNSET) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index 7cab2d8632..c68fc3d6d4 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -1,5 +1,4 @@ -//! Index-row builder + tag widgets used by the Indexes page of the -//! Structure tab. +//! The index rows of the Structure tab's Indexes page. use gtk4 as gtk; use libadwaita as adw; @@ -10,6 +9,7 @@ use tablepro_core::ddl::Permission; use tablepro_core::meta::IndexKey; use super::columns::gate; +use super::index_object::IndexObject; use super::{StructureTabInput, StructureTabSender}; fn index_badge(label: &str, accent_class: &str) -> gtk::Label { @@ -20,15 +20,15 @@ fn index_badge(label: &str, accent_class: &str) -> gtk::Label { } pub(super) fn build_index_row( - index: usize, - idx: &IndexInfo, + object: &IndexObject, removable: &Permission, - sender: StructureTabSender, + sender: &StructureTabSender, ) -> adw::ActionRow { - let subtitle = index_key_summary(idx); + let idx = object.info(); let row = adw::ActionRow::builder() - .title(glib::markup_escape_text(&idx.name)) - .subtitle(glib::markup_escape_text(&subtitle)) + .use_markup(false) + .title(idx.name.as_str()) + .subtitle(index_key_summary(idx)) .build(); if idx.unique { @@ -44,7 +44,7 @@ pub(super) fn build_index_row( .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - remove_button.set_widget_name(&format!("index-remove-{index}")); + remove_button.set_widget_name("index-remove"); // The primary key's constraint owns its index. if idx.primary { remove_button.set_sensitive(false); @@ -54,8 +54,13 @@ pub(super) fn build_index_row( } else { gate(&remove_button, removable); } + let index_for_remove = object.downgrade(); let sender_for_remove = sender.clone(); - remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveIndex(index))); + remove_button.connect_clicked(move |_| { + if let Some(index) = index_for_remove.upgrade() { + sender_for_remove.input(StructureTabInput::RemoveIndex(index)); + } + }); row.add_suffix(&remove_button); row diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index bbefecff81..9b1e950759 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -9,7 +9,7 @@ //! │ ┌─ ViewStack ─────────────────────────────────────────┐ │ //! │ │ Columns: boxed-list of AdwExpanderRow (per column, │ │ //! │ │ header = Name + summary, body = Name/Type/Null/ │ │ -//! │ │ Default/PK/AutoInc rows) + AdwButtonRow add row │ │ +//! │ │ Default/PK/AutoInc rows), then an Add Column row │ │ //! │ │ Indexes: boxed-list (Name, Cols, Unique, trash) │ │ //! │ │ FKs: boxed-list (Name, Cols, Refs, RefCols, trash) │ │ //! │ │ SQL Preview: SourceView5 (read-only, sql highlight) │ │ @@ -17,51 +17,70 @@ //! ├─ ActionBar { pending count | Discard | Save | Drop } ────┤ //! └──────────────────────────────────────────────────────────┘ //! -//! The tab edits a `TableDraft` started from the table's definition as -//! one read returned it. Every edit diffs the draft against that -//! definition, plans the change through the connection's `DdlDialect` -//! for the preview, and hands the pending structure to the tab's -//! tracker, where the window's close and save paths read it. Each -//! control is on only where the dialect allows its change, with the -//! reason in its tooltip where it does not. Discard copies the -//! definition back over the draft; there is no per-change undo. +//! The tab edits a draft started from the table's definition as one +//! read returned it. The draft's columns, indexes and foreign keys are +//! the items of three list stores, each shown by a list bound to it, so +//! an edit changes one item and its row, and every other row stays as +//! it is. Every edit diffs the draft against the definition, plans the +//! change through the connection's `DdlDialect` for the preview, and +//! hands the pending structure to the tab's tracker, where the window's +//! close and save paths read it. Each control is on only where the +//! dialect allows its change, with the reason in its tooltip where it +//! does not. Discard puts the definition's items back in the stores; +//! there is no per-change undo. //! //! The tab reads the table's definition itself, one request at a time, //! and reads it again once the session reconnects if the last read -//! failed. +//! failed. A change to the table from elsewhere reads it again too, +//! unless the draft has edits: then Discard reads it, and a plan that +//! writes the table again from the old definition refuses to save. mod columns; +mod context; +mod draft_column_object; +mod draft_column_object_imp; mod fks; +mod foreign_key_dialog; +mod foreign_key_draft; +mod foreign_key_object; +mod form_dialog; mod imp; +mod index_dialog; +mod index_draft; +mod index_object; mod indexes; +mod referential_action_label; +pub(crate) mod review_dialog; mod structure_command; +mod table_choice_object; #[cfg(test)] mod tests; -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::collections::{BTreeMap, VecDeque}; use std::rc::Rc; use std::sync::Arc; use gtk4 as gtk; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use libadwaita as adw; use sourceview5::prelude::BufferExt; -use tablepro_core::column::ColumnDefault; -use tablepro_core::ddl::{ - ColumnCapabilities, DdlCapabilities, DdlDialect, DraftColumn, DraftType, Permission, RefusalReason, TableChanges, - TableDefinition, TableDraft, default_text, diff_table, parse_default, primary_key_permission, -}; -use tablepro_core::dialect::SqlDialect; +use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft, diff_table}; use tablepro_core::meta::TableRef; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; -use self::columns::{ColumnRowContext, build_column_expander_row}; +use self::columns::build_column_row; +use self::context::StructureContext; +use self::draft_column_object::{DraftColumnObject, UnparsedDefault}; use self::fks::build_fk_row; +use self::foreign_key_dialog::{ForeignKeyDialog, ForeignKeyDialogInit, reference_scope}; +use self::foreign_key_object::ForeignKeyObject; +use self::index_dialog::IndexDialog; +use self::index_object::IndexObject; use self::indexes::build_index_row; pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; @@ -69,7 +88,6 @@ use crate::services::pending_structure::PendingStructure; use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::ui::error_text::{build_error_text, save_blocker_text}; use crate::ui::handler_guard::HandlerGuard; -use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; glib::wrapper! { pub struct StructureTab(ObjectSubclass) @@ -109,12 +127,6 @@ impl StructureTabSender { tab.send(msg); } } - - pub(crate) fn output(&self, output: StructureTabOutput) { - if let Some(tab) = self.tab.upgrade() { - tab.emit_output(output); - } - } } impl StructureTab { @@ -155,34 +167,55 @@ impl StructureTab { StructureMode::New => "editor", StructureMode::Edit => "loading", }); - tab.connect_widgets(); + let name_changed = tab.connect_widgets(); - let link_changes = init.session.link_changes(); - let model = StructureModel { - this: tab.downgrade(), - link_generation: init.session.link_generation(), + let context = Rc::new(RefCell::new(StructureContext { + mode: init.mode, dialect: match init.mode { // A draft has no table to read the dialect back with. StructureMode::New => init.session.current_dialect(), StructureMode::Edit => None, }, + definition: None, + })); + let columns = gio::ListStore::new::(); + let indexes = gio::ListStore::new::(); + let foreign_keys = gio::ListStore::new::(); + tab.bind_lists(&context, &columns, &indexes, &foreign_keys); + tab.connect_add_dialogs( + &context, + (&columns, &indexes, &foreign_keys), + init.schema.clone(), + &init.session, + &init.tasks, + ); + + let link_changes = init.session.link_changes(); + let model = StructureModel { + this: tab.downgrade(), + link_generation: init.session.link_generation(), session: init.session, tasks: init.tasks, sequencer: RequestSequencer::new(), failed: FailedRequests::new(), issued_on: BTreeMap::new(), tracker: init.tracker, - mode: init.mode, - definition: None, - draft: Rc::new(RefCell::new(TableDraft::new(TableRef::new(init.schema, init.table)))), - invalid_defaults: BTreeMap::new(), + context, + table: TableRef::new(init.schema, init.table), + columns, + indexes, + foreign_keys, inner_stack: imp.inner_stack.get(), error_status: imp.error_status.get(), name_entry: imp.name_entry.get(), name_row: imp.name_row.get(), + name_changed, columns_box: imp.columns_box.get(), indexes_box: imp.indexes_box.get(), fks_box: imp.fks_box.get(), + add_column_row: imp.add_column_row.get(), + add_index_row: imp.add_index_row.get(), + add_fk_row: imp.add_fk_row.get(), sql_buffer, pending_label: imp.pending_label.get(), save_button: imp.save_button.get(), @@ -192,11 +225,9 @@ impl StructureTab { stop_button: imp.stop_button.get(), saving: false, last_dirty: false, - suppress_emit: imp.suppress_emit.clone(), - next_column_seq: 1, - column_popovers: Rc::new(RefCell::new(Vec::new())), refetching: false, definition_shown: false, + definition_stale: false, _theme_handler: theme_handler, }; imp.model.replace(Some(model)); @@ -328,7 +359,9 @@ impl StructureTab { } } - fn connect_widgets(&self) { + /// Connects the template's controls, and gives back the name entry's + /// handler, which the tab blocks while it writes the name itself. + fn connect_widgets(&self) -> glib::SignalHandlerId { let imp = self.imp(); let on_click = |button: >k::Button, msg: fn() -> StructureTabInput| { let sender = self.sender(); @@ -342,12 +375,111 @@ impl StructureTab { // would force the user to close the tab and open it again. on_click(&imp.retry_button, || StructureTabInput::Retry); let sender = self.sender(); - let suppress = imp.suppress_emit.clone(); + imp.add_column_row + .connect_activated(move |_| sender.input(StructureTabInput::AddColumn)); + // An add row that is hidden would leave its list's frame behind. + for (row, list) in [ + (&imp.add_column_row, &imp.add_column_list), + (&imp.add_index_row, &imp.add_index_list), + (&imp.add_fk_row, &imp.add_fk_list), + ] { + row.bind_property("visible", &**list, "visible").sync_create().build(); + } + let sender = self.sender(); imp.name_entry.connect_changed(move |entry| { - if suppress.get() { - return; - } sender.input(StructureTabInput::TableNameEdited(entry.text().to_string())); + }) + } + + /// Each list builds a row for an item from the item and the context + /// alone, since the stores change while the tab's model is borrowed. + fn bind_lists( + &self, + context: &Rc>, + columns: &gio::ListStore, + indexes: &gio::ListStore, + foreign_keys: &gio::ListStore, + ) { + let imp = self.imp(); + let (context_for_columns, sender) = (context.clone(), self.sender()); + bind_list(&imp.columns_list, columns, move |column: &DraftColumnObject| { + build_column_row(column, &context_for_columns.borrow(), &sender) + }); + let (context_for_indexes, sender) = (context.clone(), self.sender()); + bind_list(&imp.indexes_list, indexes, move |index: &IndexObject| { + let context = context_for_indexes.borrow(); + let removable = context.table_permission(context.capabilities().drop_index); + build_index_row(index, &removable, &sender) + }); + let (context_for_keys, sender) = (context.clone(), self.sender()); + bind_list(&imp.fks_list, foreign_keys, move |key: &ForeignKeyObject| { + let context = context_for_keys.borrow(); + let removable = context.table_permission(context.capabilities().drop_foreign_key); + let drafted = context + .original() + .is_none_or(|definition| !definition.foreign_keys.contains(key.info())); + build_fk_row(key, &removable, drafted, &sender) + }); + } + + /// Each dialog takes the draft as it is when it opens: its columns, + /// and the names its indexes or foreign keys already take. `schema` + /// is the table's, which a foreign key may have to reference within. + fn connect_add_dialogs( + &self, + context: &Rc>, + (columns, indexes, foreign_keys): (&gio::ListStore, &gio::ListStore, &gio::ListStore), + schema: Option, + session: &Arc, + tasks: &Tasks, + ) { + let imp = self.imp(); + let (columns_for_index, indexes, sender) = (columns.downgrade(), indexes.downgrade(), self.sender()); + imp.add_index_row.connect_activated(move |row| { + let (Some(columns), Some(indexes)) = (columns_for_index.upgrade(), indexes.upgrade()) else { + return; + }; + let existing = items::(&indexes) + .iter() + .map(|index| index.info().name.clone()) + .collect(); + let sender = sender.clone(); + IndexDialog::new(column_names(&columns), existing, move |index| { + sender.input(StructureTabInput::AddIndex(index)); + }) + .present(row.upcast_ref()); + }); + let (columns_for_key, keys, context, sender) = ( + columns.downgrade(), + foreign_keys.downgrade(), + context.clone(), + self.sender(), + ); + let (session, tasks) = (session.clone(), tasks.clone()); + imp.add_fk_row.connect_activated(move |row| { + let engine = context + .borrow() + .ddl() + .map(|ddl| (ddl.referential_actions(), reference_scope(ddl, schema.as_deref()))); + let (Some(columns), Some(keys), Some((actions, scope))) = + (columns_for_key.upgrade(), keys.upgrade(), engine) + else { + return; + }; + let init = ForeignKeyDialogInit { + columns: column_names(&columns), + existing: items::(&keys) + .iter() + .map(|key| key.info().name.clone()) + .collect(), + actions, + scope, + session: session.clone(), + tasks: tasks.clone(), + }; + let sender = sender.clone(); + ForeignKeyDialog::new(init, move |key| sender.input(StructureTabInput::AddForeignKey(key))) + .present(row.upcast_ref()); }); } } @@ -381,19 +513,17 @@ pub(crate) struct StructureModel { /// The newest connection the tab has heard of. link_generation: LinkGeneration, tracker: StructureTrackerHandle, - mode: StructureMode, - /// The table as the last read returned it, which Save plans against - /// and Discard restores. `None` for a new table. - definition: Option, - /// What the user is editing. Shared with the dialogs, which list its - /// columns. - draft: Rc>, - /// Default text the engine's parser refused, by column, kept so a - /// rebuild shows what the user typed. Save waits until it is empty. - invalid_defaults: BTreeMap, - /// The connection's dialect, which plans the DDL. `None` until the - /// first read, and the SQL cannot be written before then. - dialect: Option>, + /// Whether the table exists, its definition and the dialect, shared + /// with the lists, which read it as they build rows. + context: Rc>, + /// The table the draft names, renamed as the user types a new + /// table's name. + table: TableRef, + /// The draft's columns, indexes and foreign keys, which the lists + /// show. An edit finds its item here by identity. + columns: gio::ListStore, + indexes: gio::ListStore, + foreign_keys: gio::ListStore, inner_stack: gtk::Stack, /// The status page shown when the read fails, with the driver's @@ -402,9 +532,15 @@ pub(crate) struct StructureModel { name_entry: adw::EntryRow, /// The group around `name_entry`, visible only in New mode. name_row: adw::PreferencesGroup, + /// Blocked while the tab writes the draft's name into the entry, + /// which is no edit. + name_changed: glib::SignalHandlerId, columns_box: gtk::Box, indexes_box: gtk::Box, fks_box: gtk::Box, + add_column_row: adw::ButtonRow, + add_index_row: adw::ButtonRow, + add_fk_row: adw::ButtonRow, sql_buffer: sourceview5::Buffer, pending_label: gtk::Label, save_button: gtk::Button, @@ -416,22 +552,6 @@ pub(crate) struct StructureModel { saving: bool, last_dirty: bool, - /// Suppress reentrant rebuilds: programmatic `set_text` / - /// `set_active` while seeding row widgets fires `changed` / - /// `notify::active` signals synchronously. Without this guard, - /// the row's connect_* callbacks would treat seeding as a user - /// edit and shift the draft away from the definition, surfacing - /// phantom pending changes. `Cell` (not `RefCell`) because GTK can - /// fire those signals re-entrantly during `clear_box` while - /// another `borrow_mut` is still live on the stack. - suppress_emit: Rc>, - /// Monotonic counter for the "Add Column" placeholder name, so a - /// removed column does not leave its name to be handed out twice. - next_column_seq: usize, - /// The type suggestion popovers of the column rows. A rebuild pops - /// them down before it removes the rows, so no menu stays open over - /// a row that is gone. - column_popovers: Rc>>, /// True between `SaveCompleted` and the read that follows. The draft /// is already committed while the definition still holds the /// pre-save state, so diffing them would surface phantom pending @@ -440,6 +560,10 @@ pub(crate) struct StructureModel { /// The editor shows a definition the draft still started from, so a /// read of it that fails can leave it on screen. definition_shown: bool, + /// The table changed after `definition` was read, while the draft + /// had edits that a read would replace. Discard reads it again + /// rather than restoring it. + definition_stale: bool, /// The SQL preview follows the desktop theme through the app-wide /// style manager, which would keep the handler past the tab. _theme_handler: HandlerGuard, @@ -470,21 +594,22 @@ pub(crate) enum StructureTabInput { new_table_name: Option, }, SaveFailed(String), + /// The save found the table changed since the tab read it, and left + /// it as it was. + TableChangedBeforeSave, /// The save may have landed; the tracker refuses to save again until /// the table is read again or the draft is discarded. SaveOutcomeUnknown(String), - /// Rebuild the lists and the preview from the model. - Refresh, ColumnEdited { - index: usize, + column: DraftColumnObject, field: ColumnField, }, AddColumn, - RemoveColumn(usize), + RemoveColumn(DraftColumnObject), AddIndex(IndexInfo), - RemoveIndex(usize), + RemoveIndex(IndexObject), AddForeignKey(ForeignKeyInfo), - RemoveForeignKey(usize), + RemoveForeignKey(ForeignKeyObject), TableNameEdited(String), } @@ -534,31 +659,39 @@ pub(crate) enum StructureTabOutput { } impl StructureModel { - fn sender(&self) -> StructureTabSender { - StructureTabSender { tab: self.this.clone() } + fn mode(&self) -> StructureMode { + self.context.borrow().mode } - fn ddl(&self) -> Option<&dyn DdlDialect> { - self.dialect.as_deref().and_then(|dialect| dialect.ddl()) + /// The draft as the lists hold it. + fn draft(&self) -> TableDraft { + TableDraft { + table: self.table.clone(), + columns: draft_columns(&self.columns), + indexes: items::(&self.indexes) + .iter() + .map(|index| index.info().clone()) + .collect(), + foreign_keys: items::(&self.foreign_keys) + .iter() + .map(|key| key.info().clone()) + .collect(), + } } /// What Save would write: a new table, or the changes between the /// definition and the draft. fn pending(&self) -> PendingStructure { - let draft = self.draft.borrow().clone(); - let original = match self.mode { - StructureMode::New => None, - StructureMode::Edit => self.definition.clone(), - }; + let draft = self.draft(); + let original = self.context.borrow().original().cloned(); let changes = match &original { Some(original) => diff_table(original, &draft), None => TableChanges::default(), }; - let unparsed_defaults = self - .invalid_defaults - .keys() - .filter_map(|row| draft.columns.get(*row)) - .map(|column| column.name.clone()) + let unparsed_defaults = items::(&self.columns) + .iter() + .filter(|column| column.unparsed_default().is_some()) + .map(DraftColumnObject::name) .collect(); PendingStructure { original, @@ -580,7 +713,7 @@ impl StructureModel { } let pending = self.pending(); let dirty = pending.has_changes(); - self.refresh_buttons(pending.change_count()); + self.refresh_buttons(pending.change_count(), pending.unparsed_defaults.is_empty()); self.regenerate_sql_preview(&pending); self.tracker.set_pending(Some(pending)); if self.last_dirty != dirty { @@ -589,10 +722,9 @@ impl StructureModel { } } - fn refresh_buttons(&self, pending_count: usize) { + fn refresh_buttons(&self, pending_count: usize, defaults_parsed: bool) { let has_pending = pending_count > 0 && !self.saving; - self.save_button - .set_sensitive(has_pending && self.invalid_defaults.is_empty()); + self.save_button.set_sensitive(has_pending && defaults_parsed); self.discard_button.set_sensitive(has_pending); if has_pending { let label = crate::i18n::ngettext_f( @@ -609,10 +741,11 @@ impl StructureModel { } fn regenerate_sql_preview(&self, pending: &PendingStructure) { - let text = match (&self.dialect, self.ddl()) { + let context = self.context.borrow(); + let text = match (&context.dialect, context.ddl()) { (None, _) => crate::i18n::gettext("-- Connect to a database to see the SQL."), (Some(_), None) => crate::i18n::gettext("-- This database's structure can't be edited here."), - (Some(_), Some(_)) if !self.invalid_defaults.is_empty() => { + (Some(_), Some(_)) if !pending.unparsed_defaults.is_empty() => { crate::i18n::gettext("-- Fix the highlighted fields to see the SQL.") } (Some(_), Some(_)) if !pending.has_changes() => crate::i18n::gettext("-- No pending changes."), @@ -624,181 +757,55 @@ impl StructureModel { self.sql_buffer.set_text(&text); } - fn capabilities(&self) -> DdlCapabilities { - self.ddl().map(|ddl| *ddl.capabilities()).unwrap_or_default() - } - - /// Which changes the row of `column` offers. A column the user added - /// in this tab is only a draft, so everything about it can change, - /// apart from joining the primary key of a table whose key is held. - fn column_permissions(&self, column: &DraftColumn) -> ColumnCapabilities { - let capabilities = self.capabilities(); - let (Some(ddl), Some(definition), Some(original)) = ( - self.ddl(), - self.definition.as_ref().filter(|_| self.mode == StructureMode::Edit), - column.original.as_ref(), - ) else { - let primary_key = match (self.mode, &self.definition) { - (StructureMode::Edit, Some(definition)) => primary_key_permission(&capabilities, definition), - _ => Permission::Allowed, - }; - return ColumnCapabilities { - rename: Permission::Allowed, - drop: Permission::Allowed, - alter_type: Permission::Allowed, - alter_nullability: Permission::Allowed, - alter_default: Permission::Allowed, - alter_primary_key: primary_key, - alter_auto_increment: Permission::Allowed, - alter_comment: Permission::unless(capabilities.column_comments, RefusalReason::EngineUnsupported), - }; - }; - ddl.column_capabilities(definition, original) - } - - /// The type a default's literal is read against: the column's own, or - /// the one the user typed once it parses. - fn default_column_type(&self, column: &DraftColumn) -> Option { - let dialect = self.dialect.as_deref()?; - column.column_type.column_type(dialect, &column.name).ok() - } - - fn rebuild_columns_view(&self) { - // Seeding the new rows' widgets fires their change signals, - // which must not count as user edits; emit comes back on the - // next idle tick. - self.suppress_emit.set(true); - { - let mut popovers = self.column_popovers.borrow_mut(); - for p in popovers.drain(..) { - p.popdown(); - } - } - clear_box(&self.columns_box); - let capabilities = self.capabilities(); - let type_suggestions = self.ddl().map(|ddl| ddl.type_suggestions()).unwrap_or_default(); - - let list = boxed_list(); - let columns = self.draft.borrow().columns.clone(); - for (i, col) in columns.iter().enumerate() { - let (default_text, default_error) = match self.invalid_defaults.get(&i) { - Some((text, error)) => (text.clone(), Some(error.clone())), - None => (self.default_text(col), None), - }; - let context = ColumnRowContext { - permissions: self.column_permissions(col), - type_suggestions, - dialect: self.dialect.clone(), - default_text, - default_error, - numbering_follows_key: !capabilities.auto_increment_outside_primary_key, - }; - list.append(&build_column_expander_row( - i, - col, - context, - self.sender(), - self.suppress_emit.clone(), - self.column_popovers.clone(), - )); - } - let can_add = match self.mode { - StructureMode::New => true, - StructureMode::Edit => capabilities.add_column, - }; - if can_add { - let sender_for_add = self.sender(); - append_add_button(&list, &crate::i18n::gettext("Add Column"), move || { - sender_for_add.input(StructureTabInput::AddColumn); - }); - } - self.columns_box.append(&list); - - let suppress = self.suppress_emit.clone(); - glib::idle_add_local_once(move || { - suppress.set(false); - }); + /// The Add rows the engine allows on the table as it stands. + fn sync_add_rows(&self) { + let context = self.context.borrow(); + self.add_column_row.set_visible(context.can_add_column()); + self.add_index_row.set_visible(context.can_add_index()); + self.add_fk_row.set_visible(context.can_add_foreign_key()); } - fn default_text(&self, column: &DraftColumn) -> String { - match (self.dialect.as_deref(), self.default_column_type(column)) { - (Some(dialect), Some(column_type)) => default_text(dialect, &column.default, &column_type), - _ => match &column.default { - ColumnDefault::None => String::new(), - ColumnDefault::Literal(value) => tablepro_core::export::value_to_text(value).unwrap_or_default(), - ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), - }, - } + /// Put `draft` in the lists. Each store changes once, so each list + /// builds its rows once. + fn show_draft(&mut self, draft: TableDraft) { + self.table = draft.table; + replace_items(&self.columns, draft.columns.into_iter().map(DraftColumnObject::new)); + replace_items(&self.indexes, draft.indexes.into_iter().map(IndexObject::new)); + replace_items( + &self.foreign_keys, + draft.foreign_keys.into_iter().map(ForeignKeyObject::new), + ); } - /// A draft's indexes and foreign keys are only entries in it, so the - /// engine's limits apply to a table that exists. - fn table_permission(&self, allowed: bool) -> Permission { - match self.mode { - StructureMode::New => Permission::Allowed, - StructureMode::Edit => Permission::unless(allowed, RefusalReason::EngineUnsupported), - } + /// The lists build every row again, keeping the items, for rows + /// whose controls depend on a context that changed. + fn rebuild_rows(&self) { + replace_items(&self.columns, items::(&self.columns)); + replace_items(&self.indexes, items::(&self.indexes)); + replace_items(&self.foreign_keys, items::(&self.foreign_keys)); } - fn rebuild_indexes_view(&self) { - clear_box(&self.indexes_box); - let capabilities = self.capabilities(); - let removable = self.table_permission(capabilities.drop_index); - let list = boxed_list(); - for (i, idx) in self.draft.borrow().indexes.iter().enumerate() { - list.append(&build_index_row(i, idx, &removable, self.sender())); - } - if capabilities.create_index { - let draft_for_dialog = self.draft.clone(); - let sender_for_add = self.sender(); - let parent_box = self.indexes_box.downgrade(); - append_add_button(&list, &crate::i18n::gettext("Add Index…"), move || { - if let Some(parent_box) = parent_box.upgrade() { - present_index_dialog( - parent_box.upcast_ref(), - &draft_for_dialog.borrow().columns, - sender_for_add.clone(), - ); - } - }); - } - self.indexes_box.append(&list); + /// `column_N` with the first N that no column of the draft is named. + fn placeholder_column_name(&self) -> String { + let names: Vec = items::(&self.columns) + .iter() + .map(DraftColumnObject::name) + .collect(); + (1_usize..) + .map(|n| format!("column_{n}")) + .find(|candidate| !names.iter().any(|name| name.eq_ignore_ascii_case(candidate))) + .unwrap_or_default() } - fn rebuild_fks_view(&self) { - clear_box(&self.fks_box); - let capabilities = self.capabilities(); - let removable = self.table_permission(capabilities.drop_foreign_key); - let list = boxed_list(); - for (i, fk) in self.draft.borrow().foreign_keys.iter().enumerate() { - list.append(&build_fk_row(i, fk, &removable, self.sender())); - } - // A new table declares its keys inside CREATE TABLE, which every - // engine that creates tables takes. - let can_add = match self.mode { - StructureMode::New => capabilities.create_table, - StructureMode::Edit => capabilities.add_foreign_key, - }; - if let Some(actions) = self.ddl().map(|ddl| ddl.referential_actions()).filter(|_| can_add) { - let draft_for_dialog = self.draft.clone(); - let sender_for_add = self.sender(); - let parent_box = self.fks_box.downgrade(); - append_add_button(&list, &crate::i18n::gettext("Add Foreign Key…"), move || { - if let Some(parent_box) = parent_box.upgrade() { - present_fk_dialog( - parent_box.upcast_ref(), - &draft_for_dialog.borrow().columns, - actions, - sender_for_add.clone(), - ); - } - }); + /// Write the draft's name into the entry without it reading as an + /// edit. + fn show_table_name(&self) { + if self.name_entry.text() == self.table.name { + return; } - self.fks_box.append(&list); - } - - fn table_ref(&self) -> TableRef { - self.draft.borrow().table.clone() + self.name_entry.block_signal(&self.name_changed); + self.name_entry.set_text(&self.table.name); + self.name_entry.unblock_signal(&self.name_changed); } /// Read the table's definition in one call, so the tab rebuilds @@ -808,7 +815,7 @@ impl StructureModel { .insert(StructureRequestKind::Definition, self.session.link_generation()); let RequestTicket { stamp, cancel } = self.sequencer.issue(StructureRequestKind::Definition); let session = self.session.clone(); - let table = self.table_ref(); + let table = self.table.clone(); let work = async move { session .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { @@ -840,17 +847,21 @@ impl StructureModel { } fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { - self.dialect = Some(loaded.dialect); - *self.draft.borrow_mut() = TableDraft::from_definition(&loaded.definition); - self.definition = Some(loaded.definition); - self.invalid_defaults.clear(); + let draft = TableDraft::from_definition(&loaded.definition); + { + let mut context = self.context.borrow_mut(); + context.dialect = Some(loaded.dialect); + context.definition = Some(loaded.definition); + } + self.show_draft(draft); + self.sync_add_rows(); self.inner_stack.set_visible_child_name("editor"); self.definition_shown = true; + self.definition_stale = false; // The definition is current again, so the diff counts. self.refetching = false; self.sync_editable(); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } fn on_load_failed(&mut self, message: &str, fx: &mut StructureEffects) { @@ -897,10 +908,11 @@ impl StructureModel { return; } self.link_generation = generation; - if self.mode == StructureMode::New { - self.dialect = self.session.current_dialect(); + if self.mode() == StructureMode::New { + self.context.borrow_mut().dialect = self.session.current_dialect(); + self.rebuild_rows(); + self.sync_add_rows(); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } self.retry_failed(); } @@ -916,23 +928,27 @@ impl StructureModel { fn update(&mut self, msg: StructureTabInput, fx: &mut StructureEffects) { if msg.edits_draft() && self.edits_locked() { - self.refuse_edit(fx); + self.refuse_edit(&msg, fx); return; } match msg { StructureTabInput::Start => { - if self.mode == StructureMode::Edit { + if self.mode() == StructureMode::Edit { self.fetch_definition(); } - fx.input(StructureTabInput::Refresh); + self.sync_add_rows(); + self.regenerate_sql_preview(&self.pending()); } StructureTabInput::Command(command) => self.on_command(command, fx), StructureTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), StructureTabInput::SchemaChanged => { // Reading the table again replaces the draft, so a tab - // with edits of its own keeps them. - if self.mode == StructureMode::Edit && !self.last_dirty { - self.fetch_definition(); + // with edits of its own keeps them until Discard. + if self.mode() == StructureMode::Edit { + match self.last_dirty { + true => self.definition_stale = true, + false => self.fetch_definition(), + } } } StructureTabInput::Retry => { @@ -941,79 +957,50 @@ impl StructureModel { self.show_loading(); self.fetch_definition(); } - StructureTabInput::Refresh => { - self.rebuild_columns_view(); - self.rebuild_indexes_view(); - self.rebuild_fks_view(); - self.regenerate_sql_preview(&self.pending()); - } StructureTabInput::TableNameEdited(text) => { - if self.draft.borrow().table.name == text { + if self.table.name == text { return; } - self.draft.borrow_mut().table.name = text; + self.table.name = text; self.recompute_dirty_state(fx); } - StructureTabInput::ColumnEdited { index, field } => self.edit_column(index, field, fx), + StructureTabInput::ColumnEdited { column, field } => self.edit_column(&column, field, fx), StructureTabInput::AddColumn => { - let name = format!("column_{}", self.next_column_seq); - self.next_column_seq += 1; - let column_type = self.ddl().map_or("text", |ddl| ddl.default_column_type()); - self.draft - .borrow_mut() - .columns - .push(DraftColumn::new(name, DraftType::Typed(column_type.to_owned()))); + let column_type = self + .context + .borrow() + .ddl() + .map_or("text", |ddl| ddl.default_column_type()) + .to_owned(); + let column = DraftColumn::new(self.placeholder_column_name(), DraftType::Typed(column_type)); + self.columns.append(&DraftColumnObject::new(column)); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } - StructureTabInput::RemoveColumn(index) => { - { - let mut draft = self.draft.borrow_mut(); - if index >= draft.columns.len() { - return; - } - draft.columns.remove(index); + StructureTabInput::RemoveColumn(column) => { + if let Some(position) = self.columns.find(&column) { + self.columns.remove(position); + self.recompute_dirty_state(fx); } - // The rows after it move up one. - self.invalid_defaults = std::mem::take(&mut self.invalid_defaults) - .into_iter() - .filter(|(row, _)| *row != index) - .map(|(row, invalid)| (if row > index { row - 1 } else { row }, invalid)) - .collect(); - self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } StructureTabInput::AddIndex(index) => { - self.draft.borrow_mut().indexes.push(index); + self.indexes.append(&IndexObject::new(index)); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } - StructureTabInput::RemoveIndex(position) => { - { - let mut draft = self.draft.borrow_mut(); - if position >= draft.indexes.len() { - return; - } - draft.indexes.remove(position); + StructureTabInput::RemoveIndex(index) => { + if let Some(position) = self.indexes.find(&index) { + self.indexes.remove(position); + self.recompute_dirty_state(fx); } - self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } - StructureTabInput::AddForeignKey(fk) => { - self.draft.borrow_mut().foreign_keys.push(fk); + StructureTabInput::AddForeignKey(key) => { + self.foreign_keys.append(&ForeignKeyObject::new(key)); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } - StructureTabInput::RemoveForeignKey(position) => { - { - let mut draft = self.draft.borrow_mut(); - if position >= draft.foreign_keys.len() { - return; - } - draft.foreign_keys.remove(position); + StructureTabInput::RemoveForeignKey(key) => { + if let Some(position) = self.foreign_keys.find(&key) { + self.foreign_keys.remove(position); + self.recompute_dirty_state(fx); } - self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } StructureTabInput::Save => self.save(fx), StructureTabInput::SaveStarted => self.show_saving(true), @@ -1021,7 +1008,7 @@ impl StructureModel { StructureTabInput::Reload => { // A table the save may have created has nothing to read // yet, so a new one is only cleared. - match self.mode { + match self.mode() { StructureMode::Edit => { self.show_loading(); self.fetch_definition(); @@ -1032,25 +1019,23 @@ impl StructureModel { StructureTabInput::Discard => { // This also lets go of a save whose outcome was unknown. self.tracker.clear(); - // New mode clears everything, since a new table starts - // empty. - let restored = match (self.mode, &self.definition) { - (StructureMode::Edit, Some(definition)) => TableDraft::from_definition(definition), - _ => { - let mut draft = TableDraft::new(self.table_ref()); - draft.table.name = self.draft.borrow().table.name.clone(); - draft - } + if self.mode() == StructureMode::Edit && self.definition_stale { + fx.input(StructureTabInput::Reload); + return; + } + // New mode clears everything but the name, since a new + // table starts empty. + let restored = match self.context.borrow().original() { + Some(definition) => TableDraft::from_definition(definition), + None => TableDraft::new(self.table.clone()), }; - *self.draft.borrow_mut() = restored; - self.invalid_defaults.clear(); + self.show_draft(restored); self.recompute_dirty_state(fx); - fx.input(StructureTabInput::Refresh); } StructureTabInput::DropTableRequested => { - let table = match &self.definition { + let table = match &self.context.borrow().definition { Some(definition) => definition.table.clone(), - None => self.table_ref(), + None => self.table.clone(), }; fx.output(StructureTabOutput::DropTableRequested { schema: table.schema, @@ -1066,6 +1051,17 @@ impl StructureModel { body: message, }); } + StructureTabInput::TableChangedBeforeSave => { + self.definition_stale = true; + self.show_saving(false); + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Save failed"), + body: crate::i18n::gettext( + "The table changed after this tab read it, so nothing was saved. Discard the changes to read the table again, then make them again.", + ), + }); + } StructureTabInput::SaveOutcomeUnknown(message) => { self.show_saving(false); self.recompute_dirty_state(fx); @@ -1077,73 +1073,46 @@ impl StructureModel { } } - fn edit_column(&mut self, index: usize, field: ColumnField, fx: &mut StructureEffects) { - let Some(previous) = self.draft.borrow().columns.get(index).cloned() else { + fn edit_column(&mut self, column: &DraftColumnObject, field: ColumnField, fx: &mut StructureEffects) { + // A row of a column the draft no longer holds. + if self.columns.find(column).is_none() { return; - }; - let mut column = previous.clone(); + } match field { - ColumnField::Name(name) => column.name = name, - ColumnField::Type(text) => { + ColumnField::Name(name) => column.update(|draft| draft.name = name), + ColumnField::Type(text) => column.update(|draft| { // Retyping the declaration keeps the catalogue's type, // which is never parsed again. - column.column_type = match column.original.as_ref() { + draft.column_type = match draft.original.as_ref() { Some(original) if DraftType::Catalog(original.info.column_type.clone()).text() == text => { DraftType::Catalog(original.info.column_type.clone()) } _ => DraftType::Typed(text), }; - } - ColumnField::Nullable(nullable) => column.nullable = nullable, + }), + ColumnField::Nullable(nullable) => column.update(|draft| draft.nullable = nullable), // The row's Nullable and Auto increment switches follow the // key and send their own edits. - ColumnField::PrimaryKey(primary_key) => column.primary_key = primary_key, - ColumnField::AutoIncrement(auto_increment) => column.auto_increment = auto_increment, - ColumnField::Default(text) => match self.parse_default(&column, &text) { - Ok(default) => { - self.invalid_defaults.remove(&index); - column.default = default; - } - Err(error) => { - self.invalid_defaults.insert(index, (text, error)); + ColumnField::PrimaryKey(primary_key) => column.update(|draft| draft.primary_key = primary_key), + ColumnField::AutoIncrement(auto_increment) => column.update(|draft| draft.auto_increment = auto_increment), + ColumnField::Default(text) => { + let parsed = self.context.borrow().parse_default(&column.column(), &text); + match parsed { + Ok(default) => { + column.set_unparsed_default(None); + column.update(|draft| draft.default = default); + } + // No edit of the column, but the tracker has to learn + // of it, or a close prompt would save without it. + Err(error) => column.set_unparsed_default(Some(UnparsedDefault { text, error })), } - }, - ColumnField::Comment(comment) => column.comment = comment, - } - // A rebuild sets each widget to the value the model already has, - // which is no edit. A default the parser refused is no edit - // either, but the tracker has to learn of it, or a close prompt - // would save without it. - if column == previous { - self.recompute_dirty_state(fx); - return; - } - if let Some(slot) = self.draft.borrow_mut().columns.get_mut(index) { - *slot = column; + } + ColumnField::Comment(comment) => column.update(|draft| draft.comment = comment), } self.recompute_dirty_state(fx); } - /// The default the user typed, as the engine's parser reads it. The - /// text the row showed for the loaded default gives that default - /// back, so seeing it again is no edit. - fn parse_default(&self, column: &DraftColumn, text: &str) -> Result { - if let Some(original) = column.original.as_ref().filter(|original| { - let loaded = DraftColumn::from_definition((*original).clone()); - self.default_text(&loaded) == text - }) { - return Ok(original.info.default.clone()); - } - let Some(dialect) = self.dialect.as_deref() else { - return Err(crate::i18n::gettext("Connect to a database to check the default.")); - }; - let column_type = self - .default_column_type(column) - .unwrap_or_else(|| tablepro_core::column::ColumnType::untyped_text(column.column_type.text())); - parse_default(dialect, text, &column_type).map_err(|error| error.to_string()) - } - - fn save(&mut self, fx: &mut StructureEffects) { + fn save(&self, fx: &mut StructureEffects) { let pending = self.pending(); if let Err(blocker) = pending.check() { fx.output(StructureTabOutput::ShowToast(save_blocker_text(&blocker))); @@ -1153,7 +1122,8 @@ impl StructureModel { fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); return; } - let Some(ddl) = self.ddl() else { + let context = self.context.borrow(); + let Some(ddl) = context.ddl() else { fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Connect to a database first.", ))); @@ -1206,30 +1176,30 @@ impl StructureModel { } /// An edit that reached the tab while its draft was locked, from a - /// dialog opened before the save: the widgets go back to the draft. - fn refuse_edit(&self, fx: &mut StructureEffects) { - let name = self.draft.borrow().table.name.clone(); - if self.name_entry.text() != name { - self.suppress_emit.set(true); - self.name_entry.set_text(&name); - self.suppress_emit.set(false); + /// dialog opened before the save or a row the save had not turned + /// off yet: the controls go back to what the draft holds. + fn refuse_edit(&self, msg: &StructureTabInput, fx: &mut StructureEffects) { + self.show_table_name(); + if let StructureTabInput::ColumnEdited { column, .. } = msg + && let Some(position) = self.columns.find(column) + { + self.columns.splice(position, 1, std::slice::from_ref(column)); } fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Wait for the save to finish, then make the change again.", ))); - fx.input(StructureTabInput::Refresh); } fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { self.show_saving(false); if let Some(name) = new_table_name { - self.mode = StructureMode::Edit; - self.draft.borrow_mut().table.name = name.clone(); - self.name_entry.set_text(&name); + self.context.borrow_mut().mode = StructureMode::Edit; + self.table.name = name; + self.show_table_name(); self.drop_button.set_visible(true); self.name_row.set_visible(false); } - if self.mode != StructureMode::Edit { + if self.mode() != StructureMode::Edit { return; } // The diff against the pre-save definition would show the changes @@ -1238,7 +1208,7 @@ impl StructureModel { // dirty state goes to zero now, and so does a CREATE TABLE left // in the preview by a draft that just became the table. self.tracker.clear(); - self.refresh_buttons(0); + self.refresh_buttons(0, true); self.sql_buffer .set_text(&crate::i18n::gettext("-- No pending changes.")); if self.last_dirty { @@ -1252,33 +1222,49 @@ impl StructureModel { } } -fn boxed_list() -> gtk::ListBox { - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .margin_start(12) - .margin_end(12) - .margin_top(6) - .margin_bottom(6) +/// Show `store` in `list`, a row for each item built by `build`. An empty +/// boxed list would still draw its frame, so the list hides while the +/// store is empty. +fn bind_list(list: >k::ListBox, store: &gio::ListStore, build: impl Fn(&T) -> W + 'static) +where + T: IsA, + W: IsA, +{ + store + .bind_property("n-items", list, "visible") + .transform_to(|_, items: u32| Some(items > 0)) + .sync_create() .build(); - list.add_css_class("boxed-list"); - list + list.bind_model(Some(store), move |item| match item.downcast_ref::() { + Some(item) => build(item).upcast(), + // The store takes only `T`. + None => gtk::ListBoxRow::new().upcast(), + }); } -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); - } +fn items>(store: &gio::ListStore) -> Vec { + (0..store.n_items()) + .filter_map(|position| store.item(position).and_downcast::()) + .collect() } -/// A trailing `adw::ButtonRow`, the GNOME pattern for "Add another -/// item" rows in Settings and Builder. -fn append_add_button(list: >k::ListBox, label: &str, on_activate: impl Fn() + 'static) { - let row = adw::ButtonRow::builder() - .title(label) - .start_icon_name(crate::ui::icons::LIST_ADD) - .build(); - row.connect_activated(move |_| on_activate()); - list.append(&row); +fn column_names(store: &gio::ListStore) -> Vec { + items::(store) + .iter() + .map(DraftColumnObject::name) + .collect() +} + +fn draft_columns(store: &gio::ListStore) -> Vec { + items::(store) + .iter() + .map(DraftColumnObject::column) + .collect() +} + +fn replace_items>(store: &gio::ListStore, items: impl IntoIterator) { + let items: Vec = items.into_iter().collect(); + store.splice(0, store.n_items(), &items); } fn apply_sql_scheme(buffer: &sourceview5::Buffer) { diff --git a/linux/crates/app/src/ui/structure_tab/referential_action_label.rs b/linux/crates/app/src/ui/structure_tab/referential_action_label.rs new file mode 100644 index 0000000000..c4ee5494c9 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/referential_action_label.rs @@ -0,0 +1,64 @@ +//! How the Structure tab names a foreign key's actions. + +use tablepro_core::ddl::ReferentialAction; + +/// The On Delete and On Update choices: the database's own default, then +/// each action the engine takes, in its order. +pub(super) fn action_labels(actions: &[ReferentialAction]) -> Vec { + std::iter::once(crate::i18n::gettext("Database Default")) + .chain(actions.iter().map(|action| action.as_sql().to_owned())) + .collect() +} + +/// The action a choice stands for. The first choice leaves it to the +/// database; one past the end can only be a stale selection. +pub(super) fn action_at(actions: &[ReferentialAction], position: u32) -> Option { + let index = usize::try_from(position.checked_sub(1)?).ok()?; + actions.get(index).copied() +} + +/// A key the user drafted left an unset action to the database. One +/// read from the database with no action is one the driver could not +/// name, so the row says so rather than claim the default. +pub(super) fn action_text(action: Option, drafted: bool) -> String { + match (action, drafted) { + (Some(action), _) => action.as_sql().to_owned(), + (None, true) => crate::i18n::gettext("database default"), + (None, false) => crate::i18n::gettext("not reported"), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::DdlDialect; + + use super::*; + + #[test] + fn each_choice_decodes_to_the_action_it_is_labelled_with() { + let mssql = drivers_mssql::MssqlDialect::new(16); + let actions = mssql.referential_actions(); + + let labels = action_labels(actions); + + assert_eq!(labels[0], "Database Default"); + assert_eq!(action_at(actions, 0), None); + assert!(!labels.iter().any(|label| label == "RESTRICT"), "{labels:?}"); + for (position, label) in labels.iter().enumerate().skip(1) { + let position = u32::try_from(position).expect("a short list"); + assert_eq!( + action_at(actions, position).map(ReferentialAction::as_sql), + Some(label.as_str()) + ); + } + let past_the_end = u32::try_from(labels.len()).expect("a short list"); + assert_eq!(action_at(actions, past_the_end), None); + } + + #[test] + fn an_unset_action_reads_by_where_the_key_came_from() { + assert_eq!(action_text(Some(ReferentialAction::SetNull), false), "SET NULL"); + assert_eq!(action_text(None, true), "database default"); + assert_eq!(action_text(None, false), "not reported"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/review_dialog.rs b/linux/crates/app/src/ui/structure_tab/review_dialog.rs new file mode 100644 index 0000000000..48e5898ba8 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/review_dialog.rs @@ -0,0 +1,260 @@ +//! The review a structure save asks for before it runs: what it loses, +//! whether it can stop halfway, and the SQL itself. + +use std::cell::RefCell; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::WriteAtomicity; +use tablepro_core::ddl::{ChangeReview, DestructiveChange, RecreatedObject, SchemaObjectKind, TableRebuild}; + +use crate::ui::handler_guard::HandlerGuard; +use crate::workspace::EngineProfile; + +const CANCEL: &str = "cancel"; +const APPLY: &str = "apply"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReviewDecision { + Apply, + Cancel, +} + +/// Cancel is the default, so Enter never applies a change that loses +/// data. +pub(crate) async fn confirm( + parent: >k::Widget, + table: &str, + review: &ChangeReview, + engine: &EngineProfile, +) -> ReviewDecision { + let dialog = build(table, review, engine); + match crate::workspace::respond(&dialog, parent).await.as_str() { + APPLY => ReviewDecision::Apply, + _ => ReviewDecision::Cancel, + } +} + +pub(crate) fn build(table: &str, review: &ChangeReview, engine: &EngineProfile) -> adw::AlertDialog { + let count = review.operations; + 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.to_string()), ("table", table)], + ); + let dialog = adw::AlertDialog::new(Some(&heading), Some(&body(review, engine))); + // A table or column name is never markup. + dialog.set_heading_use_markup(false); + dialog.set_body_use_markup(false); + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(APPLY, &crate::i18n::gettext("_Apply")); + let appearance = match review.destructive.is_empty() { + true => adw::ResponseAppearance::Suggested, + false => adw::ResponseAppearance::Destructive, + }; + dialog.set_response_appearance(APPLY, appearance); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + dialog.set_prefer_wide_layout(true); + dialog.set_extra_child(Some(&sql_view(&review.sql(), &dialog))); + dialog +} + +fn body(review: &ChangeReview, engine: &EngineProfile) -> String { + let mut lines: Vec = review.destructive.iter().map(destructive_text).collect(); + if engine.ddl_writes == WriteAtomicity::StatementByStatement && review.statements.len() > 1 { + lines.push(crate::i18n::gettext_f( + "{engine} applies each statement separately. If one fails, the statements before it stay applied.", + &[("engine", &engine.name)], + )); + } + lines.join("\n") +} + +fn destructive_text(change: &DestructiveChange) -> String { + match change { + DestructiveChange::RebuildTable(rebuild) => rebuild_text(rebuild), + DestructiveChange::DropColumn { column } => { + crate::i18n::gettext_f("Drops column “{column}” and the data in it.", &[("column", column)]) + } + DestructiveChange::ChangeColumnType { column, from, to } => crate::i18n::gettext_f( + "Changes column “{column}” from {from} to {to}. A value the new type can't hold is converted or refused.", + &[("column", column), ("from", from), ("to", to)], + ), + DestructiveChange::DropIndex { index } => crate::i18n::gettext_f("Drops index “{index}”.", &[("index", index)]), + DestructiveChange::DropForeignKey { key } => { + crate::i18n::gettext_f("Drops foreign key “{key}”.", &[("key", key)]) + } + } +} + +fn rebuild_text(rebuild: &TableRebuild) -> String { + let mut sentences = vec![crate::i18n::gettext( + "Rebuilds the table: copies its rows into a new table and drops the old one.", + )]; + if !rebuild.recreated.is_empty() { + let objects: Vec = rebuild.recreated.iter().map(object_text).collect(); + sentences.push(crate::i18n::gettext_f( + "Drops and creates again: {objects}.", + &[("objects", &objects.join(", "))], + )); + } + if rebuild.renumbers_rowids { + sentences.push(crate::i18n::gettext("The rows get new rowids.")); + } + sentences.join(" ") +} + +fn object_text(object: &RecreatedObject) -> String { + let name = [("name", object.name.as_str())]; + match object.kind { + SchemaObjectKind::Index => crate::i18n::gettext_f("index “{name}”", &name), + SchemaObjectKind::Trigger => crate::i18n::gettext_f("trigger “{name}”", &name), + SchemaObjectKind::View => crate::i18n::gettext_f("view “{name}”", &name), + } +} + +fn sql_view(sql: &str, dialog: &adw::AlertDialog) -> gtk::ScrolledWindow { + let buffer = match sourceview5::LanguageManager::default().language("sql") { + Some(language) => sourceview5::Buffer::with_language(&language), + None => sourceview5::Buffer::new(None), + }; + buffer.set_text(sql); + super::apply_sql_scheme(&buffer); + let style_manager = adw::StyleManager::default(); + let buffer_for_theme = buffer.downgrade(); + let theme_handler = RefCell::new(Some(HandlerGuard::new( + &style_manager, + style_manager.connect_dark_notify(move |_| { + if let Some(buffer) = buffer_for_theme.upgrade() { + super::apply_sql_scheme(&buffer); + } + }), + ))); + dialog.connect_closed(move |_| { + theme_handler.borrow_mut().take(); + }); + + let view = sourceview5::View::with_buffer(&buffer); + view.set_editable(false); + view.set_cursor_visible(false); + view.set_monospace(true); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_top_margin(8); + view.set_bottom_margin(8); + view.set_left_margin(8); + view.set_right_margin(8); + view.upcast_ref::() + .update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("SQL to run"))]); + gtk::ScrolledWindow::builder() + .child(&view) + .has_frame(true) + .hscrollbar_policy(gtk::PolicyType::Never) + .min_content_height(160) + .max_content_height(320) + .propagate_natural_height(true) + .build() +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::{DdlPlan, TableChanges}; + + use super::*; + + fn engine(ddl_writes: WriteAtomicity) -> EngineProfile { + EngineProfile { + name: "MySQL".to_owned(), + ddl_writes, + } + } + + fn review(statements: &[&str], destructive: Vec) -> ChangeReview { + let plan = DdlPlan::from_sql(statements.iter().map(|statement| (*statement).to_owned())); + ChangeReview { + destructive, + ..ChangeReview::review(&TableChanges::default(), &plan) + } + } + + fn sql_child(dialog: &adw::AlertDialog) -> String { + let view = dialog + .extra_child() + .and_then(|child| child.downcast::().ok()) + .and_then(|scrolled| scrolled.child()) + .and_then(|child| child.downcast::().ok()) + .expect("the SQL view"); + assert!(!view.is_editable()); + let buffer = view.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() + } + + #[gtk4::test] + fn table_name_with_markup_chars_renders_literally() { + adw::init().expect("libadwaita initialises under the test backend"); + let mut reviewed = review( + &["ALTER TABLE t DROP COLUMN \"\""], + vec![DestructiveChange::DropColumn { + column: "".to_owned(), + }], + ); + reviewed.operations = 1; + + let dialog = build("a & b", &reviewed, &engine(WriteAtomicity::Transactional)); + + assert_eq!(dialog.heading().as_deref(), Some("Apply 1 Change to “a & b”?")); + assert!(!dialog.is_heading_use_markup()); + assert!(!dialog.is_body_use_markup()); + assert_eq!(dialog.body(), "Drops column “” and the data in it."); + assert_eq!(sql_child(&dialog), "ALTER TABLE t DROP COLUMN \"\";"); + assert_eq!(dialog.response_appearance(APPLY), adw::ResponseAppearance::Destructive); + assert_eq!(dialog.default_response().as_deref(), Some(CANCEL)); + assert_eq!(dialog.close_response(), CANCEL); + assert!(dialog.prefers_wide_layout()); + } + + #[gtk4::test] + fn a_save_that_can_stop_halfway_says_so_and_suggests_apply() { + adw::init().expect("libadwaita initialises under the test backend"); + let reviewed = review(&["ALTER TABLE a", "CREATE INDEX b ON a (c)"], Vec::new()); + + let separately = build("a", &reviewed, &engine(WriteAtomicity::StatementByStatement)); + let atomic = build("a", &reviewed, &engine(WriteAtomicity::Transactional)); + + assert_eq!( + separately.body(), + "MySQL applies each statement separately. If one fails, the statements before it stay applied." + ); + assert_eq!( + separately.response_appearance(APPLY), + adw::ResponseAppearance::Suggested + ); + assert_eq!(atomic.body(), ""); + } + + #[test] + fn a_rebuild_names_what_it_makes_again() { + let text = rebuild_text(&TableRebuild { + recreated: vec![ + RecreatedObject { + kind: SchemaObjectKind::Trigger, + name: "stamp".to_owned(), + }, + RecreatedObject { + kind: SchemaObjectKind::View, + name: "big".to_owned(), + }, + ], + renumbers_rowids: true, + }); + + assert_eq!( + text, + "Rebuilds the table: copies its rows into a new table and drops the old one. Drops and creates again: \ + trigger “stamp”, view “big”. The rows get new rowids." + ); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/table_choice_object.rs b/linux/crates/app/src/ui/structure_tab/table_choice_object.rs new file mode 100644 index 0000000000..628460036b --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/table_choice_object.rs @@ -0,0 +1,128 @@ +//! A table the Add Foreign Key dialog offers to reference. + +use std::cell::{Cell, OnceCell}; +use std::collections::BTreeSet; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, TableRef}; + +mod imp { + use std::marker::PhantomData; + + use gtk4::prelude::*; + + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::TableChoiceObject)] + pub struct TableChoiceObject { + pub(super) table: OnceCell, + pub(super) qualified: Cell, + /// What the combo row shows and searches. + #[property(get = Self::label)] + label: PhantomData, + } + + #[glib::object_subclass] + impl ObjectSubclass for TableChoiceObject { + const NAME: &'static str = "TableProTableChoiceObject"; + type Type = super::TableChoiceObject; + } + + #[glib::derived_properties] + impl ObjectImpl for TableChoiceObject {} + + impl TableChoiceObject { + fn label(&self) -> String { + match (self.table.get(), self.qualified.get()) { + (Some(table), true) => match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }, + (Some(table), false) => table.name.clone(), + (None, _) => crate::i18n::gettext("None"), + } + } + } +} + +glib::wrapper! { + pub struct TableChoiceObject(ObjectSubclass); +} + +impl TableChoiceObject { + /// The choice of no table, which a combo row needs: it always has an + /// item selected once it has any. + pub(super) fn none() -> Self { + glib::Object::new() + } + + fn new(table: TableRef, qualified: bool) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().table.set(table); + object.imp().qualified.set(qualified); + object + } + + pub(crate) fn table(&self) -> Option<&TableRef> { + self.imp().table.get() + } +} + +/// The tables a foreign key can reference, by label. A view cannot be +/// referenced. The schema is named only where tables come from more than +/// one. +pub(super) fn table_choices(objects: &[ObjectInfo]) -> Vec { + let tables: Vec<&ObjectInfo> = objects + .iter() + .filter(|object| matches!(object.kind, ObjectKind::Table | ObjectKind::PartitionedTable)) + .collect(); + let schemas: BTreeSet> = tables.iter().map(|table| table.schema.as_deref()).collect(); + let qualified = schemas.len() > 1; + let mut choices: Vec = tables + .into_iter() + .map(|table| TableChoiceObject::new(TableRef::new(table.schema.clone(), table.name.clone()), qualified)) + .collect(); + choices.sort_by_cached_key(TableChoiceObject::label); + choices +} + +#[cfg(test)] +mod tests { + use super::*; + + fn object(schema: Option<&str>, name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: schema.map(str::to_owned), + name: name.to_owned(), + kind, + } + } + + fn labels(choices: &[TableChoiceObject]) -> Vec { + choices.iter().map(TableChoiceObject::label).collect() + } + + #[test] + fn only_tables_are_offered_and_named_with_their_schema_where_it_tells_them_apart() { + let one_schema = [ + object(Some("public"), "users", ObjectKind::Table), + object(Some("public"), "active_users", ObjectKind::View), + object(Some("public"), "events", ObjectKind::PartitionedTable), + ]; + let two_schemas = [ + object(Some("public"), "users", ObjectKind::Table), + object(Some("billing"), "users", ObjectKind::Table), + ]; + + assert_eq!(labels(&table_choices(&one_schema)), ["events", "users"]); + assert_eq!(labels(&table_choices(&two_schemas)), ["billing.users", "public.users"]); + assert_eq!( + table_choices(&two_schemas)[0].table(), + Some(&TableRef::new(Some("billing".to_owned()), "users")) + ); + assert_eq!(TableChoiceObject::none().table(), None); + assert_eq!(TableChoiceObject::none().label(), "None"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index ca67b6e17e..591e5475cc 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -1,15 +1,17 @@ -use std::time::Duration; - +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; +use std::time::Duration; use gtk4::subclass::prelude::ObjectSubclassIsExt; +use libadwaita::prelude::{ActionRowExt, AdwWindowExt, ComboRowExt, ExpanderRowExt, PreferencesRowExt}; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::ddl::{ ColumnAttributes, ColumnDefinition, ColumnKeyRoles, DraftColumn, IdentityStyle, InboundForeignKey, - PrimaryKeyConstraint, TableDefinition, UnmodeledAttribute, + PrimaryKeyConstraint, TableDefinition, TableDraft, TableSource, UnmodeledAttribute, }; use tablepro_core::dialect::SqlDialect; -use tablepro_core::meta::TableRef; +use tablepro_core::meta::{ObjectInfo, ObjectKind, TableRef}; use tablepro_core::{ColumnInfo, DriverError, LossPhase, ServerInfo}; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; @@ -62,40 +64,20 @@ impl Harness { .map(|name| name.to_string()) } + fn draft(&self) -> TableDraft { + draft(self.tab()) + } + fn column_names(&self) -> Vec { - self.tab() - .imp() - .model - .borrow() - .as_ref() - .map(|model| { - model - .draft - .borrow() - .columns - .iter() - .map(|column| column.name.clone()) - .collect() - }) - .unwrap_or_default() + self.draft().columns.into_iter().map(|column| column.name).collect() } fn comments(&self) -> Vec> { - self.tab() - .imp() - .model - .borrow() - .as_ref() - .map(|model| { - model - .draft - .borrow() - .columns - .iter() - .map(|column| column.comment.clone()) - .collect() - }) - .unwrap_or_default() + self.draft().columns.into_iter().map(|column| column.comment).collect() + } + + fn column(&self, position: u32) -> DraftColumnObject { + column_object(self.tab(), position) } fn tracker(&self) -> StructureTrackerHandle { @@ -119,6 +101,25 @@ impl Drop for Harness { } } +fn draft(tab: &StructureTab) -> TableDraft { + tab.imp() + .model + .borrow() + .as_ref() + .map(StructureModel::draft) + .expect("the tab has a model") +} + +fn column_object(tab: &StructureTab, position: u32) -> DraftColumnObject { + tab.imp() + .model + .borrow() + .as_ref() + .and_then(|model| model.columns.item(position)) + .and_downcast() + .expect("a column at that position") +} + fn postgres() -> Arc { Arc::new(drivers_postgres::PgDialect::new(&ServerInfo::new( "PostgreSQL", @@ -355,11 +356,18 @@ fn a_failed_read_after_a_save_shows_the_error() { .expect("a definition from before the save stayed editable"); } -fn named_widget(tab: &StructureTab, name: &str) -> gtk::Widget { - descendants(tab) +/// The control named `name` in the row the Columns list shows at +/// `position`. +fn column_widget(tab: &StructureTab, position: i32, name: &str) -> gtk::Widget { + let row = tab + .imp() + .columns_list + .row_at_index(position) + .unwrap_or_else(|| panic!("no column row at {position}")); + descendants(&row) .into_iter() .find(|widget| widget.widget_name() == name) - .unwrap_or_else(|| panic!("no widget named {name}")) + .unwrap_or_else(|| panic!("no widget named {name} in row {position}")) } fn integer_column(name: &str, primary_key: bool) -> ColumnDefinition { @@ -406,8 +414,8 @@ fn clickhouse_key_column_rename_is_insensitive_with_reason() { wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); drain_main_context(); - let key_name = named_widget(harness.tab(), "col-name-0"); - let plain_name = named_widget(harness.tab(), "col-name-1"); + let key_name = column_widget(harness.tab(), 0, "col-name"); + let plain_name = column_widget(harness.tab(), 1, "col-name"); assert!(!key_name.is_sensitive(), "a sorting key column could be renamed"); assert!( @@ -418,7 +426,7 @@ fn clickhouse_key_column_rename_is_insensitive_with_reason() { key_name.tooltip_text() ); assert!(plain_name.is_sensitive()); - assert!(!named_widget(harness.tab(), "col-remove-0").is_sensitive()); + assert!(!column_widget(harness.tab(), 0, "col-remove").is_sensitive()); } #[gtk4::test] @@ -446,17 +454,17 @@ fn referenced_primary_key_switch_is_insensitive_with_foreign_key_names() { wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); drain_main_context(); - for name in ["col-pk-0", "col-pk-1"] { - let switch = named_widget(harness.tab(), name); - assert!(!switch.is_sensitive(), "{name} could move a referenced key"); + for position in [0, 1] { + let switch = column_widget(harness.tab(), position, "col-pk"); + assert!(!switch.is_sensitive(), "column {position} could move a referenced key"); assert!( switch.tooltip_text().is_some_and(|tip| tip.contains("orders_user_fk")), - "{name}: {:?}", + "column {position}: {:?}", switch.tooltip_text() ); } - assert!(!named_widget(harness.tab(), "col-remove-0").is_sensitive()); - assert!(named_widget(harness.tab(), "col-remove-1").is_sensitive()); + assert!(!column_widget(harness.tab(), 0, "col-remove").is_sensitive()); + assert!(column_widget(harness.tab(), 1, "col-remove").is_sensitive()); } #[gtk4::test] @@ -480,7 +488,7 @@ fn a_computed_column_takes_no_comment_where_the_comment_restates_it() { wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); drain_main_context(); - let computed = named_widget(harness.tab(), "col-comment-0"); + let computed = column_widget(harness.tab(), 0, "col-comment"); assert!(!computed.is_sensitive(), "a generated column's comment could be set"); assert!( @@ -490,7 +498,7 @@ fn a_computed_column_takes_no_comment_where_the_comment_restates_it() { "{:?}", computed.tooltip_text() ); - assert!(named_widget(harness.tab(), "col-comment-1").is_sensitive()); + assert!(column_widget(harness.tab(), 1, "col-comment").is_sensitive()); } #[gtk4::test] @@ -522,7 +530,7 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); tab.send(StructureTabInput::ColumnEdited { - index: 0, + column: column_object(&tab, 0), field: ColumnField::Default("42".to_owned()), }); let buffer = sql_buffer(&tab); @@ -538,7 +546,7 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { )); tab.send(StructureTabInput::ColumnEdited { - index: 0, + column: column_object(&tab, 0), field: ColumnField::Default("1 +".to_owned()), }); assert!( @@ -569,7 +577,7 @@ fn stop_saving_shows_only_while_a_save_runs() { .tab() .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); harness.tab().send(StructureTabInput::ColumnEdited { - index: 0, + column: harness.column(0), field: ColumnField::Comment(Some("key".to_owned())), }); let imp = harness.tab().imp(); @@ -585,7 +593,7 @@ fn stop_saving_shows_only_while_a_save_runs() { ); let toasts = toasts(harness.tab()); harness.tab().send(StructureTabInput::ColumnEdited { - index: 0, + column: harness.column(0), field: ColumnField::Comment(Some("the key".to_owned())), }); assert_eq!( @@ -622,13 +630,13 @@ fn an_edit_waits_for_the_read_after_a_committed_save() { let tracker = harness.tracker(); let imp = harness.tab().imp(); harness.tab().send(StructureTabInput::ColumnEdited { - index: 0, + column: harness.column(0), field: ColumnField::Comment(Some("key".to_owned())), }); let _saving = tracker.begin_save().expect("a save").expect("the change is pending"); harness.tab().send(StructureTabInput::ColumnEdited { - index: 0, + column: harness.column(0), field: ColumnField::Comment(Some("before the tab heard".to_owned())), }); harness.tab().send(StructureTabInput::SaveStarted); @@ -677,7 +685,7 @@ fn renaming_existing_column_enables_save_and_previews_rename() { let imp = harness.tab().imp(); assert!(!imp.save_button.is_sensitive()); - let name = named_widget(harness.tab(), "col-name-1") + let name = column_widget(harness.tab(), 1, "col-name") .downcast::() .expect("the Name entry"); assert!(name.is_sensitive(), "an existing column's name could not be edited"); @@ -693,21 +701,15 @@ fn renaming_existing_column_enables_save_and_previews_rename() { ); } -fn switch_row(tab: &StructureTab, name: &str) -> adw::SwitchRow { - named_widget(tab, name) +fn switch_row(tab: &StructureTab, position: i32, name: &str) -> adw::SwitchRow { + column_widget(tab, position, name) .downcast::() .expect("a switch row") } impl Harness { fn draft_column(&self, index: usize) -> DraftColumn { - self.tab() - .imp() - .model - .borrow() - .as_ref() - .map(|model| model.draft.borrow().columns[index].clone()) - .expect("the tab has a model") + self.draft().columns[index].clone() } } @@ -727,9 +729,9 @@ fn open_edit(dialect: Arc, table: TableDefinition) -> Harness { fn the_primary_key_holds_a_column_not_null_until_it_leaves() { let harness = open_edit(postgres(), keyed_users()); let tab = harness.tab(); - let (key, nullable) = (switch_row(tab, "col-pk-1"), switch_row(tab, "col-nullable-1")); + let (key, nullable) = (switch_row(tab, 1, "col-pk"), switch_row(tab, 1, "col-nullable")); assert!( - !switch_row(tab, "col-nullable-0").is_sensitive(), + !switch_row(tab, 0, "col-nullable").is_sensitive(), "a key column could take NULL" ); assert!(nullable.is_sensitive() && nullable.is_active()); @@ -764,10 +766,10 @@ fn a_held_nullable_column_joins_the_key_as_it_is() { table.columns[1].attributes.unmodeled = vec![UnmodeledAttribute::new("STORAGE EXTERNAL")]; let harness = open_edit(postgres(), table); let tab = harness.tab(); - let nullable = switch_row(tab, "col-nullable-1"); + let nullable = switch_row(tab, 1, "col-nullable"); assert!(!nullable.is_sensitive()); - switch_row(tab, "col-pk-1").set_active(true); + switch_row(tab, 1, "col-pk").set_active(true); drain_main_context(); assert!(nullable.is_active()); @@ -787,13 +789,13 @@ fn a_postgres_identity_keeps_numbering_outside_the_key() { table.columns[0].attributes.identity = IdentityStyle::GeneratedAlways; let harness = open_edit(postgres(), table); let tab = harness.tab(); - let auto = switch_row(tab, "col-auto-0"); + let auto = switch_row(tab, 0, "col-auto"); assert!( - switch_row(tab, "col-auto-1").is_sensitive(), + switch_row(tab, 1, "col-auto").is_sensitive(), "a column outside the key could not number itself" ); - switch_row(tab, "col-pk-0").set_active(false); + switch_row(tab, 0, "col-pk").set_active(false); drain_main_context(); assert!(auto.is_active() && auto.is_sensitive()); @@ -814,13 +816,382 @@ fn a_mysql_column_stops_numbering_as_it_leaves_the_key() { table.columns[0].info.is_auto_increment = true; let harness = open_edit(mysql, table); let tab = harness.tab(); - let auto = switch_row(tab, "col-auto-0"); + let auto = switch_row(tab, 0, "col-auto"); assert!(auto.is_active()); - assert!(!switch_row(tab, "col-auto-1").is_sensitive()); + assert!(!switch_row(tab, 1, "col-auto").is_sensitive()); - switch_row(tab, "col-pk-0").set_active(false); + switch_row(tab, 0, "col-pk").set_active(false); drain_main_context(); assert!(!auto.is_active() && !auto.is_sensitive()); assert!(!harness.draft_column(0).auto_increment); } + +/// Edit the table, let `change` say it changed meanwhile, then Discard, +/// and count the reads of the table. +fn reads_after_discard(change: Option) -> usize { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + harness.tab().send(StructureTabInput::AddColumn); + let read_again = change.is_some(); + if let Some(change) = change { + harness.tab().send(change); + } + + harness.tab().send(StructureTabInput::Discard); + + if read_again { + wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 2) + .expect("Discard restored the definition read before the change"); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure read again"); + } + drain_main_context(); + assert_eq!(harness.column_names(), ["id"]); + CallCounters::get(&counters.fetch_table_definition) +} + +#[gtk4::test] +fn a_plain_discard_restores_the_definition_it_read() { + assert_eq!(reads_after_discard(None), 1); +} + +#[gtk4::test] +fn a_discard_after_a_change_elsewhere_reads_the_table_again() { + assert_eq!(reads_after_discard(Some(StructureTabInput::SchemaChanged)), 2); +} + +#[gtk4::test] +fn a_discard_after_a_save_found_the_table_changed_reads_it_again() { + assert_eq!(reads_after_discard(Some(StructureTabInput::TableChangedBeforeSave)), 2); +} + +/// The titles of the add buttons the tab shows under `container`. +fn add_buttons(container: >k::Box) -> Vec { + descendants(container) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|row| row.is_visible() && row.parent().is_some_and(|list| list.is_visible())) + .map(|row| adw::prelude::PreferencesRowExt::title(&row).to_string()) + .collect() +} + +/// A SQLite `users (body)` table stored as `create_statement`. +fn sqlite_users(create_statement: &str) -> Vec { + let sqlite: Arc = Arc::new(drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new( + 3, 45, 0, + ))); + let mut table = definition("body"); + table.source = Some(TableSource { + create_statement: create_statement.to_owned(), + dependents: Vec::new(), + }); + let harness = open_edit(sqlite, table); + add_buttons(&harness.tab().imp().fks_box) +} + +#[gtk4::test] +fn an_unreadable_sqlite_table_offers_no_foreign_key() { + assert!(sqlite_users("CREATE VIRTUAL TABLE users USING fts5(body)").is_empty()); +} + +#[gtk4::test] +fn a_readable_sqlite_table_offers_a_foreign_key() { + assert_eq!(sqlite_users("CREATE TABLE users (body text)"), ["Add Foreign Key…"]); +} + +fn column_row(tab: &StructureTab, position: i32) -> adw::ExpanderRow { + tab.imp() + .columns_list + .row_at_index(position) + .and_downcast() + .unwrap_or_else(|| panic!("no column row at {position}")) +} + +fn column_rows(tab: &StructureTab) -> Vec { + let list = &tab.imp().columns_list; + (0..).map_while(|position| list.row_at_index(position)).collect() +} + +fn entry(tab: &StructureTab, position: i32, name: &str) -> adw::EntryRow { + column_widget(tab, position, name) + .downcast::() + .expect("an entry row") +} + +fn columns_store(tab: &StructureTab) -> gio::ListStore { + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.columns.clone()) + .expect("the tab has a model") +} + +/// Rows built after an edit used to hold back every signal until the +/// next idle, so a name typed before then was lost. +#[gtk4::test] +fn table_name_edit_is_not_dropped() { + let harness = Harness::open(StructureMode::New, postgres_script(), []); + let tab = harness.tab(); + tab.send(StructureTabInput::AddColumn); + + tab.imp().name_entry.set_text("people"); + + assert_eq!(harness.draft().table.name, "people"); + drain_main_context(); + assert_eq!(harness.draft().table.name, "people"); + assert!(preview(tab).contains("\"people\""), "{}", preview(tab)); +} + +#[gtk4::test] +fn expanded_row_stays_expanded_after_editing_another_column() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let first = column_row(tab, 0); + first.set_expanded(true); + + tab.send(StructureTabInput::AddColumn); + entry(tab, 2, "col-name").set_text("points"); + entry(tab, 1, "col-type").set_text("bigint"); + drain_main_context(); + + assert_eq!(column_row(tab, 0), first, "the expanded row was built again"); + assert!(first.is_expanded()); + assert_eq!(harness.column_names(), ["id", "score", "points"]); +} + +#[gtk4::test] +fn remove_by_identity_after_reorder_removes_right_column() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + tab.send(StructureTabInput::AddColumn); + let store = columns_store(tab); + let added = store.item(2).expect("the added column"); + store.remove(2); + store.insert(0, &added); + drain_main_context(); + assert_eq!(harness.column_names(), ["column_1", "id", "score"]); + + column_widget(tab, 2, "col-remove") + .downcast::() + .expect("the remove button") + .emit_clicked(); + + assert_eq!(harness.column_names(), ["column_1", "id"]); + assert_eq!(column_rows(tab).len(), 2); +} + +#[gtk4::test] +fn add_column_changes_one_item() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let rows = column_rows(tab); + let changes = Rc::new(RefCell::new(Vec::new())); + let sink = changes.clone(); + columns_store(tab).connect_items_changed(move |_, position, removed, added| { + sink.borrow_mut().push((position, removed, added)); + }); + + tab.send(StructureTabInput::AddColumn); + + assert_eq!(*changes.borrow(), [(2, 0, 1)]); + let after = column_rows(tab); + assert_eq!(after.len(), 3); + assert_eq!(after[..2], rows[..], "the rows already there were built again"); + assert_eq!(column_row(tab, 2).title(), "column_1"); +} + +/// Building the restored rows sets every control, which is no edit, and +/// the rows take the user's next edit at once. +#[gtk4::test] +fn discard_restores_without_phantom_ops() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let imp = tab.imp(); + let dirty = Rc::new(RefCell::new(Vec::new())); + let sink = dirty.clone(); + tab.connect_dirty_changed(move |_, value| sink.borrow_mut().push(value)); + entry(tab, 1, "col-name").set_text("points"); + entry(tab, 0, "col-comment").set_text("key"); + tab.send(StructureTabInput::AddColumn); + assert_eq!(imp.pending_label.label(), "3 pending changes"); + + imp.discard_button.emit_clicked(); + drain_main_context(); + + assert_eq!(harness.column_names(), ["id", "score"]); + assert_eq!(harness.comments(), [None, None]); + assert!(!imp.save_button.is_sensitive() && !imp.pending_label.is_visible()); + assert_eq!(preview(tab), "-- No pending changes."); + assert_eq!(*dirty.borrow(), [true, false]); + assert_eq!(harness.tracker().begin_save(), Ok(None)); + + entry(tab, 1, "col-name").set_text("points"); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert_eq!(imp.pending_label.label(), "1 pending change"); +} + +#[gtk4::test] +fn subtitle_updates_on_type_edit() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let row = column_row(tab, 1); + let before = row.subtitle(); + + entry(tab, 1, "col-type").set_text("bigint"); + + assert_eq!(row.subtitle(), "bigint · nullable", "was {before}"); + assert!(harness.column(1).summary().starts_with("bigint")); +} + +/// The header shows a name as typed, never as Pango markup. +#[gtk4::test] +fn a_column_name_with_markup_characters_shows_as_typed() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + + entry(tab, 1, "col-name").set_text("a & b"); + + let shown: Vec = descendants(&column_row(tab, 1)) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|label| label.is_visible() && label.text() == "a & b") + .map(|label| label.text().to_string()) + .collect(); + assert_eq!(shown.len(), 1, "the name was not shown as typed"); +} + +#[gtk4::test] +fn a_default_the_parser_refuses_is_marked_until_it_parses() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let default = entry(tab, 1, "col-default"); + + default.set_text("1 +"); + + assert!(default.has_css_class("error")); + assert!(default.tooltip_text().is_some()); + assert!(!tab.imp().save_button.is_sensitive()); + assert_eq!( + harness + .column(1) + .unparsed_default() + .map(|unparsed| unparsed.text) + .as_deref(), + Some("1 +") + ); + + default.set_text("42"); + + assert!(!default.has_css_class("error")); + assert!(default.tooltip_text().is_none()); + assert!(tab.imp().save_button.is_sensitive()); +} + +/// The tab in a window of its own, which the add rows open their forms +/// on. +fn in_window(harness: &Harness) -> adw::Window { + let window = super::form_dialog::tests::window(); + window.set_content(Some(harness.tab())); + window +} + +fn shown_form(window: &adw::Window) -> adw::Dialog { + let mut shown = None; + wait_until(WAIT, || { + shown = window.visible_dialog(); + shown.is_some() + }) + .expect("the form"); + shown.expect("the form") +} + +fn rows_titled + IsA>(form: &adw::Dialog, title: &str) -> Option { + descendants(form) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) +} + +/// The tab keeps nothing of a form it opens, so a form that does not +/// keep its own state stops answering here. +#[gtk4::test] +fn add_index_from_the_tab_adds_the_index() { + let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); + + harness.tab().imp().add_index_row.emit_by_name::<()>("activated", &[]); + let form = shown_form(&window); + let name: adw::EntryRow = rows_titled(&form, "Name").expect("the Name row"); + wait_until(WAIT, || name.is_mapped()).expect("the form on screen"); + name.set_text("by_score"); + assert!( + form.activate_action("index.add-key", Some(&"score".to_variant())) + .is_ok() + ); + super::form_dialog::tests::press_enter(&name); + + wait_until(WAIT, || harness.draft().indexes.len() == 1).expect("the index to be added"); + let index = harness.draft().indexes[0].clone(); + assert_eq!(index.name, "by_score"); + assert_eq!( + index + .keys + .iter() + .map(|key| key.column_name().unwrap_or_default().to_owned()) + .collect::>(), + ["score"] + ); + assert!(harness.tab().imp().save_button.is_sensitive()); + window.destroy(); +} + +#[gtk4::test] +fn add_foreign_key_from_the_tab_adds_the_key() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("public".to_owned()), + name: "accounts".to_owned(), + kind: ObjectKind::Table, + }])); + script + .fetch_columns + .always(FakeReply::ok(vec![integer_column("id", true).info])); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let window = in_window(&harness); + + harness.tab().imp().add_fk_row.emit_by_name::<()>("activated", &[]); + let form = shown_form(&window); + let name: adw::EntryRow = rows_titled(&form, "Name").expect("the Name row"); + wait_until(WAIT, || name.is_mapped()).expect("the form on screen"); + name.set_text("fk_account"); + let table: adw::ComboRow = rows_titled(&form, "Referenced Table").expect("the table row"); + wait_until(WAIT, || table.is_sensitive()).expect("the tables to be read"); + table.set_selected(1); + let id: adw::ActionRow = rows_titled(&form, "id").expect("the id check row"); + id.activatable_widget() + .and_downcast::() + .expect("the id check") + .set_active(true); + wait_until(WAIT, || { + rows_titled::(&form, "id references").is_some_and(|pair| pair.is_sensitive()) + }) + .expect("the columns of accounts to be read"); + super::form_dialog::tests::press_enter(&name); + + wait_until(WAIT, || harness.draft().foreign_keys.len() == 1).expect("the key to be added"); + let key = harness.draft().foreign_keys[0].clone(); + assert_eq!((key.name.as_str(), key.ref_table.as_str()), ("fk_account", "accounts")); + assert_eq!( + (key.columns, key.ref_columns), + (vec!["id".to_owned()], vec!["id".to_owned()]) + ); + window.destroy(); +} diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs deleted file mode 100644 index 1b28d6cd1f..0000000000 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! Add-Index and Add-Foreign-Key form dialogs for the Structure tab. -//! -//! A form is an `adw::Dialog` with Cancel and a suggested action in -//! its header bar: the HIG keeps AlertDialog for confirmations. Its -//! fields are rows in an AdwPreferencesGroup, as in GNOME Settings' -//! "Add" dialogs. - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4 as gtk; -use libadwaita as adw; -use libadwaita::prelude::*; - -use tablepro_core::ddl::{DraftColumn, ReferentialAction}; -use tablepro_core::meta::IndexKey; -use tablepro_core::{ForeignKeyInfo, IndexInfo}; - -use super::structure_tab::{StructureTabInput, StructureTabOutput, StructureTabSender}; - -/// `(column name, checkbox)` pairs, shared between the dialog body -/// and the submit handler so the latter can collect which columns the -/// user ticked. Pulled out as an alias so `build_column_checklist`'s -/// return type stays under clippy's complexity threshold. -type ColumnChecks = Rc>>; - -/// Build the standard form-dialog skeleton: AdwDialog with an -/// AdwToolbarView, a HeaderBar carrying Cancel + suggested-action -/// submit buttons, and a vertically-scrolling content box. Returned -/// `(dialog, content, submit_btn)` lets the caller append form -/// widgets to `content` and observe `submit_btn` for the Add action. -/// -/// `submit_btn` is the dialog's default widget. A row that should -/// submit on Enter sets `activates-default` on itself, matching GNOME -/// Settings' Add-account dialogs. -fn build_form_dialog(title: &str, submit_label: &str) -> (adw::Dialog, gtk::Box, gtk::Button) { - let dialog = adw::Dialog::builder() - .title(title) - .content_width(420) - .content_height(560) - .build(); - - let header = adw::HeaderBar::builder() - .show_start_title_buttons(false) - .show_end_title_buttons(false) - .build(); - let cancel_btn = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); - let submit_btn = gtk::Button::with_label(submit_label); - submit_btn.add_css_class("suggested-action"); - header.pack_start(&cancel_btn); - header.pack_end(&submit_btn); - - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(18) - .margin_top(18) - .margin_bottom(18) - .margin_start(18) - .margin_end(18) - .build(); - let scroller = gtk::ScrolledWindow::builder() - .child(&content) - .hscrollbar_policy(gtk::PolicyType::Never) - .vexpand(true) - .hexpand(true) - .build(); - - let toolbar_view = adw::ToolbarView::new(); - toolbar_view.add_top_bar(&header); - toolbar_view.set_content(Some(&scroller)); - dialog.set_child(Some(&toolbar_view)); - // Enter submits. Each form row also carries activates-default, - // which is what makes AdwEntryRow call gtk_widget_activate_default; - // the default widget alone does nothing without it. - dialog.set_default_widget(Some(&submit_btn)); - - let dialog_for_cancel = dialog.clone(); - cancel_btn.connect_clicked(move |_| { - dialog_for_cancel.close(); - }); - - (dialog, content, submit_btn) -} - -/// A heading for the column checklist, which is a boxed list rather -/// than a PreferencesGroup with a title of its own. -fn section_label(title: &str) -> gtk::Label { - let label = gtk::Label::builder().label(title).xalign(0.0).build(); - label.add_css_class("heading"); - label -} - -fn build_column_checklist(columns: &[DraftColumn]) -> (gtk::ListBox, ColumnChecks) { - let list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); - list.add_css_class("boxed-list"); - let checks: ColumnChecks = Rc::new(RefCell::new(Vec::new())); - for col in columns { - let row = adw::ActionRow::builder().title(&col.name).build(); - let check = gtk::CheckButton::new(); - check.set_valign(gtk::Align::Center); - row.add_suffix(&check); - row.set_activatable_widget(Some(&check)); - list.append(&row); - checks.borrow_mut().push((col.name.clone(), check)); - } - (list, checks) -} - -pub(super) fn present_index_dialog(parent: >k::Widget, columns: &[DraftColumn], sender: StructureTabSender) { - let (dialog, body, submit_btn) = - build_form_dialog(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("Add")); - - // Name + Unique inside one AdwPreferencesGroup. AdwEntryRow's - // title slot doubles as the placeholder when empty (floats up - // when filled), so no separate "Name" label is needed. - let detail_group = adw::PreferencesGroup::builder().build(); - let name_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Name")) - .activates_default(true) - .build(); - detail_group.add(&name_row); - let unique_row = adw::SwitchRow::builder() - .title(crate::i18n::gettext("Unique")) - .subtitle(crate::i18n::gettext( - "Reject inserts that duplicate the indexed columns", - )) - .build(); - detail_group.add(&unique_row); - body.append(&detail_group); - - body.append(§ion_label(&crate::i18n::gettext("Columns"))); - let (columns_list, column_checks) = build_column_checklist(columns); - body.append(&columns_list); - - let column_checks_for_resp = column_checks.clone(); - let sender_for_resp = sender.clone(); - let dialog_for_submit = dialog.clone(); - submit_btn.connect_clicked(move |_| { - let name = name_row.text().to_string(); - if name.trim().is_empty() { - // Toast instead of silent no-op so the user knows why - // their click didn't land. - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Index name is required.", - ))); - return; - } - let cols: Vec = column_checks_for_resp - .borrow() - .iter() - .filter_map(|(n, c)| if c.is_active() { Some(n.clone()) } else { None }) - .collect(); - if cols.is_empty() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Select at least one column.", - ))); - return; - } - sender_for_resp.input(StructureTabInput::AddIndex(IndexInfo { - name, - keys: cols - .into_iter() - .map(|name| IndexKey::Column { - name, - descending: false, - }) - .collect(), - unique: unique_row.is_active(), - primary: false, - predicate: None, - })); - dialog_for_submit.close(); - }); - - dialog.present(Some(parent)); -} - -/// `fk_actions` are the engine's, from its DDL dialect, so the combos -/// offer nothing the engine would refuse. -pub(super) fn present_fk_dialog( - parent: >k::Widget, - columns: &[DraftColumn], - fk_actions: &'static [ReferentialAction], - sender: StructureTabSender, -) { - let action_labels = action_labels(fk_actions); - let (dialog, body, submit_btn) = - build_form_dialog(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("Add")); - - let name_group = adw::PreferencesGroup::builder().build(); - let name_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Name")) - .activates_default(true) - .build(); - name_group.add(&name_row); - body.append(&name_group); - - body.append(§ion_label(&crate::i18n::gettext("Source columns"))); - let (columns_list, column_checks) = build_column_checklist(columns); - body.append(&columns_list); - - // The referenced columns are typed: the dialog does not read the - // other table. - let ref_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("References")) - .build(); - let ref_table_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Table")) - .activates_default(true) - .build(); - ref_group.add(&ref_table_row); - let ref_cols_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Columns")) - .activates_default(true) - .build(); - ref_group.add(&ref_cols_row); - let on_delete_row = adw::ComboRow::builder() - .title(crate::i18n::gettext("On delete")) - .model(>k::StringList::new(&action_labels)) - .build(); - ref_group.add(&on_delete_row); - let on_update_row = adw::ComboRow::builder() - .title(crate::i18n::gettext("On update")) - .model(>k::StringList::new(&action_labels)) - .build(); - ref_group.add(&on_update_row); - body.append(&ref_group); - - let column_checks_for_resp = column_checks.clone(); - let sender_for_resp = sender.clone(); - let dialog_for_submit = dialog.clone(); - submit_btn.connect_clicked(move |_| { - let name = name_row.text().to_string(); - let ref_table = ref_table_row.text().to_string(); - if name.trim().is_empty() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Foreign key name is required.", - ))); - return; - } - if ref_table.trim().is_empty() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Reference table is required.", - ))); - return; - } - let cols: Vec = column_checks_for_resp - .borrow() - .iter() - .filter_map(|(n, c)| if c.is_active() { Some(n.clone()) } else { None }) - .collect(); - if cols.is_empty() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Select at least one source column.", - ))); - return; - } - let ref_cols: Vec = ref_cols_row - .text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if ref_cols.is_empty() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Reference columns are required.", - ))); - return; - } - // The server would reject it too, but only at Save and in its - // own words. - if ref_cols.len() != cols.len() { - sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Source and reference column counts must match.", - ))); - return; - } - let (ref_schema, ref_table_only) = match ref_table.split_once('.') { - Some((s, t)) => (Some(s.trim().to_string()), t.trim().to_string()), - None => (None, ref_table), - }; - // An explicit NO ACTION stays `Some`: `None` is reserved for an - // action the driver reported that this build does not know. - let action_at = |row: u32| action_for_row(fk_actions, row); - sender_for_resp.input(StructureTabInput::AddForeignKey(ForeignKeyInfo { - name, - columns: cols, - ref_schema, - ref_table: ref_table_only, - ref_columns: ref_cols, - on_delete: action_at(on_delete_row.selected()), - on_update: action_at(on_update_row.selected()), - })); - dialog_for_submit.close(); - }); - - dialog.present(Some(parent)); -} - -/// The combo labels for the actions an engine accepts, in its order. -fn action_labels(actions: &[ReferentialAction]) -> Vec<&'static str> { - actions.iter().map(|action| action.as_sql()).collect() -} - -/// The action a combo row stands for. The rows are built from the -/// same list, so a row past its end can only be a stale selection. -fn action_for_row(actions: &[ReferentialAction], row: u32) -> Option { - actions.get(row as usize).copied() -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::Duration; - - use super::*; - - /// GtkButton's activate runs a short press animation and emits - /// `clicked` when it ends, so the click lands after a spin of the - /// main loop rather than inside `emit_activate`. - const ACTIVATE_WAIT: Duration = Duration::from_secs(5); - - /// The GtkText inside an AdwEntryRow. Enter reaches the row through - /// this delegate, so that is where the test presses it. - fn press_enter(row: &adw::EntryRow) { - let text = gtk::prelude::EditableExt::delegate(row) - .and_then(|delegate| delegate.downcast::().ok()) - .expect("an AdwEntryRow delegates to a GtkText"); - text.emit_activate(); - } - - /// An Add Index-shaped dialog on a mapped window, with a counter on - /// its submit button. The window has to be mapped: GtkButton - /// refuses to activate an unrealized button, so an unmapped - /// fixture would pass both tests for the wrong reason. - fn form_on_a_window(activates_default: bool) -> (adw::Window, adw::EntryRow, Rc) { - let window = adw::Window::new(); - let (dialog, body, submit) = - build_form_dialog(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("Add")); - - let group = adw::PreferencesGroup::builder().build(); - let name_row = adw::EntryRow::builder() - .title(crate::i18n::gettext("Name")) - .activates_default(activates_default) - .build(); - group.add(&name_row); - body.append(&group); - - let clicks = Rc::new(AtomicUsize::new(0)); - let clicks_for_submit = clicks.clone(); - submit.connect_clicked(move |_| { - clicks_for_submit.fetch_add(1, Ordering::Relaxed); - }); - - window.present(); - dialog.present(Some(&window)); - crate::test_support::wait_until(ACTIVATE_WAIT, || name_row.is_mapped()).expect("the form never appeared"); - (window, name_row, clicks) - } - - #[gtk4::test] - fn enter_in_a_form_row_activates_the_dialog_default() { - let (_window, name_row, clicks) = form_on_a_window(true); - name_row.set_text("by_created_at"); - - press_enter(&name_row); - - crate::test_support::wait_until(ACTIVATE_WAIT, || clicks.load(Ordering::Relaxed) == 1) - .expect("Enter did not reach the Add button"); - } - - #[gtk4::test] - fn a_row_without_activates_default_does_not_submit() { - // The dialog's default widget alone is not enough, which is why - // every form row sets activates-default. - let (_window, name_row, clicks) = form_on_a_window(false); - name_row.set_text("by_created_at"); - - press_enter(&name_row); - - assert!( - crate::test_support::wait_until(Duration::from_secs(1), || clicks.load(Ordering::Relaxed) > 0).is_err(), - "a row without activates-default submitted the form" - ); - } - - #[test] - fn combo_rows_decode_to_the_actions_they_are_labelled_with() { - use tablepro_core::ddl::DdlDialect; - let mssql = drivers_mssql::MssqlDialect::new(16).referential_actions(); - - let labels = action_labels(mssql); - - assert!(!labels.contains(&"RESTRICT"), "SQL Server has no RESTRICT: {labels:?}"); - for (row, label) in labels.iter().enumerate() { - let action = action_for_row(mssql, row as u32).expect("a row the combo shows"); - assert_eq!(action.as_sql(), *label); - } - assert_eq!(action_for_row(mssql, labels.len() as u32), None); - } - - #[gtk4::test] - fn a_form_dialog_has_a_default_widget() { - let (dialog, _body, _submit) = - build_form_dialog(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("Add")); - - assert!( - dialog.default_widget().is_some(), - "the form dialog has no default widget for Enter to reach" - ); - } -} diff --git a/linux/crates/app/src/ui/welcome_view.rs b/linux/crates/app/src/ui/welcome_view.rs index a514641f68..222dfbc090 100644 --- a/linux/crates/app/src/ui/welcome_view.rs +++ b/linux/crates/app/src/ui/welcome_view.rs @@ -3,11 +3,13 @@ use relm4::factory::FactoryVecDeque; use relm4::prelude::*; use relm4::{adw, gtk}; +use tablepro_session::runtime::Tasks; use tablepro_storage::{ConnectionColor, SavedConnection}; use uuid::Uuid; use super::connection_list; use super::connection_row::{ConnectionRow, ConnectionRowInit, ConnectionRowOutput}; +use crate::runtime::DeliverInput; pub struct WelcomeView { connections: Vec, @@ -16,6 +18,7 @@ pub struct WelcomeView { /// reinstalled whenever the list changes. listbox: gtk::ListBox, stack: gtk::Stack, + tasks: Option, } #[derive(Debug)] @@ -40,7 +43,10 @@ pub enum WelcomeViewOutput { } #[derive(Debug, Default)] -pub struct WelcomeViewInit; +pub struct WelcomeViewInit { + /// Reads what the rows show that is on disk. + pub tasks: Option, +} impl SimpleComponent for WelcomeView { type Init = WelcomeViewInit; @@ -53,7 +59,7 @@ impl SimpleComponent for WelcomeView { gtk::Stack::builder().build() } - fn init(_init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { + fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { let listbox = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) @@ -87,7 +93,7 @@ impl SimpleComponent for WelcomeView { empty_btn.add_css_class("suggested-action"); empty_btn.add_css_class("pill"); let s_empty = sender.clone(); - empty_btn.connect_clicked(move |_| s_empty.input(WelcomeViewInput::OpenConnect)); + empty_btn.connect_clicked(move |_| s_empty.deliver(WelcomeViewInput::OpenConnect)); empty_page.set_child(Some(&empty_btn)); root.add_named(&empty_page, Some("empty")); @@ -126,7 +132,7 @@ impl SimpleComponent for WelcomeView { .build(); header_btn.add_css_class("flat"); let s_header = sender.clone(); - header_btn.connect_clicked(move |_| s_header.input(WelcomeViewInput::OpenConnect)); + header_btn.connect_clicked(move |_| s_header.deliver(WelcomeViewInput::OpenConnect)); group.set_header_suffix(Some(&header_btn)); group.add(factory.widget()); outer.append(&group); @@ -141,6 +147,7 @@ impl SimpleComponent for WelcomeView { factory, listbox, stack: root.clone(), + tasks: init.tasks, }; ComponentParts { model, widgets: () } } @@ -157,6 +164,7 @@ impl SimpleComponent for WelcomeView { guard.push_back(ConnectionRowInit { saved: saved.clone(), groups: groups.clone(), + tasks: self.tasks.clone(), }); } drop(guard); @@ -228,7 +236,7 @@ mod tests { #[gtk4::test] fn a_grouped_list_carries_one_header_per_group() { - let view = WelcomeView::builder().launch(WelcomeViewInit); + let view = WelcomeView::builder().launch(WelcomeViewInit::default()); view.sender() .send(WelcomeViewInput::SetConnections(vec![ connection("loose", None), @@ -248,7 +256,7 @@ mod tests { #[gtk4::test] fn an_ungrouped_list_carries_no_headers_at_all() { - let view = WelcomeView::builder().launch(WelcomeViewInit); + let view = WelcomeView::builder().launch(WelcomeViewInit::default()); view.sender() .send(WelcomeViewInput::SetConnections(vec![ connection("a", None), @@ -263,7 +271,7 @@ mod tests { #[gtk4::test] fn taking_the_last_connection_out_of_a_group_takes_its_header_with_it() { - let view = WelcomeView::builder().launch(WelcomeViewInit); + let view = WelcomeView::builder().launch(WelcomeViewInit::default()); view.sender() .send(WelcomeViewInput::SetConnections(vec![connection("a", Some("Work"))])) .expect("send"); diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs new file mode 100644 index 0000000000..558d57fb2f --- /dev/null +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -0,0 +1,204 @@ +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::EditorSession; +use uuid::Uuid; + +use super::workspace_persistence::WorkspaceTabs; +use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::workspace::CloseDecision; + +/// Every editor tab's connection, taken from its tab. Each tab's run is +/// stopped first, so nothing is still writing to the session it gives +/// up. +fn take_editor_sessions(tabs: &WorkspaceTabs) -> Vec> { + tabs.borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => slot.editor.take_editor_session(), + WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) => None, + }) + .collect() +} + +impl WorkspaceModel { + /// Write what the workspace shows as it is now: the last column drag + /// and filter change, the editor text and the tab list. The process + /// is held until every write lands, so quitting right after a + /// disconnect does not lose them. + pub(super) fn flush(&self) { + let hold = self.context.hold.acquire(); + let column_widths = self.storage.column_widths().flush(); + let filter_settings = self.storage.filter_settings().flush(); + // The key is free once the window lets go of it, which is before + // these land; a delete of the connection waits for them. + let drafts_landing = self.storage.workspaces().writes_in_flight(&self.key); + let editor_drafts = self.drafts.flush_all(); + let writer = self.drafts.clone(); + let tabs = self.workspace.detach(); + glib::spawn_future_local(async move { + column_widths.await; + filter_settings.await; + for failure in editor_drafts.await { + tracing::warn!(failure, "an editor draft was not saved"); + } + drop(drafts_landing); + writer.release_closed(); + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { + tracing::debug!(%error, "the open tabs were not saved"); + } + drop(hold); + }); + } + + pub(super) fn take_editor_sessions(&self) -> Vec> { + take_editor_sessions(&self.workspace_tabs) + } + + /// A tab asked to close. One with nothing to lose closes now; any + /// other waits for the prompts. + pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + if self.close_guards.may_close_tab(&self.workspace_handle, id) { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let Some(parent) = self.dialog_parent() else { + return; + }; + let input = self.input.clone(); + self.close_guards + .ask_close_tab(&parent, &self.workspace_handle, id, move |decision| { + input.deliver(WorkspaceInput::TabCloseDecided { + id, + approved: decision == CloseDecision::Approved, + }); + }); + } + + pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + if approved { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let page = self.workspace_tabs.borrow().get(&id).map(|tab| tab.page().clone()); + if let Some(page) = page { + tab_view.close_page_finish(&page, false); + } + } + + /// Tell the window whether a logout would lose something here, so it + /// holds the session back while one would. + pub(super) fn refresh_inhibitor(&self) { + let dirty = !self.workspace_handle.dirty_tabs().is_empty(); + if self.reported_dirty.replace(Some(dirty)) == Some(dirty) { + return; + } + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("dirty-changed", &[&dirty]); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + use std::time::Duration; + + use libadwaita as adw; + use tablepro_core::TransactionState; + use tablepro_session::runtime::CountingHold; + use tablepro_storage::DraftId; + use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::services::active_session::ActiveSession; + use crate::test_support::{FakeSession, MemorySettings, wait_until}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::window_tabs::WindowTabs; + use crate::ui::workspace::{EditorTabSlot, write_workspace_tab_id}; + use crate::workspace::{TabDirectory, TabEntry}; + + const WAIT: Duration = Duration::from_secs(10); + + #[gtk4::test] + fn an_open_transaction_is_named_and_its_session_handed_over_once() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session = Arc::new(FakeEditorSession::new(runs)); + let mut script = ConnectionScript::default(); + let opened: Arc = editor_session.clone(); + script.open_editor_session.always(FakeReply::ok(opened)); + script.probe.always(FakeReply::ok(())); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let hold = Rc::new(CountingHold::new()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: hold.clone(), + close_timeout: Duration::from_secs(5), + }); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + let view = adw::TabView::new(); + let page = view.append(&editor); + page.set_title("• Query 1"); + let id = Uuid::new_v4(); + write_workspace_tab_id(&page, id); + let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor: editor.clone(), + page, + draft: DraftId::new(), + }), + )]))); + let directory = WindowTabs::new(tabs.clone()); + directory.set_view(Some(&view)); + + assert_eq!( + directory.tabs(), + [TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + }] + ); + + let taken = take_editor_sessions(&tabs); + editor.shutdown(); + crate::test_support::drain_main_context(); + + assert_eq!(taken.len(), 1, "the tab's session was not handed over"); + assert!(take_editor_sessions(&tabs).is_empty(), "handed over twice"); + assert_eq!( + editor_session.close_count(), + 0, + "the tab closed the session it handed over" + ); + assert_eq!(hold.live(), 0); + drop((taken, tabs)); + crate::test_support::drain_main_context(); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs new file mode 100644 index 0000000000..61c5eb2325 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -0,0 +1,562 @@ +//! The banner over the tabs while the session needs attention. + +use gtk4::prelude::*; +use tablepro_core::{DriverError, SshFailure, TransportError}; +use tablepro_session::{FailureCause, MonitorSignal, SessionHealth}; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::{DeliverInput, spawn_and_deliver}; +use crate::ui::host_key_failure::{HostKeyFailure, HostKeyFailureKind}; +use crate::ui::host_key_review::{HostKeyReviewOutcome, review_host_key}; + +pub(super) const REVIEW_HOST_KEY: &str = "review-host-key"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BannerButton { + Retry, + ReviewKey, +} + +impl WorkspaceModel { + /// GNOME apps keep banners for states the user can act on, so the + /// banner shows only while the session is not online. A host key ssh + /// refused offers a review instead of a retry, which would only be + /// refused again. + pub(super) fn refresh_health_banner(&self, health: &SessionHealth) { + let review = self.host_key_to_review(health); + if let Some(window) = self.window() { + super::workspace_actions::set_enabled(&window, REVIEW_HOST_KEY, review.is_some()); + } + let Some(title) = health_banner_text(health, review.as_ref()) else { + self.reconnect_banner.set_revealed(false); + return; + }; + let (label, action) = match banner_button(review.as_ref()) { + BannerButton::Retry => (crate::i18n::gettext("Retry"), "win.reconnect".to_owned()), + BannerButton::ReviewKey => (crate::i18n::gettext("Review Key…"), format!("win.{REVIEW_HOST_KEY}")), + }; + self.reconnect_banner.set_title(&title); + self.reconnect_banner.set_button_label(Some(&label)); + self.reconnect_banner.set_action_name(Some(&action)); + self.reconnect_banner.set_revealed(true); + } + + /// The host key the session is parked on, read from the error that + /// parked it. + fn host_key_to_review(&self, health: &SessionHealth) -> Option { + let SessionHealth::Failed { cause } = health else { + return None; + }; + if !is_host_key(*cause) { + return None; + } + let rejection = self.context.active.get()?.last_rejection()?; + HostKeyFailure::from_connect_error(&rejection) + } + + pub(super) fn on_review_host_key(&mut self) { + if self.reviewing_host_key { + return; + } + let Some(session) = self.context.active.get() else { + return; + }; + let health = session.health().borrow().clone(); + let Some(failure) = self.host_key_to_review(&health) else { + return; + }; + let Some(parent) = self.dialog_parent() else { + return; + }; + self.reviewing_host_key = true; + let input = self.input.clone(); + gtk4::glib::spawn_future_local(async move { + let outcome = review_host_key(&parent, &failure).await; + input.deliver(WorkspaceInput::HostKeyReviewed { failure, outcome }); + }); + } + + pub(super) fn on_host_key_reviewed(&mut self, failure: HostKeyFailure, outcome: HostKeyReviewOutcome) { + self.reviewing_host_key = false; + match outcome { + HostKeyReviewOutcome::Dismissed => {} + HostKeyReviewOutcome::Retry => self.retry(), + HostKeyReviewOutcome::Trust => self.retry_trusting(&failure), + HostKeyReviewOutcome::Replace => self.reviewing_host_key = self.forget_old_host_key(failure), + } + } + + pub(super) fn on_old_host_key_forgotten(&mut self, failure: HostKeyFailure, forgotten: Result<(), String>) { + self.reviewing_host_key = false; + match forgotten { + Ok(()) => self.retry_trusting(&failure), + Err(detail) => { + tracing::warn!(host = %failure.host, %detail, "the old SSH host key was not removed"); + let file = match &failure.kind { + HostKeyFailureKind::Changed { known_hosts, .. } => known_hosts.display().to_string(), + _ => String::new(), + }; + self.show_toast(&crate::i18n::gettext_f( + "The old key for {host} could not be removed from {file}: {detail}", + &[("host", &failure.host), ("file", &file), ("detail", &detail)], + )); + } + } + } + + /// ssh is asked again with the user's answer given: the reviewed key + /// is accepted, any other is shown to them as on a first connect. + /// Without a fingerprint there is nothing to answer for them, so ssh + /// asks. + fn retry_trusting(&self, failure: &HostKeyFailure) { + let Some(session) = self.context.active.get() else { + return; + }; + match failure.fingerprint.is_empty() { + true => session.signal(MonitorSignal::RetryNow), + false => session.retry_trusting_host_key(failure.fingerprint.clone()), + } + } + + fn retry(&self) { + if let Some(session) = self.context.active.get() { + session.signal(MonitorSignal::RetryNow); + } + } + + /// ssh never accepts a changed key, so the old one leaves known_hosts + /// first, with the ssh-keygen command ssh itself suggests. Returns + /// whether ssh-keygen was started. + fn forget_old_host_key(&self, failure: HostKeyFailure) -> bool { + let HostKeyFailureKind::Changed { known_hosts, .. } = &failure.kind else { + return false; + }; + let Some(ssh) = self.ssh.clone() else { + tracing::warn!("no SSH services to remove the old host key with"); + return false; + }; + let (host, known_hosts) = (failure.host.clone(), known_hosts.clone()); + let input = self.input.clone(); + drop(spawn_and_deliver( + &self.tasks, + async move { tablepro_ssh::forget_known_host(&ssh, &host, &known_hosts).await }, + move |forgotten| { + let forgotten = match forgotten { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(forget_failure_detail(&error)), + Err(error) => Err(error.to_string()), + }; + input.deliver(WorkspaceInput::OldHostKeyForgotten { failure, forgotten }); + }, + )); + true + } +} + +fn forget_failure_detail(failure: &SshFailure) -> String { + match failure { + SshFailure::ConfigRejected { detail } => detail.clone(), + other => crate::ui::error_text::driver_message(&DriverError::Transport(TransportError::Ssh(other.clone()))), + } +} + +fn is_host_key(cause: FailureCause) -> bool { + matches!( + cause, + FailureCause::HostKeyUnknown | FailureCause::HostKeyChanged | FailureCause::HostKeyRevoked + ) +} + +fn banner_button(review: Option<&HostKeyFailure>) -> BannerButton { + match review { + Some(_) => BannerButton::ReviewKey, + None => BannerButton::Retry, + } +} + +/// The banner's sentence for a session that needs attention, `None` +/// while it is online. +fn health_banner_text(health: &SessionHealth, review: Option<&HostKeyFailure>) -> Option { + match health { + SessionHealth::Online => None, + SessionHealth::Reconnecting { attempt } => Some(crate::i18n::gettext_f( + "Connection lost. Reconnecting (attempt {n}).", + &[("n", &attempt.to_string())], + )), + SessionHealth::Offline => Some(crate::i18n::gettext("Offline. Waiting for the network.")), + SessionHealth::Failed { cause } => Some(match review { + Some(failure) => host_key_banner_text(failure), + None => failed_banner_text(*cause), + }), + } +} + +fn host_key_banner_text(failure: &HostKeyFailure) -> String { + let host = [("host", failure.host.as_str())]; + match failure.kind { + HostKeyFailureKind::Unknown { .. } => crate::i18n::gettext_f("The SSH host {host} is not trusted yet.", &host), + HostKeyFailureKind::Changed { .. } => { + crate::i18n::gettext_f("The SSH host key for {host} does not match.", &host) + } + HostKeyFailureKind::Revoked { .. } => crate::i18n::gettext_f("The SSH host key for {host} is revoked.", &host), + } +} + +fn failed_banner_text(cause: FailureCause) -> String { + match cause { + FailureCause::Authentication => { + crate::i18n::gettext("The server refused the login. Retry to enter the password again.") + } + FailureCause::Tls => crate::i18n::gettext("The secure connection failed. Check the connection's TLS settings."), + FailureCause::Configuration => { + crate::i18n::gettext("The connection settings no longer work. Check them and retry.") + } + FailureCause::HostKeyUnknown => crate::i18n::gettext("The SSH server's host key is not trusted yet."), + FailureCause::HostKeyChanged => crate::i18n::gettext( + "The SSH server's host key changed. Check with its administrator before connecting again.", + ), + FailureCause::HostKeyRevoked => crate::i18n::gettext("The SSH server's host key was revoked."), + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + + use gtk4 as gtk; + use gtk4::subclass::prelude::ObjectSubclassIsExt; + use libadwaita as adw; + use libadwaita::prelude::*; + use tablepro_session::ConnectError; + use tablepro_session::fakes::FakeEstablish; + use tablepro_ssh::{SshRuntimeCell, SshServices}; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; + + const FINGERPRINT: &str = "SHA256:reviewed"; + const KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"; + + #[test] + fn the_banner_shows_only_when_the_session_needs_attention() { + assert_eq!(health_banner_text(&SessionHealth::Online, None), None); + let reconnecting = health_banner_text(&SessionHealth::Reconnecting { attempt: 3 }, None).expect("a banner"); + assert!(reconnecting.contains('3'), "{reconnecting}"); + assert_eq!( + health_banner_text(&SessionHealth::Offline, None).as_deref(), + Some("Offline. Waiting for the network.") + ); + } + + #[test] + fn each_parked_cause_has_its_own_banner() { + let causes = [ + FailureCause::Authentication, + FailureCause::Tls, + FailureCause::Configuration, + FailureCause::HostKeyUnknown, + FailureCause::HostKeyChanged, + FailureCause::HostKeyRevoked, + ]; + + let texts: std::collections::HashSet = causes + .into_iter() + .map(|cause| health_banner_text(&SessionHealth::Failed { cause }, None).expect("a banner")) + .collect(); + + assert_eq!(texts.len(), causes.len()); + } + + #[test] + fn a_host_key_banner_names_the_host() { + let kinds = [ + HostKeyFailureKind::Unknown { trust_allowed: true }, + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 1, + }, + HostKeyFailureKind::Revoked { + detail: "revoked".to_owned(), + }, + ]; + for kind in kinds { + let failure = HostKeyFailure { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: FINGERPRINT.to_owned(), + kind, + }; + let parked = SessionHealth::Failed { + cause: FailureCause::HostKeyUnknown, + }; + + let text = health_banner_text(&parked, Some(&failure)).expect("a banner"); + + assert!(text.contains("bastion"), "{text}"); + assert_eq!(banner_button(Some(&failure)), BannerButton::ReviewKey); + } + assert_eq!(banner_button(None), BannerButton::Retry); + } + + fn healthy() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + script + } + + /// ssh asking about `fingerprint`: an unattended reconnect declines + /// it, an attended one connects on a yes. + fn asks(host: &str, fingerprint: &str) -> FakeEstablish { + FakeEstablish::AskHostKey { + host: host.to_owned(), + fingerprint: fingerprint.to_owned(), + script: healthy(), + } + } + + /// A window whose connection dropped and whose first reconnect + /// parked it; `reconnects` answer that one and the ones after. + fn parked(name: &str, reconnects: Vec, ssh: Option) -> TestWindow { + let mut establish = vec![FakeEstablish::Connect(healthy())]; + establish.extend(reconnects); + let test = TestWindow::with_script( + name, + Script { + establish, + ssh, + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test.connection().mark_lost(); + wait_until(WAIT, || matches!(health(&test), SessionHealth::Failed { .. })).expect("the session to park"); + test + } + + fn health(test: &TestWindow) -> SessionHealth { + test.services + .sessions + .session(&test.key()) + .map_or(SessionHealth::Online, |session| session.health().borrow().clone()) + } + + fn banner(test: &TestWindow) -> adw::Banner { + test.workspace().imp().reconnect_banner.get() + } + + fn wait_for_button(test: &TestWindow, label: &str) -> adw::Banner { + let banner = banner(test); + wait_until(WAIT, || { + banner.is_revealed() && banner.button_label().as_deref() == Some(label) + }) + .unwrap_or_else(|_| panic!("the banner never offered {label}")); + banner + } + + fn review_enabled(test: &TestWindow) -> bool { + test.window + .lookup_action(REVIEW_HOST_KEY) + .is_some_and(|action| action.is_enabled()) + } + + fn alert(test: &TestWindow) -> Option { + test.window.visible_dialog().and_downcast::() + } + + /// Open the review from the banner and click `response`, the way a + /// user does. + fn review_and_answer(test: &TestWindow, response: &str) { + gtk::prelude::WidgetExt::activate_action(&test.window, &format!("win.{REVIEW_HOST_KEY}"), None) + .expect("the review action"); + wait_until(WAIT, || alert(test).is_some()).expect("the review dialog"); + let dialog = alert(test).expect("the review dialog"); + let label = dialog.response_label(response); + descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + .unwrap_or_else(|| panic!("no {response} button")) + .emit_clicked(); + wait_until(WAIT, || alert(test).is_none()).expect("the review dialog to close"); + } + + #[gtk4::test] + fn banner_offers_review_for_host_key_failure() { + let test = parked("ReviewOffered", vec![asks("bastion", FINGERPRINT)], None); + + let banner = wait_for_button(&test, "Review Key…"); + + assert!(banner.title().contains("bastion"), "{}", banner.title()); + assert_eq!( + banner.action_name().as_deref(), + Some(format!("win.{REVIEW_HOST_KEY}").as_str()) + ); + assert!(review_enabled(&test)); + } + + #[gtk4::test] + fn review_trust_retries_trusting_that_key() { + let test = parked( + "ReviewTrust", + vec![asks("bastion", FINGERPRINT), asks("bastion", FINGERPRINT)], + None, + ); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "trust"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + wait_until(WAIT, || !banner(&test).is_revealed()).expect("the banner to go"); + let calls = test.connector.calls(); + assert_eq!(calls.len(), 3); + assert!(calls[2].attended, "the retry could not ask anything"); + drain_main_context(); + assert!( + alert(&test).is_none(), + "ssh's question about the trusted key reached the user" + ); + assert!(!review_enabled(&test)); + } + + #[gtk4::test] + fn dismiss_keeps_session_parked() { + let test = parked("ReviewDismiss", vec![asks("bastion", FINGERPRINT)], None); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "cancel"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!( + health(&test), + SessionHealth::Failed { + cause: FailureCause::HostKeyUnknown + } + ); + assert_eq!(test.connector.calls().len(), 2, "a dismissed review retried"); + assert!(banner(&test).is_revealed()); + assert!(review_enabled(&test)); + } + + #[gtk4::test] + fn review_retry_reconnects_a_key_fixed_outside_tablepro() { + let strict = FakeEstablish::Fail(ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyUnknown { + host: "bastion".to_owned(), + algorithm: String::new(), + sha256_fingerprint: String::new(), + trust_allowed: false, + declined: false, + }, + )))); + let test = parked("ReviewRetry", vec![strict, FakeEstablish::Connect(healthy())], None); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "retry"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + assert_eq!(test.connector.calls().len(), 3); + } + + #[gtk4::test] + fn review_action_disabled_for_other_failures() { + let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::auth(None))); + let test = parked("ReviewAuth", vec![refused], None); + + let banner = wait_for_button(&test, "Retry"); + + assert_eq!(banner.action_name().as_deref(), Some("win.reconnect")); + assert!(!review_enabled(&test)); + } + + fn ssh_services(dir: &Path) -> SshServices { + SshServices { + runtime: Arc::new(SshRuntimeCell::new(dir.join("runtime"))), + ssh_program: "ssh".into(), + ssh_keygen_program: "ssh-keygen".into(), + askpass_program: dir.join("tablepro-askpass"), + config_file: None, + } + } + + fn changed_key(known_hosts: &Path) -> FakeEstablish { + FakeEstablish::Fail(ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyChanged { + host: "[bastion]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: FINGERPRINT.to_owned(), + known_hosts: known_hosts.to_owned(), + line: 1, + }, + )))) + } + + #[gtk4::test] + fn replace_removes_the_old_key_and_retries_trusting_the_new_one() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write(&known_hosts, format!("[bastion]:2222 {KEY}\nother {KEY}\n")).unwrap(); + let test = parked( + "ReviewReplace", + vec![changed_key(&known_hosts), asks("[bastion]:2222", FINGERPRINT)], + Some(ssh_services(dir.path())), + ); + let banner = wait_for_button(&test, "Review Key…"); + assert!(banner.title().contains("does not match"), "{}", banner.title()); + + review_and_answer(&test, "replace"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + let left = std::fs::read_to_string(&known_hosts).unwrap(); + assert!(!left.contains("[bastion]:2222"), "{left}"); + assert!(left.contains("other "), "{left}"); + assert!( + alert(&test).is_none(), + "ssh's question about the reviewed key reached the user" + ); + } + + #[gtk4::test] + fn no_second_review_while_the_old_key_is_removed() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write(&known_hosts, format!("[bastion]:2222 {KEY}\n")).unwrap(); + let slow_keygen = dir.path().join("slow-ssh-keygen"); + std::fs::write(&slow_keygen, "#!/bin/sh\nsleep 1\nexec ssh-keygen \"$@\"\n").unwrap(); + std::fs::set_permissions(&slow_keygen, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ssh = SshServices { + ssh_keygen_program: slow_keygen, + ..ssh_services(dir.path()) + }; + let test = parked( + "ReviewReplaceBusy", + vec![changed_key(&known_hosts), asks("[bastion]:2222", FINGERPRINT)], + Some(ssh), + ); + wait_for_button(&test, "Review Key…"); + review_and_answer(&test, "replace"); + drain_main_context(); + + gtk::prelude::WidgetExt::activate_action(&test.window, &format!("win.{REVIEW_HOST_KEY}"), None) + .expect("the review action"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert!( + alert(&test).is_none(), + "a second review opened while ssh-keygen was still running" + ); + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + } +} diff --git a/linux/crates/app/src/ui/workspace/imp.rs b/linux/crates/app/src/ui/workspace/imp.rs new file mode 100644 index 0000000000..dddbe4dbdd --- /dev/null +++ b/linux/crates/app/src/ui/workspace/imp.rs @@ -0,0 +1,91 @@ +use std::cell::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::WorkspaceModel; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/workspace.ui")] +pub struct Workspace { + #[template_child] + pub window_title: TemplateChild, + #[template_child] + pub read_only_badge: TemplateChild, + #[template_child] + pub row_op_spinner: TemplateChild, + #[template_child] + pub primary_menu_button: TemplateChild, + #[template_child] + pub sidebar_root: TemplateChild, + #[template_child] + pub sidebar_title: TemplateChild, + #[template_child] + pub table_search_toggle: TemplateChild, + #[template_child] + pub table_search_bar: TemplateChild, + #[template_child] + pub table_search: TemplateChild, + #[template_child] + pub sidebar_scroll: TemplateChild, + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub reconnect_banner: TemplateChild, + #[template_child] + pub workspace_outer_stack: TemplateChild, + + pub(super) model: RefCell>, + /// Handles the queued input, and with it every reply the workspace + /// is still waiting on. Aborted at shutdown, which drops them. + pub(super) pump: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for Workspace { + const NAME: &'static str = "TableProWorkspace"; + type Type = super::Workspace; + type ParentType = adw::BreakpointBin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for Workspace { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("saves-in-flight-changed") + .param_types([u32::static_type()]) + .build(), + Signal::builder("title-changed") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + /// A workspace dropped without a teardown still stops its reads and + /// lets go of its tabs. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for Workspace {} +impl BreakpointBinImpl for Workspace {} diff --git a/linux/crates/app/src/ui/app/import.rs b/linux/crates/app/src/ui/workspace/import.rs similarity index 90% rename from linux/crates/app/src/ui/app/import.rs rename to linux/crates/app/src/ui/workspace/import.rs index bfd7c1d009..93a78572f2 100644 --- a/linux/crates/app/src/ui/app/import.rs +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -1,8 +1,9 @@ use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{Component, ComponentController, ComponentSender, gtk}; +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; use tablepro_core::dialect::SqlDialect; use tablepro_core::edit::CellInput; @@ -14,17 +15,18 @@ use tablepro_session::{OperationError, OperationKind, Session}; use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES}; -use super::{App, AppMsg}; +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; use crate::workspace::RanStatements; /// How many of an import's statements go into the log line that /// announces it, so a failed import has something to read back. const IMPORT_LOG_SAMPLE: usize = 3; -impl App { +impl WorkspaceModel { /// Ask for a CSV, then read it and the table's columns together so /// the mapping dialog opens with both. - pub(super) fn on_import_csv_requested(&self, schema: Option, table: String, sender: ComponentSender) { + pub(super) fn on_import_csv_requested(&self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("Connect to a database first.")); return; @@ -48,9 +50,9 @@ impl App { .filters(&filters) .build(); - let input = sender.input_sender().clone(); + let input = self.input.clone(); let tasks = self.context.tasks.clone(); - dialog.open(Some(&self.window), gio::Cancellable::NONE, move |outcome| { + dialog.open(self.window().as_ref(), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; let input = input.clone(); let schema = schema.clone(); @@ -61,7 +63,7 @@ impl App { let bytes = match read_csv_file(&file).await { Ok(bytes) => bytes, Err(message) => { - let _ = input.send(AppMsg::ShowToast(message)); + input.deliver(WorkspaceInput::ShowToast(message)); return; } }; @@ -74,7 +76,7 @@ impl App { Ok(Ok(columns)) => columns, Ok(Err(error)) => { tracing::warn!(%error, "could not read the table to import into"); - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( + input.deliver(WorkspaceInput::ShowToast(crate::i18n::gettext( "The table's columns could not be read.", ))); return; @@ -84,7 +86,7 @@ impl App { return; } }; - let _ = input.send(AppMsg::ImportCsvReady { + input.deliver(WorkspaceInput::ImportCsvReady { schema, table, columns, @@ -100,7 +102,6 @@ impl App { table: String, columns: Vec, bytes: Vec, - sender: ComponentSender, ) { use crate::ui::import_dialog::{ImportCsvDialog, ImportCsvDialogInit, ImportCsvDialogOutput}; @@ -111,17 +112,22 @@ impl App { columns, bytes, }) - .forward(sender.input_sender(), |out| match out { - ImportCsvDialogOutput::Import(import) => AppMsg::ImportCsvRun(import), - ImportCsvDialogOutput::ShowToast(text) => AppMsg::ShowToast(text), + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + ImportCsvDialogOutput::Import(import) => WorkspaceInput::ImportCsvRun(import), + ImportCsvDialogOutput::ShowToast(text) => WorkspaceInput::ShowToast(text), + }); + } }); - dialog.model().dialog().present(Some(&self.window)); + dialog.model().dialog().present(self.dialog_parent().as_ref()); self.import_dialog = Some(dialog); } /// Read the whole file, turn every row into an INSERT and write them /// as one batch, so a file either lands whole or not at all. - pub(super) fn on_import_csv_run(&self, import: CsvImport, sender: ComponentSender) { + pub(super) fn on_import_csv_run(&self, import: CsvImport) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("Connect to a database first.")); return; @@ -140,20 +146,22 @@ impl App { let count = rows.len(); let table = TableRef::new(import.schema.clone(), import.table.clone()); - let history = self.history.store(); + let history = Some(self.history.clone()); let connection = self.context.active.metadata(); - self.context.tasks.spawn_task(async move { + let dialect = session.current_dialect(); + let input = self.input.clone(); + drop(self.context.tasks.spawn_task(async move { let written = write_import(&session, table, import.columns, rows).await; match written { Ok((sql, report)) => { // An import changes the database, so it belongs in // the same history as a statement run from the editor. let touched = report.total_rows().and_then(|rows| i64::try_from(rows).ok()); - RanStatements::starting(history, connection, sql) + RanStatements::starting(history, connection, dialect, sql) .finished(touched, tablepro_storage::query_history::Outcome::Success) .await; - sender.input(AppMsg::RefreshPage); - sender.input(AppMsg::ShowToast(crate::i18n::ngettext_f( + input.deliver(WorkspaceInput::RefreshPage); + input.deliver(WorkspaceInput::ShowToast(crate::i18n::ngettext_f( "Imported {n} row.", "Imported {n} rows.", u32::try_from(count).unwrap_or(u32::MAX), @@ -162,13 +170,13 @@ impl App { } Err(error) => { let message = crate::ui::error_text::operation_error_message(&error); - sender.input(AppMsg::ShowAlert { + input.deliver(WorkspaceInput::ShowAlert { title: crate::i18n::gettext("Couldn't import"), body: message, }); } } - }); + })); } } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs new file mode 100644 index 0000000000..a7be927c98 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -0,0 +1,821 @@ +//! One connection session's workspace: the sidebar of its tables and +//! the tabs open on them. A workspace never changes session; a +//! disconnect drops it and the window shows the connection picker. + +mod close_flow; +mod health_banner; +mod imp; +mod import; +mod row_ops; +mod saves; +mod sidebar; +mod sidebar_section_header; +mod status_pages; +mod structure; +mod window_tabs; +mod workspace_actions; +mod workspace_persistence; +mod workspace_tab_view; +mod workspace_tabs; + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; +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 relm4::factory::FactoryVecDeque; +use relm4::{ComponentController, Controller}; + +use tablepro_core::{EditorSession, QueryResult}; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, Session, SessionHealth, SessionKey}; +use uuid::Uuid; + +use super::browse_tab::{BrowseTab, BrowseTabInput}; +use super::editor::SqlEditor; +use super::history_dialog::HistoryDialog; +use super::host_key_failure::HostKeyFailure; +use super::host_key_review::HostKeyReviewOutcome; +use super::sidebar_row::SidebarRow; +use crate::application::AppServices; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::workspace::{CloseGuards, WorkspaceHandle}; + +glib::wrapper! { + pub struct Workspace(ObjectSubclass) + @extends adw::BreakpointBin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +/// What a window hands the workspace it builds for an adopted session. +pub(crate) struct WorkspaceInit { + pub key: SessionKey, + pub session: Arc, + pub services: Rc, + /// The window's own, whose session slot the workspace does not use. + pub context: Rc, + pub window: adw::ApplicationWindow, + /// The window's prompts, so a tab's close and the window's never ask + /// at once. + pub close_guards: Rc, +} + +pub(crate) type WorkspaceSender = InputSender; + +pub(crate) struct WorkspaceModel { + context: Rc, + /// What the session's tabs are saved under. + key: SessionKey, + input: WorkspaceSender, + this: glib::WeakRef, + window: glib::WeakRef, + settings: Rc, + storage: crate::storage::SharedStorage, + tasks: tablepro_session::runtime::Tasks, + history: crate::services::history_service::HistoryService, + drafts: Rc, + window_title: adw::WindowTitle, + sidebar_title: adw::WindowTitle, + /// Watches `HistoryService` and flips `win.show-history`. Aborted + /// with the workspace, whose actions it acts on. + history_gate: glib::JoinHandle<()>, + /// The window's actions this workspace added, removed with it. + actions: Vec<&'static str>, + sidebar_factory: FactoryVecDeque, + /// Takes the sidebar rows' output to `input`. + sidebar_bridge: glib::JoinHandle<()>, + sidebar_schemas: Rc>>>, + toast_overlay: adw::ToastOverlay, + reconnect_banner: adw::Banner, + row_op_spinner: adw::Spinner, + read_only_badge: gtk::Label, + table_search: gtk::SearchEntry, + /// Removes a changed host key from known_hosts. `None` without ssh. + ssh: Option, + /// Held until a Replace's ssh-keygen finishes too, so a second + /// Replace cannot remove the key the first one's retry wrote. + reviewing_host_key: bool, + /// `"empty"` while no tab is open, `"tabs"` otherwise. + workspace_outer_stack: gtk::Stack, + workspace_root: Option, + workspace_tab_view: Option, + workspace_root_added: Cell, + /// Per-tab state, by tab id. Display order is read from the tab + /// view's pages. + workspace_tabs: Rc>>, + /// Pending row edits, one tracker per Table tab. + change_trackers: Rc, + /// Pending DDL, one tracker per Structure tab. + structure_trackers: Rc, + /// Starts, stops and settles the saves, and says what closing any + /// part of the workspace would lose. + workspace_handle: WorkspaceHandle, + /// The tabs as the close prompts read them. + window_tabs: Rc, + close_guards: Rc, + /// Whether the window was last told there is something to lose. + reported_dirty: Cell>, + history_dialog: Option>, + saved_queries_dialog: Option>, + import_dialog: Option>, + driver_id: String, + read_only: bool, + /// Writes the tab list a moment after the last change. + workspace: Rc, + /// Recently closed tabs for Ctrl+Shift+T. Pending edits are not + /// kept: the close has already cleared them. + closed_tabs_stack: Rc>>, + /// Feed the reconnect banner and the sidebar, and stop with the + /// workspace, so nothing reads for a session it let go of. + _health_watch: WatchForwarder, + _catalog_watch: WatchForwarder, +} + +/// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T +/// reopen, so reopen routes back through the same `append_*` +/// constructors as a restore. +#[derive(Debug, Clone)] +pub enum ClosedTabDescriptor { + Editor { + query: String, + }, + Table { + schema: Option, + table: String, + offset: u64, + page_size: u64, + sort: Option<(usize, bool)>, + }, + Structure { + schema: Option, + table: String, + }, +} + +pub(super) const CLOSED_TABS_CAPACITY: usize = 10; + +pub struct EditorTabSlot { + pub editor: SqlEditor, + pub page: adw::TabPage, + /// The file this tab's text is written to. The text itself lives in + /// the buffer, not here. + pub draft: tablepro_storage::DraftId, +} + +pub struct StructureTabSlot { + pub id: Uuid, + pub structure: crate::ui::structure_tab::StructureTab, + pub page: adw::TabPage, + pub schema: Option, + /// Empty in `New` mode until SaveCompleted carries the canonical + /// table name back from the driver. Edit mode populates from the + /// sidebar click or restore record. + pub table: String, + pub mode: crate::ui::structure_tab::StructureMode, +} + +/// A tab showing one table's rows. The table's structure opens in a +/// tab of its own: one TabPage per surface, as GNOME Files and Builder +/// do. +pub struct TableTabSlot { + pub id: Uuid, + pub page: adw::TabPage, + pub schema: Option, + pub table: String, + pub browse: BrowseTab, +} + +/// A tab in the unified workspace. +/// +/// - **Table**: one (schema, table) entity in Data (Browse grid) +/// view. Default for every sidebar single-click. +/// - **Editor**: a free-form SQL workspace, orthogonal to any one +/// table. +/// - **Structure**: the DDL editor, for a new table's draft or an +/// existing table. +pub enum WorkspaceTab { + Editor(EditorTabSlot), + Structure(StructureTabSlot), + Table(TableTabSlot), +} + +impl WorkspaceTab { + /// The Browse tab. Only `Table` slots carry one. + pub fn browse_tab(&self) -> Option<&BrowseTab> { + match self { + WorkspaceTab::Table(s) => Some(&s.browse), + _ => None, + } + } + + /// The Structure-side controller. Only `Structure` slots carry + /// one (Table slots no longer fuse the DDL editor in). + pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { + match self { + WorkspaceTab::Structure(s) => Some(&s.structure), + _ => None, + } + } + + /// `(schema, table)` when the slot is pinned to one. Editor + /// returns `None`. + pub fn schema_table(&self) -> Option<(Option<&str>, &str)> { + match self { + WorkspaceTab::Structure(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Table(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Editor(_) => None, + } + } + + pub fn page(&self) -> &adw::TabPage { + match self { + WorkspaceTab::Editor(slot) => &slot.page, + WorkspaceTab::Structure(slot) => &slot.page, + WorkspaceTab::Table(slot) => &slot.page, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum OpenMode { + /// Plain sidebar click: activate the table's tab, or append one. + SwitchOrAppend, + /// Ctrl+click / right-click "Open in new tab": always append a + /// new tab even when the same table is already open. + NewTab, +} + +// The page carries only the tab's id; its kind is in `workspace_tabs`. +fn workspace_tab_id_quark() -> glib::Quark { + static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); + *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) +} + +pub(super) fn write_workspace_tab_id(page: &adw::TabPage, id: Uuid) { + unsafe { + page.set_qdata(workspace_tab_id_quark(), id); + } +} + +pub(super) fn read_workspace_tab_id(page: &adw::TabPage) -> Option { + unsafe { page.qdata::(workspace_tab_id_quark()).map(|p| *p.as_ref()) } +} + +/// A dialog the user already closed has left its window, and closing it +/// again logs a critical. +fn close_if_presented(dialog: &adw::Dialog) { + if dialog.parent().is_some() { + dialog.close(); + } +} + +#[derive(Debug)] +pub(crate) enum WorkspaceInput { + HealthChanged(SessionHealth), + /// The banner's Review Key, for a session parked on a host key. + ReviewHostKey, + HostKeyReviewed { + failure: HostKeyFailure, + outcome: HostKeyReviewOutcome, + }, + /// ssh-keygen removed the changed key, or says why it could not. + OldHostKeyForgotten { + failure: HostKeyFailure, + forgotten: Result<(), String>, + }, + /// The session's object list for the sidebar changed: a reconnect + /// read it again, or a schema change refreshed it. + CatalogChanged(ObjectListSnapshot), + SelectTable { + schema: Option, + name: String, + open_mode: OpenMode, + }, + NewEditorTab, + CloseActiveWorkspaceTab, + EditorTabRunStateChanged(Uuid, bool), + EditorTabContentChanged(Uuid), + /// The session's saved tabs and their editor text, read off the GTK + /// thread. + WorkspaceLoaded(workspace_tabs::LoadedWorkspace), + /// The toast's Reset: move the unreadable saved tabs aside. + ResetWorkspaceLayout, + WorkspaceLayoutReset(Result, crate::workspace::WorkspaceSaveError>), + ShowHistory, + ShowSavedQueries, + SaveActiveQuery, + SaveQueryNamed { + name: String, + query: String, + }, + ImportCsvRequested { + schema: Option, + table: String, + }, + /// The file came back with the table's columns beside it, so the + /// mapping dialog has everything it needs. + ImportCsvReady { + schema: Option, + table: String, + columns: Vec, + bytes: Vec, + }, + ImportCsvRun(Box), + OpenHistoryQuery(String), + ReplaceActiveTabQuery(String), + TabCloseDecided { + id: Uuid, + approved: bool, + }, + RefreshPage, + ExportResults { + result: QueryResult, + name: String, + target: Option, + }, + CopyToClipboard(String), + WorkspaceTabClosed(Uuid), + /// Each tab still asks about its own unsaved changes. + CloseOtherWorkspaceTabs(Uuid), + CloseWorkspaceTabsToRight(Uuid), + /// The tab order, the selection or a tab's state changed, so the + /// workspace is saved. + WorkspaceTabsChanged, + SaveTab(Uuid), + StopSaving(Uuid), + /// A save started or settled, or tabs' changes were dropped. + Workspace(crate::workspace::WorkspaceEvent), + SavesInFlightChanged(usize), + /// An editor tab's transaction opened or ended. + EditorTransactionChanged, + SaveActiveTab, + UndoActiveBrowseTab, + RedoActiveBrowseTab, + ShowAlert { + title: String, + body: String, + }, + ShowToast(String), + /// A browse tab's tracker moved between empty and non-empty, so its + /// title gains or loses the "•" GNOME Text Editor puts on unsaved + /// documents. + BrowseTabDirtyChanged(Uuid, bool), + NewTableTab { + schema: Option, + }, + EditStructureTab { + schema: Option, + table: String, + }, + ShowCreateTableForExisting { + schema: Option, + table: String, + }, + ShowCreateTableLoaded(String), + DropTablePrompt { + schema: Option, + table: String, + }, + DropTableConfirmed { + schema: Option, + table: String, + }, + /// The table is gone, so its tabs close without asking. + DropTableSucceeded(tablepro_core::TableRef), + StructureTabDirtyChanged(Uuid, bool), + SchemaChanged { + schema: Option, + table: Option, + }, + ReopenClosedTab, + ShowFilterDialog, +} + +/// Tabs in either registry with unsaved changes. +fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { + let mut dirty = changes.dirty(); + dirty.extend(structures.dirty()); + dirty.sort_unstable(); + dirty.dedup(); + dirty +} + +impl Workspace { + pub(crate) fn new(init: WorkspaceInit) -> Self { + let WorkspaceInit { + key, + session, + services, + context, + window, + close_guards, + } = init; + let workspace: Self = glib::Object::new(); + let imp = workspace.imp(); + let (input, receiver) = crate::runtime::input_queue::(); + imp.pump.replace(Some(crate::runtime::pump_inputs( + &workspace, + receiver, + |workspace, message| workspace.handle_input(message), + ))); + + // The workspace's own slot: a tab reads it for the session, and + // it empties when the workspace goes. + let context = Rc::new(SessionContext { + active: crate::services::active_session::ActiveSession::new(), + ..(*context).clone() + }); + context.active.replace(session.clone()); + + let settings = services.settings.clone(); + let storage = services.storage.clone(); + let tasks = services.tasks.clone(); + let history = services.history(); + let drafts = crate::workspace::DraftWriter::new( + tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), + tasks.clone(), + ); + let persistence = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); + let change_trackers = Rc::new(ChangeTrackerRegistry::default()); + let structure_trackers = Rc::new(StructureTrackerRegistry::default()); + let workspace_tabs: workspace_persistence::WorkspaceTabs = Rc::new(RefCell::new(HashMap::new())); + let window_tabs = Rc::new(window_tabs::WindowTabs::new(workspace_tabs.clone())); + let events = input.clone(); + let workspace_handle = WorkspaceHandle::new(crate::workspace::WorkspaceDeps { + session: context.active.clone(), + tasks: context.tasks.clone(), + hold: context.hold.clone(), + history: Some(history.clone()), + rows: change_trackers.clone(), + structures: structure_trackers.clone(), + directory: window_tabs.clone(), + events: Rc::new(move |event| events.deliver(WorkspaceInput::Workspace(event))), + engine: crate::workspace::EngineProfile::of(&context.drivers, &session.metadata().driver_id), + }); + let saves = input.clone(); + workspace_handle + .ledger() + .on_change(move |count| saves.deliver(WorkspaceInput::SavesInFlightChanged(count))); + + let sidebar = sidebar::build(&workspace, &context, &input); + let actions = workspace_actions::install(&window, &input); + let history_gate = workspace_actions::spawn_history_gate(&window, history.availability()); + + let health_input = input.clone(); + let health_watch = WatchForwarder::new( + session.health(), + move |health| health_input.deliver(WorkspaceInput::HealthChanged(health)), + sidebar::spawn_local, + ); + let catalog_input = input.clone(); + let catalog_watch = sidebar::catalog_watch(&session, move |snapshot| { + catalog_input.deliver(WorkspaceInput::CatalogChanged(snapshot)); + }); + + let driver_id = session.metadata().driver_id.clone(); + let model = WorkspaceModel { + context, + key, + input, + this: workspace.downgrade(), + window: window.downgrade(), + settings, + storage, + tasks, + history, + drafts, + window_title: imp.window_title.get(), + sidebar_title: imp.sidebar_title.get(), + history_gate, + actions, + sidebar_factory: sidebar.factory, + sidebar_bridge: sidebar.bridge, + sidebar_schemas: sidebar.schemas, + toast_overlay: imp.toast_overlay.get(), + reconnect_banner: imp.reconnect_banner.get(), + row_op_spinner: imp.row_op_spinner.get(), + read_only_badge: imp.read_only_badge.get(), + table_search: imp.table_search.get(), + ssh: services.ssh(), + reviewing_host_key: false, + workspace_outer_stack: imp.workspace_outer_stack.get(), + workspace_root: None, + workspace_tab_view: None, + workspace_root_added: Cell::new(false), + workspace_tabs, + change_trackers, + structure_trackers, + workspace_handle, + window_tabs, + close_guards, + reported_dirty: Cell::new(None), + history_dialog: None, + saved_queries_dialog: None, + import_dialog: None, + driver_id, + read_only: session.read_only(), + workspace: persistence, + closed_tabs_stack: Rc::new(RefCell::new(VecDeque::with_capacity(CLOSED_TABS_CAPACITY))), + _health_watch: health_watch, + _catalog_watch: catalog_watch, + }; + imp.model.replace(Some(model)); + workspace + } + + /// Show the session: its tables, the empty tab area, and the tabs + /// saved for it. Called once the window listens for the + /// workspace's signals, so it hears the first title. + pub(crate) fn start(&self) { + self.with_model(WorkspaceModel::open); + } + + /// What the workspace's actions, tabs and background work send it. + pub(crate) fn input(&self) -> Option { + self.read(|model| model.input.clone()) + } + + /// The saves and the close prompts' view of the tabs. + pub(crate) fn handle(&self) -> Option { + self.read(|model| model.workspace_handle.clone()) + } + + /// Write what the workspace shows: the tab list, the editor text, + /// and the last column drag and filter change. The process is held + /// until every write lands. + pub(crate) fn flush(&self) { + self.with_model(|model| model.flush()); + } + + /// Stop every editor run and take each editor tab's session, so the + /// teardown closes them all under one hold. + pub(crate) fn take_editor_sessions(&self) -> Vec> { + self.with_model(|model| model.take_editor_sessions()) + .unwrap_or_default() + } + + /// Stop the tabs, their reads and the watchers, take the + /// workspace's actions off the window and drop what it holds. Any + /// reply still on its way is dropped with the input queue. + pub(crate) fn shutdown(&self) { + let imp = self.imp(); + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + let model = match imp.model.try_borrow_mut() { + Ok(mut model) => model.take(), + Err(_) => { + tracing::warn!("the workspace was shut down while it was handling input"); + return; + } + }; + if let Some(model) = model { + model.shutdown(); + } + } + + pub(crate) fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |workspace: &Self, dirty: bool| f(workspace, dirty)), + ) + } + + pub(crate) fn connect_saves_in_flight_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "saves-in-flight-changed", + false, + glib::closure_local!(move |workspace: &Self, count: u32| { + f(workspace, usize::try_from(count).unwrap_or(usize::MAX)); + }), + ) + } + + pub(crate) fn connect_title_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "title-changed", + false, + glib::closure_local!(move |workspace: &Self, title: String| f(workspace, title)), + ) + } + + fn handle_input(&self, message: WorkspaceInput) { + self.with_model(|model| model.update(message)); + } + + fn with_model(&self, f: impl FnOnce(&mut WorkspaceModel) -> R) -> Option { + let mut model = self.imp().model.try_borrow_mut().ok()?; + model.as_mut().map(f) + } + + fn read(&self, f: impl FnOnce(&WorkspaceModel) -> R) -> Option { + let model = self.imp().model.try_borrow().ok()?; + model.as_ref().map(f) + } +} + +impl WorkspaceModel { + pub(super) fn dirty_tabs(&self) -> Vec { + dirty_tabs(&self.change_trackers, &self.structure_trackers) + } + + /// The window this workspace is in, for dialogs to sit over. + pub(super) fn window(&self) -> Option { + self.window.upgrade() + } + + /// What a dialog sits over: the window, or the workspace itself + /// while it is not in one. + pub(super) fn dialog_parent(&self) -> Option { + self.window() + .map(Cast::upcast) + .or_else(|| self.this.upgrade().map(Cast::upcast)) + } + + /// Show the session it was built for: the sidebar, the empty tab + /// area, and the tabs saved for it. + fn open(&mut self) { + self.read_only_badge.set_visible(self.read_only); + self.reconnect_banner.set_revealed(false); + self.table_search.set_text(""); + self.ensure_workspace_root(); + let objects = self + .context + .active + .get() + .map(|session| { + session + .subscribe_objects(tablepro_core::meta::ObjectScope::DefaultSchema) + .borrow() + .objects + .to_vec() + }) + .unwrap_or_default(); + tracing::info!(driver = %self.driver_id, table_count = objects.len(), "workspace ready"); + self.show_objects(&objects); + self.refresh_window_title(); + self.restore_workspace_tabs(self.key.clone()); + self.refresh_inhibitor(); + } + + /// The window's title names the table in front of the user and the + /// connection, the way GNOME apps name the document; the header + /// names the connection and its driver. A "•" leads while any tab + /// has unsaved changes, so it shows in the overview and Alt+Tab. + pub(super) fn refresh_window_title(&self) { + let metadata = self.context.active.metadata(); + let name = metadata.as_ref().map_or("", |metadata| metadata.name.as_str()); + let mut title = match self.selected_browse_slot_table() { + Some((schema, table)) => format!("{} · {name}", qualified_label(schema.as_deref(), &table)), + None => name.to_owned(), + }; + if !self.dirty_tabs().is_empty() { + title = format!("• {title}"); + } + self.window_title.set_subtitle(&format!("{name} · {}", self.driver_id)); + self.sidebar_title.set_title(name); + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("title-changed", &[&title]); + } + } + + fn shutdown(mut self) { + self.history_gate.abort(); + self.sidebar_bridge.abort(); + // Their entries name this session's tables and queries. + if let Some(dialog) = self.history_dialog.take() { + close_if_presented(dialog.model().dialog()); + } + if let Some(dialog) = self.saved_queries_dialog.take() { + close_if_presented(dialog.model().dialog()); + } + if let Some(dialog) = self.import_dialog.take() { + close_if_presented(dialog.model().dialog()); + } + if let Some(window) = self.window() { + for action in &self.actions { + window.remove_action(action); + } + } + self.close_tabs(); + self.context.active.take(); + } + + fn update(&mut self, msg: WorkspaceInput) { + match msg { + WorkspaceInput::HealthChanged(health) => self.refresh_health_banner(&health), + WorkspaceInput::ReviewHostKey => self.on_review_host_key(), + WorkspaceInput::HostKeyReviewed { failure, outcome } => self.on_host_key_reviewed(failure, outcome), + WorkspaceInput::OldHostKeyForgotten { failure, forgotten } => { + self.on_old_host_key_forgotten(failure, forgotten); + } + WorkspaceInput::CatalogChanged(snapshot) => self.on_catalog_changed(&snapshot), + WorkspaceInput::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), + WorkspaceInput::SelectTable { + schema, + name, + open_mode, + } => self.dispatch_select_table(schema, name, open_mode), + WorkspaceInput::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), + WorkspaceInput::SaveTab(id) => self.request_save(id), + WorkspaceInput::StopSaving(id) => self.stop_saving(id), + WorkspaceInput::Workspace(event) => self.on_workspace_event(event), + WorkspaceInput::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), + WorkspaceInput::EditorTransactionChanged => self.refresh_inhibitor(), + WorkspaceInput::SaveActiveTab => { + if let Some(id) = self.selected_workspace_tab_id() { + self.request_save(id); + } + } + WorkspaceInput::UndoActiveBrowseTab => { + // Structure editing has no per-keystroke history: its + // undo is Discard, and a field's own text undo is GTK's. + if let Some(id) = self.selected_browse_tab_id() { + self.dispatch_to_tab(id, BrowseTabInput::Undo); + } + } + WorkspaceInput::RedoActiveBrowseTab => { + if let Some(id) = self.selected_browse_tab_id() { + self.dispatch_to_tab(id, BrowseTabInput::Redo); + } + } + WorkspaceInput::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id), + WorkspaceInput::CloseOtherWorkspaceTabs(id) => self.close_other_workspace_tabs(id), + WorkspaceInput::CloseWorkspaceTabsToRight(id) => self.close_workspace_tabs_to_right(id), + WorkspaceInput::CloseActiveWorkspaceTab => self.close_active_workspace_tab(), + WorkspaceInput::ShowAlert { title, body } => self.show_error_alert(&title, &body), + WorkspaceInput::ShowToast(msg) => self.show_toast(&msg), + WorkspaceInput::BrowseTabDirtyChanged(tab_id, dirty) => { + self.refresh_browse_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); + } + WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), + WorkspaceInput::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table), + WorkspaceInput::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table), + WorkspaceInput::ShowCreateTableLoaded(sql) => self.append_editor_tab(Some(sql)), + WorkspaceInput::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table), + WorkspaceInput::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table), + WorkspaceInput::DropTableSucceeded(table) => self.on_drop_table_succeeded(table), + WorkspaceInput::StructureTabDirtyChanged(tab_id, dirty) => { + self.refresh_structure_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); + } + WorkspaceInput::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), + WorkspaceInput::NewEditorTab => self.append_editor_tab(None), + WorkspaceInput::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), + WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), + WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), + WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), + WorkspaceInput::WorkspaceLayoutReset(outcome) => self.on_workspace_layout_reset(outcome), + WorkspaceInput::ShowHistory => self.on_show_history(), + WorkspaceInput::ShowSavedQueries => self.on_show_saved_queries(), + WorkspaceInput::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table), + WorkspaceInput::ImportCsvReady { + schema, + table, + columns, + bytes, + } => self.on_import_csv_ready(schema, table, columns, bytes), + WorkspaceInput::ImportCsvRun(import) => self.on_import_csv_run(*import), + WorkspaceInput::SaveActiveQuery => self.on_save_active_query(), + WorkspaceInput::SaveQueryNamed { name, query } => self.on_save_query_named(name, query), + WorkspaceInput::OpenHistoryQuery(text) => self.append_editor_tab(Some(text)), + WorkspaceInput::ReplaceActiveTabQuery(text) => self.on_replace_active_tab_query(text), + WorkspaceInput::RefreshPage => self.on_refresh_active_tab(), + WorkspaceInput::ExportResults { result, name, target } => { + if let Some(window) = self.window() { + let dialect = self.context.active.get().and_then(|session| session.current_dialect()); + super::export_dialog::present( + &window, + &self.toast_overlay, + result, + name, + target, + dialect, + &self.settings, + ); + } + } + WorkspaceInput::CopyToClipboard(text) => self.on_copy_to_clipboard(text), + WorkspaceInput::ReopenClosedTab => self.on_reopen_closed_tab(), + WorkspaceInput::ShowFilterDialog => self.on_show_filter_dialog(), + } + } +} + +fn qualified_label(schema: Option<&str>, table: &str) -> String { + match schema { + Some(s) => format!("{s}.{table}"), + None => table.to_string(), + } +} diff --git a/linux/crates/app/src/ui/workspace/row_ops.rs b/linux/crates/app/src/ui/workspace/row_ops.rs new file mode 100644 index 0000000000..5fde8f49fc --- /dev/null +++ b/linux/crates/app/src/ui/workspace/row_ops.rs @@ -0,0 +1,72 @@ +use gtk4::prelude::*; + +use super::WorkspaceModel; + +impl WorkspaceModel { + pub(super) fn set_row_op_in_flight(&self, in_flight: bool) { + self.row_op_spinner.set_visible(in_flight); + } + + pub(super) fn on_copy_to_clipboard(&self, text: String) { + match clipboard_refusal(&text) { + Some(refusal) => self.show_toast(&refusal), + None => { + self.toast_overlay.clipboard().set_text(&text); + self.show_toast(&crate::i18n::gettext("Copied to clipboard")); + } + } + } +} + +/// Why `text` cannot go on the clipboard, if it cannot. +/// +/// The clipboard takes a C string, so a NUL would cut the copy short, +/// and a cut copy pasted back would change the value without a word. +fn clipboard_refusal(text: &str) -> Option { + text.contains('\0') + .then(|| crate::i18n::gettext("Values with a NUL character can't be copied")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::DeliverInput; + use crate::test_support::{descendants, drain_main_context}; + use crate::ui::session_window::tests::TestWindow; + use crate::ui::workspace::WorkspaceInput; + + #[test] + fn text_with_a_nul_is_refused() { + assert!(clipboard_refusal("a\0b").is_some()); + assert_eq!(clipboard_refusal("a\tb\nc"), None); + } + + #[gtk4::test] + fn a_copy_with_a_nul_says_so_and_leaves_the_clipboard_alone() { + let test = TestWindow::connected("CopyNul"); + let clipboard = test.window.clipboard(); + clipboard.set_text("before"); + let input = test.workspace().input().expect("the workspace's input"); + + input.deliver(WorkspaceInput::CopyToClipboard("a\0b".to_owned())); + drain_main_context(); + + assert_eq!(local_text(&clipboard).as_deref(), Some("before")); + let refusal = clipboard_refusal("a\0b").expect("a refusal"); + assert!( + descendants(&test.workspace()) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == refusal), + "no toast said why" + ); + + input.deliver(WorkspaceInput::CopyToClipboard("a\tb".to_owned())); + drain_main_context(); + assert_eq!(local_text(&clipboard).as_deref(), Some("a\tb")); + } + + fn local_text(clipboard: >k4::gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } +} diff --git a/linux/crates/app/src/ui/app/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs similarity index 70% rename from linux/crates/app/src/ui/app/saves.rs rename to linux/crates/app/src/ui/workspace/saves.rs index 4daf5b8bef..a57275733f 100644 --- a/linux/crates/app/src/ui/app/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -1,38 +1,51 @@ -use relm4::{ComponentSender, adw}; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; use uuid::Uuid; use tablepro_core::WriteKind; -use super::{App, WorkspaceTab}; +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; use crate::ui::browse_tab::BrowseTabInput; use crate::ui::structure_tab::StructureTabInput; use crate::workspace::{CloseScope, DirtyTab, SaveOutcome, SaveSettled, SaveStart, WorkspaceEvent}; -impl App { - /// Save one tab, from its own Save button or Ctrl+S. +impl WorkspaceModel { + /// Save one tab, from its own Save button or Ctrl+S. A structure + /// save may wait on its review first. pub(super) fn request_save(&self, tab: Uuid) { - for start in self.workspace_handle.save(&[tab]) { - match start { - SaveStart::Started(save) => tracing::debug!(?save, %tab, "save started"), - SaveStart::Rejected(rejection) if rejection.needs_alert() => { - self.show_error_alert(&crate::i18n::gettext("Cannot Save"), &rejection.message()); + let Some(parent) = self.dialog_parent() else { + return; + }; + let (handle, input) = (self.workspace_handle.clone(), self.input.clone()); + glib::spawn_future_local(async move { + for start in handle.save(&parent, &[tab]).await { + match start { + SaveStart::Started(save) => tracing::debug!(?save, %tab, "save started"), + SaveStart::Rejected(rejection) if rejection.needs_alert() => { + input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Cannot Save"), + body: rejection.message(), + }); + } + SaveStart::NothingToSave | SaveStart::Rejected(_) => {} } - SaveStart::NothingToSave | SaveStart::Rejected(_) => {} } - } + }); } pub(super) fn stop_saving(&self, tab: Uuid) { self.workspace_handle.stop_saves(CloseScope::Tab(tab)); } - pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent, sender: ComponentSender) { + pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent) { match event { WorkspaceEvent::SaveStarted { tab } => { self.dispatch_to_tab(tab, BrowseTabInput::SaveStarted); self.send_to_structure_tab(tab, StructureTabInput::SaveStarted); } - WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled, sender), + WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled), WorkspaceEvent::Discarded { tabs, reload } => { for tab in tabs { let browse = match reload { @@ -53,10 +66,14 @@ impl App { pub(super) fn on_saves_in_flight_changed(&mut self, count: usize) { self.set_row_op_in_flight(count > 0); + if let Some(workspace) = self.this.upgrade() { + let count = u32::try_from(count).unwrap_or(u32::MAX); + workspace.emit_by_name::<()>("saves-in-flight-changed", &[&count]); + } self.refresh_inhibitor(); } - fn on_save_settled(&mut self, settled: SaveSettled, sender: ComponentSender) { + fn on_save_settled(&mut self, settled: SaveSettled) { let SaveSettled { tab, kind, @@ -89,10 +106,14 @@ impl App { self.dispatch_to_tab(tab, BrowseTabInput::SaveOutcomeUnknown(message)); } (WriteKind::SchemaChanges, SaveOutcome::Committed { .. }) => { - self.on_structure_save_completed(tab, saved_table_name, sender); + self.on_structure_save_completed(tab, saved_table_name); } (WriteKind::SchemaChanges, SaveOutcome::RolledBack(cause)) => { - self.send_to_structure_tab(tab, StructureTabInput::SaveFailed(cause.message())); + let input = match cause.found_changes() { + true => StructureTabInput::TableChangedBeforeSave, + false => StructureTabInput::SaveFailed(cause.message()), + }; + self.send_to_structure_tab(tab, input); } (WriteKind::SchemaChanges, SaveOutcome::Unknown(cause)) => { let message = cause.sentence(&self.tab_name(tab)); @@ -105,12 +126,7 @@ impl App { /// it. fn tab_name(&self, tab: Uuid) -> String { let title = self.workspace_tabs.borrow().get(&tab).map(|slot| { - let page = match slot { - WorkspaceTab::Editor(slot) => &slot.page, - WorkspaceTab::Structure(slot) => &slot.page, - WorkspaceTab::Table(slot) => &slot.page, - }; - let title = page.title(); + let title = slot.page().title(); title.strip_prefix("• ").unwrap_or(&title).to_owned() }); DirtyTab::quote(&title.unwrap_or_default()) diff --git a/linux/crates/app/src/ui/workspace/sidebar.rs b/linux/crates/app/src/ui/workspace/sidebar.rs new file mode 100644 index 0000000000..7d7e39aefb --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar.rs @@ -0,0 +1,358 @@ +//! The sidebar of the session's tables. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use relm4::factory::FactoryVecDeque; + +use tablepro_core::meta::{ObjectInfo, ObjectScope}; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, Session}; + +use super::{OpenMode, Workspace, WorkspaceInput, WorkspaceModel, WorkspaceSender, sidebar_section_header}; +use crate::runtime::DeliverInput; +use crate::services::session_context::SessionContext; +use crate::ui::sidebar_row::{SidebarRow, SidebarRowOutput}; + +/// The sidebar's rows, and the schema each one is in, by row index. +pub(super) struct Sidebar { + pub factory: FactoryVecDeque, + /// Takes the rows' output to the workspace. + pub bridge: glib::JoinHandle<()>, + pub schemas: Rc>>>, +} + +pub(super) fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { + glib::spawn_future_local(future); +} + +/// Deliver each new object list the session's catalog commits for the +/// sidebar's scope. The list on screen when the watch starts is not +/// delivered again, and a refresh that failed keeps the list it had. +pub(super) fn catalog_watch( + session: &Arc, + mut deliver: impl FnMut(ObjectListSnapshot) + 'static, +) -> WatchForwarder { + let objects = session.subscribe_objects(ObjectScope::DefaultSchema); + let mut shown = objects.borrow().generation; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != shown { + shown = snapshot.generation; + deliver(snapshot); + } else if let Some(error) = &snapshot.last_error { + tracing::warn!(%error, "the table list could not be read again"); + } + }, + spawn_local, + ) +} + +pub(super) fn build(workspace: &Workspace, context: &Rc, input: &WorkspaceSender) -> Sidebar { + let imp = workspace.imp(); + let schemas: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let (bridge_sender, bridge) = crate::runtime::relm4_bridge(input.clone()); + let factory: FactoryVecDeque = FactoryVecDeque::builder() + .launch( + gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .activate_on_single_click(true) + .css_classes(["navigation-sidebar"]) + .build(), + ) + .forward(&bridge_sender, |out| match out { + // A plain click and Enter come through the list box's + // row-activated below, the one signal that fires for both. + SidebarRowOutput::OpenInNewTab { schema, name } => WorkspaceInput::SelectTable { + schema, + name, + open_mode: OpenMode::NewTab, + }, + SidebarRowOutput::EditStructure { schema, name } => { + WorkspaceInput::EditStructureTab { schema, table: name } + } + SidebarRowOutput::ShowCreateTable { schema, name } => { + WorkspaceInput::ShowCreateTableForExisting { schema, table: name } + } + SidebarRowOutput::ImportCsv { schema, name } => WorkspaceInput::ImportCsvRequested { schema, table: name }, + SidebarRowOutput::DropTable { schema, name } => WorkspaceInput::DropTablePrompt { schema, table: name }, + }); + + let listbox = factory.widget(); + imp.sidebar_scroll.set_child(Some(listbox)); + + let schemas_for_activate = schemas.clone(); + let activate_input = input.clone(); + listbox.connect_row_activated(move |_, row| { + let name = row.widget_name().to_string(); + let index = usize::try_from(row.index()).unwrap_or_default(); + let schema = schemas_for_activate.borrow().get(index).cloned().unwrap_or(None); + activate_input.deliver(WorkspaceInput::SelectTable { + schema, + name, + open_mode: OpenMode::SwitchOrAppend, + }); + }); + + let search = imp.table_search.get(); + let schemas_for_filter = schemas.clone(); + listbox.set_filter_func(move |row| { + let query = search.text().to_lowercase(); + // The schema matches too, so "auth" finds every table in that + // schema, and so does the dotted name the tab title shows. + sidebar_row_matches( + &query, + &row.widget_name(), + schemas_for_filter + .borrow() + .get(usize::try_from(row.index()).unwrap_or_default()) + .and_then(|schema| schema.as_deref()), + ) + }); + let listbox_for_invalidate = listbox.clone(); + imp.table_search.connect_search_changed(move |_| { + listbox_for_invalidate.invalidate_filter(); + }); + imp.table_search_bar.connect_entry(&*imp.table_search); + imp.table_search_bar.set_key_capture_widget(Some(&*imp.sidebar_root)); + + // An empty list with no placeholder reads as broken. + let placeholder = adw::StatusPage::builder() + .icon_name(crate::ui::icons::VIEW_LIST) + .title(crate::i18n::gettext("No tables")) + .description(crate::i18n::gettext( + "Nothing matches the current search, or this connection has no tables yet.", + )) + .build(); + placeholder.add_css_class("compact"); + listbox.set_placeholder(Some(&placeholder)); + + imp.table_search_toggle + .bind_property("active", &*imp.table_search_bar, "search-mode-enabled") + .bidirectional() + .sync_create() + .build(); + + let schemas_for_header = schemas.clone(); + let header_input = input.clone(); + let session = context.active.clone(); + listbox.set_header_func(move |row, before| { + let schemas = schemas_for_header.borrow(); + let distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect(); + // A connection with one schema still gets a header, for its + // "New Table" button. + let multi_schema = distinct.len() >= 2; + let current = schemas + .get(usize::try_from(row.index()).unwrap_or_default()) + .cloned() + .flatten(); + let previous = before + .and_then(|before| schemas.get(usize::try_from(before.index()).unwrap_or_default())) + .cloned() + .flatten(); + let needs = match (¤t, &previous) { + (Some(current), Some(previous)) => current != previous, + (Some(_), None) => true, + (None, None) => before.is_none() && !multi_schema, + (None, Some(_)) => false, + }; + if !needs { + row.set_header(gtk::Widget::NONE); + return; + } + let can_create_table = session + .get() + .and_then(|session| session.current_dialect()) + .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) + .unwrap_or(false); + let input = header_input.clone(); + let schema = current.clone(); + let header = sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { + input.deliver(WorkspaceInput::NewTableTab { schema: schema.clone() }); + }); + row.set_header(Some(&header)); + }); + + Sidebar { + factory, + bridge, + schemas, + } +} + +/// Whether a row shows for `query`, already lowercased: by its table +/// name, its schema, or the two as `schema.table`. +fn sidebar_row_matches(query: &str, table: &str, schema: Option<&str>) -> bool { + if query.is_empty() { + return true; + } + let table = table.to_lowercase(); + if table.contains(query) { + return true; + } + let Some(schema) = schema else { + return false; + }; + let schema = schema.to_lowercase(); + schema.contains(query) || format!("{schema}.{table}").contains(query) +} + +/// Only what the grid can page through: routines and sequences have no +/// rows to show. +fn browsable(objects: Vec) -> Vec { + objects + .into_iter() + .filter(|object| object.kind.is_browsable()) + .collect() +} + +impl WorkspaceModel { + pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { + self.repopulate_sidebar(browsable(objects.to_vec())); + } + + fn repopulate_sidebar(&mut self, objects: Vec) { + { + let mut schemas = self.sidebar_schemas.borrow_mut(); + schemas.clear(); + schemas.extend(objects.iter().map(|object| object.schema.clone())); + } + let mut guard = self.sidebar_factory.guard(); + guard.clear(); + for object in objects { + guard.push_back(object); + } + drop(guard); + self.sidebar_factory.widget().invalidate_headers(); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use std::time::Duration; + + use tablepro_core::meta::ObjectKind; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::FakeSession; + + fn names(snapshot: &ObjectListSnapshot) -> Vec { + snapshot.objects.iter().map(|object| object.name.clone()).collect() + } + + /// The workspace's input queue, holding what the catalog watch + /// posted until the workspace handles it. + type Queue = Rc>>; + + fn post_to(queue: &Queue) -> impl FnMut(ObjectListSnapshot) + 'static { + let queue = queue.clone(); + move |update| queue.borrow_mut().push_back(update) + } + + fn refresh_objects( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + ) -> impl Future + use<> { + let session = session.clone(); + let refreshing = runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }); + async move { + let _ = refreshing.await; + } + } + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + /// A workspace drops its catalog watch with itself, so a list read + /// for its session after that reaches nothing. + #[gtk4::test] + fn a_late_catalog_commit_after_the_watch_drops_is_not_delivered() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(vec![object("late", ObjectKind::Table)]), + }); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let watching = catalog_watch(&fake.session, post_to(&queue)); + let late = refresh_objects(&fake.session, &runtime); + let counters = fake.connection.counters(); + crate::test_support::wait_until(Duration::from_secs(5), || { + CallCounters::get(&counters.list_objects) == 1 + }) + .expect("the list was asked for"); + + drop(watching); + crate::test_support::finish_on_main(Duration::from_secs(5), late).expect("the list to land"); + crate::test_support::drain_main_context(); + + assert_eq!( + names(&fake.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), + ["late"], + "the late list never landed, so the test proved nothing" + ); + assert!(queue.borrow().is_empty(), "a dropped watch still delivered"); + } + + #[gtk4::test] + fn a_new_list_is_delivered_once() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::ok(vec![object("users", ObjectKind::Table)])); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue)); + + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the list to land"); + crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) + .expect("the list was delivered"); + crate::test_support::drain_main_context(); + + assert_eq!(queue.borrow().len(), 1); + assert_eq!(names(&queue.borrow()[0]), ["users"]); + } + + #[test] + fn the_sidebar_lists_only_what_the_grid_can_page() { + let listed = browsable(vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("next_id", ObjectKind::Sequence), + object("refresh", ObjectKind::Function), + ]); + + let names: Vec<&str> = listed.iter().map(|object| object.name.as_str()).collect(); + assert_eq!(names, ["users", "active_users"]); + } + + #[test] + fn a_search_matches_the_table_its_schema_or_both() { + assert!(sidebar_row_matches("", "users", None)); + assert!(sidebar_row_matches("use", "Users", Some("public"))); + assert!(sidebar_row_matches("auth", "tokens", Some("auth"))); + assert!(sidebar_row_matches("auth.tok", "tokens", Some("Auth"))); + assert!(!sidebar_row_matches("auth", "tokens", None)); + } +} diff --git a/linux/crates/app/src/ui/app/sidebar_section_header.rs b/linux/crates/app/src/ui/workspace/sidebar_section_header.rs similarity index 100% rename from linux/crates/app/src/ui/app/sidebar_section_header.rs rename to linux/crates/app/src/ui/workspace/sidebar_section_header.rs diff --git a/linux/crates/app/src/ui/workspace/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs new file mode 100644 index 0000000000..37c68f7f06 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -0,0 +1,269 @@ +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; + +use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogOutput}; +use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; + +impl WorkspaceModel { + pub(super) fn show_toast(&self, msg: &str) { + self.toast_overlay.add_toast(plain_toast(msg)); + } + + pub(super) fn show_error_alert(&self, title: &str, message: &str) { + let dialog = adw::AlertDialog::new(Some(title), Some(message)); + // GNOME HIG dismiss-only alert: "Close" reads cleaner than "OK" + // (which implies acknowledgement of an action the user took) + // and matches GNOME Settings' info-alert convention. + dialog.add_response("close", &crate::i18n::gettext("Close")); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + dialog.present(self.dialog_parent().as_ref()); + } + + pub(super) fn on_show_history(&mut self) { + let dialog = HistoryDialog::builder() + .launch(HistoryDialogInit { + history: self.history.clone(), + tasks: self.tasks.clone(), + }) + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + HistoryDialogOutput::OpenInNewTab(text) => WorkspaceInput::OpenHistoryQuery(text), + HistoryDialogOutput::ReplaceCurrentTabQuery(text) => { + WorkspaceInput::ReplaceActiveTabQuery(text) + } + }); + } + }); + dialog.model().dialog().present(self.dialog_parent().as_ref()); + self.history_dialog = Some(dialog); + } + + pub(super) fn on_show_saved_queries(&mut self) { + 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) + } + SavedQueriesDialogOutput::CopyToClipboard(text) => WorkspaceInput::CopyToClipboard(text), + SavedQueriesDialogOutput::ShowToast(text) => WorkspaceInput::ShowToast(text), + }); + } + }); + dialog.model().dialog().present(self.dialog_parent().as_ref()); + self.saved_queries_dialog = Some(dialog); + } + + /// Ask what to call the SQL in front of the user, then keep it. + /// + /// The text is read now rather than when the dialog closes, so what + /// is saved is what the user was looking at when they asked. + pub(super) fn on_save_active_query(&self) { + let Some(query) = self.active_editor_query() else { + self.show_toast(&crate::i18n::gettext("Open a SQL editor tab to save a query.")); + return; + }; + if query.trim().is_empty() { + self.show_toast(&crate::i18n::gettext("There is nothing in the editor to save.")); + return; + } + + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Query")), None); + let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); + entry.set_text(&suggested_query_name(&query)); + 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("save", &crate::i18n::gettext("Save")); + dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("save")); + dialog.set_close_response("cancel"); + + let input = self.input.clone(); + dialog.connect_response(None, move |_, response| { + if response != "save" { + return; + } + let name = entry.text().to_string(); + if name.trim().is_empty() { + return; + } + input.deliver(WorkspaceInput::SaveQueryNamed { + name, + query: query.clone(), + }); + }); + dialog.present(self.dialog_parent().as_ref()); + } + + pub(super) fn on_save_query_named(&self, name: String, query: String) { + let Some(store) = self.history.store().map(|history| history.saved_queries()) else { + self.show_toast(&crate::i18n::gettext("The query database is not open yet.")); + return; + }; + let Some(metadata) = self.context.active.metadata() else { + self.show_toast(&crate::i18n::gettext( + "Connect to a database first: a saved query is kept under its connection.", + )); + return; + }; + let new_query = tablepro_storage::saved_queries::NewSavedQuery { + name: name.clone(), + query, + connection_id: metadata.id, + connection_name: metadata.name, + }; + // The save lands even when the workspace is gone by then; only + // its toast is dropped. + let input = self.input.clone(); + crate::runtime::spawn_held(&self.tasks, &*self.context.hold, async move { + let message = match store.save(new_query).await { + Ok(outcome) if outcome.replaced => crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]), + Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), + Err(error) => { + tracing::warn!(%error, "could not save the query"); + crate::i18n::gettext("Could not save that query.") + } + }; + input.deliver(WorkspaceInput::ShowToast(message)); + }); + } +} + +/// Toast text carries file paths and server messages, which an +/// `&` or `<` would break as markup. +fn plain_toast(message: &str) -> adw::Toast { + let toast = adw::Toast::new(message); + toast.set_use_markup(false); + toast +} + +/// A name for a query the user has not named, taken from the first +/// line that is neither blank nor a comment. It is a starting point in +/// an entry the user can edit, not a final name. +fn suggested_query_name(query: &str) -> String { + const MAX_CHARS: usize = 40; + for line in query.lines() { + let trimmed = line.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() || trimmed.starts_with("--") { + continue; + } + return trimmed + .chars() + .take(MAX_CHARS) + .collect::() + .trim_end() + .to_owned(); + } + String::new() +} + +#[cfg(test)] +mod tests { + use super::{plain_toast, suggested_query_name}; + use crate::test_support::{finish_on_main, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn a_saved_query_is_written_under_a_hold() { + let test = TestWindow::connected("SaveQueryHeld"); + let history = test.services.history(); + let availability = history.availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the query database to open"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + + test.workspace() + .with_model(|model| model.on_save_query_named("Orders".to_owned(), "SELECT 1".to_owned())); + assert_eq!(test.hold.live(), 1, "the save started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let store = history.store().expect("the query database").saved_queries(); + let listing = test.services.tasks.spawn_task(async move { store.list(None).await }); + let saved = finish_on_main(WAIT, listing) + .expect("the list to load") + .expect("the listing task") + .expect("the saved queries"); + assert!( + saved.iter().any(|query| query.name == "Orders"), + "the hold ended before the save" + ); + } + + #[gtk4::test] + fn a_history_dialog_closed_by_hand_is_not_closed_again_on_disconnect() { + use gtk4::glib; + use gtk4::prelude::*; + use relm4::ComponentController; + + let test = TestWindow::connected("HistoryClosedOnce"); + let dialog = test + .workspace() + .with_model(|model| { + model.on_show_history(); + model + .history_dialog + .as_ref() + .map(|dialog| dialog.model().dialog().clone()) + }) + .flatten() + .expect("the history dialog"); + wait_until(WAIT, || dialog.parent().is_some()).expect("the dialog to show"); + libadwaita::prelude::AdwDialogExt::close(&dialog); + wait_until(WAIT, || dialog.parent().is_none()).expect("the dialog to close"); + + // Closing a dialog that is no longer presented logs a critical, + // which this makes abort the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + test.window.send(crate::ui::SessionWindowInput::Disconnect); + let disconnected = wait_until(WAIT, || test.window.page_name() == "picker"); + glib::log_set_always_fatal(previous); + + disconnected.expect("the window back on the picker"); + } + + #[gtk4::test] + fn a_toast_shows_its_text_as_written() { + let text = "ssh-keygen could not open /home/a&b/"; + + let toast = plain_toast(text); + + assert!(!toast.uses_markup()); + assert_eq!(toast.title().as_deref(), Some(text)); + } + + #[test] + fn the_suggested_name_is_the_first_real_line() { + assert_eq!( + suggested_query_name("\n-- yesterday's orders\nSELECT * FROM orders;\n"), + "SELECT * FROM orders" + ); + } + + #[test] + fn a_long_first_line_is_cut_rather_than_run_on() { + let name = suggested_query_name("SELECT a, b, c, d, e, f, g, h FROM a_table_with_a_long_name"); + + assert_eq!(name.chars().count(), 40); + assert!(!name.ends_with(' ')); + } + + #[test] + fn a_query_that_is_only_comments_suggests_nothing() { + assert_eq!(suggested_query_name("-- nothing here\n\n"), ""); + } +} diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs similarity index 86% rename from linux/crates/app/src/ui/app/structure.rs rename to linux/crates/app/src/ui/workspace/structure.rs index 29dc5d06e9..26bdeafaa3 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -1,11 +1,11 @@ -//! App-side handlers for the Structure (DDL) workspace tab: opening +//! Workspace handlers for the Structure (DDL) tab: opening //! one, dropping a table, and following a save that landed. The save //! itself runs through the workspace handle, like a row save. use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw}; +use libadwaita as adw; +use libadwaita::prelude::*; use uuid::Uuid; use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, TableDefinition}; @@ -15,19 +15,15 @@ use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; use tokio_util::sync::CancellationToken; -use crate::services::from_session::FromSession; -use crate::ui::app::{App, AppMsg, WorkspaceTab}; +use crate::runtime::DeliverInput; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; +use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; use crate::workspace::run_schema_plan; -impl App { +impl WorkspaceModel { /// Sidebar right-click → "New Table…" or schema-header "+" button. - pub(super) fn on_new_table_tab(&mut self, schema: Option, sender: ComponentSender) { - if !self.connected { - self.show_toast(&crate::i18n::gettext("Connect to a database first.")); - return; - } + pub(super) fn on_new_table_tab(&mut self, schema: Option) { let creates_tables = self .context .active @@ -41,21 +37,12 @@ impl App { )); return; } - self.append_new_structure_tab(schema, sender); + self.append_new_structure_tab(schema); } /// Select the table's Structure tab, or open one. Its Table tab /// stays open beside it. - pub(super) fn on_edit_structure_tab( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { - if !self.connected { - self.show_toast(&crate::i18n::gettext("Connect to a database first.")); - return; - } + pub(super) fn on_edit_structure_tab(&mut self, schema: Option, table: String) { let existing_structure = self.workspace_tabs.borrow().iter().find_map(|(_, tab)| match tab { WorkspaceTab::Structure(slot) if slot.mode == StructureMode::Edit && slot.schema == schema && slot.table == table => @@ -70,16 +57,11 @@ impl App { tab_view.set_selected_page(&page); return; } - self.append_existing_structure_tab(schema, table, sender); + self.append_existing_structure_tab(schema, table); } /// Right-click → "Drop Table…", or in-tab destructive button. - pub(super) fn on_drop_table_prompt( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { + pub(super) fn on_drop_table_prompt(&mut self, schema: Option, table: String) { let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &table)]); let body = crate::i18n::gettext( "All rows and the table definition will be removed. This can't be undone from inside TablePro.", @@ -90,19 +72,18 @@ impl App { dialog.set_response_appearance("drop", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); - let sender_for_resp = sender.clone(); + let input = self.input.clone(); let schema_for_resp = schema.clone(); let table_for_resp = table.clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); + dialog.connect_response(None, move |_, response| { if response == "drop" { - sender_for_resp.input(AppMsg::DropTableConfirmed { + input.deliver(WorkspaceInput::DropTableConfirmed { schema: schema_for_resp.clone(), table: table_for_resp.clone(), }); } }); - dialog.present(Some(&self.window)); + dialog.present(self.dialog_parent().as_ref()); } /// User confirmed the drop. Run DROP TABLE async; only close any @@ -110,12 +91,7 @@ impl App { /// returns Ok. Tabs stay open if DROP fails (FK violation, /// privileges) so the user doesn't lose their state on a failed /// destructive action. - pub(super) fn on_drop_table_confirmed( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { + pub(super) fn on_drop_table_confirmed(&mut self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_error_alert( &crate::i18n::gettext("Cannot drop table"), @@ -128,7 +104,6 @@ impl App { if_exists: true, cascade: false, }; - let origin = Arc::downgrade(&session); let table = dropped.clone(); let work = async move { run_schema_plan( @@ -140,10 +115,13 @@ impl App { ) .await }; + // The answer goes through the workspace's input, so one that + // lands after a disconnect is dropped with the workspace. + let input = self.input.clone(); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { match settled(result).and_then(|(_, written)| written) { - Ok(_) => sender.input(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), - Err(error) => sender.input(AppMsg::ShowAlert { + Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), + Err(error) => input.deliver(WorkspaceInput::ShowAlert { title: crate::i18n::gettext("Drop failed"), body: error_text::operation_error_message(&error), }), @@ -151,9 +129,9 @@ impl App { }); } - pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef, sender: ComponentSender) { + pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef) { self.close_tabs_for_table(table.schema.as_deref(), &table.name); - sender.input(AppMsg::SchemaChanged { + self.input.deliver(WorkspaceInput::SchemaChanged { schema: table.schema, table: Some(table.name), }); @@ -187,12 +165,7 @@ impl App { /// 2. **Edit-mode tab**: the slot stays put, taking the new name /// when the save renamed the table; `SaveCompleted` makes the /// tab clear its pending ops and read the table again. - pub(super) fn on_structure_save_completed( - &mut self, - tab_id: Uuid, - new_table_name: Option, - sender: ComponentSender, - ) { + pub(super) fn on_structure_save_completed(&mut self, tab_id: Uuid, new_table_name: Option) { // Decided first, so the tabs are not borrowed while they change. enum SaveKind { PromoteNewToTable(Option, String), @@ -224,23 +197,16 @@ impl App { self.finish_close_workspace_tab(tab_id, &tab_view); } // A table just designed is filled in next. - super::App::append_table_tab( - self, - schema.clone(), - name.clone(), - 0, - u64::from(self.settings.default_page_size()), - None, - sender.clone(), - ); - sender.input(AppMsg::SchemaChanged { + let page_size = u64::from(self.settings.default_page_size()); + self.append_table_tab(schema.clone(), name.clone(), 0, page_size, None); + self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(name), }); } SaveKind::UpdateInPlace(schema, table) => { self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); - sender.input(AppMsg::SchemaChanged { + self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(table), }); @@ -253,18 +219,18 @@ impl App { /// table as it is defined now, from the dialect's /// `create_table_script`. The result lands in a fresh editor tab; no /// Structure tab is touched. - pub(super) fn on_show_create_table(&self, schema: Option, table: String, sender: ComponentSender) { + pub(super) fn on_show_create_table(&self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("No active connection.")); return; }; - let origin = Arc::downgrade(&session); let work = read_definition(session, TableRef::new(schema, table.clone())); + let input = self.input.clone(); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { let message = match settled(result) { Ok((definition, dialect)) => match create_table_sql(&definition, &*dialect) { Ok(Some(sql)) => { - sender.input(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); + input.deliver(WorkspaceInput::ShowCreateTableLoaded(sql)); return; } Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), @@ -278,7 +244,7 @@ impl App { &[("error", &error_text::operation_error_message(&error))], ), }; - sender.input(AppMsg::ShowToast(message)); + input.deliver(WorkspaceInput::ShowToast(message)); }); } @@ -516,4 +482,26 @@ mod tests { assert_eq!(settled(aborted), Err(OperationError::Cancelled)); assert_eq!(settled(Ok(Ok(5))), Ok(5)); } + + #[gtk4::test] + fn the_drop_prompt_closes_once() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected("DropPromptClosedOnce"); + test.workspace() + .with_model(|model| model.on_drop_table_prompt(None, "users".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the drop prompt"); + + // With animations off, closing the alert after its click already + // closed it logs a critical, which this makes abort the test. + let previous = gtk4::glib::log_set_always_fatal(gtk4::glib::LogLevels::LEVEL_CRITICAL); + let answered = answer(&test.window, "cancel"); + crate::test_support::drain_main_context(); + gtk4::glib::log_set_always_fatal(previous); + + assert!(answered, "the prompt has no Cancel button"); + assert!(visible_alert(&test.window).is_none(), "the prompt stayed open"); + } } diff --git a/linux/crates/app/src/ui/app/window_tabs.rs b/linux/crates/app/src/ui/workspace/window_tabs.rs similarity index 96% rename from linux/crates/app/src/ui/app/window_tabs.rs rename to linux/crates/app/src/ui/workspace/window_tabs.rs index 063bf72fa1..a143573543 100644 --- a/linux/crates/app/src/ui/app/window_tabs.rs +++ b/linux/crates/app/src/ui/workspace/window_tabs.rs @@ -1,8 +1,8 @@ use std::cell::RefCell; -use relm4::adw; -use relm4::adw::prelude::*; -use relm4::gtk::glib; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; use super::workspace_persistence::WorkspaceTabs; use super::{WorkspaceTab, read_workspace_tab_id}; diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs new file mode 100644 index 0000000000..ae8729f433 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -0,0 +1,130 @@ +//! The window actions a workspace adds while it is open, and takes off +//! the window again when it goes. + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::{WorkspaceInput, WorkspaceSender}; +use crate::runtime::DeliverInput; +use crate::services::history_availability::HistoryAvailability; + +/// Builds the input an action sends. +type ActionInput = fn() -> WorkspaceInput; + +/// The two actions that need the query database, which opens on a +/// background task. +const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; + +/// Add the workspace's actions to the window's own action map, which GTK +/// exports as `win`, and return their names for `shutdown` to remove. +/// Each one sends its input through `input`, so one activated after the +/// workspace is gone is dropped rather than handled. +pub(super) fn install(window: &adw::ApplicationWindow, input: &WorkspaceSender) -> Vec<&'static str> { + let entries: [(&'static str, ActionInput); 11] = [ + ("open-editor", || WorkspaceInput::NewEditorTab), + ("refresh-page", || WorkspaceInput::RefreshPage), + ("save-changes", || WorkspaceInput::SaveActiveTab), + ("undo-change", || WorkspaceInput::UndoActiveBrowseTab), + ("redo-change", || WorkspaceInput::RedoActiveBrowseTab), + ("reopen-closed-tab", || WorkspaceInput::ReopenClosedTab), + ("open-filter", || WorkspaceInput::ShowFilterDialog), + ("save-query", || WorkspaceInput::SaveActiveQuery), + ("show-history", || WorkspaceInput::ShowHistory), + ("show-saved-queries", || WorkspaceInput::ShowSavedQueries), + (super::health_banner::REVIEW_HOST_KEY, || WorkspaceInput::ReviewHostKey), + ]; + let actions = entries.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(actions); + // Insensitive until the query database reports it is open. + for name in HISTORY_ACTIONS { + set_enabled(window, name, false); + } + // Only a session parked on a host key has one to review. + set_enabled(window, super::health_banner::REVIEW_HOST_KEY, false); + entries.iter().map(|(name, _)| *name).collect() +} + +pub(super) fn set_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool) { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } +} + +/// Flip the actions that need the query database whenever +/// `HistoryService` changes state. The watch is read on the GTK thread, +/// so the actions are only touched from the thread that owns them. +pub(super) fn spawn_history_gate( + window: &adw::ApplicationWindow, + mut availability: tokio::sync::watch::Receiver, +) -> glib::JoinHandle<()> { + let window = window.downgrade(); + glib::spawn_future_local(async move { + loop { + let state = availability.borrow_and_update().clone(); + let Some(window) = window.upgrade() else { + return; + }; + for name in HISTORY_ACTIONS { + set_enabled(&window, name, state.is_ready()); + } + drop(window); + if let Some(failure) = state.failure() { + tracing::warn!(error = failure, "query history stays unavailable"); + } + if availability.changed().await.is_err() { + return; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_history_gate_follows_availability() { + let window = adw::ApplicationWindow::builder().build(); + let (input, _receiver) = crate::runtime::input_queue::(); + install(&window, &input); + let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); + let gate = spawn_history_gate(&window, receiver); + let enabled = || { + window + .lookup_action("show-history") + .is_some_and(|action| action.is_enabled()) + }; + + crate::test_support::drain_main_context(); + assert!(!enabled(), "enabled while the database was still opening"); + + sender.send_replace(HistoryAvailability::Ready); + crate::test_support::drain_main_context(); + assert!(enabled(), "stayed disabled after the database opened"); + + sender.send_replace(HistoryAvailability::Failed("no disk".to_owned())); + crate::test_support::drain_main_context(); + assert!(!enabled(), "stayed enabled after the database failed"); + + gate.abort(); + } + + #[gtk4::test] + fn workspace_actions_live_on_the_windows_own_map() { + let window = adw::ApplicationWindow::builder().build(); + let (input, mut receiver) = crate::runtime::input_queue::(); + + let names = install(&window, &input); + + assert!(names.contains(&"refresh-page")); + WidgetExt::activate_action(&window, "win.open-editor", None).expect("win resolves to the window's own map"); + crate::test_support::drain_main_context(); + assert!(matches!(receiver.try_recv(), Ok(WorkspaceInput::NewEditorTab))); + } +} diff --git a/linux/crates/app/src/ui/app/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs similarity index 97% rename from linux/crates/app/src/ui/app/workspace_persistence.rs rename to linux/crates/app/src/ui/workspace/workspace_persistence.rs index daf39339db..c59bb827c5 100644 --- a/linux/crates/app/src/ui/app/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use std::rc::Rc; use std::time::Duration; -use relm4::adw; -use relm4::adw::prelude::*; -use relm4::gtk::glib; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_session::SessionKey; use uuid::Uuid; @@ -178,13 +178,13 @@ pub(super) fn reopen( mod tests { use std::time::Duration; - use relm4::gtk; + use gtk4 as gtk; use tablepro_storage::DraftId; use super::*; use crate::test_support::{finish_on_main, wait_until}; - use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::{EditorTabSlot, write_workspace_tab_id}; use crate::workspace::WorkspaceLoad; const WAIT: Duration = Duration::from_secs(5); @@ -204,7 +204,11 @@ mod tests { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let root = tempfile::tempdir().expect("tempdir"); - let store = WorkspaceStore::start(root.path().join("workspaces"), &runtime.tasks()); + let store = WorkspaceStore::start( + root.path().join("workspaces"), + tablepro_storage::DraftStore::new(root.path().join("drafts")), + &runtime.tasks(), + ); Self { runtime, settings: crate::test_support::MemorySettings::new(), @@ -300,7 +304,7 @@ mod tests { write_workspace_tab_id(&page, id); fixture.tabs.borrow_mut().insert( id, - WorkspaceTab::Structure(crate::ui::app::StructureTabSlot { + WorkspaceTab::Structure(crate::ui::workspace::StructureTabSlot { id, structure, page, diff --git a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs new file mode 100644 index 0000000000..0769c9f677 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs @@ -0,0 +1,46 @@ +use libadwaita as adw; + +/// AdwTabView runs its shortcuts in a capture-phase, global controller, +/// so Ctrl+Home and Ctrl+End would switch tabs before the SQL editor or +/// a grid saw them. GNOME Text Editor removes the same four. +const CHILD_NAVIGATION_KEYS: adw::TabViewShortcuts = adw::TabViewShortcuts::CONTROL_HOME + .union(adw::TabViewShortcuts::CONTROL_END) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_HOME) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_END); + +pub(super) fn new_workspace_tab_view() -> adw::TabView { + let view = adw::TabView::new(); + view.remove_shortcuts(CHILD_NAVIGATION_KEYS); + view +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn workspace_tab_view_leaves_buffer_navigation_keys_to_children() { + adw::init().expect("libadwaita initialises under the test backend"); + + let shortcuts = new_workspace_tab_view().shortcuts(); + + assert!( + !shortcuts.intersects(CHILD_NAVIGATION_KEYS), + "the tab view still takes {:?}", + shortcuts & CHILD_NAVIGATION_KEYS + ); + let switching = adw::TabViewShortcuts::CONTROL_TAB + | adw::TabViewShortcuts::CONTROL_SHIFT_TAB + | adw::TabViewShortcuts::CONTROL_PAGE_UP + | adw::TabViewShortcuts::CONTROL_PAGE_DOWN + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_UP + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_DOWN + | adw::TabViewShortcuts::ALT_DIGITS + | adw::TabViewShortcuts::ALT_ZERO; + assert!( + shortcuts.contains(switching), + "a tab switching key was lost: {:?}", + switching - shortcuts + ); + } +} diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs similarity index 84% rename from linux/crates/app/src/ui/app/workspace_tabs.rs rename to linux/crates/app/src/ui/workspace/workspace_tabs.rs index 3ec367f6e5..df43371e5a 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -1,17 +1,16 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_session::SessionKey; use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -use crate::services::from_session::FromSession; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; @@ -20,20 +19,22 @@ use crate::workspace::{ }; use super::workspace_persistence::reopen; +use super::workspace_tab_view::new_workspace_tab_view; use super::{ - App, AppMsg, CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceTab, - read_workspace_tab_id, write_workspace_tab_id, + CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceInput, WorkspaceModel, + WorkspaceSender, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, }; +use crate::runtime::DeliverInput; -impl App { +impl WorkspaceModel { /// Builds the unified AdwTabOverview tree once per connection. /// Idempotent via `workspace_root_added`. - pub(super) fn ensure_workspace_root(&mut self, sender: ComponentSender) { + pub(super) fn ensure_workspace_root(&mut self) { if self.workspace_root_added.get() { return; } if self.workspace_root.is_none() { - self.build_workspace_root(sender); + self.build_workspace_root(); } if let Some(root) = self.workspace_root.as_ref() && self.workspace_outer_stack.child_by_name("tabs").is_none() @@ -43,9 +44,9 @@ impl App { self.workspace_root_added.set(true); } - fn build_workspace_root(&mut self, sender: ComponentSender) { + fn build_workspace_root(&mut self) { let settings = self.settings.clone(); - let history = self.history.store(); + let history = Some(self.history.clone()); let tasks = self.tasks.clone(); let session = self.context.active.clone(); let hold = self.context.hold.clone(); @@ -62,10 +63,10 @@ impl App { let create_tab = overview_editor_tab( self.workspace_tabs.clone(), editor_init, - sender.input_sender().clone(), + self.input.clone(), self.toast_overlay.downgrade(), ); - let chrome = build_workspace_chrome(sender.input_sender(), create_tab); + let chrome = build_workspace_chrome(&self.input, create_tab); self.window_tabs.set_view(Some(&chrome.view)); self.workspace_root = Some(chrome.overview); self.workspace_tab_view = Some(chrome.view); @@ -73,11 +74,8 @@ impl App { /// Read the saved tabs of the session just adopted, and the editor /// text that goes with them, off the GTK thread. - pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey, sender: ComponentSender) { - let Some(session) = self.context.active.get() else { - return; - }; - let origin = Arc::downgrade(&session); + pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey) { + let input = self.input.clone(); let loading = self.storage.workspaces().load(&key); let store = self.drafts.store().clone(); let scope = WorkspaceFileName::for_key(&key).draft_scope(); @@ -90,10 +88,7 @@ impl App { }; let reading = tasks.spawn_blocking_task(move || RestoredDrafts::read_blocking(&store, &scope, referenced)); let drafts = reading.await.unwrap_or_default(); - sender.input(AppMsg::WorkspaceLoaded(FromSession::new( - origin, - LoadedWorkspace { key, load, drafts }, - ))); + input.deliver(WorkspaceInput::WorkspaceLoaded(LoadedWorkspace { key, load, drafts })); }); } @@ -101,14 +96,14 @@ impl App { /// /// Nothing is written for the session before this: a write would /// replace the saved tabs before they were read. - pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace, sender: ComponentSender) { + pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace) { let LoadedWorkspace { key, load, drafts } = loaded; let RestoredDrafts { mut texts, unreadable } = drafts; match load { WorkspaceLoad::Restored(record) => { self.retain_drafts(&key, saved_drafts(&record)); if let Some(view) = self.workspace_tab_view.clone() { - reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts, sender.clone())); + reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts)); } } WorkspaceLoad::Empty => self.retain_drafts(&key, std::iter::empty()), @@ -148,12 +143,7 @@ impl App { self.drafts.retain(&scope, keep); } - fn reopen_tab( - &mut self, - tab: TabRecord, - drafts: &mut HashMap, - sender: ComponentSender, - ) -> Option { + fn reopen_tab(&mut self, tab: TabRecord, drafts: &mut HashMap) -> Option { match tab { TabRecord::Table { schema, @@ -167,16 +157,14 @@ impl App { offset, page_size, sort.map(|sort| (sort.column, sort.ascending)), - sender, ), - TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table, sender), + TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table), // Its text is on disk already, or there is none to keep, so // nothing is written until the user types. TabRecord::Editor { draft } => self.append_editor_tab_with_draft( Some(drafts.remove(&draft).unwrap_or_default()), draft, DraftWrite::OnChange, - sender, ), } } @@ -192,15 +180,12 @@ impl App { /// Move the unreadable saved tabs aside, so the open ones can be /// saved in their place. - pub(super) fn on_reset_workspace_layout(&self, sender: ComponentSender) { - let (Some(key), Some(session)) = (self.session_key().cloned(), self.context.active.get()) else { - return; - }; - let origin = Arc::downgrade(&session); - let resetting = self.storage.workspaces().reset(&key); + pub(super) fn on_reset_workspace_layout(&self) { + let resetting = self.storage.workspaces().reset(&self.key); + let input = self.input.clone(); glib::spawn_future_local(async move { let outcome = resetting.await; - sender.input(AppMsg::WorkspaceLayoutReset(FromSession::new(origin, outcome))); + input.deliver(WorkspaceInput::WorkspaceLayoutReset(outcome)); }); } @@ -223,8 +208,8 @@ impl App { /// flow. Edit-mode is no longer reachable through this helper; /// use `append_table_tab` for the unified Browse + Structure /// view of an existing table. - pub(super) fn append_new_structure_tab(&mut self, schema: Option, sender: ComponentSender) { - self.ensure_workspace_root(sender.clone()); + pub(super) fn append_new_structure_tab(&mut self, schema: Option) { + self.ensure_workspace_root(); let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -240,7 +225,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_structure(&structure, tab_id, sender.input_sender()); + connect_structure(&structure, tab_id, &self.input); let page = tab_view.append(&structure); page.set_title(&crate::i18n::gettext("New Table")); @@ -279,9 +264,8 @@ impl App { offset: u64, page_size: u64, sort: Option<(usize, bool)>, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let connection_id = self.context.active.metadata().map(|metadata| metadata.id); @@ -301,7 +285,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_browse(&browse, tab_id, sender.input_sender()); + connect_browse(&browse, tab_id, &self.input); let page = tab_view.append(&browse); let label = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); @@ -337,9 +321,8 @@ impl App { &mut self, schema: Option, table: String, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let session = self.context.active.get()?; @@ -352,7 +335,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_structure(&structure, tab_id, sender.input_sender()); + connect_structure(&structure, tab_id, &self.input); let page = tab_view.append(&structure); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); @@ -380,8 +363,8 @@ impl App { } /// Public entry: append an Editor tab with optional initial query. - pub(super) fn append_editor_tab(&mut self, initial_query: Option, sender: ComponentSender) { - self.append_editor_tab_with_draft(initial_query, DraftId::new(), DraftWrite::Now, sender); + pub(super) fn append_editor_tab(&mut self, initial_query: Option) { + self.append_editor_tab_with_draft(initial_query, DraftId::new(), DraftWrite::Now); } /// Append an Editor tab that writes to a given draft, so a restored @@ -391,9 +374,8 @@ impl App { initial_query: Option, draft: DraftId, write: DraftWrite, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); // Such a tab shows the editor's sample query, which names nothing. @@ -401,13 +383,13 @@ impl App { let editor = SqlEditor::new(SqlEditorInit { initial_query, settings: self.settings.clone(), - history: self.history.store(), + history: Some(self.history.clone()), tasks: self.tasks.clone(), session: self.context.active.clone(), hold: self.context.hold.clone(), close_timeout: self.context.close_timeout(), }); - connect_editor(&editor, tab_id, sender.input_sender(), &self.toast_overlay.downgrade()); + connect_editor(&editor, tab_id, &self.input, &self.toast_overlay.downgrade()); let page = tab_view.append(&editor); let preview = EditorPreview::read(&editor.buffer()); let label = if opened_empty || preview.is_blank() { @@ -443,9 +425,7 @@ impl App { /// The directory this session's editor drafts live in. fn draft_scope(&self) -> DraftScope { - self.session_key() - .map(|key| WorkspaceFileName::for_key(key).draft_scope()) - .unwrap_or_default() + WorkspaceFileName::for_key(&self.key).draft_scope() } /// Tear down a tab without prompting. Internal helper called once @@ -491,18 +471,24 @@ impl App { self.refresh_inhibitor(); } - pub(super) fn close_active_workspace_tab(&mut self, sender: ComponentSender) { - let Some(tab_view) = self.workspace_tab_view.as_ref() else { - // No tabs at all (disconnected) → close window. - self.window.close(); - return; - }; - let Some(page) = tab_view.selected_page() else { - self.window.close(); - return; - }; - tab_view.close_page(&page); - let _ = sender; + /// With no tab open, Ctrl+W closes the window, as in GNOME Text + /// Editor. + pub(super) fn close_active_workspace_tab(&mut self) { + match self.workspace_tab_view.as_ref().and_then(adw::TabView::selected_page) { + Some(page) => { + if let Some(tab_view) = self.workspace_tab_view.as_ref() { + tab_view.close_page(&page); + } + } + // The window's close asks this workspace what it would + // lose, which it cannot answer in the middle of handling + // this, so the close waits for the main loop. + None => { + if let Some(window) = self.window() { + glib::idle_add_local_once(move || window.close()); + } + } + } } /// Close every workspace tab except `keep_id`. Each close goes @@ -510,7 +496,7 @@ impl App { /// trigger the unsaved-changes alert; the user can cancel the /// dialog and the tab stays open while the others continue /// closing in the background. - pub(super) fn close_other_workspace_tabs(&mut self, keep_id: Uuid, _sender: ComponentSender) { + pub(super) fn close_other_workspace_tabs(&mut self, keep_id: Uuid) { let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -533,7 +519,7 @@ impl App { /// Close every workspace tab whose display position is greater /// than the targeted one. Useful for rapidly trimming a long /// session of throwaway tabs the user accumulated. - pub(super) fn close_workspace_tabs_to_right(&mut self, anchor_id: Uuid, _sender: ComponentSender) { + pub(super) fn close_workspace_tabs_to_right(&mut self, anchor_id: Uuid) { let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -565,13 +551,7 @@ impl App { /// A sidebar click never replaces the active tab: AdwTabView's /// close animation overlapped the append and flashed both tabs. - pub(super) fn dispatch_select_table( - &mut self, - schema: Option, - name: String, - open_mode: OpenMode, - sender: ComponentSender, - ) { + pub(super) fn dispatch_select_table(&mut self, schema: Option, name: String, open_mode: OpenMode) { if matches!(open_mode, OpenMode::SwitchOrAppend) { // Multiple tabs may be open for the same (schema, table) // pair (Ctrl+click duplicates). Prefer the currently- @@ -602,7 +582,7 @@ impl App { // for the same table opens as its own separate tab via the // sidebar right-click "Edit Structure" action. let page_size = u64::from(self.settings.default_page_size()); - self.append_table_tab(schema, name, 0, page_size, None, sender); + self.append_table_tab(schema, name, 0, page_size, None); } /// Ctrl+F / Filter button: toggle the inline filter strip on the @@ -641,8 +621,7 @@ impl App { dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let workspace_tabs = self.workspace_tabs.clone(); - dialog.connect_response(None, move |dlg: &adw::AlertDialog, response: &str| { - dlg.close(); + dialog.connect_response(None, move |_, response| { if response != "discard" { return; } @@ -655,7 +634,7 @@ impl App { browse.send(BrowseTabInput::DiscardAndRefresh); } }); - dialog.present(Some(&self.window)); + dialog.present(self.window().as_ref()); } /// Write the tab list a moment after the last change. @@ -664,7 +643,7 @@ impl App { } } -impl App { +impl WorkspaceModel { /// Single handler for `WorkspaceTabsChanged`. Persists tab state, /// refreshes the window title (so tab switches update the subtitle), /// and syncs the sidebar selection to the active Browse tab's table. @@ -757,13 +736,13 @@ impl App { self.refresh_window_title(); } - pub(super) fn teardown_workspace_tabs(&mut self) { - self.flush_workspace_state(); - self.cancel_all_editor_runs(); - // The pending edits name rows of the connection going away, so - // they cannot be saved after it. + /// Stop every tab and let go of the tab view. The pending edits + /// name rows of the connection going away, so they cannot be saved + /// after it. + pub(super) fn close_tabs(&mut self) { for tab in self.workspace_tabs.borrow().values() { match tab { + WorkspaceTab::Editor(slot) => slot.editor.shutdown(), WorkspaceTab::Structure(slot) => { slot.structure.shutdown(); self.structure_trackers.unregister(slot.id); @@ -772,54 +751,17 @@ impl App { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); } - WorkspaceTab::Editor(_) => {} } } if let Some(root) = self.workspace_root.take() - && self.workspace_root_added.get() + && self.workspace_root_added.replace(false) + && self.workspace_outer_stack.child_by_name("tabs").is_some() { - if self.workspace_outer_stack.child_by_name("tabs").is_some() { - self.workspace_outer_stack.remove(&root); - } - self.workspace_root_added.set(false); + self.workspace_outer_stack.remove(&root); } - self.workspace_outer_stack.set_visible_child_name("empty"); self.window_tabs.set_view(None); self.workspace_tab_view = None; self.workspace_tabs.borrow_mut().clear(); - self.refresh_inhibitor(); - } - - /// Write the tabs and the editor text as they are now, before the - /// teardown frees them. The process is held until the writes land, - /// so quitting right after a disconnect does not lose them. - fn flush_workspace_state(&self) { - let tabs = self.workspace.detach(); - let drafts = self.drafts.flush_all(); - let writer = self.drafts.clone(); - let hold = self.context.hold.acquire(); - glib::spawn_future_local(async move { - for failure in drafts.await { - tracing::warn!(failure, "an editor draft was not saved"); - } - writer.release_closed(); - if let Some(tabs) = tabs - && let Err(error) = tabs.await - { - tracing::debug!(%error, "the open tabs were not saved"); - } - drop(hold); - }); - } - - /// Stop every editor run and close each editor tab's connection, - /// for a disconnect that takes the tabs with it. - pub(super) fn cancel_all_editor_runs(&self) { - for tab in self.workspace_tabs.borrow().values() { - if let WorkspaceTab::Editor(s) = tab { - s.editor.shutdown(); - } - } } /// Forward a per-tab Browse input to the right slot. Routes to the @@ -866,14 +808,14 @@ impl App { } } - pub(super) fn on_replace_active_tab_query(&mut self, text: String, sender: ComponentSender) { + pub(super) fn on_replace_active_tab_query(&mut self, text: String) { if let Some(id) = self.selected_workspace_tab_id() && let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) { slot.editor.replace_query(&text); return; } - self.append_editor_tab(Some(text), sender); + self.append_editor_tab(Some(text)); } /// The SQL in the editor tab the user is looking at, or `None` @@ -905,10 +847,10 @@ struct WorkspaceChrome { /// Build the tab bar, view and overview. `create_tab` makes the page the /// overview's own new-tab button asks for, which it needs back at once. fn build_workspace_chrome( - input: &relm4::Sender, + input: &WorkspaceSender, create_tab: impl Fn(&adw::TabView) -> adw::TabPage + 'static, ) -> WorkspaceChrome { - let tab_view = adw::TabView::new(); + let tab_view = new_workspace_tab_view(); let tab_bar = adw::TabBar::builder() .view(&tab_view) .autohide(false) @@ -940,14 +882,14 @@ fn build_workspace_chrome( .build(); new_query_button.add_css_class("flat"); let new_tab_input = input.clone(); - new_query_button.connect_clicked(move |_| new_tab_input.emit(AppMsg::NewEditorTab)); + new_query_button.connect_clicked(move |_| new_tab_input.deliver(WorkspaceInput::NewEditorTab)); tab_bar.set_end_action_widget(Some(&new_query_button)); - // A tab with unsaved work asks first, so the App finishes the close. + // A tab with unsaved work asks first, so the workspace finishes the close. let close_input = input.clone(); tab_view.connect_close_page(move |_view, page| { if let Some(id) = read_workspace_tab_id(page) { - close_input.emit(AppMsg::WorkspaceTabClosed(id)); + close_input.deliver(WorkspaceInput::WorkspaceTabClosed(id)); } glib::Propagation::Stop }); @@ -968,7 +910,7 @@ fn build_workspace_chrome( if let Some(page) = target && let Some(id) = read_workspace_tab_id(&page) { - close_others_input.emit(AppMsg::CloseOtherWorkspaceTabs(id)); + close_others_input.deliver(WorkspaceInput::CloseOtherWorkspaceTabs(id)); } }); action_group.add_action(&close_others_action); @@ -980,7 +922,7 @@ fn build_workspace_chrome( if let Some(page) = target && let Some(id) = read_workspace_tab_id(&page) { - close_right_input.emit(AppMsg::CloseWorkspaceTabsToRight(id)); + close_right_input.deliver(WorkspaceInput::CloseWorkspaceTabsToRight(id)); } }); action_group.add_action(&close_right_action); @@ -999,9 +941,9 @@ fn build_workspace_chrome( // A drag reorder changes only `pages`, and it has to persist too. let selected_input = input.clone(); - tab_view.connect_selected_page_notify(move |_| selected_input.emit(AppMsg::WorkspaceTabsChanged)); + tab_view.connect_selected_page_notify(move |_| selected_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); let pages_input = input.clone(); - tab_view.connect_pages_notify(move |_| pages_input.emit(AppMsg::WorkspaceTabsChanged)); + tab_view.connect_pages_notify(move |_| pages_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); inner.append(&tab_bar); @@ -1030,7 +972,7 @@ fn build_workspace_chrome( fn overview_editor_tab( workspace_tabs: Rc>>, editor_init: impl Fn() -> SqlEditorInit + 'static, - input: relm4::Sender, + input: WorkspaceSender, toasts: glib::WeakRef, ) -> impl Fn(&adw::TabView) -> adw::TabPage + 'static { move |tab_view| { @@ -1051,7 +993,7 @@ fn overview_editor_tab( draft: DraftId::new(), }; workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); - input.emit(AppMsg::WorkspaceTabsChanged); + input.deliver(WorkspaceInput::WorkspaceTabsChanged); page } } @@ -1114,11 +1056,8 @@ fn push_closed_tab( q.push_back(descriptor); } -impl App { - pub(super) fn on_reopen_closed_tab(&mut self, sender: ComponentSender) { - if !self.connected { - return; - } +impl WorkspaceModel { + pub(super) fn on_reopen_closed_tab(&mut self) { let descriptor = self.closed_tabs_stack.borrow_mut().pop_back(); let Some(descriptor) = descriptor else { // Without a toast the shortcut would read as broken. @@ -1128,7 +1067,7 @@ impl App { match descriptor { ClosedTabDescriptor::Editor { query } => { let initial = if query.is_empty() { None } else { Some(query) }; - self.append_editor_tab(initial, sender); + self.append_editor_tab(initial); } ClosedTabDescriptor::Table { schema, @@ -1137,17 +1076,13 @@ impl App { page_size, sort, } => { - self.append_table_tab(schema, table, offset, page_size, sort, sender); + self.append_table_tab(schema, table, offset, page_size, sort); } ClosedTabDescriptor::Structure { schema, table } => { - self.append_existing_structure_tab(schema, table, sender); + self.append_existing_structure_tab(schema, table); } } } - - pub(super) fn clear_closed_tabs_stack(&self) { - self.closed_tabs_stack.borrow_mut().clear(); - } } const PREVIEW_CHARS: i32 = 200; @@ -1212,69 +1147,74 @@ fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ }) } -/// The structure tab's signals, as this window's inputs for the tab. -fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Sender) { +/// The structure tab's signals, as the workspace's inputs for the tab. +fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &WorkspaceSender) { let dirty = input.clone(); - structure.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::StructureTabDirtyChanged(tab_id, is_dirty))); + structure.connect_dirty_changed(move |_, is_dirty| { + dirty.deliver(WorkspaceInput::StructureTabDirtyChanged(tab_id, is_dirty)) + }); let save = input.clone(); - structure.connect_save_requested(move |_| save.emit(AppMsg::SaveTab(tab_id))); + structure.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); let stop = input.clone(); - structure.connect_stop_save_requested(move |_| stop.emit(AppMsg::StopSaving(tab_id))); + structure.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); let drop_table = input.clone(); structure.connect_drop_table_requested(move |_, schema, table| { - drop_table.emit(AppMsg::DropTablePrompt { schema, table }); + drop_table.deliver(WorkspaceInput::DropTablePrompt { schema, table }); }); let toast = input.clone(); - structure.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + structure.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let alert = input.clone(); - structure.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); + structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); } -/// The browse tab's signals, as this window's inputs for the tab. -fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { +/// The browse tab's signals, as the workspace's inputs for the tab. +fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &WorkspaceSender) { let state = input.clone(); - browse.connect_state_changed(move |_| state.emit(AppMsg::WorkspaceTabsChanged)); + browse.connect_state_changed(move |_| state.deliver(WorkspaceInput::WorkspaceTabsChanged)); let clipboard = input.clone(); - browse.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + browse.connect_copy_to_clipboard(move |_, text| clipboard.deliver(WorkspaceInput::CopyToClipboard(text))); let export = input.clone(); browse.connect_export_results(move |_, ExportRequest { result, name, target }| { - export.emit(AppMsg::ExportResults { result, name, target }); + export.deliver(WorkspaceInput::ExportResults { result, name, target }); }); let alert = input.clone(); - browse.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); + browse.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); let toast = input.clone(); - browse.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + browse.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let dirty = input.clone(); - browse.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::BrowseTabDirtyChanged(tab_id, is_dirty))); + browse.connect_dirty_changed(move |_, is_dirty| { + dirty.deliver(WorkspaceInput::BrowseTabDirtyChanged(tab_id, is_dirty)) + }); let save = input.clone(); - browse.connect_save_requested(move |_| save.emit(AppMsg::SaveTab(tab_id))); + browse.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); let stop = input.clone(); - browse.connect_stop_save_requested(move |_| stop.emit(AppMsg::StopSaving(tab_id))); + browse.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); } -/// Forward an editor tab's signals into the window. The toast overlay -/// is the window's, so a replaced query's Undo button lives there. +/// Forward an editor tab's signals into the workspace. The toast +/// overlay is the workspace's, so a replaced query's Undo button lives +/// there. fn connect_editor( editor: &SqlEditor, tab_id: Uuid, - input: &relm4::Sender, + input: &WorkspaceSender, toasts: &glib::WeakRef, ) { let run_state = input.clone(); editor.connect_run_state_changed(move |_, running| { - run_state.emit(AppMsg::EditorTabRunStateChanged(tab_id, running)); + run_state.deliver(WorkspaceInput::EditorTabRunStateChanged(tab_id, running)); }); let content = input.clone(); - editor.connect_content_changed(move |_| content.emit(AppMsg::EditorTabContentChanged(tab_id))); + editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); let transaction = input.clone(); - editor.connect_transaction_changed(move |_, _| transaction.emit(AppMsg::EditorTransactionChanged)); + editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); let clipboard = input.clone(); - editor.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + editor.connect_copy_to_clipboard(move |_, text| clipboard.deliver(WorkspaceInput::CopyToClipboard(text))); let toast = input.clone(); - editor.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let export = input.clone(); editor.connect_export_results(move |_, result, name| { - export.emit(AppMsg::ExportResults { + export.deliver(WorkspaceInput::ExportResults { result, name, target: None, @@ -1324,7 +1264,7 @@ mod tests { settings: &crate::test_support::MemorySettings, tabs: &Rc>>, toasts: &adw::ToastOverlay, - input: &relm4::Sender, + input: &WorkspaceSender, ) -> WorkspaceChrome { adw::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); @@ -1349,7 +1289,7 @@ mod tests { let settings = crate::test_support::MemorySettings::new(); let tabs = Rc::new(RefCell::new(HashMap::new())); let toasts = adw::ToastOverlay::new(); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); toasts.set_child(Some(&chrome.overview)); @@ -1380,7 +1320,7 @@ mod tests { let settings = crate::test_support::MemorySettings::new(); let tabs = Rc::new(RefCell::new(HashMap::new())); let toasts = adw::ToastOverlay::new(); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); let stack = gtk::Stack::new(); stack.add_named(&chrome.overview, Some("tabs")); @@ -1415,7 +1355,7 @@ mod tests { #[gtk4::test] fn overview_button_shows_from_the_second_tab() { adw::init().expect("libadwaita initialises under the test backend"); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = build_workspace_chrome(&input, blank_page); let button = find_by_action_name(&chrome.overview, "overview.open").expect("the overview button"); assert!(!button.is_visible(), "shown with no tabs"); @@ -1472,7 +1412,7 @@ mod tests { }); let toasts = adw::ToastOverlay::new(); toasts.set_child(Some(&editor)); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); let overlay = toasts.downgrade(); diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs index 8c4732271c..96e859fc26 100644 --- a/linux/crates/app/src/workspace/close_guard.rs +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -53,16 +53,11 @@ impl CloseGuard { SaveChoice::Cancel => return CloseDecision::Cancelled, SaveChoice::Proceed { save, discard } => { handle.discard(&discard, false); - let refused: Vec<(Uuid, SaveRejection)> = save - .iter() - .zip(handle.save(&save)) - .filter_map(|(tab, start)| match start { - SaveStart::Rejected(rejection) => Some((*tab, rejection)), - SaveStart::Started(_) | SaveStart::NothingToSave => None, - }) - .collect(); + let (refused, declined) = start_saves(parent, handle, &save).await; if !refused.is_empty() { show_refused(parent, &tabs, &refused); + } + if declined || !refused.is_empty() { return CloseDecision::Cancelled; } } @@ -72,6 +67,28 @@ impl CloseGuard { } } +/// Start each save in turn, and give back the ones refused and whether +/// the user declined a review. A declined review is the user keeping the +/// window open, not a save that failed, so the saves after it are not +/// started and their reviews never shown. +async fn start_saves( + parent: >k::Widget, + handle: &WorkspaceHandle, + tabs: &[Uuid], +) -> (Vec<(Uuid, SaveRejection)>, bool) { + let mut refused = Vec::new(); + for tab in tabs { + for start in handle.save(parent, &[*tab]).await { + match start { + SaveStart::Rejected(SaveRejection::ReviewDeclined) => return (refused, true), + SaveStart::Rejected(rejection) => refused.push((*tab, rejection)), + SaveStart::Started(_) | SaveStart::NothingToSave => {} + } + } + } + (refused, false) +} + fn ids(tabs: &[DirtyTab]) -> Vec { tabs.iter().map(|tab| tab.id).collect() } @@ -108,17 +125,11 @@ mod tests { use super::*; use crate::test_support::wait_until; use crate::workspace::dialog_response::{answer, visible_alert}; - use crate::workspace::test_workspace::{WAIT, Workspace}; + use crate::workspace::test_workspace::{WAIT, Workspace, edited_table, window}; use crate::workspace::{PendingState, WorkspaceEvent}; - fn window() -> adw::Window { - adw::init().expect("libadwaita initialises under the test backend"); - if let Some(settings) = gtk::Settings::default() { - settings.set_gtk_enable_animations(false); - } - let window = adw::Window::new(); - window.present(); - window + fn without_n(draft: &mut tablepro_core::ddl::TableDraft) { + draft.columns.retain(|column| column.name != "n"); } fn guard(window: &adw::Window, handle: &WorkspaceHandle, scope: CloseScope) -> Rc>> { @@ -235,10 +246,7 @@ mod tests { }); let workspace = Workspace::open(script); let window = window(); - assert!(matches!( - workspace.handle.save(&[workspace.tab]).as_slice(), - [SaveStart::Started(_)] - )); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); let decision = guard(&window, &workspace.handle, CloseScope::Window); wait_until(WAIT, || { @@ -297,10 +305,7 @@ mod tests { }))); let workspace = Workspace::open(script); let window = window(); - assert!(matches!( - workspace.handle.save(&[workspace.tab]).as_slice(), - [SaveStart::Started(_)] - )); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); let decision = guard(&window, &workspace.handle, CloseScope::Window); answer_next(&window, "Saving Changes", "stop", None); @@ -356,4 +361,56 @@ mod tests { assert_eq!(decided(&decision), CloseDecision::Approved); assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); } + + #[gtk4::test] + fn close_guard_save_also_reviews() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + let workspace = Workspace::open_structure(script, edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "apply", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.structure.pending_state(), PendingState::Clean); + } + + #[gtk4::test] + fn a_declined_review_keeps_the_window_open_without_an_alert() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "cancel", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + crate::test_support::drain_main_context(); + assert!( + visible_alert(&window).is_none(), + "a declined review was reported as a failure" + ); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + } + + #[gtk4::test] + fn a_declined_review_stops_the_saves_after_it() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let second = workspace.add_structure_tab("u", edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "cancel", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + crate::test_support::drain_main_context(); + assert!(visible_alert(&window).is_none(), "the next tab's review opened"); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(second.pending_state(), PendingState::Unsaved); + } } diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index 678290567e..1e4f03fa85 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -9,6 +9,9 @@ use uuid::Uuid; use super::{CloseDecision, CloseGuard, CloseScope, CloseStep, WorkspaceHandle}; +/// Hears how the window's close ended. +type CloseWaiter = Box; + /// Where the window's own close stands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WindowClose { @@ -25,6 +28,11 @@ enum WindowClose { pub(crate) struct CloseGuards { turn: futures::lock::Mutex<()>, window: Cell, + /// Close requests that reached the window's handler, so a caller can + /// tell when a dialog over the window took one instead. + requests: Cell, + /// Callers waiting to hear how the window's close ended. + waiting: RefCell>, /// Open transactions the user agreed to lose in the current /// disconnect or switch. A switch asks again once its connection is /// ready, about what changed while it connected, but not about these. @@ -36,6 +44,8 @@ impl CloseGuards { Rc::new(Self { turn: futures::lock::Mutex::new(()), window: Cell::new(WindowClose::Open), + requests: Cell::new(0), + waiting: RefCell::new(Vec::new()), teardown_acknowledged: RefCell::new(HashSet::new()), }) } @@ -53,23 +63,56 @@ impl CloseGuards { /// The window closes only once its prompts approve; `on_close` runs /// as it does. A close request while they decide is dropped, since - /// their dialog is already on screen. + /// their dialog is already on screen. `handle` gives the workspace + /// the window shows at the time, and a window with none closes at + /// once. pub(crate) fn connect_window>( self: &Rc, window: &W, - handle: WorkspaceHandle, + handle: impl Fn() -> Option + 'static, on_close: impl Fn(&W) + 'static, ) { let guards = self.clone(); window.connect_close_request(move |window| { - if !guards.window_may_close(window.upcast_ref(), &handle) { + guards.requests.set(guards.requests.get() + 1); + if let Some(handle) = handle() + && !guards.window_may_close(window.upcast_ref(), &handle) + { return glib::Propagation::Stop; } on_close(window); + guards.settle(CloseDecision::Approved); glib::Propagation::Proceed }); } + /// Close the window through its prompts and tell `decided` how it + /// ended. A prompt already on screen answers for this request too. + pub(crate) fn request_window_close( + &self, + window: &impl IsA, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + self.waiting.borrow_mut().push(Box::new(decided)); + if self.window.get() == WindowClose::Asking { + return; + } + let before = self.requests.get(); + window.close(); + if self.requests.get() == before { + // libadwaita takes a close request over one of its dialogs as + // dismissing the dialog, and the window stays open. + self.settle(CloseDecision::Cancelled); + } + } + + fn settle(&self, decision: CloseDecision) { + let waiting = self.waiting.take(); + for decided in waiting { + decided(decision); + } + } + fn window_may_close(self: &Rc, window: >k::Window, handle: &WorkspaceHandle) -> bool { match self.window.get() { WindowClose::Asking => return false, @@ -90,7 +133,10 @@ impl CloseGuards { guards.window.set(WindowClose::Approved); window.close(); } - CloseDecision::Cancelled => guards.window.set(WindowClose::Open), + CloseDecision::Cancelled => { + guards.window.set(WindowClose::Open); + guards.settle(CloseDecision::Cancelled); + } } }); false @@ -178,7 +224,8 @@ mod tests { let window = window(); let closes = Rc::new(Cell::new(0)); let sink = closes.clone(); - guards.connect_window(&window, workspace.handle.clone(), move |_| sink.set(sink.get() + 1)); + let handle = workspace.handle.clone(); + guards.connect_window(&window, move || Some(handle.clone()), move |_| sink.set(sink.get() + 1)); (window, closes) } @@ -229,6 +276,20 @@ mod tests { assert!(visible_alert(&window).is_none()); } + #[gtk4::test] + fn a_window_with_no_workspace_closes_without_asking() { + let guards = CloseGuards::new(); + let window = window(); + let closes = Rc::new(Cell::new(0)); + let sink = closes.clone(); + guards.connect_window(&window, || None, move |_| sink.set(sink.get() + 1)); + + window.close(); + + assert_eq!(closes.get(), 1); + assert!(visible_alert(&window).is_none()); + } + /// libadwaita takes a close request over a dialog as dismissing the /// dialog, which keeps the window open. #[gtk4::test] @@ -268,6 +329,83 @@ mod tests { wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); } + #[gtk4::test] + fn a_requested_close_of_a_clean_window_is_approved_at_once() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let (decision, sink) = decision_sink(); + + guards.request_window_close(&window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_requested_close_hears_the_prompts_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + let (kept, sink) = decision_sink(); + guards.request_window_close(&window, sink); + let first = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || kept.get().is_some()).expect("a decision"); + assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); + assert!(window.is_visible() && closes.get() == 0); + + let (approved, sink) = decision_sink(); + guards.request_window_close(&window, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert_eq!(approved.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + /// A request that arrives while the user's own close is asking is + /// answered by that prompt, rather than dismissing it. + #[gtk4::test] + fn a_request_during_the_prompt_waits_for_its_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + window.close(); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the prompt"); + let shown = visible_alert(&window); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&window, sink); + drain_main_context(); + assert_eq!(visible_alert(&window), shown, "the request dismissed the prompt"); + assert_eq!(decision.get(), None); + answer_next(&window, "Save Changes?", "discard", None); + + wait_until(WAIT, || decision.get().is_some()).expect("a decision"); + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_close_a_dialog_takes_is_reported_cancelled() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let other = adw::AlertDialog::new(Some("Elsewhere"), None); + other.add_response("close", "Close"); + other.present(Some(&window)); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the dialog"); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Cancelled)); + assert_eq!(closes.get(), 0); + assert!(window.is_visible()); + } + #[gtk4::test] fn a_teardown_proceeds_only_when_approved() { let workspace = Workspace::open(ConnectionScript::default()); diff --git a/linux/crates/app/src/workspace/close_reason.rs b/linux/crates/app/src/workspace/close_reason.rs new file mode 100644 index 0000000000..77803a5d4d --- /dev/null +++ b/linux/crates/app/src/workspace/close_reason.rs @@ -0,0 +1,11 @@ +use futures::channel::oneshot; + +use super::CloseDecision; + +/// Why a window is asked to close, and who hears how it ended. +#[derive(Debug)] +pub enum CloseReason { + /// The application is quitting. A dropped sender means the window + /// went without deciding. + Quit(oneshot::Sender), +} diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index f02541e19d..d42c320a21 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -42,26 +42,20 @@ pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> /// Answer the alert `window` shows with `response` by clicking its /// button, so the signals come in the order a user's click sends them. #[cfg(test)] -pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { - let Some(dialog) = visible_alert(window) else { - return false; - }; - let label = dialog.response_label(response); - let Some(button) = crate::test_support::descendants(&dialog) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.label().as_deref() == Some(label.as_str())) - else { - return false; - }; - button.emit_clicked(); - true +pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { + visible_alert(window).is_some_and(|dialog| crate::test_support::click_response(&dialog, response)) } -/// The alert `window` shows, if any. +/// The alert `window` shows, if any. `window` is an `AdwWindow` or an +/// `AdwApplicationWindow`, the two that host dialogs. #[cfg(test)] -pub(crate) fn visible_alert(window: &impl IsA) -> Option { - window.as_ref().visible_dialog().and_downcast::() +pub(crate) fn visible_alert(window: &impl IsA) -> Option { + let widget = window.as_ref(); + let dialog = match widget.downcast_ref::() { + Some(window) => window.visible_dialog(), + None => widget.downcast_ref::()?.visible_dialog(), + }; + dialog.and_downcast::() } #[cfg(test)] diff --git a/linux/crates/app/src/workspace/engine_profile.rs b/linux/crates/app/src/workspace/engine_profile.rs new file mode 100644 index 0000000000..70581c4f55 --- /dev/null +++ b/linux/crates/app/src/workspace/engine_profile.rs @@ -0,0 +1,53 @@ +use tablepro_core::{DriverRegistry, WriteAtomicity}; + +/// What a structure review says about the engine the workspace's +/// session runs on. A workspace never changes session, so this is read +/// once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineProfile { + pub name: String, + pub ddl_writes: WriteAtomicity, +} + +impl EngineProfile { + /// A driver the registry does not know is taken to apply each + /// statement on its own, so every save of more than one statement + /// asks first. + pub fn of(drivers: &DriverRegistry, driver_id: &str) -> Self { + match drivers.get(driver_id) { + Some(driver) => Self { + name: driver.display_name().to_owned(), + ddl_writes: driver.capabilities().ddl_writes, + }, + None => Self { + name: driver_id.to_owned(), + ddl_writes: WriteAtomicity::StatementByStatement, + }, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn an_engine_comes_from_its_driver_and_an_unknown_one_asks() { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(drivers_mysql::MysqlDriver)); + + assert_eq!( + EngineProfile::of(&drivers, "mysql"), + EngineProfile { + name: "MySQL".to_owned(), + ddl_writes: WriteAtomicity::StatementByStatement, + } + ); + assert_eq!( + EngineProfile::of(&drivers, "gone").ddl_writes, + WriteAtomicity::StatementByStatement + ); + } +} diff --git a/linux/crates/app/src/workspace/legacy_tab_file.rs b/linux/crates/app/src/workspace/legacy_tab_file.rs new file mode 100644 index 0000000000..5bafc4a8f7 --- /dev/null +++ b/linux/crates/app/src/workspace/legacy_tab_file.rs @@ -0,0 +1,55 @@ +use std::io; +use std::path::Path; + +/// The one tab file builds before per-session workspaces kept in the +/// state directory. Editor tabs wrote their SQL into it, and nothing +/// reads it any more, so it would keep that SQL on disk for good. +const LEGACY_TAB_FILE: &str = "workspace_state.json"; + +/// Removes the tab file an older build left in `state_dir`, and nothing +/// else there. Nothing writes the file any more, so the first start +/// after the upgrade is the one that finds it. +pub(crate) fn remove_legacy_tab_file(state_dir: &Path) -> io::Result { + match std::fs::remove_file(state_dir.join(LEGACY_TAB_FILE)) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_legacy_tab_file_goes_and_the_rest_of_the_state_directory_stays() { + let state = tempfile::tempdir().expect("a state directory"); + let kept = ["history.db", "workspace_state.json.tmp", "workspaces/abc.json"]; + std::fs::create_dir(state.path().join("workspaces")).expect("the workspaces directory"); + for name in kept { + std::fs::write(state.path().join(name), b"kept").expect("a kept file"); + } + std::fs::write( + state.path().join(LEGACY_TAB_FILE), + br#"{"connections":{"c":{"tabs":[{"kind":"editor","query":"SELECT 1"}]}}}"#, + ) + .expect("the legacy file"); + + let first = remove_legacy_tab_file(state.path()).expect("the removal"); + let second = remove_legacy_tab_file(state.path()).expect("a second start"); + + assert!(first); + assert!(!second, "a start without the file found one"); + assert!(!state.path().join(LEGACY_TAB_FILE).exists()); + for name in kept { + assert!(state.path().join(name).exists(), "{name} was removed"); + } + } + + #[test] + fn a_missing_state_directory_has_nothing_to_remove() { + let root = tempfile::tempdir().expect("a root"); + + assert!(!remove_legacy_tab_file(&root.path().join("never-created")).expect("no error")); + } +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index d61d185f4d..4afc4ac9f8 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -1,17 +1,24 @@ //! A window's workspace: which tabs were open and what the editor ones //! held, kept between sessions, and the saves its tabs run and the -//! prompts that decide whether they may close. +//! prompts that decide whether they may close. Also which sessions the +//! windows show, so a launch opens them again. mod close_decision; mod close_guard; mod close_guards; +mod close_reason; mod close_scope; mod close_step; -mod dialog_response; +pub(crate) mod dialog_response; mod dirty_kind; mod dirty_tab; mod draft_writer; +mod engine_profile; mod inhibit_reason; +mod legacy_tab_file; +mod open_sessions; +mod open_sessions_load; +mod open_sessions_record; mod open_transaction_dialog; mod pending_state; mod persist_debouncer; @@ -28,7 +35,10 @@ mod save_snapshot; mod save_start; mod save_ticket; mod schema_write; +mod session_key_record; mod sort_record; +#[cfg(test)] +mod structure_review_tests; mod tab_directory; mod tab_entry; mod tab_record; @@ -47,16 +57,26 @@ mod workspace_record; mod workspace_save_error; mod workspace_store; mod write_job; +mod writes_in_flight; pub use close_decision::CloseDecision; pub(crate) use close_guard::CloseGuard; pub(crate) use close_guards::CloseGuards; +pub use close_reason::CloseReason; pub use close_scope::CloseScope; pub use close_step::{CloseStep, next_close_step}; +pub(crate) use dialog_response::respond; +#[cfg(test)] +pub(crate) use dialog_response::{answer, visible_alert}; pub use dirty_kind::DirtyKind; pub use dirty_tab::DirtyTab; pub use draft_writer::DraftWriter; +pub use engine_profile::EngineProfile; pub use inhibit_reason::InhibitReason; +pub(crate) use legacy_tab_file::remove_legacy_tab_file; +pub use open_sessions::OpenSessions; +pub use open_sessions_load::OpenSessionsLoad; +pub use open_sessions_record::OpenSessionsRecord; pub use pending_state::PendingState; pub use persist_debouncer::PersistDebouncer; pub(crate) use ran_statements::RanStatements; @@ -71,11 +91,14 @@ pub use save_snapshot::SaveSnapshot; pub use save_start::SaveStart; pub use save_ticket::SaveTicket; pub(crate) use schema_write::run_schema_plan; +pub use session_key_record::SessionKeyRecord; pub use sort_record::SortRecord; pub use tab_directory::TabDirectory; pub use tab_entry::TabEntry; pub use tab_record::TabRecord; pub use unknown_cause::UnknownCause; +#[cfg(test)] +pub use window_inhibitor::InhibitTarget; pub use window_inhibitor::{ApplicationInhibit, WindowInhibitor}; pub use workspace_event::WorkspaceEvent; pub use workspace_file_name::WorkspaceFileName; diff --git a/linux/crates/app/src/workspace/open_sessions.rs b/linux/crates/app/src/workspace/open_sessions.rs new file mode 100644 index 0000000000..80de630eea --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions.rs @@ -0,0 +1,315 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::glib; +use tablepro_session::SessionKey; +use tablepro_session::runtime::ProcessHold; + +use super::{OpenSessionsLoad, OpenSessionsRecord, WorkspaceStore}; + +/// The sessions the windows show, kept on disk so the next launch opens +/// them again. GTK-thread only. +/// +/// A quit freezes the list before it asks the first window, so a window +/// it closes stays on it; a cancelled quit thaws it to the windows still +/// open, and an approved one empties it once it is on disk. +pub struct OpenSessions { + store: WorkspaceStore, + /// Each write keeps the process up until it lands, since closing the + /// last window would otherwise end it, and the writer with it. + hold: Rc, + keys: RefCell>, + frozen: Cell, +} + +impl OpenSessions { + pub fn new(store: WorkspaceStore, hold: Rc) -> Rc { + Rc::new(Self { + store, + hold, + keys: RefCell::new(Vec::new()), + frozen: Cell::new(false), + }) + } + + pub fn load(&self) -> impl Future + use<> { + self.store.load_open_sessions() + } + + /// Take the recorded keys as open, ahead of any opened since launch, + /// and return the ones to reopen. Each restored window removes its + /// key again if it cannot open it. + pub fn restore(&self, record: OpenSessionsRecord) -> Vec { + let restored = record.keys(); + let opened_since = { + let mut keys = self.keys.borrow_mut(); + let opened_since: Vec = keys.drain(..).filter(|key| !restored.contains(key)).collect(); + keys.extend(restored.iter().cloned()); + keys.extend(opened_since.iter().cloned()); + opened_since + }; + if !opened_since.is_empty() { + self.write(); + } + restored + } + + pub fn add(&self, key: SessionKey) { + { + let mut keys = self.keys.borrow_mut(); + if keys.contains(&key) { + return; + } + keys.push(key); + } + self.write(); + } + + /// Ignored while a quit runs: the windows it closes are the ones the + /// next launch reopens. + pub fn remove(&self, key: &SessionKey) { + if self.frozen.get() { + return; + } + { + let mut keys = self.keys.borrow_mut(); + let before = keys.len(); + keys.retain(|kept| kept != key); + if keys.len() == before { + return; + } + } + self.write(); + } + + /// Stop removing keys until `thaw`, for the length of a quit or the + /// application's shutdown. + pub fn freeze_for_quit(&self) { + self.frozen.set(true); + } + + /// Keep the keys `live` still names, after a quit the user cancelled + /// partway, and start removing keys again. + pub fn thaw(&self, live: impl Fn(&SessionKey) -> bool) { + self.keys.borrow_mut().retain(|key| live(key)); + self.frozen.set(false); + self.write(); + } + + /// After an approved quit has written the record, go back to how a + /// launch starts: nothing open and nothing frozen, so the next + /// restore reads the record from disk. + pub fn end_quit(&self) { + self.keys.borrow_mut().clear(); + self.frozen.set(false); + } + + /// Write the keys as they are now; resolves once they are on disk. + pub fn flush(&self) -> impl Future + use<> { + let scheduled = self.store.schedule_open_sessions(self.record()); + let written = self.store.flush_open_sessions(); + async move { + if let Err(error) = scheduled.and(written.await) { + tracing::warn!(%error, "could not record which sessions are open"); + } + } + } + + /// Resolves once every write already scheduled is on disk. + #[cfg(test)] + pub(crate) fn settled(&self) -> impl Future + use<> { + let written = self.store.flush_open_sessions(); + async move { + let _ = written.await; + } + } + + #[cfg(test)] + pub(crate) fn keys(&self) -> Vec { + self.keys.borrow().clone() + } + + fn record(&self) -> OpenSessionsRecord { + OpenSessionsRecord::from_keys(self.keys.borrow().iter()) + } + + fn write(&self) { + let token = self.hold.acquire(); + let flushing = self.flush(); + glib::spawn_future_local(async move { + flushing.await; + drop(token); + }); + } +} + +impl std::fmt::Debug for OpenSessions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenSessions") + .field("keys", &self.keys.borrow()) + .field("frozen", &self.frozen.get()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::Duration; + + use tablepro_session::runtime::CountingHold; + use tablepro_storage::document::decode_document; + use uuid::Uuid; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + struct Fixture { + runtime: Option, + dir: tempfile::TempDir, + hold: Rc, + sessions: Rc, + } + + impl Fixture { + fn new() -> Self { + let runtime = crate::test_support::test_runtime(); + let dir = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start( + dir.path().to_owned(), + tablepro_storage::DraftStore::new(dir.path().join("drafts")), + &runtime.tasks(), + ); + let hold = Rc::new(CountingHold::new()); + let sessions = OpenSessions::new(store, hold.clone()); + Self { + runtime: Some(runtime), + dir, + hold, + sessions, + } + } + + fn path(&self) -> PathBuf { + self.dir.path().join(OpenSessionsRecord::FILE_NAME) + } + + /// The keys on disk once every write has landed. + fn on_disk(&self) -> Vec { + wait_until(WAIT, || self.hold.live() == 0).expect("the writes to land"); + let path = self.path(); + let bytes = std::fs::read(&path).expect("the record was written"); + decode_document::(Path::new(&path), &bytes) + .expect("a record") + .keys() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } + } + + fn saved() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + #[gtk4::test] + fn add_records_each_key_once_in_order() { + let fixture = Fixture::new(); + let (first, second) = (saved(), SessionKey::File("file:///srv/app.db".to_owned())); + + fixture.sessions.add(first.clone()); + fixture.sessions.add(second.clone()); + fixture.sessions.add(first.clone()); + + assert_eq!(fixture.sessions.keys(), [first.clone(), second.clone()]); + assert_eq!(fixture.on_disk(), [first, second]); + } + + #[gtk4::test] + fn a_write_holds_the_process_until_it_lands() { + let fixture = Fixture::new(); + + fixture.sessions.add(saved()); + + assert!(fixture.hold.live() > 0, "the write started without holding the process"); + wait_until(WAIT, || fixture.hold.live() == 0).expect("the hold to end"); + assert!(fixture.path().exists(), "the hold ended before the write"); + } + + #[gtk4::test] + fn quit_freeze_keeps_keys_window_close_removes() { + let fixture = Fixture::new(); + let (closed, kept) = (saved(), saved()); + fixture.sessions.add(closed.clone()); + fixture.sessions.add(kept.clone()); + + fixture.sessions.remove(&closed); + assert_eq!( + fixture.sessions.keys(), + std::slice::from_ref(&kept), + "a window close kept its key" + ); + fixture.sessions.freeze_for_quit(); + fixture.sessions.remove(&kept); + + assert_eq!( + fixture.sessions.keys(), + std::slice::from_ref(&kept), + "the quit forgot a window it closed" + ); + assert_eq!(fixture.on_disk(), [kept]); + } + + #[gtk4::test] + fn cancelled_quit_thaws_to_live_keys() { + let fixture = Fixture::new(); + let (closed, open) = (saved(), saved()); + fixture.sessions.add(closed.clone()); + fixture.sessions.add(open.clone()); + fixture.sessions.freeze_for_quit(); + fixture.sessions.remove(&closed); + + fixture.sessions.thaw(|key| key == &open); + + assert_eq!(fixture.sessions.keys(), std::slice::from_ref(&open)); + assert_eq!(fixture.on_disk(), std::slice::from_ref(&open)); + fixture.sessions.remove(&open); + assert!( + fixture.sessions.keys().is_empty(), + "a thawed list still ignores removals" + ); + } + + #[gtk4::test] + fn restore_puts_the_recorded_keys_first() { + let fixture = Fixture::new(); + let (recorded, opened_since) = (saved(), saved()); + fixture.sessions.add(opened_since.clone()); + + let reopen = fixture.sessions.restore(OpenSessionsRecord::from_keys([&recorded])); + + assert_eq!(reopen, std::slice::from_ref(&recorded)); + assert_eq!(fixture.sessions.keys(), [recorded.clone(), opened_since.clone()]); + assert_eq!(fixture.on_disk(), [recorded, opened_since]); + } + + #[gtk4::test] + fn flush_writes_the_keys_as_they_are() { + let fixture = Fixture::new(); + let key = saved(); + fixture.sessions.add(key.clone()); + wait_until(WAIT, || fixture.hold.live() == 0).expect("the add to land"); + std::fs::remove_file(fixture.path()).expect("remove the record"); + + crate::test_support::finish_on_main(WAIT, fixture.sessions.flush()).expect("the flush to finish"); + + assert_eq!(fixture.on_disk(), [key]); + } +} diff --git a/linux/crates/app/src/workspace/open_sessions_load.rs b/linux/crates/app/src/workspace/open_sessions_load.rs new file mode 100644 index 0000000000..195a92fd82 --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions_load.rs @@ -0,0 +1,9 @@ +use super::OpenSessionsRecord; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenSessionsLoad { + Restored(OpenSessionsRecord), + /// Nothing was recorded, or the record could not be used and was + /// moved aside. + Empty, +} diff --git a/linux/crates/app/src/workspace/open_sessions_record.rs b/linux/crates/app/src/workspace/open_sessions_record.rs new file mode 100644 index 0000000000..dd36c0340c --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions_record.rs @@ -0,0 +1,164 @@ +use std::io::ErrorKind; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use tablepro_session::SessionKey; +use tablepro_storage::document::{VersionedDocument, decode_document}; + +use super::workspace_store::{MAX_FILE_BYTES, read_capped}; +use super::{OpenSessionsLoad, SessionKeyRecord}; + +/// The sessions the windows showed, in the order they opened, so the +/// next launch opens them again. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenSessionsRecord { + #[serde(default)] + pub sessions: Vec, +} + +impl VersionedDocument for OpenSessionsRecord { + const KIND: &'static str = "open-sessions"; + const VERSION: u32 = 1; +} + +impl OpenSessionsRecord { + /// Beside the sessions' own files, under a name none of them can + /// take. + pub const FILE_NAME: &'static str = "open-sessions.json"; + + pub fn from_keys<'a>(keys: impl IntoIterator) -> Self { + Self { + sessions: keys.into_iter().map(SessionKeyRecord::from).collect(), + } + } + + /// Each key once, in the recorded order: a key repeated in the file + /// would open a second window only to find the first. + pub fn keys(self) -> Vec { + let mut keys: Vec = Vec::with_capacity(self.sessions.len()); + for key in self.sessions.into_iter().map(SessionKeyRecord::into_key) { + if !keys.contains(&key) { + keys.push(key); + } + } + keys + } +} + +/// A file that cannot be used is moved aside, bytes and all, and loads +/// as empty. It holds nothing the user wrote, and the next write then +/// never lands on something nobody read. +pub(super) fn read_open_sessions_blocking(path: &Path) -> OpenSessionsLoad { + let problem = match read_capped(path) { + Ok(Some(bytes)) => match decode_document::(path, &bytes) { + Ok(record) => return OpenSessionsLoad::Restored(record), + Err(problem) => problem.to_string(), + }, + Ok(None) => format!("the file is larger than {MAX_FILE_BYTES} bytes"), + Err(error) if error.kind() == ErrorKind::NotFound => return OpenSessionsLoad::Empty, + Err(error) => error.to_string(), + }; + match tablepro_storage::fs::move_aside_blocking(path, std::time::SystemTime::now()) { + Ok(moved) => tracing::warn!(%problem, moved = %moved.display(), "moved the open sessions record aside"), + Err(error) => tracing::warn!(%problem, %error, "could not move the open sessions record aside"), + } + OpenSessionsLoad::Empty +} + +#[cfg(test)] +mod tests { + use tablepro_storage::document::encode_document; + use uuid::Uuid; + + use super::*; + + fn record() -> OpenSessionsRecord { + OpenSessionsRecord { + sessions: vec![ + SessionKeyRecord::Saved { id: Uuid::new_v4() }, + SessionKeyRecord::File { + uri: "file:///srv/app.db".to_owned(), + }, + ], + } + } + + fn moved_aside(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("list the directory") + .map(|entry| entry.expect("an entry").path()) + .filter(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("open-sessions.json.corrupt-")) + }) + .collect() + } + + #[test] + fn round_trip() { + let record = record(); + + let bytes = encode_document(&record).expect("encode"); + let decoded: OpenSessionsRecord = decode_document(Path::new("/tmp/o.json"), &bytes).expect("decode"); + + assert_eq!(decoded, record); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("\"version\": 1"), "{text}"); + assert!(text.contains("\"kind\": \"saved\""), "{text}"); + } + + #[test] + fn unsupported_version_moved_aside_and_loads_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + let original = br#"{"version": 2, "sessions": []}"#; + std::fs::write(&path, original).expect("seed"); + + let load = read_open_sessions_blocking(&path); + + assert_eq!(load, OpenSessionsLoad::Empty); + assert!(!path.exists(), "the unusable file was left where the next write goes"); + let moved = moved_aside(dir.path()); + assert_eq!(moved.len(), 1, "{moved:?}"); + assert_eq!(std::fs::read(&moved[0]).expect("read the moved file"), original); + } + + #[test] + fn a_corrupt_file_is_moved_aside() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + std::fs::write(&path, b"{ not json").expect("seed"); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Empty); + assert_eq!(moved_aside(dir.path()).len(), 1); + } + + #[test] + fn a_missing_file_loads_empty_and_creates_nothing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Empty); + assert_eq!(std::fs::read_dir(dir.path()).expect("list").count(), 0); + } + + #[test] + fn a_written_record_loads() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + let record = record(); + std::fs::write(&path, encode_document(&record).expect("encode")).expect("seed"); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Restored(record)); + } + + #[test] + fn keys_keep_the_recorded_order_once_each() { + let first = SessionKey::Saved(Uuid::new_v4()); + let second = SessionKey::File("file:///srv/app.db".to_owned()); + + let keys = OpenSessionsRecord::from_keys([&first, &second, &first]).keys(); + + assert_eq!(keys, [first, second]); + } +} diff --git a/linux/crates/app/src/workspace/ran_statements.rs b/linux/crates/app/src/workspace/ran_statements.rs index 984be6f058..c78e1ebac6 100644 --- a/linux/crates/app/src/workspace/ran_statements.rs +++ b/linux/crates/app/src/workspace/ran_statements.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; use std::time::SystemTime; +use tablepro_core::dialect::SqlDialect; use tablepro_session::ConnectionMetadata; -use tablepro_storage::QueryHistory; use tablepro_storage::query_history::{NewEntry, Outcome}; +use crate::services::history_service::HistoryService; + /// What the app needs to record a statement batch it ran on the user's /// behalf. /// @@ -12,10 +15,11 @@ use tablepro_storage::query_history::{NewEntry, Outcome}; /// "what did this to my table" has to carry them too. It is captured /// before the work starts, because the connection can be gone by the /// time the answer comes back. -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct RanStatements { - history: Option, + history: Option, connection: Option, + dialect: Option>, sql: String, started_at: SystemTime, } @@ -24,22 +28,24 @@ impl RanStatements { /// Capture the batch about to run. `sql` reads as one entry in the /// history list, which is how the user thinks of one save. pub(crate) fn starting( - history: Option, + history: Option, connection: Option, + dialect: Option>, statements: impl IntoIterator, ) -> Self { Self { history, connection, + dialect, sql: join(statements), started_at: SystemTime::now(), } } - /// Record how it went. Does nothing when the history is closed or - /// the batch was empty, and never fails the save it describes: a - /// history that cannot be written is worth a log line, not a lost - /// edit. + /// Record how it went, through the same gate as an editor run. Does + /// nothing when the history is closed or the batch was empty, and + /// never fails the save it describes: a history that cannot be + /// written is worth a log line, not a lost edit. pub(crate) async fn finished(self, rows_affected: Option, outcome: Outcome) { let (Some(history), Some(connection)) = (self.history, self.connection) else { return; @@ -61,9 +67,7 @@ impl RanStatements { rows_affected, outcome, }; - if let Err(error) = history.record(entry).await { - tracing::warn!(%error, "could not record the statements in history"); - } + history.record(entry, self.dialect).await; } } @@ -97,6 +101,56 @@ mod tests { assert_eq!(join(["SELECT 1;".to_owned()]), "SELECT 1;"); } + #[tokio::test] + async fn a_save_goes_through_the_history_gate() { + use tokio::sync::watch; + + use crate::services::history_preferences::HistoryPreferences; + + let root = tempfile::tempdir().expect("tempdir"); + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let history = HistoryService::start(paths, preferences, &crate::test_support::paused_tasks()); + let dialect: Arc = Arc::new(tablepro_test_support::FakeDialect); + let connection = ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "postgres".to_owned(), + }; + let save_of = |sql: &str| { + RanStatements::starting( + Some(history.clone()), + Some(connection.clone()), + Some(dialect.clone()), + [sql.to_owned()], + ) + }; + + save_of("UPDATE t SET a = 1").finished(Some(1), Outcome::Success).await; + save_of("CREATE USER u PASSWORD 'p'") + .finished(None, Outcome::Success) + .await; + save.send_replace(false); + save_of("UPDATE t SET a = 2").finished(Some(1), Outcome::Success).await; + + let store = history.ready_store().await.expect("the store"); + let queries: Vec = store + .search(tablepro_storage::query_history::SearchFilter { + limit: 100, + ..Default::default() + }) + .await + .expect("search") + .into_iter() + .map(|entry| entry.query) + .collect(); + assert_eq!(queries, vec!["UPDATE t SET a = 1;".to_owned()]); + } + #[test] fn an_empty_batch_records_nothing() { assert_eq!(join(Vec::::new()), ""); diff --git a/linux/crates/app/src/workspace/rollback_cause.rs b/linux/crates/app/src/workspace/rollback_cause.rs index b1945c24e2..6bb77b1d24 100644 --- a/linux/crates/app/src/workspace/rollback_cause.rs +++ b/linux/crates/app/src/workspace/rollback_cause.rs @@ -33,4 +33,32 @@ impl RollbackCause { ), } } + + /// A guard found what the save was built from changed after it was + /// read: the row, for a row save, or the table, for a structure save. + pub fn found_changes(&self) -> bool { + matches!(self, Self::Driver(DriverError::RowGuardFailed { .. })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_failed_guard_found_changes() { + let guard = RollbackCause::Driver(DriverError::RowGuardFailed { + statement_index: 0, + expected: 0, + actual: 2, + }); + let refused = RollbackCause::Driver(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("UNIQUE constraint failed")), + }); + + assert!(guard.found_changes()); + assert!(!refused.found_changes()); + assert!(!RollbackCause::NotApplied.found_changes()); + } } diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs index 4602d55940..6508bdc698 100644 --- a/linux/crates/app/src/workspace/save_rejection.rs +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -1,3 +1,4 @@ +use tablepro_core::ddl::BuildDdlError; use tablepro_core::dml::BuildSqlError; use tablepro_session::OperationError; @@ -25,6 +26,12 @@ pub enum SaveRejection { KeyChanged, /// The structure cannot be saved as it stands. Blocked(SaveBlocker), + /// The structure cannot be written as SQL for the review to show. + Unplannable(BuildDdlError), + /// The user read the review of a structure save and kept it. + ReviewDeclined, + /// A review of the same changes is open. + InReview, NoConnection, } @@ -52,14 +59,18 @@ impl SaveRejection { "The table's primary key changed after these edits were made, so the rows they change can no longer be found. Nothing was saved. Discard the changes and make them again.", ), Self::Blocked(blocker) => crate::ui::error_text::save_blocker_text(blocker), + Self::Unplannable(error) => crate::ui::error_text::build_error_text(error), + Self::ReviewDeclined => crate::i18n::gettext("The changes were not applied."), + Self::InReview => crate::i18n::gettext("The review of these changes is still open."), Self::NoConnection => crate::i18n::gettext("No active connection."), } } /// A Save pressed again while the first runs needs no alert: the tab - /// already shows the save running. + /// already shows the save running, or its review. Nor does a review + /// the user just declined. pub fn needs_alert(&self) -> bool { - !matches!(self, Self::AlreadySaving) + !matches!(self, Self::AlreadySaving | Self::InReview | Self::ReviewDeclined) } } @@ -83,6 +94,8 @@ mod tests { assert!(told.iter().all(SaveRejection::needs_alert)); assert!(!SaveRejection::AlreadySaving.needs_alert()); + assert!(!SaveRejection::InReview.needs_alert()); + assert!(!SaveRejection::ReviewDeclined.needs_alert()); } #[test] diff --git a/linux/crates/app/src/workspace/session_key_record.rs b/linux/crates/app/src/workspace/session_key_record.rs new file mode 100644 index 0000000000..e9ccb5fcdb --- /dev/null +++ b/linux/crates/app/src/workspace/session_key_record.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use tablepro_session::SessionKey; +use uuid::Uuid; + +/// A session key as the open-sessions file holds it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SessionKeyRecord { + Saved { id: Uuid }, + File { uri: String }, +} + +impl From<&SessionKey> for SessionKeyRecord { + fn from(key: &SessionKey) -> Self { + match key { + SessionKey::Saved(id) => Self::Saved { id: *id }, + SessionKey::File(uri) => Self::File { uri: uri.clone() }, + } + } +} + +impl SessionKeyRecord { + pub fn into_key(self) -> SessionKey { + match self { + Self::Saved { id } => SessionKey::Saved(id), + Self::File { uri } => SessionKey::File(uri), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_key_comes_back_as_it_went_in() { + for key in [ + SessionKey::Saved(Uuid::new_v4()), + SessionKey::File("file:///srv/app.db".to_owned()), + ] { + assert_eq!(SessionKeyRecord::from(&key).into_key(), key); + } + } +} diff --git a/linux/crates/app/src/workspace/structure_review_tests.rs b/linux/crates/app/src/workspace/structure_review_tests.rs new file mode 100644 index 0000000000..fc9c3edfe1 --- /dev/null +++ b/linux/crates/app/src/workspace/structure_review_tests.rs @@ -0,0 +1,202 @@ +//! The review a structure save asks for on its way to the ledger. + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::WriteReport; +use tablepro_core::ddl::{BuildDdlError, DraftColumn, DraftType, TableDraft}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::dialog_response::{answer, visible_alert}; +use super::test_workspace::{WAIT, Workspace, edited_table, window}; +use super::{PendingState, SaveOutcome, SaveRejection, SaveStart}; +use crate::services::pending_structure::PendingStructure; +use crate::test_support::wait_until; + +fn without_n(draft: &mut TableDraft) { + draft.columns.retain(|column| column.name != "n"); +} + +fn writing() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + script +} + +fn review_on(window: &adw::Window) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window); + shown.is_some() + }) + .expect("the review"); + shown.expect("the review") +} + +fn review_sql(review: &adw::AlertDialog) -> String { + let buffer = review + .extra_child() + .and_then(|child| child.downcast::().ok()) + .and_then(|scrolled| scrolled.child()) + .and_then(|child| child.downcast::().ok()) + .expect("the SQL view") + .buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +fn started(starts: &Rc>>>) -> Vec { + wait_until(WAIT, || starts.borrow().is_some()).expect("the save to be asked"); + starts.borrow().clone().expect("the starts") +} + +#[gtk4::test] +fn drop_column_save_presents_review_with_sql_child() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + let review = review_on(&window); + + assert_eq!(review.heading().as_deref(), Some("Apply 1 Change to “t”?")); + assert_eq!(review.body(), "Drops column “n” and the data in it."); + assert_eq!(review_sql(&review), "ALTER TABLE \"t\" DROP COLUMN \"n\";"); + assert!( + starts.borrow().is_none(), + "the save started before the review was answered" + ); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert!(answer(&window, "cancel")); + started(&starts); +} + +#[gtk4::test] +fn cancel_review_sends_nothing() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + assert!(answer(&window, "cancel")); + + assert_eq!(started(&starts), [SaveStart::Rejected(SaveRejection::ReviewDeclined)]); + crate::test_support::drain_main_context(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert!(workspace.settled.borrow().is_empty()); +} + +#[gtk4::test] +fn apply_review_runs_one_write_batch() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + assert!(answer(&window, "apply")); + + assert!(matches!(started(&starts).as_slice(), [SaveStart::Started(_)])); + assert!(matches!(workspace.settle().outcome, SaveOutcome::Committed { .. })); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.structure.pending_state(), PendingState::Clean); +} + +#[gtk4::test] +fn transactional_additive_save_starts_without_a_review() { + let pending = edited_table(|draft| { + draft + .columns + .push(DraftColumn::new("c", DraftType::Typed("TEXT".to_owned()))); + }); + let workspace = Workspace::open_structure(writing(), pending); + + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); + workspace.settle(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); +} + +#[gtk4::test] +fn a_rebuild_save_says_it_rebuilds_the_table() { + let pending = edited_table(|draft| { + if let Some(n) = draft.columns.iter_mut().find(|column| column.name == "n") { + n.column_type = DraftType::Typed("TEXT".to_owned()); + } + }); + let workspace = Workspace::open_structure(ConnectionScript::default(), pending); + let window = window(); + + let starts = workspace.start_on(&window); + let review = review_on(&window); + + assert_eq!( + review.body(), + "Rebuilds the table: copies its rows into a new table and drops the old one.\nChanges column “n” from \ + INTEGER to TEXT. A value the new type can't hold is converted or refused." + ); + assert!( + review_sql(&review).contains("DROP TABLE \"t\";"), + "{}", + review_sql(&review) + ); + assert_eq!( + review.response_appearance("apply"), + adw::ResponseAppearance::Destructive + ); + assert!(answer(&window, "cancel")); + started(&starts); +} + +#[gtk4::test] +fn a_draft_changed_during_the_review_is_not_saved() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + let renamed: PendingStructure = edited_table(|draft| { + without_n(draft); + draft.table.name = "u".to_owned(); + }); + workspace.structure.set_pending(Some(renamed)); + assert!(answer(&window, "apply")); + + let rejection = SaveRejection::Unplannable(BuildDdlError::NotAsReviewed); + assert_eq!(started(&starts), [SaveStart::Rejected(rejection.clone())]); + assert!(rejection.needs_alert()); + assert_eq!( + rejection.message(), + "The changes were edited or the connection changed during the review, so nothing was saved. Save again to \ + review them." + ); + crate::test_support::drain_main_context(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); +} + +#[gtk4::test] +fn a_save_asked_again_during_the_review_opens_no_second_one() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let first = workspace.start_on(&window); + let review = review_on(&window); + let again = workspace.start_on(&window); + + assert_eq!(started(&again), [SaveStart::Rejected(SaveRejection::InReview)]); + assert_eq!(window.dialogs().n_items(), 1, "a second review opened"); + assert!(answer(&window, "cancel")); + assert_eq!(started(&first), [SaveStart::Rejected(SaveRejection::ReviewDeclined)]); + + let after = workspace.start_on(&window); + wait_until(WAIT, || visible_alert(&window).is_some_and(|shown| shown != review)) + .expect("a review once the first was answered"); + assert!(answer(&window, "apply")); + assert!(matches!(started(&after).as_slice(), [SaveStart::Started(_)])); + workspace.settle(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); +} diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs index a64d097d4f..17a7e694cb 100644 --- a/linux/crates/app/src/workspace/test_workspace.rs +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -3,18 +3,26 @@ use std::rc::Rc; use std::sync::Arc; use std::time::Duration; +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; +use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition, TableDraft, TableSource, diff_table}; use tablepro_core::meta::TableRef; -use tablepro_core::{ColumnInfo, Value}; +use tablepro_core::{ColumnInfo, Value, WriteAtomicity}; use tablepro_session::runtime::CountingHold; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use uuid::Uuid; -use super::{SaveSettled, SaveStart, TabDirectory, TabEntry, WorkspaceDeps, WorkspaceEvent, WorkspaceHandle}; +use super::{ + EngineProfile, SaveSettled, SaveStart, TabDirectory, TabEntry, WorkspaceDeps, WorkspaceEvent, WorkspaceHandle, +}; use crate::services::active_session::ActiveSession; use crate::services::change_tracker::RowKey; use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::pending_structure::PendingStructure; use crate::services::save_batch::SaveTarget; +use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::services::structure_tracker_registry::StructureTrackerRegistry; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_edit::TrackerEdit; @@ -56,13 +64,62 @@ pub(crate) fn sqlite() -> Arc { ))) } -/// A workspace with one table tab holding one edited cell, over a fake -/// connection that writes as its script says. +/// `t (id INTEGER, n INTEGER)` as SQLite reads it, edited by `edit`. +pub(crate) fn edited_table(edit: impl FnOnce(&mut TableDraft)) -> PendingStructure { + let dialect = sqlite(); + let column = |name: &str| { + ColumnDefinition::new( + ColumnInfo { + column_type: dialect.parse_type("INTEGER").expect("a SQLite type"), + nullable: true, + ..integer(name, false) + }, + ColumnAttributes::default(), + ) + }; + let original = TableDefinition { + table: TableRef::unqualified("t"), + columns: vec![column("id"), column("n")], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: Some(TableSource { + create_statement: "CREATE TABLE t (id INTEGER, n INTEGER)".to_owned(), + dependents: Vec::new(), + }), + }; + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + PendingStructure { + changes: diff_table(&original, &draft), + original: Some(original), + draft, + unparsed_defaults: Vec::new(), + } +} + +/// A mapped window for a save's review and the close prompts. +pub(crate) fn window() -> adw::Window { + adw::init().expect("libadwaita initialises under the test backend"); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = adw::Window::new(); + window.present(); + window +} + +/// A workspace with one tab, over a fake connection that writes as its +/// script says: a table tab holding one edited cell, or a structure tab +/// holding a pending structure. pub(crate) struct Workspace { pub handle: WorkspaceHandle, pub tab: Uuid, pub tracker: TabTrackerHandle, + pub structure: StructureTrackerHandle, pub tabs: Rc, + structures: Rc, pub settled: Rc>>, pub events: Rc>>, pub fake: FakeSession, @@ -71,7 +128,15 @@ pub(crate) struct Workspace { } impl Workspace { - pub(crate) fn open(mut script: ConnectionScript) -> Self { + pub(crate) fn open(script: ConnectionScript) -> Self { + Self::build(script, None) + } + + pub(crate) fn open_structure(script: ConnectionScript, pending: PendingStructure) -> Self { + Self::build(script, Some(pending)) + } + + fn build(mut script: ConnectionScript, structure: Option) -> Self { script.dialect = sqlite(); script.probe.always(FakeReply::ok(())); let runtime = crate::test_support::test_runtime(); @@ -79,13 +144,24 @@ impl Workspace { let active = ActiveSession::new(); active.replace(fake.session.clone()); let rows = Rc::new(ChangeTrackerRegistry::default()); + let structures = Rc::new(StructureTrackerRegistry::default()); let tab = Uuid::new_v4(); - let tracker = rows.register(tab); - let _ = tracker.set_target(SaveTarget { - table: TableRef::unqualified("t"), - columns: vec![integer("id", true), integer("n", false)], - }); - edit(&tracker, 2); + let (tracker, structure) = match structure { + None => { + let tracker = rows.register(tab); + let _ = tracker.set_target(SaveTarget { + table: TableRef::unqualified("t"), + columns: vec![integer("id", true), integer("n", false)], + }); + edit(&tracker, 2); + (tracker, StructureTrackerHandle::default()) + } + Some(pending) => { + let structure = structures.register(tab); + structure.set_pending(Some(pending)); + (TabTrackerHandle::default(), structure) + } + }; let tabs = Rc::new(Tabs::default()); tabs.0.borrow_mut().push(TabEntry { id: tab, @@ -103,7 +179,7 @@ impl Workspace { hold: Rc::new(hold.clone()), history: None, rows, - structures: Rc::new(StructureTrackerRegistry::default()), + structures: structures.clone(), directory: tabs.clone(), events: Rc::new(move |event| { if let WorkspaceEvent::SaveSettled(settled) = &event { @@ -111,12 +187,18 @@ impl Workspace { } event_sink.borrow_mut().push(event); }), + engine: EngineProfile { + name: "SQLite".to_owned(), + ddl_writes: WriteAtomicity::Transactional, + }, }); Self { handle, tab, tracker, + structure, tabs, + structures, settled, events, fake, @@ -136,8 +218,44 @@ impl Workspace { id } + /// A structure tab after the first, holding `pending`. + pub(crate) fn add_structure_tab(&self, title: &str, pending: PendingStructure) -> StructureTrackerHandle { + let id = Uuid::new_v4(); + let tracker = self.structures.register(id); + tracker.set_pending(Some(pending)); + self.tabs.0.borrow_mut().push(TabEntry { + id, + title: title.to_owned(), + open_transaction: false, + }); + tracker + } + + /// Ask the tab to save, as its Save button does, for a save that + /// needs no review. + pub(crate) fn start(&self) -> Vec { + let parent = gtk::Label::new(None).upcast::(); + let (handle, tab) = (self.handle.clone(), self.tab); + crate::test_support::finish_on_main(WAIT, async move { handle.save(&parent, &[tab]).await }) + .expect("the save to be asked") + } + + /// Ask the tab to save with `window` as the review's parent. The + /// starts land in the cell once the review, if any, is answered. + pub(crate) fn start_on(&self, window: &adw::Window) -> Rc>>> { + let starts = Rc::new(RefCell::new(None)); + let sink = starts.clone(); + let (handle, tab) = (self.handle.clone(), self.tab); + let parent = window.clone().upcast::(); + gtk::glib::spawn_future_local(async move { + let started = handle.save(&parent, &[tab]).await; + sink.replace(Some(started)); + }); + starts + } + pub(crate) fn save(&self) -> SaveSettled { - let started = self.handle.save(&[self.tab]); + let started = self.start(); assert!(matches!(started.as_slice(), [SaveStart::Started(_)]), "{started:?}"); self.settle() } diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs index f4ccbd47f9..11b6999f72 100644 --- a/linux/crates/app/src/workspace/window_inhibitor.rs +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -47,6 +47,12 @@ impl WindowInhibitor { } } + /// Whether the session is being held back now. A session without an + /// inhibit service never holds it. + pub fn is_held(&self) -> bool { + self.cookie != 0 + } + fn release(&mut self) { if self.cookie != 0 { self.target.uninhibit(std::mem::take(&mut self.cookie)); @@ -199,12 +205,26 @@ mod tests { let mut inhibitor = WindowInhibitor::new(session.clone()); inhibitor.update(Some(InhibitReason::SavingChanges)); + assert!(!inhibitor.is_held(), "a refused inhibit counted as held"); inhibitor.update(None); drop(inhibitor); assert!(session.held.borrow().is_empty()); } + #[gtk4::test] + fn is_held_follows_the_cookie() { + let session = Session::default(); + let mut inhibitor = WindowInhibitor::new(session.clone()); + assert!(!inhibitor.is_held()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + assert!(inhibitor.is_held()); + + inhibitor.update(None); + assert!(!inhibitor.is_held()); + } + #[gtk4::test] fn a_window_without_an_application_inhibits_nothing() { let window = gtk::Window::new(); diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index ec6ca36ac0..79f086f221 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -1,7 +1,10 @@ +use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; +use gtk4 as gtk; +use tablepro_core::ddl::{BuildDdlError, ChangeReview}; use tablepro_core::save::{SavePlan, plan_save}; use tablepro_core::{CommitResolution, DriverError, WriteKind, WriteReport}; use tablepro_session::runtime::{ProcessHold, Tasks}; @@ -12,8 +15,9 @@ use uuid::Uuid; use super::ran_statements::RanStatements; use super::schema_write::run_schema_plan; use super::{ - CloseScope, CloseStep, DirtyKind, DirtyTab, PendingState, RollbackCause, SaveId, SaveLedger, SaveOutcome, - SaveRejection, SaveSettled, SaveStart, SaveTicket, TabDirectory, UnknownCause, WorkspaceEvent, next_close_step, + CloseScope, CloseStep, DirtyKind, DirtyTab, EngineProfile, PendingState, RollbackCause, SaveId, SaveLedger, + SaveOutcome, SaveRejection, SaveSettled, SaveStart, SaveTicket, TabDirectory, UnknownCause, WorkspaceEvent, + next_close_step, }; use crate::services::active_session::ActiveSession; use crate::services::change_tracker::{StatementSource, TrackedChanges, pk_identity}; @@ -22,6 +26,7 @@ use crate::services::history_service::HistoryService; use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::services::structure_tracker_registry::StructureTrackerRegistry; use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::review_dialog::{self, ReviewDecision}; /// What a window's workspace saves and closes with. pub struct WorkspaceDeps { @@ -33,6 +38,8 @@ pub struct WorkspaceDeps { pub structures: Rc, pub directory: Rc, pub events: Rc, + /// What a structure save's review says about the session's engine. + pub engine: EngineProfile, } /// Starts, stops and settles a window's saves, and says what closing @@ -43,6 +50,8 @@ pub struct WorkspaceHandle(Rc); struct Inner { deps: WorkspaceDeps, ledger: SaveLedger, + /// The structure tabs whose save waits on a review. + reviewing: RefCell>, } impl Inner { @@ -55,6 +64,26 @@ impl Inner { } } +/// A structure tab's place in `Inner::reviewing`, given up however the +/// save ends. +struct ReviewClaim<'a> { + reviewing: &'a RefCell>, + tab: Uuid, +} + +impl<'a> ReviewClaim<'a> { + fn take(reviewing: &'a RefCell>, tab: Uuid) -> Option { + let claimed = reviewing.borrow_mut().insert(tab); + claimed.then(|| Self { reviewing, tab }) + } +} + +impl Drop for ReviewClaim<'_> { + fn drop(&mut self) { + self.reviewing.borrow_mut().remove(&self.tab); + } +} + /// What a save's work brings back to the GTK thread. struct SaveWork { written: Result, @@ -78,6 +107,7 @@ impl WorkspaceHandle { Self(Rc::new(Inner { deps, ledger: SaveLedger::new(), + reviewing: RefCell::default(), })) } @@ -85,10 +115,16 @@ impl WorkspaceHandle { &self.0.ledger } - /// Save each tab. A tab with nothing pending, or that is not a table - /// or structure tab, has nothing to save. - pub fn save(&self, tabs: &[Uuid]) -> Vec { - tabs.iter().map(|tab| self.save_tab(*tab)).collect() + /// Save each tab, in order. A structure save that needs the user's + /// review shows it on `parent` and starts only once they apply it. A + /// tab with nothing pending, or that is not a table or structure tab, + /// has nothing to save. + pub async fn save(&self, parent: >k::Widget, tabs: &[Uuid]) -> Vec { + let mut starts = Vec::with_capacity(tabs.len()); + for tab in tabs { + starts.push(self.save_tab(parent, *tab).await); + } + starts } /// Stop the saves `scope` covers. Each settles when its driver @@ -163,12 +199,20 @@ impl WorkspaceHandle { (self.0.deps.events)(event); } - fn save_tab(&self, tab: Uuid) -> SaveStart { + async fn save_tab(&self, parent: >k::Widget, tab: Uuid) -> SaveStart { if let Some(tracker) = self.0.deps.rows.get(tab) { return self.save_rows(tab, &tracker); } if let Some(tracker) = self.0.deps.structures.get(tab) { - return self.save_structure(tab, &tracker); + // Ctrl+S still reaches the window while a review is open, and + // the review on screen is the one to answer. + let Some(_claim) = ReviewClaim::take(&self.0.reviewing, tab) else { + return SaveStart::Rejected(SaveRejection::InReview); + }; + return match self.review_structure(parent, &tracker).await { + Ok(reviewed) => self.save_structure(tab, &tracker, reviewed), + Err(start) => start, + }; } SaveStart::NothingToSave } @@ -286,7 +330,60 @@ impl WorkspaceHandle { SaveStart::Started(id) } - fn save_structure(&self, tab: Uuid, tracker: &StructureTrackerHandle) -> SaveStart { + /// What the structure save would run, agreed to by the user where it + /// loses something or can stop halfway. Nothing is taken from the + /// tracker yet. + async fn review_structure( + &self, + parent: >k::Widget, + tracker: &StructureTrackerHandle, + ) -> Result { + let (table, review) = self.plan_review(tracker)?; + let engine = &self.0.deps.engine; + if review.requires_confirmation(engine.ddl_writes) { + if review_dialog::confirm(parent, &table, &review, engine).await == ReviewDecision::Cancel { + return Err(SaveStart::Rejected(SaveRejection::ReviewDeclined)); + } + // Ctrl+Z is a window shortcut too, so the draft can change + // under an open review. + let (_, now) = self.plan_review(tracker)?; + if now.statements != review.statements { + return Err(SaveStart::Rejected(SaveRejection::Unplannable( + BuildDdlError::NotAsReviewed, + ))); + } + } + Ok(review) + } + + /// The structure save planned with the session's dialect, and the + /// name of its table. + fn plan_review(&self, tracker: &StructureTrackerHandle) -> Result<(String, ChangeReview), SaveStart> { + let session = match self.writable_session(tracker.pending_state()) { + Ok(Some(session)) => session, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let pending = match tracker.pending_save() { + Ok(Some(pending)) => pending, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let dialect = session + .current_dialect() + .ok_or(SaveStart::Rejected(SaveRejection::NoConnection))?; + let review = dialect + .ddl() + .ok_or(BuildDdlError::Unsupported(pending.operation())) + .and_then(|ddl| pending.review(ddl)) + .map_err(|error| SaveStart::Rejected(SaveRejection::Unplannable(error)))?; + Ok((pending.table().name, review)) + } + + /// Runs only the statements reviewed: a connection replaced since the + /// review plans others, and the save is refused before anything is + /// sent. + fn save_structure(&self, tab: Uuid, tracker: &StructureTrackerHandle, reviewed: ChangeReview) -> SaveStart { let (session, ticket, pending) = match self.begin(tab, WriteKind::SchemaChanges, tracker.pending_state(), || { tracker.begin_save() @@ -300,7 +397,11 @@ impl WorkspaceHandle { let (table, operation) = (pending.table(), pending.operation()); let work = async move { let planned = run_schema_plan(session.clone(), table, operation, &cancel, move |ddl, _| { - pending.plan(ddl) + let plan = pending.plan(ddl)?; + match reviewed.matches(&plan) { + true => Ok(plan), + false => Err(BuildDdlError::NotAsReviewed), + } }) .await; let (statements, written) = planned.unwrap_or_else(|error| (Vec::new(), Err(error))); @@ -338,8 +439,9 @@ impl WorkspaceHandle { /// the time the answer comes back. fn history_entry(&self, session: &Session) -> HistoryEntry { HistoryEntry { - history: self.0.deps.history.as_ref().and_then(HistoryService::store), + history: self.0.deps.history.clone(), connection: Some(session.metadata().clone()), + dialect: session.current_dialect(), } } } @@ -353,8 +455,9 @@ impl std::fmt::Debug for WorkspaceHandle { } struct HistoryEntry { - history: Option, + history: Option, connection: Option, + dialect: Option>, } /// A save changes the database as surely as a statement typed into the @@ -366,7 +469,7 @@ async fn record( written: &Result, resolution: Option<&Result>, ) { - let ran = RanStatements::starting(entry.history, entry.connection, statements); + let ran = RanStatements::starting(entry.history, entry.connection, entry.dialect, statements); let (rows, outcome) = match (written, resolution) { (Ok(report), _) => ( report.total_rows().and_then(|rows| i64::try_from(rows).ok()), @@ -448,6 +551,7 @@ fn failing_source(plan: &SavePlan, changes: &TrackedChanges, error: &DriverError #[cfg(test)] mod tests { + use tablepro_core::edit::CellInput; use tablepro_core::meta::TableRef; use tablepro_core::{CommitToken, LossPhase, Value}; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; @@ -496,10 +600,7 @@ mod tests { assert_eq!(settled.outcome, SaveOutcome::RolledBack(RollbackCause::NotApplied)); assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); - assert!(matches!( - workspace.handle.save(&[workspace.tab]).as_slice(), - [SaveStart::Started(_)] - )); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); workspace.settle(); } @@ -528,10 +629,7 @@ mod tests { workspace.tracker.pending_state(), PendingState::OutcomeUnknown(_) )); - assert_eq!( - workspace.handle.save(&[workspace.tab]), - [SaveStart::Rejected(SaveRejection::OutcomeUnknown)] - ); + assert_eq!(workspace.start(), [SaveStart::Rejected(SaveRejection::OutcomeUnknown)]); assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); assert!(matches!( workspace.handle.dirty_tabs().as_slice(), @@ -553,7 +651,7 @@ mod tests { source: Box::new(DriverError::Cancelled), }))); let workspace = Workspace::open(script); - let started = workspace.handle.save(&[workspace.tab]); + let started = workspace.start(); assert!(matches!(started.as_slice(), [SaveStart::Started(_)])); wait_until(WAIT, || { !workspace.fake.connection.observed_cancels("write_batch").is_empty() @@ -579,10 +677,7 @@ mod tests { ..ConnectionScript::default() }); - assert_eq!( - workspace.handle.save(&[workspace.tab]), - [SaveStart::Rejected(SaveRejection::ReadOnly)] - ); + assert_eq!(workspace.start(), [SaveStart::Rejected(SaveRejection::ReadOnly)]); assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); assert_eq!(workspace.handle.ledger().in_flight(), 0); } @@ -591,8 +686,13 @@ mod tests { let columns = vec![integer("id", true), integer("n", false)]; let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(10), Value::Int(1)], + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(10), Value::Int(1)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let _ = tracker.apply(TrackerEdit::Cell { row_key: edited.clone(), diff --git a/linux/crates/app/src/workspace/workspace_save_error.rs b/linux/crates/app/src/workspace/workspace_save_error.rs index b424d7ed80..4ab7de3830 100644 --- a/linux/crates/app/src/workspace/workspace_save_error.rs +++ b/linux/crates/app/src/workspace/workspace_save_error.rs @@ -11,4 +11,6 @@ pub enum WorkspaceSaveError { MoveAside(String), #[error("the workspace writer has stopped")] WriterStopped, + #[error("could not delete the saved tabs: {0}")] + Purge(String), } diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index 085aac9f5c..b78032ab1f 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -5,22 +5,29 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; +use futures::future::Either; use tablepro_session::SessionKey; use tablepro_session::runtime::Tasks; -use tablepro_storage::StorageError; -use tablepro_storage::document::{decode_document, encode_document}; +use tablepro_storage::document::{VersionedDocument, decode_document, encode_document}; +use tablepro_storage::{DraftScope, DraftStore, StorageError}; use tokio::sync::{mpsc, oneshot}; +use super::open_sessions_record::read_open_sessions_blocking; use super::write_job::WriteJob; -use super::{WorkspaceFileName, WorkspaceLoad, WorkspaceLoadError, WorkspaceRecord, WorkspaceSaveError}; +use super::writes_in_flight::{OutsideWrites, WritesInFlight}; +use super::{ + OpenSessionsLoad, OpenSessionsRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceLoadError, WorkspaceRecord, + WorkspaceSaveError, +}; type WriteFile = dyn Fn(&Path, &[u8]) -> Result<(), StorageError> + Send + Sync; /// Thousands of tabs encode to less than this. A file past it is read /// no further, so a runaway one costs no memory to refuse. -const MAX_FILE_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_FILE_BYTES: u64 = 1024 * 1024; -/// Every session's saved tabs, one file each. +/// Every session's saved tabs, one file each, and which sessions the +/// windows show. /// /// Writes go to one task that keeps only the newest record per file, so /// a burst of tab changes costs one write, and each write runs on a @@ -37,18 +44,26 @@ struct StoreInner { writer: mpsc::UnboundedSender, blocked: RefCell>, moved_aside: RefCell>, + outside: Rc, } impl WorkspaceStore { - pub fn start(dir: PathBuf, tasks: &Tasks) -> Self { - Self::start_with(dir, tasks, Arc::new(tablepro_storage::fs::write_private_blocking)) - } - - fn start_with(dir: PathBuf, tasks: &Tasks, write: Arc) -> Self { + /// `drafts` is where the editor tabs of these workspaces keep their + /// text, which a purge deletes with the workspace. + pub fn start(dir: PathBuf, drafts: DraftStore, tasks: &Tasks) -> Self { + Self::start_with( + dir, + drafts, + tasks, + Arc::new(tablepro_storage::fs::write_private_blocking), + ) + } + + fn start_with(dir: PathBuf, drafts: DraftStore, tasks: &Tasks, write: Arc) -> Self { let (writer, jobs) = mpsc::unbounded_channel(); // Detached, so the last store going closes the channel and the // task ends once what it was handed is written. - drop(tasks.spawn_task(run_writer(jobs, tasks.clone(), write))); + drop(tasks.spawn_task(run_writer(jobs, tasks.clone(), drafts, write))); Self { inner: Rc::new(StoreInner { dir, @@ -56,6 +71,7 @@ impl WorkspaceStore { writer, blocked: RefCell::default(), moved_aside: RefCell::default(), + outside: Rc::default(), }), } } @@ -153,6 +169,81 @@ impl WorkspaceStore { } } + /// Note writes for `key` that run outside the store, such as the + /// drafts of a workspace being closed. A purge of `key` waits for + /// them until the returned value drops. + pub fn writes_in_flight(&self, key: &SessionKey) -> WritesInFlight { + self.inner.outside.begin(key) + } + + /// Delete everything kept for a session whose connection is gone: + /// its saved tabs, any copy of them Reset moved aside, and its + /// drafts. A record still queued for it is never written. + /// + /// It goes through the writer, so a write queued before it cannot + /// land after it and bring the file back. It also waits for the + /// session's writes in flight outside the store, or a draft landing + /// late would bring its directory back. + pub fn purge(&self, key: &SessionKey) -> impl Future> + use<> { + self.inner.blocked.borrow_mut().remove(key); + self.inner.moved_aside.borrow_mut().remove(key); + let path = self.inner.path(key); + let drafts = WorkspaceFileName::for_key(key).draft_scope(); + let purging = match self.inner.outside.landing(key) { + // Queued at once, so a record scheduled before the purge is + // dropped in the same round rather than written. + None => Either::Left(queue_purge(&self.inner.writer, path, drafts)), + Some(landing) => { + let writer = self.inner.writer.clone(); + Either::Right(async move { + // A store that went drops the sender, which ends the + // wait just the same. + let _ = landing.await; + queue_purge(&writer, path, drafts).await + }) + } + }; + async move { purging.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) } + } + + /// Queue `record` as the sessions the windows show. A newer one + /// replaces it if the writer has not reached it yet. + pub fn schedule_open_sessions(&self, record: OpenSessionsRecord) -> Result<(), WorkspaceSaveError> { + self.inner + .writer + .send(WriteJob::OpenSessions { + path: self.inner.open_sessions_path(), + record, + }) + .map_err(|_| WorkspaceSaveError::WriterStopped) + } + + /// Resolves once every open-sessions record queued so far is on + /// disk, with how the last write of it went. + pub fn flush_open_sessions(&self) -> impl Future> + use<> { + let written = self.inner.queue_flush(&self.inner.open_sessions_path()); + async move { written.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) } + } + + /// Read which sessions the windows showed, after any record already + /// queued has landed. + pub fn load_open_sessions(&self) -> impl Future + use<> { + let inner = self.inner.clone(); + let path = self.inner.open_sessions_path(); + let written = self.inner.queue_flush(&path); + async move { + let _ = written.await; + inner + .tasks + .spawn_blocking_task(move || read_open_sessions_blocking(&path)) + .await + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not read which sessions were open"); + OpenSessionsLoad::Empty + }) + } + } + pub fn is_blocked(&self, key: &SessionKey) -> bool { self.inner.blocked.borrow().contains(key) } @@ -170,6 +261,10 @@ impl StoreInner { self.dir.join(WorkspaceFileName::for_key(key).file_name()) } + fn open_sessions_path(&self) -> PathBuf { + self.dir.join(OpenSessionsRecord::FILE_NAME) + } + fn queue_flush(&self, path: &Path) -> oneshot::Receiver> { let (written, receiver) = oneshot::channel(); // A writer that has stopped drops the job, and with it the @@ -182,13 +277,29 @@ impl StoreInner { } } +type Reply = oneshot::Sender>; + +fn queue_purge( + writer: &mpsc::UnboundedSender, + path: PathBuf, + drafts: DraftScope, +) -> oneshot::Receiver> { + let (purged, receiver) = oneshot::channel(); + let _ = writer.send(WriteJob::Purge { path, drafts, purged }); + receiver +} + /// The jobs that arrived while the writer was busy. Only the newest /// record per file is kept, and every waiter is answered after the /// writes it queued behind. #[derive(Default)] struct Round { records: BTreeMap, - waiting: Vec<(PathBuf, oneshot::Sender>)>, + /// A purge wins over a record queued for its file in the same + /// round, whichever came first. + purges: Vec<(PathBuf, DraftScope, Reply)>, + open_sessions: Option<(PathBuf, OpenSessionsRecord)>, + waiting: Vec<(PathBuf, Reply)>, } impl Round { @@ -197,12 +308,19 @@ impl Round { WriteJob::Write { path, record } => { self.records.insert(path, record); } + WriteJob::OpenSessions { path, record } => self.open_sessions = Some((path, record)), WriteJob::Flush { path, written } => self.waiting.push((path, written)), + WriteJob::Purge { path, drafts, purged } => self.purges.push((path, drafts, purged)), } } } -async fn run_writer(mut jobs: mpsc::UnboundedReceiver, tasks: Tasks, write: Arc) { +async fn run_writer( + mut jobs: mpsc::UnboundedReceiver, + tasks: Tasks, + drafts: DraftStore, + write: Arc, +) { let mut last: HashMap> = HashMap::new(); while let Some(job) = jobs.recv().await { let mut round = Round::default(); @@ -210,21 +328,55 @@ async fn run_writer(mut jobs: mpsc::UnboundedReceiver, tasks: Tasks, w while let Ok(job) = jobs.try_recv() { round.take(job); } + let purged: HashSet = round.purges.iter().map(|(path, ..)| path.clone()).collect(); for (path, record) in round.records { + if purged.contains(&path) { + continue; + } + let outcome = write_record(&tasks, &write, path.clone(), record).await; + last.insert(path, outcome); + } + if let Some((path, record)) = round.open_sessions { let outcome = write_record(&tasks, &write, path.clone(), record).await; last.insert(path, outcome); } + for (path, scope, reply) in round.purges { + last.remove(&path); + let store = drafts.clone(); + let outcome = tasks + .spawn_blocking_task(move || purge_blocking(&path, &store, &scope)) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::Purge(failure.to_string()))); + if let Err(error) = &outcome { + tracing::warn!(%error, "could not delete a deleted connection's saved tabs"); + } + let _ = reply.send(outcome); + } for (path, written) in round.waiting { let _ = written.send(last.get(&path).cloned().unwrap_or(Ok(()))); } } } -async fn write_record( +/// Every part is tried, so one that fails leaves no more behind than +/// it has to; the first failure is the one reported. +fn purge_blocking(path: &Path, drafts: &DraftStore, scope: &DraftScope) -> Result<(), WorkspaceSaveError> { + let file = match std::fs::remove_file(path) { + Err(error) if error.kind() != ErrorKind::NotFound => Err(error.to_string()), + _ => Ok(()), + }; + let copies = tablepro_storage::fs::remove_moved_aside_blocking(path) + .map(drop) + .map_err(|error| error.to_string()); + let drafts = drafts.delete_scope_blocking(scope).map_err(|error| error.to_string()); + file.and(copies).and(drafts).map_err(WorkspaceSaveError::Purge) +} + +async fn write_record( tasks: &Tasks, write: &Arc, path: PathBuf, - record: WorkspaceRecord, + record: D, ) -> Result<(), WorkspaceSaveError> { let write = write.clone(); let shown = path.display().to_string(); @@ -236,7 +388,7 @@ async fn write_record( .await .unwrap_or_else(|failure| Err(WorkspaceSaveError::Write(failure.to_string()))); if let Err(error) = &outcome { - tracing::warn!(%error, path = %shown, "could not save the open tabs"); + tracing::warn!(%error, path = %shown, kind = D::KIND, "could not write a workspace file"); } outcome } @@ -255,7 +407,7 @@ fn read_record(path: &Path) -> WorkspaceLoad { } /// The whole file, or `None` when it is larger than `MAX_FILE_BYTES`. -fn read_capped(path: &Path) -> std::io::Result>> { +pub(super) fn read_capped(path: &Path) -> std::io::Result>> { let file = std::fs::File::open(path)?; if file.metadata()?.len() > MAX_FILE_BYTES { return Ok(None); @@ -291,7 +443,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; use std::sync::Mutex; - use tablepro_storage::DraftId; + use tablepro_storage::{DraftId, DraftStore}; use uuid::Uuid; use super::*; @@ -322,10 +474,14 @@ mod tests { store: WorkspaceStore, } + fn drafts(root: &Path) -> DraftStore { + DraftStore::new(root.join("drafts")) + } + impl Fixture { fn new() -> Self { let root = tempfile::tempdir().expect("tempdir"); - let store = WorkspaceStore::start(root.path().join("workspaces"), &paused_tasks()); + let store = WorkspaceStore::start(root.path().join("workspaces"), drafts(root.path()), &paused_tasks()); Self { root, store } } @@ -502,7 +658,11 @@ mod tests { fixture.store.reset(&key).await.expect("reset"); assert!(fixture.store.has_moved_aside_copy(&key)); // As a later run finds it on disk. - let restarted = WorkspaceStore::start(fixture.root.path().join("workspaces"), &paused_tasks()); + let restarted = WorkspaceStore::start( + fixture.root.path().join("workspaces"), + drafts(fixture.root.path()), + &paused_tasks(), + ); assert_eq!(restarted.load(&key).await, WorkspaceLoad::Empty); restarted.load(&other).await; @@ -562,7 +722,7 @@ mod tests { } Ok(()) }); - let store = WorkspaceStore::start_with(root.path().to_owned(), &paused_tasks(), write); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); let (a, b) = (key(), key()); let path_a = store.inner.path(&a); let path_b = store.inner.path(&b); @@ -589,6 +749,133 @@ mod tests { assert_eq!(for_path(&path_b), [4], "{written:?}"); } + #[tokio::test] + async fn purge_removes_the_file_its_moved_aside_copies_and_drafts() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + let path = fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + fixture.store.reset(&key).await.expect("reset"); + fixture.store.schedule(&key, record(1)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + let store = drafts(fixture.root.path()); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + store + .write_blocking(&scope, DraftId::new(), "SELECT 1") + .expect("a draft"); + let kept_scope = WorkspaceFileName::for_key(&other).draft_scope(); + store + .write_blocking(&kept_scope, DraftId::new(), "SELECT 2") + .expect("a draft"); + fixture.store.schedule(&other, record(2)).expect("scheduled"); + + fixture.store.purge(&key).await.expect("purged"); + + assert!(!path.exists(), "the saved tabs outlived the purge"); + assert!( + !tablepro_storage::fs::moved_aside_exists_blocking(&path).expect("list"), + "a copy Reset moved aside outlived the purge" + ); + assert!( + !store.root().join(scope.as_str()).exists(), + "the drafts outlived the purge" + ); + assert!( + store.root().join(kept_scope.as_str()).exists(), + "another session's drafts went" + ); + assert!(!fixture.store.has_moved_aside_copy(&key)); + fixture.store.flush(&other).await.expect("written"); + assert_eq!(fixture.on_disk(&other), record(2)); + } + + #[tokio::test] + async fn purge_drops_a_record_still_queued() { + let root = tempfile::tempdir().expect("tempdir"); + let written: Arc>> = Arc::default(); + let (started, mut starts) = mpsc::unbounded_channel::<()>(); + let (release, hold) = std::sync::mpsc::channel::<()>(); + let hold = Mutex::new(hold); + let log = written.clone(); + // Holds the first write open, so the record and the purge after + // it arrive in one round. + let write: Arc = Arc::new(move |path: &Path, bytes: &[u8]| { + let first = { + let mut log = log.lock().expect("the log"); + log.push(path.to_owned()); + log.len() == 1 + }; + let _ = started.send(()); + if first { + let _ = hold.lock().expect("the hold").recv(); + } + tablepro_storage::fs::write_private_blocking(path, bytes) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); + let (busy, purged) = (key(), key()); + + store.schedule(&busy, record(1)).expect("scheduled"); + starts.recv().await.expect("the first write started"); + store.schedule(&purged, record(2)).expect("scheduled"); + let purging = store.purge(&purged); + release.send(()).expect("release the first write"); + purging.await.expect("purged"); + + let path = store.inner.path(&purged); + assert!(!path.exists(), "the queued record was written after all"); + assert!( + !written.lock().expect("the log").contains(&path), + "the queued record was written and then removed" + ); + } + + #[tokio::test] + async fn a_purge_waits_for_drafts_still_landing() { + let fixture = Fixture::new(); + let key = key(); + let store = drafts(fixture.root.path()); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let closing = fixture.store.writes_in_flight(&key); + + let mut purging = Box::pin(fixture.store.purge(&key)); + let early = tokio::time::timeout(std::time::Duration::from_millis(50), &mut purging).await; + assert!(early.is_err(), "the purge ran with a draft still landing"); + store + .write_blocking(&scope, DraftId::new(), "SELECT 1") + .expect("the late draft"); + drop(closing); + purging.await.expect("purged"); + + assert!( + !store.root().join(scope.as_str()).exists(), + "a draft that landed late outlived the purge" + ); + } + + #[tokio::test] + async fn purging_a_session_with_nothing_saved_succeeds() { + let fixture = Fixture::new(); + + assert_eq!(fixture.store.purge(&key()).await, Ok(())); + } + + #[tokio::test] + async fn a_purge_that_cannot_delete_the_drafts_says_so() { + let fixture = Fixture::new(); + let key = key(); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let drafts_root = fixture.root.path().join("drafts"); + std::fs::create_dir_all(&drafts_root).expect("mkdir"); + // A file where the scope's directory goes cannot be removed as + // one. + std::fs::write(drafts_root.join(scope.as_str()), b"not a directory").expect("seed"); + + assert!(matches!( + fixture.store.purge(&key).await, + Err(WorkspaceSaveError::Purge(_)) + )); + } + #[tokio::test] async fn a_failed_write_is_reported_by_flush() { let root = tempfile::tempdir().expect("tempdir"); @@ -598,7 +885,7 @@ mod tests { source: std::io::Error::other("disk full"), }) }); - let store = WorkspaceStore::start_with(root.path().to_owned(), &paused_tasks(), write); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); let key = key(); store.schedule(&key, record(1)).expect("scheduled"); diff --git a/linux/crates/app/src/workspace/write_job.rs b/linux/crates/app/src/workspace/write_job.rs index 59e5e6f086..2e85c6a5a9 100644 --- a/linux/crates/app/src/workspace/write_job.rs +++ b/linux/crates/app/src/workspace/write_job.rs @@ -1,8 +1,9 @@ use std::path::PathBuf; +use tablepro_storage::DraftScope; use tokio::sync::oneshot; -use super::{WorkspaceRecord, WorkspaceSaveError}; +use super::{OpenSessionsRecord, WorkspaceRecord, WorkspaceSaveError}; /// What the writer task is asked to do, in the order it was asked. pub(super) enum WriteJob { @@ -10,10 +11,22 @@ pub(super) enum WriteJob { path: PathBuf, record: WorkspaceRecord, }, + OpenSessions { + path: PathBuf, + record: OpenSessionsRecord, + }, /// Answered once every record queued before it for `path` is on /// disk, with how the last write of that file went. Flush { path: PathBuf, written: oneshot::Sender>, }, + /// Delete the file, every copy of it moved aside and the drafts in + /// `drafts`, for a connection that was deleted. A record queued for + /// `path` in the same round is dropped rather than written. + Purge { + path: PathBuf, + drafts: DraftScope, + purged: oneshot::Sender>, + }, } diff --git a/linux/crates/app/src/workspace/writes_in_flight.rs b/linux/crates/app/src/workspace/writes_in_flight.rs new file mode 100644 index 0000000000..4984e59b35 --- /dev/null +++ b/linux/crates/app/src/workspace/writes_in_flight.rs @@ -0,0 +1,116 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::{Rc, Weak}; + +use tablepro_session::SessionKey; +use tokio::sync::oneshot; + +/// Writes for a session that run outside the workspace store, such as +/// the editor drafts of a workspace being closed, counted per session. +#[derive(Default)] +pub(super) struct OutsideWrites { + sessions: RefCell>, +} + +#[derive(Default)] +struct Landing { + writes: usize, + waiters: Vec>, +} + +impl OutsideWrites { + pub(super) fn begin(self: &Rc, key: &SessionKey) -> WritesInFlight { + self.sessions.borrow_mut().entry(key.clone()).or_default().writes += 1; + WritesInFlight { + outside: Rc::downgrade(self), + key: key.clone(), + } + } + + /// Resolves once every write for `key` has landed, or `None` when + /// none is in flight. + pub(super) fn landing(&self, key: &SessionKey) -> Option> { + let mut sessions = self.sessions.borrow_mut(); + let landing = sessions.get_mut(key)?; + let (landed, receiver) = oneshot::channel(); + landing.waiters.push(landed); + Some(receiver) + } + + fn end(&self, key: &SessionKey) { + let waiters = { + let mut sessions = self.sessions.borrow_mut(); + let Some(landing) = sessions.get_mut(key) else { + return; + }; + landing.writes = landing.writes.saturating_sub(1); + if landing.writes > 0 { + return; + } + sessions.remove(key).map(|landing| landing.waiters).unwrap_or_default() + }; + for waiter in waiters { + let _ = waiter.send(()); + } + } +} + +/// Counts writes for one session as still landing until it drops. +#[must_use = "the writes count as landed once this drops"] +pub struct WritesInFlight { + outside: Weak, + key: SessionKey, +} + +impl Drop for WritesInFlight { + fn drop(&mut self) { + if let Some(outside) = self.outside.upgrade() { + outside.end(&self.key); + } + } +} + +impl std::fmt::Debug for WritesInFlight { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WritesInFlight").field("key", &self.key).finish() + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + fn key() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + #[test] + fn nothing_in_flight_has_nothing_to_wait_for() { + let outside = Rc::new(OutsideWrites::default()); + + assert!(outside.landing(&key()).is_none()); + } + + #[tokio::test] + async fn the_wait_ends_when_the_last_write_lands() { + let outside = Rc::new(OutsideWrites::default()); + let (key, other) = (key(), key()); + let first = outside.begin(&key); + let second = outside.begin(&key); + let _unrelated = outside.begin(&other); + let mut landing = outside.landing(&key).expect("writes in flight"); + + drop(first); + assert!(landing.try_recv().is_err(), "the wait ended with a write still landing"); + drop(second); + + assert!(landing.await.is_ok()); + assert!(outside.landing(&key).is_none()); + assert!( + outside.landing(&other).is_some(), + "another session's writes were counted as landed" + ); + } +} diff --git a/linux/crates/app/tests/application_lifecycle.rs b/linux/crates/app/tests/application_lifecycle.rs new file mode 100644 index 0000000000..51a072bfe4 --- /dev/null +++ b/linux/crates/app/tests/application_lifecycle.rs @@ -0,0 +1,497 @@ +//! Quitting a running TablePro, in process, over fake connections. +//! +//! Each case runs in a child process of its own: GTK allows one +//! application per process on the main thread, and the application id +//! can be registered on the bus only once at a time. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::cell::Cell; +use std::path::Path; +use std::process::{Command, ExitCode, Stdio}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_app::application::{AppServices, AppServicesParts, TableProApplication, WindowLaunch}; +use tablepro_app::runtime::AppRuntime; +use tablepro_app::storage::AppStorage; +use tablepro_core::{AuthMode, DriverRegistry, EditorSession, LivenessPolicy, TlsConfig, TransactionState}; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::{AppSettings, SavedConnection, StoragePaths}; +use tablepro_test_support::{ + CloseBehaviour, ConnectionScript, FakeConnection, FakeDriver, FakeEditorSession, FakeReply, FakeRun, + FakeSecretVault, Replies, +}; + +const CASE: &str = "TABLEPRO_LIFECYCLE_CASE"; +const CASES: &[&str] = &[ + "quit_waits_for_300ms_close_within_timeout_budget", + "hanging_close_abandoned_at_close_timeout_and_run_returns", + "cancelled_close_guard_keeps_app_running", + "editor_session_close_keeps_app_alive_until_resolved", +]; +/// A case that has not finished by now never will. +const CASE_LIMIT: Duration = Duration::from_secs(90); +const STEP_LIMIT: Duration = Duration::from_secs(20); + +fn main() -> ExitCode { + if let Ok(case) = std::env::var(CASE) { + return child(&case); + } + parent() +} + +/// A private session bus, so a TablePro the developer has open never +/// answers for the application under test. +fn parent() -> ExitCode { + let display = Display::current(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let mut failed = false; + for case in CASES { + match run_case(case, &display) { + Ok(()) => eprintln!("{case}: ok"), + Err(message) => { + eprintln!("{case} failed: {message}"); + failed = true; + } + } + } + bus.down(); + if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS } +} + +/// `g_test_dbus_up` unsets `DISPLAY` along with the session bus, so the +/// display is read before and handed to each case. +struct Display(Vec<(&'static str, std::ffi::OsString)>); + +impl Display { + fn current() -> Self { + Self( + ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(), + ) + } +} + +fn run_case(case: &str, display: &Display) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|error| format!("could not find the test binary: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the case runs, + // and a full one would stop it mid-write. + let log = tempfile::NamedTempFile::new().map_err(|error| format!("no log file: {error}"))?; + let stderr = log + .reopen() + .map_err(|error| format!("could not open the log file: {error}"))?; + let mut child = Command::new(exe) + .envs(display.0.iter().cloned()) + .env(CASE, case) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env_remove("LANGUAGE") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|error| format!("could not start the case: {error}"))?; + let deadline = Instant::now() + CASE_LIMIT + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + break; + } + Err(error) => return Err(format!("could not wait for the case: {error}")), + } + } + let status = child + .wait() + .map_err(|error| format!("could not wait for the case: {error}"))?; + if status.success() { + return Ok(()); + } + let stderr = std::fs::read_to_string(log.path()).unwrap_or_default(); + Err(format!("exited with {status}:\n{stderr}")) +} + +/// What a case scripts the fake connection and editor session to do. +struct Plan { + connection_close: CloseBehaviour, + close_timeout: Duration, + editor: Option, +} + +struct EditorPlan { + leaves: TransactionState, + close: CloseBehaviour, +} + +fn child(case: &str) -> ExitCode { + std::thread::spawn(|| { + std::thread::sleep(CASE_LIMIT); + eprintln!("the case did not finish within {CASE_LIMIT:?}"); + std::process::exit(2); + }); + match run_child(case) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run_child(case: &str) -> Result<(), String> { + let plan = match case { + "quit_waits_for_300ms_close_within_timeout_budget" => Plan { + connection_close: CloseBehaviour::Delay(Duration::from_millis(300)), + close_timeout: Duration::from_secs(2), + editor: None, + }, + "hanging_close_abandoned_at_close_timeout_and_run_returns" => Plan { + connection_close: CloseBehaviour::Hang, + close_timeout: Duration::from_millis(200), + editor: None, + }, + "cancelled_close_guard_keeps_app_running" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::InTransaction, + close: CloseBehaviour::Immediate, + }), + }, + "editor_session_close_keeps_app_alive_until_resolved" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::Idle, + close: CloseBehaviour::Delay(Duration::from_millis(100)), + }), + }, + other => return Err(format!("no case named {other}")), + }; + let world = World::build(&plan)?; + let outcome = world.run(case); + world.runtime.shutdown(); + outcome +} + +/// A TablePro over fakes, with what the case reads after it exits. +struct World { + runtime: AppRuntime, + app: TableProApplication, + connector: FakeConnector, + editor: Option>, + saved: SavedConnection, + _dir: tempfile::TempDir, +} + +impl World { + fn build(plan: &Plan) -> Result { + gio::resources_register_include!("tablepro.gresource") + .map_err(|error| format!("could not register the resources: {error}"))?; + let dir = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let runtime = AppRuntime::build().map_err(|error| format!("no runtime: {error}"))?; + let settings = Rc::new(settings(dir.path())?); + let paths = StoragePaths::under(dir.path(), "tablepro", tablepro_app::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + + let mut script = ConnectionScript { + close: plan.connection_close, + ..ConnectionScript::default() + }; + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + let editor = plan.editor.as_ref().map(|editor| { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(editor.leaves)); + Arc::new(FakeEditorSession::with_close(runs, editor.close)) + }); + if let Some(editor) = &editor { + let session: Arc = editor.clone(); + script.open_editor_session.always(FakeReply::ok(session)); + } + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + script.clone(), + ))); + let connector = FakeConnector::new([]).always(FakeEstablish::Connect(script)); + let liveness = LivenessPolicy { + close_timeout: plan.close_timeout, + ..LivenessPolicy::DESKTOP + }; + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings, + storage, + tasks: runtime.tasks(), + }); + let saved = saved_connection(&dir.path().join("fake.db")); + Ok(Self { + app: TableProApplication::new(services), + runtime, + connector, + editor, + saved, + _dir: dir, + }) + } + + fn run(&self, case: &str) -> Result<(), String> { + let steps = Steps::default(); + let saved = self.saved.clone(); + let (connector, editor, recorded) = (self.connector.clone(), self.editor.clone(), steps.clone()); + let case_name = case.to_owned(); + self.app.connect_startup(move |app| { + let Some(window) = app.open_window(WindowLaunch::Connect(Box::new(saved.clone()))) else { + fail("the application opened no window"); + }; + let (app, connector, editor, recorded, case) = ( + app.clone(), + connector.clone(), + editor.clone(), + recorded.clone(), + case_name.clone(), + ); + glib::spawn_future_local(async move { + if let Err(message) = script(&case, &app, &window, &connector, editor.as_deref(), &recorded).await { + fail(&message); + } + }); + }); + + let code = self.app.run_with_args(&["tablepro"]); + let returned = Instant::now(); + if code != glib::ExitCode::SUCCESS { + return Err(format!("run() returned {code:?}")); + } + let quit_at = steps.quit_at.get().ok_or("the case never asked to quit")?; + let took = returned.duration_since(quit_at); + let connection = self + .connector + .connections() + .into_iter() + .next() + .ok_or("no connection was made")?; + check(case, &steps, &connection, self.editor.as_deref(), took) + } +} + +fn check( + case: &str, + steps: &Steps, + connection: &FakeConnection, + editor: Option<&FakeEditorSession>, + took: Duration, +) -> Result<(), String> { + match case { + "quit_waits_for_300ms_close_within_timeout_budget" => { + expect(connection.close_finished(), "run() returned before the close finished")?; + expect( + took >= Duration::from_millis(300), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(2), &format!("the quit took {took:?}")) + } + "hanging_close_abandoned_at_close_timeout_and_run_returns" => { + expect(connection.is_closed(), "the close never started")?; + expect(!connection.close_finished(), "a hanging close finished")?; + expect( + took >= Duration::from_millis(200), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(5), &format!("the quit took {took:?}")) + } + "cancelled_close_guard_keeps_app_running" => { + expect(steps.kept_running.get(), "the cancelled quit closed the window")?; + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "the editor session was left open")?; + expect(connection.close_finished(), "the connection was left open") + } + "editor_session_close_keeps_app_alive_until_resolved" => { + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "run() returned before the editor session closed") + } + other => Err(format!("no checks for {other}")), + } +} + +/// What the script running inside the application saw. +#[derive(Clone, Default)] +struct Steps { + quit_at: Rc>>, + kept_running: Rc>, +} + +async fn script( + case: &str, + app: &TableProApplication, + window: &adw::ApplicationWindow, + connector: &FakeConnector, + editor: Option<&FakeEditorSession>, + steps: &Steps, +) -> Result<(), String> { + let services = app.services().ok_or("the application has no services")?; + until("the session", || services.sessions.live_count() == 1).await?; + if let Some(editor) = editor { + run_in_a_new_editor_tab(window, editor).await?; + } + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + if case != "cancelled_close_guard_keeps_app_running" { + return Ok(()); + } + + answer(window, "Close With Open Transactions?", "cancel").await?; + glib::timeout_future(Duration::from_millis(500)).await; + let editor = editor.ok_or("no editor session")?; + let connection = connector.connections().into_iter().next().ok_or("no connection")?; + if app.windows().len() != 1 || editor.is_closed() || connection.is_closed() { + return Err("the quit went on after the close was cancelled".to_owned()); + } + steps.kept_running.set(true); + + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + answer(window, "Close With Open Transactions?", "close").await +} + +/// Open an editor tab through its window action and run what it holds, +/// which opens the tab's own editor session. +async fn run_in_a_new_editor_tab(window: &adw::ApplicationWindow, editor: &FakeEditorSession) -> Result<(), String> { + WidgetExt::activate_action(window, "win.open-editor", None).map_err(|error| format!("win.open-editor: {error}"))?; + let mut run = None; + until("the editor's Run button", || { + run = descendants(window.upcast_ref()) + .into_iter() + .filter(|widget| widget.type_().name() == "TableProSqlEditor") + .flat_map(|editor| descendants(&editor)) + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Run")); + run.is_some() + }) + .await?; + let run = run.ok_or("no Run button")?; + run.emit_clicked(); + until("the run to reach the editor session", || !editor.texts().is_empty()).await?; + until("the run to end", || run.is_sensitive()).await +} + +/// Wait for the alert headed `heading` and click its `response`. +async fn answer(window: &adw::ApplicationWindow, heading: &str, response: &str) -> Result<(), String> { + let mut shown = None; + until(heading, || { + shown = window + .visible_dialog() + .and_downcast::() + .filter(|alert| alert.heading().as_deref() == Some(heading)); + shown.is_some() + }) + .await?; + let alert = shown.ok_or("no alert")?; + let label = alert.response_label(response); + let button = descendants(alert.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + .ok_or_else(|| format!("“{heading}” has no {response} button"))?; + button.emit_clicked(); + Ok(()) +} + +async fn until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + STEP_LIMIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + glib::timeout_future(Duration::from_millis(10)).await; + } + Ok(()) +} + +fn descendants(root: >k4::Widget) -> Vec { + let mut found = Vec::new(); + let mut pending: Vec = root.first_child().into_iter().collect(); + while let Some(widget) = pending.pop() { + pending.extend(widget.next_sibling()); + pending.extend(widget.first_child()); + found.push(widget); + } + found +} + +fn expect(holds: bool, otherwise: &str) -> Result<(), String> { + if holds { Ok(()) } else { Err(otherwise.to_owned()) } +} + +/// A step that cannot go on leaves the application holding windows and +/// connections, so the case ends here rather than waiting on them. +fn fail(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +fn saved_connection(database: &Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The schema compiled into the case's own directory behind a memory +/// backend, so the case never reads or writes the developer's settings. +fn settings(dir: &Path) -> Result { + let schemas = dir.join("schemas"); + std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, schemas.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(&schemas) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if !output.status.success() { + return Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + AppSettings::with_backend( + tablepro_app::config::SCHEMA_ID, + &schemas, + &gio::memory_settings_backend_new(), + ) + .map_err(|error| format!("could not open the settings: {error}")) +} diff --git a/linux/crates/app/tests/application_session_restore.rs b/linux/crates/app/tests/application_session_restore.rs new file mode 100644 index 0000000000..2537fb2e09 --- /dev/null +++ b/linux/crates/app/tests/application_session_restore.rs @@ -0,0 +1,384 @@ +//! The windows open when TablePro quits open again at the next launch, +//! and a window the user closed first does not. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +const CALL_TIMEOUT_MS: i32 = 5_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut sandbox = Sandbox::new()?; + // g_test_dbus_up unsets DISPLAY along with the session bus. + sandbox.display = ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| launches(&sandbox, &address)); + bus.down(); + outcome +} + +fn launches(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + let first = sandbox.database("first.db")?; + let second = sandbox.database("second.db")?; + sandbox.record(&[first.as_str(), second.as_str()])?; + + sandbox.launch(address, "first", || { + wait_until("both recorded windows", || window_ids(&connection).len() == 2)?; + quit(&connection) + })?; + let recorded = sandbox.recorded()?; + if recorded != [first.clone(), second.clone()] { + return Err(format!("the quit left {recorded:?} recorded")); + } + + sandbox.launch(address, "second", || { + wait_until("both windows again", || window_ids(&connection).len() == 2)?; + close_one_window(&connection)?; + quit(&connection) + })?; + let recorded = sandbox.recorded()?; + if recorded.len() != 1 { + return Err(format!("closing a window before the quit left {recorded:?} recorded")); + } + + sandbox.launch(address, "third", || { + wait_until("the one window left", || window_ids(&connection).len() == 1)?; + // The other window would open with it, so it would be here by now. + std::thread::sleep(Duration::from_millis(500)); + let windows = window_ids(&connection).len(); + if windows != 1 { + return Err(format!("{windows} windows after one was closed before the quit")); + } + quit(&connection) + }) +} + +/// `win.close-current` closes the active tab, and the window once it +/// has none, as Ctrl+W does. +fn close_one_window(connection: &gio::DBusConnection) -> Result<(), String> { + let ids = window_ids(connection); + let id = ids.first().ok_or("no window to close")?; + let path = format!("{}/window/{id}", object_path()); + for _ in 0..5 { + activate(connection, &path, "close-current")?; + let closed = wait_until("the window to close", || window_ids(connection).len() < ids.len()); + if closed.is_ok() { + return Ok(()); + } + } + Err("close-current never closed the window".to_owned()) +} + +/// Ask the primary to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + activate(connection, &object_path(), "quit") +} + +fn activate(connection: &gio::DBusConnection, path: &str, action: &str) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = (action, no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + path, + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("{action} at {path} over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_ids(connection: &gio::DBusConnection) -> Vec { + let Some((xml,)) = connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + else { + return Vec::new(); + }; + xml.split(" String { + format!("/{}", APP_ID.replace('.', "/").replace('-', "_")) +} + +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("tablepro did not exit within {WAIT:?}")); + } + Err(error) => return Err(format!("could not wait for tablepro: {error}")), + } + } +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so every launch reads and writes +/// nothing of the developer's. +struct Sandbox { + root: tempfile::TempDir, + schemas: PathBuf, + config: PathBuf, + data: PathBuf, + state: PathBuf, + cache: PathBuf, + runtime: PathBuf, + display: Vec<(&'static str, std::ffi::OsString)>, +} + +impl Sandbox { + fn new() -> Result { + let root = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let dir = |name: &str| -> Result { + let path = root.path().join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let sandbox = Self { + schemas: dir("schemas")?, + config: dir("config")?, + data: dir("data")?, + state: dir("state")?, + cache: dir("cache")?, + runtime: dir("runtime")?, + display: Vec::new(), + root, + }; + private(&sandbox.runtime)?; + compile_schema(&sandbox.schemas)?; + Ok(sandbox) + } + + /// An empty SQLite database, by the URI the application keys it by. + fn database(&self, name: &str) -> Result { + let dir = std::fs::canonicalize(self.root.path()) + .map_err(|error| format!("could not resolve the sandbox: {error}"))?; + let path = dir.join(name); + std::fs::write(&path, b"").map_err(|error| format!("could not create {name}: {error}"))?; + glib::filename_to_uri(&path, None) + .map(|uri| uri.to_string()) + .map_err(|error| format!("no URI for {name}: {error}")) + } + + fn record_path(&self) -> PathBuf { + self.state + .join(tablepro_app::config::storage_dir_name()) + .join("workspaces") + .join("open-sessions.json") + } + + /// Write the files as the sessions open at the last quit. + fn record(&self, uris: &[&str]) -> Result<(), String> { + let sessions: Vec = uris + .iter() + .map(|uri| serde_json::json!({ "kind": "file", "uri": uri })) + .collect(); + let record = serde_json::json!({ "version": 1, "sessions": sessions }); + let path = self.record_path(); + let parent = path.parent().ok_or("the record has no directory")?; + std::fs::create_dir_all(parent).map_err(|error| format!("could not create {}: {error}", parent.display()))?; + std::fs::write(&path, record.to_string()).map_err(|error| format!("could not write the record: {error}")) + } + + /// The file URIs recorded, in order. + fn recorded(&self) -> Result, String> { + let bytes = std::fs::read(self.record_path()).map_err(|error| format!("no record after the quit: {error}"))?; + let record: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|error| format!("the record is not JSON: {error}"))?; + let sessions = record["sessions"] + .as_array() + .ok_or_else(|| format!("the record lists no sessions: {record}"))?; + sessions + .iter() + .map(|session| { + session["uri"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("a recorded session is not a file: {session}")) + }) + .collect() + } + + /// Start TablePro, run `steps` against it, and wait for it to exit + /// with success. + fn launch(&self, address: &str, name: &str, steps: impl FnOnce() -> Result<(), String>) -> Result<(), String> { + // A file rather than a pipe: nobody reads a pipe while the app + // runs, and a full one would stop it mid-write. + let log_path = self.root.path().join(format!("{name}.log")); + let log = std::fs::File::create(&log_path).map_err(|error| format!("could not create the log: {error}"))?; + let mut child = self + .tablepro(address) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .map_err(|error| format!("could not start the {name} launch: {error}"))?; + let outcome = wait_until_owned(address) + .and_then(|()| steps()) + .and_then(|()| wait_for_exit(&mut child)) + .and_then(|status| { + if status.success() { + Ok(()) + } else { + Err(format!("the {name} launch exited with {status}")) + } + }); + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + outcome.map_err(|message| { + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + format!("{message}\n{name} launch stderr:\n{log}") + }) + } + + fn tablepro(&self, address: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .envs(self.display.iter().cloned()) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("GSETTINGS_SCHEMA_DIR", &self.schemas) + .env("XDG_CONFIG_HOME", &self.config) + .env("XDG_DATA_HOME", &self.data) + .env("XDG_STATE_HOME", &self.state) + .env("XDG_CACHE_HOME", &self.cache) + .env("XDG_RUNTIME_DIR", &self.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command + } +} + +fn wait_until_owned(address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + wait_until("the application to own its name", || name_has_owner(&connection)) +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} + +fn compile_schema(dir: &Path) -> Result<(), String> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/app/tests/application_uniqueness.rs b/linux/crates/app/tests/application_uniqueness.rs new file mode 100644 index 0000000000..bf8804fab6 --- /dev/null +++ b/linux/crates/app/tests/application_uniqueness.rs @@ -0,0 +1,293 @@ +//! One TablePro per session: a second launch forwards its activation to +//! the running one over D-Bus and exits. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +const CALL_TIMEOUT_MS: i32 = 5_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut sandbox = Sandbox::new()?; + // g_test_dbus_up unsets DISPLAY along with the session bus. + sandbox.display = ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| launches(&sandbox, &address)); + bus.down(); + outcome +} + +fn launches(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the primary + // runs, and a full one would stop it mid-write. + let log_path = sandbox.root().join("primary.log"); + let log = std::fs::File::create(&log_path).map_err(|error| format!("could not create the log: {error}"))?; + let mut primary = sandbox + .tablepro(address, &[]) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .map_err(|error| format!("could not start the primary: {error}"))?; + + let outcome = with_primary(sandbox, address, &connection); + if outcome.is_ok() { + quit(&connection)?; + let status = wait_for_exit(&mut primary)?; + if !status.success() { + return Err(format!("the primary exited with {status} after app.quit")); + } + return Ok(()); + } + let _ = primary.kill(); + let _ = primary.wait(); + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + outcome.map_err(|message| format!("{message}\nprimary stderr:\n{log}")) +} + +fn with_primary(sandbox: &Sandbox, address: &str, connection: &gio::DBusConnection) -> Result<(), String> { + wait_until("the primary to own its name", || name_has_owner(connection))?; + wait_until("the primary's first window", || window_count(connection) == 1)?; + + let plain = launch(sandbox, address, &[])?; + if !plain.success() { + return Err(format!("a second plain launch exited with {plain}")); + } + let new_window = launch(sandbox, address, &["--new-window"])?; + if !new_window.success() { + return Err(format!("a launch with --new-window exited with {new_window}")); + } + + wait_until("the second window", || window_count(connection) >= 2)?; + // The plain launch's activation reached the primary first, so a + // window it opened by mistake would be here by now. + std::thread::sleep(Duration::from_millis(500)); + let windows = window_count(connection); + if windows != 2 { + return Err(format!("{windows} windows after one plain launch and one --new-window")); + } + Ok(()) +} + +/// Ask the primary to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = ("quit", no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("app.quit over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_count(connection: &gio::DBusConnection) -> usize { + connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + .map_or(0, |(xml,)| xml.matches(" Result { + let mut child = sandbox + .tablepro(address, arguments) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("could not launch tablepro {arguments:?}: {error}"))?; + wait_for_exit(&mut child) +} + +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("tablepro did not exit within {WAIT:?}")); + } + Err(error) => return Err(format!("could not wait for tablepro: {error}")), + } + } +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so every launch reads and writes +/// nothing of the developer's. +struct Sandbox { + root: tempfile::TempDir, + schemas: PathBuf, + config: PathBuf, + data: PathBuf, + state: PathBuf, + cache: PathBuf, + runtime: PathBuf, + display: Vec<(&'static str, std::ffi::OsString)>, +} + +impl Sandbox { + fn new() -> Result { + let root = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let dir = |name: &str| -> Result { + let path = root.path().join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let sandbox = Self { + schemas: dir("schemas")?, + config: dir("config")?, + data: dir("data")?, + state: dir("state")?, + cache: dir("cache")?, + runtime: dir("runtime")?, + display: Vec::new(), + root, + }; + private(&sandbox.runtime)?; + compile_schema(&sandbox.schemas)?; + Ok(sandbox) + } + + fn root(&self) -> &Path { + self.root.path() + } + + fn tablepro(&self, address: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .args(arguments) + .envs(self.display.iter().cloned()) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("GSETTINGS_SCHEMA_DIR", &self.schemas) + .env("XDG_CONFIG_HOME", &self.config) + .env("XDG_DATA_HOME", &self.data) + .env("XDG_STATE_HOME", &self.state) + .env("XDG_CACHE_HOME", &self.cache) + .env("XDG_RUNTIME_DIR", &self.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command + } +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} + +fn compile_schema(dir: &Path) -> Result<(), String> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/app/tests/desktop_entry.rs b/linux/crates/app/tests/desktop_entry.rs new file mode 100644 index 0000000000..6a1e7f94c8 --- /dev/null +++ b/linux/crates/app/tests/desktop_entry.rs @@ -0,0 +1,58 @@ +//! The desktop entry offers TablePro for SQLite files and hands them +//! over the way the application expects. + +use std::path::Path; + +const MAIN: &str = "Desktop Entry"; +const NEW_WINDOW: &str = "Desktop Action new-window"; + +fn entry() -> Result { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/app.tablepro.TablePro.desktop.in.in"); + let entry = glib::KeyFile::new(); + entry.load_from_file(&path, glib::KeyFileFlags::NONE)?; + Ok(entry) +} + +fn list(entry: &glib::KeyFile, group: &str, key: &str) -> Result, glib::Error> { + Ok(entry + .string_list(group, key)? + .iter() + .map(|item| item.to_string()) + .collect()) +} + +#[test] +fn sqlite_files_are_offered_to_tablepro() { + let entry = entry().expect("the desktop entry parses"); + + let types = list(&entry, MAIN, "MimeType").expect("a MimeType list"); + + assert!( + types.contains(&"application/vnd.sqlite3".to_owned()), + "the entry does not take SQLite files: {types:?}" + ); +} + +#[test] +fn files_reach_the_running_application() { + let entry = entry().expect("the desktop entry parses"); + + let exec = entry.string(MAIN, "Exec").expect("an Exec line"); + + assert!(exec.ends_with(" %U"), "Exec passes no files: {exec}"); + assert!( + entry.boolean(MAIN, "DBusActivatable").expect("DBusActivatable"), + "files would start a second process instead of reaching the running one" + ); +} + +#[test] +fn new_window_is_a_desktop_action() { + let entry = entry().expect("the desktop entry parses"); + + let actions = list(&entry, MAIN, "Actions").expect("an Actions list"); + let name = entry.string(NEW_WINDOW, "Name").expect("the action's name"); + + assert!(actions.contains(&"new-window".to_owned()), "{actions:?}"); + assert!(!name.is_empty()); +} diff --git a/linux/crates/app/tests/open_files.rs b/linux/crates/app/tests/open_files.rs new file mode 100644 index 0000000000..a35bf3a21c --- /dev/null +++ b/linux/crates/app/tests/open_files.rs @@ -0,0 +1,313 @@ +//! A database file handed over D-Bus, the way Files' Open With does, +//! starts TablePro and opens a window on it. The same file again brings +//! that window forward; another file opens a second window. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +/// The first Open waits for D-Bus to start the service. +const CALL_TIMEOUT_MS: i32 = 30_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let root = tempfile::Builder::new() + .prefix("open-files-") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .map_err(|error| format!("no temporary directory: {error}"))?; + let sandbox = Sandbox::new(root.path())?; + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.add_service_dir(&sandbox.services.to_string_lossy()); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| opens(&sandbox, &address)); + bus.down(); + outcome +} + +fn opens(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + let first = sandbox.database("first.sqlite3")?; + let second = sandbox.database("second.sqlite3")?; + + let outcome = open_twice_then_another(&connection, &first, &second); + if name_has_owner(&connection) { + let quitting = quit(&connection) + .and_then(|()| wait_until("TablePro to exit after app.quit", || !name_has_owner(&connection))); + if let (Ok(()), Err(message)) = (&outcome, quitting) { + return Err(message); + } + } + outcome +} + +fn open_twice_then_another(connection: &gio::DBusConnection, first: &str, second: &str) -> Result<(), String> { + open(connection, first)?; + wait_until("a window on the first file", || window_ids(connection).len() == 1)?; + let window = window_ids(connection) + .first() + .copied() + .ok_or_else(|| "the first window went away".to_owned())?; + // Until the window shows the file's workspace it has not claimed the + // file, and a second Open could not find it. + wait_until("the first file's workspace", || shows_workspace(connection, window))?; + + open(connection, first)?; + std::thread::sleep(Duration::from_millis(500)); + let windows = window_ids(connection).len(); + if windows != 1 { + return Err(format!("{windows} windows after opening the same file twice")); + } + + open(connection, second)?; + wait_until("a window on the second file", || window_ids(connection).len() == 2) +} + +/// What Files sends for Open With on a D-Bus activatable application. +fn open(connection: &gio::DBusConnection, uri: &str) -> Result<(), String> { + let platform_data: HashMap = HashMap::new(); + let arguments = (vec![uri.to_owned()], platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.freedesktop.Application", + "Open", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("Open({uri}) over D-Bus failed: {error}")) +} + +/// Ask TablePro to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = ("quit", no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("app.quit over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_ids(connection: &gio::DBusConnection) -> Vec { + let xml = connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + .map(|(xml,)| xml) + .unwrap_or_default(); + xml.split(" bool { + connection + .call_sync( + Some(APP_ID), + &format!("{}/window/{window}", object_path()), + "org.gtk.Actions", + "Describe", + Some(&("disconnect",).to_variant()), + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.child_value(0).child_value(0).get::()) + .unwrap_or(false) +} + +/// GApplication's object path: the id with dots as slashes, as GTK's +/// window paths `/window/` build on. +fn object_path() -> String { + format!("/{}", APP_ID.replace('.', "/").replace('-', "_")) +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so the started TablePro reads and +/// writes nothing of the developer's. +struct Sandbox { + root: PathBuf, + services: PathBuf, +} + +impl Sandbox { + fn new(root: &Path) -> Result { + let dir = |name: &str| -> Result { + let path = root.join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let schemas = dir("schemas")?; + let runtime = dir("runtime")?; + private(&runtime)?; + compile_schema(&schemas)?; + let mut environment = vec![ + ("LC_ALL".to_owned(), PathBuf::from("C.UTF-8")), + ("GSETTINGS_BACKEND".to_owned(), PathBuf::from("memory")), + ("GSETTINGS_SCHEMA_DIR".to_owned(), schemas), + ("XDG_CONFIG_HOME".to_owned(), dir("config")?), + ("XDG_DATA_HOME".to_owned(), dir("data")?), + ("XDG_STATE_HOME".to_owned(), dir("state")?), + ("XDG_CACHE_HOME".to_owned(), dir("cache")?), + ("XDG_RUNTIME_DIR".to_owned(), runtime), + ]; + // GTestDBus clears DISPLAY once it is up, so the display goes + // into the service's own environment. + for name in ["DISPLAY", "WAYLAND_DISPLAY"] { + if let Some(value) = std::env::var_os(name) { + environment.push((name.to_owned(), PathBuf::from(value))); + } + } + let services = dir("services")?; + write_service(&services, &environment)?; + Ok(Self { + root: root.to_path_buf(), + services, + }) + } + + /// An empty file, which SQLite reads as an empty database, by URI. + fn database(&self, name: &str) -> Result { + let path = self.root.join(name); + std::fs::write(&path, b"").map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(gio::File::for_path(&path).uri().to_string()) + } +} + +/// The D-Bus service file that starts this build's binary, with the +/// sandbox set in its Exec line rather than in this process. +fn write_service(dir: &Path, environment: &[(String, PathBuf)]) -> Result<(), String> { + let mut exec = vec!["/usr/bin/env".to_owned()]; + for (name, value) in environment { + exec.push(quoted(&format!("{name}={}", value.display()))?); + } + exec.push(quoted(env!("CARGO_BIN_EXE_tablepro"))?); + exec.push("--gapplication-service".to_owned()); + let service = format!("[D-BUS Service]\nName={APP_ID}\nExec={}\n", exec.join(" ")); + let path = dir.join(format!("{APP_ID}.service")); + std::fs::write(&path, service).map_err(|error| format!("could not write {}: {error}", path.display())) +} + +/// dbus-daemon splits Exec the way a shell does. +fn quoted(word: &str) -> Result { + if word.contains('\'') { + return Err(format!("cannot quote {word} for a D-Bus service file")); + } + Ok(format!("'{word}'")) +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} + +fn compile_schema(dir: &Path) -> Result<(), String> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs index ee3b17b447..917dadead6 100644 --- a/linux/crates/app/tests/startup.rs +++ b/linux/crates/app/tests/startup.rs @@ -76,9 +76,9 @@ fn compiled_schema_dir() -> Result { /// schema is installed would be answered by their machine instead of /// by the code. /// -/// The single-instance lock lives under `XDG_RUNTIME_DIR`, so a TablePro -/// the developer has open would otherwise hold it, and the binary under -/// test would exit at once without printing or reporting anything. +/// `XDG_RUNTIME_DIR` is the test's own, so nothing the binary leaves +/// there, such as SSH control sockets, lands beside a TablePro the +/// developer has open. fn tablepro(sandbox: &Sandbox<'_>) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); command diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs index 4c4582ecfe..95a6bd270a 100644 --- a/linux/crates/app/tests/tracker_save_postgres.rs +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -14,6 +14,7 @@ use secrecy::SecretString; use tablepro_app::change_tracker::{RowKey, pk_identity}; use tablepro_app::tab_tracker_handle::TabTrackerHandle; use tablepro_app::tracker_edit::TrackerEdit; +use tablepro_core::edit::CellInput; use tablepro_core::meta::TableRef; use tablepro_core::save::plan_save; use tablepro_core::statement::{GuardedStatement, Statement, StatementBuilder}; @@ -99,12 +100,20 @@ async fn replace_by_unique_key_saves() { let table = TableRef::new(Some("public".to_owned()), "users"); let columns = connection.fetch_columns(&table, within()).await.expect("the columns"); let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::Delete { - row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), - original_values: vec![Value::Int(1), Value::Text("a".to_owned())], + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![( + RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + vec![Value::Int(1), Value::Text("a".to_owned())].into(), + )], + drafts: Vec::new(), }); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(2), Value::Text("a".to_owned())], + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(2), Value::Text("a".to_owned())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let changes = tracker.change_set(&columns).expect("a change set"); diff --git a/linux/crates/core/src/ddl/build_ddl_error.rs b/linux/crates/core/src/ddl/build_ddl_error.rs index 4e520f7635..39a2b1c3a8 100644 --- a/linux/crates/core/src/ddl/build_ddl_error.rs +++ b/linux/crates/core/src/ddl/build_ddl_error.rs @@ -31,4 +31,12 @@ pub enum BuildDdlError { /// server's own text would recreate it faithfully. #[error("the server gave no CREATE statement for this table")] NoTableSource, + /// A view, trigger or index a table rebuild makes again, whose stored + /// statement cannot run as it is. + #[error("the stored statement of {0}, which the rebuild makes again, cannot run as it is")] + UnreplayableDependent(String), + /// The statements a save would run are not the ones the user + /// reviewed, because the draft or the connection changed meanwhile. + #[error("the statements to run are not the ones reviewed")] + NotAsReviewed, } diff --git a/linux/crates/core/src/ddl/change_review.rs b/linux/crates/core/src/ddl/change_review.rs new file mode 100644 index 0000000000..403dbfa1c5 --- /dev/null +++ b/linux/crates/core/src/ddl/change_review.rs @@ -0,0 +1,102 @@ +use super::{DdlPlan, DestructiveChange, DraftType, TableChanges}; +use crate::write_ledger::WriteAtomicity; + +/// What a structure save runs and what it loses, for the user to read +/// before it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeReview { + /// The edits the user made, counted as the action bar counts them. + pub operations: usize, + pub statements: Vec, + /// The rebuild first, then the drops and type changes. + pub destructive: Vec, +} + +impl ChangeReview { + /// `plan` is the one the engine wrote for `changes`, which may drop + /// more than the changes name: a rebuild drops the whole table. + pub fn review(changes: &TableChanges, plan: &DdlPlan) -> Self { + let mut destructive: Vec = plan + .rebuild + .iter() + .cloned() + .map(DestructiveChange::RebuildTable) + .collect(); + destructive.extend( + changes + .dropped_columns + .iter() + .map(|column| DestructiveChange::DropColumn { + column: column.name().to_owned(), + }), + ); + destructive.extend( + changes + .altered_columns + .iter() + .filter(|alteration| alteration.changed.column_type) + .map(|alteration| { + let before = &alteration.original.info.column_type; + let from = match DraftType::Catalog(before.clone()).text() { + "" => before.name.clone(), + declaration => declaration.to_owned(), + }; + DestructiveChange::ChangeColumnType { + column: alteration.original.name().to_owned(), + from, + to: alteration.draft.column_type.text().to_owned(), + } + }), + ); + destructive.extend( + changes + .dropped_indexes + .iter() + .map(|index| DestructiveChange::DropIndex { + index: index.name.clone(), + }), + ); + destructive.extend( + changes + .dropped_foreign_keys + .iter() + .map(|key| DestructiveChange::DropForeignKey { key: key.name.clone() }), + ); + Self { + operations: changes.change_count(), + statements: statements(plan), + destructive, + } + } + + /// A new table: one edit, and nothing there yet to lose. + pub fn create(plan: &DdlPlan) -> Self { + Self { + operations: 1, + statements: statements(plan), + destructive: Vec::new(), + } + } + + /// Whether the user has to agree first: when something is lost, or + /// when a statement that fails leaves the ones before it applied. + pub fn requires_confirmation(&self, ddl_writes: WriteAtomicity) -> bool { + !self.destructive.is_empty() + || (ddl_writes == WriteAtomicity::StatementByStatement && self.statements.len() > 1) + } + + /// Whether `plan` runs exactly the statements reviewed. + pub fn matches(&self, plan: &DdlPlan) -> bool { + plan.sql().into_iter().eq(self.statements.iter().map(String::as_str)) + } + + /// The statements as the review shows them, laid out as the + /// structure tab's preview. + pub fn sql(&self) -> String { + super::ddl_plan::script(self.statements.iter().map(String::as_str)) + } +} + +fn statements(plan: &DdlPlan) -> Vec { + plan.sql().into_iter().map(str::to_owned).collect() +} diff --git a/linux/crates/core/src/ddl/column_attributes.rs b/linux/crates/core/src/ddl/column_attributes.rs index 55f8e67dad..0add42112a 100644 --- a/linux/crates/core/src/ddl/column_attributes.rs +++ b/linux/crates/core/src/ddl/column_attributes.rs @@ -1,4 +1,7 @@ -use super::{CollationName, ColumnKeyRoles, ColumnVisibility, GeneratedColumn, IdentityStyle, UnmodeledAttribute}; +use super::{ + CollationName, ColumnCompression, ColumnKeyRoles, ColumnStorage, ColumnVisibility, GeneratedColumn, IdentityStyle, + UnmodeledAttribute, +}; use crate::column::SqlExpression; /// What a column definition says beyond its name, type, nullability and @@ -32,6 +35,12 @@ pub struct ColumnAttributes { /// drops it unless the definition restates it; every other engine /// keeps a CHECK on the table, where a redefinition leaves it alone. pub check: Option, + /// PostgreSQL `SET STORAGE`, set only when it differs from the + /// type's own. + pub storage: Option, + /// PostgreSQL `SET COMPRESSION`, set only when the column names a + /// method rather than following the server's default. + pub compression: Option, pub identity: IdentityStyle, /// The SQL Server constraint that holds the default. A type change /// has to drop it by name first and add it back afterwards. diff --git a/linux/crates/core/src/ddl/column_storage.rs b/linux/crates/core/src/ddl/column_storage.rs new file mode 100644 index 0000000000..9b2cb9805e --- /dev/null +++ b/linux/crates/core/src/ddl/column_storage.rs @@ -0,0 +1,23 @@ +/// How PostgreSQL keeps a column's long values: inline or out of line, +/// compressed or not, as `SET STORAGE` names it. +/// +/// A type change resets it to the new type's own, so a column that had +/// one set keeps it only when the change restates it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnStorage { + Plain, + Main, + External, + Extended, +} + +/// The method PostgreSQL compresses a column's values with, as +/// `SET COMPRESSION` names it. +/// +/// A type change resets it to `default_toast_compression`, so it is +/// restated the same way as the storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnCompression { + Pglz, + Lz4, +} diff --git a/linux/crates/core/src/ddl/ddl_dialect.rs b/linux/crates/core/src/ddl/ddl_dialect.rs index bd55de2dea..d64d05aaa5 100644 --- a/linux/crates/core/src/ddl/ddl_dialect.rs +++ b/linux/crates/core/src/ddl/ddl_dialect.rs @@ -21,6 +21,13 @@ pub trait DdlDialect: Send + Sync + std::fmt::Debug { /// The dialect that quotes, binds and parses for this engine. fn sql_dialect(&self) -> &dyn SqlDialect; + /// What the engine takes for this table, where that depends on more + /// than the engine: the structure tab offers a table-wide control, + /// such as adding a foreign key, only where this allows it. + fn table_capabilities(&self, _table: &TableDefinition) -> DdlCapabilities { + *self.capabilities() + } + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { ColumnCapabilities::standard(self.capabilities(), table, column) } @@ -39,6 +46,12 @@ pub trait DdlDialect: Send + Sync + std::fmt::Debug { &ReferentialAction::ALL } + /// Whether a foreign key can reference a table in another schema. + /// Where it cannot, a key is offered only the tables of its own. + fn references_other_schemas(&self) -> bool { + true + } + fn plan_create_table(&self, draft: &TableDraft) -> Result; fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result; diff --git a/linux/crates/core/src/ddl/ddl_plan.rs b/linux/crates/core/src/ddl/ddl_plan.rs index 0d85057f74..8b3b39d142 100644 --- a/linux/crates/core/src/ddl/ddl_plan.rs +++ b/linux/crates/core/src/ddl/ddl_plan.rs @@ -1,15 +1,19 @@ -use super::{BuildDdlError, DdlOperation, TableChanges}; +use super::{BuildDdlError, DdlOperation, DdlPrecondition, TableChanges, TableRebuild}; use crate::foreign_key_mode::ForeignKeyMode; use crate::meta::TableRef; -use crate::statement::{GuardedStatement, Statement, StatementBuilder}; +use crate::statement::{GuardedStatement, RowGuard, Statement, StatementBuilder}; use crate::write_batch::{WriteBatch, WriteKind}; -/// The statements a structure change runs, in order, and whether foreign -/// keys stay on while they do. +/// The statements a structure change runs, in order, whether foreign +/// keys stay on while they do, and what the schema has to hold first. #[derive(Debug, Clone, PartialEq)] pub struct DdlPlan { pub statements: Vec, pub foreign_keys: ForeignKeyMode, + pub precondition: Option, + /// Set where the plan copies the table into a new one, which the + /// review names before it runs. + pub rebuild: Option, } impl DdlPlan { @@ -26,6 +30,8 @@ impl DdlPlan { }) .collect(), foreign_keys: ForeignKeyMode::Enforced, + precondition: None, + rebuild: None, } } @@ -51,19 +57,24 @@ impl DdlPlan { self.statements.iter().map(Statement::sql).collect() } - /// The plan as the user reads it before Save: each statement ended - /// and set apart. + /// The plan as the user reads it before Save. pub fn preview(&self) -> String { - self.statements - .iter() - .map(|statement| format!("{};", statement.sql())) - .collect::>() - .join("\n\n") + script(self.sql()) } - /// Schema statements have no row count to prove. + /// Schema statements have no row count to prove. The precondition + /// guards the first one, so the write counts it inside its + /// transaction before anything runs, and a schema guard that fails + /// always means the table changed after the plan read it. pub fn into_steps(self) -> Vec { - self.statements.into_iter().map(GuardedStatement::unguarded).collect() + let mut steps: Vec = self.statements.into_iter().map(GuardedStatement::unguarded).collect(); + if let (Some(first), Some(precondition)) = (steps.first_mut(), self.precondition) { + first.guard = RowGuard::ProbeExactly { + probe: precondition.probe, + rows: precondition.rows, + }; + } + steps } /// `table` is the table as it is named before the plan runs. @@ -73,6 +84,15 @@ impl DdlPlan { } } +/// Statements as the user reads them: each ended and set apart. +pub(super) fn script<'a>(statements: impl IntoIterator) -> String { + statements + .into_iter() + .map(|statement| format!("{statement};")) + .collect::>() + .join("\n\n") +} + #[cfg(test)] mod tests { use super::*; @@ -90,6 +110,28 @@ mod tests { assert_eq!(batch.steps[1].statement.sql(), "DROP INDEX i"); } + #[test] + fn the_precondition_guards_the_first_statement_only() { + let mut builder = StatementBuilder::new(); + builder.push_sql("SELECT count(*) FROM sqlite_schema WHERE sql <> 'x'"); + let mut plan = DdlPlan::from_sql(["DROP TABLE t".to_owned(), "ALTER TABLE u RENAME TO t".to_owned()]); + plan.precondition = Some(DdlPrecondition { + probe: builder.finish(), + rows: 0, + }); + + let steps = plan.into_steps(); + + let Some((probe, rows)) = steps[0].guard.probe() else { + panic!("the first step is unguarded: {:?}", steps[0].guard); + }; + assert_eq!( + (probe.sql(), rows), + ("SELECT count(*) FROM sqlite_schema WHERE sql <> 'x'", 0) + ); + assert_eq!(steps[1].guard, RowGuard::None); + } + #[test] fn a_change_that_wrote_nothing_is_refused() { let renamed = TableChanges { diff --git a/linux/crates/core/src/ddl/ddl_precondition.rs b/linux/crates/core/src/ddl/ddl_precondition.rs new file mode 100644 index 0000000000..a451babc21 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_precondition.rs @@ -0,0 +1,12 @@ +use crate::statement::Statement; + +/// What the schema has to hold for a plan to apply, counted in the +/// write's transaction before its first statement. A plan that writes +/// a table again from the definition it was built from would otherwise +/// undo whatever changed the table after that read. +#[derive(Debug, Clone, PartialEq)] +pub struct DdlPrecondition { + pub probe: Statement, + /// The count the probe has to return. + pub rows: u64, +} diff --git a/linux/crates/core/src/ddl/destructive_change.rs b/linux/crates/core/src/ddl/destructive_change.rs new file mode 100644 index 0000000000..0008af881c --- /dev/null +++ b/linux/crates/core/src/ddl/destructive_change.rs @@ -0,0 +1,25 @@ +use super::TableRebuild; + +/// A part of a structure save that loses data, or drops something the +/// save has to make again, named in the review before it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DestructiveChange { + /// The rows are copied into a new table and the old one dropped. + RebuildTable(TableRebuild), + DropColumn { + column: String, + }, + /// The values the column holds are converted, and a value the new + /// type cannot take is changed or refused. + ChangeColumnType { + column: String, + from: String, + to: String, + }, + DropIndex { + index: String, + }, + DropForeignKey { + key: String, + }, +} diff --git a/linux/crates/core/src/ddl/mod.rs b/linux/crates/core/src/ddl/mod.rs index 324d67848d..120918d60d 100644 --- a/linux/crates/core/src/ddl/mod.rs +++ b/linux/crates/core/src/ddl/mod.rs @@ -3,6 +3,7 @@ //! turn them into DDL. mod build_ddl_error; +mod change_review; mod changed_attributes; mod collation_name; mod column_alteration; @@ -11,13 +12,16 @@ mod column_capabilities; mod column_definition; mod column_key_roles; mod column_rename; +mod column_storage; mod column_visibility; mod ddl_capabilities; mod ddl_dialect; mod ddl_operation; mod ddl_plan; +mod ddl_precondition; pub mod ddl_sql; mod default_text; +mod destructive_change; mod diff_table; mod draft_column; mod draft_problem; @@ -36,6 +40,7 @@ mod schema_object_source; mod table_changes; mod table_definition; mod table_draft; +mod table_rebuild; mod table_source; mod unmodeled_attribute; @@ -43,6 +48,7 @@ mod unmodeled_attribute; mod tests; pub use build_ddl_error::BuildDdlError; +pub use change_review::ChangeReview; pub use changed_attributes::ChangedAttributes; pub use collation_name::CollationName; pub use column_alteration::ColumnAlteration; @@ -51,12 +57,15 @@ pub use column_capabilities::{ColumnCapabilities, primary_key_permission}; pub use column_definition::ColumnDefinition; pub use column_key_roles::ColumnKeyRoles; pub use column_rename::ColumnRename; +pub use column_storage::{ColumnCompression, ColumnStorage}; pub use column_visibility::ColumnVisibility; pub use ddl_capabilities::DdlCapabilities; pub use ddl_dialect::DdlDialect; pub use ddl_operation::DdlOperation; pub use ddl_plan::DdlPlan; +pub use ddl_precondition::DdlPrecondition; pub use default_text::{default_text, parse_default}; +pub use destructive_change::DestructiveChange; pub use diff_table::diff_table; pub use draft_column::DraftColumn; pub use draft_problem::DraftProblem; @@ -75,5 +84,6 @@ pub use schema_object_source::{SchemaObjectKind, SchemaObjectSource}; pub use table_changes::TableChanges; pub use table_definition::TableDefinition; pub use table_draft::TableDraft; +pub use table_rebuild::{RecreatedObject, TableRebuild}; pub use table_source::TableSource; pub use unmodeled_attribute::UnmodeledAttribute; diff --git a/linux/crates/core/src/ddl/refusal_reason.rs b/linux/crates/core/src/ddl/refusal_reason.rs index 008fb50fd8..16a527de2b 100644 --- a/linux/crates/core/src/ddl/refusal_reason.rs +++ b/linux/crates/core/src/ddl/refusal_reason.rs @@ -18,6 +18,9 @@ pub enum RefusalReason { IdentityColumn, /// Only an integer column can number itself. NotAnInteger, + /// The engine numbers the column for as long as it is the table's + /// integer key, as SQLite does its rowid column. + AlwaysNumbered, /// The column carries an attribute this build does not model, which a /// restated definition would drop. UnmodeledAttribute(String), @@ -45,6 +48,7 @@ impl fmt::Display for RefusalReason { Self::GeneratedColumn => f.write_str("the server computes the column"), Self::IdentityColumn => f.write_str("the server numbers the column"), Self::NotAnInteger => f.write_str("only an integer column can number itself"), + Self::AlwaysNumbered => f.write_str("the engine numbers the column while it is the table's integer key"), Self::UnmodeledAttribute(name) => write!(f, "the column has an attribute this app cannot restate: {name}"), Self::ColumnInUse(users) => write!(f, "still used by {}", users.join(", ")), Self::ReferencedByForeignKeys(keys) => write!(f, "referenced by foreign keys {}", keys.join(", ")), diff --git a/linux/crates/core/src/ddl/table_rebuild.rs b/linux/crates/core/src/ddl/table_rebuild.rs new file mode 100644 index 0000000000..ad813a3891 --- /dev/null +++ b/linux/crates/core/src/ddl/table_rebuild.rs @@ -0,0 +1,19 @@ +use super::SchemaObjectKind; + +/// A plan that copies the table's rows into a new table and drops the +/// old one, where the engine's ALTER TABLE cannot make the change. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TableRebuild { + /// The indexes, triggers and views the plan drops, with the table or + /// on their own, and makes again from the SQL the schema stored. + pub recreated: Vec, + /// The copied rows get new rowids, so anything outside the schema + /// that kept one no longer finds its row. + pub renumbers_rowids: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecreatedObject { + pub kind: SchemaObjectKind, + pub name: String, +} diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index a9bf3c02ba..f7203e0e31 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -696,3 +696,168 @@ fn a_typed_default_becomes_a_value_or_an_expression() { "'it''s'" ); } + +/// The review of `edit` made to `users`, over a plan of `statements`. +fn review(statements: &[&str], edit: impl FnOnce(&mut TableDraft)) -> ChangeReview { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + let plan = DdlPlan::from_sql(statements.iter().map(|statement| (*statement).to_owned())); + ChangeReview::review(&diff_table(&original, &draft), &plan) +} + +#[test] +fn drop_column_is_destructive() { + let reviewed = review(&["ALTER TABLE users DROP COLUMN legacy"], |draft| { + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + }); + + assert_eq!( + reviewed.destructive, + [ + DestructiveChange::DropColumn { + column: "legacy".to_owned() + }, + DestructiveChange::DropIndex { + index: "by_legacy".to_owned() + }, + ] + ); + assert_eq!(reviewed.operations, 2); + assert!(reviewed.requires_confirmation(crate::WriteAtomicity::Transactional)); +} + +#[test] +fn rename_is_not_destructive() { + let reviewed = review(&["ALTER TABLE users RENAME COLUMN email TO mail"], |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + }); + + assert!(reviewed.destructive.is_empty(), "{:?}", reviewed.destructive); + assert!(!reviewed.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); +} + +#[test] +fn unchanged_catalog_type_is_not_type_change() { + let catalog = review(&["ALTER TABLE users ALTER COLUMN email SET NOT NULL"], |draft| { + let email = draft_column(draft, "email"); + email.column_type = DraftType::Catalog(text()); + email.nullable = false; + }); + let retyped = review(&["ALTER TABLE users ALTER COLUMN email SET NOT NULL"], |draft| { + let email = draft_column(draft, "email"); + email.column_type = DraftType::Typed(" text ".to_owned()); + email.nullable = false; + }); + + assert!(catalog.destructive.is_empty(), "{:?}", catalog.destructive); + assert!(retyped.destructive.is_empty(), "{:?}", retyped.destructive); +} + +#[test] +fn typed_type_change_is_destructive() { + let reviewed = review(&["ALTER TABLE users ALTER COLUMN email TYPE varchar(40)"], |draft| { + draft_column(draft, "email").column_type = DraftType::Typed("varchar(40)".to_owned()); + }); + + assert_eq!( + reviewed.destructive, + [DestructiveChange::ChangeColumnType { + column: "email".to_owned(), + from: "text".to_owned(), + to: "varchar(40)".to_owned(), + }] + ); +} + +#[test] +fn a_dropped_foreign_key_and_a_rebuild_are_named() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.foreign_keys.clear(); + let mut plan = DdlPlan::from_sql(["CREATE TABLE _new (id integer)".to_owned()]); + let rebuild = TableRebuild { + recreated: vec![RecreatedObject { + kind: SchemaObjectKind::View, + name: "active_users".to_owned(), + }], + renumbers_rowids: false, + }; + plan.rebuild = Some(rebuild.clone()); + + let reviewed = ChangeReview::review(&diff_table(&original, &draft), &plan); + + assert_eq!( + reviewed.destructive, + [ + DestructiveChange::RebuildTable(rebuild), + DestructiveChange::DropForeignKey { + key: "users_account_fk".to_owned() + }, + ] + ); +} + +#[test] +fn non_atomic_multi_statement_requires_confirmation() { + let two = review( + &[ + "ALTER TABLE users RENAME COLUMN email TO mail", + "CREATE INDEX by_mail ON users (mail)", + ], + |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + draft.indexes.push(index("by_mail", &["mail"])); + }, + ); + let one = review(&["ALTER TABLE users RENAME COLUMN email TO mail"], |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + }); + + assert!(two.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); + assert!(!two.requires_confirmation(crate::WriteAtomicity::Transactional)); + assert!(!one.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); +} + +#[test] +fn transactional_additive_save_does_not() { + let reviewed = review( + &[ + "ALTER TABLE users ADD COLUMN nickname text", + "CREATE INDEX by_nickname ON users (nickname)", + ], + |draft| { + draft + .columns + .push(DraftColumn::new("nickname", DraftType::Typed("text".to_owned()))); + draft.indexes.push(index("by_nickname", &["nickname"])); + }, + ); + let created = ChangeReview::create(&DdlPlan::from_sql(["CREATE TABLE t (a int)".to_owned()])); + + assert!(!reviewed.requires_confirmation(crate::WriteAtomicity::Transactional)); + assert_eq!(reviewed.operations, 2); + assert!(!created.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); + assert_eq!(created.operations, 1); +} + +#[test] +fn a_review_matches_only_the_plan_it_read() { + let reviewed = review(&["ALTER TABLE a", "ALTER TABLE b"], |_| {}); + + assert!(reviewed.matches(&DdlPlan::from_sql([ + "ALTER TABLE a".to_owned(), + "ALTER TABLE b".to_owned() + ]))); + assert!(!reviewed.matches(&DdlPlan::from_sql(["ALTER TABLE a".to_owned()]))); + assert!(!reviewed.matches(&DdlPlan::from_sql([ + "ALTER TABLE a".to_owned(), + "ALTER TABLE c".to_owned() + ]))); + assert_eq!(reviewed.sql(), "ALTER TABLE a;\n\nALTER TABLE b;"); + assert_eq!( + reviewed.sql(), + DdlPlan::from_sql(["ALTER TABLE a".to_owned(), "ALTER TABLE b".to_owned()]).preview() + ); +} diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index fccf61160f..1bfb37ccdc 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -1,7 +1,7 @@ use crate::column::{ColumnInfo, ColumnType, SqlExpression}; use crate::ddl::DdlDialect; use crate::meta::{EngineRowId, EngineRowIdPart, TableRef}; -use crate::sql_syntax::{ExpressionSyntaxError, SqlGrammar, TypeSyntaxError}; +use crate::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; use crate::value::Value; use super::{ @@ -70,6 +70,12 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { fn parse_expression(&self, text: &str) -> Result; + /// Whether SQL the user ran may be kept in the query history. Each + /// engine names the statements that can hold a password or a + /// connection secret (`classify_for_history` with its own + /// `CredentialRules`), and those are never recorded. + fn history_recording(&self, sql: &str) -> HistoryRecording; + /// The engine's structure-change spelling, or `None` where the app /// does not edit this engine's structure. fn ddl(&self) -> Option<&dyn DdlDialect> { diff --git a/linux/crates/core/src/dialect/test_dialect.rs b/linux/crates/core/src/dialect/test_dialect.rs index 15cd33942e..3044c014e2 100644 --- a/linux/crates/core/src/dialect/test_dialect.rs +++ b/linux/crates/core/src/dialect/test_dialect.rs @@ -2,7 +2,10 @@ use std::sync::Mutex; use crate::column::{ColumnInfo, ColumnType, SqlExpression, SqlTypeExpr}; use crate::meta::{EngineRowId, EngineRowIdPart}; -use crate::sql_syntax::{ExpressionSyntaxError, SqlGrammar, TypeSyntaxError, parse_expression, parse_type}; +use crate::sql_syntax::{ + CredentialRules, ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError, classify_for_history, + parse_expression, parse_type, +}; use crate::statement::{BoundParam, ParamType}; use crate::value::Value; @@ -250,6 +253,15 @@ impl SqlDialect for TestDialect { let parsed = parse_expression(self.grammar, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } + + fn history_recording(&self, sql: &str) -> HistoryRecording { + const RULES: CredentialRules = CredentialRules { + statement_prefixes: &[&["CREATE", "USER"], &["ALTER", "USER"]], + conditional_prefixes: &[], + carriers: &[], + }; + classify_for_history(self.grammar, &RULES, sql) + } } fn row_id_type(part: EngineRowIdPart) -> &'static str { diff --git a/linux/crates/core/src/foreign_key_check.rs b/linux/crates/core/src/foreign_key_check.rs index 9f4fca0564..06b106f526 100644 --- a/linux/crates/core/src/foreign_key_check.rs +++ b/linux/crates/core/src/foreign_key_check.rs @@ -12,18 +12,31 @@ use crate::meta::TableRef; pub struct ForeignKeyCheck { pub before: Option, pub after: TableRef, + /// The batch gives the table's rows new rowids, as a SQLite rebuild + /// does when it makes a column the INTEGER PRIMARY KEY. A row is then + /// known by a different rowid on each side, so the violations in it + /// compare by count. + pub rowids_renumbered: bool, } impl ForeignKeyCheck { pub fn new(before: Option, after: TableRef) -> Self { - Self { before, after } + Self { + before, + after, + rowids_renumbered: false, + } } /// A table that keeps its name through the batch. pub fn same_table(table: TableRef) -> Self { + Self::new(Some(table.clone()), table) + } + + pub fn renumbered(self) -> Self { Self { - before: Some(table.clone()), - after: table, + rowids_renumbered: true, + ..self } } } diff --git a/linux/crates/core/src/sql_syntax/credential_rules.rs b/linux/crates/core/src/sql_syntax/credential_rules.rs new file mode 100644 index 0000000000..d48164e7a2 --- /dev/null +++ b/linux/crates/core/src/sql_syntax/credential_rules.rs @@ -0,0 +1,164 @@ +use super::SqlGrammar; +use super::statement_tokens::StatementToken; + +/// What marks a statement as one whose text can hold a password or a +/// connection secret, for one engine. Each driver declares its own next +/// to its dialect. Words compare without regard to case. +/// +/// Prefixes are looked for at every word, not only where a statement +/// starts: a T-SQL batch needs no semicolon between its statements, and +/// a routine body holds whole statements of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialRules { + /// Unquoted words that begin such a statement, such as `CREATE USER`. + /// An `OR REPLACE` straight after the first word is skipped. + pub statement_prefixes: &'static [&'static [&'static str]], + /// A prefix that counts only when one of the words with it follows + /// in the same statement, such as `GRANT` with `IDENTIFIED`. + pub conditional_prefixes: &'static [(&'static [&'static str], &'static [&'static str])], + /// Functions, procedures and table engines whose arguments can hold + /// a login or a connection string. + pub carriers: &'static [&'static str], +} + +/// The word before a name being declared or written to, where a carrier +/// name followed by a parenthesis is a table and its column list, not a +/// call: `CREATE TABLE s3 (id int)`. A carrier the engine reserves, such +/// as T-SQL's OPENROWSET, cannot name a table, so there it is a call. +const NAME_POSITIONS: [&str; 6] = ["TABLE", "INTO", "VIEW", "REFERENCES", "EXISTS", "ON"]; + +impl CredentialRules { + pub(crate) fn matches(&self, grammar: SqlGrammar, tokens: &[StatementToken<'_>]) -> bool { + (0..tokens.len()).any(|at| { + self.prefix_at(tokens, at) || self.conditional_at(tokens, at) || self.carrier_at(grammar, tokens, at) + }) + } + + fn prefix_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { + self.statement_prefixes + .iter() + .any(|prefix| words_at(tokens, at, prefix).is_some()) + } + + fn conditional_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { + self.conditional_prefixes.iter().any(|(prefix, triggers)| { + words_at(tokens, at, prefix).is_some_and(|end| { + tokens.get(end..).unwrap_or_default().iter().any(|token| { + token + .name() + .is_some_and(|name| triggers.iter().any(|trigger| trigger.eq_ignore_ascii_case(name))) + }) + }) + }) + } + + fn carrier_at(&self, grammar: SqlGrammar, tokens: &[StatementToken<'_>], at: usize) -> bool { + let Some(token) = tokens.get(at) else { + return false; + }; + let Some(name) = token.name() else { + return false; + }; + if !self.carriers.iter().any(|carrier| carrier.eq_ignore_ascii_case(name)) { + return false; + } + let next = tokens.get(at + 1); + if next.is_some_and(|token| token.is_punct('.')) { + return false; + } + let start = qualified_name_start(tokens, at); + if next == Some(&StatementToken::OpenParen) { + let reserved = matches!(token, StatementToken::Word(_)) && grammar.is_reserved(name); + if reserved || !names_an_object(tokens, start) { + return true; + } + } + // T-SQL runs a procedure without EXEC only as the first statement + // of a batch. At the start of a later statement, or anywhere with + // its arguments, the batch fails, and history keeps failed runs. + starts_statement(tokens, start) || follows_exec(tokens, start) || arguments_start(tokens, at + 1) + } +} + +fn starts_statement(tokens: &[StatementToken<'_>], start: usize) -> bool { + start == 0 || tokens.get(start - 1).is_some_and(|token| token.is_punct(';')) +} + +/// A string, T-SQL's `N'...'` or a `@parameter`, which only a call puts +/// straight after a name. +fn arguments_start(tokens: &[StatementToken<'_>], at: usize) -> bool { + match tokens.get(at) { + Some(StatementToken::StringLiteral(_)) => true, + Some(StatementToken::Word(word)) if word.starts_with('@') => true, + Some(token) if token.is_word("N") => { + matches!(tokens.get(at + 1), Some(StatementToken::StringLiteral(_))) + } + _ => false, + } +} + +/// Where `words` match at `at`, the index just past them. +fn words_at(tokens: &[StatementToken<'_>], at: usize, words: &[&str]) -> Option { + let (first, rest) = words.split_first()?; + if !tokens.get(at)?.is_word(first) { + return None; + } + let mut next = at + 1; + let or_replace = tokens.get(next).is_some_and(|token| token.is_word("OR")) + && tokens.get(next + 1).is_some_and(|token| token.is_word("REPLACE")); + if or_replace { + next += 2; + } + for word in rest { + if !tokens.get(next)?.is_word(word) { + return None; + } + next += 1; + } + Some(next) +} + +/// The first part of the dotted name that ends at `at`, so `master..sp_addlogin` +/// and `public.dblink(` are read as one name. +fn qualified_name_start(tokens: &[StatementToken<'_>], at: usize) -> usize { + let mut start = at; + loop { + let mut dots = start; + while dots > 0 && tokens.get(dots - 1).is_some_and(|token| token.is_punct('.')) { + dots -= 1; + } + let qualifier = dots.checked_sub(1).filter(|_| dots < start); + match qualifier.and_then(|index| tokens.get(index)?.name().map(|_| index)) { + Some(index) => start = index, + None => return start, + } + } +} + +fn names_an_object(tokens: &[StatementToken<'_>], start: usize) -> bool { + let Some(before) = start.checked_sub(1).and_then(|index| tokens.get(index)) else { + return false; + }; + if !NAME_POSITIONS.iter().any(|word| before.is_word(word)) { + return false; + } + // ClickHouse describes a table function's result: DESCRIBE TABLE s3(...). + let described = start + .checked_sub(2) + .and_then(|index| tokens.get(index)) + .is_some_and(|token| token.is_word("DESCRIBE") || token.is_word("DESC")); + !(before.is_word("TABLE") && described) +} + +/// `EXEC name`, or `EXEC @status = name`. +fn follows_exec(tokens: &[StatementToken<'_>], start: usize) -> bool { + let token_before = |back: usize| start.checked_sub(back).and_then(|index| tokens.get(index)); + let is_exec = |token: Option<&StatementToken<'_>>| { + token.is_some_and(|token| token.is_word("EXEC") || token.is_word("EXECUTE")) + }; + if is_exec(token_before(1)) { + return true; + } + let status_variable = matches!(token_before(2), Some(StatementToken::Word(word)) if word.starts_with('@')); + token_before(1).is_some_and(|token| token.is_punct('=')) && status_variable && is_exec(token_before(3)) +} diff --git a/linux/crates/core/src/sql_syntax/history_recording.rs b/linux/crates/core/src/sql_syntax/history_recording.rs new file mode 100644 index 0000000000..3f362fa662 --- /dev/null +++ b/linux/crates/core/src/sql_syntax/history_recording.rs @@ -0,0 +1,383 @@ +use super::SqlGrammar; +use super::credential_rules::CredentialRules; +use super::statement_tokens::{StatementToken, lex_statements}; + +/// Whether what the user ran may go into the query history. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryRecording { + Record, + Withhold(WithholdReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WithholdReason { + /// A statement sets a password, a key or a link to another server. + CredentialStatement, + /// A string, quoted name or comment never closes, so where the + /// statements are, and what is inside a literal, is a guess. + Untokenizable, +} + +/// Routine bodies are read for the statements inside them this many +/// levels deep: a DO block in a function body is as far as real scripts +/// nest. +const MAX_BODY_DEPTH: u8 = 4; + +/// Whether `sql` may be kept, split the way the editor splits it for +/// `grammar`. The text is never rewritten: an entry is kept whole or not +/// at all, so History can neither show nor run a statement with a secret +/// cut out of it. +pub fn classify_for_history(grammar: SqlGrammar, rules: &CredentialRules, sql: &str) -> HistoryRecording { + let script = lex_statements(sql, grammar); + if script.unterminated { + return HistoryRecording::Withhold(WithholdReason::Untokenizable); + } + match carries_credentials(grammar, rules, &script.statements, 0) { + true => HistoryRecording::Withhold(WithholdReason::CredentialStatement), + false => HistoryRecording::Record, + } +} + +fn carries_credentials( + grammar: SqlGrammar, + rules: &CredentialRules, + statements: &[Vec>], + depth: u8, +) -> bool { + // A body that does not lex cleanly is text in a literal, not a + // routine, so only the statements it does hold count. + let body_carries = |body: &str| { + depth < MAX_BODY_DEPTH + && carries_credentials(grammar, rules, &lex_statements(body, grammar).statements, depth + 1) + }; + statements.iter().any(|tokens| { + rules.matches(grammar, tokens) + || tokens.iter().enumerate().any(|(at, token)| match token { + StatementToken::DollarBody(body) => body_carries(body), + StatementToken::StringLiteral(text) if quoted_routine_body(grammar, tokens, at) => { + body_carries(&string_value(text)) + } + _ => false, + }) + }) +} + +/// PostgreSQL also takes a DO block or a function body as a plain +/// string: `DO 'BEGIN ... END'`, `CREATE FUNCTION f() ... AS 'SELECT 1'`. +fn quoted_routine_body(grammar: SqlGrammar, tokens: &[StatementToken<'_>], at: usize) -> bool { + if grammar != SqlGrammar::PostgreSql { + return false; + } + let before = |back: usize| at.checked_sub(back).and_then(|index| tokens.get(index)); + let is_word = |back: usize, word: &str| before(back).is_some_and(|token| token.is_word(word)); + if is_word(1, "DO") || (is_word(3, "DO") && is_word(2, "LANGUAGE")) { + return true; + } + is_word(1, "AS") && defines_routine(tokens.get(..at).unwrap_or_default()) +} + +fn defines_routine(tokens: &[StatementToken<'_>]) -> bool { + tokens.windows(2).any(|pair| { + let creates = pair[0].is_word("CREATE") || pair[0].is_word("REPLACE"); + creates && (pair[1].is_word("FUNCTION") || pair[1].is_word("PROCEDURE")) + }) +} + +/// The text a PostgreSQL string literal stands for: `'it''s'` and +/// `E'it\'s'` both give `it's`. +fn string_value(text: &str) -> String { + let (escapes, quoted) = match text.strip_prefix(['E', 'e']) { + Some(rest) => (true, rest), + None => (false, text), + }; + let inner = quoted + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap_or_default(); + let mut value = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + match c { + '\'' => { + chars.next(); + value.push('\''); + } + '\\' if escapes => match chars.next() { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some(escaped) => value.push(escaped), + None => {} + }, + _ => value.push(c), + } + } + value +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + const RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "NAMED", "COLLECTION"], + ], + conditional_prefixes: &[(&["GRANT"], &["IDENTIFIED"])], + carriers: &["dblink_connect", "sp_addlogin", "s3", "OPENROWSET"], + }; + + const WITHHELD: HistoryRecording = HistoryRecording::Withhold(WithholdReason::CredentialStatement); + + fn classify(grammar: SqlGrammar, sql: &str) -> HistoryRecording { + classify_for_history(grammar, &RULES, sql) + } + + #[test] + fn plain_statements_are_recorded_in_every_grammar() { + let plain = [ + "SELECT id FROM users WHERE password IS NULL AND email = 'a@b'", + "UPDATE accounts SET secret = NULL WHERE name = 'x'", + "DELETE FROM creds WHERE password = 'p'", + "SELECT 'password' AS label", + "CREATE TABLE s3 (id int)", + "SELECT s3 FROM t", + "-- only a note\n/* and another */", + "", + ]; + for grammar in SqlGrammar::ALL { + for sql in plain { + assert_eq!(classify(grammar, sql), HistoryRecording::Record, "{grammar:?}: {sql}"); + } + } + } + + #[test] + fn one_credential_statement_withholds_the_whole_script() { + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT 1; CREATE USER u PASSWORD 'p'"), + WITHHELD + ); + } + + #[test] + fn or_replace_does_not_hide_a_prefix() { + assert_eq!( + classify(SqlGrammar::ClickHouse, "CREATE OR REPLACE USER u IDENTIFIED BY 'p'"), + WITHHELD + ); + } + + #[test] + fn an_open_literal_or_comment_cannot_be_read() { + let untokenizable = HistoryRecording::Withhold(WithholdReason::Untokenizable); + + assert_eq!(classify(SqlGrammar::PostgreSql, "SELECT 'abc"), untokenizable); + assert_eq!(classify(SqlGrammar::MySql, "SELECT 1 /* note"), untokenizable); + assert_eq!(classify(SqlGrammar::MsSql, "SELECT [abc"), untokenizable); + } + + #[test] + fn words_in_strings_comments_and_names_do_not_count() { + for sql in [ + "SELECT 'CREATE USER u' AS text", + "SELECT 1 -- CREATE USER u\n", + "SELECT create_user FROM t", + "SELECT \"CREATE\", \"USER\" FROM t", + "SELECT 'sp_addlogin x'", + ] { + assert_eq!(classify(SqlGrammar::PostgreSql, sql), HistoryRecording::Record, "{sql}"); + } + } + + #[test] + fn a_conditional_prefix_needs_its_word_in_the_same_statement() { + assert_eq!( + classify(SqlGrammar::MySql, "GRANT SELECT ON t TO u"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "GRANT SELECT ON t TO u; SELECT identified FROM t"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "GRANT ALL ON *.* TO u IDENTIFIED BY 'p'"), + WITHHELD + ); + } + + #[test] + fn a_prefix_counts_anywhere_in_a_batch() { + assert_eq!( + classify(SqlGrammar::MsSql, "SELECT 1\nCREATE USER u WITH PASSWORD = 'p'"), + WITHHELD + ); + assert_eq!( + classify( + SqlGrammar::MySql, + "DELIMITER //\nCREATE PROCEDURE p() BEGIN CREATE USER u IDENTIFIED BY 'p'; END //" + ), + WITHHELD + ); + } + + #[test] + fn a_dollar_quoted_body_is_read_for_statements() { + assert_eq!( + classify( + SqlGrammar::PostgreSql, + "DO $$ BEGIN IF NOT EXISTS (SELECT 1) THEN CREATE USER app PASSWORD 'p'; END IF; END $$" + ), + WITHHELD + ); + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT $$it's a note$$"), + HistoryRecording::Record + ); + } + + #[test] + fn a_carrier_counts_as_a_call() { + for (grammar, sql) in [ + (SqlGrammar::PostgreSql, "SELECT dblink_connect('password=x')"), + (SqlGrammar::PostgreSql, "SELECT public.dblink_connect('password=x')"), + (SqlGrammar::PostgreSql, "SELECT \"dblink_connect\"('password=x')"), + (SqlGrammar::ClickHouse, "SELECT * FROM s3('url', 'key', 'secret')"), + ( + SqlGrammar::ClickHouse, + "INSERT INTO FUNCTION s3('url', 'key', 'secret') VALUES (1)", + ), + (SqlGrammar::ClickHouse, "DESCRIBE TABLE s3('url', 'key', 'secret')"), + ( + SqlGrammar::ClickHouse, + "CREATE TABLE t (id Int32) ENGINE = S3('url', 'key', 'secret')", + ), + (SqlGrammar::MsSql, "sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXECUTE master.dbo.sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC master..sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC [dbo].[sp_addlogin] 'l', 'p'"), + (SqlGrammar::MsSql, "DECLARE @rc int\nEXEC @rc = sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "SELECT 1\nGO\nsp_addlogin 'l', 'p'"), + ] { + assert_eq!(classify(grammar, sql), WITHHELD, "{sql}"); + } + } + + #[test] + fn a_carrier_name_that_names_a_table_is_not_a_call() { + for sql in [ + "CREATE TABLE s3 (id Int32) ENGINE = Memory", + "CREATE TABLE IF NOT EXISTS db.s3 (id Int32) ENGINE = Memory", + "INSERT INTO s3 (id) VALUES (1)", + "CREATE VIEW s3 (id) AS SELECT 1", + "SELECT s3.id FROM s3", + "SELECT 1 AS s3", + ] { + assert_eq!(classify(SqlGrammar::ClickHouse, sql), HistoryRecording::Record, "{sql}"); + } + } + + #[test] + fn a_procedure_named_mid_batch_without_arguments_is_not_a_call() { + assert_eq!( + classify(SqlGrammar::MsSql, "SELECT 1\nPRINT sp_addlogin"), + HistoryRecording::Record + ); + } + + #[test] + fn a_procedure_mid_batch_with_its_arguments_is_a_call() { + for sql in [ + "SELECT 1;\nsp_addlogin 'l', 'p'", + "SELECT 1;\nsp_addlogin", + "SELECT 1\nsp_addlogin 'l', 'p'", + "SELECT 1\nsp_addlogin N'l', N'p'", + "SELECT 1\nsp_addlogin @loginame = 'l', @passwd = 'p'", + ] { + assert_eq!(classify(SqlGrammar::MsSql, sql), WITHHELD, "{sql}"); + } + } + + #[test] + fn a_reserved_carrier_is_a_call_where_a_table_name_would_go() { + assert_eq!( + classify( + SqlGrammar::MsSql, + "INSERT INTO OPENROWSET('SQLNCLI', 'Server=s;Uid=sa;Pwd=secret;', 'SELECT * FROM t') SELECT * FROM t" + ), + WITHHELD + ); + assert_eq!( + classify(SqlGrammar::MsSql, "INSERT INTO [OPENROWSET] (c) VALUES (1)"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::ClickHouse, "INSERT INTO s3 (id) VALUES (1)"), + HistoryRecording::Record + ); + } + + #[test] + fn a_quoted_postgres_routine_body_is_read_for_statements() { + for sql in [ + "DO 'BEGIN CREATE USER u PASSWORD ''p''; END'", + "DO LANGUAGE plpgsql 'BEGIN CREATE USER u PASSWORD ''p''; END'", + "CREATE FUNCTION f() RETURNS void AS 'SELECT dblink_connect(''password=x'')' LANGUAGE sql", + r"CREATE OR REPLACE PROCEDURE p() LANGUAGE sql AS E'SELECT dblink_connect(\'password=x\')'", + ] { + assert_eq!(classify(SqlGrammar::PostgreSql, sql), WITHHELD, "{sql}"); + } + assert_eq!( + classify( + SqlGrammar::PostgreSql, + "CREATE FUNCTION f() RETURNS text AS 'SELECT ''CREATE USER u''' LANGUAGE sql" + ), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "DO 'CREATE USER u'"), + HistoryRecording::Record + ); + } + + #[test] + fn a_mysql_executable_comment_is_read() { + for sql in [ + "/*!80000 CREATE USER u IDENTIFIED BY 'p' */", + "SELECT 1;\n/*!80000 CREATE USER u IDENTIFIED BY 'p' */;", + "GRANT ALL ON *.* TO u /*!50000 IDENTIFIED BY 'p' */", + "/*M!100100 CREATE USER u IDENTIFIED BY 'p' */", + ] { + assert_eq!(classify(SqlGrammar::MySql, sql), WITHHELD, "{sql}"); + } + assert_eq!( + classify(SqlGrammar::MySql, "SELECT 1 /* CREATE USER u */"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT 1 /*! CREATE USER u */"), + HistoryRecording::Record + ); + } + + proptest! { + #[test] + fn any_text_classifies_without_panicking(sql in "\\PC{0,120}", grammar in 0usize..5) { + let grammar = SqlGrammar::ALL[grammar]; + let _ = classify(grammar, &sql); + } + + #[test] + fn a_credential_statement_is_withheld_whatever_runs_before_it( + before in "[A-Za-z0-9_ ,=()]{0,60}", + ) { + let sql = format!("SELECT {before};\nCREATE USER u PASSWORD 'p'"); + prop_assert_eq!(classify(SqlGrammar::PostgreSql, &sql), WITHHELD); + } + } +} diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index a3ebc84d12..4a5a85d810 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -1,14 +1,19 @@ +mod credential_rules; mod errors; mod grammar; +mod history_recording; mod keywords; mod literal_token; mod parse_expression; mod parse_type; mod reserved_words; pub mod script; +mod statement_tokens; +pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; +pub use history_recording::{HistoryRecording, WithholdReason, classify_for_history}; pub use literal_token::LiteralToken; pub use parse_expression::{MAX_EXPRESSION_LEN, ValidatedExpression, parse_expression}; pub use parse_type::{MAX_TYPE_LEN, TypeShape, ValidatedType, parse_type}; diff --git a/linux/crates/core/src/sql_syntax/reserved_words.rs b/linux/crates/core/src/sql_syntax/reserved_words.rs index 48460febc8..558982f9f5 100644 --- a/linux/crates/core/src/sql_syntax/reserved_words.rs +++ b/linux/crates/core/src/sql_syntax/reserved_words.rs @@ -111,7 +111,7 @@ impl SqlGrammar { start && chars.all(part) && !folds && !self.is_reserved(name) } - fn is_reserved(self, word: &str) -> bool { + pub(crate) fn is_reserved(self, word: &str) -> bool { self.reserved_words() .split_ascii_whitespace() .any(|reserved| reserved.eq_ignore_ascii_case(word)) diff --git a/linux/crates/core/src/sql_syntax/statement_tokens.rs b/linux/crates/core/src/sql_syntax/statement_tokens.rs new file mode 100644 index 0000000000..4584aa60dc --- /dev/null +++ b/linux/crates/core/src/sql_syntax/statement_tokens.rs @@ -0,0 +1,258 @@ +use std::ops::Range; + +use crate::sql_syntax::SqlGrammar; +use crate::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +/// One token of a statement, with comments, whitespace and client +/// directives gone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatementToken<'a> { + /// A keyword or a bare name. + Word(&'a str), + /// A quoted name, without its quotes. + QuotedName(&'a str), + /// What a dollar quote holds, which is usually a routine body. + DollarBody(&'a str), + /// A string as written, quotes and any `E` prefix included. + StringLiteral(&'a str), + OpenParen, + /// Punctuation, and `;` wherever a statement keeps one, as a T-SQL + /// batch does. + Punct(char), + /// A number or a closing parenthesis. + Other, +} + +impl<'a> StatementToken<'a> { + pub(crate) fn is_word(&self, word: &str) -> bool { + matches!(self, Self::Word(text) if text.eq_ignore_ascii_case(word)) + } + + pub(crate) fn is_punct(&self, punct: char) -> bool { + *self == Self::Punct(punct) + } + + /// The name it spells, quoted or not. + pub(crate) fn name(&self) -> Option<&'a str> { + match self { + Self::Word(text) | Self::QuotedName(text) => Some(text), + _ => None, + } + } +} + +/// A script split into statements the way the editor runs it. +#[derive(Debug)] +pub(crate) struct LexedScript<'a> { + pub statements: Vec>>, + /// A string, quoted name or comment runs to the end of the text, so + /// where the statements end is a guess. + pub unterminated: bool, +} + +pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> { + let plan = ScriptPlan::build(sql, grammar, LexicalSettings::default_for(grammar)); + // Split at the terminators rather than taken statement by statement, + // so a comment the plan leaves outside every statement still counts: + // MySQL runs a `/*!` comment with whatever stands between the same + // two terminators, and the history keeps the whole text. + let mut statements = Vec::new(); + let mut current = Vec::new(); + for token in plan.tokens(0..sql.len()) { + let ends_statement = match token.kind { + ScriptTokenKind::Directive => true, + ScriptTokenKind::Semicolon => !plan + .statement_at(token.range.start) + .is_some_and(|statement| statement.range.contains(&token.range.start)), + _ => false, + }; + if ends_statement { + if !current.is_empty() { + statements.push(std::mem::take(&mut current)); + } + continue; + } + push_significant(sql, grammar, token.kind, token.range, &mut current); + } + if !current.is_empty() { + statements.push(current); + } + LexedScript { + statements, + unterminated: !plan.diagnostics().is_empty(), + } +} + +fn push_significant<'a>( + sql: &'a str, + grammar: SqlGrammar, + kind: ScriptTokenKind, + range: Range, + tokens: &mut Vec>, +) { + let text = sql.get(range).unwrap_or_default(); + let token = match kind { + ScriptTokenKind::BlockComment => { + // MySQL and MariaDB run what a `/*!` comment holds as part of + // the statement around it. + if let Some(inner) = executable_comment(grammar, text) { + tokens.extend(lex_statements(inner, grammar).statements.into_iter().flatten()); + } + return; + } + ScriptTokenKind::Whitespace | ScriptTokenKind::LineComment | ScriptTokenKind::Directive => return, + ScriptTokenKind::Word => StatementToken::Word(text), + ScriptTokenKind::QuotedIdentifier => StatementToken::QuotedName(unquoted(text)), + ScriptTokenKind::DollarQuoted => StatementToken::DollarBody(dollar_body(text)), + ScriptTokenKind::StringLiteral => StatementToken::StringLiteral(text), + ScriptTokenKind::OpenParen => StatementToken::OpenParen, + ScriptTokenKind::Semicolon => StatementToken::Punct(';'), + ScriptTokenKind::Punct => text.chars().next().map_or(StatementToken::Other, StatementToken::Punct), + ScriptTokenKind::Number | ScriptTokenKind::CloseParen | ScriptTokenKind::Unterminated => StatementToken::Other, + }; + tokens.push(token); +} + +/// `/*!50100 text */` or MariaDB's `/*M!100100 text */` gives `text`. +fn executable_comment(grammar: SqlGrammar, text: &str) -> Option<&str> { + if grammar != SqlGrammar::MySql { + return None; + } + let body = text.strip_prefix("/*!").or_else(|| text.strip_prefix("/*M!"))?; + let body = body.strip_suffix("*/")?; + Some(body.trim_start_matches(|c: char| c.is_ascii_digit())) +} + +/// Every quote the lexer accepts for a name (`"`, `` ` ``, `[` and `]`) +/// is one byte, so dropping the first and last byte leaves the name. +fn unquoted(text: &str) -> &str { + text.get(1..text.len().saturating_sub(1)).unwrap_or_default() +} + +/// `$tag$body$tag$` gives `body`. +fn dollar_body(text: &str) -> &str { + let Some(opener) = text.get(1..).and_then(|rest| rest.find('$')).map(|close| close + 2) else { + return ""; + }; + text.get(opener..text.len().saturating_sub(opener)).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comments_whitespace_and_strings_carry_no_words() { + let script = lex_statements("SELECT /* CREATE */ 'USER' -- ROLE\n FROM t", SqlGrammar::PostgreSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::StringLiteral("'USER'"), + StatementToken::Word("FROM"), + StatementToken::Word("t"), + ]] + ); + assert!(!script.unterminated); + } + + #[test] + fn a_mysql_executable_comment_is_part_of_its_statement() { + assert_eq!( + lex_statements("CREATE /*!50100 TEMPORARY */ TABLE t", SqlGrammar::MySql).statements, + vec![vec![ + StatementToken::Word("CREATE"), + StatementToken::Word("TEMPORARY"), + StatementToken::Word("TABLE"), + StatementToken::Word("t"), + ]] + ); + assert_eq!( + lex_statements("SELECT /*M!100100 1 */ x", SqlGrammar::MySql).statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::Other, + StatementToken::Word("x"), + ]] + ); + assert_eq!( + lex_statements("SELECT /*! TEMPORARY */ x", SqlGrammar::PostgreSql).statements, + vec![vec![StatementToken::Word("SELECT"), StatementToken::Word("x")]] + ); + } + + #[test] + fn a_mysql_executable_comment_between_statements_is_a_statement() { + let script = lex_statements("SELECT 1;\n/*!40101 SET NAMES utf8 */;", SqlGrammar::MySql); + + assert_eq!( + script.statements, + vec![ + vec![StatementToken::Word("SELECT"), StatementToken::Other], + vec![ + StatementToken::Word("SET"), + StatementToken::Word("NAMES"), + StatementToken::Word("utf8"), + ], + ] + ); + } + + #[test] + fn a_batch_keeps_its_semicolons() { + let script = lex_statements("SELECT 1; SELECT 2", SqlGrammar::MsSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::Other, + StatementToken::Punct(';'), + StatementToken::Word("SELECT"), + StatementToken::Other, + ]] + ); + } + + #[test] + fn quoted_names_lose_their_quotes() { + let script = lex_statements("EXEC [dbo].[sp_who]", SqlGrammar::MsSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("EXEC"), + StatementToken::QuotedName("dbo"), + StatementToken::Punct('.'), + StatementToken::QuotedName("sp_who"), + ]] + ); + } + + #[test] + fn a_dollar_quote_gives_its_body() { + let script = lex_statements("DO $body$ SELECT 1 $body$", SqlGrammar::PostgreSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("DO"), + StatementToken::DollarBody(" SELECT 1 ") + ]] + ); + } + + #[test] + fn statements_split_as_the_editor_splits_them() { + let script = lex_statements("SELECT 1\nGO\nSELECT 2", SqlGrammar::MsSql); + + assert_eq!(script.statements.len(), 2); + } + + #[test] + fn an_open_string_is_reported() { + assert!(lex_statements("SELECT 'abc", SqlGrammar::PostgreSql).unterminated); + assert!(lex_statements("SELECT 1 /* note", SqlGrammar::MySql).unterminated); + } +} diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index 68a8c6c1a1..4b944bc215 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -10,7 +10,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -155,6 +156,10 @@ impl SqlDialect for ClickHouseDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::ClickHouse, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -341,6 +346,62 @@ fn float_literal(value: f64) -> Option { None } +/// Statements whose text can hold a password or an access key. The +/// carriers are table functions and engines (`ENGINE = MySQL(...)`) +/// whose arguments include the remote login; the Kafka, RabbitMQ and +/// NATS engines and a password-protected backup take theirs as settings. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "NAMED", "COLLECTION"], + &["ALTER", "NAMED", "COLLECTION"], + ], + conditional_prefixes: &[ + (&["CREATE", "DICTIONARY"], &["PASSWORD"]), + (&["BACKUP"], &["PASSWORD"]), + (&["RESTORE"], &["PASSWORD"]), + ( + &["CREATE"], + &["KAFKA_SASL_PASSWORD", "RABBITMQ_PASSWORD", "NATS_PASSWORD"], + ), + ( + &["ALTER"], + &["KAFKA_SASL_PASSWORD", "RABBITMQ_PASSWORD", "NATS_PASSWORD"], + ), + ], + carriers: &[ + "mysql", + "postgresql", + "mongodb", + "redis", + "s3", + "s3Cluster", + "S3Queue", + "azureBlobStorage", + "azureBlobStorageCluster", + "AzureQueue", + "gcs", + "remote", + "remoteSecure", + "MaterializedPostgreSQL", + "MaterializedMySQL", + "ExternalDistributed", + "deltaLake", + "deltaLakeCluster", + "iceberg", + "icebergS3", + "icebergS3Cluster", + "icebergAzure", + "icebergAzureCluster", + "hudi", + "hudiCluster", + "url", + "odbc", + "jdbc", + ], +}; + #[cfg(test)] mod tests { use clickhouse::sql::Identifier; @@ -529,4 +590,44 @@ mod tests { })) ); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE USER u IDENTIFIED WITH sha256_password BY 'p'", + "SELECT * FROM mysql('h','d','t','u','p')", + "CREATE TABLE t ENGINE = PostgreSQL('h','d','t','u','p')", + "SELECT * FROM remoteSecure('h', d.t, 'u', 'p')", + "CREATE NAMED COLLECTION c AS password = 'p'", + "SELECT * FROM deltaLake('url', 'key', 'secret')", + "CREATE TABLE q (m String) ENGINE = Kafka SETTINGS kafka_sasl_password = 'p'", + "BACKUP TABLE t TO Disk('backups', 'b.zip') SETTINGS password = 'x'", + "RESTORE TABLE t FROM Disk('backups', 'b.zip') SETTINGS password = 'x'", + "SELECT * FROM url('http://u:p@h/f.csv', CSV)", + "CREATE TABLE t (id Int32) ENGINE = URL('http://u:p@h/f.csv', CSV)", + "SELECT * FROM odbc('DSN=d;UID=u;PWD=p', 't')", + "SELECT * FROM jdbc('jdbc:mysql://h/?user=u&password=p', 's', 't')", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + for sql in [ + "SELECT url, domain(url) FROM visits", + "CREATE TABLE url (id Int32) ENGINE = Memory", + "BACKUP TABLE t TO Disk('backups', 'b.zip')", + ] { + assert_eq!(dialect().history_recording(sql), HistoryRecording::Record, "{sql}"); + } + } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index d821c19358..2f4d4c9546 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -9,7 +9,8 @@ use tablepro_core::dialect::{ }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -208,6 +209,10 @@ impl SqlDialect for MssqlDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::MsSql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -362,6 +367,62 @@ fn integer_param_type(kind: IntegerKind) -> ParamType { } } +/// Statements whose text can hold a password or a connection string. +/// The procedures run without EXEC as the first statement of a batch, +/// and the crypto functions take the passphrase or key password itself. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "LOGIN"], + &["ALTER", "LOGIN"], + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "APPLICATION", "ROLE"], + &["ALTER", "APPLICATION", "ROLE"], + &["CREATE", "CREDENTIAL"], + &["ALTER", "CREDENTIAL"], + &["CREATE", "DATABASE", "SCOPED", "CREDENTIAL"], + &["ALTER", "DATABASE", "SCOPED", "CREDENTIAL"], + &["CREATE", "MASTER", "KEY"], + &["ALTER", "MASTER", "KEY"], + &["ALTER", "SERVICE", "MASTER", "KEY"], + &["CREATE", "CERTIFICATE"], + &["ALTER", "CERTIFICATE"], + &["CREATE", "SYMMETRIC", "KEY"], + &["ALTER", "SYMMETRIC", "KEY"], + &["CREATE", "ASYMMETRIC", "KEY"], + &["ALTER", "ASYMMETRIC", "KEY"], + &["OPEN", "MASTER", "KEY"], + &["OPEN", "SYMMETRIC", "KEY"], + ], + conditional_prefixes: &[ + (&["BACKUP"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), + (&["RESTORE"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), + (&["ADD", "SIGNATURE"], &["PASSWORD"]), + (&["ADD", "COUNTER", "SIGNATURE"], &["PASSWORD"]), + ], + carriers: &[ + "sp_addlogin", + "sp_password", + "sp_addlinkedsrvlogin", + "sp_addapprole", + "sp_setapprole", + "sp_approlepassword", + "sp_control_dbmasterkey_password", + "sp_change_users_login", + "sp_addlinkedserver", + "OPENROWSET", + "OPENDATASOURCE", + "ENCRYPTBYPASSPHRASE", + "DECRYPTBYPASSPHRASE", + "DECRYPTBYASYMKEY", + "DECRYPTBYCERT", + "DECRYPTBYKEYAUTOASYMKEY", + "DECRYPTBYKEYAUTOCERT", + "SIGNBYASYMKEY", + "SIGNBYCERT", + ], +}; + #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate}; @@ -612,4 +673,46 @@ mod tests { assert!(dialect.parse_expression(&literal).is_ok(), "{literal}"); } } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE LOGIN l WITH PASSWORD = N'x'", + "EXEC sp_password 'o','n'", + "sp_addlogin 'l','p'", + "SELECT * FROM OPENROWSET('SQLNCLI','Server=x;PWD=y','q')", + "BACKUP DATABASE d TO DISK='f' WITH PASSWORD='x'", + "SELECT 1\nCREATE LOGIN l WITH PASSWORD = 'x'", + "ALTER SERVICE MASTER KEY WITH OLD_ACCOUNT = 'a', OLD_PASSWORD = 'p'", + "EXEC sp_change_users_login 'Auto_Fix', 'u', NULL, 'p'", + "INSERT INTO OPENROWSET('SQLNCLI', 'Server=s;Uid=sa;Pwd=secret;', 'SELECT * FROM db.dbo.t') SELECT * FROM t", + "INSERT INTO OPENDATASOURCE('SQLNCLI', 'Data Source=s;User ID=u;Password=p').db.dbo.t (c) VALUES (1)", + "SELECT 1;\nsp_addlogin 'l','p'", + "ADD SIGNATURE TO dbo.p BY CERTIFICATE c WITH PASSWORD = 'p'", + "SELECT ENCRYPTBYPASSPHRASE('passphrase', 'data')", + "SELECT DECRYPTBYPASSPHRASE('passphrase', secret) FROM t", + "EXEC sp_addlinkedserver @server = 's', @srvproduct = '', @provider = 'MSOLEDBSQL', @provstr = 'Server=h;Uid=u;Pwd=p;'", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + for sql in [ + "ALTER TABLE users ADD password varchar(100)", + "INSERT INTO [OPENROWSET] (c) VALUES (1)", + "SELECT 1\nPRINT sp_addlogin", + ] { + assert_eq!(dialect().history_recording(sql), HistoryRecording::Record, "{sql}"); + } + } } diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index fb3a725043..cef0619386 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -11,7 +11,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -181,6 +182,10 @@ impl SqlDialect for MySqlDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::MySql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -364,6 +369,30 @@ fn string_literal(text: &str) -> String { format!("'{}'", text.replace('\'', "''")) } +/// Statements whose text can hold a password or a connection string. A +/// FEDERATED or CONNECT table keeps its remote login in `CONNECTION`, and +/// `PASSWORD()` takes the password it hashes. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "SERVER"], + &["ALTER", "SERVER"], + &["SET", "PASSWORD"], + &["CHANGE", "MASTER"], + &["CHANGE", "REPLICATION", "SOURCE"], + ], + conditional_prefixes: &[ + (&["GRANT"], &["IDENTIFIED"]), + (&["START", "SLAVE"], &["PASSWORD"]), + (&["START", "REPLICA"], &["PASSWORD"]), + (&["START", "GROUP_REPLICATION"], &["PASSWORD"]), + (&["CREATE"], &["CONNECTION"]), + (&["ALTER"], &["CONNECTION"]), + ], + carriers: &["PASSWORD", "OLD_PASSWORD"], +}; + #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate}; @@ -609,4 +638,47 @@ mod tests { ); assert!(dialect.parse_type("int; DROP TABLE t").is_err()); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE USER 'u'@'%' IDENTIFIED BY 'p'", + "SET PASSWORD FOR 'u'@'h' = 'x'", + "CHANGE REPLICATION SOURCE TO SOURCE_PASSWORD='x'", + "CHANGE MASTER TO MASTER_PASSWORD='x'", + "GRANT ALL ON *.* TO u IDENTIFIED BY 'p'", + "START REPLICA USER='u' PASSWORD='p'", + "CREATE TABLE t (id INT) ENGINE=FEDERATED CONNECTION='mysql://u:p@h:3306/d/t'", + "UPDATE mysql.user SET authentication_string = PASSWORD('x') WHERE User = 'root'", + "SELECT OLD_PASSWORD('x')", + "START GROUP_REPLICATION USER='u', PASSWORD='p'", + "CREATE TABLE t (id int) /*!50100 ENGINE=FEDERATED CONNECTION='mysql://u:p@h/d/t' */", + ] { + assert_eq!( + dialect(MySqlFlavour::MySql).history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect(MySqlFlavour::MySql)), + Vec::<&str>::new() + ); + for sql in [ + "CREATE TABLE users (id INT, password VARCHAR(100))", + "INSERT INTO users (name, password) VALUES ('a', 'b')", + "SELECT start, password FROM sessions", + "START TRANSACTION", + ] { + assert_eq!( + dialect(MySqlFlavour::MySql).history_recording(sql), + HistoryRecording::Record, + "{sql}" + ); + } + } } diff --git a/linux/crates/drivers/mysql/src/write_batch.rs b/linux/crates/drivers/mysql/src/write_batch.rs index 1a249cb9fd..44c7c4e5a3 100644 --- a/linux/crates/drivers/mysql/src/write_batch.rs +++ b/linux/crates/drivers/mysql/src/write_batch.rs @@ -318,10 +318,7 @@ mod tests { #[test] fn non_sqlite_drivers_reject_suspension() { let suspended = ForeignKeyMode::SuspendedThenVerified { - checks: vec![ForeignKeyCheck { - before: None, - after: TableRef::unqualified("t"), - }], + checks: vec![ForeignKeyCheck::new(None, TableRef::unqualified("t"))], }; assert_eq!( diff --git a/linux/crates/drivers/postgres/src/catalog/mod.rs b/linux/crates/drivers/postgres/src/catalog/mod.rs index 02ed7a9aa1..5b644b4306 100644 --- a/linux/crates/drivers/postgres/src/catalog/mod.rs +++ b/linux/crates/drivers/postgres/src/catalog/mod.rs @@ -6,7 +6,7 @@ mod pg_typmod; mod table_definition; mod type_facts; -pub(crate) use pg_column_row::{COLUMNS_SQL, PgColumnRow}; +pub(crate) use pg_column_row::{COLUMNS_SQL, COLUMNS_SQL_BEFORE_14, PgColumnRow}; pub(crate) use table_definition::{INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, inbound_foreign_key}; pub(crate) use type_facts::TypeFacts; @@ -14,7 +14,8 @@ use tablepro_core::column::{ CatalogType, ColumnDefault, ColumnInfo, ColumnType, ResultColumn, SqlExpression, SqlTypeExpr, has_dynamic_storage, }; use tablepro_core::ddl::{ - CollationName, ColumnAttributes, ColumnDefinition, GeneratedColumn, GeneratedStorage, IdentityStyle, + CollationName, ColumnAttributes, ColumnCompression, ColumnDefinition, ColumnStorage, GeneratedColumn, + GeneratedStorage, IdentityStyle, UnmodeledAttribute, }; use tablepro_core::edit::parse_literal_text; use tablepro_core::sql_syntax::{LiteralToken, MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; @@ -92,11 +93,40 @@ pub(crate) fn column_definition(row: &PgColumnRow) -> ColumnDefinition { }) }), identity: identity_style(row), + storage: row.storage.as_deref().and_then(storage), + compression: row.compression.as_deref().and_then(compression), + // A method this build cannot name would be dropped by a type + // change, so the column refuses one instead. + unmodeled: row + .compression + .as_deref() + .filter(|code| compression(code).is_none()) + .map(|_| vec![UnmodeledAttribute::new("COMPRESSION")]) + .unwrap_or_default(), ..ColumnAttributes::default() }; ColumnDefinition::new(column_info(row), attributes) } +/// `pg_attribute.attstorage`, which every release spells the same. +fn storage(code: &str) -> Option { + match code { + "p" => Some(ColumnStorage::Plain), + "m" => Some(ColumnStorage::Main), + "e" => Some(ColumnStorage::External), + "x" => Some(ColumnStorage::Extended), + _ => None, + } +} + +fn compression(code: &str) -> Option { + match code { + "p" => Some(ColumnCompression::Pglz), + "l" => Some(ColumnCompression::Lz4), + _ => None, + } +} + fn identity_style(row: &PgColumnRow) -> IdentityStyle { match row.identity.as_str() { "a" => IdentityStyle::GeneratedAlways, diff --git a/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs b/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs index 13b749566f..aa618dc005 100644 --- a/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs +++ b/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs @@ -9,7 +9,13 @@ use super::type_facts::{TypeFacts, TypeShape}; /// The recursive CTE follows a domain to its base type, keeping the /// first modifier along the way, because a domain column's own /// `atttypmod` is -1 when the modifier was declared on the domain. -pub(crate) const COLUMNS_SQL: &str = "\ +/// +/// The argument is the compression projection, since +/// `attcompression` arrived in PostgreSQL 14. +macro_rules! columns_sql { + ($compression:literal) => { + concat!( + "\ WITH RECURSIVE resolved AS ( SELECT a.attnum, a.atttypid AS type_oid, a.atttypmod AS typmod, 0 AS depth FROM pg_catalog.pg_attribute a @@ -47,7 +53,11 @@ SELECT a.attname::text, a.attgenerated::text, pg_catalog.col_description(a.attrelid, a.attnum::int4)::text, CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN cn.nspname::text END, - CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN co.collname::text END + CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN co.collname::text END, + CASE WHEN a.attstorage <> at.typstorage THEN a.attstorage::text END, + ", + $compression, + " FROM pg_catalog.pg_attribute a JOIN base b ON b.attnum = a.attnum JOIN pg_catalog.pg_type at ON at.oid = a.atttypid @@ -58,7 +68,13 @@ LEFT JOIN pg_catalog.pg_constraint pk ON pk.conrelid = a.attrelid AND pk.contype LEFT JOIN pg_catalog.pg_collation co ON co.oid = a.attcollation LEFT JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace WHERE a.attrelid = $1::text::regclass AND a.attnum > 0 AND NOT a.attisdropped -ORDER BY a.attnum"; +ORDER BY a.attnum" + ) + }; +} + +pub(crate) const COLUMNS_SQL: &str = columns_sql!("NULLIF(a.attcompression::text, '')"); +pub(crate) const COLUMNS_SQL_BEFORE_14: &str = columns_sql!("NULL::text"); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PgColumnRow { @@ -76,6 +92,10 @@ pub(crate) struct PgColumnRow { pub(crate) generated: String, pub(crate) comment: Option, pub(crate) collation: Option<(Option, String)>, + /// `pg_attribute.attstorage` where it differs from the type's. + pub(crate) storage: Option, + /// `pg_attribute.attcompression` where the column names a method. + pub(crate) compression: Option, } impl PgColumnRow { @@ -124,6 +144,8 @@ impl PgColumnRow { generated: row.try_get(15).map_err(field)?, comment: row.try_get(16).map_err(field)?, collation: collation_name.map(|name| (collation_schema, name)), + storage: row.try_get(19).map_err(field)?, + compression: row.try_get(20).map_err(field)?, }) } } diff --git a/linux/crates/drivers/postgres/src/connection.rs b/linux/crates/drivers/postgres/src/connection.rs index 8f1486f338..ac3e4e08c0 100644 --- a/linux/crates/drivers/postgres/src/connection.rs +++ b/linux/crates/drivers/postgres/src/connection.rs @@ -19,6 +19,7 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use tokio_util::task::TaskTracker; use crate::cancel::{PgStop, StopProgress}; +use crate::catalog; use crate::commit_resolution::resolve_op; use crate::dialect::PgDialect; use crate::editor::PgEditorSession; @@ -87,6 +88,13 @@ impl PgConnection { self.dialect.qualify(table) } + fn columns_sql(&self) -> &'static str { + match self.server.at_least(14, 0, 0) { + true => catalog::COLUMNS_SQL, + false => catalog::COLUMNS_SQL_BEFORE_14, + } + } + /// Run one read in its own read-only transaction on a pooled /// connection, owned by a task that outlives the caller. async fn read(&self, options: CallOptions, op: ReadOp) -> Result { @@ -261,9 +269,12 @@ impl Connection for PgConnection { async fn fetch_columns(&self, table: &TableRef, options: CallOptions) -> Result, DriverError> { let relation = self.relation(table); + let columns_sql = self.columns_sql(); self.read( options, - read_op(move |client| Box::pin(async move { metadata::fetch_columns(client, &relation).await })), + read_op(move |client| { + Box::pin(async move { metadata::fetch_columns(client, columns_sql, &relation).await }) + }), ) .await } @@ -296,11 +307,12 @@ impl Connection for PgConnection { options: CallOptions, ) -> Result { let relation = self.relation(table); + let columns_sql = self.columns_sql(); let table = table.clone(); self.read( options, read_op(move |client| { - Box::pin(async move { metadata::fetch_table_definition(client, &table, &relation).await }) + Box::pin(async move { metadata::fetch_table_definition(client, columns_sql, &table, &relation).await }) }), ) .await diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 026925f576..4ee9e239a0 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -1,9 +1,9 @@ -use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth}; +use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth, NetworkFamily}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, - DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, Permission, RefusalReason, TableChanges, TableDefinition, - TableDraft, + BuildDdlError, CollationName, ColumnAttributes, ColumnCapabilities, ColumnCompression, ColumnDefinition, + ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, + Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -187,6 +187,7 @@ impl DdlDialect for PgDialect { "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", resolved.sql, resolved.sql )); + actions.extend(restated_storage(&name, &alteration.original.attributes, &resolved)); } // An identity column is NOT NULL whatever the switch said, and // turning one on sets that below. @@ -395,6 +396,139 @@ fn identity_permission(column: &ColumnDefinition) -> Permission { } } +/// A type change resets SET STORAGE and SET COMPRESSION to the new +/// type's own, so a column that set either has it restated. A type that +/// keeps every value inline refuses both, so the settings go with the +/// old type. A type this build cannot place, such as a domain or an +/// enum, is restated, and the server refuses the change if it cannot +/// take them. +fn restated_storage(name: &str, attributes: &ColumnAttributes, new_type: &ResolvedType) -> Vec { + if plain_storage(new_type) { + return Vec::new(); + } + let storage = attributes.storage.map(|storage| { + let keyword = match storage { + ColumnStorage::Plain => "PLAIN", + ColumnStorage::Main => "MAIN", + ColumnStorage::External => "EXTERNAL", + ColumnStorage::Extended => "EXTENDED", + }; + format!("ALTER COLUMN {name} SET STORAGE {keyword}") + }); + let compression = attributes.compression.map(|compression| { + let method = match compression { + ColumnCompression::Pglz => "pglz", + ColumnCompression::Lz4 => "lz4", + }; + format!("ALTER COLUMN {name} SET COMPRESSION {method}") + }); + storage.into_iter().chain(compression).collect() +} + +/// The built-in types whose `pg_type.typstorage` is PLAIN in +/// PostgreSQL 17, by `typname`. +const PLAIN_STORAGE_TYPES: &[&str] = &[ + "aclitem", + "bool", + "box", + "char", + "cid", + "circle", + "date", + "float4", + "float8", + "gtsvector", + "int2", + "int2vector", + "int4", + "int8", + "interval", + "line", + "lseg", + "macaddr", + "macaddr8", + "money", + "name", + "oid", + "oidvector", + "pg_lsn", + "point", + "regclass", + "regcollation", + "regconfig", + "regdictionary", + "regnamespace", + "regoper", + "regoperator", + "regproc", + "regprocedure", + "regrole", + "regtype", + "tid", + "time", + "timestamp", + "timestamptz", + "timetz", + "tsquery", + "uuid", + "xid", + "xid8", +]; + +/// Whether PostgreSQL keeps every value of the type inline, where SET +/// STORAGE takes only PLAIN and SET COMPRESSION is refused. The kind +/// covers the standard spellings, such as `double precision`, that are +/// not a `typname`. +fn plain_storage(new_type: &ResolvedType) -> bool { + catalog_type_name(&new_type.sql).is_some_and(|name| { + PLAIN_STORAGE_TYPES.contains(&name.as_str()) + || matches!( + new_type.column_type.kind, + ColumnKind::Bool(_) + | ColumnKind::Integer(_) + | ColumnKind::Float(_) + | ColumnKind::Money { .. } + | ColumnKind::Date + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::Interval + | ColumnKind::Uuid + | ColumnKind::Network(NetworkFamily::MacAddr | NetworkFamily::MacAddr8) + ) + }) +} + +/// The `typname` of the built-in type a declaration names: unquoted +/// words fold to lower case and `char` alone means `bpchar`, while a +/// quoted `"char"` is the one-byte type. `None` for an array or a type +/// in a schema other than `pg_catalog`. +fn catalog_type_name(declaration: &str) -> Option { + let text = declaration.trim(); + let lowered = text.to_ascii_lowercase(); + if text.contains('[') || lowered.split_whitespace().next_back() == Some("array") { + return None; + } + let text = if lowered.starts_with("pg_catalog.") { + &text["pg_catalog.".len()..] + } else { + text.strip_prefix("\"pg_catalog\".").unwrap_or(text) + }; + if let Some(quoted) = text.strip_prefix('"') { + let (name, rest) = quoted.split_once('"')?; + return (!rest.starts_with('.')).then(|| name.to_owned()); + } + let word: String = text + .chars() + .take_while(|c| !c.is_whitespace() && *c != '(') + .collect::() + .to_ascii_lowercase(); + match word.as_str() { + "char" | "character" => Some("bpchar".to_owned()), + _ if word.contains(['.', '"']) => None, + _ => Some(word), + } +} + /// Whether a type takes a COLLATE clause: text, and arrays of it. fn collatable(kind: &ColumnKind) -> bool { match kind { @@ -628,6 +762,78 @@ mod tests { assert!(!collatable(&dialect().parse_type("uuid").expect("a type").kind)); } + #[test] + fn a_type_change_restates_storage_and_compression_the_new_type_takes() { + let mut original = users(); + for column in &mut original.columns[1..] { + column.attributes.storage = Some(ColumnStorage::External); + column.attributes.compression = Some(ColumnCompression::Lz4); + } + + let plan = alter(&original, |draft| { + draft.columns[1].column_type = DraftType::Typed("varchar(320)".to_owned()); + draft.columns[2].column_type = DraftType::Typed("bigint".to_owned()); + }); + let nullability_only = alter(&original, |draft| draft.columns[1].nullable = false); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" TYPE varchar(320) USING \ + \"email\"::varchar(320), ALTER COLUMN \"email\" SET STORAGE EXTERNAL, ALTER COLUMN \"email\" SET \ + COMPRESSION lz4, ALTER COLUMN \"status\" TYPE bigint USING \"status\"::bigint" + ] + ); + assert_eq!( + sql(nullability_only), + ["ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" SET NOT NULL"] + ); + } + + #[test] + fn only_a_type_that_keeps_its_values_inline_drops_the_settings() { + let plain = |declaration: &str| { + let resolved = DraftType::Typed(declaration.to_owned()) + .resolve(&dialect(), "c") + .expect("a type"); + plain_storage(&resolved) + }; + + for declaration in [ + "point", + "pg_catalog.lseg", + "line", + "box", + "circle", + "pg_lsn", + "\"char\"", + "name", + "tsquery", + "oid", + "double precision", + "timestamp(3) with time zone", + "\"pg_catalog\".\"int4\"", + ] { + assert!(plain(declaration), "{declaration}"); + } + for declaration in [ + "char(2)", + "character", + "text", + "polygon", + "numeric(10, 2)", + "integer[]", + "geometry", + "public.point", + "\"Point\"", + "mood", + "int4range", + "tsvector", + ] { + assert!(!plain(declaration), "{declaration}"); + } + } + #[test] fn rename_then_alter_uses_new_name() { let plan = alter(&users(), |draft| { diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index b6b15a5f5c..0a766d0e9c 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -11,7 +11,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -146,6 +147,10 @@ impl SqlDialect for PgDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::PostgreSql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -337,6 +342,39 @@ fn quoted(text: &str) -> String { escape_literal(text).trim_start().to_owned() } +/// Statements whose text can hold a password or a connection string. +/// `CREATE USER` also covers `CREATE USER MAPPING`, whose options carry +/// the remote login, and pgcrypto's `crypt` and passphrase functions take +/// the password itself. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "ROLE"], + &["ALTER", "ROLE"], + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "GROUP"], + &["ALTER", "GROUP"], + &["CREATE", "SERVER"], + &["ALTER", "SERVER"], + &["CREATE", "SUBSCRIPTION"], + &["ALTER", "SUBSCRIPTION"], + ], + conditional_prefixes: &[(&["ALTER", "SYSTEM"], &["PRIMARY_CONNINFO"])], + carriers: &[ + "dblink", + "dblink_connect", + "dblink_connect_u", + "dblink_exec", + "crypt", + "pgp_sym_encrypt", + "pgp_sym_encrypt_bytea", + "pgp_sym_decrypt", + "pgp_sym_decrypt_bytea", + "pgp_pub_decrypt", + "pgp_pub_decrypt_bytea", + ], +}; + #[cfg(test)] mod tests { use tablepro_core::column::{ColumnDefault, IntegerKind}; @@ -571,4 +609,37 @@ mod tests { assert_eq!(dialect.quote_identifier("a\"b"), "\"a\"\"b\""); assert!(dialect.ddl().is_some_and(|ddl| ddl.capabilities().partial_indexes)); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE ROLE r LOGIN PASSWORD 'p'", + "ALTER USER u WITH PASSWORD $$x$$", + "CREATE USER MAPPING FOR u SERVER s OPTIONS (password 'b')", + "CREATE SUBSCRIPTION s CONNECTION 'password=x' PUBLICATION p", + "SELECT dblink_connect('password=x')", + "ALTER SYSTEM SET primary_conninfo = 'password=x'", + "SELECT 1; CREATE USER u PASSWORD 'p'", + "DO $$ BEGIN CREATE ROLE app LOGIN PASSWORD 'p'; END $$", + "DO 'BEGIN CREATE ROLE r PASSWORD ''p''; END'", + "CREATE FUNCTION f() RETURNS text AS 'SELECT dblink_connect(''password=x'')' LANGUAGE sql", + "SELECT pgp_sym_encrypt('data', 'passphrase')", + "SELECT pgp_sym_decrypt(secret, 'passphrase') FROM t", + "UPDATE users SET pw = crypt('secret', gen_salt('bf'))", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/postgres/src/metadata/mod.rs b/linux/crates/drivers/postgres/src/metadata/mod.rs index e8386e44ce..d55b53f0fe 100644 --- a/linux/crates/drivers/postgres/src/metadata/mod.rs +++ b/linux/crates/drivers/postgres/src/metadata/mod.rs @@ -23,8 +23,7 @@ use tablepro_core::meta::TableRef; use tokio_postgres::{Client, Row}; use crate::catalog::{ - COLUMNS_SQL, INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, PgColumnRow, column_definition, column_info, - inbound_foreign_key, + INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, PgColumnRow, column_definition, column_info, inbound_foreign_key, }; use crate::errors::map_error; @@ -50,14 +49,19 @@ pub(crate) fn decode_error(table: &'static str) -> impl Fn(tokio_postgres::Error /// `relation` is the table's quoted, qualified name, which `::regclass` /// resolves the same way the server resolves it in a query. -pub(crate) async fn fetch_columns(client: &Client, relation: &str) -> Result, DriverError> { - column_rows(client, relation) +/// `columns_sql` is the column query for the server's release. +pub(crate) async fn fetch_columns( + client: &Client, + columns_sql: &str, + relation: &str, +) -> Result, DriverError> { + column_rows(client, columns_sql, relation) .await .map(|rows| rows.iter().map(column_info).collect()) } -async fn column_rows(client: &Client, relation: &str) -> Result, DriverError> { - text_rows(client, COLUMNS_SQL, &[relation]) +async fn column_rows(client: &Client, columns_sql: &str, relation: &str) -> Result, DriverError> { + text_rows(client, columns_sql, &[relation]) .await? .iter() .map(PgColumnRow::from_row) @@ -66,6 +70,7 @@ async fn column_rows(client: &Client, relation: &str) -> Result pub(crate) async fn fetch_table_definition( client: &Client, + columns_sql: &str, table: &TableRef, relation: &str, ) -> Result { @@ -73,7 +78,7 @@ pub(crate) async fn fetch_table_definition( // them run inside the flight's transaction, ahead of its COMMIT. let params = [relation]; let (columns, primary_key, indexes, foreign_keys, inbound_foreign_keys) = futures_util::try_join!( - column_rows(client, relation), + column_rows(client, columns_sql, relation), text_rows(client, PRIMARY_KEY_NAME_SQL, ¶ms), fetch_indexes(client, relation), fetch_foreign_keys(client, relation), diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 0d8a76e814..db06c3dcc2 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -2,7 +2,8 @@ //! server. use tablepro_core::ddl::{ - CollationName, DraftColumn, DraftType, IdentityStyle, PrimaryKeyConstraint, TableDefinition, TableDraft, diff_table, + CollationName, ColumnCompression, ColumnStorage, DraftColumn, DraftType, IdentityStyle, PrimaryKeyConstraint, + TableDefinition, TableDraft, diff_table, }; use tablepro_core::meta::TableRef; use tablepro_core::{Connection, WriteKind}; @@ -395,3 +396,77 @@ async fn type_change_keeps_c_collation() { assert_eq!(column("amount").info.column_type.name, "integer"); connection.close().await.expect("a close"); } + +/// ALTER COLUMN ... TYPE resets SET STORAGE and SET COMPRESSION to the +/// new type's own, so the change restates what the column had. A type +/// that keeps its values inline, such as integer, point or "char", +/// refuses both, so its column goes back to the type's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn type_change_keeps_storage_and_compression() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, body varchar(10), note text, code text, spot text, flag text)", + "ALTER TABLE kept ALTER COLUMN body SET STORAGE EXTERNAL, ALTER COLUMN body SET COMPRESSION pglz, \ + ALTER COLUMN note SET STORAGE MAIN, ALTER COLUMN code SET STORAGE EXTERNAL, \ + ALTER COLUMN spot SET STORAGE EXTERNAL, ALTER COLUMN flag SET STORAGE EXTERNAL, \ + ALTER COLUMN flag SET COMPRESSION pglz", + "INSERT INTO kept VALUES (1, 'a', 'b', '7', '(1,2)', 'x')", + ], + ) + .await; + let before = read(connection.as_ref()).await; + let column = |definition: &TableDefinition, name: &str| { + let attributes = &definition.column(name).expect("the column").attributes; + (attributes.storage, attributes.compression) + }; + assert_eq!( + column(&before, "body"), + (Some(ColumnStorage::External), Some(ColumnCompression::Pglz)) + ); + assert_eq!(column(&before, "id"), (None, None)); + + let after = change(connection.as_ref(), &kept(), |column| { + let retyped = match column.name.as_str() { + "body" => "varchar(40)", + "note" => "varchar(100)", + "code" => "integer", + "spot" => "point", + "flag" => "\"char\"", + _ => return, + }; + column.column_type = DraftType::Typed(retyped.to_owned()); + }) + .await; + + assert_eq!(type_of(&after, "body"), "character varying(40)"); + assert_eq!( + column(&after, "body"), + (Some(ColumnStorage::External), Some(ColumnCompression::Pglz)) + ); + assert_eq!(column(&after, "note"), (Some(ColumnStorage::Main), None)); + assert_eq!(column(&after, "code"), (None, None)); + assert_eq!(column(&after, "spot"), (None, None)); + assert_eq!(column(&after, "flag"), (None, None)); + assert_eq!( + value( + connection.as_ref(), + "SELECT string_agg(attname::text || '=' || attstorage::text || coalesce(nullif(attcompression::text, ''), \ + '-'), ',' ORDER BY attnum) FROM pg_attribute WHERE attrelid = 'kept'::regclass AND attnum > 0" + ) + .await, + "id=p-,body=ep,note=m-,code=p-,spot=p-,flag=p-" + ); + assert_eq!( + value( + connection.as_ref(), + "SELECT body || note || code || spot::text || flag::text FROM kept" + ) + .await, + "ab7(1,2)x" + ); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/sqlite/src/catalog.rs b/linux/crates/drivers/sqlite/src/catalog.rs index 7976b24aec..52d19e4c29 100644 --- a/linux/crates/drivers/sqlite/src/catalog.rs +++ b/linux/crates/drivers/sqlite/src/catalog.rs @@ -17,10 +17,10 @@ use tablepro_core::column::{ use tablepro_core::sql_syntax::{LiteralToken, MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; use tablepro_core::value::{SqlDecimal, Value}; -pub(crate) use dependents::{SchemaRow, select_dependents}; +pub(crate) use dependents::{SchemaRow, select_dependents, words}; pub use runtime_version::{SQLITE_MIN_VERSION, SqliteVersion, check_runtime_version}; pub(crate) use sqlite_column_row::{SqliteColumnRow, TableFacts}; -pub(crate) use table_definition::{InboundKeyRow, foreign_key_name, generated_expressions, inbound_foreign_keys}; +pub(crate) use table_definition::{InboundKeyRow, foreign_key_names, generated_expressions, inbound_foreign_keys}; /// The schema unqualified names resolve to. pub(crate) const MAIN_SCHEMA: &str = "main"; @@ -69,9 +69,13 @@ pub(crate) fn dependents_sql(schema_sql: &str) -> String { ) } +/// `child_keys` counts every key of the child, wherever it points, since +/// the ids number them all. pub(crate) fn inbound_keys_sql(schema_sql: &str) -> String { format!( - "SELECT m.name AS child, f.id AS id, f.\"from\" AS \"from\", f.\"to\" AS \"to\" \ + "SELECT m.name AS child, m.sql AS child_sql, \ + (SELECT count(DISTINCT k.id) FROM pragma_foreign_key_list(m.name, ?2) k) AS child_keys, \ + f.id AS id, f.\"from\" AS \"from\", f.\"to\" AS \"to\" \ FROM {schema_sql}.sqlite_schema m, pragma_foreign_key_list(m.name, ?2) f \ WHERE m.type = 'table' AND f.\"table\" = ?1 COLLATE NOCASE ORDER BY m.name, f.id, f.seq" ) diff --git a/linux/crates/drivers/sqlite/src/catalog/dependents.rs b/linux/crates/drivers/sqlite/src/catalog/dependents.rs index e4abfb940c..12660818c7 100644 --- a/linux/crates/drivers/sqlite/src/catalog/dependents.rs +++ b/linux/crates/drivers/sqlite/src/catalog/dependents.rs @@ -66,7 +66,7 @@ fn keeps(row: &SchemaRow, mentions: &HashSet, table: &str, names: &[Stri /// Every identifier the SQL names, lowercased and unquoted. A name /// inside a string literal or a comment is not a reference. -fn words(sql: &str) -> HashSet { +pub(crate) fn words(sql: &str) -> HashSet { let plan = ScriptPlan::build( sql, SqlGrammar::Sqlite, diff --git a/linux/crates/drivers/sqlite/src/catalog/sqlite_column_row.rs b/linux/crates/drivers/sqlite/src/catalog/sqlite_column_row.rs index 05d6a3a0f4..875111c177 100644 --- a/linux/crates/drivers/sqlite/src/catalog/sqlite_column_row.rs +++ b/linux/crates/drivers/sqlite/src/catalog/sqlite_column_row.rs @@ -31,6 +31,10 @@ pub(crate) struct TableFacts { pub autoincrement: bool, /// Exactly one column is in the primary key. pub single_column_key: bool, + /// The CREATE TABLE text declares the key column `PRIMARY KEY DESC` + /// on the column itself, which SQLite reads as an ordinary column + /// rather than the rowid (lang_createtable.html#rowid). + pub descending_key: bool, } impl SqliteColumnRow { @@ -62,6 +66,13 @@ impl SqliteColumnRow { /// `INTEGER PRIMARY KEY`, spelled exactly so, names the rowid /// rather than adding a column, and only on a table that has one. pub(crate) fn is_rowid_alias(&self, table: &TableFacts) -> bool { + self.is_rowid_candidate(table) && !table.descending_key + } + + /// The rowid as `pragma_table_xinfo` alone shows it. `PRIMARY KEY + /// DESC` on the column still makes it an ordinary column, which the + /// table's CREATE TABLE text tells. + pub(crate) fn is_rowid_candidate(&self, table: &TableFacts) -> bool { self.pk > 0 && table.single_column_key && !table.without_rowid @@ -127,6 +138,7 @@ mod tests { without_rowid: false, autoincrement: false, single_column_key, + descending_key: false, } } @@ -160,6 +172,16 @@ mod tests { .column_info(&without_rowid) .is_auto_increment ); + let descending = TableFacts { + descending_key: true, + ..facts(true) + }; + let column = row("id", "INTEGER", 1, None); + assert!( + !column.column_info(&descending).is_auto_increment, + "INTEGER PRIMARY KEY DESC is an ordinary column, not the rowid" + ); + assert_eq!(column.attributes(&descending).identity, IdentityStyle::None); } #[test] diff --git a/linux/crates/drivers/sqlite/src/catalog/table_definition.rs b/linux/crates/drivers/sqlite/src/catalog/table_definition.rs index f7fe5b4490..fa29813d7d 100644 --- a/linux/crates/drivers/sqlite/src/catalog/table_definition.rs +++ b/linux/crates/drivers/sqlite/src/catalog/table_definition.rs @@ -7,11 +7,17 @@ use tablepro_core::column::SqlExpression; use tablepro_core::ddl::InboundForeignKey; use tablepro_core::meta::TableRef; +use crate::ddl::declared_foreign_key_names; + /// One row of the inbound foreign key query: a key column of another /// table that points at this one. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct InboundKeyRow { pub child: String, + /// The child's CREATE TABLE, where the key's declared name is. + pub child_sql: Option, + /// How many foreign keys the child has, here or elsewhere. + pub child_keys: usize, pub id: i64, pub from: String, /// `None` when the key names no columns, which means the parent's @@ -29,10 +35,20 @@ pub(crate) fn inbound_foreign_keys( let mut keys: Vec = Vec::new(); let mut positions: HashMap<(String, i64), usize> = HashMap::new(); let mut unnamed_targets: Vec = Vec::new(); + let mut names: HashMap> = HashMap::new(); for row in rows { + let child_names = names.entry(row.child.clone()).or_insert_with(|| { + let declared = row.child_sql.as_deref().and_then(declared_foreign_key_names); + foreign_key_names(&row.child, declared, row.child_keys) + }); + let name = usize::try_from(row.id) + .ok() + .and_then(|id| child_names.get(id)) + .cloned() + .unwrap_or_else(|| foreign_key_name(&row.child, row.id)); let position = *positions.entry((row.child.clone(), row.id)).or_insert_with(|| { keys.push(InboundForeignKey { - name: foreign_key_name(&row.child, row.id), + name, table: TableRef::new(schema.map(str::to_owned), row.child.clone()), columns: Vec::new(), referenced_columns: Vec::new(), @@ -57,13 +73,32 @@ pub(crate) fn inbound_foreign_keys( keys } -/// SQLite does not keep a foreign key's constraint name anywhere the -/// pragmas reach, so the name is made from the child table and the -/// key's position, which stays the same while the schema does. +/// The name of a key declared without one, made from the child table +/// and the key's position, which stays the same while the schema does. pub(crate) fn foreign_key_name(table: &str, id: i64) -> String { format!("fk_{table}_{id}") } +/// A name for each of a table's `count` foreign keys, by pragma id. The +/// pragmas do not report the name a CONSTRAINT clause gave a key, so it +/// comes from the table's CREATE TABLE, `declared`. A key without one, +/// or with a name an earlier key took, gets a made-up one. +pub(crate) fn foreign_key_names(table: &str, declared: Option>>, count: usize) -> Vec { + let declared = declared.filter(|declared| declared.len() == count).unwrap_or_default(); + let mut names: Vec = Vec::with_capacity(count); + for id in 0..count { + let made_up = || foreign_key_name(table, i64::try_from(id).unwrap_or(i64::MAX)); + let name = declared + .get(id) + .cloned() + .flatten() + .filter(|name| !names.iter().any(|taken| taken.eq_ignore_ascii_case(name))) + .unwrap_or_else(made_up); + names.push(name); + } + names +} + /// The expressions of the generated columns, read from the table's own /// CREATE TABLE. The pragmas say a column is generated but not how. /// The text is the parser's rendering of the expression: the table's @@ -102,6 +137,8 @@ mod tests { fn key_row(child: &str, id: i64, from: &str, to: Option<&str>) -> InboundKeyRow { InboundKeyRow { child: child.to_owned(), + child_sql: None, + child_keys: 3, id, from: from.to_owned(), to: to.map(str::to_owned), @@ -126,6 +163,42 @@ mod tests { assert_eq!(keys[1].referenced_columns, ["a", "b"]); } + #[test] + fn a_declared_constraint_name_is_the_key_name() { + let child_sql = "CREATE TABLE lines (a INTEGER CONSTRAINT to_orders REFERENCES orders, b INTEGER REFERENCES \ + orders, CONSTRAINT to_orders FOREIGN KEY (a) REFERENCES orders)"; + let rows = [(0, "a"), (1, "b"), (2, "a")].map(|(id, from)| InboundKeyRow { + child_sql: Some(child_sql.to_owned()), + ..key_row("lines", id, from, None) + }); + + let keys = inbound_foreign_keys(None, rows.to_vec(), &["id".to_owned()]); + + let names: Vec<&str> = keys.iter().map(|key| key.name.as_str()).collect(); + assert_eq!(names, ["to_orders", "fk_lines_1", "fk_lines_2"]); + let miscounted: Vec = rows + .iter() + .map(|row| InboundKeyRow { + child_keys: 4, + ..row.clone() + }) + .collect(); + let unsure: Vec = inbound_foreign_keys(None, miscounted, &["id".to_owned()]) + .into_iter() + .map(|key| key.name) + .collect(); + assert_eq!( + unsure, + ["fk_lines_0", "fk_lines_1", "fk_lines_2"], + "names read from text that declares fewer keys than the child has" + ); + assert_eq!( + foreign_key_names("t", Some(vec![Some("x".to_owned())]), 2), + ["fk_t_0", "fk_t_1"], + "a list that does not match the catalogue is not used" + ); + } + #[test] fn generated_expressions_come_from_the_create_statement() { let expressions = generated_expressions( diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index 23393692b3..ba22d8b973 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -1,36 +1,52 @@ +//! How SQLite plans structure changes: its own ALTER TABLE where that +//! can make the change, and a rebuild of the table where it cannot. + +mod column_constraint_segment; +mod create_table_source; +mod declared_shape; +mod native_change_rules; +mod rebuild_plan; +mod source_element; +mod source_read_error; + use tablepro_core::ForeignKeyInfo; use tablepro_core::column::ColumnDefault; -use tablepro_core::ddl::ddl_sql::{column_list, default_sql, foreign_key_sql, index_keys_sql, parenthesized}; +use tablepro_core::ddl::ddl_sql::{column_list, default_sql, index_keys_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, SchemaObjectKind, - TableChanges, TableDefinition, TableDraft, + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, + GeneratedStorage, Permission, RefusalReason, SchemaObjectKind, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; +use self::create_table_source::CreateTableSource; +pub(crate) use self::create_table_source::{declared_descending_key, declared_foreign_key_names}; +use self::declared_shape::readable; use crate::dialect::SqliteDialect; -/// What SQLite's ALTER TABLE does natively. Everything else needs the -/// table rebuilt, which these plans do not do yet. +/// A change ALTER TABLE cannot make rebuilds the table, so every change +/// is offered. The rebuild needs the table's stored CREATE TABLE, and +/// `table_capabilities` and `column_capabilities` refuse the changes +/// only a rebuild makes when that text cannot be read. static CAPABILITIES: DdlCapabilities = DdlCapabilities { create_table: true, rename_table: true, add_column: true, drop_column: true, rename_column: true, - alter_column_type: false, - alter_column_nullability: false, - alter_column_default: false, - alter_primary_key: false, - alter_auto_increment: false, + alter_column_type: true, + alter_column_nullability: true, + alter_column_default: true, + alter_primary_key: true, + alter_auto_increment: true, // AUTOINCREMENT belongs to the INTEGER PRIMARY KEY. auto_increment_outside_primary_key: false, create_index: true, drop_index: true, expression_index_keys: true, partial_indexes: true, - add_foreign_key: false, - drop_foreign_key: false, + add_foreign_key: true, + drop_foreign_key: true, column_comments: false, drop_table_cascade: false, }; @@ -39,8 +55,6 @@ const TYPE_SUGGESTIONS: &[&str] = &[ "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", ]; -/// SQLite runs a schema change inside the write's transaction, one -/// native ALTER TABLE per change. impl DdlDialect for SqliteDialect { fn capabilities(&self) -> &DdlCapabilities { &CAPABILITIES @@ -50,6 +64,41 @@ impl DdlDialect for SqliteDialect { self } + /// Without a readable CREATE TABLE no rebuild runs, so no foreign key + /// is added or dropped and the key stays where it is. + fn table_capabilities(&self, table: &TableDefinition) -> DdlCapabilities { + match readable(table) { + true => CAPABILITIES, + false => DdlCapabilities { + alter_primary_key: false, + add_foreign_key: false, + drop_foreign_key: false, + ..CAPABILITIES + }, + } + } + + /// The rebuild writes a new type or nullability beside a generated + /// column's expression rather than restating the column, so neither + /// loses the expression. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + let rebuildable = Permission::unless(readable(table), RefusalReason::UnreadableDefinition); + let rebuild = || rebuildable.clone(); + permissions.alter_type = rebuild(); + permissions.alter_nullability = rebuild(); + permissions.alter_default = permissions.alter_default.and(rebuild); + permissions.alter_primary_key = permissions.alter_primary_key.and(rebuild); + permissions.alter_auto_increment = permissions.alter_auto_increment.and(rebuild); + permissions + } + + /// The parent of a key is written bare and looked for in the child's + /// own schema, so a table of an attached database cannot be one. + fn references_other_schemas(&self) -> bool { + false + } + fn type_suggestions(&self) -> &'static [&'static str] { TYPE_SUGGESTIONS } @@ -58,8 +107,8 @@ impl DdlDialect for SqliteDialect { "TEXT" } - /// Foreign keys are declared inside CREATE TABLE, the only place - /// SQLite takes them. + /// Foreign keys are declared inside CREATE TABLE, and the + /// connection's `foreign_keys` setting enforces them. fn plan_create_table(&self, draft: &TableDraft) -> Result { draft.check_create(self)?; let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); @@ -82,16 +131,12 @@ impl DdlDialect for SqliteDialect { let key: Vec = key.into_iter().map(str::to_owned).collect(); definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); } - // A foreign key names its parent bare: SQLite looks for it in the - // child's own schema and refuses a qualified name. - let local = TableRef::unqualified(table.name.clone()); - definitions.extend(draft.foreign_keys.iter().map(|key| { - let key = ForeignKeyInfo { - ref_schema: None, - ..key.clone() - }; - foreign_key_sql(self, &local, &key) - })); + definitions.extend( + draft + .foreign_keys + .iter() + .map(|key| foreign_key_constraint(self, key, &key.columns)), + ); let mut statements = vec![format!( "CREATE TABLE {} (\n {}\n)", self.qualify(&table), @@ -107,8 +152,49 @@ impl DdlDialect for SqliteDialect { Ok(DdlPlan::from_sql(statements)) } + /// Native statements when ALTER TABLE makes every change, otherwise + /// a rebuild that also runs the native ones. fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { changes.check(self, original)?; + let source = CreateTableSource::of(original); + let Some(change) = native_change_rules::first_rebuild_change(original, source.as_ref().ok(), changes) else { + return self.native_plan(original, changes); + }; + let source = source.map_err(|error| { + tracing::debug!(%error, table = %original.table.name, "the stored CREATE TABLE could not be read"); + BuildDdlError::Refused { + column: change.column, + operation: change.operation, + reason: RefusalReason::UnreadableDefinition, + } + })?; + rebuild_plan::plan(self, original, &source, changes) + } + + /// The CREATE statement SQLite stored, then the stored statements of + /// the table's own indexes and triggers. Indexes SQLite made for a + /// constraint have no statement and come back with the table. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + let Some(source) = &definition.source else { + return self.plan_create_table(&TableDraft::from_definition(definition)); + }; + let own = |table: &str| table.eq_ignore_ascii_case(&definition.table.name); + let dependents = source + .dependents + .iter() + .filter(|object| matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger)) + .filter(|object| own(&object.table)) + .map(|object| object.sql.clone()); + Ok(DdlPlan::from_sql( + std::iter::once(source.create_statement.clone()).chain(dependents), + )) + } +} + +impl SqliteDialect { + /// One native ALTER TABLE per change, inside the write's + /// transaction. + fn native_plan(&self, original: &TableDefinition, changes: &TableChanges) -> Result { let mut statements = Vec::new(); // SQLite keeps a renamed table in its schema, so the new name is // bare. @@ -143,12 +229,6 @@ impl DdlDialect for SqliteDialect { )); } for column in &changes.added_columns { - // The rows already there would have nothing to hold. - if !column.nullable && column.default.is_none() { - return Err(BuildDdlError::Unsupported( - DdlOperation::AddRequiredColumnWithoutDefault, - )); - } statements.push(format!( "ALTER TABLE {qualified} ADD COLUMN {}", self.column_definition(column, false)? @@ -163,27 +243,6 @@ impl DdlDialect for SqliteDialect { DdlPlan::for_changes(changes, statements) } - /// The CREATE statement SQLite stored, then the stored statements of - /// the table's own indexes and triggers. Indexes SQLite made for a - /// constraint have no statement and come back with the table. - fn create_table_script(&self, definition: &TableDefinition) -> Result { - let Some(source) = &definition.source else { - return self.plan_create_table(&TableDraft::from_definition(definition)); - }; - let own = |table: &str| table.eq_ignore_ascii_case(&definition.table.name); - let dependents = source - .dependents - .iter() - .filter(|object| matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger)) - .filter(|object| own(&object.table)) - .map(|object| object.sql.clone()); - Ok(DdlPlan::from_sql( - std::iter::once(source.create_statement.clone()).chain(dependents), - )) - } -} - -impl SqliteDialect { fn column_definition(&self, column: &DraftColumn, inline_key: bool) -> Result { let resolved = column.column_type.resolve(self, &column.name)?; let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; @@ -230,6 +289,28 @@ impl SqliteDialect { } } +/// A foreign key as a table constraint over `columns`. The parent is +/// named bare, since SQLite looks for it in the child's own schema and +/// refuses a qualified name, and a key with no parent columns points at +/// the parent's primary key. +fn foreign_key_constraint(dialect: &SqliteDialect, key: &ForeignKeyInfo, columns: &[String]) -> String { + let mut sql = format!( + "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}", + dialect.quote_identifier(&key.name), + column_list(dialect, columns), + dialect.quote_identifier(&key.ref_table) + ); + if !key.ref_columns.is_empty() { + sql.push_str(&format!(" ({})", column_list(dialect, &key.ref_columns))); + } + for (clause, action) in [("ON DELETE", key.on_delete), ("ON UPDATE", key.on_update)] { + if let Some(action) = action.filter(|action| !action.is_default()) { + sql.push_str(&format!(" {clause} {}", action.as_sql())); + } + } + sql +} + /// An expression default goes in parentheses, apart from the three /// CURRENT_ keywords SQLite takes bare. fn default_clause(default: &ColumnDefault, sql: &str) -> String { @@ -243,348 +324,4 @@ fn default_clause(default: &ColumnDefault, sql: &str) -> String { } #[cfg(test)] -mod tests { - use tablepro_core::column::{ColumnInfo, SqlExpression}; - use tablepro_core::ddl::ReferentialAction; - use tablepro_core::ddl::{ - ColumnAttributes, ColumnDefinition, DraftType, InboundForeignKey, Permission, RefusalReason, - SchemaObjectSource, TableSource, diff_table, - }; - use tablepro_core::meta::IndexKey; - use tablepro_core::value::Value; - - use super::*; - use crate::catalog::SqliteVersion; - - fn dialect() -> SqliteDialect { - SqliteDialect::new(SqliteVersion::new(3, 46, 0)) - } - - fn typed(name: &str, declaration: &str) -> DraftColumn { - DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) - } - - fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { - ColumnDefinition::new( - ColumnInfo { - name: name.to_owned(), - column_type: dialect().parse_type(declaration).expect("a SQLite type"), - nullable: !primary_key, - primary_key, - is_auto_increment: false, - is_generated: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - }, - ColumnAttributes::default(), - ) - } - - fn users() -> TableDefinition { - TableDefinition { - table: TableRef::unqualified("users"), - columns: vec![column("id", "INTEGER", true), column("email", "TEXT", false)], - primary_key: Some(tablepro_core::ddl::PrimaryKeyConstraint { - name: None, - columns: vec!["id".to_owned()], - }), - indexes: Vec::new(), - foreign_keys: Vec::new(), - inbound_foreign_keys: Vec::new(), - source: None, - } - } - - fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { - let mut draft = TableDraft::from_definition(original); - edit(&mut draft); - dialect().plan_alter_table(original, &diff_table(original, &draft)) - } - - fn sql(plan: Result) -> Vec { - plan.expect("the change plans") - .sql() - .into_iter() - .map(str::to_owned) - .collect() - } - - #[test] - fn plan_create_table_simple() { - let mut draft = TableDraft::new(TableRef::unqualified("notes")); - let mut id = typed("id", "INTEGER"); - id.primary_key = true; - id.nullable = false; - id.auto_increment = true; - let mut body = typed("body", "TEXT"); - body.nullable = false; - body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); - let mut created = typed("created_at", "TEXT"); - created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); - let mut token = typed("token", "TEXT"); - token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("lower(hex(randomblob(4)))")); - draft.columns = vec![id, body, created, token]; - - assert_eq!( - sql(dialect().plan_create_table(&draft)), - [ - "CREATE TABLE \"notes\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n \"body\" TEXT NOT \ - NULL DEFAULT 'it''s',\n \"created_at\" TEXT DEFAULT CURRENT_TIMESTAMP,\n \"token\" TEXT DEFAULT \ - (lower(hex(randomblob(4))))\n)" - ] - ); - } - - #[test] - fn plan_create_table_composite_key_index_and_foreign_key() { - let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); - let mut order = typed("order_id", "INTEGER"); - order.primary_key = true; - order.nullable = false; - let mut line = typed("line", "INTEGER"); - line.primary_key = true; - line.nullable = false; - draft.columns = vec![order, line, typed("sku", "TEXT")]; - draft.indexes = vec![IndexInfo { - name: "by_sku".to_owned(), - keys: vec![IndexKey::Column { - name: "sku".to_owned(), - descending: false, - }], - unique: true, - primary: false, - predicate: Some("sku IS NOT NULL".to_owned()), - }]; - draft.foreign_keys = vec![ForeignKeyInfo { - name: "lines_order_fk".to_owned(), - columns: vec!["order_id".to_owned()], - ref_schema: None, - ref_table: "orders".to_owned(), - ref_columns: vec!["id".to_owned()], - on_delete: Some(ReferentialAction::Cascade), - on_update: None, - }]; - - assert_eq!( - sql(dialect().plan_create_table(&draft)), - [ - "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER NOT NULL,\n \"line\" INTEGER NOT \ - NULL,\n \"sku\" TEXT,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" \ - FOREIGN KEY (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", - "CREATE UNIQUE INDEX \"main\".\"by_sku\" ON \"lines\" (\"sku\") WHERE sku IS NOT NULL", - ] - ); - } - - #[test] - fn plan_alter_table_takes_the_native_changes_and_refuses_the_rest() { - let native = alter(&users(), |draft| { - draft.table.name = "members".to_owned(); - draft.columns[1].name = "mail".to_owned(); - draft.columns.push(typed("nickname", "TEXT")); - }); - let nullability = alter(&users(), |draft| draft.columns[1].nullable = false); - let typed_change = alter(&users(), |draft| { - draft.columns[1].column_type = DraftType::Typed("BLOB".to_owned()); - }); - let default = alter(&users(), |draft| { - draft.columns[1].default = ColumnDefault::Literal(Value::Text("x".to_owned())); - }); - - assert_eq!( - sql(native), - [ - "ALTER TABLE \"users\" RENAME TO \"members\"", - "ALTER TABLE \"members\" RENAME COLUMN \"email\" TO \"mail\"", - "ALTER TABLE \"members\" ADD COLUMN \"nickname\" TEXT", - ] - ); - for refused in [nullability, typed_change, default] { - assert!( - matches!( - refused, - Err(BuildDdlError::Refused { - reason: RefusalReason::EngineUnsupported, - .. - }) - ), - "{refused:?}" - ); - } - } - - #[test] - fn a_swap_steps_aside_through_a_temporary_name() { - let mut table = users(); - table.columns.push(column("name", "TEXT", false)); - - let plan = alter(&table, |draft| { - draft.columns[1].name = "Name".to_owned(); - draft.columns[2].name = "email".to_owned(); - }); - - assert_eq!( - sql(plan), - [ - "ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", - "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"email\"", - "ALTER TABLE \"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"Name\"", - ] - ); - } - - #[test] - fn a_required_column_without_a_default_cannot_be_added() { - let plan = alter(&users(), |draft| { - let mut required = typed("nickname", "TEXT"); - required.nullable = false; - draft.columns.push(required); - }); - - assert_eq!( - plan, - Err(BuildDdlError::Unsupported( - DdlOperation::AddRequiredColumnWithoutDefault - )) - ); - } - - #[test] - fn an_indexed_column_drop_drops_the_index_first() { - let mut table = users(); - table.indexes = vec![IndexInfo { - name: "by_email".to_owned(), - keys: vec![IndexKey::Column { - name: "email".to_owned(), - descending: false, - }], - unique: false, - primary: false, - predicate: None, - }]; - - let plan = alter(&table, |draft| { - draft.indexes.clear(); - draft.columns.retain(|column| column.name != "email"); - }); - - assert_eq!( - sql(plan), - ["DROP INDEX \"by_email\"", "ALTER TABLE \"users\" DROP COLUMN \"email\""] - ); - } - - #[test] - fn primary_key_referenced_by_foreign_key_is_refused() { - let mut table = users(); - // SQLite reports a reference to the primary key with a NULL - // target, which the catalogue resolves to the key's columns. - table.inbound_foreign_keys = vec![InboundForeignKey { - name: "fk_orders_0".to_owned(), - table: TableRef::unqualified("orders"), - columns: vec!["user_id".to_owned()], - referenced_columns: vec!["id".to_owned()], - }]; - - let permissions = dialect().column_capabilities(&table, &table.columns[1]); - let id = dialect().column_capabilities(&table, &table.columns[0]); - - assert_eq!( - permissions.alter_primary_key, - Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) - ); - assert_eq!( - id.drop, - Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) - ); - } - - #[test] - fn drop_of_referenced_column_is_refused() { - let mut table = users(); - table.inbound_foreign_keys = vec![InboundForeignKey { - name: "fk_orders_0".to_owned(), - table: TableRef::unqualified("orders"), - columns: vec!["user_email".to_owned()], - referenced_columns: vec!["email".to_owned()], - }]; - - let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); - - assert!( - matches!( - plan, - Err(BuildDdlError::Refused { - operation: DdlOperation::DropColumn, - reason: RefusalReason::ReferencedByForeignKeys(_), - .. - }) - ), - "{plan:?}" - ); - } - - #[test] - fn the_script_replays_the_stored_table_and_its_own_dependents() { - let mut table = users(); - let object = |kind, name: &str, owner: &str, sql: &str| SchemaObjectSource { - kind, - name: name.to_owned(), - table: owner.to_owned(), - sql: sql.to_owned(), - }; - table.source = Some(TableSource { - create_statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)".to_owned(), - dependents: vec![ - object( - SchemaObjectKind::Index, - "by_email", - "Users", - "CREATE INDEX by_email ON users (email)", - ), - object( - SchemaObjectKind::Index, - "by_total", - "orders", - "CREATE INDEX by_total ON orders (total)", - ), - object( - SchemaObjectKind::Trigger, - "stamp", - "users", - "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", - ), - object(SchemaObjectKind::View, "v", "v", "CREATE VIEW v AS SELECT * FROM users"), - ], - }); - - assert_eq!( - sql(dialect().create_table_script(&table)), - [ - "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)", - "CREATE INDEX by_email ON users (email)", - "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", - ] - ); - } - - #[test] - fn foreign_keys_and_comments_wait_for_the_rebuild() { - let dialect = dialect(); - let capabilities = DdlDialect::capabilities(&dialect); - - assert!(!capabilities.add_foreign_key && !capabilities.drop_foreign_key); - assert!(!capabilities.column_comments); - assert!(capabilities.partial_indexes && capabilities.expression_index_keys); - } - - #[test] - fn every_type_suggestion_parses() { - let dialect = dialect(); - - for suggestion in dialect.type_suggestions() { - assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); - } - assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); - } -} +mod tests; diff --git a/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs b/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs new file mode 100644 index 0000000000..0a629496ff --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs @@ -0,0 +1,36 @@ +use std::ops::Range; + +use super::source_element::ForeignKeyTarget; + +/// One column constraint as the CREATE TABLE text spells it, with the +/// bytes it takes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ColumnConstraintSegment { + pub range: Range, + pub kind: ColumnConstraintKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ColumnConstraintKind { + /// `CONSTRAINT name`, which SQLite reads as its own entry naming the + /// constraint after it. + Name(String), + PrimaryKey { + autoincrement: bool, + /// `PRIMARY KEY DESC` on the column itself, which keeps an + /// INTEGER column from being the rowid (lang_createtable.html, + /// "ROWIDs and the INTEGER PRIMARY KEY"). + descending: bool, + }, + NotNull, + Null, + Unique, + Check, + Default, + Collate, + References(ForeignKeyTarget), + Generated, + /// `[NOT] DEFERRABLE ...` standing on its own, which SQLite applies + /// to the foreign key before it. + Deferrable, +} diff --git a/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs b/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs new file mode 100644 index 0000000000..9f6b0964bf --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs @@ -0,0 +1,1136 @@ +//! The CREATE TABLE text SQLite stored for a table, read into the +//! pieces a rebuild splices: each column with its constraints, the +//! table constraints, and the options after the list. +//! +//! The reader follows the grammar of lang_createtable.html over the +//! script lexer's tokens, the lexer the editor splits scripts with and +//! that is tested against `sqlite3_complete`. A quoted name, a string +//! or a comment is one token there, and every token carries its bytes, +//! so what a rebuild does not change goes into the new table as it was +//! written. + +use std::collections::HashSet; +use std::ops::Range; + +use tablepro_core::ddl::TableDefinition; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +use super::column_constraint_segment::{ColumnConstraintKind, ColumnConstraintSegment}; +use super::declared_shape::DeclaredShape; +use super::source_element::{ + ElementKind, ForeignKeyTarget, SourceColumn, SourceElement, SourceForeignKey, TableConstraint, TableConstraintKind, +}; +use super::source_read_error::SourceReadError; + +/// One token that is not whitespace or a comment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Token { + pub kind: ScriptTokenKind, + pub range: Range, +} + +/// The tokens of `sql` without whitespace and comments. An unclosed +/// string, name or comment makes the text unreadable. +pub(crate) fn tokens(sql: &str) -> Result, SourceReadError> { + let plan = ScriptPlan::build( + sql, + SqlGrammar::Sqlite, + LexicalSettings::default_for(SqlGrammar::Sqlite), + ); + let mut tokens: Vec = Vec::new(); + for token in plan.tokens(0..sql.len()) { + match token.kind { + ScriptTokenKind::Unterminated => return Err(SourceReadError::Unterminated), + kind if kind.is_trivia() => {} + // The lexer reads `X'00'` as a word and a string; SQLite reads + // one blob literal. + ScriptTokenKind::StringLiteral + if tokens.last().is_some_and(|last| { + last.kind == ScriptTokenKind::Word + && last.range.end == token.range.start + && sql + .get(last.range.clone()) + .is_some_and(|word| word.eq_ignore_ascii_case("x")) + }) => + { + if let Some(last) = tokens.last_mut() { + last.kind = ScriptTokenKind::StringLiteral; + last.range.end = token.range.end; + } + } + kind => tokens.push(Token { + kind, + range: token.range, + }), + } + } + Ok(tokens) +} + +/// A table's CREATE TABLE statement, read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreateTableSource<'a> { + sql: &'a str, + tokens: Vec, + /// The table's name, quotes removed. + pub table: String, + /// The byte of the `(` that opens the list. + pub open: usize, + /// The byte of the `)` that closes it. + pub close: usize, + pub elements: Vec, + /// Everything after the closing `)`, such as ` WITHOUT ROWID`. + pub options: Range, + pub without_rowid: bool, +} + +impl<'a> CreateTableSource<'a> { + pub(crate) fn read(sql: &'a str) -> Result { + let tokens = tokens(sql)?; + let mut reader = Reader { + sql, + tokens: &tokens, + position: 0, + }; + let (table, open) = reader.head()?; + let elements = reader.elements()?; + let close = reader.close()?; + let (options, without_rowid) = reader.options()?; + Ok(Self { + sql, + tokens, + table, + open, + close, + elements, + options, + without_rowid, + }) + } + + /// The statement stored for `definition`, read and checked against + /// what the catalogue says the table has. + pub(crate) fn of(definition: &'a TableDefinition) -> Result { + let sql = definition + .source + .as_ref() + .map(|source| source.create_statement.as_str()) + .ok_or(SourceReadError::Missing)?; + let source = Self::read(sql)?; + source.shape().check(definition)?; + Ok(source) + } + + pub(crate) fn shape(&self) -> DeclaredShape { + DeclaredShape { + table: self.table.clone(), + columns: self.columns().map(|(_, column)| column.name.clone()).collect(), + foreign_keys: self.foreign_keys().len(), + } + } + + pub(crate) fn sql(&self) -> &'a str { + self.sql + } + + pub(crate) fn text(&self, range: Range) -> &'a str { + self.sql.get(range).unwrap_or_default() + } + + /// The column elements with their index among all elements. + pub(crate) fn columns(&self) -> impl Iterator { + self.elements + .iter() + .enumerate() + .filter_map(|(index, element)| match &element.kind { + ElementKind::Column(column) => Some((index, column)), + ElementKind::Constraint(_) => None, + }) + } + + /// The table constraints with their index among all elements. + pub(crate) fn constraints(&self) -> impl Iterator { + self.elements + .iter() + .enumerate() + .filter_map(|(index, element)| match &element.kind { + ElementKind::Constraint(constraint) => Some((index, constraint)), + ElementKind::Column(_) => None, + }) + } + + /// Every foreign key in the order the text declares them. SQLite + /// numbers them from the last: `pragma_foreign_key_list` id 0 is + /// the last one here. + pub(crate) fn foreign_keys(&self) -> Vec> { + let mut keys = Vec::new(); + for (index, element) in self.elements.iter().enumerate() { + match &element.kind { + ElementKind::Column(column) => { + for (position, segment) in column.segments.iter().enumerate() { + if let ColumnConstraintKind::References(target) = &segment.kind { + let name = position + .checked_sub(1) + .and_then(|before| column.segments.get(before)) + .and_then(|before| match &before.kind { + ColumnConstraintKind::Name(name) => Some(name.as_str()), + _ => None, + }); + keys.push(SourceForeignKey { + name, + element: index, + segment: Some(position), + columns: std::slice::from_ref(&column.name), + target, + }); + } + } + } + ElementKind::Constraint(TableConstraint { + name, + kind: TableConstraintKind::ForeignKey { columns, target }, + }) => keys.push(SourceForeignKey { + name: name.as_deref(), + element: index, + segment: None, + columns, + target, + }), + ElementKind::Constraint(_) => {} + } + } + keys + } + + /// The column declared with AUTOINCREMENT, on its own PRIMARY KEY or + /// in a table PRIMARY KEY of that one column. + pub(crate) fn autoincrement_column(&self) -> Option<&str> { + let inline = self.columns().find_map(|(_, column)| { + column + .segments + .iter() + .any(|segment| { + matches!( + segment.kind, + ColumnConstraintKind::PrimaryKey { + autoincrement: true, + .. + } + ) + }) + .then_some(column.name.as_str()) + }); + inline.or_else(|| { + self.constraints().find_map(|(_, constraint)| match &constraint.kind { + TableConstraintKind::PrimaryKey { + columns, + autoincrement: true, + } => match columns.as_slice() { + [only] => Some(only.as_str()), + _ => None, + }, + _ => None, + }) + }) + } + + /// Whether `column` carries `PRIMARY KEY DESC` on itself, which keeps + /// an INTEGER column from being the rowid. The same order in a table + /// PRIMARY KEY does not (lang_createtable.html#rowid). + pub(crate) fn declares_descending_key(&self, column: &str) -> bool { + self.columns() + .filter(|(_, source)| source.name.eq_ignore_ascii_case(column)) + .flat_map(|(_, source)| &source.segments) + .any(|segment| matches!(segment.kind, ColumnConstraintKind::PrimaryKey { descending: true, .. })) + } + + /// The names `range` mentions, lowercased and unquoted. A name inside + /// a string literal is not a mention. + pub(crate) fn mentions(&self, range: Range) -> HashSet { + self.tokens + .iter() + .filter(|token| token.range.start >= range.start && token.range.end <= range.end) + .filter_map(|token| match token.kind { + ScriptTokenKind::Word => Some(self.text(token.range.clone()).to_lowercase()), + ScriptTokenKind::QuotedIdentifier => Some(unquote(self.text(token.range.clone())).to_lowercase()), + _ => None, + }) + .collect() + } +} + +/// The names a CREATE TABLE gives its foreign keys, in the order +/// `pragma_foreign_key_list` numbers them, which is from the last one +/// declared. `None` for a key declared without a name, and no list when +/// the text does not read. +pub(crate) fn declared_foreign_key_names(sql: &str) -> Option>> { + let source = CreateTableSource::read(sql).ok()?; + let mut names: Vec> = source + .foreign_keys() + .iter() + .map(|key| key.name.map(str::to_owned)) + .collect(); + names.reverse(); + Some(names) +} + +/// Whether a CREATE TABLE declares `column` `PRIMARY KEY DESC`. Text +/// that does not read declares nothing. +pub(crate) fn declared_descending_key(sql: &str, column: &str) -> bool { + CreateTableSource::read(sql).is_ok_and(|source| source.declares_descending_key(column)) +} + +/// `"a""b"`, `` `a``b` ``, `[a b]` and `'a'` all name what SQLite +/// would read. A bare word is the name as written. +pub(crate) fn unquote(text: &str) -> String { + let mut characters = text.chars(); + match (characters.next(), characters.next_back()) { + (Some('['), Some(']')) => characters.as_str().to_owned(), + (Some(open @ ('"' | '`' | '\'')), Some(close)) if open == close => { + let doubled = format!("{open}{open}"); + characters.as_str().replace(&doubled, &open.to_string()) + } + _ => text.to_owned(), + } +} + +/// Words that start a column constraint, where a column's type ends. +const COLUMN_CONSTRAINT_STARTS: &[&str] = &[ + "CONSTRAINT", + "PRIMARY", + "NOT", + "NULL", + "UNIQUE", + "CHECK", + "DEFAULT", + "COLLATE", + "REFERENCES", + "AS", +]; + +const TABLE_CONSTRAINT_STARTS: &[&str] = &["CONSTRAINT", "PRIMARY", "UNIQUE", "CHECK", "FOREIGN"]; + +struct Reader<'s, 't> { + sql: &'s str, + tokens: &'t [Token], + position: usize, +} + +impl Reader<'_, '_> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.position) + } + + fn text_of(&self, token: &Token) -> &str { + self.sql.get(token.range.clone()).unwrap_or_default() + } + + fn is_word(&self, ahead: usize, word: &str) -> bool { + self.tokens + .get(self.position + ahead) + .is_some_and(|token| token.kind == ScriptTokenKind::Word && self.text_of(token).eq_ignore_ascii_case(word)) + } + + fn at(&self, word: &str) -> bool { + self.is_word(0, word) + } + + fn at_any(&self, words: &[&str]) -> bool { + words.iter().any(|word| self.at(word)) + } + + fn at_kind(&self, kind: ScriptTokenKind) -> bool { + self.peek().is_some_and(|token| token.kind == kind) + } + + fn at_punct(&self, punct: &str) -> bool { + self.peek() + .is_some_and(|token| token.kind == ScriptTokenKind::Punct && self.text_of(token) == punct) + } + + fn eat(&mut self, word: &str) -> bool { + let found = self.at(word); + if found { + self.position += 1; + } + found + } + + fn eat_any(&mut self, words: &[&str]) -> bool { + words.iter().any(|word| self.eat(word)) + } + + fn expect(&mut self, word: &str) -> Result<(), SourceReadError> { + match self.eat(word) { + true => Ok(()), + false => Err(self.unexpected()), + } + } + + fn unexpected(&self) -> SourceReadError { + match self.peek() { + Some(token) => SourceReadError::Unexpected { + offset: token.range.start, + found: self.text_of(token).to_owned(), + }, + None => SourceReadError::Unterminated, + } + } + + /// The end of the last token read. + fn last_end(&self) -> usize { + self.position + .checked_sub(1) + .and_then(|last| self.tokens.get(last)) + .map_or(0, |token| token.range.end) + } + + /// A name: a word, a quoted name, or a string, which SQLite also + /// takes as a name. + fn name(&mut self) -> Result<(String, Range), SourceReadError> { + let Some(token) = self.peek().cloned() else { + return Err(SourceReadError::Unterminated); + }; + match token.kind { + ScriptTokenKind::Word | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::StringLiteral => { + self.position += 1; + Ok((unquote(self.text_of(&token)), token.range)) + } + _ => Err(self.unexpected()), + } + } + + /// A parenthesised group, up to the `)` that closes it. + fn group(&mut self) -> Result, SourceReadError> { + let Some(open) = self + .peek() + .cloned() + .filter(|token| token.kind == ScriptTokenKind::OpenParen) + else { + return Err(self.unexpected()); + }; + let mut depth = 0usize; + while let Some(token) = self.peek().cloned() { + self.position += 1; + match token.kind { + ScriptTokenKind::OpenParen => depth += 1, + ScriptTokenKind::CloseParen => { + depth -= 1; + if depth == 0 { + return Ok(open.range.start..token.range.end); + } + } + _ => {} + } + } + Err(SourceReadError::Unterminated) + } + + /// `( name [COLLATE c] [ASC|DESC], ... )`, the names only. + fn name_list(&mut self) -> Result, SourceReadError> { + if !self.at_kind(ScriptTokenKind::OpenParen) { + return Err(self.unexpected()); + } + self.position += 1; + let mut names = Vec::new(); + loop { + names.push(self.name()?.0); + loop { + let Some(token) = self.peek() else { + return Err(SourceReadError::Unterminated); + }; + match token.kind { + ScriptTokenKind::CloseParen => { + self.position += 1; + return Ok(names); + } + ScriptTokenKind::OpenParen => { + self.group()?; + } + _ if self.at_punct(",") => { + self.position += 1; + break; + } + _ => self.position += 1, + } + } + } + } + + /// `CREATE [TEMP] TABLE [IF NOT EXISTS] [schema.]name (`, giving the + /// name and the byte of the `(`. + fn head(&mut self) -> Result<(String, usize), SourceReadError> { + if !self.eat("CREATE") { + return Err(SourceReadError::NotCreateTable); + } + self.eat_any(&["TEMP", "TEMPORARY"]); + if !self.eat("TABLE") { + return Err(SourceReadError::NotCreateTable); + } + if self.eat("IF") { + self.expect("NOT")?; + self.expect("EXISTS")?; + } + let (mut table, _) = self.name()?; + if self.at_punct(".") { + self.position += 1; + table = self.name()?.0; + } + match self.peek() { + Some(token) if token.kind == ScriptTokenKind::OpenParen => { + let open = token.range.start; + self.position += 1; + Ok((table, open)) + } + _ => Err(SourceReadError::NotCreateTable), + } + } + + /// The columns, then the table constraints, which SQLite lets follow + /// one another without a comma. + fn elements(&mut self) -> Result, SourceReadError> { + let mut elements = Vec::new(); + let mut constraints = false; + loop { + let start = self.peek().ok_or(SourceReadError::Unterminated)?.range.start; + let kind = if self.at_any(TABLE_CONSTRAINT_STARTS) { + constraints = true; + ElementKind::Constraint(self.table_constraint()?) + } else if constraints { + return Err(self.unexpected()); + } else { + ElementKind::Column(self.column()?) + }; + elements.push(SourceElement { + range: start..self.last_end(), + kind, + }); + if self.at_punct(",") { + self.position += 1; + } else if self.at_kind(ScriptTokenKind::CloseParen) { + return Ok(elements); + } else if !(constraints && self.at_any(TABLE_CONSTRAINT_STARTS)) { + return Err(self.unexpected()); + } + } + } + + fn close(&mut self) -> Result { + match self.peek() { + Some(token) if token.kind == ScriptTokenKind::CloseParen => { + let close = token.range.start; + self.position += 1; + Ok(close) + } + _ => Err(self.unexpected()), + } + } + + /// `WITHOUT ROWID` and `STRICT`, comma separated, up to the end or a + /// closing semicolon. + fn options(&mut self) -> Result<(Range, bool), SourceReadError> { + let start = self.last_end(); + let mut end = start; + let mut without_rowid = false; + while let Some(token) = self.peek().cloned() { + match token.kind { + ScriptTokenKind::Semicolon => { + self.position += 1; + return match self.peek() { + Some(_) => Err(self.unexpected()), + None => Ok((start..end, without_rowid)), + }; + } + ScriptTokenKind::Word => { + without_rowid |= self.at("WITHOUT") && self.is_word(1, "ROWID"); + } + _ if self.at_punct(",") => {} + _ => return Err(self.unexpected()), + } + end = token.range.end; + self.position += 1; + } + Ok((start..end, without_rowid)) + } + + fn column(&mut self) -> Result { + let (name, name_range) = self.name()?; + let type_range = self.type_name()?; + let segments = self.column_constraints()?; + Ok(SourceColumn { + name, + name_range, + type_range, + segments, + }) + } + + fn at_column_constraint(&self) -> bool { + self.at_any(COLUMN_CONSTRAINT_STARTS) || (self.at("GENERATED") && self.is_word(1, "ALWAYS")) + } + + /// The type: names up to the first constraint, then at most one + /// parenthesised size. + fn type_name(&mut self) -> Result>, SourceReadError> { + let first = self.position; + while let Some(token) = self.peek() { + match token.kind { + ScriptTokenKind::Word if self.at_column_constraint() => break, + ScriptTokenKind::Word | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::StringLiteral => { + self.position += 1; + } + ScriptTokenKind::OpenParen if self.position > first => { + self.group()?; + break; + } + _ => break, + } + } + Ok(self + .tokens + .get(first) + .filter(|_| self.position > first) + .map(|token| token.range.start..self.last_end())) + } + + fn column_constraints(&mut self) -> Result, SourceReadError> { + let mut segments = Vec::new(); + loop { + let Some(token) = self.peek() else { + return Err(SourceReadError::Unterminated); + }; + if token.kind == ScriptTokenKind::CloseParen || self.at_punct(",") { + return Ok(segments); + } + let start = token.range.start; + let kind = self.column_constraint()?; + segments.push(ColumnConstraintSegment { + range: start..self.last_end(), + kind, + }); + } + } + + fn column_constraint(&mut self) -> Result { + if self.eat("CONSTRAINT") { + return Ok(ColumnConstraintKind::Name(self.name()?.0)); + } + if self.eat("PRIMARY") { + self.expect("KEY")?; + let descending = self.at("DESC"); + self.eat_any(&["ASC", "DESC"]); + self.conflict()?; + let autoincrement = self.eat("AUTOINCREMENT"); + return Ok(ColumnConstraintKind::PrimaryKey { + autoincrement, + descending, + }); + } + // NOT NULL and NOT DEFERRABLE share their first word. + if self.at("NOT") && self.is_word(1, "NULL") { + self.position += 2; + self.conflict()?; + return Ok(ColumnConstraintKind::NotNull); + } + if self.at("DEFERRABLE") || (self.at("NOT") && self.is_word(1, "DEFERRABLE")) { + self.deferrable()?; + return Ok(ColumnConstraintKind::Deferrable); + } + if self.eat("NULL") { + self.conflict()?; + return Ok(ColumnConstraintKind::Null); + } + if self.eat("UNIQUE") { + self.conflict()?; + return Ok(ColumnConstraintKind::Unique); + } + if self.eat("CHECK") { + self.group()?; + return Ok(ColumnConstraintKind::Check); + } + if self.eat("DEFAULT") { + self.default_value()?; + return Ok(ColumnConstraintKind::Default); + } + if self.eat("COLLATE") { + self.name()?; + return Ok(ColumnConstraintKind::Collate); + } + if self.eat("REFERENCES") { + return Ok(ColumnConstraintKind::References(self.references()?)); + } + if self.eat("GENERATED") { + self.expect("ALWAYS")?; + self.expect("AS")?; + self.generated()?; + return Ok(ColumnConstraintKind::Generated); + } + if self.eat("AS") { + self.generated()?; + return Ok(ColumnConstraintKind::Generated); + } + Err(self.unexpected()) + } + + /// `ON CONFLICT resolution`, where one is allowed. + fn conflict(&mut self) -> Result<(), SourceReadError> { + if self.at("ON") && self.is_word(1, "CONFLICT") { + self.position += 2; + if !self.eat_any(&["ROLLBACK", "ABORT", "FAIL", "IGNORE", "REPLACE"]) { + return Err(self.unexpected()); + } + } + Ok(()) + } + + /// One item: a parenthesised expression, a signed number, a literal + /// or a name. `DEFAULT NULL NOT NULL` is a NULL default on a NOT NULL + /// column. + fn default_value(&mut self) -> Result<(), SourceReadError> { + if self.at_kind(ScriptTokenKind::OpenParen) { + self.group()?; + return Ok(()); + } + if self.at_punct("+") || self.at_punct("-") { + self.position += 1; + } + match self.peek().map(|token| token.kind) { + Some( + ScriptTokenKind::Number + | ScriptTokenKind::StringLiteral + | ScriptTokenKind::Word + | ScriptTokenKind::QuotedIdentifier, + ) => { + self.position += 1; + Ok(()) + } + _ => Err(self.unexpected()), + } + } + + /// The parenthesised expression and the storage after it. SQLite's + /// grammar takes any name there and checks it later. + fn generated(&mut self) -> Result<(), SourceReadError> { + self.group()?; + self.eat_any(&["STORED", "VIRTUAL"]); + Ok(()) + } + + /// What follows REFERENCES: the parent, its columns, the actions, + /// MATCH, and whether the check is deferred. + fn references(&mut self) -> Result { + let (table, _) = self.name()?; + let columns = match self.at_kind(ScriptTokenKind::OpenParen) { + true => self.name_list()?, + false => Vec::new(), + }; + loop { + if self.at("ON") && !self.is_word(1, "CONFLICT") { + self.position += 1; + if !self.eat_any(&["DELETE", "UPDATE", "INSERT"]) { + return Err(self.unexpected()); + } + self.action()?; + } else if self.eat("MATCH") { + self.name()?; + } else { + break; + } + } + if self.at("DEFERRABLE") || (self.at("NOT") && self.is_word(1, "DEFERRABLE")) { + self.deferrable()?; + } + Ok(ForeignKeyTarget { table, columns }) + } + + fn action(&mut self) -> Result<(), SourceReadError> { + let known = if self.eat("SET") { + self.eat_any(&["NULL", "DEFAULT"]) + } else if self.eat("NO") { + self.eat("ACTION") + } else { + self.eat_any(&["CASCADE", "RESTRICT"]) + }; + match known { + true => Ok(()), + false => Err(self.unexpected()), + } + } + + fn deferrable(&mut self) -> Result<(), SourceReadError> { + self.eat("NOT"); + self.expect("DEFERRABLE")?; + if self.eat("INITIALLY") && !self.eat_any(&["DEFERRED", "IMMEDIATE"]) { + return Err(self.unexpected()); + } + Ok(()) + } + + fn table_constraint(&mut self) -> Result { + let name = match self.eat("CONSTRAINT") { + true => Some(self.name()?.0), + false => None, + }; + let kind = if self.eat("PRIMARY") { + self.expect("KEY")?; + let start = self.position; + let columns = self.name_list()?; + let autoincrement = self + .tokens + .get(start..self.position) + .unwrap_or_default() + .iter() + .any(|token| { + token.kind == ScriptTokenKind::Word && self.text_of(token).eq_ignore_ascii_case("AUTOINCREMENT") + }); + self.conflict()?; + TableConstraintKind::PrimaryKey { columns, autoincrement } + } else if self.eat("UNIQUE") { + let columns = self.name_list()?; + self.conflict()?; + TableConstraintKind::Unique { columns } + } else if self.eat("CHECK") { + self.group()?; + self.conflict()?; + TableConstraintKind::Check + } else if self.eat("FOREIGN") { + self.expect("KEY")?; + let columns = self.name_list()?; + self.expect("REFERENCES")?; + let target = self.references()?; + TableConstraintKind::ForeignKey { columns, target } + } else if name.is_some() { + TableConstraintKind::NameOnly + } else { + return Err(self.unexpected()); + }; + Ok(TableConstraint { name, kind }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(sql: &str) -> CreateTableSource<'_> { + CreateTableSource::read(sql).unwrap_or_else(|error| panic!("{sql}: {error}")) + } + + fn column<'s>(source: &'s CreateTableSource<'_>, name: &str) -> &'s SourceColumn { + source + .columns() + .map(|(_, column)| column) + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("no column {name}")) + } + + fn kinds(column: &SourceColumn) -> Vec<&ColumnConstraintKind> { + column.segments.iter().map(|segment| &segment.kind).collect() + } + + fn segment_texts<'s>(source: &CreateTableSource<'s>, column: &SourceColumn) -> Vec<&'s str> { + column + .segments + .iter() + .map(|segment| source.text(segment.range.clone())) + .collect() + } + + fn type_text<'s>(source: &CreateTableSource<'s>, column: &SourceColumn) -> Option<&'s str> { + column.type_range.clone().map(|range| source.text(range)) + } + + #[test] + fn identifiers_in_every_quote_style_read_as_their_names() { + let source = read("CREATE TABLE \"we\"\"ird\" (\"a\"\"b\" TEXT, `c``d` INT, [e f] REAL, 'g' BLOB, plain)"); + + assert_eq!(source.table, "we\"ird"); + let names: Vec<&str> = source.columns().map(|(_, column)| column.name.as_str()).collect(); + assert_eq!(names, ["a\"b", "c`d", "e f", "g", "plain"]); + assert_eq!(type_text(&source, column(&source, "e f")), Some("REAL")); + assert_eq!(column(&source, "plain").type_range, None); + } + + #[test] + fn a_multibyte_identifier_keeps_its_byte_ranges() { + let sql = "CREATE TABLE café (naïve TEXT NOT NULL, \"日本\" VARCHAR(10) DEFAULT 'é')"; + let source = read(sql); + + let naive = column(&source, "naïve"); + assert_eq!(source.text(naive.name_range.clone()), "naïve"); + assert_eq!(segment_texts(&source, naive), ["NOT NULL"]); + let japanese = column(&source, "日本"); + assert_eq!(type_text(&source, japanese), Some("VARCHAR(10)")); + assert_eq!(segment_texts(&source, japanese), ["DEFAULT 'é'"]); + assert_eq!( + source.text(source.elements[1].range.clone()), + "\"日本\" VARCHAR(10) DEFAULT 'é'" + ); + } + + #[test] + fn default_null_is_one_item_before_not_null() { + let source = + read("CREATE TABLE t (a TEXT DEFAULT NULL NOT NULL, b INT DEFAULT -1 NULL, c DEFAULT x'0A' UNIQUE)"); + + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["DEFAULT NULL", "NOT NULL"] + ); + assert_eq!( + kinds(column(&source, "b")), + [&ColumnConstraintKind::Default, &ColumnConstraintKind::Null] + ); + assert_eq!( + segment_texts(&source, column(&source, "c")), + ["DEFAULT x'0A'", "UNIQUE"] + ); + assert_eq!(column(&source, "c").type_range, None); + } + + #[test] + fn not_deferrable_is_part_of_the_foreign_key_and_not_null_is_not() { + let source = read( + "CREATE TABLE t (a INT REFERENCES p (id) ON DELETE SET NULL MATCH SIMPLE NOT DEFERRABLE INITIALLY \ + IMMEDIATE NOT NULL, b INT REFERENCES q DEFERRABLE INITIALLY DEFERRED)", + ); + + let a = column(&source, "a"); + assert_eq!( + segment_texts(&source, a), + [ + "REFERENCES p (id) ON DELETE SET NULL MATCH SIMPLE NOT DEFERRABLE INITIALLY IMMEDIATE", + "NOT NULL" + ] + ); + assert_eq!( + kinds(a)[0], + &ColumnConstraintKind::References(ForeignKeyTarget { + table: "p".to_owned(), + columns: vec!["id".to_owned()], + }) + ); + assert_eq!( + kinds(column(&source, "b")), + [&ColumnConstraintKind::References(ForeignKeyTarget { + table: "q".to_owned(), + columns: Vec::new(), + })] + ); + } + + #[test] + fn conflict_clauses_stay_with_their_constraint() { + let source = read( + "CREATE TABLE t (id INTEGER PRIMARY KEY DESC ON CONFLICT REPLACE AUTOINCREMENT, a TEXT NOT NULL ON \ + CONFLICT IGNORE UNIQUE ON CONFLICT FAIL, CONSTRAINT k UNIQUE (a) ON CONFLICT ABORT)", + ); + + assert_eq!( + kinds(column(&source, "id")), + [&ColumnConstraintKind::PrimaryKey { + autoincrement: true, + descending: true + }] + ); + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["NOT NULL ON CONFLICT IGNORE", "UNIQUE ON CONFLICT FAIL"] + ); + assert_eq!(source.autoincrement_column(), Some("id")); + let constraints: Vec<&TableConstraint> = source.constraints().map(|(_, constraint)| constraint).collect(); + assert_eq!( + constraints, + [&TableConstraint { + name: Some("k".to_owned()), + kind: TableConstraintKind::Unique { + columns: vec!["a".to_owned()] + }, + }] + ); + } + + #[test] + fn inline_and_table_foreign_keys_come_in_declaration_order() { + let source = read( + "CREATE TABLE c (a INTEGER CONSTRAINT to_p REFERENCES p, b TEXT, CONSTRAINT second FOREIGN KEY (b) \ + REFERENCES q (k) ON DELETE CASCADE ON UPDATE NO ACTION MATCH FULL DEFERRABLE, FOREIGN KEY (a, b) \ + REFERENCES z)", + ); + + let keys = source.foreign_keys(); + let summary: Vec = keys + .iter() + .map(|key| { + format!( + "{:?} {} -> {} at {} {:?}", + key.name, + key.columns.join(","), + key.target.table, + key.element, + key.segment + ) + }) + .collect(); + assert_eq!( + summary, + [ + "Some(\"to_p\") a -> p at 0 Some(1)", + "Some(\"second\") b -> q at 2 None", + "None a,b -> z at 3 None", + ] + ); + assert_eq!( + declared_foreign_key_names(source.sql()), + Some(vec![None, Some("second".to_owned()), Some("to_p".to_owned())]) + ); + assert_eq!( + kinds(column(&source, "a")), + [ + &ColumnConstraintKind::Name("to_p".to_owned()), + &ColumnConstraintKind::References(ForeignKeyTarget { + table: "p".to_owned(), + columns: Vec::new() + }) + ] + ); + } + + #[test] + fn nested_check_and_stored_generated_column_read_whole() { + let source = read( + "CREATE TABLE t (a INT CHECK (a > (SELECT 0) AND a <> ')'), b INT GENERATED ALWAYS AS (a * (2 + 1)) \ + STORED NOT NULL, c AS (a || 'x') VIRTUAL, CHECK (b > a))", + ); + + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["CHECK (a > (SELECT 0) AND a <> ')')"] + ); + assert_eq!( + segment_texts(&source, column(&source, "b")), + ["GENERATED ALWAYS AS (a * (2 + 1)) STORED", "NOT NULL"] + ); + assert_eq!(segment_texts(&source, column(&source, "c")), ["AS (a || 'x') VIRTUAL"]); + let (index, constraint) = source.constraints().next().unwrap(); + assert_eq!(constraint.kind, TableConstraintKind::Check); + assert_eq!(source.text(source.elements[index].range.clone()), "CHECK (b > a)"); + } + + #[test] + fn comments_stay_outside_the_elements_they_follow() { + let sql = + "CREATE TABLE t ( -- the key\n id INTEGER /* inline */ PRIMARY KEY, -- trailing\n name TEXT -- last\n)"; + let source = read(sql); + + let texts: Vec<&str> = source + .elements + .iter() + .map(|element| source.text(element.range.clone())) + .collect(); + assert_eq!(texts, ["id INTEGER /* inline */ PRIMARY KEY", "name TEXT"]); + assert_eq!(source.text(source.close..source.close + 1), ")"); + assert!(!source.mentions(source.elements[0].range.clone()).contains("inline")); + } + + #[test] + fn without_rowid_and_strict_options_are_kept() { + let source = read("CREATE TABLE t (k TEXT PRIMARY KEY, v ANY) WITHOUT ROWID, STRICT"); + let plain = read("CREATE TABLE t (k TEXT PRIMARY KEY) STRICT;"); + + assert!(source.without_rowid); + assert_eq!(source.text(source.options.clone()), " WITHOUT ROWID, STRICT"); + assert!(!plain.without_rowid); + assert_eq!(plain.text(plain.options.clone()), " STRICT"); + } + + #[test] + fn table_constraints_may_follow_one_another_without_commas() { + let source = read( + "CREATE TABLE t2(a INTEGER, b, UNIQUE(a) CHECK(a > 0) PRIMARY KEY (a COLLATE nocase DESC, b AUTOINCREMENT))", + ); + + let kinds: Vec<&TableConstraintKind> = source.constraints().map(|(_, constraint)| &constraint.kind).collect(); + assert_eq!( + kinds, + [ + &TableConstraintKind::Unique { + columns: vec!["a".to_owned()] + }, + &TableConstraintKind::Check, + &TableConstraintKind::PrimaryKey { + columns: vec!["a".to_owned(), "b".to_owned()], + autoincrement: true + }, + ] + ); + assert_eq!(source.autoincrement_column(), None); + } + + #[test] + fn a_type_of_several_words_ends_at_the_first_constraint() { + let source = + read("CREATE TABLE t (a UNSIGNED BIG INT NOT NULL, b DOUBLE PRECISION, c DECIMAL(10, 5) COLLATE BINARY)"); + + assert_eq!(type_text(&source, column(&source, "a")), Some("UNSIGNED BIG INT")); + assert_eq!(type_text(&source, column(&source, "b")), Some("DOUBLE PRECISION")); + assert_eq!(type_text(&source, column(&source, "c")), Some("DECIMAL(10, 5)")); + assert_eq!(segment_texts(&source, column(&source, "c")), ["COLLATE BINARY"]); + } + + #[test] + fn a_column_added_by_alter_table_reads_before_the_table_constraints() { + let source = read("CREATE TABLE \"p\" (\n \"id\" INTEGER NOT NULL, \"note\" TEXT,\n PRIMARY KEY (\"id\")\n)"); + + let names: Vec<&str> = source.columns().map(|(_, column)| column.name.as_str()).collect(); + assert_eq!(names, ["id", "note"]); + assert_eq!(source.constraints().count(), 1); + } + + #[test] + fn what_is_not_a_readable_create_table_is_refused() { + let cases = [ + "CREATE VIRTUAL TABLE f USING fts5(body)", + "CREATE TABLE t AS SELECT 1", + "CREATE TABLE t (a TEXT", + "CREATE TABLE t (a TEXT DEFAULT 'open)", + "CREATE TABLE t (PRIMARY KEY (a), a TEXT)", + "CREATE TABLE t (a TEXT) WITHOUT ROWID; SELECT 1", + "CREATE INDEX i ON t (a)", + ]; + + for sql in cases { + assert!(CreateTableSource::read(sql).is_err(), "{sql}"); + } + } + + #[test] + fn only_desc_on_the_column_itself_is_a_descending_key() { + assert!(declared_descending_key( + "CREATE TABLE t (\"Id\" INTEGER PRIMARY KEY DESC, a TEXT)", + "id" + )); + assert!(!declared_descending_key( + "CREATE TABLE t (id INTEGER PRIMARY KEY ASC, a TEXT)", + "id" + )); + assert!(!declared_descending_key( + "CREATE TABLE t (id INTEGER, a TEXT, PRIMARY KEY (id DESC))", + "id" + )); + assert!(!declared_descending_key( + "CREATE TABLE t (id INTEGER PRIMARY KEY DESC", + "id" + )); + } + + #[test] + fn mentions_skip_strings_and_unquote_names() { + let source = read("CREATE TABLE t (a INT CHECK (\"B\" > 0 AND a <> 'c'))"); + + let mentions = source.mentions(source.elements[0].range.clone()); + assert!(mentions.contains("b") && mentions.contains("a")); + assert!(!mentions.contains("c")); + } +} diff --git a/linux/crates/drivers/sqlite/src/ddl/declared_shape.rs b/linux/crates/drivers/sqlite/src/ddl/declared_shape.rs new file mode 100644 index 0000000000..a7d41373f2 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/declared_shape.rs @@ -0,0 +1,78 @@ +use std::cell::RefCell; + +use tablepro_core::ddl::TableDefinition; + +use super::create_table_source::CreateTableSource; +use super::source_read_error::SourceReadError; + +/// What a CREATE TABLE text declares that the catalogue can confirm: the +/// table's name, its columns in order, and how many foreign keys it has. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeclaredShape { + pub table: String, + pub columns: Vec, + pub foreign_keys: usize, +} + +impl DeclaredShape { + /// Whether the text describes the table the catalogue read. Columns + /// pair by position, which `pragma_table_xinfo` keeps in declaration + /// order. + pub(crate) fn check(&self, definition: &TableDefinition) -> Result<(), SourceReadError> { + if !self.table.eq_ignore_ascii_case(&definition.table.name) { + return Err(SourceReadError::TableName { + expected: definition.table.name.clone(), + found: self.table.clone(), + }); + } + if self.columns.len() != definition.columns.len() { + return Err(SourceReadError::ColumnCount { + expected: definition.columns.len(), + found: self.columns.len(), + }); + } + for (position, (column, defined)) in self.columns.iter().zip(&definition.columns).enumerate() { + if !column.eq_ignore_ascii_case(defined.name()) { + return Err(SourceReadError::ColumnName { + position, + expected: defined.name().to_owned(), + found: column.clone(), + }); + } + } + if self.foreign_keys != definition.foreign_keys.len() { + return Err(SourceReadError::ForeignKeyCount { + expected: definition.foreign_keys.len(), + found: self.foreign_keys, + }); + } + Ok(()) + } +} + +thread_local! { + /// The last CREATE TABLE text read for capabilities. The structure + /// tab asks once per column of the same table, and reading the text + /// costs as much as the text is long. + static LAST_READ: RefCell)>> = const { RefCell::new(None) }; +} + +/// Whether a rebuild could read `definition`'s CREATE TABLE. +pub(crate) fn readable(definition: &TableDefinition) -> bool { + let Some(sql) = definition + .source + .as_ref() + .map(|source| source.create_statement.as_str()) + else { + return false; + }; + LAST_READ.with_borrow_mut(|last| { + if last.as_ref().is_none_or(|(read, _)| read != sql) { + let shape = CreateTableSource::read(sql).ok().map(|source| source.shape()); + *last = Some((sql.to_owned(), shape)); + } + last.as_ref() + .and_then(|(_, shape)| shape.as_ref()) + .is_some_and(|shape| shape.check(definition).is_ok()) + }) +} diff --git a/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs b/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs new file mode 100644 index 0000000000..e65bd12201 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs @@ -0,0 +1,164 @@ +//! Which changes SQLite's own ALTER TABLE makes, per the rules of +//! lang_altertable.html. Anything else rebuilds the table. + +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ColumnDefinition, DdlOperation, DraftColumn, TableChanges, TableDefinition}; +use tablepro_core::value::Value; + +use super::column_constraint_segment::ColumnConstraintKind; +use super::create_table_source::CreateTableSource; +use super::source_element::TableConstraintKind; + +/// The first change that needs a rebuild, named as the error that +/// refuses it would name it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RebuildChange { + pub column: String, + pub operation: DdlOperation, +} + +impl RebuildChange { + fn new(column: &str, operation: DdlOperation) -> Self { + Self { + column: column.to_owned(), + operation, + } + } +} + +/// `None` when every change has a native statement: a table or column +/// rename, an index created or dropped, a column added or dropped where +/// ALTER TABLE allows it. +pub(super) fn first_rebuild_change( + original: &TableDefinition, + source: Option<&CreateTableSource<'_>>, + changes: &TableChanges, +) -> Option { + for alteration in &changes.altered_columns { + let changed = alteration.changed; + let operation = if changed.column_type { + DdlOperation::AlterColumnType + } else if changed.nullability { + DdlOperation::AlterColumnNullability + } else if changed.default { + DdlOperation::AlterColumnDefault + } else if changed.primary_key { + DdlOperation::AlterPrimaryKey + } else if changed.auto_increment { + DdlOperation::AlterAutoIncrement + } else { + continue; + }; + return Some(RebuildChange::new(alteration.original.name(), operation)); + } + if let Some(change) = &changes.primary_key { + let column = change + .to + .first() + .or_else(|| change.from.as_ref().and_then(|key| key.columns.first())) + .map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::AlterPrimaryKey)); + } + if let Some(key) = changes.dropped_foreign_keys.first() { + let column = key.columns.first().map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::DropForeignKey)); + } + if let Some(key) = changes.added_foreign_keys.first() { + let column = key.columns.first().map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::AddForeignKey)); + } + if let Some(column) = changes.added_columns.iter().find(|column| !adds_natively(column)) { + return Some(RebuildChange::new(&column.name, DdlOperation::AddColumn)); + } + changes + .dropped_columns + .iter() + .find(|column| !drops_natively(original, source, column)) + .map(|column| RebuildChange::new(column.name(), DdlOperation::DropColumn)) +} + +/// ADD COLUMN takes no key, no default but a constant one, and NOT NULL +/// only with a default the rows already there can take. +fn adds_natively(column: &DraftColumn) -> bool { + let constant = !matches!(column.default, ColumnDefault::Expression(_)); + let has_value = !matches!( + column.default, + ColumnDefault::None | ColumnDefault::Literal(Value::Null) + ); + !column.primary_key && constant && (column.nullable || has_value) +} + +/// DROP COLUMN refuses a key or UNIQUE column, which a rebuild can drop +/// with its constraint. Every other use, by an index, a CHECK, a +/// generated column, a trigger or a view, SQLite checks against the +/// schema itself and refuses by name, which no rebuild could improve on +/// without breaking that use. +fn drops_natively( + original: &TableDefinition, + source: Option<&CreateTableSource<'_>>, + column: &ColumnDefinition, +) -> bool { + if column.info.primary_key + || original + .foreign_keys + .iter() + .any(|key| names(&key.columns, column.name())) + { + return false; + } + let Some(source) = source else { + return true; + }; + let own_unique = source + .columns() + .find(|(_, source_column)| source_column.name.eq_ignore_ascii_case(column.name())) + .is_some_and(|(_, source_column)| { + source_column.segments.iter().any(|segment| { + matches!( + segment.kind, + ColumnConstraintKind::Unique | ColumnConstraintKind::PrimaryKey { .. } + ) + }) + }); + let unique_alone = source.constraints().any(|(_, constraint)| match &constraint.kind { + TableConstraintKind::Unique { columns } => { + matches!(columns.as_slice(), [only] if only.eq_ignore_ascii_case(column.name())) + } + _ => false, + }); + !own_unique && !unique_alone +} + +pub(super) fn names(columns: &[String], name: &str) -> bool { + columns.iter().any(|column| column.eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::SqlExpression; + use tablepro_core::ddl::DraftType; + + use super::*; + + fn column(nullable: bool, default: ColumnDefault) -> DraftColumn { + let mut column = DraftColumn::new("c", DraftType::Typed("TEXT".to_owned())); + column.nullable = nullable; + column.default = default; + column + } + + #[test] + fn add_column_is_native_only_where_alter_table_allows_it() { + let literal = ColumnDefault::Literal(Value::Text("x".to_owned())); + let expression = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + + assert!(adds_natively(&column(true, ColumnDefault::None))); + assert!(adds_natively(&column(false, literal.clone()))); + assert!(!adds_natively(&column(false, ColumnDefault::None))); + assert!(!adds_natively(&column(false, ColumnDefault::Literal(Value::Null)))); + assert!(!adds_natively(&column(true, expression))); + let mut keyed = column(false, literal); + keyed.primary_key = true; + assert!(!adds_natively(&keyed)); + } +} diff --git a/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs new file mode 100644 index 0000000000..8b6f498435 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs @@ -0,0 +1,1152 @@ +//! A table rebuilt for the changes SQLite's ALTER TABLE cannot make, by +//! the procedure of lang_altertable.html section 7: a new table in the +//! revised shape, the rows copied into it, the old table dropped and the +//! new one renamed into its place, then its indexes, triggers and views +//! made again. The write protocol runs the plan in one transaction with +//! foreign keys off, and checks them before it commits. +//! +//! The new table is the stored CREATE TABLE with only the edited parts +//! replaced, so a CHECK, a COLLATE, a generated expression or a comment +//! the app does not model stays as it was written. Kept columns keep +//! their names through the copy, and renames run natively at the end, +//! where SQLite carries them into every index, trigger and view. + +use std::collections::{HashMap, HashSet}; +use std::ops::Range; + +use tablepro_core::ddl::ddl_sql::{column_list, default_sql}; +use tablepro_core::ddl::{ + BuildDdlError, ColumnAlteration, ColumnDefinition, ColumnRename, DdlOperation, DdlPlan, DdlPrecondition, + DraftColumn, RecreatedObject, RefusalReason, SchemaObjectKind, SchemaObjectSource, TableChanges, TableDefinition, + TableRebuild, order_renames, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; +use tablepro_core::statement::{Statement, StatementBuilder}; +use tablepro_core::{ForeignKeyCheck, ForeignKeyMode}; + +use super::column_constraint_segment::ColumnConstraintKind; +use super::create_table_source::{CreateTableSource, Token, tokens, unquote}; +use super::native_change_rules::names; +use super::source_element::{SourceColumn, SourceElement, TableConstraint, TableConstraintKind}; +use super::{default_clause, foreign_key_constraint}; +use crate::catalog::{MAIN_SCHEMA, words}; +use crate::dialect::SqliteDialect; + +/// The new table is built under this prefix and renamed over the old +/// one once the rows are in it. +const REBUILD_PREFIX: &str = "_tablepro_rebuild_"; + +/// An added column whose name a kept column holds until its own rename +/// runs is created under this prefix and renamed last. +const ADDED_PREFIX: &str = "__tablepro_added_"; + +pub(super) fn plan( + dialect: &SqliteDialect, + original: &TableDefinition, + source: &CreateTableSource<'_>, + changes: &TableChanges, +) -> Result { + let naming = Naming::new(original, changes); + let mut rebuild = Rebuild { + dialect, + original, + source, + changes, + dependents: original + .source + .as_ref() + .map_or(&[][..], |source| source.dependents.as_slice()), + naming, + key: Key::Kept, + dropped_keys: HashSet::new(), + }; + rebuild.key = rebuild.written_key()?; + rebuild.check_numbering()?; + rebuild.dropped_keys = rebuild.find_dropped_keys()?; + rebuild.check_dropped_columns()?; + rebuild.plan() +} + +/// The primary key of the new table. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Key { + /// As the stored text declares it. + Kept, + /// Written again: the columns under the names they are created with, + /// and the one column that takes AUTOINCREMENT on its own definition. + Written { + columns: Vec, + autoincrement: Option, + }, +} + +/// Where each column of the finished table is while the rebuild runs. +struct Naming { + /// The name a column is created under in the new table, by its + /// final name folded. + created: HashMap, + /// The added columns, each under the name it is created with. + added: Vec, + /// The renames left for after the rebuild: the edit's own, and the + /// added columns stepping out of a temporary name. + renames: Vec, + /// The columns of the new table as it is created. + occupied: Vec, +} + +impl Naming { + fn new(original: &TableDefinition, changes: &TableChanges) -> Self { + let mut created = HashMap::new(); + let mut occupied = Vec::new(); + for column in original + .columns + .iter() + .filter(|column| !dropped(changes, column.name())) + { + created.insert(fold(&final_name(changes, column.name())), column.name().to_owned()); + occupied.push(column.name().to_owned()); + } + let mut taken: HashSet = occupied.iter().map(|name| fold(name)).collect(); + let mut reserved: HashSet = taken.clone(); + reserved.extend(created.keys().cloned()); + reserved.extend(changes.added_columns.iter().map(|column| fold(&column.name))); + let mut renames = changes.renamed_columns.clone(); + let mut added = Vec::new(); + let mut next = 1usize; + for column in &changes.added_columns { + let mut name = column.name.clone(); + if taken.contains(&fold(&name)) { + let temporary = loop { + let candidate = format!("{ADDED_PREFIX}{next}"); + next += 1; + if !reserved.contains(&fold(&candidate)) { + break candidate; + } + }; + renames.push(ColumnRename { + from: temporary.clone(), + to: column.name.clone(), + }); + name = temporary; + } + taken.insert(fold(&name)); + reserved.insert(fold(&name)); + created.insert(fold(&column.name), name.clone()); + occupied.push(name.clone()); + added.push(DraftColumn { name, ..column.clone() }); + } + Self { + created, + added, + renames, + occupied, + } + } + + fn created<'n>(&'n self, final_name: &'n str) -> &'n str { + self.created.get(&fold(final_name)).map_or(final_name, String::as_str) + } + + fn ordered_renames(&self) -> Vec { + order_renames( + &self.renames, + self.occupied.iter().map(String::as_str), + std::iter::empty(), + ) + } +} + +struct Rebuild<'a> { + dialect: &'a SqliteDialect, + original: &'a TableDefinition, + source: &'a CreateTableSource<'a>, + changes: &'a TableChanges, + dependents: &'a [SchemaObjectSource], + naming: Naming, + key: Key, + /// The foreign keys the edit drops, by their element and, for an + /// inline one, their segment. + dropped_keys: HashSet<(usize, Option)>, +} + +impl Rebuild<'_> { + fn plan(&self) -> Result { + let table = &self.original.table; + let schema = table.schema.clone(); + let temporary = TableRef::new(schema.clone(), format!("{REBUILD_PREFIX}{}", table.name)); + let qualified = self.dialect.qualify(table); + let create = self.create_statement(&temporary)?; + let copy = self.copy_statement(&temporary)?; + + let mut statements = Vec::new(); + // Dropped first, as the native plan does: SQLite refuses to drop an + // index it made for a constraint, and says so here rather than the + // rebuild quietly keeping it. + for index in &self.changes.dropped_indexes { + let index = TableRef::new(schema.clone(), index.name.clone()); + statements.push(format!("DROP INDEX {}", self.dialect.qualify(&index))); + } + // The rename below checks every view and trigger in the schema, + // and fails on one that names a table that is not there. + let elsewhere: Vec<&SchemaObjectSource> = self.dependents.iter().filter(|object| !self.owned(object)).collect(); + for kind in [SchemaObjectKind::Trigger, SchemaObjectKind::View] { + for object in elsewhere.iter().rev().filter(|object| object.kind == kind) { + let keyword = match kind { + SchemaObjectKind::Trigger => "TRIGGER", + _ => "VIEW", + }; + let name = TableRef::new(schema.clone(), object.name.clone()); + statements.push(format!("DROP {keyword} {}", self.dialect.qualify(&name))); + } + } + statements.push(create); + statements.push(copy); + if self.carries_sequence() { + let sequence = self.dialect.qualify(&TableRef::new(schema.clone(), "sqlite_sequence")); + let new_name = text_literal(&temporary.name); + statements.push(format!("DELETE FROM {sequence} WHERE name = {new_name}")); + statements.push(format!( + "INSERT INTO {sequence} (name, seq) SELECT {new_name}, seq FROM {sequence} WHERE name = {} COLLATE \ + NOCASE", + text_literal(&table.name) + )); + } + statements.push(format!("DROP TABLE {qualified}")); + statements.push(format!( + "ALTER TABLE {} RENAME TO {}", + self.dialect.qualify(&temporary), + self.dialect.quote_identifier(&table.name) + )); + for object in self.recreated() { + let sql = replayable(self.dialect, &object.sql, schema.as_deref()) + .ok_or_else(|| BuildDdlError::UnreplayableDependent(object.name.clone()))?; + statements.push(sql); + } + for rename in self.naming.ordered_renames() { + statements.push(format!( + "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", + self.dialect.quote_identifier(&rename.from), + self.dialect.quote_identifier(&rename.to) + )); + } + if let Some(name) = &self.changes.renamed_table { + statements.push(format!( + "ALTER TABLE {qualified} RENAME TO {}", + self.dialect.quote_identifier(name) + )); + } + let target = self.changes.target(table); + statements.extend( + self.changes + .added_indexes + .iter() + .map(|index| self.dialect.create_index(&target, index)), + ); + + let mut plan = DdlPlan::from_sql(statements); + plan.foreign_keys = ForeignKeyMode::SuspendedThenVerified { + checks: self.checks(&target), + }; + plan.precondition = Some(DdlPrecondition { + probe: self.unchanged_probe(), + rows: 0, + }); + plan.rebuild = Some(TableRebuild { + recreated: self + .recreated() + .map(|object| RecreatedObject { + kind: object.kind, + name: object.name.clone(), + }) + .collect(), + renumbers_rowids: self.rowids_renumbered(), + }); + Ok(plan) + } + + /// A count of what differs between the schema the plan was built + /// from and the one there now: the table's CREATE TABLE, its own + /// indexes and triggers, the views and triggers elsewhere the rebuild + /// makes again, and the key columns of other tables that point at it. + /// A view or trigger that came since and names the table makes the + /// rename refuse on its own. + /// + /// Every name but the table's own was read from the catalogue, so + /// they compare exactly. The table's is matched without regard to + /// case, as SQLite matches it, and left out of the comparison. + fn unchanged_probe(&self) -> Statement { + let table = &self.original.table; + let catalogue = self + .dialect + .qualify(&TableRef::new(table.schema.clone(), "sqlite_schema")); + let name = text_literal(&table.name); + let create = self + .original + .source + .as_ref() + .map_or("", |source| source.create_statement.as_str()); + let mut objects = vec![format!("('table', '', {})", text_literal(create))]; + objects.extend(self.dependents.iter().map(|object| { + let kind = match object.kind { + SchemaObjectKind::Index => "index", + SchemaObjectKind::Trigger => "trigger", + SchemaObjectKind::View => "view", + }; + format!( + "('{kind}', {}, {})", + text_literal(&object.name), + text_literal(&object.sql) + ) + })); + let elsewhere: Vec = self + .dependents + .iter() + .filter(|object| !self.owned(object)) + .map(|object| text_literal(&object.name)) + .collect(); + let mut scope = format!( + "(type = 'table' AND name = {name} COLLATE NOCASE) OR (type IN ('index', 'trigger') AND tbl_name = \ + {name} COLLATE NOCASE AND sql IS NOT NULL)" + ); + if !elsewhere.is_empty() { + scope.push_str(&format!( + " OR (type IN ('view', 'trigger') AND name IN ({}))", + elsewhere.join(", ") + )); + } + let objects_now = + format!("SELECT type, CASE type WHEN 'table' THEN '' ELSE name END, sql FROM {catalogue} WHERE {scope}"); + + let schema = table + .schema + .as_deref() + .filter(|schema| !schema.is_empty()) + .unwrap_or(MAIN_SCHEMA); + let children_now = format!( + "SELECT m.name, f.\"from\" FROM {catalogue} m, pragma_foreign_key_list(m.name, {}) f WHERE m.type = \ + 'table' AND f.\"table\" = {name} COLLATE NOCASE", + text_literal(schema) + ); + let children: Vec = self + .original + .inbound_foreign_keys + .iter() + .flat_map(|key| { + key.columns + .iter() + .map(|column| format!("({}, {})", text_literal(&key.table.name), text_literal(column))) + }) + .collect(); + + let mut builder = StatementBuilder::new(); + builder.push_sql(&format!( + "SELECT {} + {}", + differences(&objects_now, &objects), + differences(&children_now, &children) + )); + builder.finish() + } + + /// The rebuilt table under both its names, and every table whose + /// foreign keys point at it, since their rows now point into a new + /// table. + fn checks(&self, target: &TableRef) -> Vec { + let table = &self.original.table; + let rebuilt = ForeignKeyCheck::new(Some(table.clone()), target.clone()); + let mut checks = vec![match self.rowids_renumbered() { + true => rebuilt.renumbered(), + false => rebuilt, + }]; + for key in &self.original.inbound_foreign_keys { + let child = &key.table; + let known = same_table(child, table) + || checks + .iter() + .any(|check| check.before.as_ref().is_some_and(|before| same_table(before, child))); + if !known { + checks.push(ForeignKeyCheck::same_table(child.clone())); + } + } + checks + } + + /// An index or trigger on the table goes with it when it is dropped. + fn owned(&self, object: &SchemaObjectSource) -> bool { + matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger) + && object.table.eq_ignore_ascii_case(&self.original.table.name) + } + + /// The dependents made again after the rename, in the order the + /// schema first made them. + fn recreated(&self) -> impl Iterator { + self.dependents.iter().filter(|object| { + !(object.kind == SchemaObjectKind::Index + && self + .changes + .dropped_indexes + .iter() + .any(|index| index.name.eq_ignore_ascii_case(&object.name))) + }) + } + + fn carries_sequence(&self) -> bool { + let before = self.source.autoincrement_column().is_some(); + let after = match &self.key { + Key::Kept => before, + Key::Written { autoincrement, .. } => autoincrement.is_some(), + }; + before && after + } + + fn alteration(&self, original: &str) -> Option<&ColumnAlteration> { + self.changes + .altered_columns + .iter() + .find(|alteration| alteration.original.name() == original) + } + + fn altered_to(&self, final_name: &str) -> Option<&ColumnAlteration> { + self.changes + .altered_columns + .iter() + .find(|alteration| alteration.draft.name.eq_ignore_ascii_case(final_name)) + } + + fn added(&self, final_name: &str) -> Option<&DraftColumn> { + self.changes + .added_columns + .iter() + .find(|column| column.name.eq_ignore_ascii_case(final_name)) + } + + /// The kept column that ends up as `final_name`. + fn kept(&self, final_name: &str) -> Option<&ColumnDefinition> { + let created = self.naming.created(final_name); + self.original + .columns + .iter() + .filter(|column| !dropped(self.changes, column.name())) + .find(|column| column.name() == created) + } + + /// The primary key the finished table has, by final names. + fn final_key(&self) -> Vec { + if let Some(change) = &self.changes.primary_key { + return change.to.clone(); + } + let key: Vec = match &self.original.primary_key { + Some(key) => key.columns.clone(), + None => self + .original + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(), + }; + key.iter().map(|name| final_name(self.changes, name)).collect() + } + + fn declared_type(&self, final_name: &str) -> Result { + if let Some(alteration) = self.altered_to(final_name) { + let column_type = &alteration.draft.column_type; + return Ok(column_type.resolve(self.dialect, alteration.original.name())?.sql); + } + if let Some(column) = self.added(final_name) { + return Ok(column.column_type.resolve(self.dialect, &column.name)?.sql); + } + Ok(self + .kept(final_name) + .and_then(|column| column.info.column_type.catalog.as_ref()) + .map(|catalog| catalog.declaration.as_sql().to_owned()) + .unwrap_or_default()) + } + + /// Whether the key column takes AUTOINCREMENT. One the edit left + /// alone keeps what it was declared with. + fn numbers_itself(&self, final_name: &str) -> bool { + if let Some(alteration) = self.altered_to(final_name) { + return alteration.draft.auto_increment; + } + if let Some(column) = self.added(final_name) { + return column.auto_increment; + } + let declared = self.source.autoincrement_column(); + self.kept(final_name) + .is_some_and(|column| declared.is_some_and(|name| name.eq_ignore_ascii_case(column.name()))) + } + + fn written_key(&self) -> Result { + let key = self.final_key(); + let numbering_toggled = self + .changes + .altered_columns + .iter() + .any(|alteration| alteration.changed.auto_increment && names(&key, &alteration.draft.name)); + if self.changes.primary_key.is_none() && !numbering_toggled { + return Ok(Key::Kept); + } + let autoincrement = match key.as_slice() { + [only] if self.numbers_itself(only) => { + if !is_integer(&self.declared_type(only)?) { + return Err(BuildDdlError::Refused { + column: only.clone(), + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::NotAnInteger, + }); + } + Some(self.naming.created(only).to_owned()) + } + _ => None, + }; + Ok(Key::Written { + columns: key.iter().map(|name| self.naming.created(name).to_owned()).collect(), + autoincrement, + }) + } + + /// The foreign keys the edit drops, found in the text. The catalogue + /// numbers them from the last one declared, so the n-th key it lists + /// is the n-th from the end here. + fn find_dropped_keys(&self) -> Result)>, BuildDdlError> { + let declared = self.source.foreign_keys(); + let listed = &self.original.foreign_keys; + let mut taken = vec![false; listed.len()]; + let mut dropped = HashSet::new(); + for key in &self.changes.dropped_foreign_keys { + let column = key.columns.first().map_or("", String::as_str); + let refuse = || unreadable(column, DdlOperation::DropForeignKey); + let id = listed + .iter() + .enumerate() + .position(|(id, listed)| listed == key && !taken.get(id).copied().unwrap_or(true)) + .ok_or_else(refuse)?; + if let Some(slot) = taken.get_mut(id) { + *slot = true; + } + let found = listed + .len() + .checked_sub(id + 1) + .and_then(|position| declared.get(position)) + .ok_or_else(refuse)?; + let matches = found.columns.len() == key.columns.len() + && found + .columns + .iter() + .zip(&key.columns) + .all(|(found, listed)| found.eq_ignore_ascii_case(listed)) + && found.target.table.eq_ignore_ascii_case(&key.ref_table); + if !matches { + return Err(refuse()); + } + dropped.insert((found.element, found.segment)); + } + Ok(dropped) + } + + /// Whether a table constraint is left out of the new table. + fn constraint_removed(&self, index: usize, constraint: &TableConstraint) -> bool { + match &constraint.kind { + TableConstraintKind::PrimaryKey { .. } => matches!(self.key, Key::Written { .. }), + TableConstraintKind::ForeignKey { .. } => self.dropped_keys.contains(&(index, None)), + // A UNIQUE of the dropped column alone goes with it. + TableConstraintKind::Unique { columns } => { + matches!(columns.as_slice(), [only] if dropped(self.changes, only)) + } + TableConstraintKind::Check | TableConstraintKind::NameOnly => false, + } + } + + /// Refuse to drop a column something the rebuild keeps still uses. + /// SQLite would refuse the new table, or worse, make the trigger or + /// view again and let it fail on its next use. + fn check_dropped_columns(&self) -> Result<(), BuildDdlError> { + for column in &self.changes.dropped_columns { + let name = column.name(); + // Mentions are lowercased whole, which can only find more. + let folded = name.to_lowercase(); + let mut users: Vec = self + .original + .foreign_keys + .iter() + .filter(|key| names(&key.columns, name) && !self.changes.dropped_foreign_keys.contains(key)) + .map(|key| key.name.clone()) + .collect(); + for (index, constraint) in self.source.constraints() { + let range = self.element(index).range.clone(); + let uses = !matches!(constraint.kind, TableConstraintKind::ForeignKey { .. }) + && !self.constraint_removed(index, constraint) + && self.source.mentions(range.clone()).contains(&folded); + if uses { + users.push( + constraint + .name + .clone() + .unwrap_or_else(|| self.source.text(range).to_owned()), + ); + } + } + for (_, other) in self.source.columns() { + if other.name.eq_ignore_ascii_case(name) || dropped(self.changes, &other.name) { + continue; + } + let uses = other.segments.iter().any(|segment| { + matches!( + segment.kind, + ColumnConstraintKind::Check | ColumnConstraintKind::Generated + ) && self.source.mentions(segment.range.clone()).contains(&folded) + }); + if uses { + users.push(other.name.clone()); + } + } + users.extend( + self.recreated() + .filter(|object| self.mentions(object).contains(&folded)) + .map(|object| object.name.clone()), + ); + if !users.is_empty() { + return Err(BuildDdlError::Refused { + column: name.to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::ColumnInUse(users), + }); + } + } + Ok(()) + } + + /// The names a recreated object mentions, lowercased and unquoted. + /// In a trigger on another table, `new.x` and `old.x` are that + /// table's columns, never this one's. + fn mentions(&self, object: &SchemaObjectSource) -> HashSet { + if object.kind != SchemaObjectKind::Trigger || self.owned(object) { + return words(&object.sql); + } + let Ok(scanned) = tokens(&object.sql) else { + return words(&object.sql); + }; + let text = |token: &Token| object.sql.get(token.range.clone()).unwrap_or_default(); + let is = |index: Option, kind: ScriptTokenKind, spellings: &[&str]| { + index.and_then(|index| scanned.get(index)).is_some_and(|token| { + token.kind == kind + && spellings + .iter() + .any(|spelling| text(token).eq_ignore_ascii_case(spelling)) + }) + }; + let mut mentions = HashSet::new(); + for (index, token) in scanned.iter().enumerate() { + let name = match token.kind { + ScriptTokenKind::Word => text(token).to_lowercase(), + ScriptTokenKind::QuotedIdentifier => unquote(text(token)).to_lowercase(), + _ => continue, + }; + let row_reference = is(index.checked_sub(1), ScriptTokenKind::Punct, &["."]) + && is(index.checked_sub(2), ScriptTokenKind::Word, &["new", "old"]); + if !row_reference { + mentions.insert(name); + } + } + mentions + } + + fn element(&self, index: usize) -> &SourceElement { + &self.source.elements[index] + } + + /// The stored CREATE TABLE under the temporary name, with the edited + /// parts written again: kept columns where they were, added columns + /// after them, then the table constraints, the new key and the added + /// foreign keys. + fn create_statement(&self, temporary: &TableRef) -> Result { + let sql = self.source.sql(); + let mut pieces: Vec<(Option, String)> = Vec::new(); + for ((index, column), original) in self.source.columns().zip(&self.original.columns) { + if !dropped(self.changes, original.name()) { + pieces.push((Some(index), self.column_text(index, column, original)?)); + } + } + for column in &self.naming.added { + let inline = matches!(&self.key, Key::Written { autoincrement: Some(name), .. } if *name == column.name); + pieces.push((None, self.dialect.column_definition(column, inline)?)); + } + for (index, constraint) in self.source.constraints() { + if !self.constraint_removed(index, constraint) { + pieces.push(( + Some(index), + self.source.text(self.element(index).range.clone()).to_owned(), + )); + } + } + if let Key::Written { + columns, + autoincrement: None, + } = &self.key + && !columns.is_empty() + { + pieces.push((None, format!("PRIMARY KEY ({})", column_list(self.dialect, columns)))); + } + for key in &self.changes.added_foreign_keys { + let columns: Vec = key + .columns + .iter() + .map(|column| self.naming.created(column).to_owned()) + .collect(); + pieces.push((None, foreign_key_constraint(self.dialect, key, &columns))); + } + + let list = self.source.text(self.source.open + 1..self.source.close); + let separator = if list.contains('\n') { ",\n " } else { ", " }; + let first = self + .source + .elements + .first() + .map_or(self.source.close, |element| element.range.start); + let last = self + .source + .elements + .last() + .map_or(self.source.close, |element| element.range.end); + let mut body = self.source.text(self.source.open + 1..first).to_owned(); + let mut previous: Option> = None; + for (index, text) in &pieces { + match (previous, index) { + (None, _) => {} + // Elements that stood side by side keep what was between + // them, comments included. + (Some(Some(before)), Some(index)) if *index == before + 1 => { + body.push_str( + self.source + .text(self.element(before).range.end..self.element(*index).range.start), + ); + } + _ => body.push_str(separator), + } + body.push_str(text); + previous = Some(*index); + } + body.push_str(self.source.text(last..self.source.close)); + Ok(format!( + "CREATE TABLE {} ({body}){}", + self.dialect.qualify(temporary), + sql.get(self.source.options.clone()).unwrap_or_default() + )) + } + + /// A kept column's definition with the edited parts replaced and the + /// rest as written: a COLLATE, CHECK, GENERATED or REFERENCES the + /// edit did not touch stays. + fn column_text( + &self, + index: usize, + column: &SourceColumn, + original: &ColumnDefinition, + ) -> Result { + let element = self.element(index); + let mut splice = Splice::new(element.range.clone()); + let mut removed = vec![false; column.segments.len()]; + let mut appended: Vec = Vec::new(); + let remove = |removed: &mut Vec, pick: &dyn Fn(&ColumnConstraintKind) -> bool| { + for (slot, segment) in removed.iter_mut().zip(&column.segments) { + *slot |= pick(&segment.kind); + } + }; + + if let Key::Written { autoincrement, .. } = &self.key { + remove(&mut removed, &|kind| { + matches!(kind, ColumnConstraintKind::PrimaryKey { .. }) + }); + if autoincrement.as_deref() == Some(original.name()) { + appended.push("PRIMARY KEY AUTOINCREMENT".to_owned()); + } + } + if let Some(alteration) = self.alteration(original.name()) { + let name = original.name(); + let draft = &alteration.draft; + if alteration.changed.column_type { + let resolved = draft.column_type.resolve(self.dialect, name)?; + match &column.type_range { + Some(range) => splice.replace(range.clone(), resolved.sql), + None => splice.insert(column.name_range.end, format!(" {}", resolved.sql)), + } + } + if alteration.changed.nullability { + remove(&mut removed, &|kind| { + matches!(kind, ColumnConstraintKind::NotNull | ColumnConstraintKind::Null) + }); + if !draft.nullable { + appended.push("NOT NULL".to_owned()); + } + } + if alteration.changed.default { + remove(&mut removed, &|kind| matches!(kind, ColumnConstraintKind::Default)); + let column_type = draft.column_type.column_type(self.dialect, name)?; + if let Some(default) = default_sql(self.dialect, name, &draft.default, &column_type)? { + appended.push(format!("DEFAULT {}", default_clause(&draft.default, &default))); + } + } + } + for (position, slot) in removed.iter_mut().enumerate() { + *slot |= self.dropped_keys.contains(&(index, Some(position))); + } + // A CONSTRAINT name goes with the constraint it names. + for position in 1..removed.len() { + let named = column + .segments + .get(position - 1) + .is_some_and(|segment| matches!(segment.kind, ColumnConstraintKind::Name(_))); + if removed[position] && named { + removed[position - 1] = true; + } + } + for (segment, removed) in column.segments.iter().zip(&removed) { + if *removed { + splice.remove(self.with_space_before(segment.range.clone(), element.range.start)); + } + } + for text in appended { + splice.insert(element.range.end, format!(" {text}")); + } + Ok(splice.render(self.source.sql())) + } + + /// `range` with the spaces before it on its own line, so a removed + /// constraint leaves no gap. The newline stays: it may end a `--` + /// comment, which would otherwise run on over what follows. + fn with_space_before(&self, range: Range, floor: usize) -> Range { + let bytes = self.source.sql().as_bytes(); + let mut start = range.start; + while start > floor + && bytes + .get(start - 1) + .is_some_and(|byte| byte.is_ascii_whitespace() && *byte != b'\n') + { + start -= 1; + } + start..range.end + } + + /// The rows into the new table. Kept columns have their old names + /// there, and a generated column is computed again. The rowid comes + /// along too, so whatever names a row by it still finds the row, + /// unless a copied INTEGER PRIMARY KEY column carries it already. + fn copy_statement(&self, temporary: &TableRef) -> Result { + let mut columns: Vec = self.rowid_name().into_iter().map(str::to_owned).collect(); + columns.extend( + self.original + .columns + .iter() + .filter(|column| !dropped(self.changes, column.name())) + .filter(|column| !column.info.is_generated && column.attributes.generated.is_none()) + .map(|column| self.dialect.quote_identifier(column.name())), + ); + if columns.is_empty() { + return Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)); + } + let list = columns.join(", "); + // OR ABORT overrides an ON CONFLICT IGNORE or REPLACE the kept + // text declares, which would drop a row the new table refuses + // rather than fail the save. + Ok(format!( + "INSERT OR ABORT INTO {} ({list}) SELECT {list} FROM {}", + self.dialect.qualify(temporary), + self.dialect.qualify(&self.original.table) + )) + } + + /// A name for the rowid that no column of either table hides, unless + /// a copied column is the new table's rowid. + fn rowid_name(&self) -> Option<&'static str> { + if self.source.without_rowid { + return None; + } + if self + .rowid_alias_after() + .is_some_and(|alias| self.kept(&alias).is_some()) + { + return None; + } + let taken: HashSet = self + .original + .columns + .iter() + .map(|column| fold(column.name())) + .chain(self.naming.occupied.iter().map(|name| fold(name))) + .collect(); + ["rowid", "oid", "_rowid_"] + .into_iter() + .find(|name| !taken.contains(*name)) + } + + /// The column that is the new table's rowid, by its final name: a + /// lone INTEGER PRIMARY KEY, unless the kept text declares it + /// `PRIMARY KEY DESC` on the column itself. + fn rowid_alias_after(&self) -> Option { + if self.source.without_rowid { + return None; + } + let [only] = self.final_key().try_into().ok()?; + if !self.declared_type(&only).is_ok_and(|declared| is_integer(&declared)) { + return None; + } + let descending = self.key == Key::Kept + && self + .kept(&only) + .is_some_and(|column| self.source.declares_descending_key(column.name())); + (!descending).then_some(only) + } + + /// The column that is the old table's rowid. + fn rowid_alias_before(&self) -> Option<&str> { + if self.source.without_rowid { + return None; + } + let key: Vec<&ColumnDefinition> = match &self.original.primary_key { + Some(key) => key + .columns + .iter() + .filter_map(|name| self.original.column(name)) + .collect(), + None => self + .original + .columns + .iter() + .filter(|column| column.info.primary_key) + .collect(), + }; + let [only] = key.as_slice() else { + return None; + }; + let declared = only + .info + .column_type + .catalog + .as_ref() + .map_or("", |catalog| catalog.declaration.as_sql()); + (is_integer(declared) && !self.source.declares_descending_key(only.name())).then_some(only.name()) + } + + /// Whether the copy gives the rows new rowids: a kept column that + /// was not the rowid becomes it, or no name reaches the old one. + fn rowids_renumbered(&self) -> bool { + if self.source.without_rowid { + return false; + } + let copied_alias = self + .rowid_alias_after() + .and_then(|alias| self.kept(&alias)) + .map(ColumnDefinition::name); + match copied_alias { + Some(alias) => !self + .rowid_alias_before() + .is_some_and(|before| before.eq_ignore_ascii_case(alias)), + None => self.rowid_name().is_none(), + } + } + + /// SQLite numbers the rowid column, and takes AUTOINCREMENT only on + /// it. So numbering cannot stop on a column that stays the rowid, + /// and a column that keeps AUTOINCREMENT stays INTEGER. + fn check_numbering(&self) -> Result<(), BuildDdlError> { + let alias = self.rowid_alias_after(); + for alteration in &self.changes.altered_columns { + let stops = alteration.changed.auto_increment && !alteration.draft.auto_increment; + if stops + && alias + .as_deref() + .is_some_and(|alias| alias.eq_ignore_ascii_case(&alteration.draft.name)) + { + return Err(BuildDdlError::Refused { + column: alteration.original.name().to_owned(), + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::AlwaysNumbered, + }); + } + } + let Some(column) = self + .source + .autoincrement_column() + .filter(|column| self.key == Key::Kept && !dropped(self.changes, column)) + else { + return Ok(()); + }; + match is_integer(&self.declared_type(&final_name(self.changes, column))?) { + true => Ok(()), + false => Err(BuildDdlError::Refused { + column: column.to_owned(), + operation: DdlOperation::AlterColumnType, + reason: RefusalReason::NotAnInteger, + }), + } + } +} + +/// The one declared type that makes a lone primary key column the rowid. +fn is_integer(declared: &str) -> bool { + declared.trim().eq_ignore_ascii_case("INTEGER") +} + +/// Edits to one stretch of the source, applied in order. +struct Splice { + range: Range, + edits: Vec<(Range, String)>, +} + +impl Splice { + fn new(range: Range) -> Self { + Self { + range, + edits: Vec::new(), + } + } + + fn replace(&mut self, range: Range, text: String) { + self.edits.push((range, text)); + } + + fn insert(&mut self, at: usize, text: String) { + self.edits.push((at..at, text)); + } + + fn remove(&mut self, range: Range) { + self.edits.push((range, String::new())); + } + + fn render(mut self, sql: &str) -> String { + self.edits.sort_by_key(|(range, _)| (range.start, range.end)); + let mut text = String::new(); + let mut cursor = self.range.start; + for (range, replacement) in self.edits { + if range.start >= cursor { + text.push_str(sql.get(cursor..range.start).unwrap_or_default()); + } + text.push_str(&replacement); + cursor = cursor.max(range.end); + } + text.push_str(sql.get(cursor..self.range.end).unwrap_or_default()); + text + } +} + +/// The stored CREATE of an index, trigger or view, ready to run again: +/// one statement, creating its object in `schema`. `None` when the text +/// is not one such statement. +/// +/// SQLite compiles only the first statement of a schema entry when it +/// loads the schema, so text edited through `writable_schema` can carry +/// more that it never ran, and replaying that would run them. It stores +/// the CREATE without its schema, and run as stored it would land in +/// `main`. +fn replayable(dialect: &SqliteDialect, sql: &str, schema: Option<&str>) -> Option { + let plan = ScriptPlan::build( + sql, + SqlGrammar::Sqlite, + LexicalSettings::default_for(SqlGrammar::Sqlite), + ); + if plan.statements().len() != 1 { + return None; + } + let Some(schema) = schema.filter(|schema| !schema.eq_ignore_ascii_case(MAIN_SCHEMA)) else { + return Some(sql.to_owned()); + }; + let tokens = tokens(sql).ok()?; + let word = |index: usize, word: &str| { + tokens.get(index).is_some_and(|token| { + token.kind == ScriptTokenKind::Word + && sql + .get(token.range.clone()) + .is_some_and(|text| text.eq_ignore_ascii_case(word)) + }) + }; + if !word(0, "CREATE") { + return None; + } + let mut index = 1; + if word(index, "TEMP") || word(index, "TEMPORARY") { + index += 1; + } + if word(index, "UNIQUE") { + index += 1; + } + if !(word(index, "INDEX") || word(index, "TRIGGER") || word(index, "VIEW")) { + return None; + } + index += 1; + if word(index, "IF") { + index += 3; + } + let name = tokens.get(index)?; + let qualified = tokens + .get(index + 1) + .is_some_and(|token| token.kind == ScriptTokenKind::Punct && sql.get(token.range.clone()) == Some(".")); + if qualified { + return Some(sql.to_owned()); + } + Some(format!( + "{}{}.{}", + sql.get(..name.range.start)?, + dialect.quote_identifier(schema), + sql.get(name.range.start..)? + )) +} + +/// How many rows `now` and `rows` do not share, each a parenthesised +/// VALUES row. `EXCEPT` compares rows as distinct, which names in a +/// schema are. +fn differences(now: &str, rows: &[String]) -> String { + if rows.is_empty() { + return format!("(SELECT count(*) FROM ({now}))"); + } + let read = format!("VALUES {}", rows.join(", ")); + format!("((SELECT count(*) FROM ({now} EXCEPT {read})) + (SELECT count(*) FROM ({read} EXCEPT {now})))") +} + +fn unreadable(column: &str, operation: DdlOperation) -> BuildDdlError { + BuildDdlError::Refused { + column: column.to_owned(), + operation, + reason: RefusalReason::UnreadableDefinition, + } +} + +fn dropped(changes: &TableChanges, name: &str) -> bool { + changes + .dropped_columns + .iter() + .any(|column| column.name().eq_ignore_ascii_case(name)) +} + +/// The name a column has once the edit is done. +fn final_name(changes: &TableChanges, original: &str) -> String { + changes + .renamed_columns + .iter() + .find(|rename| rename.from == original) + .map_or(original, |rename| rename.to.as_str()) + .to_owned() +} + +fn same_table(left: &TableRef, right: &TableRef) -> bool { + let schema = |table: &TableRef| { + table + .schema + .as_deref() + .filter(|schema| !schema.is_empty()) + .unwrap_or(MAIN_SCHEMA) + .to_lowercase() + }; + left.name.eq_ignore_ascii_case(&right.name) && schema(left) == schema(right) +} + +/// SQLite compares names without regard to ASCII case, and only ASCII. +fn fold(name: &str) -> String { + name.to_ascii_lowercase() +} + +fn text_literal(text: &str) -> String { + format!("'{}'", text.replace('\'', "''")) +} diff --git a/linux/crates/drivers/sqlite/src/ddl/source_element.rs b/linux/crates/drivers/sqlite/src/ddl/source_element.rs new file mode 100644 index 0000000000..8073b1b3c4 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/source_element.rs @@ -0,0 +1,72 @@ +use std::ops::Range; + +use super::column_constraint_segment::ColumnConstraintSegment; + +/// One entry of the CREATE TABLE list: a column or a table constraint, +/// from its first token to its last. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceElement { + pub range: Range, + pub kind: ElementKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ElementKind { + Column(SourceColumn), + Constraint(TableConstraint), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceColumn { + /// As SQLite reads it, quotes removed. + pub name: String, + pub name_range: Range, + /// `None` for a column declared without a type. + pub type_range: Option>, + pub segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TableConstraint { + pub name: Option, + pub kind: TableConstraintKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TableConstraintKind { + PrimaryKey { + columns: Vec, + autoincrement: bool, + }, + Unique { + columns: Vec, + }, + Check, + ForeignKey { + columns: Vec, + target: ForeignKeyTarget, + }, + /// `CONSTRAINT name` with nothing after it, which SQLite accepts. + NameOnly, +} + +/// What a foreign key points at. No columns means the parent's primary +/// key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ForeignKeyTarget { + pub table: String, + pub columns: Vec, +} + +/// A foreign key the text declares, inline on a column or as a table +/// constraint, in the order SQLite numbers them from the end. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SourceForeignKey<'s> { + /// The name its CONSTRAINT clause gives it. + pub name: Option<&'s str>, + pub element: usize, + /// The REFERENCES segment of an inline key. + pub segment: Option, + pub columns: &'s [String], + pub target: &'s ForeignKeyTarget, +} diff --git a/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs b/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs new file mode 100644 index 0000000000..a669718012 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs @@ -0,0 +1,64 @@ +use std::fmt; + +/// Why the CREATE TABLE text SQLite stored could not be read as the +/// table the catalogue describes. A rebuild splices that text, so any +/// doubt about it refuses the changes only a rebuild makes. It never +/// leaves the crate: the plan turns it into `UnreadableDefinition`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SourceReadError { + Missing, + /// A virtual table, or a statement that is not CREATE TABLE. + NotCreateTable, + Unexpected { + offset: usize, + found: String, + }, + Unterminated, + TableName { + expected: String, + found: String, + }, + ColumnCount { + expected: usize, + found: usize, + }, + ColumnName { + position: usize, + expected: String, + found: String, + }, + ForeignKeyCount { + expected: usize, + found: usize, + }, +} + +impl fmt::Display for SourceReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => f.write_str("the catalogue gave no CREATE statement"), + Self::NotCreateTable => f.write_str("not a CREATE TABLE statement with a column list"), + Self::Unexpected { offset, found } => write!(f, "unexpected {found:?} at byte {offset}"), + Self::Unterminated => f.write_str("the statement ends before its column list closes"), + Self::TableName { expected, found } => write!(f, "the statement creates {found}, not {expected}"), + Self::ColumnCount { expected, found } => write!( + f, + "the statement declares {found} columns where the catalogue has {expected}" + ), + Self::ColumnName { + position, + expected, + found, + } => write!( + f, + "column {position} is {found} in the statement and {expected} in the catalogue" + ), + Self::ForeignKeyCount { expected, found } => write!( + f, + "the statement declares {found} foreign keys where the catalogue has {expected}" + ), + } + } +} + +impl std::error::Error for SourceReadError {} diff --git a/linux/crates/drivers/sqlite/src/ddl/tests.rs b/linux/crates/drivers/sqlite/src/ddl/tests.rs new file mode 100644 index 0000000000..c9a83f487b --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/tests.rs @@ -0,0 +1,1242 @@ +use tablepro_core::column::{ColumnInfo, SqlExpression}; +use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DdlOperation, DraftType, GeneratedColumn, IdentityStyle, InboundForeignKey, + Permission, PrimaryKeyConstraint, RecreatedObject, ReferentialAction, RefusalReason, SchemaObjectSource, + TableRebuild, TableSource, diff_table, +}; +use tablepro_core::meta::IndexKey; +use tablepro_core::value::Value; +use tablepro_core::{ForeignKeyCheck, ForeignKeyMode}; + +use super::*; +use crate::catalog::SqliteVersion; + +fn dialect() -> SqliteDialect { + SqliteDialect::new(SqliteVersion::new(3, 46, 0)) +} + +fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) +} + +fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect().parse_type(declaration).expect("a SQLite type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +/// `INTEGER PRIMARY KEY`, the rowid itself, as the catalogue reads it. +fn rowid_alias(name: &str) -> ColumnDefinition { + let mut column = column(name, "INTEGER", true); + column.info.nullable = true; + column.info.is_auto_increment = true; + column.attributes.identity = IdentityStyle::SqliteRowidAlias; + column +} + +fn not_null(mut column: ColumnDefinition) -> ColumnDefinition { + column.info.nullable = false; + column +} + +fn table(name: &str, columns: Vec, create: &str) -> TableDefinition { + let key: Vec = columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(); + TableDefinition { + table: TableRef::unqualified(name), + columns, + primary_key: (!key.is_empty()).then_some(PrimaryKeyConstraint { + name: None, + columns: key, + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: Some(TableSource { + create_statement: create.to_owned(), + dependents: Vec::new(), + }), + } +} + +fn object(kind: SchemaObjectKind, name: &str, owner: &str, sql: &str) -> SchemaObjectSource { + SchemaObjectSource { + kind, + name: name.to_owned(), + table: owner.to_owned(), + sql: sql.to_owned(), + } +} + +fn index(name: &str, column: &str) -> IndexInfo { + IndexInfo { + name: name.to_owned(), + keys: vec![IndexKey::Column { + name: column.to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + } +} + +fn foreign_key(name: &str, column: &str, parent: &str, parent_column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: vec![column.to_owned()], + ref_schema: None, + ref_table: parent.to_owned(), + ref_columns: vec![parent_column.to_owned()], + on_delete: Some(ReferentialAction::NoAction), + on_update: Some(ReferentialAction::NoAction), + } +} + +fn inbound(name: &str, child: &str, column: &str, parent_column: &str) -> InboundForeignKey { + InboundForeignKey { + name: name.to_owned(), + table: TableRef::unqualified(child), + columns: vec![column.to_owned()], + referenced_columns: vec![parent_column.to_owned()], + } +} + +fn users() -> TableDefinition { + TableDefinition { + source: None, + ..table( + "users", + vec![column("id", "INTEGER", true), column("email", "TEXT", false)], + "", + ) + } +} + +/// A table with each kind of dependent: its own index and trigger, a +/// view on it, a trigger on another table that writes to it, and a +/// table whose foreign key points at it. +fn orders() -> TableDefinition { + let mut total = column("total", "REAL", false); + total.info.default = ColumnDefault::Literal(Value::Int(0)); + let mut orders = table( + "orders", + vec![ + rowid_alias("id"), + not_null(column("customer", "INTEGER", false)), + total, + column("note", "TEXT", false), + ], + "CREATE TABLE orders (\n id INTEGER PRIMARY KEY,\n customer INTEGER NOT NULL REFERENCES customers (id),\n \ + total REAL DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE NOCASE\n)", + ); + orders.indexes = vec![index("orders_by_customer", "customer")]; + orders.foreign_keys = vec![foreign_key("fk_orders_0", "customer", "customers", "id")]; + orders.inbound_foreign_keys = vec![inbound("fk_lines_0", "lines", "order_id", "id")]; + if let Some(source) = orders.source.as_mut() { + source.dependents = vec![ + object( + SchemaObjectKind::Index, + "orders_by_customer", + "orders", + "CREATE INDEX orders_by_customer ON orders (customer)", + ), + object( + SchemaObjectKind::Trigger, + "orders_stamp", + "orders", + "CREATE TRIGGER orders_stamp AFTER INSERT ON orders BEGIN UPDATE orders SET note = 'new' WHERE id = \ + new.id; END", + ), + object( + SchemaObjectKind::View, + "big_orders", + "big_orders", + "CREATE VIEW big_orders AS SELECT id, total FROM orders WHERE total > 100", + ), + object( + SchemaObjectKind::Trigger, + "customers_cleanup", + "customers", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + ), + ]; + } + orders +} + +fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) +} + +fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() +} + +fn checks(plan: &Result) -> Vec { + match plan.as_ref().map(|plan| &plan.foreign_keys) { + Ok(ForeignKeyMode::SuspendedThenVerified { checks }) => checks.clone(), + other => panic!("not a rebuild: {other:?}"), + } +} + +fn retype(draft: &mut TableDraft, name: &str, declaration: &str) { + if let Some(column) = draft.columns.iter_mut().find(|column| column.name == name) { + column.column_type = DraftType::Typed(declaration.to_owned()); + } +} + +fn refusal(plan: &Result) -> Option<(&str, DdlOperation, &RefusalReason)> { + match plan { + Err(BuildDdlError::Refused { + column, + operation, + reason, + }) => Some((column.as_str(), *operation, reason)), + _ => None, + } +} + +#[test] +fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::unqualified("notes")); + let mut id = typed("id", "INTEGER"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "TEXT"); + body.nullable = false; + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + let mut created = typed("created_at", "TEXT"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + let mut token = typed("token", "TEXT"); + token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("lower(hex(randomblob(4)))")); + draft.columns = vec![id, body, created, token]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"notes\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n \"body\" TEXT NOT \ + NULL DEFAULT 'it''s',\n \"created_at\" TEXT DEFAULT CURRENT_TIMESTAMP,\n \"token\" TEXT DEFAULT \ + (lower(hex(randomblob(4))))\n)" + ] + ); +} + +#[test] +fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); + let mut order = typed("order_id", "INTEGER"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "INTEGER"); + line.primary_key = true; + line.nullable = false; + draft.columns = vec![order, line, typed("sku", "TEXT")]; + draft.indexes = vec![IndexInfo { + name: "by_sku".to_owned(), + keys: vec![IndexKey::Column { + name: "sku".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: Some("sku IS NOT NULL".to_owned()), + }]; + draft.foreign_keys = vec![ForeignKeyInfo { + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + ..foreign_key("lines_order_fk", "order_id", "orders", "id") + }]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER NOT NULL,\n \"line\" INTEGER NOT NULL,\n \ + \"sku\" TEXT,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" FOREIGN KEY \ + (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", + "CREATE UNIQUE INDEX \"main\".\"by_sku\" ON \"lines\" (\"sku\") WHERE sku IS NOT NULL", + ] + ); +} + +#[test] +fn create_table_inlines_foreign_keys_without_pragma() { + let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); + draft.columns = vec![typed("order_id", "INTEGER")]; + draft.foreign_keys = vec![ForeignKeyInfo { + ref_schema: Some("main".to_owned()), + on_delete: Some(ReferentialAction::SetNull), + ..foreign_key("lines_order", "order_id", "orders", "id") + }]; + + let plan = dialect().plan_create_table(&draft).expect("the table plans"); + + assert_eq!( + plan.sql(), + [ + "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER,\n CONSTRAINT \"lines_order\" FOREIGN KEY \ + (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE SET NULL\n)" + ] + ); + assert!(plan.foreign_keys.is_enforced()); + assert!(plan.sql().iter().all(|statement| !statement.contains("PRAGMA"))); +} + +#[test] +fn native_rename_and_add_column_skip_rebuild() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), column("body", "TEXT", false)], + "CREATE TABLE notes (title TEXT, body TEXT)", + ); + + let plan = alter(¬es, |draft| { + draft.table.name = "memos".to_owned(); + draft.columns[1].name = "content".to_owned(); + let mut tag = typed("tag", "TEXT"); + tag.nullable = false; + tag.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + draft.columns.push(tag); + }); + + assert!(plan.as_ref().is_ok_and(|plan| plan.foreign_keys.is_enforced())); + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"notes\" RENAME TO \"memos\"", + "ALTER TABLE \"memos\" RENAME COLUMN \"body\" TO \"content\"", + "ALTER TABLE \"memos\" ADD COLUMN \"tag\" TEXT NOT NULL DEFAULT 'none'", + ] + ); +} + +#[test] +fn type_change_builds_rebuild_with_rowid_copy() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), column("body", "TEXT", false)], + "CREATE TABLE notes (title TEXT, body TEXT)", + ); + + let plan = alter(¬es, |draft| retype(draft, "body", "BLOB")); + + assert_eq!( + checks(&plan), + [ForeignKeyCheck::same_table(TableRef::unqualified("notes"))] + ); + assert_eq!( + sql(plan), + [ + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT, body BLOB)", + "INSERT OR ABORT INTO \"_tablepro_rebuild_notes\" (rowid, \"title\", \"body\") SELECT rowid, \ + \"title\", \"body\" FROM \"notes\"", + "DROP TABLE \"notes\"", + "ALTER TABLE \"_tablepro_rebuild_notes\" RENAME TO \"notes\"", + ] + ); +} + +#[test] +fn plan_alter_table_rebuilds_for_nullability_type_and_default() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), not_null(column("body", "TEXT", false))], + "CREATE TABLE notes (title TEXT DEFAULT 'x' NOT NULL, body TEXT NOT NULL ON CONFLICT REPLACE)", + ); + let mut notes = notes; + notes.columns[0].info.default = ColumnDefault::Literal(Value::Text("x".to_owned())); + notes.columns[0].info.nullable = false; + + let plan = alter(¬es, |draft| { + draft.columns[0].default = ColumnDefault::Expression(SqlExpression::from_catalog_text("upper('y')")); + draft.columns[0].nullable = true; + draft.columns[1].nullable = true; + retype(draft, "body", "VARCHAR(20)"); + }); + + assert_eq!( + sql(plan)[0], + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT DEFAULT (upper('y')), body VARCHAR(20))" + ); +} + +#[test] +fn rebuild_preserves_dependents_and_checks_children() { + let orders = orders(); + + let plan = alter(&orders, |draft| retype(draft, "total", "NUMERIC")); + + assert_eq!( + checks(&plan), + [ + ForeignKeyCheck::same_table(TableRef::unqualified("orders")), + ForeignKeyCheck::same_table(TableRef::unqualified("lines")), + ] + ); + assert_eq!( + sql(plan), + [ + "DROP TRIGGER \"customers_cleanup\"", + "DROP VIEW \"big_orders\"", + "CREATE TABLE \"_tablepro_rebuild_orders\" (\n id INTEGER PRIMARY KEY,\n customer INTEGER NOT NULL \ + REFERENCES customers (id),\n total NUMERIC DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE \ + NOCASE\n)", + "INSERT OR ABORT INTO \"_tablepro_rebuild_orders\" (\"id\", \"customer\", \"total\", \"note\") \ + SELECT \"id\", \"customer\", \"total\", \"note\" FROM \"orders\"", + "DROP TABLE \"orders\"", + "ALTER TABLE \"_tablepro_rebuild_orders\" RENAME TO \"orders\"", + "CREATE INDEX orders_by_customer ON orders (customer)", + "CREATE TRIGGER orders_stamp AFTER INSERT ON orders BEGIN UPDATE orders SET note = 'new' WHERE id = \ + new.id; END", + "CREATE VIEW big_orders AS SELECT id, total FROM orders WHERE total > 100", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + ] + ); +} + +#[test] +fn rebuild_checks_cover_table_and_inbound_children() { + let mut orders = orders(); + orders.inbound_foreign_keys = vec![ + inbound("fk_lines_0", "lines", "order_id", "id"), + inbound("fk_lines_1", "Lines", "first_order_id", "id"), + inbound("fk_refunds_0", "refunds", "order_id", "id"), + inbound("fk_orders_1", "orders", "parent_id", "id"), + ]; + + let plan = alter(&orders, |draft| { + draft.table.name = "purchases".to_owned(); + retype(draft, "total", "NUMERIC"); + }); + + assert_eq!( + checks(&plan), + [ + ForeignKeyCheck::new( + Some(TableRef::unqualified("orders")), + TableRef::unqualified("purchases") + ), + ForeignKeyCheck::same_table(TableRef::unqualified("lines")), + ForeignKeyCheck::same_table(TableRef::unqualified("refunds")), + ] + ); +} + +#[test] +fn rebuild_keeps_check_collate_generated_verbatim() { + let mut generated = column("c", "INT", false); + generated.info.is_generated = true; + generated.attributes.generated = Some(GeneratedColumn { + expression: SqlExpression::from_catalog_text("a * 2"), + storage: GeneratedStorage::Stored, + }); + let table = table( + "m", + vec![ + column("a", "INT", false), + not_null(column("b", "TEXT", false)), + generated, + ], + "CREATE TABLE m (a INT CHECK (a > 0) COLLATE BINARY, b TEXT COLLATE NOCASE NOT NULL, c INT GENERATED ALWAYS \ + AS (a * 2) STORED, CHECK (b <> ''))", + ); + let permissions = dialect().column_capabilities(&table, &table.columns[2]); + + let plan = alter(&table, |draft| { + retype(draft, "a", "BIGINT"); + draft.columns[1].nullable = true; + retype(draft, "c", "REAL"); + }); + + assert!(permissions.alter_type.is_allowed() && permissions.alter_nullability.is_allowed()); + assert_eq!( + permissions.alter_default, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!( + sql(plan)[..2], + [ + "CREATE TABLE \"_tablepro_rebuild_m\" (a BIGINT CHECK (a > 0) COLLATE BINARY, b TEXT COLLATE NOCASE, c \ + REAL GENERATED ALWAYS AS (a * 2) STORED, CHECK (b <> ''))", + "INSERT OR ABORT INTO \"_tablepro_rebuild_m\" (rowid, \"a\", \"b\") SELECT rowid, \"a\", \"b\" FROM \"m\"", + ] + ); +} + +#[test] +fn rebuild_orders_views_before_renames() { + let orders = orders(); + + let statements = sql(alter(&orders, |draft| { + draft.table.name = "purchases".to_owned(); + draft.columns[3].name = "remark".to_owned(); + retype(draft, "total", "NUMERIC"); + draft.indexes.push(index("purchases_by_remark", "remark")); + })); + + let position = |prefix: &str| { + statements + .iter() + .position(|statement| statement.starts_with(prefix)) + .unwrap_or_else(|| panic!("no {prefix} in {statements:#?}")) + }; + assert!(position("CREATE VIEW big_orders") < position("ALTER TABLE \"orders\" RENAME COLUMN")); + assert_eq!( + statements[position("ALTER TABLE \"orders\" RENAME COLUMN")..], + [ + "ALTER TABLE \"orders\" RENAME COLUMN \"note\" TO \"remark\"", + "ALTER TABLE \"orders\" RENAME TO \"purchases\"", + "CREATE INDEX \"purchases_by_remark\" ON \"purchases\" (\"remark\")", + ] + ); +} + +#[test] +fn rebuild_renames_use_temporary_names_on_collision() { + let people = table( + "people", + vec![ + rowid_alias("id"), + column("email", "TEXT", false), + column("name", "TEXT", false), + ], + "CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT, name TEXT)", + ); + + let added = sql(alter(&people, |draft| { + draft.columns[1].name = "mail".to_owned(); + retype(draft, "mail", "BLOB"); + draft.columns.push(typed("email", "TEXT")); + })); + let swapped = sql(alter(&people, |draft| { + draft.columns[1].name = "name".to_owned(); + draft.columns[2].name = "email".to_owned(); + retype(draft, "name", "BLOB"); + })); + + assert_eq!( + added[0], + "CREATE TABLE \"_tablepro_rebuild_people\" (id INTEGER PRIMARY KEY, email BLOB, name TEXT, \ + \"__tablepro_added_1\" TEXT)" + ); + assert_eq!( + added[4..], + [ + "ALTER TABLE \"people\" RENAME COLUMN \"email\" TO \"mail\"", + "ALTER TABLE \"people\" RENAME COLUMN \"__tablepro_added_1\" TO \"email\"", + ] + ); + assert_eq!( + swapped[4..], + [ + "ALTER TABLE \"people\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"people\" RENAME COLUMN \"name\" TO \"email\"", + "ALTER TABLE \"people\" RENAME COLUMN \"__tablepro_rename_1\" TO \"name\"", + ] + ); +} + +#[test] +fn dropping_a_foreign_key_removes_it_from_the_text() { + let mut lines = table( + "lines", + vec![ + not_null(column("order_id", "INTEGER", false)), + column("sku", "TEXT", false), + ], + "CREATE TABLE lines (order_id INTEGER CONSTRAINT to_order REFERENCES orders (id) ON DELETE CASCADE NOT NULL, \ + sku TEXT, FOREIGN KEY (sku) REFERENCES products (sku))", + ); + // The catalogue numbers keys from the last one declared. + lines.foreign_keys = vec![ + foreign_key("fk_lines_0", "sku", "products", "sku"), + ForeignKeyInfo { + on_delete: Some(ReferentialAction::Cascade), + ..foreign_key("fk_lines_1", "order_id", "orders", "id") + }, + ]; + + let inline = sql(alter(&lines, |draft| { + draft.foreign_keys.remove(1); + })); + let constraint = sql(alter(&lines, |draft| { + draft.foreign_keys.remove(0); + })); + + assert_eq!( + inline[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (order_id INTEGER NOT NULL, sku TEXT, FOREIGN KEY (sku) REFERENCES \ + products (sku))" + ); + assert_eq!( + constraint[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (order_id INTEGER CONSTRAINT to_order REFERENCES orders (id) ON \ + DELETE CASCADE NOT NULL, sku TEXT)" + ); +} + +#[test] +fn adding_a_foreign_key_appends_a_table_constraint() { + let lines = table( + "lines", + vec![column("order_id", "INTEGER", false), column("sku", "TEXT", false)], + "CREATE TABLE lines (\n order_id INTEGER,\n sku TEXT\n)", + ); + + let plan = alter(&lines, |draft| { + draft.foreign_keys.push(ForeignKeyInfo { + on_update: Some(ReferentialAction::Cascade), + ..foreign_key("lines_order", "order_id", "orders", "id") + }); + }); + + assert_eq!( + sql(plan)[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (\n order_id INTEGER,\n sku TEXT,\n CONSTRAINT \"lines_order\" \ + FOREIGN KEY (\"order_id\") REFERENCES \"orders\" (\"id\") ON UPDATE CASCADE\n)" + ); +} + +#[test] +fn primary_key_change_writes_the_key_again() { + let tags = table( + "tags", + vec![column("id", "INTEGER", false), not_null(column("name", "TEXT", false))], + "CREATE TABLE tags (id INTEGER, name TEXT NOT NULL)", + ); + + let numbered = sql(alter(&tags, |draft| { + draft.columns[0].primary_key = true; + draft.columns[0].nullable = false; + draft.columns[0].auto_increment = true; + })); + let composite = sql(alter(&tags, |draft| { + for column in &mut draft.columns { + column.primary_key = true; + column.nullable = false; + } + })); + let text_key = alter(&tags, |draft| { + draft.columns[1].primary_key = true; + draft.columns[1].auto_increment = true; + }); + + assert_eq!( + numbered[..2], + [ + "CREATE TABLE \"_tablepro_rebuild_tags\" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT \ + NULL)", + "INSERT OR ABORT INTO \"_tablepro_rebuild_tags\" (\"id\", \"name\") SELECT \"id\", \"name\" FROM \"tags\"", + ] + ); + assert_eq!( + composite[0], + "CREATE TABLE \"_tablepro_rebuild_tags\" (id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY (\"id\", \ + \"name\"))" + ); + assert_eq!( + refusal(&text_key), + Some(("name", DdlOperation::AlterAutoIncrement, &RefusalReason::NotAnInteger)) + ); +} + +#[test] +fn autoincrement_carries_the_sequence_through_the_rebuild() { + let mut id = rowid_alias("id"); + id.info.nullable = true; + let counters = table( + "counters", + vec![id, column("n", "INT", false)], + "CREATE TABLE counters (id INTEGER PRIMARY KEY AUTOINCREMENT, n INT)", + ); + + let statements = sql(alter(&counters, |draft| retype(draft, "n", "TEXT"))); + + assert_eq!( + statements[2..4], + [ + "DELETE FROM \"sqlite_sequence\" WHERE name = '_tablepro_rebuild_counters'", + "INSERT INTO \"sqlite_sequence\" (name, seq) SELECT '_tablepro_rebuild_counters', seq FROM \ + \"sqlite_sequence\" WHERE name = 'counters' COLLATE NOCASE", + ] + ); +} + +#[test] +fn dropping_a_column_a_view_uses_is_refused_in_a_rebuild() { + let orders = orders(); + + let plan = alter(&orders, |draft| { + draft.columns.retain(|column| column.name != "total"); + retype(draft, "note", "BLOB"); + }); + + assert_eq!( + refusal(&plan), + Some(( + "total", + DdlOperation::DropColumn, + &RefusalReason::ColumnInUse(vec!["big_orders".to_owned()]) + )) + ); +} + +#[test] +fn a_unique_column_drops_with_its_constraint() { + let codes = table( + "codes", + vec![ + column("id", "INTEGER", false), + column("code", "TEXT", false), + column("label", "TEXT", false), + ], + "CREATE TABLE codes (id INTEGER, code TEXT UNIQUE, label TEXT, UNIQUE (label))", + ); + + let own = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "code") + })); + let alone = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "label") + })); + let plain = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "id") + })); + + assert_eq!( + own[0], + "CREATE TABLE \"_tablepro_rebuild_codes\" (id INTEGER, label TEXT, UNIQUE (label))" + ); + assert_eq!( + alone[0], + "CREATE TABLE \"_tablepro_rebuild_codes\" (id INTEGER, code TEXT UNIQUE)" + ); + assert_eq!(plain, ["ALTER TABLE \"codes\" DROP COLUMN \"id\""]); +} + +#[test] +fn a_required_column_without_a_default_rebuilds() { + let notes = table( + "notes", + vec![column("title", "TEXT", false)], + "CREATE TABLE notes (title TEXT)", + ); + let add = |draft: &mut TableDraft| { + let mut required = typed("nickname", "TEXT"); + required.nullable = false; + draft.columns.push(required); + }; + + let rebuilt = sql(alter(¬es, add)); + let unread = alter(&users(), add); + + assert_eq!( + rebuilt[0], + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT, \"nickname\" TEXT NOT NULL)" + ); + assert_eq!( + refusal(&unread), + Some(( + "nickname", + DdlOperation::AddColumn, + &RefusalReason::UnreadableDefinition + )) + ); +} + +#[test] +fn unreadable_source_refuses_rebuild_only_operations() { + let virtual_table = table( + "docs", + vec![column("body", "TEXT", false)], + "CREATE VIRTUAL TABLE docs USING fts5(body)", + ); + let unreadable = Permission::Refused(RefusalReason::UnreadableDefinition); + + for table in [users(), virtual_table] { + let permissions = dialect().column_capabilities(&table, &table.columns[table.columns.len() - 1]); + assert_eq!(permissions.alter_type, unreadable, "{}", table.table.name); + assert_eq!(permissions.alter_nullability, unreadable); + assert_eq!(permissions.alter_default, unreadable); + assert_eq!(permissions.alter_primary_key, unreadable); + assert!(permissions.rename.is_allowed() && permissions.drop.is_allowed()); + } + let plan = alter(&users(), |draft| retype(draft, "email", "BLOB")); + assert_eq!( + refusal(&plan), + Some(( + "email", + DdlOperation::AlterColumnType, + &RefusalReason::UnreadableDefinition + )) + ); + let renamed = alter(&users(), |draft| draft.columns[1].name = "mail".to_owned()); + assert_eq!( + sql(renamed), + ["ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"mail\""] + ); +} + +#[test] +fn a_dependent_carrying_a_second_statement_is_not_replayed() { + let mut orders = orders(); + if let Some(view) = orders + .source + .as_mut() + .and_then(|source| source.dependents.iter_mut().find(|object| object.name == "big_orders")) + { + view.sql = "CREATE VIEW big_orders AS SELECT id FROM orders; DELETE FROM orders".to_owned(); + } + + let plan = alter(&orders, |draft| retype(draft, "total", "NUMERIC")); + + assert_eq!(plan, Err(BuildDdlError::UnreplayableDependent("big_orders".to_owned()))); +} + +#[test] +fn an_attached_schema_rebuilds_in_place() { + let mut orders = orders(); + orders.table = TableRef::new(Some("aux".to_owned()), "orders"); + orders.inbound_foreign_keys.clear(); + + let statements = sql(alter(&orders, |draft| retype(draft, "total", "NUMERIC"))); + + assert_eq!(statements[0], "DROP TRIGGER \"aux\".\"customers_cleanup\""); + assert_eq!( + statements[2].split(" (").next(), + Some("CREATE TABLE \"aux\".\"_tablepro_rebuild_orders\"") + ); + assert_eq!( + statements[4..7], + [ + "DROP TABLE \"aux\".\"orders\"", + "ALTER TABLE \"aux\".\"_tablepro_rebuild_orders\" RENAME TO \"orders\"", + "CREATE INDEX \"aux\".orders_by_customer ON orders (customer)", + ] + ); + assert_eq!( + statements[8], + "CREATE VIEW \"aux\".big_orders AS SELECT id, total FROM orders WHERE total > 100" + ); +} + +#[test] +fn a_swap_steps_aside_through_a_temporary_name() { + let mut table = users(); + table.columns.push(column("name", "TEXT", false)); + + let plan = alter(&table, |draft| { + draft.columns[1].name = "Name".to_owned(); + draft.columns[2].name = "email".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"email\"", + "ALTER TABLE \"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"Name\"", + ] + ); +} + +#[test] +fn an_indexed_column_drop_drops_the_index_first() { + let mut table = users(); + table.indexes = vec![index("by_email", "email")]; + + let plan = alter(&table, |draft| { + draft.indexes.clear(); + draft.columns.retain(|column| column.name != "email"); + }); + + assert_eq!( + sql(plan), + ["DROP INDEX \"by_email\"", "ALTER TABLE \"users\" DROP COLUMN \"email\""] + ); +} + +#[test] +fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = users(); + // SQLite reports a reference to the primary key with a NULL + // target, which the catalogue resolves to the key's columns. + table.inbound_foreign_keys = vec![inbound("fk_orders_0", "orders", "user_id", "id")]; + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + let id = dialect().column_capabilities(&table, &table.columns[0]); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); + assert_eq!( + id.drop, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); +} + +#[test] +fn drop_of_referenced_column_is_refused() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("fk_orders_0", "orders", "user_email", "email")]; + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); +} + +#[test] +fn the_script_replays_the_stored_table_and_its_own_dependents() { + let mut table = users(); + table.source = Some(TableSource { + create_statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)".to_owned(), + dependents: vec![ + object( + SchemaObjectKind::Index, + "by_email", + "Users", + "CREATE INDEX by_email ON users (email)", + ), + object( + SchemaObjectKind::Index, + "by_total", + "orders", + "CREATE INDEX by_total ON orders (total)", + ), + object( + SchemaObjectKind::Trigger, + "stamp", + "users", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ), + object(SchemaObjectKind::View, "v", "v", "CREATE VIEW v AS SELECT * FROM users"), + ], + }); + + assert_eq!( + sql(dialect().create_table_script(&table)), + [ + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)", + "CREATE INDEX by_email ON users (email)", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ] + ); +} + +#[test] +fn foreign_keys_rebuild_and_comments_stay_refused() { + let dialect = dialect(); + let capabilities = DdlDialect::capabilities(&dialect); + + assert!(capabilities.add_foreign_key && capabilities.drop_foreign_key); + assert!(capabilities.alter_column_type && capabilities.alter_primary_key); + assert!(!capabilities.column_comments); + assert!(capabilities.partial_indexes && capabilities.expression_index_keys); +} + +#[test] +fn a_rebuild_counts_what_changed_since_its_read_and_a_native_plan_does_not() { + let orders = orders(); + + let rebuild = alter(&orders, |draft| retype(draft, "total", "NUMERIC")).expect("the rebuild plans"); + let native = alter(&orders, |draft| draft.columns[3].name = "memo".to_owned()).expect("the rename plans"); + + let Some(precondition) = &rebuild.precondition else { + panic!("a rebuild without a precondition"); + }; + assert_eq!(precondition.rows, 0); + let probe = precondition.probe.sql(); + for read in [ + "('table', '', 'CREATE TABLE orders (\n id INTEGER PRIMARY KEY,", + "('index', 'orders_by_customer', 'CREATE INDEX orders_by_customer ON orders (customer)')", + "('view', 'big_orders',", + "('trigger', 'customers_cleanup',", + "('lines', 'order_id')", + "name IN ('big_orders', 'customers_cleanup')", + ] { + assert!(probe.contains(read), "{read} is not in {probe}"); + } + assert!(rebuild.into_steps()[0].guard.probe().is_some()); + assert_eq!(native.precondition, None); +} + +#[test] +fn a_constraint_removed_after_a_line_comment_keeps_the_comment_on_its_line() { + let kept = table( + "kept", + vec![ + rowid_alias("id"), + not_null(column("a", "TEXT", false)), + column("b", "TEXT", false), + ], + "CREATE TABLE kept (id INTEGER PRIMARY KEY, a TEXT -- note\n NOT NULL, b TEXT)", + ); + + let statements = sql(alter(&kept, |draft| { + draft.columns[1].nullable = true; + draft.columns[1].default = ColumnDefault::Literal(Value::Text("dflt".to_owned())); + })); + + assert_eq!( + statements[0], + "CREATE TABLE \"_tablepro_rebuild_kept\" (id INTEGER PRIMARY KEY, a TEXT -- note\n DEFAULT 'dflt', b TEXT)" + ); +} + +#[test] +fn numbering_stays_on_while_the_column_is_the_rowid() { + let counters = table( + "counters", + vec![rowid_alias("id"), column("n", "INT", false)], + "CREATE TABLE counters (id INTEGER PRIMARY KEY AUTOINCREMENT, n INT)", + ); + + let off = alter(&counters, |draft| draft.columns[0].auto_increment = false); + let retyped_off = sql(alter(&counters, |draft| { + retype(draft, "id", "BIGINT"); + draft.columns[0].auto_increment = false; + })); + let retyped = alter(&counters, |draft| retype(draft, "id", "BIGINT")); + + assert_eq!( + refusal(&off), + Some(("id", DdlOperation::AlterAutoIncrement, &RefusalReason::AlwaysNumbered)) + ); + assert_eq!( + retyped_off[0], + "CREATE TABLE \"_tablepro_rebuild_counters\" (id BIGINT, n INT, PRIMARY KEY (\"id\"))" + ); + assert!( + retyped_off + .iter() + .all(|statement| !statement.contains("sqlite_sequence")) + ); + assert_eq!( + refusal(&retyped), + Some(("id", DdlOperation::AlterColumnType, &RefusalReason::NotAnInteger)) + ); +} + +#[test] +fn a_descending_integer_key_keeps_its_rows_rowids() { + let descending = table( + "t", + vec![column("id", "INTEGER", true), column("v", "TEXT", false)], + "CREATE TABLE t (id INTEGER PRIMARY KEY DESC, v TEXT)", + ); + let table_key = table( + "t", + vec![column("id", "INTEGER", true), column("v", "TEXT", false)], + "CREATE TABLE t (id INTEGER, v TEXT, PRIMARY KEY (id DESC))", + ); + + let kept = sql(alter(&descending, |draft| retype(draft, "v", "BLOB"))); + let alias = sql(alter(&table_key, |draft| retype(draft, "v", "BLOB"))); + + assert_eq!( + kept[1], + "INSERT OR ABORT INTO \"_tablepro_rebuild_t\" (rowid, \"id\", \"v\") SELECT rowid, \"id\", \"v\" FROM \"t\"" + ); + assert_eq!( + alias[1], + "INSERT OR ABORT INTO \"_tablepro_rebuild_t\" (\"id\", \"v\") SELECT \"id\", \"v\" FROM \"t\"" + ); +} + +#[test] +fn a_column_that_becomes_the_rowid_renumbers_the_rows() { + let loose = table( + "kept", + vec![ + not_null(column("id", "INTEGER", false)), + column("parent", "INTEGER", false), + ], + "CREATE TABLE kept (id INTEGER NOT NULL, parent INTEGER)", + ); + + let keyed = alter(&loose, |draft| draft.columns[0].primary_key = true); + let retyped = alter(&orders(), |draft| retype(draft, "total", "NUMERIC")); + let copied = alter(&loose, |draft| retype(draft, "parent", "TEXT")); + + assert_eq!( + checks(&keyed)[0], + ForeignKeyCheck::same_table(TableRef::unqualified("kept")).renumbered() + ); + assert!(!checks(&retyped)[0].rowids_renumbered); + assert!(!checks(&copied)[0].rowids_renumbered); +} + +#[test] +fn a_trigger_elsewhere_naming_its_own_row_does_not_hold_a_dropped_column() { + let mut kept = table( + "kept", + vec![ + rowid_alias("id"), + column("status", "TEXT", false), + column("note", "TEXT", false), + ], + "CREATE TABLE kept (id INTEGER PRIMARY KEY, status TEXT UNIQUE, note TEXT)", + ); + let own_row = object( + SchemaObjectKind::Trigger, + "others_sync", + "others", + "CREATE TRIGGER others_sync AFTER UPDATE ON others WHEN new.status = 'done' BEGIN UPDATE kept SET note = \ + OLD.\"STATUS\" WHERE id = new.id; END", + ); + let writes = object( + SchemaObjectKind::Trigger, + "others_copy", + "others", + "CREATE TRIGGER others_copy AFTER INSERT ON others BEGIN UPDATE kept SET status = new.status; END", + ); + let drop_status = |draft: &mut TableDraft| draft.columns.retain(|column| column.name != "status"); + if let Some(source) = kept.source.as_mut() { + source.dependents = vec![own_row.clone()]; + } + let allowed = sql(alter(&kept, drop_status)); + if let Some(source) = kept.source.as_mut() { + source.dependents = vec![own_row, writes]; + } + + let refused = alter(&kept, drop_status); + + assert_eq!( + allowed[1], + "CREATE TABLE \"_tablepro_rebuild_kept\" (id INTEGER PRIMARY KEY, note TEXT)" + ); + assert_eq!( + refusal(&refused), + Some(( + "status", + DdlOperation::DropColumn, + &RefusalReason::ColumnInUse(vec!["others_copy".to_owned()]) + )) + ); +} + +#[test] +fn an_unreadable_table_offers_no_foreign_key_or_key_change() { + let virtual_table = table( + "docs", + vec![column("body", "TEXT", false)], + "CREATE VIRTUAL TABLE docs USING fts5(body)", + ); + let dialect = dialect(); + + let unreadable = dialect.table_capabilities(&virtual_table); + let readable = dialect.table_capabilities(&orders()); + + assert!(!unreadable.add_foreign_key && !unreadable.drop_foreign_key && !unreadable.alter_primary_key); + assert!(unreadable.add_column && unreadable.create_index && unreadable.rename_column); + assert_eq!(readable, *DdlDialect::capabilities(&dialect)); +} + +#[test] +fn the_cached_read_is_checked_against_each_table_asked_about() { + let notes = table( + "notes", + vec![column("title", "TEXT", false)], + "CREATE TABLE notes (title TEXT)", + ); + let mut renamed = notes.clone(); + renamed.columns[0].info.name = "heading".to_owned(); + let mut rewritten = notes.clone(); + if let Some(source) = rewritten.source.as_mut() { + source.create_statement = "CREATE TABLE notes (title TEXT".to_owned(); + } + let dialect = dialect(); + let alter_type = |table: &TableDefinition| dialect.column_capabilities(table, &table.columns[0]).alter_type; + let unreadable = Permission::Refused(RefusalReason::UnreadableDefinition); + + assert_eq!(alter_type(¬es), Permission::Allowed); + assert_eq!( + alter_type(&renamed), + unreadable, + "the same text checked against other columns" + ); + assert_eq!(alter_type(&rewritten), unreadable, "other text read again"); + assert_eq!(alter_type(¬es), Permission::Allowed); +} + +#[test] +fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); +} + +#[test] +fn a_rebuild_names_what_it_drops_and_makes_again() { + let orders = orders(); + let loose = table( + "kept", + vec![ + not_null(column("id", "INTEGER", false)), + column("parent", "INTEGER", false), + ], + "CREATE TABLE kept (id INTEGER NOT NULL, parent INTEGER)", + ); + let recreated = |kind, name: &str| RecreatedObject { + kind, + name: name.to_owned(), + }; + + let retyped = alter(&orders, |draft| { + retype(draft, "total", "NUMERIC"); + draft.indexes.clear(); + }) + .expect("the rebuild plans"); + let keyed = alter(&loose, |draft| draft.columns[0].primary_key = true).expect("the rebuild plans"); + let native = alter(&orders, |draft| draft.columns[3].name = "memo".to_owned()).expect("the rename plans"); + + assert_eq!( + retyped.rebuild, + Some(TableRebuild { + recreated: vec![ + recreated(SchemaObjectKind::Trigger, "orders_stamp"), + recreated(SchemaObjectKind::View, "big_orders"), + recreated(SchemaObjectKind::Trigger, "customers_cleanup"), + ], + renumbers_rowids: false, + }) + ); + assert!(keyed.rebuild.is_some_and(|rebuild| rebuild.renumbers_rowids)); + assert_eq!(native.rebuild, None); +} diff --git a/linux/crates/drivers/sqlite/src/dialect.rs b/linux/crates/drivers/sqlite/src/dialect.rs index 275293b296..447e77f6db 100644 --- a/linux/crates/drivers/sqlite/src/dialect.rs +++ b/linux/crates/drivers/sqlite/src/dialect.rs @@ -7,7 +7,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::Value; @@ -160,6 +161,10 @@ impl SqlDialect for SqliteDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::Sqlite, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -233,6 +238,20 @@ fn value_param(value: Value) -> Result<(Value, ParamType), BindError> { Ok((value, param_type)) } +/// Statements whose text can hold an encryption key, on builds with +/// SQLCipher or the SQLite Encryption Extension. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[], + conditional_prefixes: &[ + ( + &["PRAGMA"], + &["KEY", "REKEY", "HEXKEY", "HEXREKEY", "TEXTKEY", "TEXTREKEY"], + ), + (&["ATTACH"], &["KEY"]), + ], + carriers: &[], +}; + #[cfg(test)] mod tests { use tablepro_core::column::{BoolStorage, ColumnDefault, ReadForm, TextLength}; @@ -422,4 +441,27 @@ mod tests { ); assert!(dialect().ddl().is_some_and(|ddl| ddl.capabilities().partial_indexes)); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "PRAGMA key = 'x'", + "PRAGMA main.rekey = 'x'", + "ATTACH DATABASE 'other.db' AS other KEY 'x'", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/sqlite/src/foreign_key_verification.rs b/linux/crates/drivers/sqlite/src/foreign_key_verification.rs index 9e4c44f56d..ab665ef6a1 100644 --- a/linux/crates/drivers/sqlite/src/foreign_key_verification.rs +++ b/linux/crates/drivers/sqlite/src/foreign_key_verification.rs @@ -18,7 +18,7 @@ use crate::errors::{SQLITE_CONSTRAINT, SQLITE_CONSTRAINT_FOREIGNKEY}; pub(crate) struct Violation { pub table: String, /// `None` for a WITHOUT ROWID child table, which has no rowid to - /// name the row by. + /// name the row by, and for one whose rows the batch renumbers. pub rowid: Option, pub parent: String, } @@ -47,8 +47,8 @@ pub(crate) fn renamed(baseline: Vec, renames: &HashMap Vec { let mut remaining: HashMap<(String, Option, String), usize> = HashMap::new(); for violation in before { diff --git a/linux/crates/drivers/sqlite/src/metadata.rs b/linux/crates/drivers/sqlite/src/metadata.rs index 87569d2bfc..f4f1ef472a 100644 --- a/linux/crates/drivers/sqlite/src/metadata.rs +++ b/linux/crates/drivers/sqlite/src/metadata.rs @@ -19,9 +19,10 @@ use tablepro_core::meta::{ use tablepro_core::{DriverError, ForeignKeyInfo, ServerCode, ServerDiagnostics}; use crate::catalog::{ - self, InboundKeyRow, MAIN_SCHEMA, SchemaRow, SqliteColumnRow, TableFacts, foreign_key_name, generated_expressions, + self, InboundKeyRow, MAIN_SCHEMA, SchemaRow, SqliteColumnRow, TableFacts, foreign_key_names, generated_expressions, inbound_foreign_keys, select_dependents, }; +use crate::ddl::{declared_descending_key, declared_foreign_key_names}; use crate::errors::{SQLITE_ERROR, map_error}; use crate::index_sql::read_index_sql; @@ -111,6 +112,20 @@ pub(crate) async fn fetch_foreign_keys( connection: &mut SqlxConnection, table: &TableRef, ) -> Result, DriverError> { + let keys = foreign_key_rows(connection, table).await?; + let create = match keys.is_empty() { + true => None, + false => create_statement(connection, table).await?, + }; + resolve_foreign_keys(connection, table, keys, create.as_deref()).await +} + +/// The table's foreign keys by pragma id, each with whether it points +/// at the parent's primary key without naming its columns. +async fn foreign_key_rows( + connection: &mut SqlxConnection, + table: &TableRef, +) -> Result, DriverError> { let schema = schema_of(table); let rows = sqlx::query(catalog::FOREIGN_KEY_LIST_SQL) .bind(table.name.as_str()) @@ -126,7 +141,7 @@ pub(crate) async fn fetch_foreign_keys( Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => entry.insert(( ForeignKeyInfo { - name: foreign_key_name(&table.name, id), + name: String::new(), columns: Vec::new(), ref_schema: table.schema.clone(), ref_table: text(&row, "table")?, @@ -143,10 +158,22 @@ pub(crate) async fn fetch_foreign_keys( None => *to_primary = true, } } + Ok(keys) +} + +/// Name the keys from the table's CREATE TABLE, and give a key that +/// names no parent columns the parent's primary key. +async fn resolve_foreign_keys( + connection: &mut SqlxConnection, + table: &TableRef, + keys: BTreeMap, + create_statement: Option<&str>, +) -> Result, DriverError> { + let declared = create_statement.and_then(declared_foreign_key_names); + let mut names = foreign_key_names(&table.name, declared, keys.len()).into_iter(); let mut resolved = Vec::with_capacity(keys.len()); for (mut key, to_primary) in keys.into_values() { - // A key that names no parent columns points at the parent's - // primary key. + key.name = names.next().unwrap_or_default(); if to_primary { let parent = TableRef::new(table.schema.clone(), key.ref_table.clone()); key.ref_columns = table_shape(connection, &parent).await?.primary_key(); @@ -239,15 +266,7 @@ pub(crate) async fn fetch_table_definition( ) -> Result { let shape = table_shape(connection, table).await?; let schema_sql = quote(schema_of(table)); - let create_statement: Option = sqlx::query(AssertSqlSafe(catalog::object_sql(&schema_sql))) - .bind(table.name.as_str()) - .fetch_optional(&mut *connection) - .await - .map_err(map_error)? - .map(|row| row.try_get::, _>("sql")) - .transpose() - .map_err(map_error)? - .flatten(); + let create_statement = create_statement(connection, table).await?; let expressions = match ( &create_statement, @@ -273,7 +292,8 @@ pub(crate) async fn fetch_table_definition( columns: key_columns.clone(), }); let indexes = indexes(connection, table, &shape).await?; - let foreign_keys = fetch_foreign_keys(connection, table).await?; + let foreign_keys = foreign_key_rows(connection, table).await?; + let foreign_keys = resolve_foreign_keys(connection, table, foreign_keys, create_statement.as_deref()).await?; let inbound_rows = sqlx::query(AssertSqlSafe(catalog::inbound_keys_sql(&schema_sql))) .bind(table.name.as_str()) @@ -285,6 +305,8 @@ pub(crate) async fn fetch_table_definition( .map(|row| { Ok(InboundKeyRow { child: text(row, "child")?, + child_sql: row.try_get("child_sql").map_err(map_error)?, + child_keys: usize::try_from(row.try_get::("child_keys").map_err(map_error)?).unwrap_or(0), id: row.try_get("id").map_err(map_error)?, from: text(row, "from")?, to: row.try_get("to").map_err(map_error)?, @@ -312,6 +334,20 @@ pub(crate) async fn fetch_table_definition( }) } +/// The CREATE statement SQLite stored for the table. +async fn create_statement(connection: &mut SqlxConnection, table: &TableRef) -> Result, DriverError> { + let schema_sql = quote(schema_of(table)); + Ok(sqlx::query(AssertSqlSafe(catalog::object_sql(&schema_sql))) + .bind(table.name.as_str()) + .fetch_optional(&mut *connection) + .await + .map_err(map_error)? + .map(|row| row.try_get::, _>("sql")) + .transpose() + .map_err(map_error)? + .flatten()) +} + async fn dependents( connection: &mut SqlxConnection, schema_sql: &str, @@ -456,9 +492,15 @@ async fn table_shape(connection: &mut SqlxConnection, table: &TableRef) -> Resul without_rowid, autoincrement: false, single_column_key, + descending_key: false, }; - if columns.iter().any(|column| column.is_rowid_alias(&facts)) { - facts.autoincrement = autoincrement(connection, schema, &table.name).await?; + if let Some(candidate) = columns.iter().find(|column| column.is_rowid_candidate(&facts)) { + facts.descending_key = create_statement(connection, table) + .await? + .is_some_and(|sql| declared_descending_key(&sql, &candidate.name)); + if !facts.descending_key { + facts.autoincrement = autoincrement(connection, schema, &table.name).await?; + } } Ok(TableShape { kind, facts, columns }) } diff --git a/linux/crates/drivers/sqlite/src/write_batch.rs b/linux/crates/drivers/sqlite/src/write_batch.rs index db9ab73e03..83165a5542 100644 --- a/linux/crates/drivers/sqlite/src/write_batch.rs +++ b/linux/crates/drivers/sqlite/src/write_batch.rs @@ -305,7 +305,7 @@ async fn baseline(connection: &mut SqlxConnection, checks: &[ForeignKeyCheck]) - let Some(before) = &check.before else { continue; }; - found.extend(violations(connection, before).await?); + found.extend(violations(connection, before, check.rowids_renumbered).await?); renames.insert(before.name.to_lowercase(), check.after.name.clone()); } Ok(renamed(found, &renames)) @@ -317,14 +317,19 @@ async fn violations_after( ) -> Result, DriverError> { let mut found = Vec::new(); for check in checks { - found.extend(violations(connection, &check.after).await?); + found.extend(violations(connection, &check.after, check.rowids_renumbered).await?); } Ok(found) } /// `foreign_key_check` for one child table: the rows whose parent row -/// is missing. -async fn violations(connection: &mut SqlxConnection, table: &TableRef) -> Result, DriverError> { +/// is missing. A rowid the batch changes names no row on the other side +/// of it, so it is left out and the rows compare by count. +async fn violations( + connection: &mut SqlxConnection, + table: &TableRef, + rowids_renumbered: bool, +) -> Result, DriverError> { let schema = table .schema .as_deref() @@ -338,9 +343,10 @@ async fn violations(connection: &mut SqlxConnection, table: &TableRef) -> Result .map_err(map_error)?; rows.iter() .map(|row| { + let rowid: Option = row.try_get("rowid").map_err(map_error)?; Ok(Violation { table: row.try_get("table").map_err(map_error)?, - rowid: row.try_get("rowid").map_err(map_error)?, + rowid: rowid.filter(|_| !rowids_renumbered), parent: row.try_get("parent").map_err(map_error)?, }) }) diff --git a/linux/crates/drivers/sqlite/tests/integration/ddl.rs b/linux/crates/drivers/sqlite/tests/integration/ddl.rs index e77af128d5..183ccde5cc 100644 --- a/linux/crates/drivers/sqlite/tests/integration/ddl.rs +++ b/linux/crates/drivers/sqlite/tests/integration/ddl.rs @@ -1,37 +1,78 @@ //! Structure changes the SQLite dialect plans, run against a database -//! file. +//! file: native ALTER TABLE where SQLite has one, and the table rebuild +//! where it does not. -use tablepro_core::ddl::{DraftColumn, SchemaObjectKind, TableDefinition, TableDraft, diff_table}; -use tablepro_core::meta::TableRef; -use tablepro_core::{Connection, Value}; +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ + BuildDdlError, DdlOperation, DraftColumn, DraftType, ReferentialAction, RefusalReason, SchemaObjectKind, + TableDefinition, TableDraft, diff_table, +}; +use tablepro_core::meta::{IndexKey, TableRef}; +use tablepro_core::statement::GuardedStatement; +use tablepro_core::{ + Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, ServerCode, Value, WriteBatch, WriteKind, WriteReport, +}; -use crate::support::{TestFile, apply, call, query}; +use crate::support::{TestFile, apply, call, query, statement}; fn kept() -> TableRef { TableRef::unqualified("kept") } -async fn read(connection: &dyn Connection) -> TableDefinition { - connection.fetch_table_definition(&kept(), call()).await.unwrap() +fn named(name: &str) -> TableRef { + TableRef::unqualified(name) } -/// Plan the edit against the table as read, run it as the structure tab -/// saves, and read the table again. -async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { - let before = read(connection).await; - let mut draft = TableDraft::from_definition(&before); - draft.columns.iter_mut().for_each(edit); +async fn read(connection: &dyn Connection, table: &TableRef) -> TableDefinition { + connection.fetch_table_definition(table, call()).await.unwrap() +} + +fn plan( + connection: &dyn Connection, + before: &TableDefinition, + edit: impl FnOnce(&mut TableDraft), +) -> Result { + let mut draft = TableDraft::from_definition(before); + edit(&mut draft); let dialect = connection.dialect(); - let plan = dialect + dialect .ddl() .unwrap() - .plan_alter_table(&before, &diff_table(&before, &draft)) - .unwrap(); + .plan_alter_table(before, &diff_table(before, &draft)) +} + +/// Plan the edit against the table as read and run it as the structure +/// tab saves. +async fn save( + connection: &dyn Connection, + table: &TableRef, + edit: impl FnOnce(&mut TableDraft), +) -> Result { + let before = read(connection, table).await; + let plan = plan(connection, &before, edit).unwrap(); connection - .write_batch(&plan.into_write_batch(kept()), call()) + .write_batch(&plan.into_write_batch(table.clone()), call()) + .await +} + +/// Edit each column of `kept`, save, and read the table again. +async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { + save(connection, &kept(), |draft| draft.columns.iter_mut().for_each(edit)) .await .unwrap(); - read(connection).await + read(connection, &kept()).await +} + +fn column<'d>(draft: &'d mut TableDraft, name: &str) -> &'d mut DraftColumn { + draft + .columns + .iter_mut() + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("no column {name}")) +} + +fn retype(draft: &mut TableDraft, name: &str, declaration: &str) { + column(draft, name).column_type = DraftType::Typed(declaration.to_owned()); } /// The stored statement with its quotes taken out: SQLite writes a @@ -45,6 +86,58 @@ fn dependent(definition: &TableDefinition, kind: SchemaObjectKind) -> String { .unwrap_or_default() } +fn dependent_names(definition: &TableDefinition) -> Vec { + definition + .source + .as_ref() + .map(|source| source.dependents.iter().map(|object| object.name.clone()).collect()) + .unwrap_or_default() +} + +fn create_statement(definition: &TableDefinition) -> String { + definition + .source + .as_ref() + .map(|source| source.create_statement.clone()) + .unwrap_or_default() +} + +fn names(definition: &TableDefinition) -> Vec { + definition + .columns + .iter() + .map(|column| column.name().to_owned()) + .collect() +} + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +/// Rows written with foreign keys off, which SQLite never checks. +async fn unchecked(connection: &dyn Connection, statements: &[&str]) { + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + named("unchecked"), + statements + .iter() + .map(|sql| GuardedStatement::unguarded(statement(sql))) + .collect(), + ForeignKeyMode::SuspendedThenVerified { checks: Vec::new() }, + ); + connection.write_batch(&batch, call()).await.unwrap(); +} + +async fn rebuild_tables_left(connection: &dyn Connection) -> Value { + query( + connection, + "SELECT count(*) FROM sqlite_schema WHERE name LIKE '!_tablepro!_%' ESCAPE '!'", + ) + .await + .rows[0][0] + .clone() +} + /// SQLite rewrites the indexes and triggers that name a column as it /// renames it, and a swap goes through a temporary name. #[tokio::test] @@ -86,13 +179,6 @@ async fn rename_column_updates_index_and_trigger() { .await .unwrap(); - let names = |definition: &TableDefinition| { - definition - .columns - .iter() - .map(|column| column.name().to_owned()) - .collect::>() - }; assert_eq!(names(&renamed), ["id", "mail", "name"]); assert!(dependent(&renamed, SchemaObjectKind::Index).contains("(mail)")); assert!(dependent(&renamed, SchemaObjectKind::Trigger).contains("new.mail")); @@ -100,14 +186,815 @@ async fn rename_column_updates_index_and_trigger() { assert!(dependent(&swapped, SchemaObjectKind::Index).contains("(name)")); assert!(dependent(&swapped, SchemaObjectKind::Trigger).contains("new.name")); let audit = query(connection.as_ref(), "SELECT note FROM audit ORDER BY rowid").await; + assert_eq!(audit.rows, [vec![text("a@x")], vec![text("b@x")]]); + let kept = query(connection.as_ref(), "SELECT name, mail FROM kept WHERE id = 1").await; + assert_eq!(kept.rows, [vec![text("a@x"), text("Ann")]]); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn create_table_with_foreign_key_succeeds() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "INSERT INTO parents VALUES (1)", + ], + ) + .await + .unwrap(); + let mut draft = TableDraft::new(named("children")); + let mut id = DraftColumn::new("id", DraftType::Typed("INTEGER".to_owned())); + id.primary_key = true; + id.nullable = false; + let parent = DraftColumn::new("parent_id", DraftType::Typed("INTEGER".to_owned())); + draft.columns = vec![id, parent]; + draft.foreign_keys = vec![ForeignKeyInfo { + name: "children_parent".to_owned(), + columns: vec!["parent_id".to_owned()], + ref_schema: None, + ref_table: "parents".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + }]; + let plan = connection.dialect().ddl().unwrap().plan_create_table(&draft).unwrap(); + + connection + .write_batch(&plan.into_write_batch(named("children")), call()) + .await + .unwrap(); + + let children = read(connection.as_ref(), &named("children")).await; + assert_eq!(children.foreign_keys.len(), 1); + let key = &children.foreign_keys[0]; assert_eq!( - audit.rows, - [vec![Value::Text("a@x".to_owned())], vec![Value::Text("b@x".to_owned())]] + (key.columns.as_slice(), key.ref_table.as_str(), key.on_delete), + ( + &["parent_id".to_owned()][..], + "parents", + Some(ReferentialAction::Cascade) + ) ); - let kept = query(connection.as_ref(), "SELECT name, mail FROM kept WHERE id = 1").await; + apply(connection.as_ref(), &["INSERT INTO children VALUES (1, 1)"]) + .await + .unwrap(); + let orphan = apply(connection.as_ref(), &["INSERT INTO children VALUES (2, 99)"]).await; + assert!(orphan.is_err(), "{orphan:?}"); + apply(connection.as_ref(), &["DELETE FROM parents WHERE id = 1"]) + .await + .unwrap(); + let left = query(connection.as_ref(), "SELECT count(*) FROM children").await; + assert_eq!(left.rows[0][0], Value::Int(0)); + connection.close().await.unwrap(); +} + +/// A rebuild keeps every row under its rowid, the indexes and the +/// constraints written into the table, its own trigger, a view on it, a +/// trigger elsewhere that writes to it, and the keys that point at it. +#[tokio::test] +async fn rebuild_preserves_data_rowids_and_dependents() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)", + "CREATE TABLE orders (\n code TEXT NOT NULL UNIQUE, -- the order's code\n customer INTEGER REFERENCES \ + customers (id),\n total REAL DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE NOCASE\n)", + "CREATE TABLE lines (order_code TEXT REFERENCES orders (code), qty INTEGER)", + "CREATE TABLE audit (note TEXT)", + "CREATE INDEX orders_by_customer ON orders (customer)", + "CREATE TRIGGER orders_audit AFTER INSERT ON orders BEGIN INSERT INTO audit VALUES ('order ' || \ + new.code); END", + "CREATE VIEW big_orders AS SELECT code, total FROM orders WHERE total > 10", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + "INSERT INTO customers VALUES (1, 'Ann'), (2, 'Bo')", + "INSERT INTO orders (rowid, code, customer, total, note) VALUES (5, 'a', 1, 20, 'X'), (9, 'b', 2, 5, \ + 'y'), (12, 'c', 1, 50, NULL)", + "INSERT INTO lines VALUES ('a', 1), ('c', 2)", + ], + ) + .await + .unwrap(); + let rows_sql = "SELECT rowid, code, customer, total, note FROM orders ORDER BY rowid"; + let before_rows = query(connection.as_ref(), rows_sql).await.rows; + let before = read(connection.as_ref(), &named("orders")).await; + + save(connection.as_ref(), &named("orders"), |draft| { + retype(draft, "total", "DOUBLE PRECISION"); + column(draft, "note").default = ColumnDefault::Literal(text("none")); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &named("orders")).await; + assert_eq!(query(connection.as_ref(), rows_sql).await.rows, before_rows); + assert_eq!(dependent_names(&after), dependent_names(&before)); + assert_eq!(after.indexes, before.indexes); + assert_eq!(after.foreign_keys, before.foreign_keys); + assert_eq!(after.inbound_foreign_keys, before.inbound_foreign_keys); + let create = create_statement(&after); + for kept in [ + "-- the order's code", + "CHECK (total >= 0)", + "COLLATE NOCASE", + "total DOUBLE PRECISION", + "DEFAULT 'none'", + ] { + assert!(create.contains(kept), "{kept} is not in {create}"); + } + assert_eq!(rebuild_tables_left(connection.as_ref()).await, Value::Int(0)); + + let view = query(connection.as_ref(), "SELECT code FROM big_orders ORDER BY code").await; + assert_eq!(view.rows, [vec![text("a")], vec![text("c")]]); + apply( + connection.as_ref(), + &["INSERT INTO orders (code, customer) VALUES ('d', 1)"], + ) + .await + .unwrap(); + let audit = query(connection.as_ref(), "SELECT note FROM audit ORDER BY rowid").await; + assert_eq!(audit.rows.last(), Some(&vec![text("order d")])); + assert_eq!(audit.rows.len(), 4, "the copy fired no trigger"); + apply(connection.as_ref(), &["DELETE FROM customers WHERE id = 2"]) + .await + .unwrap(); + let codes = query(connection.as_ref(), "SELECT code FROM orders ORDER BY code").await; + assert_eq!(codes.rows, [vec![text("a")], vec![text("c")], vec![text("d")]]); + let dangling = apply(connection.as_ref(), &["INSERT INTO lines VALUES ('zzz', 1)"]).await; + assert!(dangling.is_err(), "foreign keys are back on: {dangling:?}"); + connection.close().await.unwrap(); +} + +/// Violations written while foreign keys were off, in the rebuilt +/// table's children and in a table the save does not touch, are not +/// the save's. +#[tokio::test] +async fn rebuild_succeeds_with_preexisting_unrelated_violation() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY, label TEXT)", + "CREATE TABLE children (id INTEGER PRIMARY KEY, parent INTEGER REFERENCES parents (id))", + "CREATE TABLE others (id INTEGER PRIMARY KEY, parent INTEGER REFERENCES parents (id))", + "INSERT INTO parents VALUES (1, 'one')", + ], + ) + .await + .unwrap(); + unchecked( + connection.as_ref(), + &[ + "INSERT INTO children VALUES (1, 1), (2, 99)", + "INSERT INTO others VALUES (1, 98)", + ], + ) + .await; + + let parents = save(connection.as_ref(), &named("parents"), |draft| { + retype(draft, "label", "BLOB"); + }) + .await; + let children = save(connection.as_ref(), &named("children"), |draft| { + column(draft, "parent").default = ColumnDefault::Literal(Value::Int(1)); + }) + .await; + + assert!(parents.is_ok(), "{parents:?}"); + assert!(children.is_ok(), "{children:?}"); + let orphans = query(connection.as_ref(), "SELECT count(*) FROM pragma_foreign_key_check").await; + assert_eq!(orphans.rows[0][0], Value::Int(2)); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn not_null_change_with_existing_nulls_rolls_back_schema_unchanged() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, nickname TEXT)", + "CREATE INDEX kept_by_nickname ON kept (nickname)", + "INSERT INTO kept VALUES (1, 'a'), (2, NULL)", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + + let outcome = save(connection.as_ref(), &kept(), |draft| { + column(draft, "nickname").nullable = false; + }) + .await; + + let Err(DriverError::RolledBack { source, .. }) = &outcome else { + panic!("{outcome:?}"); + }; + assert!(source.to_string().contains("NOT NULL"), "{source}"); + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(after, before); + let rows = query(connection.as_ref(), "SELECT id, nickname FROM kept ORDER BY id").await; + assert_eq!( + rows.rows, + [vec![Value::Int(1), text("a")], vec![Value::Int(2), Value::Null]] + ); + assert_eq!(rebuild_tables_left(connection.as_ref()).await, Value::Int(0)); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn adding_violating_foreign_key_rolls_back_with_diagnostics() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE children (id INTEGER PRIMARY KEY, parent INTEGER)", + "INSERT INTO parents VALUES (1)", + "INSERT INTO children VALUES (1, 1), (2, 7)", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &named("children")).await; + + let outcome = save(connection.as_ref(), &named("children"), |draft| { + draft.foreign_keys.push(ForeignKeyInfo { + name: "children_parent".to_owned(), + columns: vec!["parent".to_owned()], + ref_schema: None, + ref_table: "parents".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }); + }) + .await; + + let Err(DriverError::RolledBack { source, .. }) = &outcome else { + panic!("{outcome:?}"); + }; + let DriverError::Server(diagnostics) = source.as_ref() else { + panic!("{source:?}"); + }; + assert_eq!( + diagnostics.code, + Some(ServerCode::Sqlite { + primary: 19, + extended: 787 + }) + ); + assert!( + diagnostics.message.contains("1 rows of children") && diagnostics.message.contains("parents"), + "{}", + diagnostics.message + ); + assert_eq!(read(connection.as_ref(), &named("children")).await, before); + connection.close().await.unwrap(); +} + +/// Two columns trade names while one of them changes type, so the +/// renames after the rebuild step aside through a temporary name. +#[tokio::test] +async fn swap_rename_with_type_change_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, a TEXT, b TEXT)", + "CREATE TABLE audit (note TEXT)", + "CREATE INDEX kept_by_a ON kept (a)", + "CREATE TRIGGER kept_audit AFTER INSERT ON kept BEGIN INSERT INTO audit VALUES (new.b); END", + "INSERT INTO kept VALUES (1, 'first a', 'first b')", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + column(draft, "a").name = "b".to_owned(); + draft.columns[2].name = "a".to_owned(); + draft.columns[1].column_type = DraftType::Typed("BLOB".to_owned()); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(names(&after), ["id", "b", "a"]); + let declared: Vec = after + .columns + .iter() + .map(|column| column.info.column_type.name.clone()) + .collect(); + assert_eq!(declared, ["INTEGER", "BLOB", "TEXT"]); + let rows = query(connection.as_ref(), "SELECT b, a FROM kept").await; + assert_eq!(rows.rows, [vec![text("first a"), text("first b")]]); + assert!(dependent(&after, SchemaObjectKind::Index).contains("(b)")); + assert!(dependent(&after, SchemaObjectKind::Trigger).contains("new.a")); + assert_eq!( + after + .indexes + .iter() + .find(|index| index.name == "kept_by_a") + .map(|index| index.keys.clone()), + Some(vec![IndexKey::Column { + name: "b".to_owned(), + descending: false + }]) + ); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn autoincrement_high_water_mark_survives_rebuild() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)", + "INSERT INTO kept (v) VALUES ('a'), ('b'), ('c')", + "DELETE FROM kept WHERE id = 3", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| retype(draft, "v", "BLOB")) + .await + .unwrap(); + apply(connection.as_ref(), &["INSERT INTO kept (v) VALUES ('d')"]) + .await + .unwrap(); + + let ids = query(connection.as_ref(), "SELECT id FROM kept ORDER BY id").await; + assert_eq!( + ids.rows, + [vec![Value::Int(1)], vec![Value::Int(2)], vec![Value::Int(4)]] + ); + let sequence = query( + connection.as_ref(), + "SELECT name, seq FROM sqlite_sequence ORDER BY name", + ) + .await; + assert_eq!(sequence.rows, [vec![text("kept"), Value::Int(4)]]); + assert!(create_statement(&read(connection.as_ref(), &kept()).await).contains("AUTOINCREMENT")); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn primary_key_change_via_rebuild_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (code TEXT, n INTEGER)", + "INSERT INTO kept VALUES ('x', 1), ('y', 2)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + let code = column(draft, "code"); + code.primary_key = true; + code.nullable = false; + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(after.primary_key.map(|key| key.columns), Some(vec!["code".to_owned()])); + let rows = query(connection.as_ref(), "SELECT rowid, code, n FROM kept ORDER BY rowid").await; + assert_eq!( + rows.rows, + [ + vec![Value::Int(1), text("x"), Value::Int(1)], + vec![Value::Int(2), text("y"), Value::Int(2)] + ] + ); + let duplicate = apply(connection.as_ref(), &["INSERT INTO kept VALUES ('x', 3)"]).await; + assert!(duplicate.is_err(), "{duplicate:?}"); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn without_rowid_table_rebuild_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (k TEXT PRIMARY KEY, v INTEGER) WITHOUT ROWID", + "INSERT INTO kept VALUES ('a', 1), ('b', 2)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + retype(draft, "v", "TEXT"); + column(draft, "v").nullable = false; + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert!( + create_statement(&after).ends_with("WITHOUT ROWID"), + "{}", + create_statement(&after) + ); + assert!(!after.columns[1].info.nullable); + let rows = query(connection.as_ref(), "SELECT k, v FROM kept ORDER BY k").await; + assert_eq!(rows.rows, [vec![text("a"), text("1")], vec![text("b"), text("2")]]); + connection.close().await.unwrap(); +} + +/// The catalogue numbers a table's foreign keys from the last one +/// declared, and the rebuild removes the one the user picked. +#[tokio::test] +async fn dropping_one_of_several_foreign_keys_keeps_the_others() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE products (sku TEXT PRIMARY KEY)", + "CREATE TABLE lines (parent INTEGER CONSTRAINT to_parent REFERENCES parents (id), sku TEXT, qty \ + INTEGER, FOREIGN KEY (sku) REFERENCES products (sku) ON DELETE CASCADE)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &named("lines"), |draft| { + draft.foreign_keys.retain(|key| key.ref_table != "parents"); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &named("lines")).await; + let parents: Vec<&str> = after.foreign_keys.iter().map(|key| key.ref_table.as_str()).collect(); + assert_eq!(parents, ["products"]); + assert!(!create_statement(&after).contains("to_parent")); + apply(connection.as_ref(), &["INSERT INTO lines VALUES (42, NULL, 1)"]) + .await + .unwrap(); + connection.close().await.unwrap(); +} + +/// A rename of the table in the same save as a rebuild runs last, and +/// SQLite carries it into the keys of the tables that point at it. +#[tokio::test] +async fn rename_with_rebuild_moves_the_children_keys() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, label TEXT)", + "CREATE TABLE children (kept_id INTEGER REFERENCES kept (id))", + "INSERT INTO kept VALUES (1, 'one')", + "INSERT INTO children VALUES (1)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + draft.table.name = "renamed".to_owned(); + retype(draft, "label", "BLOB"); + }) + .await + .unwrap(); + + let children = read(connection.as_ref(), &named("children")).await; + assert_eq!(children.foreign_keys[0].ref_table, "renamed"); + let rows = query(connection.as_ref(), "SELECT id, label FROM renamed").await; + assert_eq!(rows.rows, [vec![Value::Int(1), text("one")]]); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn dropping_a_column_a_view_needs_is_refused_before_anything_runs() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, code TEXT UNIQUE, note TEXT)", + "CREATE VIEW codes AS SELECT code FROM kept", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + let mut draft = TableDraft::from_definition(&before); + draft.columns.retain(|column| column.name != "code"); + + let plan = connection + .dialect() + .ddl() + .unwrap() + .plan_alter_table(&before, &diff_table(&before, &draft)); + + assert_eq!( + plan, + Err(BuildDdlError::Refused { + column: "code".to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::ColumnInUse(vec!["codes".to_owned()]), + }) + ); + connection.close().await.unwrap(); +} + +/// The tab plans against the table as it read it. Whatever changed the +/// table, its dependents or the keys pointing at it since then, the +/// rebuild refuses before it runs a statement, rather than writing the +/// table again without it. +#[tokio::test] +async fn a_rebuild_planned_before_another_change_is_refused() { + let changes: [&[&str]; 4] = [ + &[ + "ALTER TABLE kept ADD COLUMN important TEXT", + "UPDATE kept SET important = 'keep me'", + "CREATE INDEX kept_by_important ON kept (important)", + ], + &["CREATE INDEX kept_by_v ON kept (v)"], + &[ + "DROP VIEW kept_values", + "CREATE VIEW kept_values AS SELECT id, v FROM kept", + ], + &["CREATE TABLE children (kept_id INTEGER REFERENCES kept (id))"], + ]; + for change in changes { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, v TEXT)", + "CREATE VIEW kept_values AS SELECT v FROM kept", + "INSERT INTO kept VALUES (1, 'a')", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + let stale = plan(connection.as_ref(), &before, |draft| retype(draft, "v", "BLOB")).unwrap(); + apply(connection.as_ref(), change).await.unwrap(); + let changed = read(connection.as_ref(), &kept()).await; + let rows_sql = "SELECT * FROM kept ORDER BY id"; + let rows = query(connection.as_ref(), rows_sql).await.rows; + + let outcome = connection.write_batch(&stale.into_write_batch(kept()), call()).await; + + assert!( + matches!( + outcome, + Err(DriverError::RowGuardFailed { + statement_index: 0, + expected: 0, + .. + }) + ), + "{change:?}: {outcome:?}" + ); + assert_eq!(read(connection.as_ref(), &kept()).await, changed, "{change:?}"); + assert_eq!(query(connection.as_ref(), rows_sql).await.rows, rows, "{change:?}"); + let fresh = save(connection.as_ref(), &kept(), |draft| retype(draft, "v", "BLOB")).await; + assert!( + fresh.is_ok(), + "{change:?}: a plan read after the change was refused: {fresh:?}" + ); + connection.close().await.unwrap(); + } +} + +/// The copy runs as INSERT OR ABORT, so a conflict clause the table +/// declares cannot quietly drop a row the new type makes a duplicate. +#[tokio::test] +async fn a_conflict_clause_in_the_table_cannot_drop_rows_in_the_copy() { + for create in [ + "CREATE TABLE kept (code TEXT UNIQUE ON CONFLICT IGNORE, note TEXT)", + "CREATE TABLE kept (code TEXT PRIMARY KEY ON CONFLICT REPLACE, note TEXT)", + ] { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[create, "INSERT INTO kept VALUES ('1', 'one'), ('01', 'zero one')"], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + + let outcome = save(connection.as_ref(), &kept(), |draft| retype(draft, "code", "INTEGER")).await; + + let Err(DriverError::RolledBack { source, .. }) = &outcome else { + panic!("{create}: {outcome:?}"); + }; + assert!(source.to_string().contains("UNIQUE"), "{create}: {source}"); + assert_eq!(read(connection.as_ref(), &kept()).await, before, "{create}"); + let count = query(connection.as_ref(), "SELECT count(*) FROM kept").await; + assert_eq!(count.rows[0][0], Value::Int(2), "{create}"); + connection.close().await.unwrap(); + } +} + +#[tokio::test] +async fn a_default_written_after_a_line_comment_reads_back() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, a TEXT -- note\n NOT NULL, b TEXT)", + "INSERT INTO kept VALUES (1, 'x', 'y')", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + let a = column(draft, "a"); + a.nullable = true; + a.default = ColumnDefault::Literal(text("dflt")); + }) + .await + .unwrap(); + apply(connection.as_ref(), &["INSERT INTO kept (id) VALUES (2)"]) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert!(after.columns[1].info.nullable); + assert_eq!(after.columns[1].info.default, ColumnDefault::Literal(text("dflt"))); + assert!( + create_statement(&after).contains("-- note\n"), + "{}", + create_statement(&after) + ); + let rows = query(connection.as_ref(), "SELECT a, b FROM kept ORDER BY id").await; + assert_eq!(rows.rows, [vec![text("x"), text("y")], vec![text("dflt"), Value::Null]]); + connection.close().await.unwrap(); +} + +/// SQLite numbers an INTEGER PRIMARY KEY however it is declared, so +/// numbering turns off only with the type or the key, and then reads +/// back off. +#[tokio::test] +async fn numbering_turns_off_only_where_it_reads_back_off() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)", + "INSERT INTO kept (v) VALUES ('a'), ('b')", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + assert!(before.columns[0].info.is_auto_increment); + + let off = plan(connection.as_ref(), &before, |draft| { + column(draft, "id").auto_increment = false; + }); + save(connection.as_ref(), &kept(), |draft| { + retype(draft, "id", "BIGINT"); + column(draft, "id").auto_increment = false; + }) + .await + .unwrap(); + + assert!( + matches!( + off, + Err(BuildDdlError::Refused { + reason: RefusalReason::AlwaysNumbered, + .. + }) + ), + "{off:?}" + ); + let after = read(connection.as_ref(), &kept()).await; + assert!(!after.columns[0].info.is_auto_increment); + assert_eq!(after.columns[0].info.column_type.name, "BIGINT"); + assert_eq!(after.primary_key.map(|key| key.columns), Some(vec!["id".to_owned()])); + let rows = query(connection.as_ref(), "SELECT rowid, id, v FROM kept ORDER BY id").await; + assert_eq!( + rows.rows, + [ + vec![Value::Int(1), Value::Int(1), text("a")], + vec![Value::Int(2), Value::Int(2), text("b")] + ] + ); + let sequence = query( + connection.as_ref(), + "SELECT count(*) FROM sqlite_sequence WHERE name = 'kept'", + ) + .await; + assert_eq!(sequence.rows[0][0], Value::Int(0)); + connection.close().await.unwrap(); +} + +/// A column that becomes the INTEGER PRIMARY KEY gives its rows its own +/// values as rowids, so an orphan already there keeps no rowid to be +/// recognised by. It is still the file's, not the save's. +#[tokio::test] +async fn a_new_rowid_key_keeps_an_orphan_the_save_did_not_make() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE kept (id INTEGER NOT NULL, parent INTEGER REFERENCES parents (id))", + "CREATE TABLE loose (id INTEGER NOT NULL, parent INTEGER)", + "INSERT INTO parents VALUES (1)", + "INSERT INTO loose VALUES (10, 99)", + ], + ) + .await + .unwrap(); + unchecked(connection.as_ref(), &["INSERT INTO kept VALUES (10, 1), (20, 99)"]).await; + + let kept_outcome = save(connection.as_ref(), &kept(), |draft| { + column(draft, "id").primary_key = true; + }) + .await; + let loose_outcome = save(connection.as_ref(), &named("loose"), |draft| { + column(draft, "id").primary_key = true; + draft.foreign_keys.push(ForeignKeyInfo { + name: "loose_parent".to_owned(), + columns: vec!["parent".to_owned()], + ref_schema: None, + ref_table: "parents".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }); + }) + .await; + + assert!(kept_outcome.is_ok(), "{kept_outcome:?}"); + let rows = query(connection.as_ref(), "SELECT rowid, id, parent FROM kept ORDER BY id").await; + assert_eq!( + rows.rows, + [ + vec![Value::Int(10), Value::Int(10), Value::Int(1)], + vec![Value::Int(20), Value::Int(20), Value::Int(99)] + ] + ); + let Err(DriverError::RolledBack { source, .. }) = &loose_outcome else { + panic!("a violation the save made was let through: {loose_outcome:?}"); + }; + assert!(source.to_string().contains("1 rows of loose"), "{source}"); + connection.close().await.unwrap(); +} + +/// `INTEGER PRIMARY KEY DESC` on the column is not the rowid, so the +/// rebuild carries the rowids across on their own. +#[tokio::test] +async fn a_descending_integer_key_keeps_its_rowids_through_a_rebuild() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY DESC, v TEXT)", + "INSERT INTO kept (rowid, id, v) VALUES (5, 1, 'a'), (9, 2, 'b')", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| retype(draft, "v", "BLOB")) + .await + .unwrap(); + + let rows = query(connection.as_ref(), "SELECT rowid, id, v FROM kept ORDER BY rowid").await; assert_eq!( - kept.rows, - [vec![Value::Text("a@x".to_owned()), Value::Text("Ann".to_owned())]] + rows.rows, + [ + vec![Value::Int(5), Value::Int(1), text("a")], + vec![Value::Int(9), Value::Int(2), text("b")] + ] ); connection.close().await.unwrap(); } diff --git a/linux/crates/drivers/sqlite/tests/integration/metadata.rs b/linux/crates/drivers/sqlite/tests/integration/metadata.rs index 3a741d5922..d93d47d2fe 100644 --- a/linux/crates/drivers/sqlite/tests/integration/metadata.rs +++ b/linux/crates/drivers/sqlite/tests/integration/metadata.rs @@ -1,6 +1,6 @@ //! The catalogue as the sidebar, the grid and the structure tab read it. -use tablepro_core::ddl::ReferentialAction; +use tablepro_core::ddl::{IdentityStyle, ReferentialAction}; use tablepro_core::meta::{ EngineRowId, IndexInfo, IndexKey, ObjectKind, ObjectScope, RowCountEstimate, RowIdentity, TableRef, }; @@ -220,6 +220,42 @@ async fn integer_primary_key_with_default_is_not_auto_increment() { connection.close().await.unwrap(); } +/// SQLite stores NULL in a key it does not number, which shows the +/// column is not the rowid. DESC in a table PRIMARY KEY keeps the alias. +#[tokio::test] +async fn integer_primary_key_desc_on_the_column_is_not_the_rowid() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE t (id INTEGER PRIMARY KEY DESC, name TEXT)", + "CREATE TABLE u (id INTEGER, name TEXT, PRIMARY KEY (id DESC))", + "INSERT INTO t (name) VALUES ('a')", + "INSERT INTO u (name) VALUES ('a')", + ], + ) + .await + .unwrap(); + + let descending = connection.fetch_table_definition(&table("t"), call()).await.unwrap(); + let table_key = connection.fetch_table_definition(&table("u"), call()).await.unwrap(); + let stored = query(connection.as_ref(), "SELECT (SELECT id FROM t), (SELECT id FROM u)").await; + + assert_eq!(stored.rows[0], [Value::Null, Value::Int(1)]); + let id = &descending.columns[0]; + assert!( + !id.info.is_auto_increment, + "PRIMARY KEY DESC was read as numbering itself" + ); + assert_eq!(id.attributes.identity, IdentityStyle::None); + assert!(id.info.primary_key); + let alias = &table_key.columns[0]; + assert!(alias.info.is_auto_increment); + assert_eq!(alias.attributes.identity, IdentityStyle::SqliteRowidAlias); + connection.close().await.unwrap(); +} + #[tokio::test] async fn composite_primary_key_no_auto_increment() { let file = TestFile::new().await; diff --git a/linux/crates/session/src/connect/credential_source.rs b/linux/crates/session/src/connect/credential_source.rs index d6fb549cfa..362e883f3f 100644 --- a/linux/crates/session/src/connect/credential_source.rs +++ b/linux/crates/session/src/connect/credential_source.rs @@ -11,6 +11,19 @@ pub struct RefreshedRequest { /// Secrets typed into a prompt with "Remember" on. They are stored /// only once the login they were typed for has worked. pub remember: Vec, + /// Every kind the user typed into a prompt, remembered or not. + pub typed: Vec, +} + +impl RefreshedRequest { + /// `request` as it came, with nothing typed for it. + pub fn unchanged(request: ConnectRequest) -> Self { + Self { + request, + remember: Vec::new(), + typed: Vec::new(), + } + } } /// Where a request's secrets come from at each connect. diff --git a/linux/crates/session/src/connect/driver_connector.rs b/linux/crates/session/src/connect/driver_connector.rs index 6203847e5d..939abce8f5 100644 --- a/linux/crates/session/src/connect/driver_connector.rs +++ b/linux/crates/session/src/connect/driver_connector.rs @@ -247,6 +247,7 @@ mod tests { ssh_program: PathBuf::from("/nonexistent/ssh"), ssh_keygen_program: PathBuf::from("/nonexistent/ssh-keygen"), askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), + config_file: None, }, liveness: LivenessPolicy::DESKTOP, }; diff --git a/linux/crates/session/src/connect/keyring_source.rs b/linux/crates/session/src/connect/keyring_source.rs index a3f63d05d5..b105e34175 100644 --- a/linux/crates/session/src/connect/keyring_source.rs +++ b/linux/crates/session/src/connect/keyring_source.rs @@ -19,20 +19,20 @@ impl CredentialSource for KeyringCredentials { let credentials = self.clone(); Box::pin(async move { let mut request = request; - let mut remember = Vec::new(); + let mut typed = Typed::default(); let RequestTarget::Network { credentials: login, ssh, .. } = &mut request.target else { - return Ok(RefreshedRequest { request, remember }); + return Ok(RefreshedRequest::unchanged(request)); }; if let Credentials::Password { password, .. } = login { let resolved = credentials .resolve(SecretKind::DatabasePassword, Some(password), &interaction, demand) .await?; - fill(password, resolved, &mut remember); + fill(password, SecretKind::DatabasePassword, resolved, &mut typed); } if let Some(ssh) = ssh { match &mut ssh.auth { @@ -40,21 +40,24 @@ impl CredentialSource for KeyringCredentials { let resolved = credentials .resolve(SecretKind::SshPassword, Some(password), &interaction, demand) .await?; - fill(password, resolved, &mut remember); + fill(password, SecretKind::SshPassword, resolved, &mut typed); } SshAuth::PrivateKey { passphrase, .. } => { let resolved = credentials .resolve(SecretKind::SshPassphrase, passphrase.as_ref(), &interaction, demand) .await?; if let Some(resolved) = resolved { - remember.extend(resolved.remember); - *passphrase = Some(resolved.secret); + *passphrase = Some(typed.note(SecretKind::SshPassphrase, resolved)); } } SshAuth::Agent | SshAuth::KeyboardInteractive => {} } } - Ok(RefreshedRequest { request, remember }) + Ok(RefreshedRequest { + request, + remember: typed.remember, + typed: typed.kinds, + }) }) } @@ -73,9 +76,25 @@ impl CredentialSource for KeyringCredentials { } } -fn fill(slot: &mut SecretString, resolved: Option, remember: &mut Vec) { +/// What the user typed while one request was refreshed. +#[derive(Default)] +struct Typed { + remember: Vec, + kinds: Vec, +} + +impl Typed { + fn note(&mut self, kind: SecretKind, resolved: ResolvedSecret) -> SecretString { + self.remember.extend(resolved.remember); + if resolved.typed { + self.kinds.push(kind); + } + resolved.secret + } +} + +fn fill(slot: &mut SecretString, kind: SecretKind, resolved: Option, typed: &mut Typed) { if let Some(resolved) = resolved { - remember.extend(resolved.remember); - *slot = resolved.secret; + *slot = typed.note(kind, resolved); } } diff --git a/linux/crates/session/src/connect/no_stored_credentials.rs b/linux/crates/session/src/connect/no_stored_credentials.rs index 7e84912892..1bb1a7ef6e 100644 --- a/linux/crates/session/src/connect/no_stored_credentials.rs +++ b/linux/crates/session/src/connect/no_stored_credentials.rs @@ -16,11 +16,6 @@ impl CredentialSource for NoStoredCredentials { _interaction: CredentialInteraction, _demand: CredentialDemand, ) -> BoxFuture<'static, Result> { - Box::pin(async move { - Ok(RefreshedRequest { - request, - remember: Vec::new(), - }) - }) + Box::pin(async move { Ok(RefreshedRequest::unchanged(request)) }) } } diff --git a/linux/crates/session/src/connect/resolve_saved.rs b/linux/crates/session/src/connect/resolve_saved.rs index 1c1a86756c..8321f9533c 100644 --- a/linux/crates/session/src/connect/resolve_saved.rs +++ b/linux/crates/session/src/connect/resolve_saved.rs @@ -176,6 +176,7 @@ mod tests { .await .unwrap(); + assert!(refreshed.typed.is_empty(), "{:?}", refreshed.typed); let request = refreshed.request; assert_eq!(request.metadata.id, saved.id); assert!(request.workspace_settings.read_only); @@ -203,6 +204,42 @@ mod tests { assert_eq!(password.expose_secret(), "ssh"); } + #[tokio::test] + async fn the_kinds_typed_into_a_prompt_are_reported() { + use tablepro_core::credentials::PromptReply; + use tablepro_test_support::FakePrompter; + + let saved = saved(Some(bastion(SavedSshAuth::Password))); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("db"), + "label", + ) + .await + .unwrap(); + let credentials = KeyringCredentials::for_saved(vault, &saved); + let prompter = Arc::new(FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("ssh")], + remember: false, + }])); + + let refreshed = resolve_saved( + &saved, + &drivers(), + &credentials, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + ) + .await + .unwrap(); + + assert_eq!(refreshed.typed, [SecretKind::SshPassword]); + assert!(refreshed.remember.is_empty()); + } + #[tokio::test] async fn a_saved_password_that_is_not_stored_never_becomes_an_empty_login() { let saved = saved(None); diff --git a/linux/crates/session/src/fakes/fake_connector.rs b/linux/crates/session/src/fakes/fake_connector.rs index 1dd9c0dae2..fd0698d85c 100644 --- a/linux/crates/session/src/fakes/fake_connector.rs +++ b/linux/crates/session/src/fakes/fake_connector.rs @@ -4,8 +4,12 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; use futures::future::BoxFuture; -use tablepro_core::credentials::CredentialInteraction; -use tablepro_core::{ConfigError, FileEndpoint, LivenessPolicy, WorkspaceSettings}; +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialInteraction, CredentialPrompt, PromptPurpose, PromptReply}; +use tablepro_core::{ + ConfigError, Credentials, DriverError, FileEndpoint, LivenessPolicy, NetworkEndpoint, SshFailure, TlsConfig, + TransportError, WorkspaceSettings, +}; use tablepro_test_support::{ConnectionScript, FakeConnection, FakeDriver}; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -27,6 +31,29 @@ pub enum FakeEstablish { }, /// Never connects; ends with `Cancelled` when cancelled. UntilCancelled, + /// Asks about an unknown host key the way ssh does: through the + /// prompter when attended, and never otherwise. A yes connects; a + /// no, or nobody to ask, fails as a declined host key. + AskHostKey { + host: String, + fingerprint: String, + script: ConnectionScript, + }, +} + +impl FakeEstablish { + /// The error ssh gives for an unknown host key nobody accepted. + pub fn declined_host_key(host: &str, fingerprint: &str) -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyUnknown { + host: host.to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: fingerprint.to_owned(), + trust_allowed: true, + declined: true, + }, + ))) + } } /// What one `establish` call was asked. @@ -93,6 +120,28 @@ impl FakeConnector { }) } + /// A request for a server at `host:port`, for tests where the network + /// has to reach it. + pub fn network_request(host: &str, port: u16) -> Result { + Ok(ConnectRequest { + target: RequestTarget::Network { + endpoint: NetworkEndpoint::new(host, port)?, + ssh: None, + credentials: Credentials::Password { + username: "app".to_owned(), + password: SecretString::from("secret"), + }, + database: None, + tls: TlsConfig::disabled(), + }, + driver: Arc::new(FakeDriver::connecting( + &FakeDriver::NETWORK_CAPABILITIES, + ConnectionScript::default(), + )), + ..Self::request()? + }) + } + /// A pending session over a new fake connection, without a connector. pub fn pending_session(request: ConnectRequest, script: ConnectionScript) -> (PendingSession, Arc) { let connection = Arc::new(FakeConnection::new(script)); @@ -149,6 +198,33 @@ impl Connector for FakeConnector { cancel.cancelled().await; Err(ConnectError::Cancelled) } + Some(FakeEstablish::AskHostKey { + host, + fingerprint, + script, + }) => { + let reply = match interaction { + CredentialInteraction::Attended(prompter) => { + prompter + .prompt(CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: host.clone(), + algorithm: "ED25519".to_owned(), + fingerprint: fingerprint.clone(), + }, + target: host.clone(), + fields: Vec::new(), + offer_remember: false, + }) + .await + } + CredentialInteraction::Unattended => PromptReply::Cancelled, + }; + match reply { + PromptReply::Submitted { .. } => Ok(connector.adopt(request, script)), + PromptReply::Cancelled => Err(FakeEstablish::declined_host_key(&host, &fingerprint)), + } + } None => Err(ConnectError::UnknownDriver( "FakeConnector has no scripted establish result".to_owned(), )), diff --git a/linux/crates/session/src/fakes/fake_credential_source.rs b/linux/crates/session/src/fakes/fake_credential_source.rs index 5bb556d35b..444304d79b 100644 --- a/linux/crates/session/src/fakes/fake_credential_source.rs +++ b/linux/crates/session/src/fakes/fake_credential_source.rs @@ -62,10 +62,7 @@ impl CredentialSource for FakeCredentialSource { Box::pin(async move { match failure { Some(error) => Err(error), - None => Ok(RefreshedRequest { - request, - remember: Vec::new(), - }), + None => Ok(RefreshedRequest::unchanged(request)), } }) } diff --git a/linux/crates/session/src/health/health_monitor.rs b/linux/crates/session/src/health/health_monitor.rs index 34541872a2..0146f92cfa 100644 --- a/linux/crates/session/src/health/health_monitor.rs +++ b/linux/crates/session/src/health/health_monitor.rs @@ -30,6 +30,10 @@ pub(crate) struct MonitorContext { pub catalog: Arc, pub reachability: Arc>, pub close_timeout: Duration, + /// The error that parked the session, for the user to review. + pub last_rejection: Arc>>>, + /// A host key the user reviewed and trusted for their next retry. + pub trusted_host_key: Arc>>, } enum Wakeup { @@ -157,7 +161,11 @@ async fn probe(ctx: &MonitorContext) -> ProbeOutcome { /// and whatever it built is closed rather than adopted. async fn reconnect(ctx: &MonitorContext, user_asked: bool) -> Option { let plan = ctx.plan.clone(); - let interaction = plan.interaction(user_asked); + let trusted = match user_asked { + true => lock(&ctx.trusted_host_key).take(), + false => None, + }; + let interaction = plan.interaction(user_asked, trusted); let attempt_cancel = ctx.cancel.child_token(); let attempt = async move { // Only the connect has to run on after a cancel. A password @@ -195,7 +203,11 @@ async fn reconnect(ctx: &MonitorContext, user_asked: bool) -> Option established, Ok(Err(error)) => { tracing::info!(%error, "a reconnect attempt failed"); - return Some(classify_failure(&error)); + let result = classify_failure(&error); + if matches!(result, ReconnectResult::Rejected(_)) { + *lock(&ctx.last_rejection) = Some(Arc::new(error)); + } + return Some(result); } Err(failure) => { tracing::error!(%failure, "a reconnect attempt panicked"); @@ -221,7 +233,8 @@ async fn reconnect(ctx: &MonitorContext, user_asked: bool) -> Option ReconnectResult { } } +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + async fn discard_late(pending: PendingSession) { let report = pending.discard().await; tracing::debug!(?report, "closed a reconnect that finished after its session ended"); @@ -259,6 +276,7 @@ mod tests { use super::*; use crate::connect::{ConnectRequest, CredentialSource, RefreshedRequest}; use crate::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; + use crate::health::Reachability; use crate::operation::DefaultDeadlines; use crate::session::{Session, SessionDeps}; use crate::test_support::paused_tasks; @@ -291,10 +309,7 @@ mod tests { self.questions.lock().unwrap().push(question); Box::pin(async move { let _unanswered = answer.await; - Ok(RefreshedRequest { - request, - remember: Vec::new(), - }) + Ok(RefreshedRequest::unchanged(request)) }) } } @@ -421,6 +436,38 @@ mod tests { ); } + #[tokio::test(start_paused = true)] + async fn lost_then_unreachable_offline_then_reachable_reconnects_immediately() { + let connector = FakeConnector::new([FakeEstablish::Fail(refused()), FakeEstablish::Fail(refused())]) + .always(FakeEstablish::Connect(healthy())); + let started = start(healthy(), connector); + let lost_at = Instant::now(); + + started.session.signal(lost()); + settle().await; + started + .session + .signal(MonitorSignal::Reachability(Reachability::Unreachable)); + tokio::time::sleep(HealthPolicy::BACKOFF_INITIAL + Duration::from_secs(5)).await; + assert_eq!(*started.session.health().borrow(), SessionHealth::Offline); + assert_eq!(started.connector.calls().len(), 2); + + let back_at = Instant::now(); + started + .session + .signal(MonitorSignal::Reachability(Reachability::Reachable)); + settle().await; + + let calls = started.connector.calls(); + assert_eq!(calls.len(), 3, "the network came back and nothing reconnected"); + assert_eq!( + calls[2].at, back_at, + "the reconnect waited for the offline recheck instead of the network" + ); + assert!(back_at - lost_at < HealthPolicy::OFFLINE_RECHECK); + assert_eq!(*started.session.health().borrow(), SessionHealth::Online); + } + #[tokio::test(start_paused = true)] async fn health_counts_the_reconnect_attempts() { let connector = FakeConnector::new([]).always(FakeEstablish::Fail(refused())); @@ -518,6 +565,90 @@ mod tests { assert_eq!(*started.session.health().borrow(), SessionHealth::Online); } + fn ask_host_key(fingerprint: &str) -> FakeEstablish { + FakeEstablish::AskHostKey { + host: "bastion".to_owned(), + fingerprint: fingerprint.to_owned(), + script: healthy(), + } + } + + fn parked_on_unknown_host_key() -> SessionHealth { + SessionHealth::Failed { + cause: FailureCause::HostKeyUnknown, + } + } + + #[tokio::test(start_paused = true)] + async fn monitor_records_last_rejection() { + let declined = FakeEstablish::declined_host_key("bastion", "SHA256:new"); + let connector = FakeConnector::new([FakeEstablish::Fail(refused()), FakeEstablish::Fail(declined.clone())]) + .always(FakeEstablish::Connect(healthy())); + let started = start(healthy(), connector); + + started.session.signal(lost()); + settle().await; + assert_eq!( + started.session.last_rejection(), + None, + "a retryable failure was kept as a rejection" + ); + tokio::time::sleep(HealthPolicy::BACKOFF_INITIAL).await; + settle().await; + + assert_eq!(*started.session.health().borrow(), parked_on_unknown_host_key()); + assert_eq!(started.session.last_rejection().as_deref(), Some(&declined)); + + started.session.signal(MonitorSignal::RetryNow); + settle().await; + assert_eq!(*started.session.health().borrow(), SessionHealth::Online); + assert_eq!( + started.session.last_rejection(), + None, + "a rejection outlived the reconnect" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_trusted_host_key_is_not_asked_about_again() { + let prompter = Arc::new(FakePrompter::new([])); + let connector = FakeConnector::new([ask_host_key("SHA256:new"), ask_host_key("SHA256:new")]); + let started = start_with(healthy(), connector, Some(prompter.clone())); + started.session.signal(lost()); + settle().await; + assert_eq!(*started.session.health().borrow(), parked_on_unknown_host_key()); + + started.session.retry_trusting_host_key("SHA256:new".to_owned()); + settle().await; + + assert_eq!(*started.session.health().borrow(), SessionHealth::Online); + assert!( + prompter.requests().is_empty(), + "the user was asked about a key they trusted" + ); + let attended: Vec = started.connector.calls().iter().map(|call| call.attended).collect(); + assert_eq!(attended, [false, true]); + } + + #[tokio::test(start_paused = true)] + async fn a_different_host_key_on_the_trusted_retry_asks_the_user() { + let prompter = Arc::new(FakePrompter::new([])); + let connector = FakeConnector::new([ask_host_key("SHA256:new"), ask_host_key("SHA256:other")]); + let started = start_with(healthy(), connector, Some(prompter.clone())); + started.session.signal(lost()); + settle().await; + + started.session.retry_trusting_host_key("SHA256:new".to_owned()); + settle().await; + + assert_eq!(prompter.requests().len(), 1, "a key nobody reviewed was trusted"); + assert_eq!(*started.session.health().borrow(), parked_on_unknown_host_key()); + assert_eq!( + started.session.last_rejection().as_deref(), + Some(&FakeEstablish::declined_host_key("bastion", "SHA256:other")) + ); + } + #[tokio::test(start_paused = true)] async fn busy_probe_keeps_online() { let mut script = ConnectionScript::default(); diff --git a/linux/crates/session/src/health/mod.rs b/linux/crates/session/src/health/mod.rs index 36459c9626..9188d17f74 100644 --- a/linux/crates/session/src/health/mod.rs +++ b/linux/crates/session/src/health/mod.rs @@ -17,6 +17,7 @@ mod reachability_target; mod reconnect_plan; mod reconnect_result; mod session_health; +mod trusted_host_key; pub use failure_cause::FailureCause; pub(crate) use health_monitor::{MonitorContext, run_monitor}; @@ -32,3 +33,4 @@ pub use reachability_target::ReachabilityTarget; pub use reconnect_plan::ReconnectPlan; pub use reconnect_result::ReconnectResult; pub use session_health::SessionHealth; +pub(crate) use trusted_host_key::TrustedHostKey; diff --git a/linux/crates/session/src/health/reconnect_plan.rs b/linux/crates/session/src/health/reconnect_plan.rs index df560c9828..f618205739 100644 --- a/linux/crates/session/src/health/reconnect_plan.rs +++ b/linux/crates/session/src/health/reconnect_plan.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use tablepro_core::credentials::{CredentialInteraction, CredentialPrompter}; +use super::TrustedHostKey; use crate::connect::{ConnectRequest, Connector, CredentialSource}; /// How the monitor reconnects: the same request, with its secrets read @@ -17,9 +18,14 @@ pub struct ReconnectPlan { } impl ReconnectPlan { - pub(crate) fn interaction(&self, user_asked: bool) -> CredentialInteraction { - match (&self.prompter, user_asked) { - (Some(prompter), true) => CredentialInteraction::Attended(prompter.clone()), + /// `trusted` is a host key fingerprint the user reviewed before + /// asking for this attempt, answered for them when ssh shows it. + pub(crate) fn interaction(&self, user_asked: bool, trusted: Option) -> CredentialInteraction { + match (user_asked, trusted, &self.prompter) { + (true, Some(fingerprint), prompter) => { + CredentialInteraction::Attended(Arc::new(TrustedHostKey::new(fingerprint, prompter.clone()))) + } + (true, None, Some(prompter)) => CredentialInteraction::Attended(prompter.clone()), _ => CredentialInteraction::Unattended, } } diff --git a/linux/crates/session/src/health/trusted_host_key.rs b/linux/crates/session/src/health/trusted_host_key.rs new file mode 100644 index 0000000000..3a79e4eb04 --- /dev/null +++ b/linux/crates/session/src/health/trusted_host_key.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use futures::FutureExt; +use futures::future::BoxFuture; +use tablepro_core::credentials::{CredentialPrompt, CredentialPrompter, PromptPurpose, PromptReply}; + +/// The user's answer to ssh's host key question, given before ssh asks. +/// +/// The user reviewed this fingerprint from a parked session and chose to +/// trust it, so the retry they started should not ask them again. Only +/// that key is answered, and only once: ssh showing any other key, or +/// asking anything else, reaches the user as on any connect. ssh still +/// makes the decision and writes known_hosts itself. +pub(crate) struct TrustedHostKey { + fingerprint: String, + prompter: Option>, + answered: AtomicBool, +} + +impl TrustedHostKey { + pub(crate) fn new(fingerprint: String, prompter: Option>) -> Self { + Self { + fingerprint, + prompter, + answered: AtomicBool::new(false), + } + } + + fn answers(&self, request: &CredentialPrompt) -> bool { + let PromptPurpose::SshHostKeyConfirmation { fingerprint, .. } = &request.purpose else { + return false; + }; + !fingerprint.is_empty() && *fingerprint == self.fingerprint && !self.answered.swap(true, Ordering::AcqRel) + } +} + +impl CredentialPrompter for TrustedHostKey { + fn prompt(&self, request: CredentialPrompt) -> BoxFuture<'static, PromptReply> { + if self.answers(&request) { + return futures::future::ready(PromptReply::Submitted { + values: Vec::new(), + remember: false, + }) + .boxed(); + } + match &self.prompter { + Some(prompter) => prompter.prompt(request), + None => futures::future::ready(PromptReply::Cancelled).boxed(), + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_test_support::FakePrompter; + + use super::*; + + fn host_key(fingerprint: &str) -> CredentialPrompt { + CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: fingerprint.to_owned(), + }, + target: "bastion".to_owned(), + fields: Vec::new(), + offer_remember: false, + } + } + + fn accepted(reply: &PromptReply) -> bool { + matches!(reply, PromptReply::Submitted { .. }) + } + + #[tokio::test] + async fn the_reviewed_key_is_answered_once_and_anything_else_asks() { + let user = Arc::new(FakePrompter::new([])); + let trusted = TrustedHostKey::new("SHA256:reviewed".to_owned(), Some(user.clone())); + + let other = trusted.prompt(host_key("SHA256:other")).await; + let reviewed = trusted.prompt(host_key("SHA256:reviewed")).await; + let again = trusted.prompt(host_key("SHA256:reviewed")).await; + + assert!(!accepted(&other), "a key nobody reviewed was trusted"); + assert!(accepted(&reviewed)); + assert!(!accepted(&again), "one review answered twice"); + assert_eq!(user.requests().len(), 2, "the user was not asked about the rest"); + } + + #[tokio::test] + async fn an_empty_fingerprint_is_never_answered() { + let trusted = TrustedHostKey::new(String::new(), None); + + assert!(!accepted(&trusted.prompt(host_key("")).await)); + } +} diff --git a/linux/crates/session/src/registry/session_registry.rs b/linux/crates/session/src/registry/session_registry.rs index dd7a3064ad..b016aac5ed 100644 --- a/linux/crates/session/src/registry/session_registry.rs +++ b/linux/crates/session/src/registry/session_registry.rs @@ -56,6 +56,10 @@ impl SessionRegistry { self.entries.borrow().get(key).map(|entry| entry.window_id) } + pub fn session(&self, key: &SessionKey) -> Option> { + self.entries.borrow().get(key)?.session.as_ref()?.upgrade() + } + pub fn attached_sessions(&self) -> Vec<(SessionKey, Arc)> { self.entries .borrow() @@ -215,9 +219,15 @@ mod tests { claim.attach(&session); assert_eq!(registry.live_count(), 1); assert_eq!(registry.attached_sessions().len(), 1); + assert!( + registry + .session(&key) + .is_some_and(|found| Arc::ptr_eq(&found, &session)) + ); drop(claim); assert_eq!(registry.live_count(), 0); + assert!(registry.session(&key).is_none()); assert_eq!( *events.borrow(), [ diff --git a/linux/crates/session/src/session/session.rs b/linux/crates/session/src/session/session.rs index cb363216c9..d6dfed2244 100644 --- a/linux/crates/session/src/session/session.rs +++ b/linux/crates/session/src/session/session.rs @@ -11,7 +11,7 @@ use tokio_util::task::AbortOnDropHandle; use uuid::Uuid; use super::{ObjectListSnapshot, SchemaCatalog, SessionDeps}; -use crate::connect::{ConnectionMetadata, PendingSession}; +use crate::connect::{ConnectError, ConnectionMetadata, PendingSession}; use crate::health::{ LinkGeneration, MonitorContext, MonitorMailbox, MonitorSignal, ReachabilityTarget, ReconnectPlan, SessionHealth, run_monitor, @@ -30,6 +30,8 @@ pub struct Session { metadata: ConnectionMetadata, workspace_settings: WorkspaceSettings, reachability: Arc>, + last_rejection: Arc>>>, + trusted_host_key: Arc>>, runner: OperationRunner, mailbox: Arc, health: watch::Receiver, @@ -54,6 +56,8 @@ impl Session { &parts.established, ))); let link = Arc::new(ConnectionLink::new(parts.established)); + let last_rejection = Arc::new(Mutex::new(None)); + let trusted_host_key = Arc::new(Mutex::new(None)); let mailbox = Arc::new(MonitorMailbox::new()); let (health_sender, health) = watch::channel(SessionHealth::Online); let cancel = CancellationToken::new(); @@ -86,12 +90,16 @@ impl Session { catalog: catalog.clone(), reachability: reachability.clone(), close_timeout, + last_rejection: last_rejection.clone(), + trusted_host_key: trusted_host_key.clone(), }; let monitor = deps.tasks.spawn_owned(run_monitor(context)); Arc::new(Session { metadata: parts.request.metadata, workspace_settings: parts.request.workspace_settings, reachability, + last_rejection, + trusted_host_key, runner, mailbox, health, @@ -192,6 +200,24 @@ impl Session { self.mailbox.post(signal); } + /// Why the last reconnect was refused, kept until one succeeds. A + /// parked session's banner reads it to say what the user can do. + pub fn last_rejection(&self) -> Option> { + self.last_rejection + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Retry, answering ssh's question about the host key `fingerprint` + /// for the user, who reviewed it. A different key, or any other + /// question, still reaches them; ssh records the key in known_hosts + /// as it does when the user answers. + pub fn retry_trusting_host_key(&self, fingerprint: String) { + *self.trusted_host_key.lock().unwrap_or_else(PoisonError::into_inner) = Some(fingerprint); + self.mailbox.post(MonitorSignal::RetryNow); + } + pub fn runner(&self) -> &OperationRunner { &self.runner } diff --git a/linux/crates/session/tests/session_postgres.rs b/linux/crates/session/tests/session_postgres.rs index fa4a33d65b..0a8693e628 100644 --- a/linux/crates/session/tests/session_postgres.rs +++ b/linux/crates/session/tests/session_postgres.rs @@ -101,6 +101,7 @@ fn isolated_ssh() -> IsolatedSsh { ssh_program: wrapper, ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), + config_file: None, }; IsolatedSsh { dir, services } } diff --git a/linux/crates/ssh/src/argv.rs b/linux/crates/ssh/src/argv.rs index 441791cbfb..9467778565 100644 --- a/linux/crates/ssh/src/argv.rs +++ b/linux/crates/ssh/src/argv.rs @@ -38,13 +38,21 @@ pub(crate) enum ControlOp<'a> { Exit, } +/// `config_file` is the ssh_config the master and every jump host's ssh +/// read in place of OpenSSH's own, which ssh.c hands on to each hop. pub(crate) fn master_args( config: &SshConfig, control: &Path, liveness: &LivenessPolicy, host_keys: Option, + config_file: Option<&Path>, ) -> Vec { - let mut args: Vec = ["-M", "-N", "-T"].into_iter().map(OsString::from).collect(); + let mut args: Vec = Vec::new(); + if let Some(config_file) = config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } + args.extend(["-M", "-N", "-T"].into_iter().map(OsString::from)); push_option(&mut args, control_path_option(control)); for option in &FORCED_OPTIONS[..FORCED_OPTIONS.len() - 1] { push_option(&mut args, OsString::from(option)); @@ -174,6 +182,7 @@ mod tests { Path::new("/run/user/1000/app/ssh/0123456789abcdef/89abcdef/control"), &LivenessPolicy::DESKTOP, None, + None, ); assert_eq!( strings(&args), @@ -250,6 +259,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, host_keys, + None, ); let strict: Vec<&str> = strings(&args) .into_iter() @@ -268,6 +278,7 @@ mod tests { control, &LivenessPolicy::DESKTOP, None, + None, )) .join(" "); assert!(!without.contains(" -p ") && !without.contains(" -l ")); @@ -276,6 +287,7 @@ mod tests { control, &LivenessPolicy::DESKTOP, None, + None, ); assert_eq!( strings(&with[with.len() - 6..]), @@ -287,13 +299,33 @@ mod tests { fn jump_hosts_render_one_minus_j() { let mut with_jumps = config(None, None, SshAuth::Agent); with_jumps.jump_hosts = SshDestination::parse_jump_list("ops@jump1:2200,[fd00::1]").unwrap(); - let args = master_args(&with_jumps, Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None); + let args = master_args( + &with_jumps, + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + None, + None, + ); let args = strings(&args); assert_eq!(args.iter().filter(|arg| **arg == "-J").count(), 1); let position = args.iter().position(|arg| *arg == "-J").unwrap(); assert_eq!(args[position + 1], "ops@jump1:2200,[fd00::1]"); } + #[test] + fn a_config_file_comes_first_so_every_hop_gets_it() { + let args = master_args( + &config(None, None, SshAuth::Agent), + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + None, + Some(Path::new("/run/user/1000/m/ssh_config")), + ); + + assert_eq!(strings(&args)[..3], ["-F", "/run/user/1000/m/ssh_config", "-M"]); + assert_eq!(strings(&args).iter().filter(|arg| **arg == "-F").count(), 1); + } + #[test] fn private_key_path_adds_minus_i() { let auth = SshAuth::PrivateKey { @@ -305,6 +337,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); let joined = strings(&args).join(" "); assert!( @@ -321,6 +354,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); assert!(!strings(&args).contains(&"-i")); } @@ -346,6 +380,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); let preferred = strings(&args) .into_iter() diff --git a/linux/crates/ssh/src/destination.rs b/linux/crates/ssh/src/destination.rs index 2dd14dd997..59379abd08 100644 --- a/linux/crates/ssh/src/destination.rs +++ b/linux/crates/ssh/src/destination.rs @@ -95,6 +95,10 @@ impl fmt::Display for SshDestination { /// command that `sh -c` runs, so one of these would run as a command. const SHELL_CHARACTERS: &[char] = &['\'', '`', '"', '$', ';', '&', '<', '>', '|', '(', ')', '{', '}', ',']; +/// What the shell globs in that proxy command, and where OpenSSH cuts a +/// ProxyJump value as a comment. +const GLOB_AND_COMMENT_CHARACTERS: &[char] = &['*', '?', '[', ']', '#']; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Part { Host, @@ -114,6 +118,12 @@ impl Part { fn allows_backslash(self) -> bool { self == Self::User } + + /// A jump host whose own configuration would trust a key without + /// asking is named in a `Host` line, where `!` negates the pattern. + fn allows_bang(self) -> bool { + self == Self::User + } } fn check_part(part: Part, value: &str) -> Result<(), SshFailure> { @@ -127,7 +137,9 @@ fn check_part(part: Part, value: &str) -> Result<(), SshFailure> { if value.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(invalid(&format!("the {name} contains spaces or control characters"))); } - if let Some(found) = value.chars().find(|c| SHELL_CHARACTERS.contains(c)) { + if let Some(found) = value.chars().find(|c| { + SHELL_CHARACTERS.contains(c) || GLOB_AND_COMMENT_CHARACTERS.contains(c) || (*c == '!' && !part.allows_bang()) + }) { return Err(invalid(&format!("the {name} contains '{found}'"))); } // A trailing backslash would escape whatever ssh appends after it. @@ -169,6 +181,12 @@ mod tests { SshDestination::new("bastion\\x", None, None), SshDestination::new("bastion", None, Some("ops'x".to_owned())), SshDestination::new("bastion", None, Some("ops\\".to_owned())), + SshDestination::new("jump*", None, None), + SshDestination::new("jump?", None, None), + SshDestination::new("!jump", None, None), + SshDestination::new("jump#1", None, None), + SshDestination::new("bastion", None, Some("#ops".to_owned())), + SshDestination::new("bastion", None, Some("ops*".to_owned())), ]; for result in rejected { assert!( diff --git a/linux/crates/ssh/src/first_hop.rs b/linux/crates/ssh/src/first_hop.rs deleted file mode 100644 index f6abd116e9..0000000000 --- a/linux/crates/ssh/src/first_hop.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; - -use tablepro_core::NetworkEndpoint; -use tokio::process::Command; - -use crate::SshDestination; - -/// `ssh -G` does no network I/O, but a `Match exec` line can run a -/// command, so each lookup is still bounded. -const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); -/// A ProxyJump chain whose hops name each other never ends; real chains -/// are a few hops long. -const MAX_HOPS: usize = 8; -const DEFAULT_PORT: u16 = 22; - -#[derive(Debug, PartialEq, Eq)] -enum Resolved { - Direct(NetworkEndpoint), - Jump(SshDestination), - ProxyCommand, -} - -/// The host and port the first TCP connection of a session goes to, -/// from what `ssh -G` printed for the session's destination. -/// -/// An ssh_config alias is only a name until `ssh -G` resolves it, and a -/// jump chain starts at its first hop, so this asks ssh rather than -/// trusting what the user typed. `None` when the route is a -/// ProxyCommand, whose first hop only that command knows. -pub(crate) async fn resolve_first_hop(ssh_program: &Path, destination: Option<&str>) -> Option { - let mut output = destination?.to_owned(); - for _ in 0..MAX_HOPS { - match parse_resolved(&output)? { - Resolved::Direct(endpoint) => return Some(endpoint), - Resolved::ProxyCommand => return None, - Resolved::Jump(first) => output = lookup(ssh_program, &first, &[]).await?, - } - } - tracing::debug!("the ssh jump chain did not end"); - None -} - -pub(crate) async fn lookup( - ssh_program: &Path, - destination: &SshDestination, - jumps: &[SshDestination], -) -> Option { - let mut args: Vec = vec!["-G".into()]; - if !jumps.is_empty() { - let hops: Vec = jumps.iter().map(ToString::to_string).collect(); - args.push("-J".into()); - args.push(hops.join(",").into()); - } - if let Some(port) = destination.port() { - args.push("-p".into()); - args.push(port.to_string().into()); - } - if let Some(user) = destination.user() { - args.push("-l".into()); - args.push(user.into()); - } - args.push("--".into()); - args.push(destination.host().into()); - - let run = Command::new(ssh_program) - .args(&args) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .output(); - match tokio::time::timeout(LOOKUP_TIMEOUT, run).await { - Ok(Ok(output)) if output.status.success() => Some(String::from_utf8_lossy(&output.stdout).into_owned()), - Ok(Ok(output)) => { - tracing::debug!(status = ?output.status.code(), "ssh -G could not resolve the first hop"); - None - } - Ok(Err(error)) => { - tracing::debug!(%error, "could not run ssh -G"); - None - } - Err(_) => { - tracing::debug!("ssh -G did not finish"); - None - } - } -} - -fn parse_resolved(ssh_g_output: &str) -> Option { - let mut hostname = None; - let mut port = None; - let mut proxy_jump = None; - let mut proxy_command = false; - for line in ssh_g_output.lines() { - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let value = value.trim(); - match key { - "hostname" => hostname = Some(value), - "port" => port = value.parse::().ok(), - "proxyjump" if value != "none" => proxy_jump = Some(value), - "proxycommand" if value != "none" => proxy_command = true, - _ => {} - } - } - if let Some(chain) = proxy_jump { - let first = chain.split(',').next()?.trim(); - let first = first.strip_prefix("ssh://").unwrap_or(first); - let mut hops = SshDestination::parse_jump_list(first).ok()?; - return hops.pop().map(Resolved::Jump); - } - if proxy_command { - return Some(Resolved::ProxyCommand); - } - NetworkEndpoint::new(hostname?, port.unwrap_or(DEFAULT_PORT)) - .ok() - .map(Resolved::Direct) -} - -#[cfg(test)] -mod tests { - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - - use super::*; - - fn endpoint(host: &str, port: u16) -> NetworkEndpoint { - NetworkEndpoint::new(host, port).unwrap() - } - - #[test] - fn a_direct_destination_resolves_to_its_hostname_and_port() { - let output = "user deploy\nhostname 192.0.2.10\nport 2222\n"; - - assert_eq!( - parse_resolved(output), - Some(Resolved::Direct(endpoint("192.0.2.10", 2222))) - ); - } - - #[test] - fn a_jump_chain_starts_at_its_first_hop() { - let output = "hostname db.internal\nport 22\nproxyjump ops@jump.example.com:2200,second\n"; - - assert_eq!( - parse_resolved(output), - Some(Resolved::Jump( - SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap() - )) - ); - } - - #[test] - fn a_uri_jump_and_an_ipv6_jump_parse() { - let uri = "hostname t\nproxyjump ssh://ops@jump.example.com:2200\n"; - let ipv6 = "hostname t\nproxyjump [::1]:2201\n"; - - assert_eq!( - parse_resolved(uri), - Some(Resolved::Jump( - SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap() - )) - ); - assert_eq!( - parse_resolved(ipv6), - Some(Resolved::Jump(SshDestination::new("::1", Some(2201), None).unwrap())) - ); - } - - #[test] - fn a_proxy_command_has_no_known_first_hop() { - let output = "hostname t\nport 22\nproxycommand nc %h %p\n"; - - assert_eq!(parse_resolved(output), Some(Resolved::ProxyCommand)); - } - - #[test] - fn none_values_are_not_proxies() { - let output = "hostname t\nport 22\nproxyjump none\nproxycommand none\n"; - - assert_eq!(parse_resolved(output), Some(Resolved::Direct(endpoint("t", 22)))); - } - - fn fake_ssh(dir: &Path, script: &str) -> PathBuf { - let program = dir.join("ssh"); - std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap(); - std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); - program - } - - async fn first_hop(program: &Path, host: &str, jumps: &[&str]) -> Option { - let destination = SshDestination::new(host, None, None).unwrap(); - let jumps: Vec = jumps - .iter() - .map(|jump| SshDestination::new(*jump, None, None).unwrap()) - .collect(); - let resolved = lookup(program, &destination, &jumps).await; - resolve_first_hop(program, resolved.as_deref()).await - } - - /// The fake prints a jump for the database host and resolves the - /// alias the jump names, the way an ssh_config would. - #[tokio::test] - async fn an_alias_behind_a_jump_resolves_through_ssh_g() { - let temp = tempfile::tempdir().unwrap(); - let program = fake_ssh( - temp.path(), - r#"for last; do :; done -case "$last" in - db) printf 'hostname 10.0.0.5\nport 5432\nproxyjump bastion\n' ;; - bastion) printf 'hostname 192.0.2.10\nport 2222\n' ;; - *) exit 255 ;; -esac"#, - ); - - let first = first_hop(&program, "db", &["bastion"]).await; - - assert_eq!(first, Some(endpoint("192.0.2.10", 2222))); - } - - #[tokio::test] - async fn a_failing_lookup_and_a_jump_loop_resolve_to_nothing() { - let temp = tempfile::tempdir().unwrap(); - let failing = fake_ssh(temp.path(), "exit 255"); - assert_eq!(first_hop(&failing, "db", &[]).await, None); - - let looping = tempfile::tempdir().unwrap(); - let program = fake_ssh(looping.path(), r"printf 'hostname x\nproxyjump loop\n'"); - assert_eq!(first_hop(&program, "loop", &[]).await, None); - } -} diff --git a/linux/crates/ssh/src/hop_config.rs b/linux/crates/ssh/src/hop_config.rs new file mode 100644 index 0000000000..13a6823c5c --- /dev/null +++ b/linux/crates/ssh/src/hop_config.rs @@ -0,0 +1,232 @@ +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +use tablepro_core::SshFailure; + +use crate::host_key_checking::HostKeyChecking; +use crate::route::JumpHop; + +/// What OpenSSH reads after the user's file when it is given no `-F` +/// (`process_config_files` in ssh.c). +const SYSTEM_CONFIG: &str = "/etc/ssh/ssh_config"; +const FILE_NAME: &str = "ssh_config"; + +/// The jump hosts whose own configuration would trust a host key the +/// user never saw, which the destination's `StrictHostKeyChecking=ask` +/// on the master's command line does not reach. +pub(crate) fn hosts_to_pin(hops: &[JumpHop]) -> Vec<&str> { + let mut hosts: Vec<&str> = hops + .iter() + .filter(|hop| matches!(hop.host_keys, Some(HostKeyChecking::AcceptNew | HostKeyChecking::No))) + .map(|hop| hop.destination.host()) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts +} + +/// An ssh_config that asks before trusting the key of each host in +/// `hosts`, then reads what ssh would have read without it. +/// +/// ssh.c runs a jump host through a second ssh that gets only `-l`, +/// `-p`, `-J` and `-F` from the master's command line, so the master's +/// `-F` is the only way to reach it. The first value ssh_config gives +/// wins, so the pins come first. `base` is the file the master would +/// otherwise be given with `-F`. +pub(crate) fn render(hosts: &[impl AsRef], base: Option<&Path>) -> Result, SshFailure> { + render_over(hosts, base, Path::new(SYSTEM_CONFIG)) +} + +/// OpenSSH holds every `Include` to an owner check that it skips for +/// the `-F` file and the system file. In a Flatpak sandbox root's files +/// belong to nobody, so those two are copied in rather than included. +/// The user's file gets the same check either way. +fn render_over(hosts: &[impl AsRef], base: Option<&Path>, system_config: &Path) -> Result, SshFailure> { + let mut text = Vec::new(); + for host in hosts { + let host = host.as_ref(); + text.extend_from_slice(format!("Host {host}\n StrictHostKeyChecking ask\n").as_bytes()); + } + text.extend_from_slice(b"Match all\n"); + match base { + Some(base) => { + let contents = std::fs::read(base).map_err(|error| SshFailure::ConfigRejected { + detail: format!("{}: {error}", base.display()), + })?; + text.extend_from_slice(&contents); + } + None => { + text.extend_from_slice(b"Include ~/.ssh/config\n"); + // ssh skips a system file it cannot open, so this does too. + match std::fs::read(system_config) { + Ok(contents) => text.extend_from_slice(&contents), + Err(error) => tracing::debug!(%error, "the system ssh_config could not be read"), + } + } + } + Ok(text) +} + +/// Writes the file into the master's private directory. +/// +/// OpenSSH pastes the path into the `sh -c` line it builds for each hop +/// and expands `%` tokens in it first, so a path that is anything other +/// than one plain shell word is refused rather than run. +pub(crate) fn write(master_dir: &Path, contents: &[u8]) -> Result { + let path = master_dir.join(FILE_NAME); + let unsafe_dir = |detail: String| SshFailure::RuntimeDirUnsafe { + path: path.clone(), + detail, + }; + if !plain_shell_word(&path) { + return Err(unsafe_dir( + "a jump host's ssh cannot be given a path with spaces or shell characters".to_owned(), + )); + } + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .and_then(|mut file| file.write_all(contents)) + .map_err(|error| unsafe_dir(error.to_string()))?; + Ok(path) +} + +fn plain_shell_word(path: &Path) -> bool { + path.as_os_str() + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || b"/._-+@:=,".contains(byte)) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + use crate::SshDestination; + + fn hop(host: &str, host_keys: Option) -> JumpHop { + JumpHop { + destination: SshDestination::new(host, None, None).unwrap(), + host_keys, + } + } + + #[test] + fn only_hosts_whose_configuration_skips_the_dialog_are_pinned() { + let hops = [ + hop("j3", Some(HostKeyChecking::No)), + hop("j2", Some(HostKeyChecking::Yes)), + hop("j1", Some(HostKeyChecking::AcceptNew)), + hop("j0", Some(HostKeyChecking::Ask)), + hop("unknown", None), + hop("j1", Some(HostKeyChecking::AcceptNew)), + ]; + + assert_eq!(hosts_to_pin(&hops), ["j1", "j3"]); + } + + fn file(dir: &Path, name: &str, contents: &str, mode: u32) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path + } + + #[test] + fn the_pins_come_before_what_ssh_would_have_read() { + let dir = tempfile::tempdir().unwrap(); + let system = file(dir.path(), "system", "SendEnv LANG\n", 0o644); + let base = file(dir.path(), "base", "Host *\n User ops\n", 0o644); + + assert_eq!( + String::from_utf8(render_over(&["j1", "fd00::1"], None, &system).unwrap()).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nHost fd00::1\n StrictHostKeyChecking ask\nMatch all\n\ + Include ~/.ssh/config\nSendEnv LANG\n" + ); + assert_eq!( + String::from_utf8(render_over(&["j1"], Some(&base), &system).unwrap()).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nMatch all\nHost *\n User ops\n" + ); + assert_eq!( + String::from_utf8(render_over(&["j1"], None, &dir.path().join("missing")).unwrap()).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nMatch all\nInclude ~/.ssh/config\n" + ); + } + + #[test] + fn a_base_file_ssh_could_not_open_is_refused() { + let dir = tempfile::tempdir().unwrap(); + + assert!(matches!( + render(&["j1"], Some(&dir.path().join("missing"))), + Err(SshFailure::ConfigRejected { .. }) + )); + } + + /// A system file anyone can write fails the owner check the way + /// root's file does in a Flatpak sandbox, where it belongs to nobody. + /// ssh reads it on its own all the same, so the pinned file has to as + /// well. + #[test] + fn ssh_reads_the_pinned_file_over_a_system_file_it_would_not_include() { + let dir = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let system = file( + dir.path(), + "system", + "Host *\n StrictHostKeyChecking accept-new\n Port 2299\n", + 0o666, + ); + let master_dir = dir.path().join("master"); + std::fs::create_dir(&master_dir).unwrap(); + let pinned = write(&master_dir, &render_over(&["j1"], None, &system).unwrap()).unwrap(); + + let resolve = |host: &str| { + let output = std::process::Command::new("ssh") + .arg("-G") + .arg("-F") + .arg(&pinned) + .arg(host) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap() + }; + + let jump = resolve("j1"); + let other = resolve("other"); + assert!(jump.lines().any(|line| line == "stricthostkeychecking ask"), "{jump}"); + assert!(jump.lines().any(|line| line == "port 2299"), "{jump}"); + assert!( + other.lines().any(|line| line == "stricthostkeychecking accept-new"), + "{other}" + ); + } + + #[test] + fn the_file_is_private_and_its_path_one_shell_word() { + let dir = tempfile::tempdir().unwrap(); + + let path = write(dir.path(), b"Match all\n").unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "Match all\n"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let spaced = dir.path().join("with space"); + std::fs::create_dir(&spaced).unwrap(); + assert!(matches!( + write(&spaced, b"Match all\n"), + Err(SshFailure::RuntimeDirUnsafe { .. }) + )); + assert!(!plain_shell_word(Path::new("/run/user/1000/%d"))); + assert!(!plain_shell_word(Path::new("/run/user/1000/$(id)"))); + assert!(plain_shell_word(Path::new( + "/run/user/1000/app/app.tablepro.TablePro/ssh/0123/89ab/ssh_config" + ))); + } +} diff --git a/linux/crates/ssh/src/known_hosts.rs b/linux/crates/ssh/src/known_hosts.rs index 0871191730..96e4f44a77 100644 --- a/linux/crates/ssh/src/known_hosts.rs +++ b/linux/crates/ssh/src/known_hosts.rs @@ -10,6 +10,10 @@ use crate::{SshConfig, SshServices}; pub async fn forget_host_key(services: &SshServices, config: &SshConfig, known_hosts: &Path) -> Result<(), SshFailure> { let mut args: Vec = vec!["-G".into()]; + if let Some(config_file) = &services.config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } if let Some(port) = config.destination.port() { args.push("-p".into()); args.push(port.to_string().into()); @@ -25,6 +29,18 @@ pub async fn forget_host_key(services: &SshServices, config: &SshConfig, known_h let lookup = known_hosts_lookup(&resolved).ok_or_else(|| SshFailure::ConfigRejected { detail: "ssh -G printed no hostname".to_owned(), })?; + forget_known_host(services, &lookup, known_hosts).await +} + +/// Remove every key `known_hosts` holds for `lookup`, the name ssh looks +/// the host up by: `host`, `[host]:port` or a HostKeyAlias, as ssh +/// prints it when a key changes. +pub async fn forget_known_host(services: &SshServices, lookup: &str, known_hosts: &Path) -> Result<(), SshFailure> { + if lookup.trim().is_empty() { + return Err(SshFailure::InvalidDestination { + detail: "no host to remove from known_hosts".to_owned(), + }); + } let keygen_args: Vec = vec![ "-R".into(), lookup.into(), @@ -107,6 +123,47 @@ mod tests { assert_eq!(known_hosts_lookup("port 22\n"), None); } + fn services(dir: &Path) -> SshServices { + SshServices { + runtime: std::sync::Arc::new(crate::SshRuntimeCell::new(dir.join("runtime"))), + ssh_program: "ssh".into(), + ssh_keygen_program: "ssh-keygen".into(), + askpass_program: "tablepro-askpass".into(), + config_file: None, + } + } + + const KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"; + + #[tokio::test] + async fn forget_known_host_removes_only_that_host() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write( + &known_hosts, + format!("[db.example.com]:2222 {KEY}\ndb.example.com {KEY}\n"), + ) + .unwrap(); + + forget_known_host(&services(dir.path()), "[db.example.com]:2222", &known_hosts) + .await + .unwrap(); + + let left = std::fs::read_to_string(&known_hosts).unwrap(); + assert!(!left.contains("[db.example.com]:2222"), "{left}"); + assert!(left.contains(&format!("db.example.com {KEY}")), "{left}"); + } + + #[tokio::test] + async fn forget_known_host_refuses_an_empty_name() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + + let refused = forget_known_host(&services(dir.path()), " ", &known_hosts).await; + + assert!(matches!(refused, Err(SshFailure::InvalidDestination { .. }))); + } + #[test] fn missing_program_is_open_ssh_client_missing() { let error = io::Error::from(io::ErrorKind::NotFound); diff --git a/linux/crates/ssh/src/lib.rs b/linux/crates/ssh/src/lib.rs index deba84c29f..74f6f5d834 100644 --- a/linux/crates/ssh/src/lib.rs +++ b/linux/crates/ssh/src/lib.rs @@ -4,12 +4,13 @@ mod argv; mod askpass_bridge; mod config; mod destination; -mod first_hop; mod forward; +mod hop_config; mod host_key_checking; mod known_hosts; mod password_owner; mod prompt; +mod route; mod runtime; mod runtime_cell; mod services; @@ -21,7 +22,7 @@ mod sweep; pub use config::{SshAuth, SshConfig}; pub use destination::SshDestination; pub use forward::SshForwardTransport; -pub use known_hosts::forget_host_key; +pub use known_hosts::{forget_host_key, forget_known_host}; pub use prompt::AskpassPrompt; pub use runtime::SshRuntime; pub use runtime_cell::SshRuntimeCell; diff --git a/linux/crates/ssh/src/route.rs b/linux/crates/ssh/src/route.rs new file mode 100644 index 0000000000..2d0f5393f4 --- /dev/null +++ b/linux/crates/ssh/src/route.rs @@ -0,0 +1,356 @@ +use std::ffi::OsString; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tablepro_core::NetworkEndpoint; +use tokio::process::Command; + +use crate::SshDestination; +use crate::host_key_checking::HostKeyChecking; + +/// `ssh -G` does no network I/O, but a `Match exec` line can run a +/// command, so each lookup is still bounded. +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); +/// A ProxyJump chain whose hops name each other never ends; real chains +/// are a few hops long. +const MAX_HOPS: usize = 8; +const DEFAULT_PORT: u16 = 22; + +#[derive(Debug, PartialEq, Eq)] +enum Resolved { + Direct(NetworkEndpoint), + /// The ProxyJump chain, first hop first. + Jump(Vec), + ProxyCommand, +} + +/// One jump host the master goes through, with the StrictHostKeyChecking +/// the ssh OpenSSH starts for it reads from the configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct JumpHop { + pub(crate) destination: SshDestination, + pub(crate) host_keys: Option, +} + +/// The jump hosts between this machine and the destination, as far as +/// `ssh -G` could follow them, and where the first TCP connection goes. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Route { + pub(crate) hops: Vec, + /// `None` when the route starts at a ProxyCommand, whose first hop + /// only that command knows, or could not be followed. + pub(crate) first_hop: Option, +} + +/// Follow the route from what `ssh -G` printed for the destination. +/// +/// An ssh_config alias is only a name until `ssh -G` resolves it, so +/// this asks ssh rather than trusting what the user typed. ssh.c runs +/// the last hop of a ProxyJump chain through a second ssh, given the +/// earlier hops as its own `-J`, so each hop is looked up the same way, +/// and only the first hop's own ProxyJump from the configuration applies. +pub(crate) async fn trace_route(ssh_program: &Path, config_file: Option<&Path>, destination: Option<&str>) -> Route { + let mut route = Route::default(); + let Some(mut output) = destination.map(str::to_owned) else { + return route; + }; + loop { + let chain = match parse_resolved(&output) { + Some(Resolved::Direct(endpoint)) => { + route.first_hop = Some(endpoint); + return route; + } + Some(Resolved::Jump(chain)) => chain, + Some(Resolved::ProxyCommand) | None => return route, + }; + for index in (0..chain.len()).rev() { + if route.hops.len() == MAX_HOPS { + tracing::debug!("the ssh jump chain did not end"); + return route; + } + let Some(resolved) = lookup(ssh_program, config_file, &chain[index], &chain[..index]).await else { + return route; + }; + route.hops.push(JumpHop { + destination: chain[index].clone(), + host_keys: HostKeyChecking::from_ssh_g(&resolved), + }); + output = resolved; + } + } +} + +pub(crate) async fn lookup( + ssh_program: &Path, + config_file: Option<&Path>, + destination: &SshDestination, + jumps: &[SshDestination], +) -> Option { + let mut args: Vec = vec!["-G".into()]; + if let Some(config_file) = config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } + if !jumps.is_empty() { + let hops: Vec = jumps.iter().map(ToString::to_string).collect(); + args.push("-J".into()); + args.push(hops.join(",").into()); + } + if let Some(port) = destination.port() { + args.push("-p".into()); + args.push(port.to_string().into()); + } + if let Some(user) = destination.user() { + args.push("-l".into()); + args.push(user.into()); + } + args.push("--".into()); + args.push(destination.host().into()); + + let run = Command::new(ssh_program) + .args(&args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(LOOKUP_TIMEOUT, run).await { + Ok(Ok(output)) if output.status.success() => Some(String::from_utf8_lossy(&output.stdout).into_owned()), + Ok(Ok(output)) => { + tracing::debug!(status = ?output.status.code(), "ssh -G could not resolve a host"); + None + } + Ok(Err(error)) => { + tracing::debug!(%error, "could not run ssh -G"); + None + } + Err(_) => { + tracing::debug!("ssh -G did not finish"); + None + } + } +} + +fn parse_resolved(ssh_g_output: &str) -> Option { + let mut hostname = None; + let mut port = None; + let mut proxy_jump = None; + let mut proxy_command = false; + for line in ssh_g_output.lines() { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let value = value.trim(); + match key { + "hostname" => hostname = Some(value), + "port" => port = value.parse::().ok(), + "proxyjump" if value != "none" => proxy_jump = Some(value), + "proxycommand" if value != "none" => proxy_command = true, + _ => {} + } + } + if let Some(chain) = proxy_jump { + let hops: Option> = chain + .split(',') + .map(|hop| { + let hop = hop.trim(); + let hop = hop.strip_prefix("ssh://").unwrap_or(hop); + SshDestination::parse_jump_list(hop).ok()?.pop() + }) + .collect(); + return hops.filter(|hops| !hops.is_empty()).map(Resolved::Jump); + } + if proxy_command { + return Some(Resolved::ProxyCommand); + } + NetworkEndpoint::new(hostname?, port.unwrap_or(DEFAULT_PORT)) + .ok() + .map(Resolved::Direct) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + + use super::*; + + fn endpoint(host: &str, port: u16) -> NetworkEndpoint { + NetworkEndpoint::new(host, port).unwrap() + } + + fn hop(host: &str) -> SshDestination { + SshDestination::new(host, None, None).unwrap() + } + + #[test] + fn a_direct_destination_resolves_to_its_hostname_and_port() { + let output = "user deploy\nhostname 192.0.2.10\nport 2222\n"; + + assert_eq!( + parse_resolved(output), + Some(Resolved::Direct(endpoint("192.0.2.10", 2222))) + ); + } + + #[test] + fn a_jump_chain_lists_every_hop_first_hop_first() { + let output = "hostname db.internal\nport 22\nproxyjump ops@jump.example.com:2200,second\n"; + + assert_eq!( + parse_resolved(output), + Some(Resolved::Jump(vec![ + SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap(), + hop("second"), + ])) + ); + } + + #[test] + fn a_uri_jump_and_an_ipv6_jump_parse() { + let uri = "hostname t\nproxyjump ssh://ops@jump.example.com:2200,ssh://second\n"; + let ipv6 = "hostname t\nproxyjump [::1]:2201\n"; + + assert_eq!( + parse_resolved(uri), + Some(Resolved::Jump(vec![ + SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap(), + hop("second"), + ])) + ); + assert_eq!( + parse_resolved(ipv6), + Some(Resolved::Jump(vec![ + SshDestination::new("::1", Some(2201), None).unwrap() + ])) + ); + } + + #[test] + fn a_hop_that_does_not_parse_leaves_the_route_unknown() { + assert_eq!(parse_resolved("hostname t\nproxyjump jump1,,jump2\n"), None); + } + + #[test] + fn a_proxy_command_has_no_known_first_hop() { + let output = "hostname t\nport 22\nproxycommand nc %h %p\n"; + + assert_eq!(parse_resolved(output), Some(Resolved::ProxyCommand)); + } + + #[test] + fn none_values_are_not_proxies() { + let output = "hostname t\nport 22\nproxyjump none\nproxycommand none\n"; + + assert_eq!(parse_resolved(output), Some(Resolved::Direct(endpoint("t", 22)))); + } + + fn fake_ssh(dir: &Path, script: &str) -> PathBuf { + let program = dir.join("ssh"); + std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + program + } + + async fn route(program: &Path, host: &str, jumps: &[&str]) -> Route { + let destination = hop(host); + let jumps: Vec = jumps.iter().map(|jump| hop(jump)).collect(); + let resolved = lookup(program, None, &destination, &jumps).await; + trace_route(program, None, resolved.as_deref()).await + } + + /// The fake prints a jump for the database host and resolves the + /// alias the jump names, the way an ssh_config would. + #[tokio::test] + async fn an_alias_behind_a_jump_resolves_through_ssh_g() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"for last; do :; done +case "$last" in + db) printf 'hostname 10.0.0.5\nport 5432\nproxyjump bastion\n' ;; + bastion) printf 'hostname 192.0.2.10\nport 2222\nstricthostkeychecking accept-new\n' ;; + *) exit 255 ;; +esac"#, + ); + + let route = route(&program, "db", &["bastion"]).await; + + assert_eq!(route.first_hop, Some(endpoint("192.0.2.10", 2222))); + assert_eq!( + route.hops, + [JumpHop { + destination: hop("bastion"), + host_keys: Some(HostKeyChecking::AcceptNew), + }] + ); + } + + /// The last hop is looked up with the earlier ones as its `-J`, the + /// way its ssh is started, and the first hop's own ProxyJump from the + /// configuration adds the hop before it. + #[tokio::test] + async fn every_hop_of_a_chain_is_looked_up_as_its_ssh_starts() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"for last; do :; done +case "$*" in + "-G -- db") printf 'hostname 10.0.0.5\nproxyjump j1,j2\n' ;; + "-G -J j1 -- j2") printf 'hostname 10.0.0.2\nproxyjump j1\nstricthostkeychecking false\n' ;; + "-G -- j1") printf 'hostname 10.0.0.1\nproxyjump j0\nstricthostkeychecking true\n' ;; + "-G -- j0") printf 'hostname 192.0.2.1\nport 2200\nstricthostkeychecking ask\n' ;; + *) exit 255 ;; +esac"#, + ); + + let route = route(&program, "db", &[]).await; + + let looked_up: Vec<(String, Option)> = route + .hops + .iter() + .map(|hop| (hop.destination.to_string(), hop.host_keys)) + .collect(); + assert_eq!( + looked_up, + [ + ("j2".to_owned(), Some(HostKeyChecking::No)), + ("j1".to_owned(), Some(HostKeyChecking::Yes)), + ("j0".to_owned(), Some(HostKeyChecking::Ask)), + ] + ); + assert_eq!(route.first_hop, Some(endpoint("192.0.2.1", 2200))); + } + + #[tokio::test] + async fn a_failing_lookup_and_a_jump_loop_resolve_to_nothing() { + let temp = tempfile::tempdir().unwrap(); + let failing = fake_ssh(temp.path(), "exit 255"); + assert_eq!(route(&failing, "db", &[]).await, Route::default()); + + let looping = tempfile::tempdir().unwrap(); + let program = fake_ssh(looping.path(), r"printf 'hostname x\nproxyjump loop\n'"); + let looped = route(&program, "loop", &[]).await; + assert_eq!(looped.first_hop, None); + assert_eq!(looped.hops.len(), MAX_HOPS); + } + + #[tokio::test] + async fn a_config_file_reaches_every_lookup() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"case "$*" in + "-G -F /etc/tablepro-test.conf -- db") printf 'hostname 10.0.0.5\nproxyjump j1\n' ;; + "-G -F /etc/tablepro-test.conf -- j1") printf 'hostname 192.0.2.1\n' ;; + *) exit 255 ;; +esac"#, + ); + let config = Path::new("/etc/tablepro-test.conf"); + + let resolved = lookup(&program, Some(config), &hop("db"), &[]).await; + let route = trace_route(&program, Some(config), resolved.as_deref()).await; + + assert_eq!(route.first_hop, Some(endpoint("192.0.2.1", 22))); + } +} diff --git a/linux/crates/ssh/src/services.rs b/linux/crates/ssh/src/services.rs index c86e09c7b1..b7c2141ec5 100644 --- a/linux/crates/ssh/src/services.rs +++ b/linux/crates/ssh/src/services.rs @@ -15,6 +15,10 @@ pub struct SshServices { pub ssh_program: PathBuf, pub ssh_keygen_program: PathBuf, pub askpass_program: PathBuf, + /// The ssh_config file every ssh this crate starts reads in place of + /// the user's and the system's, as `ssh -F` does. `None` leaves + /// OpenSSH to read its own. + pub config_file: Option, } impl SshServices { @@ -29,6 +33,7 @@ impl SshServices { ssh_program: PathBuf::from("ssh"), ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: locate_askpass(libexecdir, executable_dir.as_deref()), + config_file: None, } } @@ -72,6 +77,7 @@ mod tests { ssh_program: PathBuf::from("ssh"), ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program, + config_file: None, } } diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index e9993c00db..8f89851f7c 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -18,10 +18,11 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use crate::argv::{ControlOp, master_args}; use crate::askpass_bridge::AskpassBridge; -use crate::first_hop::{lookup, resolve_first_hop}; +use crate::hop_config; use crate::host_key_checking::HostKeyChecking; use crate::known_hosts::spawn_failure; use crate::password_owner::password_owner; +use crate::route::{JumpHop, lookup, trace_route}; use crate::stderr_classify::{ClassifyContext, classify}; use crate::supervisor::{ForwardCancel, Supervisor, TASKS, run_control}; use crate::{SshAuth, SshConfig, SshServices, lock}; @@ -73,14 +74,41 @@ impl SshSession { return Err(error); } }; - let resolved = lookup(&services.ssh_program, &config.destination, &config.jump_hosts).await; + let config_file = services.config_file.as_deref(); + let resolved = lookup( + &services.ssh_program, + config_file, + &config.destination, + &config.jump_hosts, + ) + .await; let password_owner = match config.auth { SshAuth::Password { .. } => resolved.as_deref().and_then(password_owner), SshAuth::Agent | SshAuth::PrivateKey { .. } | SshAuth::KeyboardInteractive => None, }; let host_keys = resolved.as_deref().and_then(HostKeyChecking::from_ssh_g); + let route = tokio::select! { + route = trace_route(&services.ssh_program, config_file, resolved.as_deref()) => route, + () = cancel.cancelled() => { + remove_dir(&master_dir).await; + return Err(SshFailure::InteractionCancelled.into()); + } + }; + let master_config = match pin_jump_hosts(&route.hops, config_file, &master_dir).await { + Ok(written) => written.or_else(|| config_file.map(Path::to_path_buf)), + Err(error) => { + remove_dir(&master_dir).await; + return Err(error); + } + }; let spawned = Command::new(&services.ssh_program) - .args(master_args(config, &control, liveness, host_keys)) + .args(master_args( + config, + &control, + liveness, + host_keys, + master_config.as_deref(), + )) .env("SSH_ASKPASS", &services.askpass_program) .env("SSH_ASKPASS_REQUIRE", "force") .env_remove("SSH_ASKPASS_PROMPT") @@ -110,18 +138,16 @@ impl SshSession { &config.auth, password_owner, ); - let (outcome, first_hop) = tokio::join!( - handshake( - &mut child, - &listener, - &mut bridge, - services, - &control, - liveness, - &cancel, - ), - resolve_first_hop(&services.ssh_program, resolved.as_deref()), - ); + let outcome = handshake( + &mut child, + &listener, + &mut bridge, + services, + &control, + liveness, + &cancel, + ) + .await; drop(listener); if let Err(error) = tokio::fs::remove_file(master_dir.join("askpass")).await { tracing::debug!(%error, "could not remove the askpass socket"); @@ -161,7 +187,7 @@ impl SshSession { master_dir: master_dir.clone(), control: control.clone(), master_pid: child.id(), - first_hop, + first_hop: route.first_hop, closed: closed.clone(), shutdown: shutdown.clone(), exit_status: exit_status.clone(), @@ -227,6 +253,30 @@ impl Drop for SshSession { } } +/// The master's ssh_config when a jump host's own configuration would +/// trust a host key without asking, which the master's `-o` options do +/// not reach. `None` when every hop already asks or refuses. +async fn pin_jump_hosts( + hops: &[JumpHop], + config_file: Option<&Path>, + master_dir: &Path, +) -> Result, TransportError> { + let pinned: Vec = hop_config::hosts_to_pin(hops).into_iter().map(str::to_owned).collect(); + if pinned.is_empty() { + return Ok(None); + } + let config_file = config_file.map(Path::to_path_buf); + let master_dir = master_dir.to_owned(); + tokio::task::spawn_blocking(move || { + let contents = hop_config::render(&pinned, config_file.as_deref())?; + hop_config::write(&master_dir, &contents) + }) + .await + .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))? + .map(Some) + .map_err(TransportError::from) +} + pub(crate) struct HandshakeDeadline { remaining: Duration, running_since: Option, diff --git a/linux/crates/ssh/src/sweep.rs b/linux/crates/ssh/src/sweep.rs index 5248baa908..f840c3db8c 100644 --- a/linux/crates/ssh/src/sweep.rs +++ b/linux/crates/ssh/src/sweep.rs @@ -115,6 +115,7 @@ mod tests { ssh_program: PathBuf::from("true"), ssh_keygen_program: PathBuf::from("true"), askpass_program: PathBuf::from("/nonexistent"), + config_file: None, } } diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index d99c71af69..7ecd9ec397 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -26,7 +26,7 @@ const SSHD_SCRIPT: &str = "apk add --no-cache openssh >/dev/null \ struct Fixture { container: ContainerAsync, - _temp: tempfile::TempDir, + temp: tempfile::TempDir, host: String, port: u16, config: PathBuf, @@ -72,10 +72,11 @@ async fn start() -> TestResult { ssh_program: wrapper, ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: PathBuf::from(env!("CARGO_BIN_EXE_tablepro-askpass")), + config_file: None, }; Ok(Fixture { container, - _temp: temp, + temp, host, port, config, @@ -406,6 +407,110 @@ async fn first_hop_is_the_destination_ssh_resolved() { assert_eq!(first_hop.host(), fixture.host); } +/// OpenSSH runs a jump host through a second ssh that never sees the +/// master's `-o` options, so a configuration that trusts new keys for +/// every host would trust the jump host's without the dialog. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_jump_host_whose_config_skips_the_host_key_check_still_asks() { + let fixture = start().await.unwrap(); + write_config(&fixture.config, &fixture.known_hosts, "accept-new").unwrap(); + let services = SshServices { + ssh_program: PathBuf::from("ssh"), + config_file: Some(fixture.config.clone()), + ..fixture.services.clone() + }; + + jump_host_asks(&fixture, &services).await.unwrap(); +} + +/// The same with no `-F`, as the app runs: the pinned file includes the +/// user's `~/.ssh/config` and copies in the system's. ssh looks the user +/// file up in the password database but an `Include` follows `HOME`, so +/// the wrapper hands the file to the lookups with `-F` and to the +/// `Include` through `HOME`. The pinned file's own `-F` comes last on +/// the master's command line and wins. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_jump_host_whose_user_config_skips_the_host_key_check_still_asks() { + let fixture = start().await.unwrap(); + let home = fixture.temp.path().join("home"); + std::fs::create_dir_all(home.join(".ssh")).unwrap(); + let user_config = home.join(".ssh").join("config"); + write_config(&user_config, &fixture.known_hosts, "accept-new").unwrap(); + // An Include refuses a file others can write. + std::fs::set_permissions(&user_config, std::fs::Permissions::from_mode(0o600)).unwrap(); + let wrapper = fixture.temp.path().join("ssh-as-user"); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\nHOME='{}' exec ssh -F '{}' \"$@\"\n", + home.display(), + user_config.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let services = SshServices { + ssh_program: wrapper, + config_file: None, + ..fixture.services.clone() + }; + + jump_host_asks(&fixture, &services).await.unwrap(); +} + +async fn jump_host_asks(fixture: &Fixture, services: &SshServices) -> TestResult<()> { + let config = SshConfig { + // sshd reaches itself on its own port, so one container is both + // the jump host and the destination behind it. + destination: SshDestination::new("127.0.0.1", Some(2222), Some("deploy".to_owned()))?, + jump_hosts: vec![SshDestination::new( + &fixture.host, + Some(fixture.port), + Some("deploy".to_owned()), + )?], + auth: SshAuth::Agent, + }; + let prompter = Arc::new(FakePrompter::new(vec![ + accept(), + answer("s3cret"), + accept(), + answer("s3cret"), + ])); + + let session = SshSession::connect( + &config, + services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Attended(prompter.clone()), + CancellationToken::new(), + ) + .await; + + let purposes: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + let jump = format!("]:{}", fixture.port); + assert!( + matches!( + purposes.as_slice(), + [ + PromptPurpose::SshHostKeyConfirmation { host, .. }, + PromptPurpose::SshPassword { .. }, + PromptPurpose::SshHostKeyConfirmation { .. }, + PromptPurpose::SshPassword { .. }, + ] if host.ends_with(&jump) + ), + "{purposes:?}" + ); + let session = session?; + assert_eq!( + session.first_hop_endpoint(), + Some(&NetworkEndpoint::new(&fixture.host, fixture.port)?) + ); + session.shutdown(Duration::from_secs(5)).await; + Ok(()) +} + #[tokio::test] #[ignore = "requires docker"] async fn no_loopback_listener_while_tunnelled() { diff --git a/linux/crates/storage/src/credentials/keyring_credentials.rs b/linux/crates/storage/src/credentials/keyring_credentials.rs index 78f925de5a..409f06dddc 100644 --- a/linux/crates/storage/src/credentials/keyring_credentials.rs +++ b/linux/crates/storage/src/credentials/keyring_credentials.rs @@ -24,6 +24,7 @@ pub struct KeyringCredentials { targets: [String; 3], key_path: Option, labeler: SecretLabeler, + session_only: Vec, } impl KeyringCredentials { @@ -35,9 +36,18 @@ impl KeyringCredentials { targets: SecretKind::ALL.map(|kind| saved.secret_target(kind)), key_path: saved.ssh_key_path().map(PathBuf::from), labeler: english_secret_label, + session_only: Vec::new(), } } + /// For a session that logged in with secrets of `kinds` the user + /// typed and the keyring does not hold. Its reconnects use the one + /// in use: the keyring's copy may be the one the server refused. + pub fn keeping_session_secrets(mut self, kinds: &[SecretKind]) -> Self { + self.session_only = kinds.to_vec(); + self + } + /// Label remembered secrets with the app's own, translated wording. pub fn with_labeler(mut self, labeler: SecretLabeler) -> Self { self.labeler = labeler; @@ -71,6 +81,11 @@ impl KeyringCredentials { { return self.ask(prompter, kind, PromptReason::Rejected, true).await.map(Some); } + if self.session_only.contains(&kind) + && let Some(secret) = in_use.filter(|secret| !secret.expose_secret().is_empty()) + { + return Ok(Some(ResolvedSecret::known(secret.clone()))); + } if kind == SecretKind::SshPassphrase && !self.passphrase_stored { return Ok(None); } @@ -135,7 +150,11 @@ impl KeyringCredentials { secret: secret.clone(), label: (self.labeler)(kind, &target), }); - Ok(ResolvedSecret { secret, remember }) + Ok(ResolvedSecret { + secret, + remember, + typed: true, + }) } fn target(&self, kind: SecretKind) -> &str { @@ -152,6 +171,7 @@ impl std::fmt::Debug for KeyringCredentials { f.debug_struct("KeyringCredentials") .field("connection_id", &self.connection_id) .field("passphrase_stored", &self.passphrase_stored) + .field("session_only", &self.session_only) .finish_non_exhaustive() } } @@ -343,6 +363,7 @@ mod tests { assert_eq!(resolved.secret.expose_secret(), "s3cret"); assert!(resolved.remember.is_none()); + assert!(!resolved.typed); } #[tokio::test] @@ -407,6 +428,7 @@ mod tests { .unwrap(); assert_eq!(resolved.secret.expose_secret(), "typed"); + assert!(resolved.typed); let requests = prompter.requests(); assert_eq!(requests.len(), 1); assert_eq!( @@ -547,6 +569,50 @@ mod tests { ); } + #[tokio::test] + async fn a_secret_typed_for_the_session_wins_over_the_stored_copy() { + let (credentials, vault) = credentials(Vault::holding(SecretKind::DatabasePassword, "refused"), None); + let credentials = credentials.keeping_session_secrets(&[SecretKind::DatabasePassword]); + let in_use = SecretString::from("typed"); + + let resolved = credentials + .resolve( + SecretKind::DatabasePassword, + Some(&in_use), + &CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolved.secret.expose_secret(), "typed"); + assert!(vault.loads().is_empty(), "the stored copy was read"); + } + + #[tokio::test] + async fn other_kinds_still_come_from_the_keyring() { + let (credentials, _) = credentials( + Vault::holding(SecretKind::SshPassword, "stored"), + Some(SavedSshAuth::Password), + ); + let credentials = credentials.keeping_session_secrets(&[SecretKind::DatabasePassword]); + let in_use = SecretString::from("typed"); + + let resolved = credentials + .resolve( + SecretKind::SshPassword, + Some(&in_use), + &CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolved.secret.expose_secret(), "stored"); + } + #[tokio::test] async fn not_stored_keeps_the_secret_already_in_use() { let (credentials, _) = credentials(Vault::default(), None); diff --git a/linux/crates/storage/src/credentials/resolved_secret.rs b/linux/crates/storage/src/credentials/resolved_secret.rs index 8e16a55b08..2bef31b458 100644 --- a/linux/crates/storage/src/credentials/resolved_secret.rs +++ b/linux/crates/storage/src/credentials/resolved_secret.rs @@ -8,10 +8,17 @@ pub struct ResolvedSecret { /// Set when the user typed the secret and asked for it to be kept. /// The caller stores it once the login it was typed for works. pub remember: Option, + /// The user typed it into a prompt, rather than the keyring or the + /// request supplying it. + pub typed: bool, } impl ResolvedSecret { pub(super) fn known(secret: SecretString) -> Self { - Self { secret, remember: None } + Self { + secret, + remember: None, + typed: false, + } } } diff --git a/linux/crates/storage/src/fs.rs b/linux/crates/storage/src/fs.rs index 71a35ad67c..869cfde71f 100644 --- a/linux/crates/storage/src/fs.rs +++ b/linux/crates/storage/src/fs.rs @@ -168,6 +168,43 @@ pub fn moved_aside_exists_blocking(path: &Path) -> Result { Ok(false) } +/// Delete every copy of `path` that `move_aside_blocking` moved aside, +/// for a file whose owner is gone. Returns how many there were. +pub fn remove_moved_aside_blocking(path: &Path) -> Result { + let Some(name) = path.file_name() else { + return Ok(0); + }; + let prefix = format!("{}{MOVED_ASIDE}", name.to_string_lossy()); + let parent = path.parent().unwrap_or(Path::new(".")); + let entries = match std::fs::read_dir(parent) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(0), + Err(source) => { + return Err(StorageError::Io { + path: parent.to_owned(), + source, + }); + } + }; + let mut removed = 0; + for entry in entries { + let entry = entry.map_err(|source| StorageError::Io { + path: parent.to_owned(), + source, + })?; + if !entry.file_name().to_string_lossy().starts_with(&prefix) { + continue; + } + let copy = entry.path(); + match std::fs::remove_file(©) { + Ok(()) => removed += 1, + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(source) => return Err(StorageError::Io { path: copy, source }), + } + } + Ok(removed) +} + #[cfg(test)] mod tests { use super::*; @@ -291,6 +328,31 @@ mod tests { assert!(!moved_aside_exists_blocking(&root.path().join("missing").join("a.json")).expect("no directory")); } + #[test] + fn remove_moved_aside_takes_only_that_files_copies() { + let root = tempfile::tempdir().expect("tempdir"); + let file = root.path().join("a.json"); + let other = root.path().join("a.json2"); + for path in [&file, &other] { + std::fs::write(path, b"x").expect("seed"); + move_aside_blocking(path, std::time::SystemTime::now()).expect("move"); + } + std::fs::write(&file, b"current").expect("seed"); + + assert_eq!(remove_moved_aside_blocking(&file).expect("remove"), 1); + + assert!(!moved_aside_exists_blocking(&file).expect("list")); + assert!( + moved_aside_exists_blocking(&other).expect("list"), + "another file's copy went" + ); + assert_eq!(std::fs::read(&file).expect("read"), b"current"); + assert_eq!( + remove_moved_aside_blocking(&root.path().join("missing").join("a.json")).expect("no directory"), + 0 + ); + } + #[test] fn create_private_file_if_missing_keeps_existing_contents() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/linux/crates/storage/src/paths.rs b/linux/crates/storage/src/paths.rs index baa243ce75..09ab19737f 100644 --- a/linux/crates/storage/src/paths.rs +++ b/linux/crates/storage/src/paths.rs @@ -61,10 +61,6 @@ impl StoragePaths { pub fn drafts_dir(&self) -> PathBuf { self.data.join("drafts") } - - pub fn instance_lock(&self) -> PathBuf { - self.runtime.join("tablepro.lock") - } } #[cfg(test)] diff --git a/linux/crates/storage/src/query_history/mod.rs b/linux/crates/storage/src/query_history/mod.rs index 8fe3f3d00f..8e03c50455 100644 --- a/linux/crates/storage/src/query_history/mod.rs +++ b/linux/crates/storage/src/query_history/mod.rs @@ -282,6 +282,33 @@ impl QueryHistory { Ok(report) } + /// How many queries ran on a connection, pinned ones included. + pub async fn count_for_connection(&self, connection: Uuid) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history WHERE connection_id = ?") + .bind(connection.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(u64::try_from(count).unwrap_or(0)) + } + + /// Drop every query run on a connection, pinned ones included, for a + /// connection the user deleted along with its history. + pub async fn delete_for_connection(&self, connection: Uuid) -> Result { + let removed = sqlx::query("DELETE FROM history WHERE connection_id = ?") + .bind(connection.to_string()) + .execute(&self.pool) + .await? + .rows_affected(); + // As after a prune: the delete triggers leave tombstones in the + // index until it is optimized. + if removed > 0 { + sqlx::query("INSERT INTO history_fts(history_fts) VALUES('optimize')") + .execute(&self.pool) + .await?; + } + Ok(removed) + } + pub async fn known_connections(&self) -> Result, StorageError> { let rows = sqlx::query( "SELECT DISTINCT connection_id, connection_name FROM history ORDER BY connection_name COLLATE NOCASE", @@ -545,6 +572,44 @@ mod tests { assert!(left.iter().any(|entry| entry.pinned), "the pinned row was dropped"); } + #[tokio::test] + async fn delete_for_connection_removes_only_that_connections_queries() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let (going, staying) = (Uuid::new_v4(), Uuid::new_v4()); + for index in 0..3 { + let id = history + .record(entry(&format!("SELECT widgets {index}"), going)) + .await + .expect("record"); + if index == 0 { + history.set_pinned(id, true).await.expect("pin"); + } + } + history + .record(entry("SELECT widgets kept", staying)) + .await + .expect("record"); + assert_eq!(history.count_for_connection(going).await.expect("count"), 3); + + let removed = history.delete_for_connection(going).await.expect("delete"); + + assert_eq!(removed, 3, "a pinned query outlived its connection"); + assert_eq!(history.count_for_connection(going).await.expect("count"), 0); + assert_eq!(history.count_for_connection(staying).await.expect("count"), 1); + let found = history + .search(SearchFilter { + needle: Some("widgets".to_owned()), + limit: 10, + ..SearchFilter::default() + }) + .await + .expect("search"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].connection_id, staying); + assert_eq!(history.delete_for_connection(going).await.expect("delete"), 0); + } + #[tokio::test] async fn fts_search_still_matches_after_optimize() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/linux/crates/storage/src/settings/keys.rs b/linux/crates/storage/src/settings/keys.rs index 0090946104..ddfbe67a1c 100644 --- a/linux/crates/storage/src/settings/keys.rs +++ b/linux/crates/storage/src/settings/keys.rs @@ -1,11 +1,13 @@ pub const DEFAULT_PAGE_SIZE: &str = "default-page-size"; pub const CONFIRM_DESTRUCTIVE: &str = "confirm-destructive"; +pub const SAVE_QUERY_HISTORY: &str = "save-query-history"; pub const HISTORY_RETENTION_DAYS: &str = "history-retention-days"; pub const QUERY_TIMEOUT_SECS: &str = "query-timeout-secs"; pub const USE_SYSTEM_FONT: &str = "use-system-font"; pub const CUSTOM_FONT: &str = "custom-font"; pub const STYLE_SCHEME: &str = "style-scheme"; pub const CONNECT_DIALOG_DRIVER: &str = "connect-dialog-driver"; +pub const RESTORE_SESSION: &str = "restore-session"; pub const CSV_NULL_TO_EMPTY: &str = "csv-null-to-empty"; pub const CSV_LINE_BREAK_TO_SPACE: &str = "csv-line-break-to-space"; diff --git a/linux/crates/storage/src/settings/mod.rs b/linux/crates/storage/src/settings/mod.rs index 407211425d..b60e04d100 100644 --- a/linux/crates/storage/src/settings/mod.rs +++ b/linux/crates/storage/src/settings/mod.rs @@ -75,6 +75,16 @@ impl AppSettings { self.set_boolean(keys::CONFIRM_DESTRUCTIVE, confirm) } + /// Whether runs go into the query history. Off records nothing and + /// leaves the entries already there. + pub fn save_query_history(&self) -> bool { + self.settings.boolean(keys::SAVE_QUERY_HISTORY) + } + + pub fn set_save_query_history(&self, save: bool) -> Result<(), SettingsError> { + self.set_boolean(keys::SAVE_QUERY_HISTORY, save) + } + pub fn history_retention_days(&self) -> u32 { self.settings.uint(keys::HISTORY_RETENTION_DAYS) } @@ -132,6 +142,15 @@ impl AppSettings { self.set_string(keys::CONNECT_DIALOG_DRIVER, driver_id) } + /// Whether the connections open at quit are opened again at launch. + pub fn restore_session(&self) -> bool { + self.settings.boolean(keys::RESTORE_SESSION) + } + + pub fn set_restore_session(&self, restore: bool) -> Result<(), SettingsError> { + self.set_boolean(keys::RESTORE_SESSION, restore) + } + pub fn csv_options(&self) -> CsvOptions { CsvOptions { null_to_empty: self.settings.boolean(keys::CSV_NULL_TO_EMPTY), diff --git a/linux/crates/storage/tests/settings.rs b/linux/crates/storage/tests/settings.rs index bd090942ab..cecb9adafa 100644 --- a/linux/crates/storage/tests/settings.rs +++ b/linux/crates/storage/tests/settings.rs @@ -49,6 +49,28 @@ fn defaults_match_schema() -> TestResult { assert_eq!(fixture.settings.query_timeout(), Some(Duration::from_secs(60))); assert_eq!(fixture.settings.style_scheme(), "Adwaita"); assert_eq!(fixture.settings.csv_options(), CsvOptions::default()); + assert!(fixture.settings.restore_session()); + assert!(fixture.settings.save_query_history()); + Ok(()) +} + +#[test] +fn restore_session_round_trip() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.set_restore_session(false)?; + + assert!(!fixture.settings.restore_session()); + Ok(()) +} + +#[test] +fn save_query_history_round_trip() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.set_save_query_history(false)?; + + assert!(!fixture.settings.save_query_history()); Ok(()) } diff --git a/linux/crates/test-fixtures/src/scripted_askpass.rs b/linux/crates/test-fixtures/src/scripted_askpass.rs index 46585402f3..1bd6a5d59b 100644 --- a/linux/crates/test-fixtures/src/scripted_askpass.rs +++ b/linux/crates/test-fixtures/src/scripted_askpass.rs @@ -45,10 +45,24 @@ mod tests { let askpass = ScriptedAskpass::new().unwrap(); let mode = std::fs::metadata(askpass.path()).unwrap().permissions().mode() & 0o777; - let output = Command::new(askpass.path()) - .arg("tablepro@localhost's password: ") - .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") - .output() + // A test thread that forks while the script is still open for + // writing hands its child the write handle until that child + // execs, and exec refuses a file open for writing. + let output = (0..100) + .find_map(|_| { + match Command::new(askpass.path()) + .arg("tablepro@localhost's password: ") + .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") + .output() + { + Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(std::time::Duration::from_millis(10)); + None + } + ran => Some(ran), + } + }) + .expect("the script was busy on every try") .unwrap(); assert_eq!(mode, 0o700); diff --git a/linux/crates/test-support/src/conformance/cases/ddl.rs b/linux/crates/test-support/src/conformance/cases/ddl.rs index e0731e385d..1b9155d4d9 100644 --- a/linux/crates/test-support/src/conformance/cases/ddl.rs +++ b/linux/crates/test-support/src/conformance/cases/ddl.rs @@ -11,16 +11,19 @@ use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, IndexKey, ObjectKind, ObjectScope}; use tablepro_core::value::Value; use tablepro_core::{ - Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, TableRef, WriteAtomicity, WriteBatch, WriteKind, + Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, RowLimit, TableRef, WriteAtomicity, WriteBatch, WriteKind, WriteReport, }; -use crate::conformance::support::{CALL_DEADLINE, connect, expect, unexpected, unique, within, writable}; +use crate::conformance::support::{ + CALL_DEADLINE, apply, connect, count, expect, insert_sql, statement, unexpected, unique, within, writable, +}; use crate::conformance::{ConformanceDdlEngine, ConformanceFailure}; const APPLY_CASE: &str = "ddl_plans_apply_and_read_back"; const FAILED_CASE: &str = "failed_ddl_batch_outcome_matches_ddl_atomicity"; const SCRIPT_CASE: &str = "create_table_script_recreates_the_table"; +const ROWS_CASE: &str = "structure_changes_keep_rows_and_indexes"; /// Case 1: each structure change the engine offers applies and reads /// back as drafted, and each one it refuses is refused by the plan @@ -340,6 +343,97 @@ pub async fn create_table_script_recreates_the_table( Ok(()) } +/// Case 4: a table's rows and indexes come through the column changes +/// the engine offers. SQLite makes these by rebuilding the table, which +/// copies every row and makes every index again. +pub async fn structure_changes_keep_rows_and_indexes( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = ROWS_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let parent = engine.scratch_table(&unique("ddl_rows")); + create( + case, + &engine, + connection.as_ref(), + ddl, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + ) + .await?; + let mut table = definition(connection.as_ref(), &parent).await?; + let step = Step { + case, + connection: connection.as_ref(), + ddl, + }; + let index = IndexInfo { + name: format!("{}_label", parent.name), + keys: vec![IndexKey::Column { + name: "label".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }; + step.apply( + &mut table, + "create index", + table_permission(ddl.capabilities().create_index), + |draft| draft.indexes.push(index), + ) + .await?; + let labels = ["first", "second", "third"]; + let inserts = (1..) + .zip(labels) + .map(|(id, label)| insert_sql(dialect.as_ref(), &parent, id, label)) + .collect(); + apply(connection.as_ref(), &parent, WriteKind::RowChanges, inserts).await?; + let indexes = secondary_indexes(&table); + + let permission = column_permission(ddl, &table, "label", |permissions| permissions.alter_default); + step.apply(&mut table, "change default", permission, |draft| { + edit_column(draft, "label", |column| { + column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + }) + }) + .await?; + // `created_at` took its default on every row, so none is NULL. + let permission = column_permission(ddl, &table, "created_at", |permissions| permissions.alter_nullability); + step.apply(&mut table, "change nullability", permission, |draft| { + edit_column(draft, "created_at", |column| column.nullable = false) + }) + .await?; + + let rows = count(connection.as_ref(), &parent).await?; + expect(case, rows == 3, || format!("{rows} rows are left of 3"))?; + let sql = format!( + "SELECT {} FROM {} ORDER BY {}", + dialect.quote_identifier("label"), + dialect.qualify(&parent), + dialect.quote_identifier("id") + ); + let read = connection + .query_bound(&statement(&sql), RowLimit::EDITOR_DEFAULT, within(CALL_DEADLINE)) + .await?; + let expected: Vec> = labels + .iter() + .map(|label| vec![Value::Text((*label).to_owned())]) + .collect(); + expect(case, read.rows == expected, || { + format!("the labels read back as {:?}", read.rows) + })?; + let after = secondary_indexes(&table); + expect(case, after == indexes, || { + format!("the indexes were {indexes:?} and are {after:?}") + })?; + drop_table(case, connection.as_ref(), ddl, &parent).await?; + connection.close().await?; + Ok(()) +} + fn ddl_of<'a>(case: &'static str, dialect: &'a dyn SqlDialect) -> Result<&'a dyn DdlDialect, ConformanceFailure> { dialect .ddl() diff --git a/linux/crates/test-support/src/conformance/ddl_suite_macro.rs b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs index 6b48621987..a46c8e8e75 100644 --- a/linux/crates/test-support/src/conformance/ddl_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs @@ -26,5 +26,11 @@ macro_rules! ddl_conformance_suite { create_table_script_recreates_the_table, $crate::conformance::cases::ddl::create_table_script_recreates_the_table::<$engine> } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + structure_changes_keep_rows_and_indexes, + $crate::conformance::cases::ddl::structure_changes_keep_rows_and_indexes::<$engine> + } }; } diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index 241e611e50..7bed407872 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -29,6 +29,7 @@ pub struct FakeConnection { read_only: bool, close_behaviour: CloseBehaviour, close_started: AtomicBool, + close_finished: AtomicBool, link: LinkWatch, closed: CancellationToken, counters: CallCounters, @@ -44,6 +45,7 @@ impl FakeConnection { read_only: script.read_only, close_behaviour: script.close, close_started: AtomicBool::new(false), + close_finished: AtomicBool::new(false), script: Mutex::new(script), link: LinkWatch::new(), closed: CancellationToken::new(), @@ -66,6 +68,12 @@ impl FakeConnection { self.closed.is_cancelled() } + /// Set once the first close has run to its end, delay included. A + /// close that hangs, or whose caller gave up on it, never sets it. + pub fn close_finished(&self) -> bool { + self.close_finished.load(Ordering::SeqCst) + } + /// The cancel token every call to `method` was handed, oldest first, /// so a test can tell its own stop reached the call. pub fn observed_cancels(&self, method: &str) -> Vec { @@ -395,6 +403,7 @@ impl Connection for FakeConnection { self.closed.cancel(); if first { self.close_behaviour.run().await; + self.close_finished.store(true, Ordering::SeqCst); } Ok(()) } @@ -480,6 +489,7 @@ mod tests { }); let outcome = tokio::time::timeout(Duration::from_secs(3600), hanging.close()).await; assert!(outcome.is_err(), "a hanging close returned"); + assert!(hanging.is_closed() && !hanging.close_finished()); let delayed = FakeConnection::new(ConnectionScript { close: CloseBehaviour::Delay(Duration::from_secs(5)), @@ -488,6 +498,7 @@ mod tests { let started = Instant::now(); delayed.close().await.unwrap(); assert!(started.elapsed() >= Duration::from_secs(5)); + assert!(delayed.close_finished()); let again = Instant::now(); delayed.close().await.unwrap(); assert_eq!(again.elapsed(), Duration::ZERO, "a second close waited again"); diff --git a/linux/crates/test-support/src/fake_dialect.rs b/linux/crates/test-support/src/fake_dialect.rs index 26b4448880..fef324188f 100644 --- a/linux/crates/test-support/src/fake_dialect.rs +++ b/linux/crates/test-support/src/fake_dialect.rs @@ -4,7 +4,8 @@ use tablepro_core::dialect::{ }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::Value; @@ -87,8 +88,25 @@ impl SqlDialect for FakeDialect { let parsed = parse_expression(SqlGrammar::PostgreSql, text, MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } + + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::PostgreSql, &CREDENTIAL_RULES, sql) + } } +/// Logins and roles, the credential statements every engine with users +/// has. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "ROLE"], + &["ALTER", "ROLE"], + ], + conditional_prefixes: &[], + carriers: &[], +}; + /// The parameter type that carries a value as it is, so a fake bind /// never fails on a type the test did not mean to check. fn param_type_of(value: &Value) -> ParamType { diff --git a/linux/crates/test-support/src/fake_secret_vault.rs b/linux/crates/test-support/src/fake_secret_vault.rs index 1e74259a79..1bb82ee1a1 100644 --- a/linux/crates/test-support/src/fake_secret_vault.rs +++ b/linux/crates/test-support/src/fake_secret_vault.rs @@ -51,6 +51,13 @@ impl FakeSecretVault { self.lock().fail_delete_connection = Some(error); } + /// Stop every failure set so far, as a keyring the user unlocked. + pub fn clear_failures(&self) { + let mut state = self.lock(); + state.fail_store.clear(); + state.fail_delete_connection = None; + } + pub fn calls(&self) -> Vec { self.lock().calls.clone() } @@ -171,6 +178,18 @@ mod tests { assert!(vault.is_empty()); } + #[tokio::test] + async fn cleared_failures_no_longer_fail() { + let vault = FakeSecretVault::new(); + let id = Uuid::new_v4(); + vault.fail_delete_connection(SecretError::Locked); + assert_eq!(vault.delete_connection(id).await, Err(SecretError::Locked)); + + vault.clear_failures(); + + assert_eq!(vault.delete_connection(id).await, Ok(())); + } + #[tokio::test] async fn a_configured_failure_is_returned() { let vault = FakeSecretVault::new(); diff --git a/linux/crates/test-support/src/history_cases.rs b/linux/crates/test-support/src/history_cases.rs new file mode 100644 index 0000000000..5afc30dffa --- /dev/null +++ b/linux/crates/test-support/src/history_cases.rs @@ -0,0 +1,46 @@ +//! Statements every engine's dialect has to let into the query history. + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::HistoryRecording; + +/// SQL that names passwords, secrets or a carrier without setting a +/// credential, so withholding any of it would only lose history. +pub const PLAIN_STATEMENTS: [&str; 7] = [ + "SELECT id FROM users WHERE password IS NULL AND email = 'a@b'", + "UPDATE accounts SET secret = NULL WHERE name = 'x'", + "DELETE FROM creds WHERE password = 'p'", + "SELECT 'password' AS label", + "CREATE TABLE s3 (id int)", + "SELECT s3 FROM t", + "-- only a note\n/* and another */", +]; + +/// The plain statements `dialect` would not record, for a test to +/// expect none of. +pub fn unrecorded_plain_statements(dialect: &dyn SqlDialect) -> Vec<&'static str> { + PLAIN_STATEMENTS + .into_iter() + .filter(|sql| dialect.history_recording(sql) != HistoryRecording::Record) + .collect() +} + +#[cfg(test)] +mod tests { + use tablepro_core::sql_syntax::WithholdReason; + + use super::*; + use crate::FakeDialect; + + #[test] + fn the_fake_dialect_records_plain_statements() { + assert_eq!(unrecorded_plain_statements(&FakeDialect), Vec::<&str>::new()); + } + + #[test] + fn the_fake_dialect_withholds_a_login() { + assert_eq!( + FakeDialect.history_recording("SELECT 1; CREATE USER u PASSWORD 'p'"), + HistoryRecording::Withhold(WithholdReason::CredentialStatement) + ); + } +} diff --git a/linux/crates/test-support/src/lib.rs b/linux/crates/test-support/src/lib.rs index f02954726a..21a17fd313 100644 --- a/linux/crates/test-support/src/lib.rs +++ b/linux/crates/test-support/src/lib.rs @@ -21,6 +21,7 @@ mod fake_reply; mod fake_run; mod fake_secret_vault; mod fault; +pub mod history_cases; mod replies; pub use call_counters::CallCounters; diff --git a/linux/data/app.tablepro.TablePro.desktop.in.in b/linux/data/app.tablepro.TablePro.desktop.in.in index d2e403678e..fa1c3937ea 100644 --- a/linux/data/app.tablepro.TablePro.desktop.in.in +++ b/linux/data/app.tablepro.TablePro.desktop.in.in @@ -4,10 +4,16 @@ Name=TablePro GenericName=Database Client Comment=Browse and edit PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse databases Keywords=SQL;Database;PostgreSQL;MySQL;SQLite;SQL Server;ClickHouse; -Exec=tablepro +Exec=tablepro %U Icon=@app-id@ Terminal=false Type=Application Categories=Development;Database; +MimeType=application/vnd.sqlite3; DBusActivatable=true StartupNotify=true +Actions=new-window; + +[Desktop Action new-window] +Name=New Window +Exec=tablepro --new-window diff --git a/linux/data/app.tablepro.TablePro.gschema.xml b/linux/data/app.tablepro.TablePro.gschema.xml index 7b9fced5e2..341907a9cb 100644 --- a/linux/data/app.tablepro.TablePro.gschema.xml +++ b/linux/data/app.tablepro.TablePro.gschema.xml @@ -58,6 +58,12 @@ Ask before dropping a table, deleting rows, or discarding pending edits. + + true + Save query history + Whether the SQL you run is kept in the query history. Statements that contain passwords or connection secrets are never saved. Turning this off keeps the entries already saved. + + 30 @@ -90,6 +96,12 @@ GtkSourceView style scheme id for the SQL editor. + + true + Restore session + Whether the connections open when TablePro quit are opened again at the next launch. + + 'postgres' Driver the connect dialog opens with diff --git a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in index 39499d3947..91117c30a9 100644 --- a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in +++ b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in @@ -57,6 +57,7 @@ tablepro + application/vnd.sqlite3 diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 96c11d2e9e..43f8172b18 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -5,13 +5,18 @@ shortcuts-dialog.ui ui/browse-tab.ui ui/connect-dialog.ui + ui/connect-failed-page.ui + ui/connecting-page.ui + ui/picker-page.ui ui/preferences-dialog.ui ui/results-pane.ui + ui/session-window.ui ui/sql-editor.ui ui/sqlite-file-row.ui ui/ssh-section.ui ui/structure-tab.ui ui/tls-section.ui + ui/workspace.ui icons/scalable/actions/table-relation-symbolic.svg icons/scalable/actions/statement-pending-symbolic.svg diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 63b2a08c89..4ebcc751ee 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -18,10 +18,16 @@ F5 + + + New window + app.new-window + + Open Preferences - <Primary>comma + app.preferences @@ -57,68 +63,85 @@ Quit - <Primary>q + app.quit - Browse table + Data grid - Edit focused cell + Edit cell, or toggle a check box F2 Return - Cancel edit + Toggle a check box + space + + + + + Finish editing + Return + + + + + Cancel editing Escape - Move to next cell, committing an edit + Finish editing and edit the next cell Tab - Move to previous cell, committing an edit + Finish editing and edit the previous cell <Shift>Tab Move between cells - Left Right + Up Down Left Right - Toggle boolean cell - space + Show cell menu + Menu <Shift>F10 - Insert row - <Primary>n + Set cell to NULL + <Primary>BackSpace - Delete selected row + Mark rows for deletion Delete - Set focused cell to NULL - <Primary><Shift>n + Insert row + <Primary>n + + + + + Browse table Filter rows diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 37704ef1e3..40683461de 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -24,18 +24,12 @@ textview.sql-editor, textview.sql-editor text { text-decoration: line-through; } -/* Italic only: the dim-label Adwaita class added alongside already - supplies the opacity. */ -label.tp-null-sentinel { - font-style: italic; -} - /* GtkColumnView's default cell focus is a 1px outline that vanishes against the selected-row highlight. A 2px inset accent ring is the - spreadsheet-standard focused-cell signal. The selectors stay explicit - so the ring never stacks on a GtkCheckButton, which paints its own. */ -columnview > listview > row > cell:focus-within > tp-grid-cell > label, -columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { + spreadsheet-standard focused-cell signal, on the focused cell and on + the field of a cell being edited. */ +columnview > listview > row > cell:focus-visible > tp-grid-cell, +columnview > listview > row > cell > tp-grid-cell > text.tp-cell-text:focus-within { box-shadow: inset 0 0 0 2px var(--accent-color); border-radius: 2px; } @@ -71,8 +65,8 @@ columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor color: var(--error-fg-color); } - columnview > listview > row > cell:focus-within > tp-grid-cell > label, - columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { + columnview > listview > row > cell:focus-visible > tp-grid-cell, + columnview > listview > row > cell > tp-grid-cell > text.tp-cell-text:focus-within { box-shadow: inset 0 0 0 3px var(--accent-color); } } diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index 523a0c37d0..afd7c3c4de 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -75,6 +75,16 @@ + + + list-remove-symbolic + tab.delete-rows + Delete selected rows (Delete) + + + @@ -116,7 +126,7 @@ status @@ -164,7 +174,7 @@ @@ -180,7 +190,7 @@ Rows: page_size_dropdown @@ -197,7 +207,7 @@ diff --git a/linux/data/resources/ui/connect-failed-page.ui b/linux/data/resources/ui/connect-failed-page.ui new file mode 100644 index 0000000000..8a060c9fb6 --- /dev/null +++ b/linux/data/resources/ui/connect-failed-page.ui @@ -0,0 +1,74 @@ + + + + + + diff --git a/linux/data/resources/ui/connecting-page.ui b/linux/data/resources/ui/connecting-page.ui new file mode 100644 index 0000000000..9a864a9775 --- /dev/null +++ b/linux/data/resources/ui/connecting-page.ui @@ -0,0 +1,35 @@ + + + + + + diff --git a/linux/data/resources/ui/picker-page.ui b/linux/data/resources/ui/picker-page.ui new file mode 100644 index 0000000000..b21f3486e0 --- /dev/null +++ b/linux/data/resources/ui/picker-page.ui @@ -0,0 +1,77 @@ + + + + + + +
+ + New Window + app.new-window + +
+
+ + Import Connections… + win.import-connections + + + Export Connections… + win.export-connections + +
+
+ + Preferences + app.preferences + +
+
+ + Keyboard Shortcuts + app.shortcuts + + + About TablePro + app.about + + + Quit + app.quit + +
+
+
diff --git a/linux/data/resources/ui/preferences-dialog.ui b/linux/data/resources/ui/preferences-dialog.ui index 270abc47b7..e6e458c827 100644 --- a/linux/data/resources/ui/preferences-dialog.ui +++ b/linux/data/resources/ui/preferences-dialog.ui @@ -9,6 +9,17 @@ General preferences-system-symbolic + + + Startup + + + Restore session + Reopen the connections that were open when TablePro quit + + + + Data browser @@ -38,7 +49,12 @@ Query history - Persistent record of every SQL query you run. + + + Save query history + Keep a searchable record of the SQL you run. Statements that contain passwords or connection secrets are never saved. + + Retention (days) diff --git a/linux/data/resources/ui/session-window.ui b/linux/data/resources/ui/session-window.ui new file mode 100644 index 0000000000..f1d470a8cf --- /dev/null +++ b/linux/data/resources/ui/session-window.ui @@ -0,0 +1,15 @@ + + + + + + diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index 70485e2cc8..a8bcc6c94d 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -93,6 +93,38 @@ vertical + + + none + 12 + 12 + 6 + 6 + + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Column + list-add-symbolic + + + + @@ -110,6 +142,38 @@ vertical + + + none + 12 + 12 + 6 + 6 + + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Index… + list-add-symbolic + + + + @@ -127,6 +191,38 @@ vertical + + + none + 12 + 12 + 6 + 6 + + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Foreign Key… + list-add-symbolic + + + + diff --git a/linux/data/resources/ui/workspace.ui b/linux/data/resources/ui/workspace.ui new file mode 100644 index 0000000000..ac7306f84c --- /dev/null +++ b/linux/data/resources/ui/workspace.ui @@ -0,0 +1,216 @@ + + + + + + +
+ + New Window + app.new-window + +
+
+ + Disconnect + win.disconnect + action-disabled + +
+
+ + Query History + win.show-history + + + Saved Queries + win.show-saved-queries + +
+
+ + Import Connections… + win.import-connections + + + Export Connections… + win.export-connections + +
+
+ + Preferences + app.preferences + +
+
+ + Keyboard Shortcuts + app.shortcuts + + + About TablePro + app.about + + + Quit + app.quit + +
+
+
diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 2a28fba5a4..0813e5dc49 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -307,6 +307,8 @@ A run streams `ResultEvent`s into a `ResultSink`: `Connection::dialect()` returns the engine's `SqlDialect`, built from `ServerInfo` at connect. Every builder in `tablepro-core` (pages, filters, row edits, exports) goes through it: quoting, placeholders, literals, the page window, keyset comparison, the engine's row address and type parsing. A dialect never reads session state. `SqlDialect::ddl()` returns the engine's `DdlDialect`, or `None` where the app does not edit the engine's structure. Never match on a driver id in core; add a dialect method. +`SqlDialect::history_recording` says whether SQL the user ran may go into the query history. Implement it as `classify_for_history(, &CREDENTIAL_RULES, sql)`, with a `CredentialRules` const next to the dialect naming the engine's statements, conditional statements and functions that can carry a password or a connection string, and test each one ([storage.md](storage.md#what-goes-into-the-history)). + ### Structure changes `DdlDialect` lives in the driver's `src/ddl.rs`, implemented on the dialect type itself, so `ddl()` returns `Some(self)`. It plans every structure change the Structure tab makes, against the `TableDefinition` that `fetch_table_definition` returned: diff --git a/linux/docs/decisions/0003-relm4-architecture.md b/linux/docs/decisions/0003-relm4-architecture.md index bb32dee1cc..0f684a1a46 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 +- **Status**: Accepted; superseded for windows and the workspace by [0012](0012-window-per-session.md) - **Date**: 2026-04-26 ## Context diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 0d9dfcea7c..a1bad18236 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -13,7 +13,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` ## Rationale -**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE only on SQLite, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. +**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE on SQLite where it has one and a rebuild of the table where it does not, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements, the `ForeignKeyMode` and an optional `DdlPrecondition`, and becomes the `WriteBatch`. The precondition is a count the write reads in its transaction before the first statement, carried as that statement's probe guard, so a plan that writes a table again from what it read refuses to run once the table changed. **Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. @@ -37,7 +37,8 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - Engines that rename one column per statement (PostgreSQL, SQL Server, SQLite, ClickHouse) run the renames in an order where each new name is free, and a swap or longer cycle steps one column aside to `__tablepro_rename_` first. MySQL reads every clause of its one ALTER against the table as it was, so its renames need no order. PostgreSQL runs the drops before the renames when a save has both, so a column can take the name of one the same save drops. - Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays owned by the column. The identity's sequence is therefore found by its internal dependency on the column, not by `pg_get_serial_sequence`, which can return that serial sequence instead. - A plan for a non-empty change writes at least one statement or refuses it, so a save never reports success for a table it left as it was. -- SQLite table rebuilds are a plan of their own, added to the same dialect later, and their capabilities are off until then. +- SQLite changes a column's type, nullability, default or key, and adds or drops a foreign key, by rebuilding the table (lang_altertable.html section 7) under `ForeignKeyMode::SuspendedThenVerified`. The new table is the stored CREATE TABLE with only the edited parts replaced, so what the definition does not model stays as it was written, and a table whose CREATE TABLE cannot be read refuses those changes as `UnreadableDefinition`. +- SQLite's pragmas drop a foreign key's constraint name, so the definition takes it from the stored CREATE TABLE and makes one up for a key declared without one. ## Alternatives considered diff --git a/linux/docs/decisions/0012-window-per-session.md b/linux/docs/decisions/0012-window-per-session.md new file mode 100644 index 0000000000..72de86b1ca --- /dev/null +++ b/linux/docs/decisions/0012-window-per-session.md @@ -0,0 +1,40 @@ +# 0012: One window per connection session, built from gtk-rs subclasses + +- **Status**: Accepted +- **Date**: 2026-10-14 + +## Context + +Each window used to be one Relm4 `App` component that connected, disconnected and switched connections in place. Everything that read the session (tabs, the sidebar, watches, replies still on their way) had to be reset by hand at every switch, and a reply from the old session could land in the new one. `RelmApp::run` opened that one window, and a `flock` on a file in the runtime directory kept a second launch out. + +The 2026-09-14 dependency review found Relm4 used by none of the 31 Rust apps in GNOME Core and Circle, and found the plan wrapping its runtime and spawn functions and banning parts of its API to work around it. + +## Decision + +The process is one `TableProApplication`, an `adw::Application` subclass that owns the windows, the app actions and the record of open sessions, and keeps one process per profile through GApplication's D-Bus uniqueness. A window is a `SessionWindow` that holds at most one connection session: it shows the picker, the connect in progress, the failed connect, then the session's `Workspace`, and a disconnect tears the workspace down and returns to the picker. `SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files in the GResource, not Relm4 components. Their actions go on the window's own action map through `add_action_entries`, and their input goes through `runtime::input_queue`, handled one message at a time on the GTK thread. + +## Rationale + +- A workspace that never changes session has nothing to reset. Its teardown writes what it shows, closes its input queue and drops it before the session closes, so a late reply has nowhere to land. +- Every connect claims the session's key in the `SessionRegistry` first, so a second window asking for the same database brings the first forward instead of connecting. +- A second launch forwards an activate, the files it was given or `app.new-window` to the running process over D-Bus, so Open With and `--new-window` reach the windows that already exist, and Quit can ask each window through its own prompts. +- Subclasses with templates are what GNOME's Rust apps and the gtk-rs book use. The input queue keeps what Relm4 gave: one message at a time, never handled inside the signal or action that sent it, and dropped once the widget is gone. + +## Consequences + +- This supersedes [0003](0003-relm4-architecture.md) for windows and the workspace. The Relm4 components that remain follow 0003 until a rewrite turns them into subclasses, so two patterns coexist until then. +- A message sent to a window or workspace after it shut down is dropped. One that owns something, such as a connection that finished connecting, is closed instead. +- Ctrl+W and the close prompts belong to the window, so they work on every page, not only while a session is open. +- Each window records the session it adopts, so a launch can open them again when `restore-session` is on. + +## Alternatives considered + +**Keep one `App` per window that swaps sessions.** The source of the cross-session replies and the per-switch resets. + +**A Relm4 `SessionWindow` component.** Keeps the wrappers the dependency review removes. + +**One process-wide session service shared by every window.** Ties every window to one session's lifetime and brings back the reset problem one level up. + +**A `relm4::RelmApp` root.** It builds an application of its own around one root component, which knows neither the other windows nor the app actions, so every second window, Quit and D-Bus activation would work around it. + +**A `flock` for one instance.** A hand-written gate for what GApplication already does, and it only refuses a second launch: that launch's files and `--new-window` still have to reach the running process. diff --git a/linux/docs/decisions/0013-openssh-client-transport.md b/linux/docs/decisions/0013-openssh-client-transport.md index 494d4bfb9f..325f90fe84 100644 --- a/linux/docs/decisions/0013-openssh-client-transport.md +++ b/linux/docs/decisions/0013-openssh-client-transport.md @@ -22,6 +22,7 @@ People reach their databases through bastions configured in `~/.ssh/config`: `Pr - openssh-client 8.4 or later is a runtime dependency; the GNOME 50 Flatpak runtime ships it. - The helper is installed to libexecdir next to the app. +- A jump host runs in a second ssh that gets only `-l`, `-p`, `-J` and `-F` from the master (ssh.c turns ProxyJump into a ProxyCommand), never its `-o` options. When a hop's configuration sets `StrictHostKeyChecking accept-new` or `no`, the master gets a generated `-F` file that sets `ask` for that host, includes `~/.ssh/config` and then copies in `/etc/ssh/ssh_config`, so the hop asks through the same dialog. The system file is copied because OpenSSH holds an `Include` to an owner check it skips for that file, and in a Flatpak sandbox root's files belong to nobody. Copied in, a relative `Include` inside it resolves under `~/.ssh` rather than `/etc/ssh`. The `~` of `Include ~/.ssh/config` follows `HOME`, where ssh's own read of the user file follows the password database. A hop whose `ssh -G` lookup fails or takes over 5 seconds is not pinned, and neither is any hop before it. Every other connection reads the configuration as before. - Failures are classified from OpenSSH's stderr, pinned by captured fixtures. A change to those strings in a later OpenSSH release shows up as a failing fixture test. - russh, ssh-key and internal-russh-forked-ssh-key leave the dependency graph when the app moves to this transport. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index d3b6e9c058..c7e28eb3c5 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -41,13 +41,14 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin |---|---|---|---| | [0001](0001-no-plugin-system.md) | No plugin system; drivers are static | Accepted | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | | [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | -| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted | Elm-style components scale to TablePro's view count. | +| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded in part by 0012 | Elm-style components scale to TablePro's view count. | | [0004](0004-libsecret-secret-storage.md) | libsecret via oo7 for password storage | Accepted | Secret Service API is the universal Linux secret backend. | | [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted | 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 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | +| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md index c7417cb847..b98f6e158b 100644 --- a/linux/docs/drivers/sqlite.md +++ b/linux/docs/drivers/sqlite.md @@ -67,6 +67,26 @@ A `COMMIT` never crosses a network, so nothing is left to resolve afterwards (`C The grid names a row by its primary key, then by a unique index whose columns are all `NOT NULL`, then by `rowid`. A `WITHOUT ROWID` table with neither key, or a table with a column named `rowid` that hides the real one, is read-only in the grid. +## Structure changes + +`ALTER TABLE` renames a table or a column, adds a column, and drops one. The DDL dialect uses it where SQLite allows the change: an added column with no key, a constant default, and NOT NULL only with a default; a dropped column that has no key or UNIQUE of its own. Every other change rebuilds the table, following the procedure in [lang_altertable.html](https://www.sqlite.org/lang_altertable.html#otheralter), in one batch under `ForeignKeyMode::SuspendedThenVerified`: + +1. Drop the views, and the triggers on other tables, that name the table, since the rename in step 5 checks every one of them. +2. Create `_tablepro_rebuild_` from the stored CREATE TABLE with only the edited parts replaced: a column's type, NULL, DEFAULT and key, the key itself, and the foreign keys added or dropped. A COLLATE, CHECK, generated expression, conflict clause, comment or `WITHOUT ROWID` the edit did not touch stays as it was written. A removed constraint takes the spaces before it but not a newline, which may end a `--` comment. +3. Copy the rows with `INSERT OR ABORT`, so an `ON CONFLICT IGNORE` or `REPLACE` the table declares fails the save rather than dropping a row. Rowids come along unless the table has none or a copied column becomes the rowid. A generated column is computed again. +4. With AUTOINCREMENT on both sides, carry the `sqlite_sequence` mark, so a deleted number is not handed out again. +5. Drop the old table and rename the new one into its place. +6. Make the indexes, triggers and views again from their stored SQL, in the order the schema first made them. +7. Rename columns, then the table, natively, so SQLite carries the new names into every index, trigger, view and foreign key. + +Before step 1, inside the same transaction, the plan's `DdlPrecondition` counts what differs from the schema the plan was built from: the table's CREATE TABLE, its own indexes and triggers, the views and triggers elsewhere the rebuild makes again, and the key columns of other tables that point at it. Anything but zero refuses the save as `RowGuardFailed`, since the rebuild would write the table again without that change. The Structure tab then reads the table again on Discard. + +The foreign key check covers the rebuilt table and every table whose keys point at it, and fails the save only for a violation the save introduced. Where a column becomes the `INTEGER PRIMARY KEY`, its values become the rowids, so the rebuilt table's violations compare by count rather than by rowid. A rebuild refuses to drop a column that a kept index, CHECK, generated column, foreign key, trigger or view still names; in a trigger on another table, `new.x` and `old.x` are that table's columns and do not count. A table whose CREATE TABLE the reader cannot follow, such as a virtual table, keeps the native changes, offers no foreign key or key change, and refuses the rest as `UnreadableDefinition`. + +SQLite always numbers its rowid column, a lone `INTEGER PRIMARY KEY` not declared `PRIMARY KEY DESC` on the column itself, and takes AUTOINCREMENT only there. So numbering turns off only together with a change of the column's type or of the key (`AlwaysNumbered` otherwise), and a column that keeps AUTOINCREMENT keeps the type `INTEGER` (`NotAnInteger`). + +SQLite's pragmas do not report a foreign key's constraint name, so the definition reads it from the CREATE TABLE text, and a key declared without one is named `fk_
_` after its position. + ## Editor sessions - A script is split where SQLite itself would end a statement, so the semicolons inside a trigger body do not end it. Text the splitter cannot finish, such as an unterminated string, goes to SQLite whole, so the error is SQLite's own. The splitter is tested against `sqlite3_complete` (`tests/script_plan_matches_sqlite3_complete.rs`). diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index d18e19cbcd..ed6b61bb18 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -1,138 +1,102 @@ -# State management with Relm4 +# State management -The `app` crate uses [Relm4](https://relm4.org) on top of gtk4-rs. The choice is recorded in [decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md). This file describes the patterns we follow inside the codebase. Read the official Relm4 book first; this document only covers the conventions specific to TablePro Linux. +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)): `WelcomeView`, the `ConnectionRow` and `SidebarRow` factories, and the history, 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. -## When to use which component flavour +## Where state lives -| Flavour | Use when | -|---|---| -| `Component` | Synchronous init, no async work in `update`. Default choice. | -| `AsyncComponent` | Loading data on init or in update is the core of the component. Connection list, table content viewer. | -| `SimpleComponent` | A leaf widget with no `Output` to its parent. Avoid; very few cases. | -| `Factory` (`FactoryComponent`) | A homogeneous list of children driven by a model. Connection sidebar entries, tab strip items. | -| `Worker` | Background unit that does not own widgets. Use for the driver registry interaction; receives requests, returns results. | +| State | Owner | Lives as long as | +|---|---|---| +| Driver registry, connector, deadlines, settings, storage, `Tasks`, `SessionRegistry`, query history, the open-sessions record | `AppServices`, built by `run()` and handed to every window | the process | +| The claim on a session's key | the window's `SessionPage` | the page | +| The connection | `Session`, shared by the window's page and its workspace | the page, until the teardown closes it | +| Schemas, objects and columns | the session's `SchemaCatalog` | the session | +| Tabs, pending edits, running saves | the `Workspace`: `ChangeTrackerRegistry`, `StructureTrackerRegistry`, `WorkspaceHandle` with its `SaveLedger` | the workspace | +| A tab's rows, filter and requests | the tab's model, in its `imp` struct | the tab | -If you find yourself reaching for a static `Mutex`, you are not using the framework. Stop and re-read the model. +Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `crates/app/src/services`, since a registry shared that way would show one window's unsaved edits in every other window. -## Component skeleton +## Rules for every view -```rust -use relm4::{Component, ComponentParts, ComponentSender}; - -pub struct ConnectionListModel { - connections: Vec, - selected: Option, -} - -#[derive(Debug)] -pub enum ConnectionListInput { - Select(ConnectionId), - Connect(ConnectionId), - Delete(ConnectionId), - Reload, -} - -#[derive(Debug)] -pub enum ConnectionListOutput { - OpenConnection(SavedConnection), -} - -#[derive(Debug)] -pub enum ConnectionListCmd { - Reloaded(Vec), -} - -impl Component for ConnectionListModel { - type Init = (); - type Input = ConnectionListInput; - type Output = ConnectionListOutput; - type CommandOutput = ConnectionListCmd; - type Root = gtk::Box; - type Widgets = ConnectionListWidgets; - - fn init(_: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - // Build widgets, attach handlers, return ComponentParts - } +**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 the user expects to finish, such as a save. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current`, `relm4::spawn` and their siblings, so nothing reaches for whichever runtime happens to be ambient. - fn update(&mut self, msg: Self::Input, sender: ComponentSender, _: &Self::Root) { - match msg { - ConnectionListInput::Select(id) => self.selected = Some(id), - ConnectionListInput::Reload => sender.command(|out, shutdown| { - shutdown.register(async move { - let conns = storage::load_connections().await.unwrap_or_default(); - out.send(ConnectionListCmd::Reloaded(conns)).ok(); - }).drop_on_shutdown() - }), - // ... - } - } +**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. - fn update_cmd(&mut self, msg: Self::CommandOutput, _: ComponentSender, _: &Self::Root) { - match msg { - ConnectionListCmd::Reloaded(conns) => self.connections = conns, - } - } -} -``` +**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. -Naming: +**A view holds the session, never a connection.** Every read and write goes through the session (`Session::run`, `run_cancellable`, the catalog reads), which leases the current link through its `OperationRunner` and carries the deadline to the driver. A view asks the session for the dialect (`current_dialect`) and asks again when the link changes, since a reconnect can reach another server version. A save plans with the dialect of the connection that runs it. -- Model: `Model`. Holds private state. -- Input: `Input`, an enum. Every UI interaction is a message. -- Output: `Output`, an enum. Only the messages a parent should react to. -- Command output: `Cmd`, an enum. Async work finishes by sending one of these back. +**One request of each kind is current.** A browse or structure tab keeps a `RequestSequencer` keyed by request kind. `issue` returns a `RequestTicket` with a stamp and a cancel token, and stops the request of that kind still running, so the server drops the statement. `accept` takes only the reply to the current request; a reply to a replaced one is dropped. Dropping the sequencer stops every request, which is how closing a tab stops its reads. -## Async work via commands +**A failed read is asked again on the next link.** `FailedRequests` holds the kinds whose last accepted reply failed. When `link_changes` reports a newer `LinkGeneration`, the tab asks those again on the new connection; a reply that failed after the link had already moved is asked again at once. A tab whose reads all landed keeps its rows. -Components do not call `tokio::spawn` directly. They issue commands: +**The catalog belongs to the session.** `SchemaCatalog` (`crates/session/src/session/schema_catalog.rs`) holds the schemas, each scope's objects and each table's columns. Views read through `Session::schemas`, `objects` and `columns`, or `refresh_objects` and `refresh_columns` to fetch again. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A schema change calls `invalidate_table`, which marks the table stale and keeps its data until the next fetch lands. -```rust -sender.command(|out, shutdown| { - shutdown - .register(async move { - let result = some_async_work().await; - out.send(MyCmd::Done(result)).ok(); - }) - .drop_on_shutdown() -}) -``` +**Saves go through the workspace.** `WorkspaceHandle` starts, stops and settles every save. Its `SaveLedger` records each running save with its tab, its kind, a cancel token and a process hold, and refuses a second save of the same kind for the same tab. Stop Saving, or a close that chose to stop, cancels the token, which reaches the driver through `run_cancellable`; the save settles when the driver answers. + +**Closing asks through the window's prompts.** `CloseGuards` runs one `CloseGuard` at a time per window, so a tab's close and the window's never ask about the same tab at once. A guard reads the workspace again after every answer and every settled save: it offers to save, waits for or stops running saves, asks about a save whose outcome is unknown, and confirms open transactions. A disconnect asks the same prompts before the teardown. + +## Windows and the workspace -The command runs on the runtime owned by `app::runtime`. It is automatically cancelled when the component is destroyed. **Always use `drop_on_shutdown`** unless the work is critical to complete (rare; saving user data is the main case). +`SessionWindow` and `Workspace` each own an input queue from `runtime::input_queue`: -## Talking to drivers +- Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. +- Shutdown aborts the pump, which closes the queue. A queued message that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. +- Window actions go on the window's own action map with `add_action_entries`, which GTK exports as `win`; CI fails on a group inserted as `win`. The workspace removes the actions it added when it shuts down. +- The workspace reports to the window through signals (`title-changed`, `dirty-changed`, `saves-in-flight-changed`), and the window turns each into one of its own inputs. -Driver interaction is centralised in `app::services::DatabaseService`. Components never call `core::DriverRegistry` directly. They send a request to the service worker and receive a typed reply. +A background read follows this shape, trimmed from `SessionWindow::on_restore`, which looks up the saved connection `id` names: ```rust -let req = DatabaseRequest::FetchRows { - connection_id, - table: "users".into(), - offset: 0, - limit: 1000, +let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; }; -db_service.send(req); -// In update_cmd: -DatabaseReply::Rows { rows, .. } => { /* update model */ } +let store = state.services.storage.connections().clone(); +let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); +glib::spawn_future_local(async move { + if let Ok(Ok(connections)) = reading.await { + let saved = connections.iter().find(|saved| saved.id == id).cloned().map(Box::new); + input.deliver(SessionWindowInput::RestoreSaved { id, saved }); + } +}); ``` -Why centralise: connection lifecycles, retries, health pings, cancellation, logging are concerns the UI must not see. +The closure holds only the sender, so a window that closed before the read ends is not kept alive, and the answer is dropped. -## State that does not belong in a component +## Tabs -Some state is genuinely global: open connections, the driver registry, app settings. We model these as `Worker` components or as `Arc>` owned by `app::main` and passed to component `init` payloads. Never reach for global statics other than the tokio runtime handle and the application identifier. +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. -## Anti-patterns to flag in review +## Relm4 components + +The components that remain follow these conventions until a rewrite turns them into subclasses: + +- Input reaches them through `deliver`, and their output reaches the subclass that hosts them through `connect_receiver` or `runtime::relm4_bridge`, which put it on that subclass's queue. +- Their commands (`sender.command`, `oneshot_command`) run on Relm4's own runtime, not on `Tasks`. The history and saved-queries dialogs still load 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 -- `gtk::glib::clone!` capturing `&mut` references to model fields. Use `ComponentSender` and route via `Input`. -- Async work spawned with raw `tokio::spawn` from inside a component. Use `sender.command`. -- Components reading from a `Mutex`. Pass state in via `Init` or via parent → child `Input`. -- A `Component` doing async work in its `init`. Promote to `AsyncComponent`. -- One enormous `Input` enum with 30 variants. Split the component. +- `Input`: every message a view handles, as an enum (`SessionWindowInput`, `WorkspaceInput`, `BrowseTabInput`). +- `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. + +## Anti-patterns to flag in review -## Testing components +- `tokio::spawn`, `relm4::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. +- State in a `thread_local!` or a static. It belongs to the workspace or to `AppServices`. +- A `glib::clone!` that captures a view strongly in a callback the view itself holds. Capture its input sender or a weak reference. +- Blocking work on the GTK thread: `std::fs`, a `_blocking` call outside `spawn_blocking_task`, or `MainContext::block_on`. -Relm4 ships test helpers but they require a running GTK main loop, which is awkward in CI. Our policy: +## Testing -- Test pure logic by extracting it into plain Rust functions or a separate `services` module. Test those. -- Do not write component-level tests until we hit a bug that they would have caught. -- Prefer integration tests at the driver layer and unit tests at the model layer. +[testing.md](testing.md#app--ui-tests) covers widget tests, the test windows and the application tests. diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 846454d1b6..1c8c97f7f0 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -72,6 +72,54 @@ Notes: - 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. - 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 + +`KeyringCredentials` reads each secret at every connect. What happens when +it cannot depends on whether someone is there to answer: + +- A connect the user started, and the connect dialog's Test, run attended. + A secret that is not stored, or that a locked or missing keyring cannot + give, is asked for in a dialog over the window. A missing Secret Service + or portal offers no Remember switch, since nothing could keep it. +- An SSH key with no stored passphrase is left to ssh, which asks through + the same prompts at the hop that needs it. +- A secret typed with Remember on is stored only once the login it was + typed for works. A store the keyring refuses is reported in a toast, and + the connection stays open. +- A login the server refused offers Enter Password on the failed page. That + retry passes `CredentialDemand::reenter`, which asks again without reading + the keyring's copy, the one just refused. +- A secret typed for a session that the keyring does not hold, because + Remember was off or the keyring refused it, is the one that session's + reconnects use (`KeyringCredentials::keeping_session_secrets`). Otherwise a + reconnect would log in with the stored copy the server refused. +- Background reconnects run unattended and never ask. A missing secret parks + the session until the user presses Retry, which runs attended. +- A typed secret lives in a `SecretString`, and the prompt clears its rows + as it hands the answer over. + +## Deleting a connection + +`ConnectionDeletion::run` removes a saved connection in an order that +leaves nothing unreachable behind when a step fails: + +1. The keyring secrets. If the keyring refuses, nothing else runs and the + entry stays: an entry with no secrets only asks for them again, while a + secret with no entry is orphaned in the keyring. +2. The entry in `connections.json`. +3. Everything kept for it: column widths, filters, the saved tabs with any + copy Reset moved aside, the editor drafts (`WorkspaceStore::purge`), the + saved queries, and the query history when the user turned that on. A + failure here is reported and does not bring the entry back. + +A connection open or connecting in a window is not deleted; the dialog +offers that window instead. The delete claims the connection's key while it +runs, so a connect started meanwhile from a picker that still lists it is +refused, and opening it from such a picker afterwards says it was deleted. +The purge waits for the drafts a just-closed workspace is still writing +(`WorkspaceStore::writes_in_flight`). The window holds the process until the +delete ends, as it does for every other write to the connection list. + ## Where files go `tablepro_storage::StoragePaths` resolves every directory once at startup @@ -86,7 +134,6 @@ base directories and their fallbacks. | `history_database()` | `$XDG_STATE_HOME//history.db` | | `workspaces_dir()` | `$XDG_STATE_HOME//workspaces` | | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | -| `instance_lock()` | `$XDG_RUNTIME_DIR/app//tablepro.lock` | `` is `tablepro` for an installed build and `tablepro-devel` for a development one, so `cargo run` never writes over an installed build's @@ -132,6 +179,28 @@ When it deleted anything it runs FTS5 `optimize`, because the delete triggers otherwise leave tombstones that grow the index as the table shrinks. It returns a `PruneReport { expired, over_cap }`. +### What goes into the history + +`HistoryService::record` is the one way in, for editor runs, saves and +imports. It records nothing while `save-query-history` is off, or when the +connection has no dialect, such as while its link is down. Otherwise the +dialect's `history_recording` decides, reading the SQL with the editor's own +script lexer: + +- A statement that can carry a credential withholds the whole entry. Each + engine lists its own in a `CredentialRules`: logins, roles, keys and links + to other servers (`CREATE USER`, `CHANGE MASTER`), statements that do so + only with a given word (`GRANT ... IDENTIFIED`), and functions or engines + that take a password (`dblink`, `OPENROWSET`, `s3`). A routine body is + read for the statements inside it, whether dollar-quoted or, on + PostgreSQL, a plain string after `DO` or `AS`. So is a MySQL `/*!` + comment, which the server runs. +- A string, quoted name or comment that never closes withholds it too, since + what is inside a literal is then a guess. + +An entry is kept whole or not at all, never rewritten to cut a secret out. +Turning the setting off keeps what is already there; Clear removes it. + ## App preferences with `gio::Settings` `data/app.tablepro.TablePro.gschema.xml` holds every app-wide preference and the window geometry. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). @@ -145,6 +214,8 @@ Schema id and path: `app.tablepro.TablePro`. | `is-maximized` | `b` | `false` | | | `default-page-size` | `u` | `1000` | 100 to 100000 | | `confirm-destructive` | `b` | `true` | | +| `restore-session` | `b` | `true` | | +| `save-query-history` | `b` | `true` | | | `history-retention-days` | `u` | `30` | 0 to 365 | | `query-timeout-secs` | `u` | `60` | 0 to 3600 | | `use-system-font` | `b` | `true` | | diff --git a/linux/docs/testing.md b/linux/docs/testing.md index e38c41ccbe..ce71c18e81 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -4,7 +4,7 @@ Three layers, three tools. Each crate's test policy follows from its position in | Crate | Layer | Tools | Required for merge? | |---|---|---|---| -| `core` | Pure traits + types | Unit tests in `src/`, table-driven for type mappers | Yes | +| `core` | Pure traits + types | Unit tests in `crates/core/src/`, table-driven for type mappers | Yes | | `storage` | Filesystem + libsecret + GSchema | Unit tests + integration tests with `tempfile` | Yes | | `session` | Sessions, connect, health | Unit tests with the `fakes` feature + `testcontainers-rs` tests | Yes | | `drivers/` | Real engines | Unit tests, the conformance suites, `testcontainers-rs` integration tests | Yes | @@ -144,6 +144,7 @@ Every case plans through `connection.dialect().ddl()` and runs the plan with `wr - `ddl_plans_apply_and_read_back` creates a table with a quoted literal default and an expression default, then adds, renames and drops a column, changes its nullability and default, adds an index and a foreign key, renames the table, which has to stay listed in its own schema, and drops the tables. After each step `fetch_table_definition` has to match the draft. A step the dialect refuses has to be refused by its plan as well, with a reason, so no step is skipped without a check. Once the foreign key exists, the referenced primary key has to read as held by it. - `failed_ddl_batch_outcome_matches_ddl_atomicity` sends two plans whose second statement the server rejects, and checks the outcome against `ddl_writes`: `RolledBack` and nothing applied, or `PartiallyApplied` and the first plan applied. - `create_table_script_recreates_the_table` drops a table and runs its `create_table_script`, which has to bring back the same column definitions and index keys. +- `structure_changes_keep_rows_and_indexes` fills an indexed table, then changes a default and a nullability. The rows and the index have to come through, which on SQLite means through the table rebuild. The PostgreSQL, MySQL, SQL Server, ClickHouse and SQLite targets run it, and the MySQL target runs it on MariaDB as well. SQLite passes `docker: false`, so its cases run in the unit suite. @@ -218,9 +219,11 @@ GTK code is tested with `#[gtk4::test]`, which runs each test on a single GTK th Helpers live in `crates/app/src/test_support`, compiled only for tests: -- `drain_main_context()` runs pending main-loop sources. `wait_until(timeout, condition)` iterates the main loop until the condition holds or the timeout passes, so a missing signal fails the test instead of hanging it. +- `drain_main_context()` runs pending main-loop sources. `wait_until(timeout, condition)` iterates the main loop until the condition holds or the timeout passes, so a missing signal fails the test instead of hanging it. `finish_on_main(timeout, future)` runs a future on the main context and gives it up at the timeout. - `SignalLog::connect(object, signal, extract)` records emissions and disconnects when dropped. - `descendants`, `find_by_action_name` and `first_descendant_of_type` walk a widget tree. +- `MemorySettings` compiles the schema into a `TempDir` behind the memory backend, so a test never touches the developer's dconf database. +- `FakeSession` is a session over a fake connection; `FakeClocks` and `FakeProbe` stand in for the suspend clocks and the reachability check. - `assert_labelled(widget)` checks that a screen reader can name a control: - an `adw::PreferencesRow` needs a non-empty title; - an `adw::EntryRow` or `adw::PasswordEntryRow` must keep its inner text field labelled by that title; @@ -229,16 +232,68 @@ Helpers live in `crates/app/src/test_support`, compiled only for tests: CI sets `GTK_A11Y=test` so results never depend on an AT-SPI bus. With `GTK_A11Y=none`, GTK records no accessible properties and `assert_labelled` returns `TestBackendMissing` instead of a wrong verdict. +Two harnesses build the app's real objects over fakes: + +- `TestWindow` (`crates/app/src/ui/session_window/tests.rs`) starts a `TableProApplication` with one window on the picker, a saved connection in its list and a `FakeConnector`. `TestWindow::connected` opens that connection and waits for its workspace. Each test registers an application id of its own, so it never meets another test, or a TablePro the developer has open, on the session bus. `test.hold` is the application's `CountingHold`. +- `workspace::test_workspace::Workspace` builds a `WorkspaceHandle` with one tab over a scripted connection, for the save and close prompt tests. + +Both turn GTK animations off, so a dialog or a page change finishes before the next line runs. + +### Background work and time + +A `#[gtk4::test]` has no tokio runtime. `test_runtime()` builds an `AppRuntime` the test owns, and its `tasks()` go to the code under test. A `#[tokio::test]` gets the `Tasks` of its own runtime from `paused_tasks()`, and with `start_paused = true` tokio's clock stands still and jumps to the next timer whenever every task is waiting, so a test of a deadline, a backoff or a debounce runs in no time and never flakes on a slow runner. The session crate's tests use the same pattern for the health monitor, the catalog and the operation runner. + +### Holds + +`CountingHold` (`tablepro_session::runtime`) is the test `ProcessHold`: it counts live tokens. A test that work holds the process checks that `live()` is 1 while the work runs and 0 once it ends, and that the work's effect is in place before the count drops. + +### Dialogs + +- `workspace::answer(window, response)` clicks the button of the alert `window` shows, so the alert closes itself and emits its response in the order a user's click does. `workspace::visible_alert(window)` returns that alert. +- `test_support::respond(dialog, response)` clicks the button of a presented alert the test holds. Never emit `response` by name: that skips the close a click makes first, so a `closed` handler sees the signals in an order no user can produce. + +A test that a step logs no GLib critical turns criticals fatal around that step and restores the old mask after: + +```rust +let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); +let answered = crate::workspace::answer(&test.window, "reset"); +crate::test_support::drain_main_context(); +glib::log_set_always_fatal(previous); +``` + +A critical then aborts the process with the message, for example libadwaita's "Trying to close AdwAlertDialog ... that's not presented". cargo-nextest runs each test in a process of its own, so it reports that one test as failed. `cargo test`, which the Meson `gtk` suite runs, has every test of the `tablepro` library in one process: the abort ends the whole binary and the tests after it never run. + +### Fakes + +`tablepro-test-support` holds the driver contract's fakes, which every crate's tests share: `FakeDriver`, `FakeConnection` scripted through `ConnectionScript`, `FakeEditorSession` with `FakeRun`, `FakeSecretVault`, `FakePrompter` and `FaultTransport`. A `FakeReply` answers at once, after a delay unless the deadline or cancel comes first, late whatever the caller asked, only once stopped, or never. `CallCounters` counts the calls a connection received after it was handed over. `tablepro_session::fakes` adds `FakeConnector` and `FakeCredentialSource` behind the session crate's `fakes` feature, which only the app's dev-dependencies turn on. + +### Application tests + +The tests in `crates/app/tests` that start TablePro run without the test harness (`harness = false` in `crates/app/Cargo.toml`): GTK allows one application per process, on its main thread. Each binary has its own `main` and runs TablePro in child processes. The ones that talk to it over D-Bus give it a private session bus through `gio::TestDBus`, so a TablePro the developer has open never answers. + +| Test | What it checks | +|---|---| +| `application_uniqueness` | a second plain launch activates the running instance and exits without opening a window, and `--new-window` opens one there | +| `application_lifecycle` | Quit waits for a session's close within the close timeout and gives up on a close that hangs, a cancelled close keeps the app running, and an editor session's close holds the process | +| `application_session_restore` | the windows open at quit open again at the next launch, and a window closed first does not | +| `open_files` | a file handed over D-Bus opens a window on it, the same file brings that window forward, and another file opens a second window | +| `startup`, `i18n_catalog`, `logging` | the command-line options, a missing schema, the translation catalogue and the log setup | + +They need a display and a session bus, so run them the way CI does: + +```bash +xvfb-run -a dbus-run-session -- meson test -C _build --suite gtk --print-errorlogs +``` + +or one at a time, `xvfb-run -a dbus-run-session -- meson devenv -C _build cargo test -p tablepro --test application_lifecycle`. `startup` needs the build Meson configures, so it fails under plain Cargo. cargo-nextest asks each test binary to list its tests, which a harness-free binary does not do, so run these with `cargo test`. + Policy: - Pure logic: extract to `app::services::` and write plain unit tests. - Widgets the app builds: a `#[gtk4::test]` that constructs the widget, drives it through its actions or signals, checks the result, and runs `assert_labelled` on the controls a user reaches. +- A flow across windows or launches: an application test. - UI changes still carry before and after screenshots in the PR description. -## End-to-end test - -There is no app-level end-to-end test yet. Driving the GTK app under `xvfb-run` through its registered `gtk::Application` actions is the intended shape when we add one. - ## CI GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner: @@ -254,6 +309,6 @@ Tracked with `cargo-llvm-cov` once the codebase has substance. No hard coverage ## Mocking -Avoid mock objects. We do not mock drivers, the filesystem, or `tokio::time`. Either use a real implementation (testcontainers, `tempfile`, `tokio::time::pause`) or extract the logic to a pure function and test that. +Avoid mock objects. We do not mock drivers, the filesystem, or `tokio::time`. Either use a real implementation (testcontainers, `tempfile`, `tokio::time::pause`) or extract the logic to a pure function and test that. The fakes in `tablepro-test-support` are not mocks: they implement the driver contract and keep its rules, so the code under test meets what a conforming driver does, and each real driver runs the conformance suites against its own server. If a test cannot be written without a mock, the design is wrong. Refactor before writing the mock. diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 27b2ed9ca2..f80aabcfbb 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,3 +1,15 @@ +crates/app/src/application/app_actions.rs +crates/app/src/application/app_services.rs +crates/app/src/application/app_window_host.rs +crates/app/src/application/imp.rs +crates/app/src/application/mod.rs +crates/app/src/application/open_files.rs +crates/app/src/application/quit_sequence.rs +crates/app/src/application/session_signals.rs +crates/app/src/application/tests.rs +crates/app/src/application/window_host.rs +crates/app/src/application/window_launch.rs +crates/app/src/application/window_registry.rs crates/app/src/config.rs crates/app/src/i18n.rs crates/app/src/lib.rs @@ -10,21 +22,38 @@ crates/app/src/persistence/filter_settings_store.rs crates/app/src/persistence/mod.rs crates/app/src/persistence/state_file.rs crates/app/src/runtime/app_runtime.rs +crates/app/src/runtime/clock_sample.rs +crates/app/src/runtime/clock_source.rs crates/app/src/runtime/deliver.rs +crates/app/src/runtime/deliver_input.rs +crates/app/src/runtime/detect_resume.rs crates/app/src/runtime/failed_requests.rs crates/app/src/runtime/gio_process_hold.rs +crates/app/src/runtime/gio_reachability.rs +crates/app/src/runtime/input_queue.rs crates/app/src/runtime/mod.rs +crates/app/src/runtime/network_watch.rs +crates/app/src/runtime/reachability_fanout.rs +crates/app/src/runtime/reachability_probe.rs crates/app/src/runtime/request_sequencer.rs crates/app/src/runtime/request_stamp.rs crates/app/src/runtime/request_ticket.rs +crates/app/src/runtime/resume_detector.rs crates/app/src/runtime/stamped.rs +crates/app/src/runtime/system_clocks.rs crates/app/src/services/active_session.rs crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs crates/app/src/services/change_tracker_registry.rs crates/app/src/services/column_relayout.rs -crates/app/src/services/from_session.rs +crates/app/src/services/connection_deletion.rs +crates/app/src/services/deletion_error.rs +crates/app/src/services/deletion_report.rs +crates/app/src/services/file_location.rs +crates/app/src/services/file_location_kind.rs +crates/app/src/services/file_resolution.rs crates/app/src/services/history_availability.rs +crates/app/src/services/history_preferences.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs crates/app/src/services/pending_structure.rs @@ -32,7 +61,7 @@ crates/app/src/services/save_batch.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs -crates/app/src/services/single_instance.rs +crates/app/src/services/ssh_environment.rs crates/app/src/services/structure_tracker.rs crates/app/src/services/structure_tracker_handle.rs crates/app/src/services/structure_tracker_registry.rs @@ -45,6 +74,9 @@ crates/app/src/services/undo_op.rs crates/app/src/storage.rs crates/app/src/test_support.rs crates/app/src/test_support/accessible_label.rs +crates/app/src/test_support/alert_response.rs +crates/app/src/test_support/fake_clocks.rs +crates/app/src/test_support/fake_reachability.rs crates/app/src/test_support/fake_session.rs crates/app/src/test_support/main_loop.rs crates/app/src/test_support/memory_settings.rs @@ -53,25 +85,15 @@ crates/app/src/test_support/tests.rs crates/app/src/test_support/unlabelled_widget.rs crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs -crates/app/src/ui/app/close_flow.rs -crates/app/src/ui/app/connect_flow.rs -crates/app/src/ui/app/connection.rs -crates/app/src/ui/app/import.rs -crates/app/src/ui/app/mod.rs -crates/app/src/ui/app/row_ops.rs -crates/app/src/ui/app/saves.rs -crates/app/src/ui/app/sidebar_section_header.rs -crates/app/src/ui/app/status_pages.rs -crates/app/src/ui/app/structure.rs -crates/app/src/ui/app/window_tabs.rs -crates/app/src/ui/app/workspace_persistence.rs -crates/app/src/ui/app/workspace_tabs.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_request_kind.rs +crates/app/src/ui/browse_tab/confirming_delete.rs crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/mod.rs crates/app/src/ui/browse_tab/page_reply.rs +crates/app/src/ui/browse_tab/page_state.rs +crates/app/src/ui/browse_tab/row_deletion.rs crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs @@ -93,6 +115,7 @@ crates/app/src/ui/credential_prompt/presenter.rs crates/app/src/ui/credential_prompt/prompt_form.rs crates/app/src/ui/credential_prompt/prompt_text.rs crates/app/src/ui/credential_prompt/prompter.rs +crates/app/src/ui/delete_connection_dialog.rs crates/app/src/ui/editor/buffer_edit.rs crates/app/src/ui/editor/completion/catalog_read.rs crates/app/src/ui/editor/completion/completion_scope.rs @@ -109,6 +132,7 @@ crates/app/src/ui/editor/format_plan.rs crates/app/src/ui/editor/imp.rs crates/app/src/ui/editor/mod.rs crates/app/src/ui/editor/result_item.rs +crates/app/src/ui/editor/result_rows.rs crates/app/src/ui/editor/result_widgets.rs crates/app/src/ui/editor/results_pane.rs crates/app/src/ui/editor/run_history.rs @@ -124,6 +148,7 @@ crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor_font_provider.rs crates/app/src/ui/error_text/ddl.rs crates/app/src/ui/error_text/driver.rs +crates/app/src/ui/error_text/edit.rs crates/app/src/ui/error_text/mod.rs crates/app/src/ui/error_text/operation.rs crates/app/src/ui/error_text/script.rs @@ -134,65 +159,138 @@ crates/app/src/ui/error_text/tls.rs crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs crates/app/src/ui/file_read.rs +crates/app/src/ui/file_write.rs crates/app/src/ui/filter_strip.rs +crates/app/src/ui/grid/calendar_editor.rs +crates/app/src/ui/grid/cell_accessibility.rs crates/app/src/ui/grid/cell_binding.rs +crates/app/src/ui/grid/cell_edit.rs crates/app/src/ui/grid/cell_editability.rs -crates/app/src/ui/grid/cell_editor.rs crates/app/src/ui/grid/cell_editor_kind.rs +crates/app/src/ui/grid/cell_focus.rs crates/app/src/ui/grid/cell_lock.rs -crates/app/src/ui/grid/cell_popovers.rs crates/app/src/ui/grid/cell_preset.rs +crates/app/src/ui/grid/cell_tooltip.rs crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs +crates/app/src/ui/grid/delete_availability.rs crates/app/src/ui/grid/display_text.rs +crates/app/src/ui/grid/edit_commit.rs +crates/app/src/ui/grid/edit_navigation.rs crates/app/src/ui/grid/edit_snapshot.rs +crates/app/src/ui/grid/grid_attributes.rs crates/app/src/ui/grid/grid_cell.rs +crates/app/src/ui/grid/grid_columns.rs +crates/app/src/ui/grid/grid_content.rs +crates/app/src/ui/grid/grid_keyboard.rs crates/app/src/ui/grid/grid_menus.rs crates/app/src/ui/grid/grid_msg.rs crates/app/src/ui/grid/grid_row_id.rs crates/app/src/ui/grid/grid_row_index.rs -crates/app/src/ui/grid/inline_edit.rs +crates/app/src/ui/grid/inline_editor.rs +crates/app/src/ui/grid/menu_rows.rs crates/app/src/ui/grid/mod.rs +crates/app/src/ui/grid/multiline_editor.rs +crates/app/src/ui/grid/row_factory.rs crates/app/src/ui/grid/row_json_dialog.rs +crates/app/src/ui/grid/row_key_builder.rs crates/app/src/ui/grid/row_object.rs +crates/app/src/ui/grid/row_snapshot.rs crates/app/src/ui/grid/selection.rs crates/app/src/ui/grid/tab_grid_context.rs +crates/app/src/ui/grid/test_grid.rs +crates/app/src/ui/grid/toggle_cell.rs crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog.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/mod.rs crates/app/src/ui/preferences_dialog/imp.rs crates/app/src/ui/preferences_dialog/mod.rs crates/app/src/ui/saved_queries_dialog.rs +crates/app/src/ui/session_window/connect_failed_page.rs +crates/app/src/ui/session_window/connect_flow.rs +crates/app/src/ui/session_window/connecting_page.rs +crates/app/src/ui/session_window/connections.rs +crates/app/src/ui/session_window/imp.rs +crates/app/src/ui/session_window/locate_file.rs +crates/app/src/ui/session_window/mod.rs +crates/app/src/ui/session_window/open_file_tests.rs +crates/app/src/ui/session_window/picker_page.rs +crates/app/src/ui/session_window/resolve_file_identity.rs +crates/app/src/ui/session_window/secret_retry.rs +crates/app/src/ui/session_window/session_page.rs +crates/app/src/ui/session_window/session_restore.rs +crates/app/src/ui/session_window/session_teardown.rs +crates/app/src/ui/session_window/session_window_command.rs +crates/app/src/ui/session_window/session_window_input.rs +crates/app/src/ui/session_window/sqlite_location_dialog.rs +crates/app/src/ui/session_window/tests.rs +crates/app/src/ui/session_window/window_actions.rs crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs crates/app/src/ui/structure_tab/columns.rs +crates/app/src/ui/structure_tab/context.rs +crates/app/src/ui/structure_tab/draft_column_object.rs +crates/app/src/ui/structure_tab/draft_column_object_imp.rs crates/app/src/ui/structure_tab/fks.rs +crates/app/src/ui/structure_tab/foreign_key_dialog.rs +crates/app/src/ui/structure_tab/foreign_key_draft.rs +crates/app/src/ui/structure_tab/foreign_key_object.rs +crates/app/src/ui/structure_tab/form_dialog.rs crates/app/src/ui/structure_tab/imp.rs +crates/app/src/ui/structure_tab/index_dialog.rs +crates/app/src/ui/structure_tab/index_draft.rs +crates/app/src/ui/structure_tab/index_object.rs crates/app/src/ui/structure_tab/indexes.rs crates/app/src/ui/structure_tab/mod.rs +crates/app/src/ui/structure_tab/referential_action_label.rs +crates/app/src/ui/structure_tab/review_dialog.rs crates/app/src/ui/structure_tab/structure_command.rs +crates/app/src/ui/structure_tab/table_choice_object.rs crates/app/src/ui/structure_tab/tests.rs -crates/app/src/ui/structure_tab_dialogs.rs crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs +crates/app/src/ui/workspace/close_flow.rs +crates/app/src/ui/workspace/health_banner.rs +crates/app/src/ui/workspace/imp.rs +crates/app/src/ui/workspace/import.rs +crates/app/src/ui/workspace/mod.rs +crates/app/src/ui/workspace/row_ops.rs +crates/app/src/ui/workspace/saves.rs +crates/app/src/ui/workspace/sidebar.rs +crates/app/src/ui/workspace/sidebar_section_header.rs +crates/app/src/ui/workspace/status_pages.rs +crates/app/src/ui/workspace/structure.rs +crates/app/src/ui/workspace/window_tabs.rs +crates/app/src/ui/workspace/workspace_actions.rs +crates/app/src/ui/workspace/workspace_persistence.rs +crates/app/src/ui/workspace/workspace_tab_view.rs +crates/app/src/ui/workspace/workspace_tabs.rs crates/app/src/workspace/close_decision.rs crates/app/src/workspace/close_guard.rs crates/app/src/workspace/close_guards.rs +crates/app/src/workspace/close_reason.rs crates/app/src/workspace/close_scope.rs crates/app/src/workspace/close_step.rs crates/app/src/workspace/dialog_response.rs crates/app/src/workspace/dirty_kind.rs crates/app/src/workspace/dirty_tab.rs crates/app/src/workspace/draft_writer.rs +crates/app/src/workspace/engine_profile.rs crates/app/src/workspace/inhibit_reason.rs +crates/app/src/workspace/legacy_tab_file.rs crates/app/src/workspace/mod.rs +crates/app/src/workspace/open_sessions.rs +crates/app/src/workspace/open_sessions_load.rs +crates/app/src/workspace/open_sessions_record.rs crates/app/src/workspace/open_transaction_dialog.rs crates/app/src/workspace/pending_state.rs crates/app/src/workspace/persist_debouncer.rs @@ -209,7 +307,9 @@ crates/app/src/workspace/save_snapshot.rs crates/app/src/workspace/save_start.rs crates/app/src/workspace/save_ticket.rs crates/app/src/workspace/schema_write.rs +crates/app/src/workspace/session_key_record.rs crates/app/src/workspace/sort_record.rs +crates/app/src/workspace/structure_review_tests.rs crates/app/src/workspace/tab_directory.rs crates/app/src/workspace/tab_entry.rs crates/app/src/workspace/tab_record.rs @@ -227,16 +327,22 @@ crates/app/src/workspace/workspace_record.rs crates/app/src/workspace/workspace_save_error.rs crates/app/src/workspace/workspace_store.rs crates/app/src/workspace/write_job.rs +crates/app/src/workspace/writes_in_flight.rs data/app.tablepro.TablePro.desktop.in.in data/app.tablepro.TablePro.gschema.xml data/app.tablepro.TablePro.metainfo.xml.in.in data/resources/shortcuts-dialog.ui data/resources/ui/browse-tab.ui data/resources/ui/connect-dialog.ui +data/resources/ui/connect-failed-page.ui +data/resources/ui/connecting-page.ui +data/resources/ui/picker-page.ui data/resources/ui/preferences-dialog.ui data/resources/ui/results-pane.ui +data/resources/ui/session-window.ui data/resources/ui/sql-editor.ui data/resources/ui/sqlite-file-row.ui data/resources/ui/ssh-section.ui data/resources/ui/structure-tab.ui data/resources/ui/tls-section.ui +data/resources/ui/workspace.ui diff --git a/scripts/check-doc-symbols.sh b/scripts/check-doc-symbols.sh index 08eb2630a2..f1a93de46b 100755 --- a/scripts/check-doc-symbols.sh +++ b/scripts/check-doc-symbols.sh @@ -6,7 +6,7 @@ # script stays named in the docs until something asks. This asks. # # Scope: CLAUDE.md, AGENTS.md, the same two under linux/, and every .md under .claude/rules and -# .claude/skills. +# .claude/skills. Documents named on the command line replace that set. # # What is checked. Only mechanical claims, because those are the ones that rot silently: # paths a backticked repo-relative path must exist @@ -27,6 +27,7 @@ # Usage: # scripts/check-doc-symbols.sh # exit 1 if anything is stale # scripts/check-doc-symbols.sh --list # also print what passed and what was skipped +# scripts/check-doc-symbols.sh [--list] linux/ARCHITECTURE.md ... # check only these set -uo pipefail @@ -34,15 +35,25 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" || exit 3 LIST=0 -[ "${1:-}" = "--list" ] && LIST=1 +if [ "${1:-}" = "--list" ]; then + LIST=1 + shift +fi DOCS=() -for top in CLAUDE.md AGENTS.md linux/CLAUDE.md linux/AGENTS.md; do - [ -f "$top" ] && DOCS+=("$top") -done -while IFS= read -r f; do DOCS+=("$f"); done < <( - find .claude/rules .claude/skills -name '*.md' -type f 2> /dev/null | sort -) +if [ "$#" -gt 0 ]; then + for doc in "$@"; do + [ -f "$doc" ] || { echo "no such document: $doc" >&2; exit 2; } + DOCS+=("$doc") + done +else + for top in CLAUDE.md AGENTS.md linux/CLAUDE.md linux/AGENTS.md; do + [ -f "$top" ] && DOCS+=("$top") + done + while IFS= read -r f; do DOCS+=("$f"); done < <( + find .claude/rules .claude/skills -name '*.md' -type f 2> /dev/null | sort + ) +fi BUILTIN_SKILLS="code-review security-review simplify swiftui-pro run init update-config loop schedule" @@ -59,7 +70,7 @@ CloudKit Security CoreText QuartzCore CoreGraphics OSLog" PLACEHOLDER_PATTERN='^(Some|My|Your|Foo|Bar|Example)[A-Z][a-z]|^(TypeName|ClassName|SuiteType|TestClassName)$' FRAMEWORK_PREFIX_PATTERN='^(NS|CK|CF|CG|CA|CT|UI|XC|OS)[A-Z]' -GNOME_PREFIX_PATTERN='^(Gtk|Gdk|Gsk|Adw|GtkSource|Gio|GLib|GObject|Pango|Secret)[A-Z]' +GNOME_PREFIX_PATTERN='^(Gtk|Gdk|Gsk|Adw|GtkSource|Gio|GLib|GObject|Pango|Secret|G)[A-Z]' # The GObject Introspection files the Linux app's ground truth comes from, when installed. GIR_FILES="Gtk-4.0 Gdk-4.0 Gsk-4.0 Adw-1 GtkSource-5 Gio-2.0 GObject-2.0 GLib-2.0 Pango-1.0 Secret-1" TAB="$(printf '\t')" @@ -197,10 +208,12 @@ resolve_path() { # expected state, and flagging Secrets.xcconfig or Libs/*.a would train everyone to ignore this. # A pattern ending in a slash, like Libs/dylibs/, matches only a directory, and git cannot tell # that a path it has never seen is one, so an absent directory is asked about with the slash. - if git check-ignore -q "$token" 2> /dev/null || git check-ignore -q "$token/" 2> /dev/null; then - printf '%s\t (gitignored, optional by design)\n' "$token" - return 0 - fi + for candidate in "$token" "$dir/$token" "$dir/../$token"; do + if git check-ignore -q "$candidate" 2> /dev/null || git check-ignore -q "$candidate/" 2> /dev/null; then + printf '%s\t (gitignored, optional by design)\n' "$candidate" + return 0 + fi + done return 1 }